Skip to content
14 changes: 9 additions & 5 deletions packages/app/e2e/session/session-w1-contracts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const TROW_RESULT_BODY = `${TROW_BLOCK} [data-slot="trow-result-body"]`
const TROW_INNER_TRIGGER = `${TROW_BLOCK} [data-slot="trow-body"] [data-component="tool-trigger"]`
const BASH_SCROLL = `${TROW_BLOCK} [data-slot="bash-scroll"]`
const THINKING = '[data-slot="session-turn-thinking"]'
const CONNECTING = '[data-slot="session-turn-thinking"][data-phase="connecting"]'
const USER_TEXT = '[data-component="user-message"] [data-slot="user-message-text"]'
const AGENT_PROSE = '[data-component="text-part"]'
const AGENT_REASONING = '[data-component="reasoning-body"]'
Expand Down Expand Up @@ -170,7 +171,7 @@ test("@smoke W1 rendered turn locks chevron, selectability, and trow typography"
})
})

test("@smoke W1 thinking indicator shows while the turn is working with nothing visible", async ({
test("@smoke W1 connecting indicator shows before first provider progress (nothing visible)", async ({
page,
project,
assistant,
Expand All @@ -181,7 +182,7 @@ test("@smoke W1 thinking indicator shows while the turn is working with nothing

// Submit by hand: project.prompt() waits for the session to go idle, which
// never happens while the reply hangs. Type + Enter and only wait for the
// thinking shimmer to surface.
// status shimmer to surface.
const text = "Hold the turn open with nothing rendered yet."
const prompt = page.locator(promptSelector).first()
await expect(prompt).toBeVisible()
Expand All @@ -191,9 +192,12 @@ test("@smoke W1 thinking indicator shows while the turn is working with nothing
await expect.poll(async () => (await prompt.textContent())?.replace(/\u200B/g, "").trim()).toBe(text)
await page.keyboard.press("Enter")

const thinking = page.locator(THINKING)
await expect(thinking).toBeVisible({ timeout: 30_000 })
await expect(thinking.locator('[data-component="text-shimmer"]')).toBeVisible()
// The reply hangs with no provider progress (#1358), so the status reads as
// "connecting", not "thinking" \u2014 the model hasn't started responding yet.
const connecting = page.locator(CONNECTING)
await expect(connecting).toBeVisible({ timeout: 30_000 })
await expect(connecting.locator('[data-component="text-shimmer"]')).toBeVisible()
await expect(page.locator('[data-slot="session-turn-thinking"][data-phase="thinking"]')).toHaveCount(0)

// Manual submit bypasses project.prompt(), so register the session the UI
// created. Otherwise teardown only drops the project directory and leaves the
Expand Down
186 changes: 186 additions & 0 deletions packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { render } from "solid-js/web"
import type { AssistantMessage, NoticePart, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
import { DataProvider, I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { dict as zh } from "@opencode-ai/ui/i18n/zh"
import { dict as en } from "@opencode-ai/ui/i18n/en"
import { AssistantParts } from "@opencode-ai/ui/message-part"
import type { UiI18nKey, UiI18nParams } from "@opencode-ai/ui/context/i18n"

// The #1358 terminal notice through the real pipeline (AssistantParts →
// tool.tsx card + notice.tsx), in the REAL cross-message topology: a
// side-effecting tool completes in one assistant message, and the trailing
// safe_retry_failed notice lands on the NEXT assistant message of the same turn
// (the post-tool continuation runs as a new message). The notice now carries the
// backend `sideEffect` flag, so the UI reads the field instead of scanning its
// own message. Three scenarios × two languages (中英对照).
const SESSION = "ses_recovery_presentation"

function assistant(id: string): AssistantMessage {
return {
id,
role: "assistant",
sessionID: SESSION,
parentID: "msg_recovery_user",
modelID: "test-model",
providerID: "test-provider",
mode: "build",
agent: "build",
path: { cwd: "/Users/yuhan/PawWork", root: "/Users/yuhan/PawWork" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 1 },
}
}

function text(messageID: string, body: string): TextPart {
return { id: `${messageID}_text`, sessionID: SESSION, messageID, type: "text", text: body, time: { start: 0, end: 1 } }
}

function bashTool(messageID: string): ToolPart {
return {
id: `${messageID}_bash`,
sessionID: SESSION,
messageID,
type: "tool",
callID: `${messageID}_call`,
tool: "bash",
state: {
status: "completed",
input: { command: 'gh issue comment 1358 --body "已按方案排期。"', description: "在 #1358 下留言" },
output: "https://github.com/Astro-Han/pawwork/issues/1358#issuecomment-3920481",
title: "在 #1358 下留言",
metadata: {},
time: { start: 0, end: 1 },
},
}
}

function grepTool(messageID: string): ToolPart {
return {
id: `${messageID}_grep`,
sessionID: SESSION,
messageID,
type: "tool",
callID: `${messageID}_call`,
tool: "grep",
state: {
status: "completed",
input: { pattern: "safe_retry_failed", include: "*.tsx" },
output: "packages/ui/src/components/message-part/parts/notice.tsx",
title: "搜索 safe_retry_failed",
metadata: {},
time: { start: 0, end: 1 },
},
}
}

// `sideEffect` is what the backend writes: true when a side-effecting tool
// completed earlier in the turn (bash here), false for read-only / no tool.
function notice(messageID: string, sideEffect: boolean): NoticePart {
return { id: `${messageID}_notice`, sessionID: SESSION, messageID, type: "notice", kind: "safe_retry_failed", sideEffect, time: { created: 1 } }
}

function makeI18n(dict: Record<string, string>) {
return {
locale: () => "x",
t: (key: UiI18nKey, params?: UiI18nParams) => {
const template = dict[key] ?? en[key] ?? String(key)
return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => String(params?.[String(rawKey)] ?? ""))
},
}
}

type MsgParts = { message: AssistantMessage; parts: (TextPart | ToolPart | NoticePart)[] }

// AssistantParts renders each message's parts in order, so a two-message turn
// shows the tool card (message A) above the notice (message B) — the real split.
function Turn(props: { messages: MsgParts[] }) {
const store = {
message: {},
part: Object.fromEntries(props.messages.map((m) => [m.message.id, m.parts])),
}
return (
<MarkedProvider>
<DataProvider data={store} directory="/Users/yuhan/PawWork">
<AssistantParts messages={props.messages.map((m) => m.message)} />
</DataProvider>
</MarkedProvider>
)
}

// Scenarios built fresh per band so each language's Turn gets an isolated store.
function sideEffectTurn(): MsgParts[] {
const a = assistant("msg_se_a")
const b = assistant("msg_se_b")
return [
{ message: a, parts: [text(a.id, "我帮你在 issue #1358 下留了一条评论。"), bashTool(a.id)] },
{ message: b, parts: [notice(b.id, true)] },
]
}
function readOnlyTurn(): MsgParts[] {
const a = assistant("msg_ro_a")
const b = assistant("msg_ro_b")
return [
{ message: a, parts: [text(a.id, "我先在代码里查了下相关实现。"), grepTool(a.id)] },
{ message: b, parts: [notice(b.id, false)] },
]
}
function noToolTurn(): MsgParts[] {
const b = assistant("msg_nt_b")
return [{ message: b, parts: [notice(b.id, false)] }]
}

function Band(props: { dict: Record<string, string>; label: string }) {
return (
<I18nProvider value={makeI18n(props.dict)}>
<div data-lang={props.label} style={{ display: "flex", "flex-direction": "column", gap: "10px" }}>
<div style={{ "font-size": "12px", "font-weight": "600", color: "var(--fg-weak)", "letter-spacing": "0.04em" }}>
{props.label}
</div>
<div style={{ display: "grid", "grid-template-columns": "repeat(3, 360px)", gap: "24px", "align-items": "start" }}>
<div data-snap="side-effect">
<Turn messages={sideEffectTurn()} />
</div>
<div data-snap="read-only">
<Turn messages={readOnlyTurn()} />
</div>
<div data-snap="default">
<Turn messages={noToolTurn()} />
</div>
</div>
</div>
</I18nProvider>
)
}

function RecoveryPresentationSnapFixture() {
return (
<DialogProvider>
{/* Opaque full-viewport cover at max z-index so the app's dev chrome
(debug bar, server-health toast) renders behind the captured grid. */}
<div
style={{
position: "fixed",
inset: "0",
"z-index": "2147483647",
overflow: "auto",
display: "flex",
"flex-direction": "column",
gap: "28px",
padding: "24px",
background: "var(--bg-base)",
color: "var(--fg-base)",
}}
>
<Band dict={zh} label="中文" />
<Band dict={en} label="English" />
</div>
</DialogProvider>
)
}

export function mountRecoveryPresentationSnapFixture(root: HTMLElement) {
render(() => <RecoveryPresentationSnapFixture />, root)
}
92 changes: 92 additions & 0 deletions packages/app/e2e/snap/fixtures/turn-status-phase-snap-fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { render } from "solid-js/web"
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { SessionRetry } from "@opencode-ai/ui/session-retry"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { dict as zh } from "@opencode-ai/ui/i18n/zh"
import type { UiI18nKey, UiI18nParams } from "@opencode-ai/ui/context/i18n"

// The #1358 turn-status split, rendered through the real components. Before the
// provider sends its first chunk the wait reads as "connecting", not "thinking";
// safe recovery names the retry attempt. SessionRetry is the production recovery
// row; TextShimmer is the production status shimmer — the same markup the turn
// uses (`session-turn-thinking` + `data-phase`).
const i18n = {
locale: () => "zh",
t: (key: UiI18nKey, params?: UiI18nParams) => {
const template = zh[key] ?? String(key)
return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => String(params?.[String(rawKey)] ?? ""))
},
}

const recoveryStatus: SessionStatus = {
type: "retry",
attempt: 2,
message: "",
next: 0,
presentation: "safe_recovery",
}

// The visible token values of `[data-slot="session-turn-thinking"]` in
// session-turn.css. Applied inline because that rule is scoped under a
// full-height `[data-component="session-turn"]` flex container that would fight
// an isolated snap tile; the shimmer itself is the real TextShimmer.
const thinkingRow = {
display: "flex",
"align-items": "center",
gap: "8px",
color: "var(--fg-weak)",
"font-family": "var(--font-family-sans)",
"font-size": "var(--font-size-body)",
"font-weight": "var(--font-weight-emphasis)",
"line-height": "20px",
}

function StatusRow(props: { phase: "connecting" | "thinking"; labelKey: UiI18nKey }) {
return (
<div data-slot="session-turn-thinking" data-phase={props.phase} style={thinkingRow}>
<TextShimmer text={i18n.t(props.labelKey)} />
</div>
)
}

function TurnStatusPhaseSnapFixture() {
return (
<I18nProvider value={i18n}>
<DialogProvider>
{/* Opaque full-viewport cover at max z-index so the app's dev chrome
(debug bar, server-health toast) renders behind the captured grid. */}
<div
style={{
position: "fixed",
inset: "0",
"z-index": "2147483647",
overflow: "auto",
display: "grid",
"grid-template-columns": "repeat(3, 280px)",
"align-content": "start",
gap: "24px",
padding: "24px",
background: "var(--bg-base)",
color: "var(--fg-base)",
}}
>
<div data-snap="connecting">
<StatusRow phase="connecting" labelKey="ui.sessionTurn.status.connecting" />
</div>
<div data-snap="thinking">
<StatusRow phase="thinking" labelKey="ui.sessionTurn.status.thinking" />
</div>
<div data-snap="recovery">
<SessionRetry status={recoveryStatus} show />
</div>
</div>
</DialogProvider>
</I18nProvider>
)
}

export function mountTurnStatusPhaseSnapFixture(root: HTMLElement) {
render(() => <TurnStatusPhaseSnapFixture />, root)
}
69 changes: 69 additions & 0 deletions packages/app/e2e/snap/recovery-presentation.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect, type Locator, type Page } from "@playwright/test"
import { fileURLToPath } from "node:url"
import { test } from "../fixtures"
import { composeGrid, snapOutputPath, type Shot } from "./_compose"

test.use({ viewport: { width: 1200, height: 760 }, deviceScaleFactor: 2 })

const fixturePath = fileURLToPath(new URL("./fixtures/recovery-presentation-snap-fixture.tsx", import.meta.url))

async function waitForThemeBoot(page: Page): Promise<void> {
await page.waitForFunction(
() => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0,
null,
{ timeout: 30_000 },
)
}

async function capture(name: string, block: Locator): Promise<Shot> {
await expect(block).toBeVisible({ timeout: 30_000 })
return { name, buf: await block.screenshot() }
}

test("recovery-presentation", async ({ page }) => {
test.setTimeout(180_000)

await page.goto("/")
await waitForThemeBoot(page)
await page.evaluate(async (path) => {
const mod = await import(path)
// Wipe the booted app shell so its dev chrome can't bleed into the capture.
document.body.replaceChildren()
const root = document.createElement("div")
document.body.appendChild(root)
mod.mountRecoveryPresentationSnapFixture(root)
}, `/@fs/${fixturePath}`)

const zh = page.locator('[data-lang="中文"]')
const en = page.locator('[data-lang="English"]')

// Side-effect, REAL cross-message topology: the bash card lives on message A,
// the notice on message B; the backend `sideEffect` flag still drives the
// reassuring copy that names "no redo".
const zhSide = zh.locator('[data-snap="side-effect"]')
await expect(zhSide).toContainText("在 #1358 下留言", { timeout: 30_000 })
await expect(zhSide.locator('[data-kind="safe_retry_failed"][data-variant="side-effect"]')).toBeVisible()
await expect(zhSide).toContainText("操作已完成")
await expect(zhSide).toContainText("无需重复")

// Read-only turn: a grep ran, but it carries no side effect, so the backend
// sets sideEffect=false and the notice falls back to the default copy.
const zhRead = zh.locator('[data-snap="read-only"]')
await expect(zhRead.locator('[data-kind="safe_retry_failed"][data-variant="default"]')).toBeVisible()
await expect(zhRead).toContainText("回复未完成")
await expect(zhRead).not.toContainText("操作已完成")

// No-tool turn: default copy.
await expect(zh.locator('[data-snap="default"] [data-variant="default"]')).toBeVisible()

// English mirrors the same three scenarios.
const enSide = en.locator('[data-snap="side-effect"]')
await expect(enSide.locator('[data-variant="side-effect"]')).toBeVisible({ timeout: 30_000 })
await expect(enSide).toContainText("Action completed")
await expect(en.locator('[data-snap="read-only"] [data-variant="default"]')).toBeVisible()
await expect(en.locator('[data-snap="read-only"]')).toContainText("Reply incomplete")

const out = snapOutputPath("recovery-presentation")
await composeGrid([await capture("中文", zh), await capture("English", en)], out)
process.stdout.write(`\n[snap] recovery-presentation grid -> ${out}\n\n`)
})
Loading
Loading