diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index c9f9550599..29ced02e1e 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -10,9 +10,11 @@ "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__/composer-session-draft.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && 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-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.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__/composer-session-draft.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && 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__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.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__/composer-session-draft.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && 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-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.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__/composer-session-draft.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && 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__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.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 1aadf5272e..a222f6462e 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"); { @@ -168,7 +208,139 @@ console.log("\ncomposer goal toggle"); { 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(); +} + +{ + const dom = installDom(); + const { root: dropNavRoot } = await renderComposer(); const composer = document.querySelector(".composer") as HTMLElement | null; if (!composer) throw new Error("composer did not render"); @@ -193,7 +365,7 @@ console.log("\ncomposer goal toggle"); ok(drop.defaultPrevented, "native file drop prevents browser image navigation"); await act(async () => { - root.unmount(); + dropNavRoot.unmount(); }); dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx index f055aa857e..ad6bb60cdc 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: "", @@ -150,6 +160,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 aee98e7e0a..b183093076 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -1169,6 +1169,8 @@ export function Composer({ const path = await app.SavePastedFile(file.name, dataUrl); addAttachmentToDraft(sourceDraftKey, { path, displayName: file.name }, key); } 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)); @@ -1215,6 +1217,8 @@ export function Composer({ addAttachmentToDraft(sourceDraftKey, { path: item.path, previewUrl: item.previewUrl, displayName: baseName(path) }, key); } } 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 8beb9c4029..875139ca76 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -81,6 +81,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); @@ -96,9 +98,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(); @@ -179,10 +191,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 698f284b47..a9255bc22a 100644 --- a/desktop/frontend/src/components/WorkspacePanel.tsx +++ b/desktop/frontend/src/components/WorkspacePanel.tsx @@ -39,7 +39,7 @@ import { createRafResizeUpdater } from "../lib/resizeDrag"; import { closeWorkspacePreviewTab } from "../lib/workspacePreviewTabs"; import { shouldScrollWorkspaceTreeSelection } from "../lib/workspaceTreeReveal"; import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch"; -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"; @@ -245,7 +245,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); @@ -314,11 +314,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]); @@ -641,12 +646,13 @@ export function WorkspacePanel({ setTreeMenu(null); if (viewMode === "changed") { void loadGitHistory(); + void loadWorkspaceChanges(); return; } const dirs = Array.from(openDirsRef.current); setEntriesByDir({}); dirs.forEach((dir) => void loadDir(dir)); - }, [loadGitHistory, loadDir, viewMode]); + }, [loadGitHistory, loadWorkspaceChanges, loadDir, viewMode]); const refreshSelected = useCallback(() => { if (!selectedPath) return; @@ -703,9 +709,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"); @@ -1342,6 +1351,11 @@ export function WorkspacePanel({ ) : viewMode === "changed" && !selectedPath ? (
+ {workspaceGitWarning && ( +
+ {workspaceGitWarning} +
+ )} {sessionChanges && sessionChanges.length > 0 && (
@@ -1376,7 +1390,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) => ( @@ -1570,7 +1584,7 @@ export function WorkspacePanel({ )} {showViewTabs && ( - diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index ebc44a0b4d..c156a5e41c 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", @@ -802,6 +806,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 992590bcfc..c23de3757c 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": "已刪除", @@ -590,6 +592,7 @@ export const zhTW: Record = { // 設定抽屜 "settings.title": "設定", "settings.loading": "載入中…", + "settings.loadFailed": "設定載入失敗。", "settings.modelsProviders": "模型與模型服務", "settings.tab.general": "通用", "settings.tab.models": "模型", @@ -1243,6 +1246,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 623ce0bf5d..399d7315bc 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": "工作区引用", @@ -804,6 +808,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 5184710787..1dbee6f264 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -7391,6 +7391,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;