Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
566816f
feat(app): remove Files tab and register changes icon
Astro-Han May 31, 2026
7362d5e
feat(app): redesign Status panel with Git and Artifact sections
Astro-Han May 31, 2026
c598169
test(app): update tests for Files tab removal
Astro-Han May 31, 2026
92d9855
fix(app): address Status panel visual review feedback
Astro-Han May 31, 2026
defd761
fix(app): align artifact row with turn-change-row design system
Astro-Han May 31, 2026
2db714f
fix(i18n): rename status panel sections to Workspace / Changed files
Astro-Han May 31, 2026
19e4f1e
fix(app): add rounded corners to artifact row hover
Astro-Han May 31, 2026
9b0046b
fix(app): unify status panel row hover to rounded-md + surface-raised
Astro-Han May 31, 2026
eb67836
fix(app): use rounded-md on sidebar session row for visual consistency
Astro-Han May 31, 2026
d4ce483
fix(app): unify status panel row heights to min-h-[30px]
Astro-Han May 31, 2026
6459670
fix(app): revert TodoRow/SourceRow to compact py-1 height
Astro-Han May 31, 2026
c5f4209
fix(app): align TodoRow/SourceRow to 26px compact row height
Astro-Han May 31, 2026
feee004
fix(app): fix branch not updating by removing createMemo wrapper on vcs
Astro-Han May 31, 2026
6d9a2f4
fix(app): use worktree branch as primary source for branch row
Astro-Han May 31, 2026
990288c
fix(app): actively refresh vcs on file watcher HEAD changes
Astro-Han May 31, 2026
07027af
revert: undo 3 branch-display fix attempts
Astro-Han May 31, 2026
b75c0fc
fix(app): address review bot findings for status panel
Astro-Han May 31, 2026
298c70b
fix(app): align status panel rows with design spec (#1013 follow-up)
Astro-Han May 31, 2026
213ebca
test(app): cover full status panel in a new snap target
Astro-Han May 31, 2026
43ef84e
fix(app): normalise artifact path key for diff stats lookup
Astro-Han May 31, 2026
a16b1e6
refactor(app): split status summary sections, restore artifact open c…
Astro-Han May 31, 2026
144531e
Merge remote-tracking branch 'origin/dev' into claude/status-panel-re…
Astro-Han Jun 3, 2026
a8e8842
Merge remote-tracking branch 'origin/dev' into claude/status-panel-re…
Astro-Han Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 266 additions & 0 deletions packages/app/e2e/snap/status-summary-panel.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
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<typeof createSdk>
type LLM = Parameters<typeof test>[0]["llm"]

test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 })

const SEED_TODOS: Array<Pick<Todo, "content" | "status" | "priority">> = [
{ 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<ReturnType<Sdk["session"]["messages"]>>["data"][number]
| undefined
let textPart: Extract<
Awaited<ReturnType<Sdk["session"]["messages"]>>["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 },
},
},
})
}

// 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<Page["locator"]>) {
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
// 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`)
})
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -55,7 +54,6 @@ describe("buildCommandPaletteDefaultGroups", () => {
"panel.toggle",
"terminal.toggle",
"review.toggle",
"fileTree.toggle",
"model.choose",
"mcp.toggle",
"permissions.autoaccept",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
]

Expand Down
24 changes: 0 additions & 24 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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()) {
Expand All @@ -77,10 +69,6 @@ export function SessionHeader() {
})
}
const openProjectDirectory = () => openDirectory(projectDirectory())
const openActiveWorktree = () => {
openDirectory(activeWorktreeDirectory())
}

const [leftMount, setLeftMount] = createSignal<HTMLElement>()
const [rightMount, setRightMount] = createSignal<HTMLElement>()

Expand Down Expand Up @@ -124,18 +112,6 @@ export function SessionHeader() {
<span class="min-w-0 truncate">{name()}</span>
</Button>
</Show>
<Show when={activeWorktree()}>
{(worktree) => (
<PawworkWorktreeBadge
name={worktree().name}
branch={worktree().branch}
directory={worktree().directory}
onClick={openActiveWorktree}
ariaLabel={language.t("session.header.worktree.open")}
disabled={!canOpenActiveWorktreeDirectory()}
/>
)}
</Show>
</div>
</Show>
</div>
Expand Down
Loading
Loading