From 566816f24af4ddbde474f648e0322fe979884c26 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 19:19:51 +0800 Subject: [PATCH 01/21] feat(app): remove Files tab and register changes icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove "files" from RightPanelStaticTab and its metadata, command palette entry, and keybind. Legacy persisted "files" values map to "status" via coerceLegacySidePanelTab and migrateLegacyRightPanelTab. Register the new "changes" icon (Imagen → Potrace pipeline, portrait 14×18 keyshape, document with +/− marks) for the Git environment section that replaces the standalone Files tab. Add i18n keys for the new Git and Artifact sections in en/zh. --- .../command-palette-default-items.ts | 2 +- packages/app/src/context/layout.tsx | 2 +- packages/app/src/i18n/en.ts | 7 +++++++ packages/app/src/i18n/zh.ts | 7 +++++++ .../src/pages/session/right-panel-tab-strip.tsx | 3 --- .../app/src/pages/session/right-panel-tabs.ts | 17 +++++------------ .../src/pages/session/use-session-commands.tsx | 6 ------ packages/ui/src/components/icon.tsx | 1 + 8 files changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/app/src/components/command-palette/command-palette-default-items.ts b/packages/app/src/components/command-palette/command-palette-default-items.ts index 9d01250cc..510836a66 100644 --- a/packages/app/src/components/command-palette/command-palette-default-items.ts +++ b/packages/app/src/components/command-palette/command-palette-default-items.ts @@ -26,7 +26,7 @@ const SUGGESTED_PREFIX = "suggested." const DEFAULT_GROUPS: readonly CommandPaletteDefaultGroup[] = [ { id: "suggested", commandIDs: ["session.new", "project.open", "file.open", "settings.open"] }, { id: "navigation", commandIDs: ["session.previous", "session.next", "input.focus"] }, - { id: "panels", commandIDs: ["sidebar.toggle", "panel.toggle", "terminal.toggle", "review.toggle", "fileTree.toggle"] }, + { id: "panels", commandIDs: ["sidebar.toggle", "panel.toggle", "terminal.toggle", "review.toggle"] }, { id: "configure", commandIDs: ["model.choose", "mcp.toggle", "permissions.autoaccept"] }, ] diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 3b1e93bba..427d05392 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -94,7 +94,7 @@ export function createSessionKeyReader(sessionKey: string | Accessor, en } } -export function defaultSidePanelTab(tab?: RightPanelTab | "changes") { +export function defaultSidePanelTab(tab?: RightPanelTab | "changes" | "files") { return defaultRightPanelTab(tab) } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3243baffd..3c1091b41 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -699,6 +699,13 @@ export const dict = { "status.summary.progress.empty": "No todos yet.", "status.summary.sources": "Sources", "status.summary.sources.empty": "No sources used yet.", + "status.summary.git": "Git", + "status.summary.git.changes": "Changes", + "status.summary.git.worktree.open": "Open worktree folder", + "status.summary.artifact": "Artifact", + "status.summary.artifact.empty": "No artifacts yet.", + "status.summary.artifact.open": "Open file", + "status.summary.artifact.reveal": "Reveal in folder", "status.connections.state.disabled": "disabled", "status.connections.state.failed": "failed", "status.connections.state.needs_auth": "needs auth", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 180af65e4..411b6d304 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -633,6 +633,13 @@ export const dict = { "status.summary.progress.empty": "还没有待办事项。", "status.summary.sources": "来源", "status.summary.sources.empty": "还没有使用任何来源。", + "status.summary.git": "Git", + "status.summary.git.changes": "变更", + "status.summary.git.worktree.open": "打开 worktree 文件夹", + "status.summary.artifact": "Artifact", + "status.summary.artifact.empty": "暂无产出物", + "status.summary.artifact.open": "打开文件", + "status.summary.artifact.reveal": "在文件夹中显示", "status.connections.state.disabled": "已停用", "status.connections.state.failed": "已失败", "status.connections.state.needs_auth": "需要授权", diff --git a/packages/app/src/pages/session/right-panel-tab-strip.tsx b/packages/app/src/pages/session/right-panel-tab-strip.tsx index 4d2b6ae43..2433ec324 100644 --- a/packages/app/src/pages/session/right-panel-tab-strip.tsx +++ b/packages/app/src/pages/session/right-panel-tab-strip.tsx @@ -30,9 +30,6 @@ function RightPanelShellIcon(props: { icon: ShellTabIcon; active?: boolean }) { - - - diff --git a/packages/app/src/pages/session/right-panel-tabs.ts b/packages/app/src/pages/session/right-panel-tabs.ts index 121d4e278..03c06cb0c 100644 --- a/packages/app/src/pages/session/right-panel-tabs.ts +++ b/packages/app/src/pages/session/right-panel-tabs.ts @@ -2,7 +2,7 @@ * Right-side panel tab value space. * * Two arms: - * 1. RightPanelStaticTab — the four fixed slots (status / files / review / context). + * 1. RightPanelStaticTab — the three fixed slots (status / review / context). * Persisted in openShellTabs by name. * 2. `terminal:` — one dynamic tab per live terminal. The id is the * TerminalTabID from the terminal context. These are NOT persisted in @@ -14,18 +14,17 @@ * slot is dropped via migrateLegacyRightPanelTab / coerceLegacySidePanelTab. */ -export type RightPanelStaticTab = "status" | "files" | "review" | "context" +export type RightPanelStaticTab = "status" | "review" | "context" export type RightPanelTerminalTab = `terminal:${string}` export type RightPanelTab = RightPanelStaticTab | RightPanelTerminalTab export const RIGHT_PANEL_TAB_VALUES: readonly RightPanelStaticTab[] = [ "status", - "files", "review", "context", ] as const -export type RightPanelShellIconName = "status" | "folder" | "review" | "terminal" +export type RightPanelShellIconName = "status" | "review" | "terminal" export type ShellTabIcon = | { kind: "icon"; name: RightPanelShellIconName } @@ -33,7 +32,6 @@ export type ShellTabIcon = export type RightPanelTabLabelKey = | "status.popover.trigger" - | "session.panel.files" | "session.tab.review" | "session.tab.context" @@ -47,12 +45,6 @@ export interface RightPanelTabMeta { /** Static-tab metadata only. Terminal tabs derive their meta from terminal state. */ export const RIGHT_PANEL_TAB_META: Record = { status: { icon: { kind: "icon", name: "status" }, labelKey: "status.popover.trigger", closable: false }, - files: { - icon: { kind: "icon", name: "folder" }, - labelKey: "session.panel.files", - commandId: "fileTree.toggle", - closable: true, - }, review: { icon: { kind: "icon", name: "review" }, labelKey: "session.tab.review", @@ -105,6 +97,7 @@ export const terminalTabValue = (id: string): RightPanelTerminalTab => { // Used when reading legacy persisted state where invalid input should remain unset. export const coerceLegacySidePanelTab = (value: unknown): RightPanelTab | undefined => { if (value === "changes") return "review" + if (value === "files") return "status" // files tab merged into status panel if (value === "terminal") return undefined // legacy fixed terminal slot is gone return isRightPanelTab(value) ? value : undefined } @@ -113,7 +106,7 @@ export const coerceLegacySidePanelTab = (value: unknown): RightPanelTab | undefi export const migrateLegacyRightPanelTab = (tab?: string): RightPanelTab => { if (tab === "changes") return "review" if (tab === "terminal") return "status" // legacy fixed slot; flatten dropped it - if (tab === "files") return "files" + if (tab === "files") return "status" // files tab merged into status panel if (tab === "review" || tab === "status" || tab === "context") return tab if (typeof tab === "string" && isRightPanelTab(tab)) return tab return "status" diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 0ba0ca88b..7369a2d8b 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -442,12 +442,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { keybind: "mod+shift+r", onSelect: () => view().sidePanel.toggleTab("review"), }), - viewCommand({ - id: "fileTree.toggle", - title: language.t("command.fileTree.toggle"), - keybind: "mod+\\", - onSelect: () => view().sidePanel.toggleTab("files"), - }), viewCommand({ id: "panel.toggle", title: language.t("command.panel.toggle"), diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 34012e4a3..10cf2d27f 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -19,6 +19,7 @@ export const icons = { "bullet-list": ``, "check": ``, "check-small": ``, + "changes": ``, "checklist": ``, "chevron-down": ``, "chevron-grabber-vertical": ``, From 7362d5e8df2d7e424285609c757508b454b942cc Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 19:20:05 +0800 Subject: [PATCH 02/21] feat(app): redesign Status panel with Git and Artifact sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the Status panel from two sections (Progress + Sources) to four (Progress → Git → Artifact → Sources). Git section shows three interactive rows: - Diff stats (+N/−N from session aggregate, click → Review panel) - Branch name (from sync.data.vcs, chevron for future branch picker) - Worktree indicator (tooltip with name/branch/location, click opens directory) — migrated from the titlebar PawworkWorktreeBadge which is removed from session-header.tsx Artifact section replaces the deleted Files tab with a compact list of session-produced files (filename + open/reveal buttons, hover + focus-within visibility). The Git section is conditionally hidden when not in a git repository. Artifact data flows from session-side-panel via SessionStatusPanel props as Accessor. --- .../src/components/session/session-header.tsx | 24 -- .../session/session-status-panel.tsx | 39 ++- .../session/session-status-summary.tsx | 244 +++++++++++++++++- .../src/pages/session/session-side-panel.tsx | 13 +- 4 files changed, 275 insertions(+), 45 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index bc90d952b..89446e99c 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -13,7 +13,6 @@ import { canOpenLocalPath, usePlatform } from "@/context/platform" import { useServer } from "@/context/server" import { useShellSurface } from "@/context/shell-surface" import { useSync } from "@/context/sync" -import { PawworkWorktreeBadge } from "@/pages/layout/pawwork-worktree-badge" import { useSessionLayout } from "@/pages/session/session-layout" import { decode64 } from "@/utils/base64" import { StatusPopover } from "../status-popover" @@ -42,11 +41,6 @@ export function SessionHeader() { }) const sessionInfo = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const sessionTitle = createMemo(() => sessionInfo()?.title || params.id || "") - const activeWorktree = createMemo(() => { - const exec = sessionInfo()?.executionContext - if (!exec || exec.activeDirectory === exec.ownerDirectory) return - return exec.activeWorktree - }) const homeTitle = createMemo(() => language.t("command.session.new")) const onSessionRoute = createMemo(() => location.pathname.includes("/session")) const fileManagerLabel = createMemo(() => { @@ -55,9 +49,7 @@ export function SessionHeader() { return language.t("session.header.open.finder") }) const canOpenDirectory = (directory?: string) => canOpenLocalPath(platform) && server.isLocal() && !!directory - const activeWorktreeDirectory = createMemo(() => activeWorktree()?.directory ?? "") const canOpenProjectDirectory = createMemo(() => canOpenDirectory(projectDirectory())) - const canOpenActiveWorktreeDirectory = createMemo(() => canOpenDirectory(activeWorktreeDirectory())) const rightPanelOpen = createMemo(() => view().sidePanel.opened()) const toggleRightPanel = () => { if (rightPanelOpen()) { @@ -77,10 +69,6 @@ export function SessionHeader() { }) } const openProjectDirectory = () => openDirectory(projectDirectory()) - const openActiveWorktree = () => { - openDirectory(activeWorktreeDirectory()) - } - const [leftMount, setLeftMount] = createSignal() const [rightMount, setRightMount] = createSignal() @@ -124,18 +112,6 @@ export function SessionHeader() { {name()} - - {(worktree) => ( - - )} - diff --git a/packages/app/src/components/session/session-status-panel.tsx b/packages/app/src/components/session/session-status-panel.tsx index 3d8a26e63..b4930b567 100644 --- a/packages/app/src/components/session/session-status-panel.tsx +++ b/packages/app/src/components/session/session-status-panel.tsx @@ -3,13 +3,15 @@ import { useParams } from "@solidjs/router" import type { Part } from "@opencode-ai/sdk/v2" import { useGlobalSync } from "@/context/global-sync" import { useSync } from "@/context/sync" +import type { FilesTabEntry } from "@/pages/session/files-tab-state" +import { aggregateFiles } from "@/pages/session/session-aggregate-files" import { SessionStatusSummary } from "./session-status-summary" -// `shown` is kept on the props contract: callers still gate this tab and the -// summary stays a cheap reactive read, so there is nothing here to pause when -// the tab is hidden. Connection health moved to Settings.Integrations + -// the global ConnectionHealth toast monitor (closes #862). -export function SessionStatusPanel(_props: { shown: Accessor }) { +export function SessionStatusPanel(props: { + shown: Accessor + artifactFiles?: Accessor + onNavigateReview?: () => void +}) { const params = useParams() const globalSync = useGlobalSync() const sync = useSync() @@ -27,6 +29,28 @@ export function SessionStatusPanel(_props: { shown: Accessor }) { params.id && sync.directory ? globalSync.todoHydrate.isPending(sync.directory, params.id) : false, ) + const vcs = createMemo(() => sync.data.vcs) + + const activeWorktree = createMemo(() => { + if (!params.id) return undefined + const session = sync.session.get(params.id) + const exec = session?.executionContext + if (!exec) return undefined + return exec.activeWorktree + }) + + const diffStats = createMemo(() => { + if (!params.id) return { additions: 0, deletions: 0 } + const files = aggregateFiles(sync.data.turn_change_aggregate[params.id]) + let additions = 0 + let deletions = 0 + for (const file of files) { + additions += file.additions + deletions += file.deletions + } + return { additions, deletions } + }) + return (
}) { isAuthoritativelyInvalidated={isAuthoritativelyInvalidated} isPending={isPending} parts={parts} + vcs={vcs} + activeWorktree={activeWorktree} + diffStats={diffStats} + artifactFiles={props.artifactFiles} + onNavigateReview={props.onNavigateReview} />
) diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index 621f0cecd..d82ca4476 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -1,17 +1,18 @@ import { For, Show, createMemo, type Accessor, type JSX } from "solid-js" import { TodoStatusMarker } from "@opencode-ai/ui/todo-status-marker" -import type { Part } from "@opencode-ai/sdk/v2" +import { Icon } from "@opencode-ai/ui/icon" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import type { Part, VcsInfo } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" +import { canOpenLocalPath, usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { extractSources } from "@/pages/session/session-status-extractors" import { selectSessionTodoDataSnapshot } from "@/pages/session/session-todos" import type { SessionTodoItem } from "@/pages/session/todos/todo-model" import type { CanonicalTodoSnapshot } from "@/pages/session/todos/todo-source" +import type { FilesTabEntry } from "@/pages/session/files-tab-state" function Section(props: { title: string; children: JSX.Element }) { - // No divider — sections are separated by 24px of breathing room only. - // Hairlines felt too "boxed in" against the warm-neutral surface; the - // generous py-6 (24px top + 24px bottom = 48px between sections) reads - // as a calm pause without enclosing each section in chrome. return (
{props.title}
@@ -54,13 +55,202 @@ function SourceRow(props: { url: string }) { ) } +interface ActiveWorktree { + name: string + branch?: string + directory?: string +} + +function GitRow(props: { + icon: string + onClick?: () => void + children: JSX.Element + chevron?: "down" | "right" | false + title?: string +}) { + return ( + + ) +} + +function GitSection(props: { + vcs: Accessor + activeWorktree: Accessor + diffStats: Accessor<{ additions: number; deletions: number }> + onNavigateReview: () => void + onOpenWorktreeDirectory: (directory: string) => void +}) { + const language = useLanguage() + const hasChanges = createMemo(() => { + const stats = props.diffStats() + return stats.additions > 0 || stats.deletions > 0 + }) + + const worktreeTooltip = (worktree: ActiveWorktree) => ( +
+
+ Worktree + {worktree.name || "Not available"} +
+
+ Branch + {worktree.branch || "Not available"} +
+
+ Location + {worktree.directory || "Not available"} +
+
+ ) + + return ( +
+
+ + {language.t("status.summary.git.changes")}} + > + + +{props.diffStats().additions} + {" "} + −{props.diffStats().deletions} + + + + + + {(branch) => ( + + {branch()} + + )} + + + + {(worktree) => ( + + { + const directory = worktree().directory + if (directory) props.onOpenWorktreeDirectory(directory) + }} + title={language.t("status.summary.git.worktree.open")} + > + {worktree().name || worktree().branch || "Worktree"} + + + )} + +
+
+ ) +} + +function ArtifactRow(props: { + file: FilesTabEntry + onOpen: () => void + onReveal: () => void +}) { + const language = useLanguage() + const filename = createMemo(() => { + const parts = props.file.path.split("/") + return parts[parts.length - 1] || props.file.path + }) + + return ( +
+ + + {filename()} + +
+ + +
+
+ ) +} + +function ArtifactSection(props: { + files: Accessor + onOpenFile: (path: string) => void + onRevealFile: (path: string) => void +}) { + const language = useLanguage() + + return ( +
+ 0} + fallback={} + > +
+ + {(file) => ( + props.onOpenFile(file.path)} + onReveal={() => props.onRevealFile(file.path)} + /> + )} + +
+
+
+ ) +} + export function SessionStatusSummary(props: { canonical?: Accessor isAuthoritativelyInvalidated?: Accessor isPending?: Accessor parts: Accessor + vcs?: Accessor + activeWorktree?: Accessor + diffStats?: Accessor<{ additions: number; deletions: number }> + artifactFiles?: Accessor + onNavigateReview?: () => void }) { const language = useLanguage() + const platform = usePlatform() + const server = useServer() + const snapshot = createMemo(() => selectSessionTodoDataSnapshot({ primary: { @@ -74,9 +264,27 @@ export function SessionStatusSummary(props: { const todos = createMemo(() => snapshot().items) const sources = createMemo(() => extractSources(props.parts())) - // No outer wrapper — Section components attach directly to SessionStatusPanel's - // scroll container, so the first:border-t-0 selector correctly drops the leading - // hairline regardless of how SessionStatusSummary is placed inside. + const isGitRepo = createMemo(() => !!props.vcs?.()?.branch || !!props.activeWorktree?.()) + + const navigateToReview = () => { + props.onNavigateReview?.() + } + + const openWorktreeDirectory = (directory: string) => { + if (!canOpenLocalPath(platform) || !server.isLocal() || !platform.openPath) return + void platform.openPath(directory).catch(() => {}) + } + + const openFile = (path: string) => { + if (!platform.openPath) return + void platform.openPath(path).catch(() => {}) + } + + const revealFile = (path: string) => { + if (!platform.showItemInFolder) return + void platform.showItemInFolder(path).catch(() => {}) + } + return ( <> @@ -89,6 +297,26 @@ export function SessionStatusSummary(props: { + + props.activeWorktree?.()} + diffStats={props.diffStats!} + onNavigateReview={navigateToReview} + onOpenWorktreeDirectory={openWorktreeDirectory} + /> + + + + {(files) => ( + + )} + +
0} fallback={}>
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 9a6c29bba..0d19c99e6 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -16,7 +16,6 @@ import { useLanguage } from "@/context/language" import { MAX_RIGHT_PANEL_WIDTH, MIN_RIGHT_PANEL_WIDTH, useLayout } from "@/context/layout" import { useTerminal } from "@/context/terminal" import type { TerminalTabID } from "@/context/terminal-types" -import { FilesTab } from "@/pages/session/files-tab" import type { FilesTabEntry } from "@/pages/session/files-tab-state" import { createOpenSessionFileTab, @@ -382,13 +381,11 @@ export function SessionSidePanel(props: { /> - open() && sidePanelTab() === "status"} /> - - - - - - + open() && sidePanelTab() === "status"} + artifactFiles={props.files} + onNavigateReview={() => setSidePanelTabValue("review")} + /> From c59816948784808295b2ec2345d60d7201e45352 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 19:20:17 +0800 Subject: [PATCH 03/21] test(app): update tests for Files tab removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt shell-tab, right-panel-tabs, helpers, migrate-session-view, command-palette, side-panel, and use-session-commands tests to the removal of the "files" static tab. Key changes: - Legacy "files" values in normalize/coerce/migrate tests now expect "status" fallback instead of passthrough - planShellTabReorder test uses review→context instead of the removed files→review pair - command-palette default items test drops fileTree.toggle - Type assertion test-d.ts marks "files" as @ts-expect-error - Add normalizeShellTabs legacy-files-to-status regression test --- .../command-palette-default-items.test.ts | 2 - .../app/src/context/layout.shell-tabs.test.ts | 67 ++++++++++--------- packages/app/src/context/layout.test.ts | 4 +- .../app/src/pages/session/helpers.test.ts | 34 +++++----- .../session/migrate-session-view.test.ts | 20 +++--- .../pages/session/right-panel-tabs.test-d.ts | 11 +-- .../pages/session/right-panel-tabs.test.ts | 67 +++++++++---------- .../pages/session/session-side-panel.test.tsx | 13 +--- .../session/use-session-commands.test.ts | 6 +- 9 files changed, 106 insertions(+), 118 deletions(-) diff --git a/packages/app/src/components/command-palette/command-palette-default-items.test.ts b/packages/app/src/components/command-palette/command-palette-default-items.test.ts index 83420f946..e334a0a13 100644 --- a/packages/app/src/components/command-palette/command-palette-default-items.test.ts +++ b/packages/app/src/components/command-palette/command-palette-default-items.test.ts @@ -33,7 +33,6 @@ describe("buildCommandPaletteDefaultGroups", () => { command("panel.toggle"), command("terminal.toggle"), command("review.toggle"), - command("fileTree.toggle"), command("model.choose"), command("mcp.toggle"), command("permissions.autoaccept"), @@ -55,7 +54,6 @@ describe("buildCommandPaletteDefaultGroups", () => { "panel.toggle", "terminal.toggle", "review.toggle", - "fileTree.toggle", "model.choose", "mcp.toggle", "permissions.autoaccept", diff --git a/packages/app/src/context/layout.shell-tabs.test.ts b/packages/app/src/context/layout.shell-tabs.test.ts index 2c585a766..e0ec95740 100644 --- a/packages/app/src/context/layout.shell-tabs.test.ts +++ b/packages/app/src/context/layout.shell-tabs.test.ts @@ -12,14 +12,14 @@ describe("shell tab transitions", () => { const base = normalizeShellTabs({ openShellTabs: ["status"], sidePanelTab: "status" }) test("openShellTab appends to end and sets active", () => { - expect(openShellTab(base, "files")).toEqual({ openShellTabs: ["status", "files"], sidePanelTab: "files" }) + expect(openShellTab(base, "review")).toEqual({ openShellTabs: ["status", "review"], sidePanelTab: "review" }) }) test("openShellTab on existing tab only sets active", () => { - const start = openShellTab(openShellTab(base, "files"), "review") - const next = openShellTab(start, "files") - expect(next.openShellTabs).toEqual(["status", "files", "review"]) - expect(next.sidePanelTab).toBe("files") + const start = openShellTab(openShellTab(base, "review"), "context") + const next = openShellTab(start, "review") + expect(next.openShellTabs).toEqual(["status", "review", "context"]) + expect(next.sidePanelTab).toBe("review") }) test("closeShellTab status is no-op", () => { @@ -27,56 +27,53 @@ describe("shell tab transitions", () => { }) test("closeShellTab on active falls back to left neighbor", () => { - const start = openShellTab(openShellTab(base, "files"), "review") - expect(closeShellTab(start, "review")).toEqual({ openShellTabs: ["status", "files"], sidePanelTab: "files" }) + const start = openShellTab(openShellTab(base, "review"), "context") + expect(closeShellTab(start, "context")).toEqual({ openShellTabs: ["status", "review"], sidePanelTab: "review" }) }) test("closeShellTab on active with no left neighbor falls back to status", () => { - const start = openShellTab(base, "files") - expect(closeShellTab(start, "files")).toEqual({ openShellTabs: ["status"], sidePanelTab: "status" }) + const start = openShellTab(base, "review") + expect(closeShellTab(start, "review")).toEqual({ openShellTabs: ["status"], sidePanelTab: "status" }) }) test("closeShellTab on non-active preserves active", () => { - const start = openShellTab(openShellTab(base, "files"), "review") - expect(closeShellTab(start, "files")).toEqual({ openShellTabs: ["status", "review"], sidePanelTab: "review" }) + const start = openShellTab(openShellTab(base, "review"), "context") + expect(closeShellTab(start, "review")).toEqual({ openShellTabs: ["status", "context"], sidePanelTab: "context" }) }) test("closeShellTab on an active terminal tab shifts selection to status, openShellTabs untouched", () => { - // The dangling-terminal guard in SessionSidePanel routes through this path - // (closeTab) instead of openTab so it corrects the selection without the - // this.open() side effect that would pop a closed panel. const start = openShellTab(base, "terminal:x") expect(start).toEqual({ openShellTabs: ["status"], sidePanelTab: "terminal:x" }) expect(closeShellTab(start, "terminal:x")).toEqual({ openShellTabs: ["status"], sidePanelTab: "status" }) }) test("toggleShellTab opens when not in list", () => { - const next = toggleShellTab(base, "files", true) + const next = toggleShellTab(base, "review", true) expect(next.closePanel).toBe(false) - expect(next.state).toEqual({ openShellTabs: ["status", "files"], sidePanelTab: "files" }) + expect(next.state).toEqual({ openShellTabs: ["status", "review"], sidePanelTab: "review" }) }) test("toggleShellTab on inactive in-list tab only activates", () => { - const start = openShellTab(openShellTab(base, "files"), "review") - const next = toggleShellTab(start, "files", true) + const start = openShellTab(openShellTab(base, "review"), "context") + const next = toggleShellTab(start, "review", true) expect(next.closePanel).toBe(false) - expect(next.state.openShellTabs).toEqual(["status", "files", "review"]) - expect(next.state.sidePanelTab).toBe("files") + expect(next.state.openShellTabs).toEqual(["status", "review", "context"]) + expect(next.state.sidePanelTab).toBe("review") }) test("toggleShellTab on active but panel closed activates", () => { - const start = openShellTab(base, "files") - const next = toggleShellTab(start, "files", false) + const start = openShellTab(base, "review") + const next = toggleShellTab(start, "review", false) expect(next.closePanel).toBe(false) - expect(next.state.sidePanelTab).toBe("files") + expect(next.state.sidePanelTab).toBe("review") }) test("toggleShellTab on active and panel open closes the panel without removing the tab", () => { - const start = openShellTab(base, "files") - const next = toggleShellTab(start, "files", true) + const start = openShellTab(base, "review") + const next = toggleShellTab(start, "review", true) expect(next.closePanel).toBe(true) - expect(next.state.openShellTabs).toEqual(["status", "files"]) - expect(next.state.sidePanelTab).toBe("files") + expect(next.state.openShellTabs).toEqual(["status", "review"]) + expect(next.state.sidePanelTab).toBe("review") }) test("toggleShellTab status never closes", () => { @@ -86,20 +83,26 @@ describe("shell tab transitions", () => { }) test("moveShellTab reorders non-status tabs and keeps active", () => { - const start = openShellTab(openShellTab(openShellTab(base, "files"), "review"), "context") + const start = openShellTab(openShellTab(base, "review"), "context") expect(moveShellTab(start, "context", 1)).toEqual({ - openShellTabs: ["status", "context", "files", "review"], + openShellTabs: ["status", "context", "review"], sidePanelTab: "context", }) }) test("moveShellTab cannot move status", () => { - const start = openShellTab(base, "files") + const start = openShellTab(base, "review") expect(moveShellTab(start, "status", 1)).toEqual(start) }) test("moveShellTab clamps negative indexes after the pinned status tab", () => { - const start = openShellTab(openShellTab(openShellTab(base, "files"), "review"), "context") - expect(moveShellTab(start, "review", -1).openShellTabs).toEqual(["status", "review", "files", "context"]) + const start = openShellTab(openShellTab(base, "review"), "context") + expect(moveShellTab(start, "context", -1).openShellTabs).toEqual(["status", "context", "review"]) + }) + + test("normalizeShellTabs maps legacy files tab to status", () => { + const result = normalizeShellTabs({ openShellTabs: ["status", "files" as any], sidePanelTab: "files" }) + expect(result.openShellTabs).toEqual(["status"]) + expect(result.sidePanelTab).toBe("status") }) }) diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts index 01c60b6df..6ad02189c 100644 --- a/packages/app/src/context/layout.test.ts +++ b/packages/app/src/context/layout.test.ts @@ -98,8 +98,8 @@ describe("defaultSidePanelTab", () => { expect(defaultSidePanelTab("changes")).toBe("review") }) - test("keeps files stable", () => { - expect(defaultSidePanelTab("files")).toBe("files") + test("migrates legacy files to status", () => { + expect(defaultSidePanelTab("files")).toBe("status") }) }) diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index f253d9601..f92c1a9e9 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -122,19 +122,19 @@ describe("getTabReorderIndex", () => { describe("planShellTabReorder", () => { test("static to static returns a static move", () => { const plan = planShellTabReorder({ - draggableId: "files", - droppableId: "review", - openStatic: ["status", "files", "review", "context"], + draggableId: "review", + droppableId: "context", + openStatic: ["status", "review", "context"], terminalIds: [], }) - expect(plan).toEqual({ kind: "static", target: "files", to: 2 }) + expect(plan).toEqual({ kind: "static", target: "review", to: 2 }) }) test("terminal to terminal returns a terminal move with terminal-segment index", () => { const plan = planShellTabReorder({ draggableId: "terminal:a", droppableId: "terminal:c", - openStatic: ["status", "files"], + openStatic: ["status", "review"], terminalIds: ["a", "b", "c"], }) expect(plan).toEqual({ kind: "terminal", target: "a", to: 2 }) @@ -143,9 +143,9 @@ describe("planShellTabReorder", () => { test("cross-segment drag (static <-> terminal) is a no-op", () => { expect( planShellTabReorder({ - draggableId: "files", + draggableId: "review", droppableId: "terminal:a", - openStatic: ["status", "files", "review"], + openStatic: ["status", "review", "review"], terminalIds: ["a"], }), ).toBeNull() @@ -153,7 +153,7 @@ describe("planShellTabReorder", () => { planShellTabReorder({ draggableId: "terminal:a", droppableId: "review", - openStatic: ["status", "files", "review"], + openStatic: ["status", "review", "review"], terminalIds: ["a"], }), ).toBeNull() @@ -162,9 +162,9 @@ describe("planShellTabReorder", () => { test("dragging onto self is a no-op", () => { expect( planShellTabReorder({ - draggableId: "files", - droppableId: "files", - openStatic: ["status", "files", "review"], + draggableId: "review", + droppableId: "review", + openStatic: ["status", "review", "review"], terminalIds: [], }), ).toBeNull() @@ -174,8 +174,8 @@ describe("planShellTabReorder", () => { expect( planShellTabReorder({ draggableId: "ghost", - droppableId: "files", - openStatic: ["status", "files"], + droppableId: "review", + openStatic: ["status", "review"], terminalIds: [], }), ).toBeNull() @@ -192,9 +192,9 @@ describe("planShellTabReorder", () => { test("rejects dragging onto status (pinned)", () => { expect( planShellTabReorder({ - draggableId: "files", + draggableId: "review", droppableId: "status", - openStatic: ["status", "files"], + openStatic: ["status", "review"], terminalIds: [], }), ).toBeNull() @@ -204,8 +204,8 @@ describe("planShellTabReorder", () => { expect( planShellTabReorder({ draggableId: "status", - droppableId: "files", - openStatic: ["status", "files"], + droppableId: "review", + openStatic: ["status", "review"], terminalIds: [], }), ).toBeNull() diff --git a/packages/app/src/pages/session/migrate-session-view.test.ts b/packages/app/src/pages/session/migrate-session-view.test.ts index 2a845ee16..6c0a3ac9b 100644 --- a/packages/app/src/pages/session/migrate-session-view.test.ts +++ b/packages/app/src/pages/session/migrate-session-view.test.ts @@ -45,15 +45,15 @@ describe("resolveActiveCandidate", () => { }) test("maps legacy review inner tabs before stored side panel tab", () => { - expect(resolveActiveCandidate(legacyEntry({ migratedSidePanelTab: "files", sessionTabsRaw: { active: "changes" } }))) + expect(resolveActiveCandidate(legacyEntry({ migratedSidePanelTab: "status", sessionTabsRaw: { active: "changes" } }))) .toBe("review") - expect(resolveActiveCandidate(legacyEntry({ migratedSidePanelTab: "files", sessionTabsRaw: { active: "review" } }))) + expect(resolveActiveCandidate(legacyEntry({ migratedSidePanelTab: "status", sessionTabsRaw: { active: "review" } }))) .toBe("review") }) test("falls back to stored side panel tab before inferred review presence", () => { - expect(resolveActiveCandidate(legacyEntry({ migratedSidePanelTab: "files", reviewInSessionTabs: true }))).toBe( - "files", + expect(resolveActiveCandidate(legacyEntry({ migratedSidePanelTab: "status", reviewInSessionTabs: true }))).toBe( + "status", ) expect(resolveActiveCandidate(legacyEntry({ reviewInSessionTabs: true }))).toBe("review") }) @@ -95,10 +95,10 @@ describe("migrateSessionView", () => { test("preserves existing openShellTabs and absorbs sessionTabs active context", () => { const out = migrateSessionView( - { a: { scroll: {}, openShellTabs: ["status", "files"], sidePanelTab: "files" } }, + { a: { scroll: {}, openShellTabs: ["status", "review"], sidePanelTab: "review" } }, { a: { all: ["file://x"], active: "context" } }, ) - expect((out.sessionView as any).a.openShellTabs).toEqual(["status", "files", "review", "context"]) + expect((out.sessionView as any).a.openShellTabs).toEqual(["status", "review", "context"]) expect((out.sessionView as any).a.sidePanelTab).toBe("context") expect((out.sessionTabs as any).a).toEqual({ all: ["file://x"], active: undefined }) }) @@ -139,21 +139,21 @@ describe("migrateSessionView", () => { expect(out.changed).toBe(true) }) - test("normalizes damaged openShellTabs", () => { + test("normalizes damaged openShellTabs and drops legacy files", () => { const out = migrateSessionView( { a: { scroll: {}, openShellTabs: ["review", "status", "files", "files"], sidePanelTab: "review" } }, {}, ) - expect((out.sessionView as any).a.openShellTabs).toEqual(["status", "review", "files"]) + expect((out.sessionView as any).a.openShellTabs).toEqual(["status", "review"]) expect((out.sessionView as any).a.sidePanelTab).toBe("review") }) test("keeps valid openShellTabs but resets invalid sidePanelTab", () => { const out = migrateSessionView( - { a: { scroll: {}, openShellTabs: ["status", "files"], sidePanelTab: "bogus" } }, + { a: { scroll: {}, openShellTabs: ["status", "review"], sidePanelTab: "bogus" } }, {}, ) - expect((out.sessionView as any).a.openShellTabs).toEqual(["status", "files"]) + expect((out.sessionView as any).a.openShellTabs).toEqual(["status", "review"]) expect((out.sessionView as any).a.sidePanelTab).toBe("status") expect(out.changed).toBe(true) }) diff --git a/packages/app/src/pages/session/right-panel-tabs.test-d.ts b/packages/app/src/pages/session/right-panel-tabs.test-d.ts index 8f09872cf..3011426b7 100644 --- a/packages/app/src/pages/session/right-panel-tabs.test-d.ts +++ b/packages/app/src/pages/session/right-panel-tabs.test-d.ts @@ -4,13 +4,11 @@ import type { RightPanelTab, RightPanelStaticTab } from "./right-panel-tabs" -// Static slots: the four fixed panel tabs are valid. +// Static slots: the three fixed panel tabs are valid. const status: RightPanelTab = "status" -const files: RightPanelTab = "files" const review: RightPanelTab = "review" const context: RightPanelTab = "context" void status -void files void review void context @@ -20,8 +18,8 @@ const anotherTerminal: RightPanelTab = `terminal:${"42-xyz"}` void someTerminal void anotherTerminal -// Static-only type narrows to the four fixed tabs without the terminal arm. -const fixed: RightPanelStaticTab = "files" +// Static-only type narrows to the three fixed tabs without the terminal arm. +const fixed: RightPanelStaticTab = "status" // @ts-expect-error a terminal: string is not a static tab const bogusFixed: RightPanelStaticTab = "terminal:abc" void fixed @@ -34,6 +32,9 @@ const settings: RightPanelTab = "settings" const bareTerminal: RightPanelTab = "terminal" // @ts-expect-error "changes" is the pre-refactor name for review; not valid anymore const changes: RightPanelTab = "changes" +// @ts-expect-error "files" was merged into the status panel; not a valid tab anymore +const files: RightPanelTab = "files" void settings void bareTerminal void changes +void files diff --git a/packages/app/src/pages/session/right-panel-tabs.test.ts b/packages/app/src/pages/session/right-panel-tabs.test.ts index 89e334aa5..1e016c3d0 100644 --- a/packages/app/src/pages/session/right-panel-tabs.test.ts +++ b/packages/app/src/pages/session/right-panel-tabs.test.ts @@ -23,18 +23,16 @@ describe("right panel tab helpers", () => { expect(migrateLegacyRightPanelTab("changes")).toBe("review") }) - test("migrates old files tab to files", () => { - expect(migrateLegacyRightPanelTab("files")).toBe("files") + test("migrates old files tab to status", () => { + expect(migrateLegacyRightPanelTab("files")).toBe("status") }) test("keeps new right panel tabs stable", () => { - const tabs: RightPanelTab[] = ["status", "files", "review", "context"] + const tabs: RightPanelTab[] = ["status", "review", "context"] expect(tabs.map((tab) => migrateLegacyRightPanelTab(tab))).toEqual(tabs) }) test("drops legacy 'terminal' static value (terminals now come from terminal.all)", () => { - // Pre-refactor 'terminal' was a fixed slot. After flattening, only - // dynamic 'terminal:' is valid; the bare value falls back to status. expect(migrateLegacyRightPanelTab("terminal")).toBe("status") }) }) @@ -46,6 +44,7 @@ describe("isRightPanelTab", () => { test("rejects unknown strings and non-strings", () => { expect(isRightPanelTab("changes")).toBe(false) + expect(isRightPanelTab("files")).toBe(false) expect(isRightPanelTab(undefined)).toBe(false) expect(isRightPanelTab(123)).toBe(false) expect(isRightPanelTab(null)).toBe(false) @@ -76,9 +75,12 @@ describe("coerceLegacySidePanelTab", () => { expect(coerceLegacySidePanelTab("changes")).toBe("review") }) + test("maps files to status", () => { + expect(coerceLegacySidePanelTab("files")).toBe("status") + }) + test("passes through known tabs", () => { expect(coerceLegacySidePanelTab("status")).toBe("status") - expect(coerceLegacySidePanelTab("files")).toBe("files") expect(coerceLegacySidePanelTab("context")).toBe("context") }) @@ -105,18 +107,18 @@ describe("normalizeShellTabs", () => { }) test("injects status at head if missing", () => { - expect(normalizeShellTabs({ openShellTabs: ["files", "review"], sidePanelTab: "files" })).toEqual({ - openShellTabs: ["status", "files", "review"], - sidePanelTab: "files", + expect(normalizeShellTabs({ openShellTabs: ["review", "context"], sidePanelTab: "review" })).toEqual({ + openShellTabs: ["status", "review", "context"], + sidePanelTab: "review", }) }) test("dedupes preserving first occurrence", () => { expect( - normalizeShellTabs({ openShellTabs: ["status", "files", "files", "review"], sidePanelTab: "review" }), + normalizeShellTabs({ openShellTabs: ["status", "review", "review", "context"], sidePanelTab: "context" }), ).toEqual({ - openShellTabs: ["status", "files", "review"], - sidePanelTab: "review", + openShellTabs: ["status", "review", "context"], + sidePanelTab: "context", }) }) @@ -130,17 +132,23 @@ describe("normalizeShellTabs", () => { }) test("falls back to status when sidePanelTab not in list", () => { - expect(normalizeShellTabs({ openShellTabs: ["status", "files"], sidePanelTab: "review" })).toEqual({ - openShellTabs: ["status", "files"], + expect(normalizeShellTabs({ openShellTabs: ["status", "review"], sidePanelTab: "context" })).toEqual({ + openShellTabs: ["status", "review"], sidePanelTab: "status", }) }) test("idempotent", () => { - const once = normalizeShellTabs({ openShellTabs: ["files", "status", "files"], sidePanelTab: "files" }) + const once = normalizeShellTabs({ openShellTabs: ["review", "status", "review"], sidePanelTab: "review" }) const twice = normalizeShellTabs(once) expect(twice).toEqual(once) }) + + test("drops legacy files from persisted openShellTabs", () => { + const result = normalizeShellTabs({ openShellTabs: ["status", "files"], sidePanelTab: "files" }) + expect(result.openShellTabs).toEqual(["status"]) + expect(result.sidePanelTab).toBe("status") + }) }) describe("terminalTabValue", () => { @@ -152,15 +160,12 @@ describe("terminalTabValue", () => { test("throws on an empty id rather than returning the invalid 'terminal:'", () => { expect(() => terminalTabValue("")).toThrow() - // The empty form would otherwise be rejected by the type guard. expect(isRightPanelTerminalTab("terminal:")).toBe(false) }) }) describe("isDanglingTerminalSelection", () => { test("not dangling while terminal store is still hydrating (ready=false)", () => { - // The restore race: layout restored sidePanelTab=terminal:t1 but the - // terminal store hasn't loaded yet, so all() is empty. Must NOT bounce. expect(isDanglingTerminalSelection("terminal:t1" as RightPanelTab, false, [])).toBe(false) }) @@ -174,43 +179,35 @@ describe("isDanglingTerminalSelection", () => { test("static tabs are never dangling, ready or not", () => { expect(isDanglingTerminalSelection("status", true, [])).toBe(false) - expect(isDanglingTerminalSelection("files", false, [])).toBe(false) + expect(isDanglingTerminalSelection("review", false, [])).toBe(false) }) }) describe("shouldCommitDeferredOpen", () => { - // Scenario: openTab("files") scheduled a microtask while baseline was "status". - // By the time the microtask runs, evaluate whether the deferred selection - // write to "files" is still safe. - test("commits when chip still open and baseline selection unchanged", () => { - const after = normalizeShellTabs({ openShellTabs: ["status", "files"], sidePanelTab: "status" }) - expect(shouldCommitDeferredOpen(after, "files", "status")).toBe(true) + const after = normalizeShellTabs({ openShellTabs: ["status", "review"], sidePanelTab: "status" }) + expect(shouldCommitDeferredOpen(after, "review", "status")).toBe(true) }) test("skips when chip was closed before microtask fired", () => { const after = normalizeShellTabs({ openShellTabs: ["status"], sidePanelTab: "status" }) - expect(shouldCommitDeferredOpen(after, "files", "status")).toBe(false) + expect(shouldCommitDeferredOpen(after, "review", "status")).toBe(false) }) test("skips when a same-tick openTab(B) moved selection off baseline", () => { - // Sequence: openTab("files") defers, openTab("review") commits sync to - // "review". The deferred microtask must not overwrite "review". const after = normalizeShellTabs({ - openShellTabs: ["status", "files", "review"], - sidePanelTab: "review", + openShellTabs: ["status", "review", "context"], + sidePanelTab: "context", }) - expect(shouldCommitDeferredOpen(after, "files", "status")).toBe(false) + expect(shouldCommitDeferredOpen(after, "review", "status")).toBe(false) }) test("skips when selection moved off baseline to a different open tab", () => { const after = normalizeShellTabs({ - openShellTabs: ["status", "files", "context"], + openShellTabs: ["status", "review", "context"], sidePanelTab: "context", }) - // Baseline was "status"; by microtask time selection is on "context". - // Don't overwrite that with the deferred "files". - expect(shouldCommitDeferredOpen(after, "files", "status")).toBe(false) + expect(shouldCommitDeferredOpen(after, "review", "status")).toBe(false) }) test("never commits for a terminal target (terminal chips skip the defer path entirely)", () => { diff --git a/packages/app/src/pages/session/session-side-panel.test.tsx b/packages/app/src/pages/session/session-side-panel.test.tsx index 36e72e2ed..bb74b7d5d 100644 --- a/packages/app/src/pages/session/session-side-panel.test.tsx +++ b/packages/app/src/pages/session/session-side-panel.test.tsx @@ -88,7 +88,6 @@ beforeAll(async () => { }), })) mock.module("@/pages/session/file-tabs", () => ({ FileTabContent: () => null })) - mock.module("@/pages/session/files-tab", () => ({ FilesTab: () => null })) mock.module("@/pages/session/handoff", () => ({ setSessionHandoff: () => undefined })) mock.module("@/pages/session/session-layout", () => ({ sessionRouteLayoutKey: (params: { dir: string | undefined; id: string | undefined }) => @@ -169,7 +168,7 @@ describe("sortableShellTabIds", () => { test("keeps the pinned status tab out of sortable ids", async () => { const { sortableShellTabIds } = await import("./session-side-panel") - expect(sortableShellTabIds(["status", "files", "review", "context"])).toEqual(["files", "review", "context"]) + expect(sortableShellTabIds(["status", "review", "context"])).toEqual(["review", "context"]) }) }) @@ -215,16 +214,6 @@ describe("makeRightPanelResizeHandler", () => { // `no-mode-picker.test.ts` and catches the most likely regressions: tab-id // typos, copy-paste mistakes, and any future "let's drop the Show wrap" change. describe("right-panel inactive-tab gating contract", () => { - test("files tab body is gated by Show when={sidePanelTab() === \"files\"}", async () => { - const source = await fs.readFile(SOURCE_PATH, "utf8") - const block = findTabContentBlock(source, "files") - expect(block).toContain(``) - expect(block).toContain("`) - const compIdx = block.indexOf(" { const source = await fs.readFile(SOURCE_PATH, "utf8") const block = findTabContentBlock(source, "context") diff --git a/packages/app/src/pages/session/use-session-commands.test.ts b/packages/app/src/pages/session/use-session-commands.test.ts index 8391dcd87..6cd281fcd 100644 --- a/packages/app/src/pages/session/use-session-commands.test.ts +++ b/packages/app/src/pages/session/use-session-commands.test.ts @@ -122,9 +122,9 @@ describe("createCloseShellTabRouter", () => { } test("static tab close goes straight to sidePanel.closeTab", () => { - const { router, calls } = buildDeps({ activeTab: "files", terminals: [] }) - router("files") - expect(calls).toEqual(["closeTab:files"]) + const { router, calls } = buildDeps({ activeTab: "review", terminals: [] }) + router("review") + expect(calls).toEqual(["closeTab:review"]) }) test("terminal tab close calls terminal.close (P1: keyboard close used to skip this)", () => { From 92d9855141a50f6a81560710e30f475e87285d9a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 20:20:32 +0800 Subject: [PATCH 04/21] fix(app): address Status panel visual review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate section titles to Chinese (环境/产出物) and fix English (Artifacts) - Align GitRow to interactive row sizing (min-h 30px, px-2, conditional hover) - Remove non-functional branch chevron (backend has no branch-switch API yet) - Make changes row always clickable to navigate to review panel --- .../components/session/session-status-summary.tsx | 13 ++++++++----- packages/app/src/i18n/en.ts | 4 ++-- packages/app/src/i18n/zh.ts | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index d82ca4476..17664c0b5 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -71,8 +71,11 @@ function GitRow(props: { return ( - -
+ + + + + + + + + +
) } From 2db714f13c4486fb84eb5c28205d35a56ec646db Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 20:53:36 +0800 Subject: [PATCH 06/21] fix(i18n): rename status panel sections to Workspace / Changed files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zh: 工作区 / 改动文件 (confirmed via DeepSeek + Codex consensus) en: Workspace / Changed files --- packages/app/src/i18n/en.ts | 6 +++--- packages/app/src/i18n/zh.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 609a74536..ed9650944 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -699,11 +699,11 @@ export const dict = { "status.summary.progress.empty": "No todos yet.", "status.summary.sources": "Sources", "status.summary.sources.empty": "No sources used yet.", - "status.summary.git": "Environment", + "status.summary.git": "Workspace", "status.summary.git.changes": "Changes", "status.summary.git.worktree.open": "Open worktree folder", - "status.summary.artifact": "Artifacts", - "status.summary.artifact.empty": "No artifacts yet.", + "status.summary.artifact": "Changed files", + "status.summary.artifact.empty": "No changed files yet.", "status.summary.artifact.open": "Open file", "status.summary.artifact.reveal": "Reveal in folder", "status.connections.state.disabled": "disabled", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 05d7211e0..78f86b7d2 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -633,11 +633,11 @@ export const dict = { "status.summary.progress.empty": "还没有待办事项。", "status.summary.sources": "来源", "status.summary.sources.empty": "还没有使用任何来源。", - "status.summary.git": "环境", + "status.summary.git": "工作区", "status.summary.git.changes": "变更", "status.summary.git.worktree.open": "打开 worktree 文件夹", - "status.summary.artifact": "产出物", - "status.summary.artifact.empty": "暂无产出物", + "status.summary.artifact": "改动文件", + "status.summary.artifact.empty": "暂无改动文件", "status.summary.artifact.open": "打开文件", "status.summary.artifact.reveal": "在文件夹中显示", "status.connections.state.disabled": "已停用", From 19e4f1eff1509bab838eddb1f86583ab560a7a92 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 20:54:39 +0800 Subject: [PATCH 07/21] fix(app): add rounded corners to artifact row hover Unlike turn-change-row which sits inside a bordered container, artifact rows are exposed directly in the status panel section. --- packages/app/src/components/session/session-status-summary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index c43b00eaf..35fd2084e 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -185,7 +185,7 @@ function ArtifactRow(props: { return (
From 9b0046b94fc72081f69c1f27393b9cc8e150ac13 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 20:56:08 +0800 Subject: [PATCH 08/21] fix(app): unify status panel row hover to rounded-md + surface-raised Align GitRow and ArtifactRow with sidebar/settings convention: rounded-md border-radius, hover:bg-surface-raised. --- .../app/src/components/session/session-status-summary.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index 35fd2084e..6f40f97cc 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -72,7 +72,7 @@ function GitRow(props: { return (
+ ) } @@ -75,7 +84,7 @@ function GitRow(props: { class="flex w-full items-center gap-2 rounded-md px-2 text-left transition-colors min-h-[30px]" classList={{ "cursor-default": !props.onClick, - "hover:bg-surface-raised": !!props.onClick, + "hover:bg-[var(--row-hover-overlay)]": !!props.onClick, }} disabled={!props.onClick} onClick={props.onClick} @@ -177,6 +186,7 @@ function GitSection(props: { function ArtifactRow(props: { file: FilesTabEntry + diff?: { additions: number; deletions: number } onOpen: () => void onReveal: () => void }) { @@ -188,15 +198,24 @@ function ArtifactRow(props: { return (
- + {filename()} - - + + + {(diff) => ( + + +{diff().additions} + −{diff().deletions} + + )} + + + diffsByPath?: Accessor> onOpenFile: (path: string) => void onRevealFile: (path: string) => void }) { @@ -237,6 +257,7 @@ function ArtifactSection(props: { {(file) => ( props.onOpenFile(file.path)} onReveal={() => props.onRevealFile(file.path)} /> @@ -257,6 +278,7 @@ export function SessionStatusSummary(props: { activeWorktree?: Accessor diffStats?: Accessor<{ additions: number; deletions: number }> artifactFiles?: Accessor + diffsByPath?: Accessor> onNavigateReview?: () => void }) { const language = useLanguage() @@ -297,6 +319,10 @@ export function SessionStatusSummary(props: { void platform.showItemInFolder(path).catch(() => {}) } + const openSource = (url: string) => { + platform.openLink(url) + } + return ( <> @@ -323,6 +349,7 @@ export function SessionStatusSummary(props: { {(files) => ( @@ -332,7 +359,7 @@ export function SessionStatusSummary(props: {
0} fallback={}>
- {(url) => } + {(url) => }
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f750cc297..9a6dbffcd 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -699,6 +699,7 @@ export const dict = { "status.summary.progress.empty": "No todos yet.", "status.summary.sources": "Sources", "status.summary.sources.empty": "No sources used yet.", + "status.summary.sources.open": "Open in browser", "status.summary.git": "Workspace", "status.summary.git.changes": "Changes", "status.summary.git.worktree.open": "Open worktree folder", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 9aff8a276..4558c904e 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -633,6 +633,7 @@ export const dict = { "status.summary.progress.empty": "还没有待办事项。", "status.summary.sources": "来源", "status.summary.sources.empty": "还没有使用任何来源。", + "status.summary.sources.open": "在浏览器中打开", "status.summary.git": "工作区", "status.summary.git.changes": "变更", "status.summary.git.worktree.open": "打开 worktree 文件夹", From 213ebca1a279f30644733784fb58af253e7b5790 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 23:01:53 +0800 Subject: [PATCH 19/21] test(app): cover full status panel in a new snap target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status-summary-todos.snap.ts only seeds todos so the Workspace, Changed files, and Sources sections went unsnapped. status-summary-panel seeds all four sections in one session (todos via __e2e/update-todos, two apply_patch turns via mock LLM for real turn-change aggregate, two text turns whose text parts are swapped to completed webfetch parts via sdk.part.update for the source URLs) and captures two shots: - rest: full panel showing aggregate diff in Workspace, per-file diff stats in Changed files, leading-arrow URL rows in Sources. - artifact hover: first Changed files row with hover bg active and open + reveal icons revealed in place of the diff stats, locking down the rest→hover trailing transition introduced when ArtifactRow adopted the turn-change trailing pattern. Notification region (mock-LLM health-check toasts) is hidden via a scoped style tag before screenshotting so the snap captures the panel itself. ArtifactRow / SourceRow gain `data-slot` attributes so the snap can target the rows without coupling to CSS class names. --- .../app/e2e/snap/status-summary-panel.snap.ts | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 packages/app/e2e/snap/status-summary-panel.snap.ts diff --git a/packages/app/e2e/snap/status-summary-panel.snap.ts b/packages/app/e2e/snap/status-summary-panel.snap.ts new file mode 100644 index 000000000..3a0660ead --- /dev/null +++ b/packages/app/e2e/snap/status-summary-panel.snap.ts @@ -0,0 +1,254 @@ +import { expect, type Page } from "@playwright/test" +import type { Todo } from "@opencode-ai/sdk/v2/client" +import { test } from "../fixtures" +import { openRightPanel, openSidebar } from "../actions" +import { sessionItemSelector } from "../selectors" +import { bodyText } from "../prompt/mock" +import type { createSdk } from "../utils" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +// Companion to status-summary-todos.snap.ts. That target covers the four todo +// marker variants in isolation; this one drives the whole Overview panel +// (Progress / Workspace / Changed files / Sources) so each section's rest +// state plus the Changed files row's rest→hover trailing transition has a +// durable baseline. Required so future picker / hover-token regressions on +// any of the four sections — not just todos — surface in CI. + +type Sdk = ReturnType +type LLM = Parameters[0]["llm"] + +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) + +const SEED_TODOS: Array> = [ + { content: "Wire status summary markers", status: "completed", priority: "high" }, + { content: "Cover all four states in snap", status: "in_progress", priority: "high" }, + { content: "Queue follow-up cleanup", status: "pending", priority: "medium" }, + { content: "Notify user for review", status: "cancelled", priority: "low" }, +] + +const SEED_SOURCES = [ + "https://docs.pawwork.dev/status-panel", + "https://blog.pawwork.dev/2026/changelog", +] + +function patch(file: string, marker: string) { + return [ + "*** Begin Patch", + `*** Add File: ${file}`, + `+title ${marker}`, + `+mark ${marker}`, + "+line three", + "*** End Patch", + ].join("\n") +} + +async function seedTodos(input: { url: string; directory: string; sessionID: string }) { + const response = await fetch( + `${input.url}/session/__e2e/update-todos?directory=${encodeURIComponent(input.directory)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: input.sessionID, todos: SEED_TODOS }), + }, + ) + if (response.status !== 204) { + throw new Error(`update-todos failed: ${response.status} ${await response.text()}`) + } +} + +async function applyPatchTurn(llm: LLM, sdk: Sdk, sessionID: string, patchText: string) { + const callsBefore = await llm.calls() + await llm.toolMatch( + (hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."), + "apply_patch", + { patchText }, + ) + await sdk.session.prompt({ + sessionID, + agent: "build", + system: [ + "You are seeding deterministic snap UI state.", + "Your only valid response is one apply_patch tool call.", + `Use this JSON input: ${JSON.stringify({ patchText })}`, + "Do not call any other tools.", + "Do not output plain text.", + ].join("\n"), + parts: [{ type: "text", text: "Apply the provided patch exactly once." }], + }) + await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true) +} + +async function seedWebfetchSource(input: { + llm: LLM + sdk: Sdk + sessionID: string + url: string + prompt: string + reply: string +}) { + const beforeCount = await input.sdk.session.messages({ sessionID: input.sessionID, limit: 200 }) + .then((r) => (r.data ?? []).length) + await input.llm.text(input.reply) + await input.sdk.session.prompt({ + sessionID: input.sessionID, + agent: "build", + parts: [{ type: "text", text: input.prompt }], + }) + + // Poll until the assistant has finished persisting at least one new message + // with a text part. We don't match on text content — the build agent may add + // reasoning/tool parts around the seeded reply, and a content-equality check + // breaks on whitespace or wrapper drift. Newest-first scan picks the seed + // turn's text without colliding with prior apply_patch turns (which have + // no text part). + let target: + | Awaited>["data"][number] + | undefined + let textPart: Extract< + Awaited>["data"][number]["parts"][number], + { type: "text" } + > | undefined + await expect + .poll( + async () => { + const messages = await input.sdk.session.messages({ sessionID: input.sessionID, limit: 200 }) + .then((r) => r.data ?? []) + if (messages.length <= beforeCount) return false + for (let i = messages.length - 1; i >= beforeCount; i -= 1) { + const message = messages[i] + if (message.info.role !== "assistant") continue + const tp = message.parts.find((p) => p.type === "text") + if (tp) { + target = message + textPart = tp as typeof textPart + return true + } + } + return false + }, + { timeout: 60_000 }, + ) + .toBe(true) + if (!target || !textPart) throw new Error(`Failed to find seeded text part for ${input.url}`) + + const now = Date.now() + await input.sdk.part.update({ + sessionID: input.sessionID, + messageID: target.info.id, + partID: textPart.id, + part: { + id: textPart.id, + sessionID: input.sessionID, + messageID: target.info.id, + type: "tool", + callID: `call_snap_webfetch_${now}`, + tool: "webfetch", + state: { + status: "completed", + input: { url: input.url, format: "text" }, + output: `Fetched ${input.url}`, + title: input.url, + metadata: {}, + time: { start: now - 12, end: now }, + }, + }, + }) +} + +async function waitForAllSections(panel: ReturnType) { + await panel.locator('[data-slot="status-summary-todo"]').first().waitFor({ state: "visible", timeout: 30_000 }) + await panel.locator('[data-slot="status-summary-artifact"]').first().waitFor({ state: "visible", timeout: 30_000 }) + await panel.locator('[data-slot="status-summary-source"]').first().waitFor({ state: "visible", timeout: 30_000 }) +} + +// The mock LLM backend triggers a "Server unreachable" health-check toast plus +// per-turn "Response ready" toasts, all anchored bottom-right where they cover +// the lower half of the right panel. Hide the notifications region via CSS so +// the snap captures the Sources section, not a notification stack. CSS instead +// of clicking Dismiss because some toasts auto-regenerate while the LLM mock +// is still emitting events. +async function hideToasts(page: Page) { + await page.addStyleTag({ + content: '[data-component="toast-region"]{display:none !important;}', + }) +} + +test("status-summary-panel", async ({ page, project, llm }) => { + test.setTimeout(240_000) + + let sessionID: string | undefined + await project.open({ + beforeGoto: async ({ sdk }) => { + const session = await sdk.session.create({ title: "snap status summary panel" }).then((r) => r.data) + if (!session?.id) throw new Error("Failed to create session") + sessionID = session.id + }, + }) + if (!sessionID) throw new Error("Session create did not return an id") + project.trackSession(sessionID) + + await seedTodos({ url: project.url, directory: project.directory, sessionID }) + await applyPatchTurn(llm, project.sdk, sessionID, patch("snap-status-panel-a.txt", "alpha")) + await applyPatchTurn(llm, project.sdk, sessionID, patch("snap-status-panel-b.txt", "beta")) + await seedWebfetchSource({ + llm, + sdk: project.sdk, + sessionID, + url: SEED_SOURCES[0], + prompt: "Reference the docs page.", + reply: "snap docs reference", + }) + await seedWebfetchSource({ + llm, + sdk: project.sdk, + sessionID, + url: SEED_SOURCES[1], + prompt: "Reference the changelog.", + reply: "snap changelog reference", + }) + + // Wait for the turn-change aggregate to capture both patched files before + // navigating; otherwise the right panel might render mid-aggregation and snap + // an empty Changed files section. + await expect + .poll( + async () => { + const aggregate = await project.sdk.session.diff({ sessionID: sessionID! }).then((r) => r.data) + if (!aggregate || aggregate.kind === "empty" || aggregate.kind === "uncaptured") return 0 + return aggregate.files.filter((file) => file.restoreState === "applied").length + }, + { timeout: 120_000 }, + ) + .toBeGreaterThanOrEqual(2) + + await openSidebar(page) + await page.locator(sessionItemSelector(sessionID)).click() + const panel = await openRightPanel(page) + await waitForAllSections(panel) + await hideToasts(page) + + const shots: Shot[] = [] + + // Park the cursor away so the artifact-row trailing slot shows diff stats + // (rest state). animations:"disabled" freezes the in_progress todo's pw-spin + // ring so consecutive runs render the same frame. + await page.mouse.move(0, 0) + shots.push({ name: "panel rest", buf: await panel.screenshot({ animations: "disabled" }) }) + + // Hover the first artifact row to capture the trailing rest→hover transition: + // the +N −N diff fades out, the open + reveal IconButtons fade in. This is + // the new contract introduced when the panel adopted the turn-change trailing + // pattern; without this shot a regression to "always show actions" or + // "actions on top of diff" would slip past CI. + const artifactRow = panel.locator('[data-slot="status-summary-artifact"]').first() + await artifactRow.hover() + await expect(artifactRow.locator('[data-component="icon-button"]').first()).toBeVisible({ timeout: 5_000 }) + // Wait out the opacity transition (~150ms) so the action icons are fully + // visible and the diff stats fully faded before the screenshot. + await page.waitForTimeout(220) + shots.push({ name: "panel artifact hover", buf: await panel.screenshot({ animations: "disabled" }) }) + + const out = snapOutputPath("status-summary-panel") + await composeGrid(shots, out, { cols: 2 }) + process.stdout.write(`\n[snap] status-summary-panel grid -> ${out}\n\n`) +}) From 43ef84e42e651993fc579082bb2df84355b20a3a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 23:15:20 +0800 Subject: [PATCH 20/21] fix(app): normalise artifact path key for diff stats lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review flagged a silent Windows regression: SessionStatusPanel keyed its per-file diff map by `aggregateFiles().file` (server-side `openPath ?? path`) while ArtifactSection looked up `FilesTabEntry.path` (locally joined by deriveArtifactFiles using `/`). On Windows the two sides can mix `C:\repo\src\a.ts` and `C:\repo/src/a.ts`, so exact string equality fails — the artifact row still renders but loses its `+N -N` diff stat. The aggregate Workspace total stays correct because it sums the map values directly, hiding the regression. Add a single `normalizeArtifactPathKey` helper in files-tab-state and route both sides through it so the lookup matches regardless of slash direction. The helper is scoped to keyed comparisons; native shell callers (openPath / showItemInFolder) still receive the original path. Includes a unit test that locks the mixed-slash case. --- .../components/session/session-status-panel.tsx | 4 ++-- .../session/session-status-summary.tsx | 4 ++-- .../src/pages/session/files-tab-state.test.ts | 17 ++++++++++++++++- .../app/src/pages/session/files-tab-state.ts | 10 ++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/session/session-status-panel.tsx b/packages/app/src/components/session/session-status-panel.tsx index 0c1f5a803..7ea90e9d3 100644 --- a/packages/app/src/components/session/session-status-panel.tsx +++ b/packages/app/src/components/session/session-status-panel.tsx @@ -3,7 +3,7 @@ import { useParams } from "@solidjs/router" import type { Part } from "@opencode-ai/sdk/v2" import { useGlobalSync } from "@/context/global-sync" import { useSync } from "@/context/sync" -import type { FilesTabEntry } from "@/pages/session/files-tab-state" +import { normalizeArtifactPathKey, type FilesTabEntry } from "@/pages/session/files-tab-state" import { aggregateFiles } from "@/pages/session/session-aggregate-files" import { SessionStatusSummary } from "./session-status-summary" @@ -56,7 +56,7 @@ export function SessionStatusPanel(props: { const diffsByPath = createMemo(() => { const map = new Map() for (const file of aggregatedFiles()) { - map.set(file.file, { additions: file.additions, deletions: file.deletions }) + map.set(normalizeArtifactPathKey(file.file), { additions: file.additions, deletions: file.deletions }) } return map }) diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index bf7b19887..100dc597a 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -11,7 +11,7 @@ import { extractSources } from "@/pages/session/session-status-extractors" import { selectSessionTodoDataSnapshot } from "@/pages/session/session-todos" import type { SessionTodoItem } from "@/pages/session/todos/todo-model" import type { CanonicalTodoSnapshot } from "@/pages/session/todos/todo-source" -import type { FilesTabEntry } from "@/pages/session/files-tab-state" +import { normalizeArtifactPathKey, type FilesTabEntry } from "@/pages/session/files-tab-state" function Section(props: { title: string; children: JSX.Element }) { return ( @@ -257,7 +257,7 @@ function ArtifactSection(props: { {(file) => ( props.onOpenFile(file.path)} onReveal={() => props.onRevealFile(file.path)} /> diff --git a/packages/app/src/pages/session/files-tab-state.test.ts b/packages/app/src/pages/session/files-tab-state.test.ts index b9c80abc3..fa9f32ea6 100644 --- a/packages/app/src/pages/session/files-tab-state.test.ts +++ b/packages/app/src/pages/session/files-tab-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { deriveArtifactFiles } from "./files-tab-state" +import { deriveArtifactFiles, normalizeArtifactPathKey } from "./files-tab-state" describe("files tab state", () => { test("maps cumulative artifact history into Files-tab entries", () => { @@ -13,4 +13,19 @@ describe("files tab state", () => { "/Users/yuhan/PawWork/notes.md", ]) }) + + test("normalizeArtifactPathKey collapses mixed Windows slashes so map lookups match", () => { + // deriveArtifactFiles always joins with `/`, so locally-built paths look + // like `C:\repo/src/a.ts` while server-side openPath often arrives as + // `C:\repo\src\a.ts`. Both must hash to the same key so the per-file + // diff stats render on Windows; otherwise the row silently loses +N -N. + const local = "C:\\repo/src/a.ts" + const server = "C:\\repo\\src\\a.ts" + expect(normalizeArtifactPathKey(local)).toBe("C:/repo/src/a.ts") + expect(normalizeArtifactPathKey(local)).toBe(normalizeArtifactPathKey(server)) + // POSIX paths are unchanged so Mac/Linux behaviour stays identical. + expect(normalizeArtifactPathKey("/Users/yuhan/PawWork/report.docx")).toBe( + "/Users/yuhan/PawWork/report.docx", + ) + }) }) diff --git a/packages/app/src/pages/session/files-tab-state.ts b/packages/app/src/pages/session/files-tab-state.ts index b1b270184..4e399f31e 100644 --- a/packages/app/src/pages/session/files-tab-state.ts +++ b/packages/app/src/pages/session/files-tab-state.ts @@ -19,3 +19,13 @@ export function deriveArtifactFiles(baseDir: string, artifacts: SessionArtifactF : `${baseDir.replace(/[\\/]+$/, "")}/${artifact.file.replace(/^[\\/]+/, "")}`, })) } + +// Normalise a path for keyed comparisons only (e.g. matching an artifact row +// against per-file diff stats). Server-side openPath and locally-joined +// FilesTabEntry.path can mix backslashes and forward slashes on Windows, so +// exact string equality is unsafe. Do not use the return value for native +// shell calls (openPath / showItemInFolder) — those expect the OS-native +// separator and the original path should be passed through unchanged. +export function normalizeArtifactPathKey(path: string): string { + return path.replace(/\\/g, "/") +} From a16b1e68b50982ce9be8c23fc53c71e9deaaf8b0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 31 May 2026 23:34:08 +0800 Subject: [PATCH 21/21] refactor(app): split status summary sections, restore artifact open capability Decompose session-status-summary into shell + git + artifact files. Per-row open / reveal capability now follows the old Files-tab pattern: panel stats artifact paths via platform.statPaths, disables actions when the file no longer exists or the host can't open paths, and surfaces openPath / showItemInFolder / openLink failures through a toast instead of swallowing them. Worktree row applies the same canOpen / disabled treatment. snap target now asserts exact seeded counts (4 todos, 2 artifacts, 2 sources) and waits for per-path diff stats to render before screenshotting, so partial-seed regressions and silent normalizeArtifactPathKey breakage cannot pass. --- .../app/e2e/snap/status-summary-panel.snap.ts | 18 +- .../session/session-status-panel.tsx | 75 ++++- .../session-status-summary-artifact.tsx | 106 +++++++ .../session/session-status-summary-git.tsx | 142 +++++++++ .../session/session-status-summary-shell.tsx | 18 ++ .../session/session-status-summary.tsx | 277 ++---------------- 6 files changed, 379 insertions(+), 257 deletions(-) create mode 100644 packages/app/src/components/session/session-status-summary-artifact.tsx create mode 100644 packages/app/src/components/session/session-status-summary-git.tsx create mode 100644 packages/app/src/components/session/session-status-summary-shell.tsx diff --git a/packages/app/e2e/snap/status-summary-panel.snap.ts b/packages/app/e2e/snap/status-summary-panel.snap.ts index 3a0660ead..efd0debc9 100644 --- a/packages/app/e2e/snap/status-summary-panel.snap.ts +++ b/packages/app/e2e/snap/status-summary-panel.snap.ts @@ -155,10 +155,22 @@ async function seedWebfetchSource(input: { }) } +// Wait for the panel to reflect the exact seeded counts (4 todos, 2 artifacts, +// 2 sources). Exact counts catch partial-seed regressions that a "first row +// visible" wait would miss — e.g. one webfetch part failing to attach would +// leave only one Source row, but a "first visible" check would still pass. async function waitForAllSections(panel: ReturnType) { - await panel.locator('[data-slot="status-summary-todo"]').first().waitFor({ state: "visible", timeout: 30_000 }) - await panel.locator('[data-slot="status-summary-artifact"]').first().waitFor({ state: "visible", timeout: 30_000 }) - await panel.locator('[data-slot="status-summary-source"]').first().waitFor({ state: "visible", timeout: 30_000 }) + await expect.poll(() => panel.locator('[data-slot="status-summary-todo"]').count(), { timeout: 30_000 }).toBe(4) + await expect.poll(() => panel.locator('[data-slot="status-summary-artifact"]').count(), { timeout: 30_000 }).toBe(2) + await expect.poll(() => panel.locator('[data-slot="status-summary-source"]').count(), { timeout: 30_000 }).toBe(2) + // The first artifact row must show its per-path diff stats (+N −N) before + // we screenshot. If the path-key normalization broke, the row would render + // without numbers, and a rest-state snapshot would silently match a + // diff-less baseline. Match on text content rather than CSS class so the + // assertion is robust to the artifact row's exact markup. + const firstArtifact = panel.locator('[data-slot="status-summary-artifact"]').first() + await expect(firstArtifact).toContainText(/\+\d+/, { timeout: 10_000 }) + await expect(firstArtifact).toContainText(/−\d+/, { timeout: 10_000 }) } // The mock LLM backend triggers a "Server unreachable" health-check toast plus diff --git a/packages/app/src/components/session/session-status-panel.tsx b/packages/app/src/components/session/session-status-panel.tsx index 7ea90e9d3..d18d923e6 100644 --- a/packages/app/src/components/session/session-status-panel.tsx +++ b/packages/app/src/components/session/session-status-panel.tsx @@ -1,7 +1,11 @@ -import { createMemo, type Accessor } from "solid-js" +import { createMemo, createResource, type Accessor } from "solid-js" import { useParams } from "@solidjs/router" +import { showToast } from "@opencode-ai/ui/toast" import type { Part } from "@opencode-ai/sdk/v2" import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { canOpenLocalPath, usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { normalizeArtifactPathKey, type FilesTabEntry } from "@/pages/session/files-tab-state" import { aggregateFiles } from "@/pages/session/session-aggregate-files" @@ -15,6 +19,9 @@ export function SessionStatusPanel(props: { const params = useParams() const globalSync = useGlobalSync() const sync = useSync() + const language = useLanguage() + const platform = usePlatform() + const server = useServer() const parts = createMemo(() => { if (!params.id) return [] @@ -61,6 +68,65 @@ export function SessionStatusPanel(props: { return map }) + // Stat artifact files so per-row open/reveal buttons can disable themselves + // when the file is gone (deleted between the agent writing it and the user + // clicking). Mirrors the old Files-tab stat resource. On web (no statPaths + // capability) every file is treated as existing — clicking still no-ops + // because canOpenLocalPath returns false there. + const artifactPaths = createMemo(() => (props.artifactFiles?.() ?? []).map((file) => file.path)) + const [artifactStats] = createResource(artifactPaths, async (paths) => { + if (paths.length === 0) return {} as Record + if (!platform.statPaths) { + return Object.fromEntries(paths.map((path) => [path, { size: 0, exists: true }])) + } + return platform.statPaths(paths) + }) + // While the resource is pending the row should still feel actionable, so + // assume the file exists until we know otherwise. This matches how the row + // first renders right after a turn — the file did exist when the agent wrote + // it, and the click is no-op-safe even if stat later reports missing. + const artifactExists = (path: string) => artifactStats()?.[path]?.exists ?? true + + const reportFailure = (error: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + } + + const canOpenWorktreeDirectory = (directory: string): boolean => + !!(canOpenLocalPath(platform) && server.isLocal() && platform.openPath && directory) + const openWorktreeDirectory = (directory: string) => { + if (!canOpenWorktreeDirectory(directory) || !platform.openPath) return + void platform.openPath(directory).catch(reportFailure) + } + + const canOpenArtifactFile = (path: string): boolean => + !!(canOpenLocalPath(platform) && server.isLocal() && platform.openPath && artifactExists(path)) + const openArtifactFile = (path: string) => { + if (!canOpenArtifactFile(path) || !platform.openPath) return + void platform.openPath(path).catch(reportFailure) + } + + const canRevealArtifactFile = (path: string): boolean => + !!(canOpenLocalPath(platform) && server.isLocal() && platform.showItemInFolder && artifactExists(path)) + const revealArtifactFile = (path: string) => { + if (!canRevealArtifactFile(path) || !platform.showItemInFolder) return + void platform.showItemInFolder(path).catch(reportFailure) + } + + // openLink is the only mandatory Platform method, so there is no capability + // gate. It may still throw synchronously (e.g. an invalid scheme) — catch + // and surface the same failure toast as the file/directory openers. + const openSourceLink = (url: string) => { + try { + platform.openLink(url) + } catch (error) { + reportFailure(error) + } + } + return (
) diff --git a/packages/app/src/components/session/session-status-summary-artifact.tsx b/packages/app/src/components/session/session-status-summary-artifact.tsx new file mode 100644 index 000000000..e467c8b5a --- /dev/null +++ b/packages/app/src/components/session/session-status-summary-artifact.tsx @@ -0,0 +1,106 @@ +import { For, Show, createMemo, type Accessor } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useLanguage } from "@/context/language" +import { normalizeArtifactPathKey, type FilesTabEntry } from "@/pages/session/files-tab-state" +import { Empty, Section } from "./session-status-summary-shell" + +// One row in the Changed files section. Rest state shows +N -N diff stats in +// mono-small; hover/focus fades them out and reveals open + reveal IconButtons +// (the turn-change trailing pattern from packages/ui/src/components/session-turn.css). +// +// Each action button derives its disabled state from a per-row capability flag +// supplied by the host. Capability false means: the platform shell can't open +// this kind of path (web), the local server isn't local, or the file no longer +// exists on disk. Disabled buttons still render so the trailing slot keeps its +// width and the user gets a hover affordance for the row. +function ArtifactRow(props: { + file: FilesTabEntry + diff?: { additions: number; deletions: number } + canOpen: boolean + canReveal: boolean + onOpen: () => void + onReveal: () => void +}) { + const language = useLanguage() + const filename = createMemo(() => { + const parts = props.file.path.replace(/\\/g, "/").split("/") + return parts[parts.length - 1] || props.file.path + }) + + return ( +
+ + + {filename()} + + + + {(diff) => ( + + +{diff().additions} + −{diff().deletions} + + )} + + + + + + + + + + +
+ ) +} + +export function ArtifactSection(props: { + files: Accessor + diffsByPath?: Accessor> + canOpenFile: (path: string) => boolean + canRevealFile: (path: string) => boolean + onOpenFile: (path: string) => void + onRevealFile: (path: string) => void +}) { + const language = useLanguage() + + return ( +
+ 0} fallback={}> +
+ + {(file) => ( + props.onOpenFile(file.path)} + onReveal={() => props.onRevealFile(file.path)} + /> + )} + +
+
+
+ ) +} diff --git a/packages/app/src/components/session/session-status-summary-git.tsx b/packages/app/src/components/session/session-status-summary-git.tsx new file mode 100644 index 000000000..4997e00aa --- /dev/null +++ b/packages/app/src/components/session/session-status-summary-git.tsx @@ -0,0 +1,142 @@ +import { Show, createMemo, type Accessor, type JSX } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import type { VcsInfo } from "@opencode-ai/sdk/v2" +import { useLanguage } from "@/context/language" +import { Section } from "./session-status-summary-shell" + +export interface ActiveWorktree { + name: string + branch?: string + directory?: string +} + +// A row in the Git section. Three shapes: +// - readonly (no onClick): renders as a non-interactive
, no hover bg, +// no focus ring — matches docs/DESIGN.md L445 "No hover state" for the +// branch row, and matches the old worktree badge's disabled state when +// the host can't open paths. +// - clickable: button with the standard row-hover overlay. +function GitRow(props: { + icon: string + onClick?: () => void + children: JSX.Element + chevron?: "down" | "right" | false + title?: string +}) { + const chevronIcon = () => (props.chevron === "down" ? "chevron-down" : "chevron-right") + const body = ( + <> + +
{props.children}
+ + + + + ) + + return ( + + {body} +
+ } + > + {(onClick) => ( + + )} +
+ ) +} + +export function GitSection(props: { + vcs: Accessor + activeWorktree: Accessor + diffStats: Accessor<{ additions: number; deletions: number }> + onNavigateReview: () => void + canOpenDirectory: (directory: string) => boolean + onOpenDirectory: (directory: string) => void +}) { + const language = useLanguage() + const hasChanges = createMemo(() => { + const stats = props.diffStats() + return stats.additions > 0 || stats.deletions > 0 + }) + + const na = () => language.t("status.summary.git.worktree.notAvailable") + + const worktreeTooltip = (worktree: ActiveWorktree) => ( +
+
+ {language.t("status.summary.git.worktree.label")} + {worktree.name || na()} +
+
+ {language.t("status.summary.git.branch.label")} + {worktree.branch || na()} +
+
+ {language.t("status.summary.git.location.label")} + {worktree.directory || na()} +
+
+ ) + + return ( +
+
+ + {language.t("status.summary.git.changes")}} + > + + +{props.diffStats().additions}{" "} + −{props.diffStats().deletions} + + + + + + {(branch) => ( + + {branch()} + + )} + + + + {(worktree) => { + const directory = () => worktree().directory + const canOpen = () => { + const dir = directory() + return !!dir && props.canOpenDirectory(dir) + } + const label = () => worktree().name || worktree().branch || language.t("status.summary.git.worktree.fallback") + const tooltipTitle = canOpen() ? language.t("status.summary.git.worktree.open") : undefined + return ( + + props.onOpenDirectory(directory()!) : undefined} + title={tooltipTitle} + > + {label()} + + + ) + }} + +
+
+ ) +} diff --git a/packages/app/src/components/session/session-status-summary-shell.tsx b/packages/app/src/components/session/session-status-summary-shell.tsx new file mode 100644 index 000000000..44559f467 --- /dev/null +++ b/packages/app/src/components/session/session-status-summary-shell.tsx @@ -0,0 +1,18 @@ +import type { JSX } from "solid-js" + +// Shared chrome for every Overview section. Kept in its own module so the +// per-section files (git, artifact, ...) can import it without taking a +// dependency on the top-level composition, which itself imports back from +// each section file. +export function Section(props: { title: string; children: JSX.Element }) { + return ( +
+
{props.title}
+ {props.children} +
+ ) +} + +export function Empty(props: { text: string }) { + return
{props.text}
+} diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index 100dc597a..733cba04f 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -1,30 +1,16 @@ -import { For, Show, createMemo, type Accessor, type JSX } from "solid-js" +import { For, Show, createMemo, type Accessor } from "solid-js" import { TodoStatusMarker } from "@opencode-ai/ui/todo-status-marker" import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" import type { Part, VcsInfo } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" -import { canOpenLocalPath, usePlatform } from "@/context/platform" -import { useServer } from "@/context/server" import { extractSources } from "@/pages/session/session-status-extractors" import { selectSessionTodoDataSnapshot } from "@/pages/session/session-todos" import type { SessionTodoItem } from "@/pages/session/todos/todo-model" import type { CanonicalTodoSnapshot } from "@/pages/session/todos/todo-source" -import { normalizeArtifactPathKey, type FilesTabEntry } from "@/pages/session/files-tab-state" - -function Section(props: { title: string; children: JSX.Element }) { - return ( -
-
{props.title}
- {props.children} -
- ) -} - -function Empty(props: { text: string }) { - return
{props.text}
-} +import type { FilesTabEntry } from "@/pages/session/files-tab-state" +import { Empty, Section } from "./session-status-summary-shell" +import { GitSection, type ActiveWorktree } from "./session-status-summary-git" +import { ArtifactSection } from "./session-status-summary-artifact" function TodoRow(props: { todo: SessionTodoItem }) { const isDone = () => props.todo.status === "completed" || props.todo.status === "cancelled" @@ -65,210 +51,10 @@ function SourceRow(props: { url: string; onOpen: (url: string) => void }) { ) } -interface ActiveWorktree { - name: string - branch?: string - directory?: string -} - -function GitRow(props: { - icon: string - onClick?: () => void - children: JSX.Element - chevron?: "down" | "right" | false - title?: string -}) { - return ( - - ) -} - -function GitSection(props: { - vcs: Accessor - activeWorktree: Accessor - diffStats: Accessor<{ additions: number; deletions: number }> - onNavigateReview: () => void - onOpenWorktreeDirectory: (directory: string) => void -}) { - const language = useLanguage() - const hasChanges = createMemo(() => { - const stats = props.diffStats() - return stats.additions > 0 || stats.deletions > 0 - }) - - const na = () => language.t("status.summary.git.worktree.notAvailable") - - const worktreeTooltip = (worktree: ActiveWorktree) => ( -
-
- {language.t("status.summary.git.worktree.label")} - {worktree.name || na()} -
-
- {language.t("status.summary.git.branch.label")} - {worktree.branch || na()} -
-
- {language.t("status.summary.git.location.label")} - {worktree.directory || na()} -
-
- ) - - return ( -
-
- - {language.t("status.summary.git.changes")}} - > - - +{props.diffStats().additions} - {" "} - −{props.diffStats().deletions} - - - - - - {(branch) => ( - - {branch()} - - )} - - - - {(worktree) => ( - - { - const directory = worktree().directory - if (directory) props.onOpenWorktreeDirectory(directory) - }} - title={language.t("status.summary.git.worktree.open")} - > - {worktree().name || worktree().branch || language.t("status.summary.git.worktree.fallback")} - - - )} - -
-
- ) -} - -function ArtifactRow(props: { - file: FilesTabEntry - diff?: { additions: number; deletions: number } - onOpen: () => void - onReveal: () => void -}) { - const language = useLanguage() - const filename = createMemo(() => { - const parts = props.file.path.replace(/\\/g, "/").split("/") - return parts[parts.length - 1] || props.file.path - }) - - return ( -
- - - {filename()} - - - - {(diff) => ( - - +{diff().additions} - −{diff().deletions} - - )} - - - - - - - - - - -
- ) -} - -function ArtifactSection(props: { - files: Accessor - diffsByPath?: Accessor> - onOpenFile: (path: string) => void - onRevealFile: (path: string) => void -}) { - const language = useLanguage() - - return ( -
- 0} - fallback={} - > -
- - {(file) => ( - props.onOpenFile(file.path)} - onReveal={() => props.onRevealFile(file.path)} - /> - )} - -
-
-
- ) -} - +// Pure composition. All platform-touching work (file stat, openPath / +// showItemInFolder / openLink wrappers, capability detection, failure toast) +// lives in SessionStatusPanel — this file only stitches the four sections and +// has no React-context dependency beyond useLanguage. export function SessionStatusSummary(props: { canonical?: Accessor isAuthoritativelyInvalidated?: Accessor @@ -279,11 +65,16 @@ export function SessionStatusSummary(props: { diffStats?: Accessor<{ additions: number; deletions: number }> artifactFiles?: Accessor diffsByPath?: Accessor> + canOpenWorktreeDirectory: (directory: string) => boolean + canOpenArtifactFile: (path: string) => boolean + canRevealArtifactFile: (path: string) => boolean onNavigateReview?: () => void + onOpenWorktreeDirectory: (directory: string) => void + onOpenArtifactFile: (path: string) => void + onRevealArtifactFile: (path: string) => void + onOpenSourceLink: (url: string) => void }) { const language = useLanguage() - const platform = usePlatform() - const server = useServer() const snapshot = createMemo(() => selectSessionTodoDataSnapshot({ @@ -300,29 +91,6 @@ export function SessionStatusSummary(props: { const isGitRepo = createMemo(() => !!props.vcs?.() || !!props.activeWorktree?.()) - const navigateToReview = () => { - props.onNavigateReview?.() - } - - const openWorktreeDirectory = (directory: string) => { - if (!canOpenLocalPath(platform) || !server.isLocal() || !platform.openPath) return - void platform.openPath(directory).catch(() => {}) - } - - const openFile = (path: string) => { - if (!canOpenLocalPath(platform) || !server.isLocal() || !platform.openPath) return - void platform.openPath(path).catch(() => {}) - } - - const revealFile = (path: string) => { - if (!canOpenLocalPath(platform) || !server.isLocal() || !platform.showItemInFolder) return - void platform.showItemInFolder(path).catch(() => {}) - } - - const openSource = (url: string) => { - platform.openLink(url) - } - return ( <> @@ -340,8 +108,9 @@ export function SessionStatusSummary(props: { vcs={props.vcs!} activeWorktree={() => props.activeWorktree?.()} diffStats={props.diffStats!} - onNavigateReview={navigateToReview} - onOpenWorktreeDirectory={openWorktreeDirectory} + onNavigateReview={() => props.onNavigateReview?.()} + canOpenDirectory={props.canOpenWorktreeDirectory} + onOpenDirectory={props.onOpenWorktreeDirectory} /> @@ -350,8 +119,10 @@ export function SessionStatusSummary(props: { )} @@ -359,7 +130,7 @@ export function SessionStatusSummary(props: {
0} fallback={}>
- {(url) => } + {(url) => }