From 1466e44233c0be8bca68e325f4e281a78ee09544 Mon Sep 17 00:00:00 2001 From: GTC2080 <140309575+GTC2080@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:46:03 +1000 Subject: [PATCH] fix: surface desktop error states --- desktop/frontend/package.json | 4 +- .../__tests__/composer-goal-toggle.test.tsx | 172 ++++++++++++++++++ .../settings-refresh-snapshot.test.tsx | 59 ++++++ .../workspace-changes-errors.test.tsx | 155 ++++++++++++++++ desktop/frontend/src/components/Composer.tsx | 4 + .../frontend/src/components/SettingsPanel.tsx | 26 ++- .../src/components/WorkspacePanel.tsx | 25 ++- desktop/frontend/src/locales/en.ts | 5 + desktop/frontend/src/locales/zh-TW.ts | 5 + desktop/frontend/src/locales/zh.ts | 5 + desktop/frontend/src/styles.css | 4 + 11 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index c3bf501824..77735a0256 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -10,9 +10,9 @@ "check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css", "test:todo-visibility": "node scripts/test-todo-visibility.mjs", "typecheck": "tsc --noEmit", - "test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts", + "test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts", "test:typecheck": "tsc --noEmit -p tsconfig.test.json", - "test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts" + "test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts" }, "dependencies": { "@gsap/react": "^2.1.2", diff --git a/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx b/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx index 6ae319b060..2021b7c978 100644 --- a/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx +++ b/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx @@ -7,6 +7,7 @@ import { createRoot } from "react-dom/client"; import { Composer } from "../components/Composer"; import { LocaleProvider } from "../lib/i18n"; import { ToastProvider } from "../lib/toast"; +import type { AppBindings } from "../lib/bridge"; import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types"; let passed = 0; @@ -56,6 +57,8 @@ function installDom() { globalThis.MouseEvent = dom.window.MouseEvent; globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent; globalThis.MutationObserver = dom.window.MutationObserver; + globalThis.File = dom.window.File; + globalThis.FileReader = dom.window.FileReader; globalThis.localStorage = dom.window.localStorage; globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); @@ -128,6 +131,43 @@ async function renderComposer(props: Partial[0]> = { return { root, calls, rerender: paint }; } +function mockApp(methods: Partial) { + window.go = { + main: { + App: { + Commands: async () => [], + Models: async () => [], + ModelsForTab: async () => [], + ...methods, + } as Partial as AppBindings, + }, + }; +} + +function dispatchPasteFile(textarea: HTMLTextAreaElement, file: File) { + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + configurable: true, + value: { + files: [file], + items: [], + types: ["Files"], + getData: () => "", + }, + }); + textarea.dispatchEvent(event); +} + +async function waitFor(label: string, predicate: () => boolean) { + for (let attempt = 0; attempt < 20; attempt += 1) { + await act(async () => { + await flushTimers(); + }); + if (predicate()) return; + } + throw new Error(`timed out waiting for ${label}`); +} + console.log("\ncomposer goal toggle"); { @@ -166,5 +206,137 @@ console.log("\ncomposer goal toggle"); dom.window.close(); } +{ + const dom = installDom(); + mockApp({ + SavePastedFile: async () => { + throw new Error("/Users/example/private.pdf: permission denied"); + }, + }); + const { root, rerender } = await renderComposer(); + await rerender({ insertRequest: { id: 2, text: "keep this draft", mode: "replace" } }); + + const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null; + if (!textarea) throw new Error("composer textarea did not render"); + const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null; + if (!sendButton) throw new Error("composer send button did not render"); + + await act(async () => { + dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" })); + await flushTimers(); + }); + await waitFor("pasted file failure toast", () => document.body.textContent?.includes("File attach failed") === true); + + ok(document.body.textContent?.includes("File attach failed") === true, "SavePastedFile rejection shows a visible error"); + eq(textarea.value, "keep this draft", "failed pasted file attach preserves composer text"); + ok(sendButton.disabled === false, "failed pasted file attach clears the pending state"); + ok(document.body.textContent?.includes("/Users/example") === false, "pasted file failure toast does not expose the local path"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +{ + const dom = installDom(); + mockApp({ + SavePastedFile: async () => ".reasonix/attachments/notes.txt", + }); + const { root } = await renderComposer(); + + const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null; + if (!textarea) throw new Error("composer textarea did not render"); + + await act(async () => { + dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" })); + await flushTimers(); + }); + await waitFor("pasted file attachment", () => document.body.textContent?.includes("notes.txt") === true); + + ok(document.body.textContent?.includes("notes.txt") === true, "successful pasted file attach still renders the attachment"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +{ + const dom = installDom(); + let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined; + window.runtime = { + EventsOn: () => () => {}, + BrowserOpenURL: () => {}, + OnFileDrop: (cb) => { + droppedCallback = cb; + }, + OnFileDropOff: () => {}, + }; + mockApp({ + AttachDropped: async () => { + throw new Error("/Users/example/secret.pdf: permission denied"); + }, + }); + const { root, rerender } = await renderComposer(); + await rerender({ insertRequest: { id: 3, text: "drop draft", mode: "replace" } }); + + const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null; + if (!textarea) throw new Error("composer textarea did not render"); + const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null; + if (!sendButton) throw new Error("composer send button did not render"); + if (!droppedCallback) throw new Error("native file drop handler did not register"); + + await act(async () => { + droppedCallback?.(0, 0, ["/Users/example/secret.pdf"]); + await flushTimers(); + }); + await waitFor("dropped file failure toast", () => document.body.textContent?.includes("Dropped file attach failed") === true); + + ok(document.body.textContent?.includes("Dropped file attach failed") === true, "AttachDropped rejection shows a visible error"); + eq(textarea.value, "drop draft", "failed dropped file attach preserves composer text"); + ok(sendButton.disabled === false, "failed dropped file attach clears the pending state"); + ok(document.body.textContent?.includes("/Users/example") === false, "dropped file failure toast does not expose the local path"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +{ + const dom = installDom(); + let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined; + window.runtime = { + EventsOn: () => () => {}, + BrowserOpenURL: () => {}, + OnFileDrop: (cb) => { + droppedCallback = cb; + }, + OnFileDropOff: () => {}, + }; + mockApp({ + AttachDropped: async () => ({ + kind: "attachment", + path: ".reasonix/attachments/report.pdf", + }), + }); + const { root } = await renderComposer(); + if (!droppedCallback) throw new Error("native file drop handler did not register"); + + await act(async () => { + droppedCallback?.(0, 0, ["/Users/example/report.pdf"]); + await flushTimers(); + }); + await waitFor("dropped file attachment", () => document.body.textContent?.includes("report.pdf") === true); + + ok(document.body.textContent?.includes("report.pdf") === true, "successful dropped file attach still renders the attachment"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx index 15d664fc23..819eb97f42 100644 --- a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx +++ b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx @@ -34,6 +34,16 @@ function flushPromises(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } +async function waitFor(label: string, predicate: () => boolean) { + for (let attempt = 0; attempt < 20; attempt += 1) { + await act(async () => { + await flushPromises(); + }); + if (predicate()) return; + } + throw new Error(`timed out waiting for ${label}`); +} + function baseSettings(displayMode: "standard" | "compact" = "standard"): SettingsView { return { defaultModel: "", @@ -148,6 +158,55 @@ ok(onChangedSettings?.displayMode === "compact", "onChanged receives the post-sa await act(async () => { root.unmount(); }); + +const retryRootEl = document.createElement("div"); +document.body.appendChild(retryRootEl); +const retryRoot = createRoot(retryRootEl); +let failingSettingsCalls = 0; +window.go = { + main: { + App: { + Settings: async () => { + failingSettingsCalls += 1; + if (failingSettingsCalls === 1) throw new Error("/Users/example/.reasonix/settings.toml: permission denied"); + return baseSettings("standard"); + }, + } as Partial as AppBindings, + }, +}; + +await act(async () => { + retryRoot.render( + + {}} + onChanged={() => {}} + /> + , + ); + await flushPromises(); +}); +await waitFor("settings load failure", () => Boolean(document.querySelector(".banner--error"))); + +ok(document.body.textContent?.includes("Settings could not be loaded.") === true, "failed initial settings load shows a visible error"); +ok(document.body.textContent?.includes("Loading…") === false, "failed initial settings load stops showing the loading state"); + +const retryButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Retry") as HTMLButtonElement | undefined; +if (!retryButton) throw new Error("settings retry button did not render"); + +await act(async () => { + retryButton.click(); + await flushPromises(); +}); +await waitFor("settings retry success", () => Boolean(Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Compact"))); + +eq(failingSettingsCalls, 2, "settings retry calls Settings again"); +ok(document.body.textContent?.includes("Settings could not be loaded.") === false, "settings retry clears the load error"); + +await act(async () => { + retryRoot.unmount(); +}); dom.window.close(); console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); diff --git a/desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx b/desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx new file mode 100644 index 0000000000..b142d6e093 --- /dev/null +++ b/desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx @@ -0,0 +1,155 @@ +// Run: tsx src/__tests__/workspace-changes-errors.test.tsx + +import { JSDOM } from "jsdom"; +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { WorkspacePanel } from "../components/WorkspacePanel"; +import { LocaleProvider } from "../lib/i18n"; +import type { AppBindings } from "../lib/bridge"; +import type { WorkspaceChangesView } from "../lib/types"; + +let passed = 0; +let failed = 0; + +function ok(value: boolean, label: string) { + if (value) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}\n`); + failed += 1; + } +} + +function flushPromises(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function waitFor(label: string, predicate: () => boolean) { + for (let attempt = 0; attempt < 20; attempt += 1) { + await act(async () => { + await flushPromises(); + }); + if (predicate()) return; + } + throw new Error(`timed out waiting for ${label}`); +} + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +function installDom() { + const dom = new JSDOM("
", { + pretendToBeVisual: true, + url: "http://localhost/", + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + globalThis.window = dom.window as unknown as Window & typeof globalThis; + globalThis.document = dom.window.document; + Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); + globalThis.Node = dom.window.Node; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.Event = dom.window.Event; + globalThis.CustomEvent = dom.window.CustomEvent; + globalThis.KeyboardEvent = dom.window.KeyboardEvent; + globalThis.MouseEvent = dom.window.MouseEvent; + globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent; + globalThis.MutationObserver = dom.window.MutationObserver; + globalThis.ResizeObserver = TestResizeObserver; + globalThis.localStorage = dom.window.localStorage; + globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); + globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); + Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} }); + return dom; +} + +async function renderWorkspace(changes: WorkspaceChangesView) { + const dom = installDom(); + window.go = { + main: { + App: { + ListDir: async () => [], + WorkspaceGitHistory: async () => [], + WorkspaceChanges: async () => changes, + } as Partial as AppBindings, + }, + }; + const rootEl = document.getElementById("root"); + if (!rootEl) throw new Error("missing root"); + const root = createRoot(rootEl); + await act(async () => { + root.render( + + {}} + onToggleMaximized={() => {}} + /> + , + ); + await flushPromises(); + }); + await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body"))); + return { dom, root }; +} + +console.log("\nworkspace changes git errors"); + +{ + const { dom, root } = await renderWorkspace({ files: [], gitAvailable: false }); + await waitFor("git unavailable warning", () => document.body.textContent?.includes("Git status is unavailable for this workspace.") === true); + ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitAvailable=false renders a warning"); + ok(document.body.textContent?.includes("No changed files") === false, "gitAvailable=false is not shown as a clean workspace"); + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +{ + const { dom, root } = await renderWorkspace({ + files: [], + gitAvailable: true, + gitErr: "git status timed out", + }); + await waitFor("git error warning without files", () => document.body.textContent?.includes("Git status is unavailable for this workspace.") === true); + ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitErr without files renders a warning"); + ok(document.body.textContent?.includes("No changed files") === false, "empty files plus gitErr is not shown as a clean workspace"); + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +{ + const { dom, root } = await renderWorkspace({ + files: [ + { + path: "src/app.ts", + sources: ["session"], + gitStatus: "modified", + latestPrompt: "edit app", + }, + ], + gitAvailable: true, + gitErr: "git status timed out", + }); + await waitFor("git error warning with files", () => document.body.textContent?.includes("app.ts") === true); + ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitErr renders a warning"); + ok(document.body.textContent?.includes("app.ts") === true, "files still render when gitErr is present"); + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 0b0105cdaa..4629d7fe8a 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -979,6 +979,8 @@ export function Composer({ rememberAttachment(path, key); setAttachments((prev) => [...prev, { path, displayName: file.name }]); } catch { + console.warn("[composer] failed to attach pasted file"); + showToast(t("composer.attachFileFailed"), "warn"); // non-fatal: a failed attach must not block normal text input } finally { setPendingPaste((n) => Math.max(0, n - 1)); @@ -1026,6 +1028,8 @@ export function Composer({ setAttachments((prev) => [...prev, { path: item.path, previewUrl: item.previewUrl, displayName: baseName(path) }]); } } catch { + console.warn("[composer] failed to attach dropped file"); + showToast(t("composer.attachDropFailed"), "warn"); // non-fatal: a failed drop attach must not block normal text input } finally { setPendingPaste((n) => Math.max(0, n - 1)); diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index 3926fbd398..e35ba70488 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -80,6 +80,8 @@ export function SettingsPanel({ }) { const t = useT(); const [s, setS] = useState(null); + const [loadingSettings, setLoadingSettings] = useState(true); + const [settingsLoadFailed, setSettingsLoadFailed] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const [warning, setWarning] = useState(null); @@ -95,9 +97,19 @@ export function SettingsPanel({ const { status, requestClose } = useDeferredClose(onClose, 240); const reload = useCallback(async () => { - const next = normalizeSettingsView(await app.Settings().catch(() => null)); - setS(next); - return next; + setLoadingSettings(true); + setSettingsLoadFailed(false); + try { + const next = normalizeSettingsView(await app.Settings()); + setS(next); + return next; + } catch { + setS(null); + setSettingsLoadFailed(true); + return null; + } finally { + setLoadingSettings(false); + } }, []); useEffect(() => { void reload(); @@ -178,10 +190,16 @@ export function SettingsPanel({ ))}
+ {needsSettings && settingsLoadFailed && ( +
+ {t("settings.loadFailed")} + +
+ )} {needsSettings && err &&
{err}
} {needsSettings && warning &&
{warning}
} {needsSettings && !s ? ( -
{t("settings.loading")}
+ loadingSettings ?
{t("settings.loading")}
: null ) : ( <> {tab === "general" && s && } diff --git a/desktop/frontend/src/components/WorkspacePanel.tsx b/desktop/frontend/src/components/WorkspacePanel.tsx index 8c2833665f..f0825aa65f 100644 --- a/desktop/frontend/src/components/WorkspacePanel.tsx +++ b/desktop/frontend/src/components/WorkspacePanel.tsx @@ -27,7 +27,7 @@ import { import { app } from "../lib/bridge"; import { useT } from "../lib/i18n"; import { loadLayoutSize, saveLayoutSize } from "../lib/layoutPreferences"; -import type { DirEntry, FilePreview, GitCommitView, GitCommitDetailView, WorkspaceChangeView } from "../lib/types"; +import type { DirEntry, FilePreview, GitCommitView, GitCommitDetailView, WorkspaceChangesView } from "../lib/types"; import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag"; import { cleanGitDiff } from "../lib/diff"; import { CodeViewer } from "./CodeViewer"; @@ -241,7 +241,7 @@ export function WorkspacePanel({ const [loadingPreview, setLoadingPreview] = useState(false); const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode); const [gitHistory, setGitHistory] = useState([]); - const [workspaceChanges, setWorkspaceChanges] = useState(null); + const [workspaceChanges, setWorkspaceChanges] = useState(null); const [loadingHistory, setLoadingHistory] = useState(false); const [expandedCommit, setExpandedCommit] = useState(null); const [commitDetail, setCommitDetail] = useState(null); @@ -307,11 +307,16 @@ export function WorkspacePanel({ try { const result = await app.WorkspaceChanges(requestTabId); if (workspaceChangesRequestIdRef.current === requestId && lastWorkspaceTabIdRef.current === requestTabId) { - setWorkspaceChanges(result.files && result.files.length > 0 ? result.files : null); + setWorkspaceChanges({ + files: Array.isArray(result?.files) ? result.files : [], + gitAvailable: result?.gitAvailable !== false, + gitErr: result?.gitErr, + gitBranch: result?.gitBranch, + }); } } catch { if (workspaceChangesRequestIdRef.current === requestId && lastWorkspaceTabIdRef.current === requestTabId) { - setWorkspaceChanges(null); + setWorkspaceChanges({ files: [], gitAvailable: false }); } } }, [tabId]); @@ -706,9 +711,12 @@ export function WorkspacePanel({ const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""]; const pathParts = selectedPath?.split("/").filter(Boolean) ?? []; const sessionChanges = useMemo( - () => workspaceChanges?.filter((c) => c.sources.includes("session")) ?? null, + () => workspaceChanges?.files.filter((c) => c.sources.includes("session")) ?? null, [workspaceChanges], ); + const workspaceGitWarning = workspaceChanges && (!workspaceChanges.gitAvailable || workspaceChanges.gitErr?.trim()) + ? t("workspace.gitUnavailable") + : null; const changedMode = viewMode === "changed"; const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile"); @@ -1237,6 +1245,11 @@ export function WorkspacePanel({ ) : viewMode === "changed" && !selectedPath ? (
+ {workspaceGitWarning && ( +
+ {workspaceGitWarning} +
+ )} {sessionChanges && sessionChanges.length > 0 && (
@@ -1271,7 +1284,7 @@ export function WorkspacePanel({ {loadingHistory ? (
{t("workspace.loading")}
) : gitHistory.length === 0 && !(sessionChanges && sessionChanges.length > 0) ? ( -
{t("workspace.noChanges")}
+
{workspaceGitWarning ? t("workspace.gitChangesUnknown") : t("workspace.noChanges")}
) : (
{gitHistory.map((commit) => ( diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 8a0692100f..10a6a2ad30 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -13,6 +13,7 @@ export const en = { "common.add": "Add", "common.submit": "Submit", "common.copy": "Copy", + "common.retry": "Retry", "common.expand": "Expand", "common.collapse": "Collapse", "common.none": "none", @@ -191,6 +192,7 @@ export const en = { "workspace.loadingChanges": "Loading changes…", "workspace.noChanges": "No changed files", "workspace.gitUnavailable": "Git status is unavailable for this workspace.", + "workspace.gitChangesUnknown": "Changed files could not be determined.", "workspace.sourceSession": "Session", "workspace.sourceGit": "Git", "workspace.deleted": "Deleted", @@ -409,6 +411,8 @@ export const en = { "composer.pastedRemove": "Remove pasted text", "composer.removeImage": "Remove image", "composer.attachImageFailed": "Image paste failed", + "composer.attachFileFailed": "File attach failed", + "composer.attachDropFailed": "Dropped file attach failed", "composer.pasteImageFailed": "Could not read clipboard image", "composer.contextItems": "Context items", "composer.workspaceReferences": "Workspace references", @@ -785,6 +789,7 @@ export const en = { // settings drawer "settings.title": "Settings", "settings.loading": "Loading…", + "settings.loadFailed": "Settings could not be loaded.", "settings.modelsProviders": "Models & providers", "settings.tab.general": "General", "settings.tab.models": "Models", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index fd47f7eebe..8e3382838f 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -14,6 +14,7 @@ export const zhTW: Record = { "common.add": "新增", "common.submit": "提交", "common.copy": "複製", + "common.retry": "重試", "common.expand": "展開", "common.collapse": "收起", "common.none": "無", @@ -122,6 +123,7 @@ export const zhTW: Record = { "workspace.loadingChanges": "正在載入變更…", "workspace.noChanges": "暫無變更檔案", "workspace.gitUnavailable": "當前工作區不可讀取 Git 狀態。", + "workspace.gitChangesUnknown": "無法確認目前變更檔案。", "workspace.sourceSession": "會話", "workspace.sourceGit": "Git", "workspace.deleted": "已刪除", @@ -573,6 +575,7 @@ export const zhTW: Record = { // 設定抽屜 "settings.title": "設定", "settings.loading": "載入中…", + "settings.loadFailed": "設定載入失敗。", "settings.modelsProviders": "模型與模型服務", "settings.tab.general": "通用", "settings.tab.models": "模型", @@ -1214,6 +1217,8 @@ export const zhTW: Record = { "composer.shellMode": "Shell 模式:給輸入前加 !", "composer.shellModeOn": "Shell 模式已開:點選移除 !", "composer.attachImageFailed": "圖片貼上失敗", + "composer.attachFileFailed": "檔案附加失敗", + "composer.attachDropFailed": "拖放檔案附加失敗", "composer.pasteImageFailed": "未能讀取剪貼簿圖片", "status.ctxLabel": "上下文", "status.ctxTitle": "當前會話已佔用的上下文視窗比例", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 0d6f8c19fc..d5d5e22325 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -14,6 +14,7 @@ export const zh: Record = { "common.add": "添加", "common.submit": "提交", "common.copy": "复制", + "common.retry": "重试", "common.expand": "展开", "common.collapse": "收起", "common.none": "无", @@ -192,6 +193,7 @@ export const zh: Record = { "workspace.loadingChanges": "正在加载改动…", "workspace.noChanges": "暂无改动文件", "workspace.gitUnavailable": "当前工作区不可读取 Git 状态。", + "workspace.gitChangesUnknown": "无法确认当前改动文件。", "workspace.sourceSession": "会话", "workspace.sourceGit": "Git", "workspace.deleted": "已删除", @@ -410,6 +412,8 @@ export const zh: Record = { "composer.pastedRemove": "删除粘贴文本", "composer.removeImage": "移除图片", "composer.attachImageFailed": "图片粘贴失败", + "composer.attachFileFailed": "文件附加失败", + "composer.attachDropFailed": "拖放文件附加失败", "composer.pasteImageFailed": "未能读取剪贴板图片", "composer.contextItems": "上下文项目", "composer.workspaceReferences": "工作区引用", @@ -787,6 +791,7 @@ export const zh: Record = { // 设置抽屉 "settings.title": "设置", "settings.loading": "加载中…", + "settings.loadFailed": "设置加载失败。", "settings.modelsProviders": "模型与模型服务", "settings.tab.general": "通用", "settings.tab.models": "模型", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 913dc41177..df36f08c96 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -7260,6 +7260,10 @@ a[href] { color: var(--fg-dim); font-size: 12px; } +.workspace-note--warning { + background: color-mix(in srgb, var(--warn) 10%, var(--bg-soft)); + color: var(--warn); +} .workspace-note--compact { margin: 0 10px 8px; padding: 6px 8px;