Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,28 @@ export default defineConfig({
},
plugins: [react(), tailwindcss(), tightenCsp(), dropLegacyFontFallbacks()],
resolve: {
alias: {
"@renderer": resolve("src"),
alias: [
{ find: "@renderer", replacement: resolve("src") },
{
find: /^@pi-desktop\/i18n\/locales\/([^/]+)$/,
replacement: resolve(
__dirname,
"../../packages/i18n/src/locales/$1/index.ts",
),
},
{
find: "@pi-desktop/i18n/locale-info",
replacement: resolve(
__dirname,
"../../packages/i18n/src/locale-info.ts",
),
},
// Always read locale source so new keys work without a stale packages/*/dist.
"@pi-desktop/i18n": resolve(__dirname, "../../packages/i18n/src/index.ts"),
},
{
find: "@pi-desktop/i18n",
replacement: resolve(__dirname, "../../packages/i18n/src/index.ts"),
},
],
},
},
});
48 changes: 41 additions & 7 deletions apps/desktop/src/components/ReleaseNotesDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ChangelogEntry } from "@pi-desktop/shared";
import {
CHANGELOG,
loadChangelogCatalog,
normalizeChangelogVersion,
resolveChangelogLocale,
} from "@pi-desktop/shared";
} from "@pi-desktop/shared/changelog-loader";
import { Badge, cx, portalOverlay, TooltipButton } from "./ui";
import { IconClose } from "./icons";

Expand All @@ -20,13 +21,38 @@ export function ReleaseNotesDialog({
const { t, i18n } = useTranslation();
const dialogRef = useRef<HTMLDivElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const [loadedCatalog, setLoadedCatalog] = useState<{
locale: string;
entries: readonly ChangelogEntry[];
} | null>(null);
const locale = resolveChangelogLocale(
i18n.resolvedLanguage ?? i18n.language,
);
const entries = CHANGELOG[locale];
const entries = loadedCatalog?.locale === locale ? loadedCatalog.entries : null;
const normalizedCurrent = normalizeChangelogVersion(currentVersion);
const normalizedAvailable = normalizeChangelogVersion(availableVersion);

useEffect(() => {
let cancelled = false;
void loadChangelogCatalog(locale).then(
(catalog) => {
if (!cancelled) setLoadedCatalog({ locale, entries: catalog });
},
).catch(async (error: unknown) => {
console.error(`Failed to load ${locale} release notes`, error);
if (locale === "en") return;
try {
const fallback = await loadChangelogCatalog("en");
if (!cancelled) setLoadedCatalog({ locale, entries: fallback });
} catch (fallbackError) {
console.error("Failed to load English release notes fallback", fallbackError);
}
});
return () => {
cancelled = true;
};
}, [locale]);

const dateFormatter = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
Expand Down Expand Up @@ -95,7 +121,9 @@ export function ReleaseNotesDialog({
<div className="release-notes-heading">
<h2 id="release-notes-title">{t("updates.releaseNotes")}</h2>
<p id="release-notes-summary">
{t("updates.releaseCount", { count: entries.length })}
{entries
? t("updates.releaseCount", { count: entries.length })
: t("app.loadingView")}
</p>
</div>
<TooltipButton
Expand All @@ -111,7 +139,8 @@ export function ReleaseNotesDialog({
</header>

<div className="release-notes-list selectable">
{entries.map((entry) => {
{entries ? (
entries.map((entry) => {
const isAvailable = entry.version === normalizedAvailable;
const isCurrent = entry.version === normalizedCurrent;
return (
Expand Down Expand Up @@ -152,7 +181,12 @@ export function ReleaseNotesDialog({
</ul>
</article>
);
})}
})
) : (
<div className="route-pending" role="status">
<span className="route-pending-indicator" aria-hidden />
</div>
)}
</div>
</div>
</div>,
Expand Down
80 changes: 64 additions & 16 deletions apps/desktop/src/features/app/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type CSSProperties, lazy, type ReactNode, Suspense } from "react";
import { ChatSurface } from "../../components/ChatSurface";
import { type CSSProperties, lazy, type ReactNode, Suspense, useEffect } from "react";
import { ConversationTopbar } from "../../components/ConversationTopbar";
import { ExtensionPromptHost } from "../../components/ExtensionPromptDialog";
import { PluginRendererHost } from "../../plugins/renderer-host/PluginRendererHost";
Expand All @@ -16,10 +15,10 @@ import { ToastHost } from "../../components/Toast";
import { UpdateBanner } from "../../components/UpdateBanner";
import { cx, TooltipButton } from "../../components/ui";
import { WindowControls } from "../../components/WindowControls";
import { WorkPanel } from "../../components/workpanel/WorkPanel";
import { useCopyTex } from "../../hooks/use-copy-tex";
import { api } from "../../lib/api";
import { PortalVisibilityProvider } from "../../lib/portal-visibility";
import { workPanelLayout } from "../../lib/work-panel-resize";
import { LiveVoiceStatusHost } from "../voice/live/LiveVoiceStatusHost";
import { CollapsedTitlebarActions, RoutePending } from "./chrome";
import { useAppShellRuntime } from "./useAppShellRuntime";
Expand All @@ -39,6 +38,16 @@ const PluginsPage = lazy(() =>
default: module.PluginsPage,
})),
);
const loadChatSurface = () => import("../../components/ChatSurface");
const ChatSurface = lazy(() =>
loadChatSurface().then((module) => ({
default: module.ChatSurface,
})),
);
const loadWorkPanel = () => import("../../components/workpanel/WorkPanel");
const WorkPanel = lazy(() =>
loadWorkPanel().then((module) => ({ default: module.WorkPanel })),
);

export function AppShell() {
const {
Expand Down Expand Up @@ -82,9 +91,31 @@ export function AppShell() {
startupRetrying,
sidebarToggleShortcut,
workPanelToggleTooltip,
workPanelWidth,
} = useAppShellRuntime();
useCopyTex();

useEffect(() => {
void loadChatSurface().catch((error: unknown) => {
console.error("Failed to preload the chat surface", error);
});
}, []);

useEffect(() => {
if (!ready || page !== "chat" || !workPanelOpen) return;
void loadWorkPanel().catch((error: unknown) => {
console.error("Failed to preload the work panel", error);
});
}, [page, ready, workPanelOpen]);

const pendingWorkPanelWidth = workPanelLayout({
containerWidth: shellWidth,
sidebarWidth,
sidebarCollapsed,
requestedPanelWidth: workPanelWidth,
maximized: workPanelMaximized,
}).panelWidth;

// A boot that never reaches the shell gets a surface it can act on instead of
// a window that only knows how to wait (issue #831). Rendered as a direct child
// of the shell so it can layer above the splash and below the window controls.
Expand Down Expand Up @@ -257,20 +288,37 @@ export function AppShell() {
)}

{(presentedWorkPanelOpen || workPanelExiting) && (
<WorkPanel
panelBlocked={searchOpen}
exiting={workPanelExiting}
onExitAnimationEnd={() =>
finishWorkPanelExit(workPanelExitGeneration.current)
<Suspense
fallback={
<aside
className="work-panel work-panel-pending"
role="status"
aria-label={t("app.loadingView")}
style={
{
"--work-panel-width": `${pendingWorkPanelWidth}px`,
} as CSSProperties
}
>
<span className="route-pending-indicator" aria-hidden />
</aside>
}
containerWidth={shellWidth}
sidebarWidth={sidebarWidth}
sidebarCollapsed={sidebarCollapsed}
sidebarExiting={sidebarExiting}
onAutoCollapseSidebar={autoCollapseSidebar}
maximized={workPanelMaximized}
onToggleMaximize={toggleWorkPanelMaximize}
/>
>
<WorkPanel
panelBlocked={searchOpen}
exiting={workPanelExiting}
onExitAnimationEnd={() =>
finishWorkPanelExit(workPanelExitGeneration.current)
}
containerWidth={shellWidth}
sidebarWidth={sidebarWidth}
sidebarCollapsed={sidebarCollapsed}
sidebarExiting={sidebarExiting}
onAutoCollapseSidebar={autoCollapseSidebar}
maximized={workPanelMaximized}
onToggleMaximize={toggleWorkPanelMaximize}
/>
</Suspense>
)}

<TooltipButton
Expand Down
32 changes: 24 additions & 8 deletions apps/desktop/src/features/settings/agent-sections.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { lazy, Suspense, useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { AgentInstructionFile, AppSettings, UpdatePreference } from "@pi-desktop/shared";
import { api } from "../../lib/api";
import { useAppStore } from "../../stores/app-store";
import { useUpdateState } from "../../hooks/use-update-state";
import { Button } from "../../components/ui";
import { IconFileText } from "../../components/icons";
import { ReleaseNotesDialog } from "../../components/ReleaseNotesDialog";
import { SettingsMenuSelect } from "../../components/settings/SettingsMenuSelect";
import { persistUpdatePreference } from "./update-preference";
import { SettingsCard, SettingsRow } from "./primitives";

const ReleaseNotesDialog = lazy(() =>
import("../../components/ReleaseNotesDialog").then((module) => ({
default: module.ReleaseNotesDialog,
})),
);

export function AgentInstructionsSection() {
const { t } = useTranslation();
const [global, setGlobal] = useState<AgentInstructionFile | null>(null);
Expand Down Expand Up @@ -251,14 +256,25 @@ export function UpdatesRow({
) : null}
</div>
{releaseNotesOpen ? (
<ReleaseNotesDialog
currentVersion={update?.currentVersion ?? currentVersion}
availableVersion={update?.availableVersion}
onClose={closeReleaseNotes}
/>
<Suspense
fallback={
<div
className="route-pending"
role="status"
aria-label={t("app.loadingView")}
>
<span className="route-pending-indicator" aria-hidden />
</div>
}
>
<ReleaseNotesDialog
currentVersion={update?.currentVersion ?? currentVersion}
availableVersion={update?.availableVersion}
onClose={closeReleaseNotes}
/>
</Suspense>
) : null}
</SettingsRow>
</>
);
}

56 changes: 18 additions & 38 deletions apps/desktop/src/lib/app-language.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,30 @@
import i18n from "i18next";
import {
isAppLocale,
resolveLocale,
type AppLocale,
} from "@pi-desktop/i18n";
import type { AppSettings } from "@pi-desktop/shared";
import {
applyAppLanguage,
resolveAppLanguage,
resolveOsLocale,
type AppLanguageSetting,
} from "./renderer-language.ts";
import { useAppStore } from "../stores/app-store";

export type AppLanguageSetting = NonNullable<AppSettings["language"]>;

/**
* Authoritative OS locale for "auto" detection.
*
* The renderer's `navigator.language` often reports `en-US` regardless of
* the actual system language, so we prefer the main-process
* `app.getLocale()` exposed synchronously by the preload bridge.
*/
export function resolveOsLocale(): string {
return (
window.piDesktop?.locale ||
navigator.language ||
(navigator as { userLanguage?: string }).userLanguage ||
"en-US"
);
}
export { applyAppLanguage, resolveAppLanguage, resolveOsLocale };
export type { AppLanguageSetting };

/** Concrete locale for a stored language setting; `auto`/absent follows the OS. */
export function resolveAppLanguage(
language: AppSettings["language"],
): AppLocale {
if (language && language !== "auto" && isAppLocale(language)) return language;
return resolveLocale(resolveOsLocale());
}

export function applyAppLanguage(language: AppSettings["language"]) {
const target = resolveAppLanguage(language);
document.documentElement.lang = target;
if (i18n.language !== target) void i18n.changeLanguage(target);
function applyStoreLanguage(language: AppSettings["language"]): Promise<void> {
return applyAppLanguage(language).catch((error: unknown) => {
console.error("Failed to load the selected UI language", error);
});
}

/** Keep i18n in step with the persisted settings.language for the app lifetime. */
export function initLanguageSync() {
applyAppLanguage(useAppStore.getState().settings?.language);
export function initLanguageSync(): Promise<void> {
const initialLanguage = applyStoreLanguage(
useAppStore.getState().settings?.language,
);
useAppStore.subscribe((state, prev) => {
if (state.settings?.language !== prev.settings?.language) {
applyAppLanguage(state.settings?.language);
void applyStoreLanguage(state.settings?.language);
}
});
return initialLanguage;
}
Loading
Loading