diff --git a/packages/app/e2e/session/session-w1-contracts.spec.ts b/packages/app/e2e/session/session-w1-contracts.spec.ts index 9227a9177..bb49ab538 100644 --- a/packages/app/e2e/session/session-w1-contracts.spec.ts +++ b/packages/app/e2e/session/session-w1-contracts.spec.ts @@ -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"]' @@ -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, @@ -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() @@ -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 diff --git a/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx b/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx new file mode 100644 index 000000000..e23d0c01f --- /dev/null +++ b/packages/app/e2e/snap/fixtures/recovery-presentation-snap-fixture.tsx @@ -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) { + 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 ( + + + m.message)} /> + + + ) +} + +// 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; label: string }) { + return ( + +
+
+ {props.label} +
+
+
+ +
+
+ +
+
+ +
+
+
+
+ ) +} + +function RecoveryPresentationSnapFixture() { + return ( + + {/* Opaque full-viewport cover at max z-index so the app's dev chrome + (debug bar, server-health toast) renders behind the captured grid. */} +
+ + +
+
+ ) +} + +export function mountRecoveryPresentationSnapFixture(root: HTMLElement) { + render(() => , root) +} diff --git a/packages/app/e2e/snap/fixtures/turn-status-phase-snap-fixture.tsx b/packages/app/e2e/snap/fixtures/turn-status-phase-snap-fixture.tsx new file mode 100644 index 000000000..71499b16c --- /dev/null +++ b/packages/app/e2e/snap/fixtures/turn-status-phase-snap-fixture.tsx @@ -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 ( +
+ +
+ ) +} + +function TurnStatusPhaseSnapFixture() { + return ( + + + {/* Opaque full-viewport cover at max z-index so the app's dev chrome + (debug bar, server-health toast) renders behind the captured grid. */} +
+
+ +
+
+ +
+
+ +
+
+
+
+ ) +} + +export function mountTurnStatusPhaseSnapFixture(root: HTMLElement) { + render(() => , root) +} diff --git a/packages/app/e2e/snap/recovery-presentation.snap.ts b/packages/app/e2e/snap/recovery-presentation.snap.ts new file mode 100644 index 000000000..3509d69d0 --- /dev/null +++ b/packages/app/e2e/snap/recovery-presentation.snap.ts @@ -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 { + await page.waitForFunction( + () => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0, + null, + { timeout: 30_000 }, + ) +} + +async function capture(name: string, block: Locator): Promise { + 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`) +}) diff --git a/packages/app/e2e/snap/turn-status-phase.snap.ts b/packages/app/e2e/snap/turn-status-phase.snap.ts new file mode 100644 index 000000000..8fc4b3d50 --- /dev/null +++ b/packages/app/e2e/snap/turn-status-phase.snap.ts @@ -0,0 +1,61 @@ +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: 920, height: 200 }, deviceScaleFactor: 2 }) + +const fixturePath = fileURLToPath(new URL("./fixtures/turn-status-phase-snap-fixture.tsx", import.meta.url)) + +async function waitForThemeBoot(page: Page): Promise { + await page.waitForFunction( + () => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0, + null, + { timeout: 30_000 }, + ) +} + +async function capture(name: string, block: Locator): Promise { + await expect(block).toBeVisible({ timeout: 30_000 }) + return { name, buf: await block.screenshot() } +} + +test("turn-status-phase", 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.mountTurnStatusPhaseSnapFixture(root) + }, `/@fs/${fixturePath}`) + + const connecting = page.locator('[data-snap="connecting"]') + const thinking = page.locator('[data-snap="thinking"]') + const recovery = page.locator('[data-snap="recovery"]') + + // Before first provider progress: connecting, not thinking. + await expect(connecting.locator('[data-phase="connecting"]')).toBeVisible({ timeout: 30_000 }) + await expect(connecting).toContainText("连接中") + // After provider progress: thinking. + await expect(thinking.locator('[data-phase="thinking"]')).toBeVisible() + await expect(thinking).toContainText("思考中") + // Safe recovery names the attempt. + await expect(recovery.locator('[data-slot="session-turn-safe-retry"]')).toBeVisible() + await expect(recovery).toContainText("正在恢复…第 2 次") + + const out = snapOutputPath("turn-status-phase") + await composeGrid( + [ + await capture("before first provider progress", connecting), + await capture("provider responding", thinking), + await capture("safe recovery, attempt 2", recovery), + ], + out, + ) + process.stdout.write(`\n[snap] turn-status-phase grid -> ${out}\n\n`) +}) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ffc89124c..ec45707ff 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -259,6 +259,12 @@ export type ReasoningPart = z.infer export const NoticePart = PartBase.extend({ type: z.literal("notice"), kind: z.literal("safe_retry_failed"), + // True when a side-effecting tool already completed earlier in this turn — + // possibly on a sibling assistant message, since the post-tool continuation + // runs as a new message (#1358). The backend is the single source of truth so + // the UI need not (and cannot reliably) scan sibling messages or reclassify + // tools. Drives the "Action completed — don't repeat it" notice copy. + sideEffect: z.boolean().optional(), time: z.object({ created: z.number(), }), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 9f0999e34..94f9521f7 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -28,6 +28,7 @@ import { TurnChange } from "./turn-change" import { LLMTrace } from "./llm-trace" import type { RunIncident } from "./run-incident" import { RunObservability } from "./run-observability" +import { turnHasCompletedSideEffect } from "./safe-retry-notice" import { currentLifecycleCloseAction, isLifecycleClosing, @@ -1764,12 +1765,18 @@ export const layer: Layer.Layer< ) { ctx.streamError = true yield* removeReasoningForAttempt(attemptID) + // Scan the whole turn (not just this message): the side-effecting tool + // ran in an earlier step's assistant message, while this notice lands + // on the failed continuation's message (#1358). + const parentID = ctx.assistantMessage.parentID + const sideEffect = parentID ? turnHasCompletedSideEffect(turnMessages(parentID), parentID) : false yield* session.updatePart({ id: PartID.ascending(), sessionID: ctx.sessionID, messageID: ctx.assistantMessage.id, type: "notice", kind: "safe_retry_failed", + sideEffect, time: { created: Date.now() }, } satisfies MessageV2.NoticePart) yield* status.set(ctx.sessionID, { type: "idle" }) diff --git a/packages/opencode/src/session/safe-retry-notice.test.ts b/packages/opencode/src/session/safe-retry-notice.test.ts new file mode 100644 index 000000000..08a2e5631 --- /dev/null +++ b/packages/opencode/src/session/safe-retry-notice.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test" +import type { MessageV2 } from "./message-v2" +import { turnHasCompletedSideEffect } from "./safe-retry-notice" + +const PARENT = "msg_user_turn" as unknown as NonNullable + +// Minimal structural fixtures: the helper only reads info.role, info.parentID, +// part.type, part.state.status, and part.tool. +function assistant(parentID: string, parts: unknown[]): MessageV2.WithParts { + return { info: { role: "assistant", parentID }, parts } as unknown as MessageV2.WithParts +} +function tool(name: string, status: string) { + return { type: "tool", tool: name, state: { status } } +} +function notice() { + return { type: "notice", kind: "safe_retry_failed" } +} + +test("side-effecting tool completed on a sibling message of the turn → true", () => { + // #1358 real topology: bash completed in message A; the notice lands on B. + const messages = [assistant(PARENT, [tool("bash", "completed")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(true) +}) + +test("only a completed read-only tool → false (no side effect to claim)", () => { + const messages = [assistant(PARENT, [tool("grep", "completed")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(false) +}) + +test("no tool, only the notice → false", () => { + expect(turnHasCompletedSideEffect([assistant(PARENT, [notice()])], PARENT)).toBe(false) +}) + +test("side-effecting tool still running (not completed) → false", () => { + const messages = [assistant(PARENT, [tool("bash", "running")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(false) +}) + +test("unknown/custom tool counts as side-effecting (errs toward reassurance)", () => { + const messages = [assistant(PARENT, [tool("deploy_thing", "completed")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(true) +}) + +test("a completed side-effecting tool from a different turn is ignored", () => { + const messages = [assistant("other_turn", [tool("bash", "completed")]), assistant(PARENT, [notice()])] + expect(turnHasCompletedSideEffect(messages, PARENT)).toBe(false) +}) diff --git a/packages/opencode/src/session/safe-retry-notice.ts b/packages/opencode/src/session/safe-retry-notice.ts new file mode 100644 index 000000000..0f85e6701 --- /dev/null +++ b/packages/opencode/src/session/safe-retry-notice.ts @@ -0,0 +1,35 @@ +import type { MessageV2 } from "./message-v2" +import { RunObservability } from "./run-observability" + +/** + * True when a side-effecting tool completed anywhere in the turn — possibly on a + * sibling assistant message. + * + * #1358: the post-tool model continuation runs as a NEW assistant message (the + * turn loop in prompt.ts creates one message per step), so a completed + * side-effecting tool and the trailing `safe_retry_failed` notice land on + * DIFFERENT messages of the same turn. The notice's own message therefore can't + * be scanned for the tool — the whole turn must be. Only the backend can do this + * reliably: the UI sees one part at a time and must not reclassify tools. + * + * "Side-effecting" is the backend's own classification (`toolEffect().unsafe`): + * bash / apply_patch / unknown count; read-only tools (read/glob/grep/webfetch/ + * tool_info) do not. Unknown errs toward side-effecting so a real side effect is + * never under-warned. Drives the "Action completed — don't repeat it" copy. + */ +export function turnHasCompletedSideEffect( + messages: readonly MessageV2.WithParts[], + parentID: NonNullable, +): boolean { + return messages.some( + (message) => + message.info.role === "assistant" && + message.info.parentID === parentID && + message.parts.some( + (part) => + part.type === "tool" && + part.state.status === "completed" && + RunObservability.toolEffect(part.tool).unsafe, + ), + ) +} diff --git a/packages/opencode/test/config/e2e-smoke-tagging.test.ts b/packages/opencode/test/config/e2e-smoke-tagging.test.ts index f6b2315f0..23c55b0b9 100644 --- a/packages/opencode/test/config/e2e-smoke-tagging.test.ts +++ b/packages/opencode/test/config/e2e-smoke-tagging.test.ts @@ -27,8 +27,8 @@ const expectedSmokeTests = [ "packages/app/e2e/prompt/first-message-reply.spec.ts:@smoke first replied message in a new session renders without page errors", "packages/app/e2e/prompt/prompt.spec.ts:@smoke can send a prompt and receive a reply", "packages/app/e2e/release-notes/release-notes-toast.spec.ts:@smoke shows subtle toast when stored version is older than current", + "packages/app/e2e/session/session-w1-contracts.spec.ts:@smoke W1 connecting indicator shows before first provider progress (nothing visible)", "packages/app/e2e/session/session-w1-contracts.spec.ts:@smoke W1 rendered turn locks chevron, selectability, and trow typography", - "packages/app/e2e/session/session-w1-contracts.spec.ts:@smoke W1 thinking indicator shows while the turn is working with nothing visible", "packages/app/e2e/settings/settings-memory.spec.ts:@smoke memory settings exposes the raw MEMORY.md controls", "packages/app/e2e/settings/settings-shell.spec.ts:@smoke back-to-app button closes the settings shell", "packages/app/e2e/settings/settings-shell.spec.ts:@smoke escape closes the settings shell", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6197cc57d..668b5a125 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1018,6 +1018,7 @@ export type NoticePart = { messageID: string type: "notice" kind: "safe_retry_failed" + sideEffect?: boolean time: { created: number } diff --git a/packages/ui/src/components/message-part/parts/notice-render.test.tsx b/packages/ui/src/components/message-part/parts/notice-render.test.tsx new file mode 100644 index 000000000..5dda1acf2 --- /dev/null +++ b/packages/ui/src/components/message-part/parts/notice-render.test.tsx @@ -0,0 +1,105 @@ +import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test" +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { createRequire } from "module" +import { createServer, type ViteDevServer } from "vite" +import solidPlugin from "vite-plugin-solid" + +// Real render of the notice part (#1358): the side-effect/default copy must be +// driven by the backend `sideEffect` field alone. The notice carries no tool +// part and no DataProvider, so a correct copy proves the UI never needs to scan +// or classify tools — replacing the earlier brittle source-string assertions. + +const require = createRequire(import.meta.url) +const solidWeb = require.resolve("solid-js/web/dist/web.js") +const solidCore = require.resolve("solid-js/dist/solid.js") + +let server: ViteDevServer | undefined +let registeredDom = false + +beforeAll(async () => { + if (typeof document === "undefined" || typeof window === "undefined") { + GlobalRegistrator.register() + registeredDom = true + } + + server = await createServer({ + root: new URL("../../../..", import.meta.url).pathname, + configFile: false, + plugins: [solidPlugin({ solid: { generate: "dom" } })], + resolve: { + alias: { + "solid-js/web": solidWeb, + "solid-js": solidCore, + }, + }, + server: { middlewareMode: true }, + appType: "custom", + logLevel: "silent", + ssr: { noExternal: ["@kobalte/core", "solid-js"] }, + }) +}) + +beforeEach(() => { + document.body.textContent = "" +}) + +afterAll(async () => { + await server?.close() + if (registeredDom) GlobalRegistrator.unregister() +}) + +async function loadFixture(): Promise { + if (!server) throw new Error("Vite server not initialized") + return (await server.ssrLoadModule( + "/test/fixtures/notice-render.fixture.tsx", + )) as typeof import("../../../../test/fixtures/notice-render.fixture") +} + +test("sideEffect=true renders the reassuring 'action completed' copy, with no tool in sight", async () => { + const { mountNotice } = await loadFixture() + const view = mountNotice(true) + + expect(view.variant()).toBe("side-effect") + expect(view.title()).toBe("Action completed") + expect(view.body()).toContain("no need to repeat it") + // The copy was chosen from the field, not from any rendered/scanned tool. + expect(view.toolCard()).toBeNull() + + view.dispose() +}) + +test("sideEffect=false falls back to the default 'reply incomplete' copy", async () => { + const { mountNotice } = await loadFixture() + const view = mountNotice(false) + + expect(view.variant()).toBe("default") + expect(view.title()).toBe("Reply incomplete") + expect(view.body()).toContain("couldn't be generated") + + view.dispose() +}) + +test("a missing sideEffect field (older notices) is treated as the safe default", async () => { + const { mountNotice } = await loadFixture() + const view = mountNotice(undefined) + + expect(view.variant()).toBe("default") + expect(view.title()).toBe("Reply incomplete") + + view.dispose() +}) + +test("the same field drives localized copy (zh)", async () => { + const { mountNotice, dicts } = await loadFixture() + + const side = mountNotice(true, dicts.zh) + expect(side.variant()).toBe("side-effect") + expect(side.title()).toBe("操作已完成") + expect(side.body()).toContain("无需重复") + side.dispose() + + const plain = mountNotice(false, dicts.zh) + expect(plain.variant()).toBe("default") + expect(plain.title()).toBe("回复未完成") + plain.dispose() +}) diff --git a/packages/ui/src/components/message-part/parts/notice.css b/packages/ui/src/components/message-part/parts/notice.css index b5af8bd36..a889660ab 100644 --- a/packages/ui/src/components/message-part/parts/notice.css +++ b/packages/ui/src/components/message-part/parts/notice.css @@ -13,3 +13,40 @@ padding-top: 12px; border-top: 1px solid var(--border-weaker); } + +/* + * safe_retry_failed reads as its own terminal step, separate from the completed + * tool card above it (#1358): a stroked status icon in ink (no semantic color + * flood — the operation usually succeeded, so it must not read as an error), + * an ink title, then the explanation. Typography sets size/weight/line-height + * only, never font-family, so the CJK stack is inherited. + */ +[data-component="notice-part"][data-kind="safe_retry_failed"] { + display: flex; + gap: 9px; + align-items: flex-start; +} + +[data-component="notice-part"] [data-slot="notice-icon"] { + flex: 0 0 auto; + margin-top: 1px; + color: var(--fg-weak); +} + +[data-component="notice-part"] [data-slot="notice-text"] { + min-width: 0; +} + +[data-component="notice-part"] [data-slot="notice-title"] { + font-size: var(--font-size-body); + font-weight: var(--font-weight-emphasis); + line-height: var(--line-height-h3); + color: var(--fg-strong); +} + +[data-component="notice-part"] [data-slot="notice-body"] { + margin-top: 2px; + font-size: var(--font-size-body); + line-height: var(--line-height-h3); + color: var(--fg-base); +} diff --git a/packages/ui/src/components/message-part/parts/notice.tsx b/packages/ui/src/components/message-part/parts/notice.tsx index e8ba0e0f7..091a816fe 100644 --- a/packages/ui/src/components/message-part/parts/notice.tsx +++ b/packages/ui/src/components/message-part/parts/notice.tsx @@ -1,6 +1,7 @@ import { Match, Switch } from "solid-js" import type { NoticePart } from "@opencode-ai/sdk/v2" import { useI18n } from "../../../context/i18n" +import { Icon } from "../../icon" import { registerPartComponent } from "../registry" import "./notice.css" @@ -8,11 +9,35 @@ registerPartComponent("notice", function NoticePartDisplay(props) { const i18n = useI18n() const part = () => props.part as NoticePart + // `sideEffect` is set by the backend when a side-effecting tool already + // completed earlier in this turn — possibly on a sibling assistant message, + // since the post-tool continuation runs as a new message (#1358). The UI can't + // see sibling messages and must not reclassify tools, so it trusts the field: + // true → reassure the action landed and not to redo it; false → the reply + // simply never started. return ( -
- {i18n.t("ui.sessionTurn.notice.safeRetryFailed")} +
+ + + +
+
+ {part().sideEffect + ? i18n.t("ui.sessionTurn.notice.safeRetryFailed.sideEffect.title") + : i18n.t("ui.sessionTurn.notice.safeRetryFailed.default.title")} +
+
+ {part().sideEffect + ? i18n.t("ui.sessionTurn.notice.safeRetryFailed.sideEffect.body") + : i18n.t("ui.sessionTurn.notice.safeRetryFailed.default.body")} +
+
diff --git a/packages/ui/src/components/session-retry.tsx b/packages/ui/src/components/session-retry.tsx index 4cd97ba92..f132bf79e 100644 --- a/packages/ui/src/components/session-retry.tsx +++ b/packages/ui/src/components/session-retry.tsx @@ -113,7 +113,9 @@ export function SessionRetry(props: {
- {i18n.t("ui.sessionTurn.retry.recovery")} + {current().attempt > 0 + ? i18n.t("ui.sessionTurn.retry.recoveryAttempt", { attempt: current().attempt }) + : i18n.t("ui.sessionTurn.retry.recovery")}
)} diff --git a/packages/ui/src/components/session-safe-retry-contract.test.ts b/packages/ui/src/components/session-safe-retry-contract.test.ts index ad1bd9095..c2f0ce38d 100644 --- a/packages/ui/src/components/session-safe-retry-contract.test.ts +++ b/packages/ui/src/components/session-safe-retry-contract.test.ts @@ -2,7 +2,6 @@ import { expect, test } from "bun:test" import { readdirSync, readFileSync } from "node:fs" const retry = readFileSync(new URL("./session-retry.tsx", import.meta.url), "utf8") -const notice = readFileSync(new URL("./message-part/parts/notice.tsx", import.meta.url), "utf8") const en = readFileSync(new URL("../i18n/en.ts", import.meta.url), "utf8") const zh = readFileSync(new URL("../i18n/zh.ts", import.meta.url), "utf8") const zht = readFileSync(new URL("../i18n/zht.ts", import.meta.url), "utf8") @@ -22,28 +21,48 @@ test("recovery retry uses a lightweight status row instead of the error card", ( expect(retry).toContain('') }) -test("safe retry failure renders as a dedicated notice part", () => { - expect(notice).toContain('registerPartComponent("notice"') - expect(notice).toContain('part().kind === "safe_retry_failed"') - expect(notice).toContain('data-kind="safe_retry_failed"') - expect(notice).toContain('i18n.t("ui.sessionTurn.notice.safeRetryFailed")') -}) +// The notice's rendering behavior — sideEffect=true → "操作已完成", false/undefined +// → "回复未完成", driven by the backend field with no tool scan — is proven by the +// real render in notice-render.test.tsx (#1358), so it is not re-grepped here. + +test("safe-retry notice copy names an external cause and a next step, without nudging a redo", () => { + // Side-effect case reassures the action already ran AND tells the user not to + // repeat it — it points at regenerating the reply, never a plain "retry" that + // could redo the external action (#1358). Default case makes no completion + // claim, so a plain retry stays safe. Both attribute the failure outside PawWork. + expect(en).toContain('"ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "Action completed"') + expect(en).toContain('"ui.sessionTurn.notice.safeRetryFailed.default.title": "Reply incomplete"') + expect(en).toMatch(/already went through[\s\S]*no need to repeat[\s\S]*regenerate the reply[\s\S]*switch models/) -test("recovery copy stays short and non-technical in English and Chinese", () => { - expect(en).toContain('"ui.sessionTurn.retry.recovery": "Recovering..."') - expect(en).toContain( - '"ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models."', + expect(zh).toContain('"ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成"') + expect(zh).toContain('"ui.sessionTurn.notice.safeRetryFailed.default.title": "回复未完成"') + // Side-effect body: no redo nudge — "无需重复" + regenerate the reply, never "重试". + expect(zh).toContain( + "上一项操作已执行,无需重复。当前网络或模型服务商连接异常,可稍后重新生成回复,或更换模型。", + ) + // Default body still offers a safe retry (nothing landed). + expect(zh).toMatch(/模型回复未能生成[\s\S]*请稍后重试/) + + expect(zht).toContain('"ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成"') + expect(zht).toContain('"ui.sessionTurn.notice.safeRetryFailed.default.title": "回覆未完成"') + expect(zht).toContain( + "上一項操作已執行,無需重複。目前網路或模型服務商連線異常,可稍後重新生成回覆,或更換模型。", ) - expect(zh).toContain('"ui.sessionTurn.retry.recovery": "正在恢复…"') - expect(zh).toContain('"ui.sessionTurn.notice.safeRetryFailed": "恢复失败。你可以稍后再试,或换一个模型。"') - expect(zht).toContain('"ui.sessionTurn.retry.recovery": "正在恢復…"') - expect(zht).toContain('"ui.sessionTurn.notice.safeRetryFailed": "恢復失敗。你可以稍後再試,或換一個模型。"') }) -test("all locale safe retry failure copy uses recovery wording", () => { +test("the old single safe-retry notice key is gone everywhere", () => { + // Split into sideEffect/default title+body; the runtime locales (en, zh) carry + // the split copy, other locale files fall back to en, and the old flat key is + // removed so no stale string lingers. for (const [path, source] of Object.entries(localeFiles)) { - expect(source, path).toContain('"ui.sessionTurn.notice.safeRetryFailed": "') - expect(source, path).toMatch(/Recovery failed|恢复失败|恢復失敗/) + expect(source, path).not.toContain('"ui.sessionTurn.notice.safeRetryFailed":') expect(source, path).not.toContain("Network connection dropped. Automatic retry did not complete.") } + for (const variant of ["sideEffect", "default"] as const) { + for (const slot of ["title", "body"] as const) { + const key = `"ui.sessionTurn.notice.safeRetryFailed.${variant}.${slot}":` + expect(en, "en").toContain(key) + expect(zh, "zh").toContain(key) + } + } }) diff --git a/packages/ui/src/components/session-thinking-phase-contract.test.ts b/packages/ui/src/components/session-thinking-phase-contract.test.ts new file mode 100644 index 000000000..175a5a694 --- /dev/null +++ b/packages/ui/src/components/session-thinking-phase-contract.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +const turn = readFileSync(new URL("./session-turn.tsx", import.meta.url), "utf8") +const retry = readFileSync(new URL("./session-retry.tsx", import.meta.url), "utf8") +const en = readFileSync(new URL("../i18n/en.ts", import.meta.url), "utf8") +const zh = readFileSync(new URL("../i18n/zh.ts", import.meta.url), "utf8") +const zht = readFileSync(new URL("../i18n/zht.ts", import.meta.url), "utf8") + +test("the pre-first-progress wait reads as connecting, not thinking (#1358)", () => { + // Provider output parts (text / reasoning / tool) mirror the backend's + // `isProviderProgressEvent` set: their presence is the UI proxy for "the + // provider has started responding". A `step-start` part must NOT flip the + // phase — it can land before the first provider chunk. + expect(turn).toContain('part.type === "text" || part.type === "reasoning" || part.type === "tool"') + expect(turn).not.toContain('part.type === "step-start"') + // The thinking slot carries the phase so the split is observable without + // depending on copy/locale. + expect(turn).toContain('data-phase={providerStarted() ? "thinking" : "connecting"}') + // Both labels are wired; connecting is the pre-progress copy, thinking the + // post-progress copy. + expect(turn).toContain('i18n.t("ui.sessionTurn.status.connecting")') + expect(turn).toContain('i18n.t("ui.sessionTurn.status.thinking")') +}) + +test("safe recovery shows the retry attempt, not just a generic recovering label", () => { + // Recovery-in-progress should make clear PawWork is retrying the model + // response (attempt N), not re-running a tool. Falls back to the plain + // recovering label when no attempt count is available. + expect(retry).toContain('i18n.t("ui.sessionTurn.retry.recoveryAttempt", { attempt: current().attempt })') + expect(retry).toContain('i18n.t("ui.sessionTurn.retry.recovery")') +}) + +test("connecting and recovery-attempt copy exists in the runtime locales", () => { + expect(en).toContain('"ui.sessionTurn.status.connecting": "Connecting"') + expect(en).toContain('"ui.sessionTurn.retry.recoveryAttempt": "Recovering... attempt #{{attempt}}"') + + expect(zh).toContain('"ui.sessionTurn.status.connecting": "连接中"') + expect(zh).toContain('"ui.sessionTurn.retry.recoveryAttempt": "正在恢复…第 {{attempt}} 次"') + + expect(zht).toContain('"ui.sessionTurn.status.connecting": "連線中"') + expect(zht).toContain('"ui.sessionTurn.retry.recoveryAttempt": "正在恢復…第 {{attempt}} 次"') +}) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 9019cea03..0817959c5 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -351,6 +351,21 @@ export function SessionTurn( } return visible }) + // Once any provider-output part exists (text / reasoning / tool — the same set + // the backend counts as `isProviderProgressEvent`), the provider has started + // responding, so the silent wait is real "thinking". Before that — building + // the request, connecting, waiting for the stream to be accepted, waiting for + // the first chunk — it is only "connecting" (#1358). A `step-start` part does + // not count: it can precede the first provider chunk and would otherwise make + // a connection wait read as model reasoning. + const providerStarted = createMemo(() => { + for (const message of visibleAssistantMessages()) { + for (const part of list(data.store.part?.[message.id], emptyParts)) { + if (part.type === "text" || part.type === "reasoning" || part.type === "tool") return true + } + } + return false + }) const showThinking = createMemo(() => { if (compactionDivider() === "pending") return false if (!working() || !!error()) return false @@ -462,8 +477,17 @@ export function SessionTurn(
-
- +
+
diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 44f86e316..c45347408 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini مزدحم حاليا", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "تم تجاوز حد الاستخدام المجاني", "ui.sessionTurn.error.addCredits": "إضافة رصيد", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 04ea02105..9a56ea135 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini está muito sobrecarregado agora", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Limite de uso gratuito excedido", "ui.sessionTurn.error.addCredits": "Adicionar créditos", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 3f65369b8..172004246 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -49,7 +49,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini je trenutno preopterećen", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Besplatna upotreba premašena", "ui.sessionTurn.error.addCredits": "Dodaj kredite", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 862def0e0..3694097c7 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -44,7 +44,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini er meget overbelastet lige nu", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Gratis forbrug overskredet", "ui.sessionTurn.error.addCredits": "Tilføj kreditter", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 0a6849f35..e05900d89 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -50,7 +50,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini ist gerade sehr überlastet", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Kostenloses Nutzungslimit überschritten", "ui.sessionTurn.error.addCredits": "Guthaben aufladen", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index ec2c6cb20..03ad40b22 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -75,8 +75,14 @@ export const dict: Record = { "ui.sessionTurn.retry.attemptLine": "{{line}} - attempt #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini is way too hot right now", "ui.sessionTurn.retry.recovery": "Recovering...", + "ui.sessionTurn.retry.recoveryAttempt": "Recovering... attempt #{{attempt}}", "ui.sessionTurn.retry.safeRecovery": "Recovering...", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "Action completed", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": + "The previous action already went through, so no need to repeat it. The network or model provider may be having connection issues. You can regenerate the reply later, or switch models.", + "ui.sessionTurn.notice.safeRetryFailed.default.title": "Reply incomplete", + "ui.sessionTurn.notice.safeRetryFailed.default.body": + "The model's reply couldn't be generated. The network or model provider may be having connection issues. Try again later, or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Free usage exceeded", "ui.sessionTurn.error.addCredits": "Add credits", @@ -88,6 +94,7 @@ export const dict: Record = { "ui.sessionTurn.status.searchingWeb": "Searching the web", "ui.sessionTurn.status.makingEdits": "Making edits", "ui.sessionTurn.status.runningCommands": "Running commands", + "ui.sessionTurn.status.connecting": "Connecting", "ui.sessionTurn.status.thinking": "Thinking", "ui.sessionTurn.status.thinkingWithTopic": "Thinking - {{topic}}", "ui.sessionTurn.status.gatheringThoughts": "Gathering thoughts", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 119dd0195..f1bfce8b2 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini está demasiado saturado", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Límite de uso gratuito excedido", "ui.sessionTurn.error.addCredits": "Añadir créditos", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index e56a58a00..1109d86f6 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini est en surchauffe", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Limite d'utilisation gratuite dépassée", "ui.sessionTurn.error.addCredits": "Ajouter des crédits", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 5646fbd58..478c8da04 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -44,7 +44,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini が混雑しています", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "無料使用制限に達しました", "ui.sessionTurn.error.addCredits": "クレジットを追加", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 7202738f9..3dbedc987 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -45,7 +45,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini가 현재 과부하 상태입니다", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "무료 사용량 초과", "ui.sessionTurn.error.addCredits": "크레딧 추가", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 044108a93..5c4856b79 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -48,7 +48,6 @@ export const dict: Record = { "ui.sessionTurn.retry.geminiHot": "gemini er veldig overbelastet nå", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Gratis bruk overskredet", "ui.sessionTurn.error.addCredits": "Legg til kreditt", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 13713f384..b0b510096 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -44,7 +44,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini jest teraz mocno przeciążony", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Przekroczono limit darmowego użytkowania", "ui.sessionTurn.error.addCredits": "Dodaj kredyty", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 17cf8291b..305c6ce2e 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -44,7 +44,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini сейчас перегружен", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Лимит бесплатного использования превышен", "ui.sessionTurn.error.addCredits": "Добавить кредиты", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index fa72f0bc2..691c550a3 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -46,7 +46,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini กำลังใช้งานหนาแน่นมาก", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "เกินขีดจำกัดการใช้งานฟรี", "ui.sessionTurn.error.addCredits": "เพิ่มเครดิต", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index f6291bcb9..ab1c13054 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -51,7 +51,6 @@ export const dict = { "ui.sessionTurn.retry.geminiHot": "gemini şu anda aşırı yoğun", "ui.sessionTurn.retry.recovery": "Recovering...", "ui.sessionTurn.retry.safeRecovery": "Network connection dropped, retrying automatically", - "ui.sessionTurn.notice.safeRetryFailed": "Recovery failed. Try again later or switch models.", "ui.sessionTurn.error.freeUsageExceeded": "Ücretsiz kullanım aşıldı", "ui.sessionTurn.error.addCredits": "Kredi ekle", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 3af95b5af..a35efd32e 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -78,8 +78,14 @@ export const dict = { "ui.sessionTurn.retry.attemptLine": "{{line}} - 第 {{attempt}} 次", "ui.sessionTurn.retry.geminiHot": "gemini 当前过载", "ui.sessionTurn.retry.recovery": "正在恢复…", + "ui.sessionTurn.retry.recoveryAttempt": "正在恢复…第 {{attempt}} 次", "ui.sessionTurn.retry.safeRecovery": "正在恢复…", - "ui.sessionTurn.notice.safeRetryFailed": "恢复失败。你可以稍后再试,或换一个模型。", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": + "上一项操作已执行,无需重复。当前网络或模型服务商连接异常,可稍后重新生成回复,或更换模型。", + "ui.sessionTurn.notice.safeRetryFailed.default.title": "回复未完成", + "ui.sessionTurn.notice.safeRetryFailed.default.body": + "模型回复未能生成。当前网络或模型服务商存在连接问题,请稍后重试,或换一个模型。", "ui.sessionTurn.error.freeUsageExceeded": "免费使用额度已用完", "ui.sessionTurn.error.addCredits": "添加积分", @@ -91,6 +97,7 @@ export const dict = { "ui.sessionTurn.status.searchingWeb": "正在搜索网页", "ui.sessionTurn.status.makingEdits": "正在修改", "ui.sessionTurn.status.runningCommands": "正在运行命令", + "ui.sessionTurn.status.connecting": "连接中", "ui.sessionTurn.status.thinking": "思考中", "ui.sessionTurn.status.thinkingWithTopic": "思考:{{topic}}", "ui.sessionTurn.status.gatheringThoughts": "正在整理思路", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index bde333201..1a3dd5a2a 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -50,8 +50,14 @@ export const dict = { "ui.sessionTurn.retry.attemptLine": "{{line}} - 第 {{attempt}} 次", "ui.sessionTurn.retry.geminiHot": "gemini 目前過載", "ui.sessionTurn.retry.recovery": "正在恢復…", + "ui.sessionTurn.retry.recoveryAttempt": "正在恢復…第 {{attempt}} 次", "ui.sessionTurn.retry.safeRecovery": "正在恢復…", - "ui.sessionTurn.notice.safeRetryFailed": "恢復失敗。你可以稍後再試,或換一個模型。", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.title": "操作已完成", + "ui.sessionTurn.notice.safeRetryFailed.sideEffect.body": + "上一項操作已執行,無需重複。目前網路或模型服務商連線異常,可稍後重新生成回覆,或更換模型。", + "ui.sessionTurn.notice.safeRetryFailed.default.title": "回覆未完成", + "ui.sessionTurn.notice.safeRetryFailed.default.body": + "模型回覆未能生成。目前網路或模型服務商存在連線問題,請稍後重試,或換一個模型。", "ui.sessionTurn.error.freeUsageExceeded": "免費使用額度已用完", "ui.sessionTurn.error.addCredits": "新增點數", @@ -63,6 +69,7 @@ export const dict = { "ui.sessionTurn.status.searchingWeb": "正在搜尋網頁", "ui.sessionTurn.status.makingEdits": "正在修改", "ui.sessionTurn.status.runningCommands": "正在執行命令", + "ui.sessionTurn.status.connecting": "連線中", "ui.sessionTurn.status.thinking": "思考中", "ui.sessionTurn.status.thinkingWithTopic": "思考 - {{topic}}", "ui.sessionTurn.status.gatheringThoughts": "正在整理思緒", diff --git a/packages/ui/test/fixtures/notice-render.fixture.tsx b/packages/ui/test/fixtures/notice-render.fixture.tsx new file mode 100644 index 000000000..a71bf8eb9 --- /dev/null +++ b/packages/ui/test/fixtures/notice-render.fixture.tsx @@ -0,0 +1,69 @@ +import type { Message, NoticePart } from "@opencode-ai/sdk/v2" +import { Dynamic, render } from "solid-js/web" +import { I18nProvider, type UiI18n, type UiI18nKey, type UiI18nParams } from "../../src/context/i18n" +import { dict as en } from "../../src/i18n/en" +import { dict as zh } from "../../src/i18n/zh" +import { PART_MAPPING } from "../../src/components/message-part/registry" +// Importing the part module registers the "notice" component into PART_MAPPING. +import "../../src/components/message-part/parts/notice" + +export const dicts = { en, zh } + +function i18nFor(dict: Record): UiI18n { + return { + locale: () => "test", + t: (key: UiI18nKey, params?: UiI18nParams) => { + const template = dict[key] ?? en[key] ?? String(key) + return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, raw) => String(params?.[String(raw)] ?? "")) + }, + } +} + +// A notice carrying ONLY the backend `sideEffect` flag — no tool part, no +// DataProvider, no turn context. If the UI still picks the right copy, it must +// be reading the field alone, not scanning/classifying tools (#1358). +function notice(sideEffect: boolean | undefined): NoticePart { + return { + id: "prt_notice", + sessionID: "ses_test", + messageID: "msg_test", + type: "notice", + kind: "safe_retry_failed", + ...(sideEffect === undefined ? {} : { sideEffect }), + time: { created: 1 }, + } +} + +// The component reads only props.part; message is required by the prop type but +// never touched, so a stub stands in. +const MESSAGE = { id: "msg_test" } as unknown as Message + +export function mountNotice(sideEffect: boolean | undefined, dict: Record = en) { + const Comp = PART_MAPPING["notice"] + if (!Comp) throw new Error("notice part component is not registered") + const host = document.createElement("div") + document.body.append(host) + + const dispose = render( + () => ( + + + + ), + host, + ) + + const root = () => host.querySelector("[data-component='notice-part']") as HTMLElement | null + return { + host, + variant: () => root()?.getAttribute("data-variant") ?? null, + title: () => host.querySelector("[data-slot='notice-title']")?.textContent ?? null, + body: () => host.querySelector("[data-slot='notice-body']")?.textContent ?? null, + // No tool card is ever mounted — proves the copy did not come from a tool scan. + toolCard: () => host.querySelector("[data-component='tool']"), + dispose: () => { + dispose() + host.remove() + }, + } +}