From 56138f4a6056a03542cdec38b409c8e01276f438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 30 Aug 2026 15:28:23 +0000 Subject: [PATCH] feat: show previews for expandable thinking messages (#3160) --- .../chat/messages/thinking-message.test.tsx | 113 ++++++++++++++ .../task/chat/messages/thinking-message.tsx | 42 +++++- .../tests/chat/mobile-markdown-wrap.spec.ts | 139 ++++++++++++++++++ docs/plans/thinking-message-preview/plan.md | 138 +++++++++++++++++ ...ask-01-render-thinking-message-previews.md | 129 ++++++++++++++++ docs/specs/ui/README.md | 10 +- .../requirements/thinking-message-preview.md | 75 ++++++++++ .../system-design/thinking-message-preview.md | 133 +++++++++++++++++ 8 files changed, 769 insertions(+), 10 deletions(-) create mode 100644 apps/web/components/task/chat/messages/thinking-message.test.tsx create mode 100644 docs/plans/thinking-message-preview/plan.md create mode 100644 docs/plans/thinking-message-preview/task-01-render-thinking-message-previews.md create mode 100644 docs/specs/ui/requirements/thinking-message-preview.md create mode 100644 docs/specs/ui/system-design/thinking-message-preview.md diff --git a/apps/web/components/task/chat/messages/thinking-message.test.tsx b/apps/web/components/task/chat/messages/thinking-message.test.tsx new file mode 100644 index 0000000000..8f6332534d --- /dev/null +++ b/apps/web/components/task/chat/messages/thinking-message.test.tsx @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { ThinkingMessage } from "./thinking-message"; +import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http"; + +afterEach(cleanup); + +const PREVIEW_TEST_ID = "thinking-message-preview"; + +function thinkingMessage(content: string): Message { + return { + id: "thinking-1", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + author_type: "agent", + content: "", + type: "thinking", + created_at: "2026-08-30T00:00:00Z", + metadata: { thinking: content }, + }; +} + +describe("ThinkingMessage", () => { + // @covers AC-UI-THINKING-MESSAGE-PREVIEW-001.1, AC-UI-THINKING-MESSAGE-PREVIEW-001.2, AC-UI-THINKING-MESSAGE-PREVIEW-001.7 + it("shows the first meaningful markdown-stripped line in an expandable header", () => { + render( + , + ); + + const preview = screen.queryByText("Choose the implementation path with context", { + exact: true, + }); + + expect(preview).not.toBeNull(); + expect(preview?.className).toContain("min-w-0"); + expect(preview?.className).toContain("truncate"); + expect(screen.queryByText("Later details remain in full.", { exact: true })).toBeNull(); + }); + + // @covers AC-UI-THINKING-MESSAGE-PREVIEW-001.2 + it("keeps the label-only fallback when decoration produces no visible text", () => { + render(); + + expect(screen.getByText("Thinking", { exact: true })).toBeTruthy(); + expect(screen.queryByTestId(PREVIEW_TEST_ID)).toBeNull(); + }); + + // @covers AC-UI-THINKING-MESSAGE-PREVIEW-001.2 + it("skips fenced and thematic Markdown-only lines", () => { + render( + , + ); + + expect(screen.getByTestId(PREVIEW_TEST_ID).textContent).toBe("First visible reasoning"); + }); + + // @covers AC-UI-THINKING-MESSAGE-PREVIEW-001.1 + it("preserves identifier punctuation in the preview", () => { + render(); + + expect(screen.getByTestId(PREVIEW_TEST_ID).textContent).toBe("foo_bar_baz"); + }); + + // @covers AC-UI-THINKING-MESSAGE-PREVIEW-001.3 + it("keeps the original preview when later reasoning is appended", () => { + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect(screen.getByTestId(PREVIEW_TEST_ID).textContent).toBe("Original subject"); + expect(screen.queryByText("A later subject", { exact: true })).toBeNull(); + }); + + // @covers AC-UI-THINKING-MESSAGE-PREVIEW-001.5 + it("reveals the complete markdown content when expanded", () => { + render( + , + ); + + fireEvent.click(screen.getByText("Thinking", { exact: true })); + + expect(screen.getByText("The later details remain in full.", { exact: true })).toBeTruthy(); + }); + + // @covers AC-UI-THINKING-MESSAGE-PREVIEW-001.6 + it("keeps compact single-line thinking inline and non-expandable", () => { + const { container } = render( + , + ); + + const compactText = screen.getByText("Compact thought", { exact: true }); + expect(compactText).toBeTruthy(); + expect(compactText.className).toContain("min-w-0"); + expect(compactText.className).toContain("break-words"); + expect(compactText.className).not.toContain("truncate"); + expect(screen.queryByTestId(PREVIEW_TEST_ID)).toBeNull(); + expect(container.querySelector(".markdown-body")).toBeNull(); + }); +}); diff --git a/apps/web/components/task/chat/messages/thinking-message.tsx b/apps/web/components/task/chat/messages/thinking-message.tsx index 00495d1127..83a6cea1c2 100644 --- a/apps/web/components/task/chat/messages/thinking-message.tsx +++ b/apps/web/components/task/chat/messages/thinking-message.tsx @@ -13,8 +13,8 @@ function stripMarkdown(text: string): string { return ( text // Bold/italic: **text** or __text__ or *text* or _text_ - .replace(/(\*\*|__)(.*?)\1/g, "$2") - .replace(/(\*|_)(.*?)\1/g, "$2") + .replace(/(^|[^\w])(\*\*|__)(?=\S)(.*?\S)\2(?=$|[^\w])/g, "$1$3") + .replace(/(^|[^\w])(\*|_)(?=\S)(.*?\S)\2(?=$|[^\w])/g, "$1$3") // Code: `code` or ```code``` .replace(/`{1,3}([^`]+)`{1,3}/g, "$1") // Links: [text](url) -> text @@ -23,11 +23,26 @@ function stripMarkdown(text: string): string { .replace(/^#{1,6}\s+/gm, "") // Strikethrough: ~~text~~ .replace(/~~(.*?)~~/g, "$1") + // Markdown-only block markers do not provide preview text + .replace(/^[ \t]*#{1,6}[ \t]*$/gm, "") + .replace(/^[ \t]*(?:\*{1,3}|_{1,3}|~{2,}|`{1,3})[ \t]*$/gm, "") + .replace(/^[ \t]*(?:`{3,}|~{3,})[ \t]*[\w-]*[ \t]*$/gm, "") + .replace(/^[ \t]*(?:(?:\*|_|-)[ \t]*){3,}$/gm, "") + .replace(/^[ \t]*(?:[-+*]|\d+[.)]|>)[ \t]*$/gm, "") // Remove any remaining special chars at start/end .trim() ); } +function firstMeaningfulLine(text: string): string { + for (const line of text.split(/\r?\n/)) { + const plainText = stripMarkdown(line); + if (plainText) return plainText; + } + + return ""; +} + export const ThinkingMessage = memo(function ThinkingMessage({ comment, worktreePath, @@ -48,16 +63,31 @@ export const ThinkingMessage = memo(function ThinkingMessage({ // Short = no newlines and less than 100 characters const isShort = !text.includes("\n") && text.length <= 100; const displayText = isShort ? stripMarkdown(text) : text; + const preview = isShort ? "" : firstMeaningfulLine(text); return ( } header={ -
- - {t("task:thinking")} - {isShort && {displayText}} +
+ + + {t("task:thinking")} + + {isShort && ( + + {displayText} + + )} + {!isShort && preview && ( + + {preview} + + )}
} hasExpandableContent={!isShort && !!text} diff --git a/apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts b/apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts index e0e0d7f0cf..43c069c991 100644 --- a/apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts @@ -123,4 +123,143 @@ test.describe("mobile: Markdown table wrapping", () => { ), ).toBe(true); }); + + test("thinking preview stays visible and contained before expansion", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + + const firstLine = + "First meaningful reasoning summary stays visible while this long subject is truncated to the available mobile row width"; + const laterLine = "Later reasoning detail remains available after expansion"; + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile Thinking Message Preview", + seedData.agentProfileId, + { + description: `e2e:thinking("${["", "## ", `**${firstLine}**`, "", laterLine].join("\\n")}")`, + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("preview task did not create a session"); + + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(task.session_id!); + return messages.some( + (message) => + message.type === "thinking" && + String(message.metadata?.thinking ?? message.content ?? "").includes(firstLine), + ); + }, + { timeout: 30_000, message: "Waiting for thinking preview content to persist" }, + ) + .toBe(true); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const chat = session.activeChat(); + const preview = chat.getByTestId("thinking-message-preview"); + await expect(preview).toBeVisible(); + await expect(preview).toHaveText(firstLine, { exact: true }); + await expect(chat.getByText(laterLine, { exact: true })).toHaveCount(0); + + const previewMetrics = await preview.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + clientHeight: element.clientHeight, + lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), + })); + expect(previewMetrics.clientWidth).toBeGreaterThan(0); + expect(previewMetrics.scrollWidth).toBeGreaterThan(previewMetrics.clientWidth); + expect(previewMetrics.clientHeight).toBeLessThanOrEqual(previewMetrics.lineHeight + 1); + expect(await chat.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe( + true, + ); + expect( + await testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1, + ), + ).toBe(true); + + await chat.getByText("Thinking", { exact: true }).tap(); + await expect(chat.getByText(laterLine, { exact: true })).toBeVisible(); + }); + + test("compact thinking preview stays contained on mobile", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + + const compactLine = + "Compact thinking line remains within the available mobile row width for safe rendering"; + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile Compact Thinking Preview", + seedData.agentProfileId, + { + description: `e2e:thinking("${compactLine}")`, + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("compact preview task did not create a session"); + + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(task.session_id!); + return messages.some( + (message) => + message.type === "thinking" && + String(message.metadata?.thinking ?? message.content ?? "").includes(compactLine), + ); + }, + { timeout: 30_000, message: "Waiting for compact thinking content to persist" }, + ) + .toBe(true); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const chat = session.activeChat(); + const compact = chat.getByText(compactLine, { exact: true }); + await expect(compact).toBeVisible(); + await expect(chat.getByTestId("thinking-message-preview")).toHaveCount(0); + + const compactMetrics = await compact.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), + text: element.textContent, + whiteSpace: getComputedStyle(element).whiteSpace, + })); + expect(compactMetrics.clientWidth).toBeGreaterThan(0); + expect(compactMetrics.scrollWidth).toBeLessThanOrEqual(compactMetrics.clientWidth + 1); + expect(compactMetrics.clientHeight).toBeGreaterThan(compactMetrics.lineHeight); + expect(compactMetrics.scrollHeight).toBe(compactMetrics.clientHeight); + expect(compactMetrics.text).toBe(compactLine); + expect(compactMetrics.whiteSpace).toBe("normal"); + expect(await chat.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe( + true, + ); + expect( + await testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1, + ), + ).toBe(true); + }); }); diff --git a/docs/plans/thinking-message-preview/plan.md b/docs/plans/thinking-message-preview/plan.md new file mode 100644 index 0000000000..3037c4ebbd --- /dev/null +++ b/docs/plans/thinking-message-preview/plan.md @@ -0,0 +1,138 @@ +--- +created: 2026-08-30 +status: implemented +requirements: + - REQ-UI-THINKING-MESSAGE-PREVIEW-001 +system_design: + - ../../specs/ui/system-design/thinking-message-preview.md +legacy_specs: [] +--- + +# Implementation Plan: Preview Collapsed Thinking Messages + +## Overview + +Derive a model-agnostic preview from the first meaningful reasoning line and +show it in expandable thinking-message headers. Implement the behavior as one +frontend TDD slice because the derivation, row layout, component evidence, and +mobile overflow proof share one component contract. + +## Scope + +### In scope + +- Skip leading blank or decoration-only reasoning lines. +- Strip inline Markdown from the selected preview line. +- Keep the localized Thinking label and add a one-line truncated preview for + expandable messages. +- Preserve compact single-line and expanded Markdown behavior; compact text + remains complete and may wrap on narrow rows. +- Prove streaming stability and desktop/mobile containment. + +### Out of scope + +- Backend, ACP, persistence, WebSocket, and message-schema changes. +- Provider-specific preview parsing or generated summaries. +- New settings, copy, localization keys, or public documentation. +- Expandable-row interaction and accessibility redesign. + +## Technical approach + +### Preview helper and component + +- Add a pure first-meaningful-line helper near `stripMarkdown` in + `apps/web/components/task/chat/messages/thinking-message.tsx`. +- Derive the preview from `metadata.thinking` or the current content fallback + on every render. +- Keep the current compact single-line predicate and expandable-content + condition. +- Render the preview for expandable content instead of limiting header text to + compact messages. +- Give the header and preview flex regions `min-w-0`, keep the localized label + non-shrinking, truncate only the expandable preview to one visual line, and + keep compact text in a shrinkable wrapping region. + +### Component regression tests + +- Add + `apps/web/components/task/chat/messages/thinking-message.test.tsx`. +- First write a failing test for a Codex-shaped source containing leading blank + lines, a bold first summary, and later details. +- Cover the empty-preview fallback, conservative inline Markdown stripping, + compact single-line preservation and wrapping classes, rerendered appended + content, expansion, and truncation classes. + +### Mobile browser evidence + +- Extend `apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts` with a focused + preview scenario. +- Seed leading blank lines, a long bold summary, and a later detail line through + the existing mock-agent thinking command. +- Require the first summary in the collapsed row, reveal the later detail only + after expansion, and assert that the preview truncates without chat or + document horizontal overflow. + +## Tests + +| Acceptance criterion | Test evidence | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `AC-UI-THINKING-MESSAGE-PREVIEW-001.1` | Component and mobile browser tests require the first meaningful line in the collapsed header. | +| `AC-UI-THINKING-MESSAGE-PREVIEW-001.2` | Component tests cover leading blank, decoration-only, and empty content. | +| `AC-UI-THINKING-MESSAGE-PREVIEW-001.3` | A component rerender test appends a later summary and keeps the original preview. | +| `AC-UI-THINKING-MESSAGE-PREVIEW-001.4` | Header class assertions and the mobile browser geometry scenario prove expandable one-line containment. | +| `AC-UI-THINKING-MESSAGE-PREVIEW-001.5` | Component and mobile browser tests expand the row and find the complete later content. | +| `AC-UI-THINKING-MESSAGE-PREVIEW-001.6` | A component test preserves compact inline and non-expandable rendering. | +| `AC-UI-THINKING-MESSAGE-PREVIEW-001.7` | The pure helper uses only the provider-neutral thinking string. | + +## E2E tests + +- Mobile: run the new thinking-preview scenario in + `apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts` with the + `mobile-chrome` project. This proves the shared component's visible preview, + expansion path, expandable-preview truncation, compact full-text wrapping, + and containment on the narrowest supported chat surface. +- Desktop behavior is covered by the shared component test because the change + does not branch by viewport or pointer mode. + +## Mobile design contract + +- Desktop outcome: an expandable thinking row shows its subject before the + complete Markdown block is opened. +- Mobile entry point: the same thinking row in the existing task Chat tab. +- Nearest exemplar: the thinking-message scenario in + `apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts`. +- Hierarchy and surface: the localized label remains first; the preview is + secondary inline content; the existing transcript remains the scroll owner. +- Presentation rationale: this shallow scan aid belongs in the existing row; + a drawer, new route, or separate surface would add interaction without new + content depth. +- Shared logic: desktop and mobile use the same preview derivation, expansion + state, and complete Markdown content. +- Mobile proof: the new `mobile-chrome` scenario verifies preview visibility, + expansion, expandable-preview truncation, compact full-text wrapping, and + the absence of document overflow. + +## Work orders + +- [x] [Task 01: Render thinking-message previews](task-01-render-thinking-message-previews.md) + +## Verification results + +Implementation is complete. The exact task verification commands passed: + +- Thinking-message component tests: 7 passed. +- Web typecheck, targeted ESLint, and Prettier checks passed. +- Specification tests and all specification files passed lint. +- Mobile Pixel 5 thinking-preview E2E: 2 passed with expandable-preview + truncation, compact full-text wrapping, and no chat or document overflow. +- Fresh desktop and 393px mobile PR screenshots captured, inspected, and compressed. + +## Risks + +- A source can begin with Markdown structure rather than a natural-language + heading. The contract intentionally selects the first visible line instead + of adding provider-specific or semantic ranking. +- Streaming can initially contain only blank lines. The label-only fallback + must transition cleanly when the first meaningful content arrives. +- A long preview can widen nested flex content unless every ancestor in the + header path permits shrinking. diff --git a/docs/plans/thinking-message-preview/task-01-render-thinking-message-previews.md b/docs/plans/thinking-message-preview/task-01-render-thinking-message-previews.md new file mode 100644 index 0000000000..ed57956301 --- /dev/null +++ b/docs/plans/thinking-message-preview/task-01-render-thinking-message-previews.md @@ -0,0 +1,129 @@ +--- +id: "01-render-thinking-message-previews" +title: "Render thinking-message previews" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-THINKING-MESSAGE-PREVIEW-001 +acceptance_criteria: + - AC-UI-THINKING-MESSAGE-PREVIEW-001.1 + - AC-UI-THINKING-MESSAGE-PREVIEW-001.2 + - AC-UI-THINKING-MESSAGE-PREVIEW-001.3 + - AC-UI-THINKING-MESSAGE-PREVIEW-001.4 + - AC-UI-THINKING-MESSAGE-PREVIEW-001.5 + - AC-UI-THINKING-MESSAGE-PREVIEW-001.6 + - AC-UI-THINKING-MESSAGE-PREVIEW-001.7 +system_design: + - ../../specs/ui/system-design/thinking-message-preview.md +--- + +# Task 01: Render Thinking-Message Previews + +## Summary + +Show the first meaningful reasoning line in expandable thinking-message +headers. Preserve complete Markdown content, compact single-line behavior, and +provider-neutral message contracts while containing the preview on mobile. + +## In scope + +- Write the focused component regression first. +- Derive a plain-text preview from the first meaningful source line. +- Render and truncate the preview in expandable thinking-message headers. +- Preserve label-only, compact-inline, streaming, and expanded-content paths. +- Add focused mobile browser evidence for visibility and containment. + +## Out of scope + +- Agent adapter, lifecycle, task-message service, persistence, and WebSocket + changes. +- Generated summaries, provider branches, new translations, or settings. +- Changes to `ExpandableRow`, the Markdown renderer, or shared transcript + navigation. + +## Acceptance + +- A Codex-shaped multiline thought shows its first non-empty Markdown-stripped + line beside Thinking before expansion, while later appended lines do not + replace it. +- Empty or decoration-only thoughts retain the label-only fallback, and compact + single-line thoughts retain their current full inline behavior while wrapping + when the row is narrow. +- The expandable preview stays on one truncated line without widening the + mobile chat or document, and expansion reveals the complete source. + +## Verification + +Bootstrap workspace dependencies once when `apps/node_modules` is absent: + +```bash +cd apps && pnpm install --frozen-lockfile +``` + +Run the focused TDD and validation commands: + +```bash +cd apps && pnpm --filter @kandev/web test -- components/task/chat/messages/thinking-message.test.tsx +cd apps/web && pnpm run typecheck +cd apps/web && pnpm exec eslint components/task/chat/messages/thinking-message.tsx components/task/chat/messages/thinking-message.test.tsx e2e/tests/chat/mobile-markdown-wrap.spec.ts +cd apps/web && pnpm exec prettier --check components/task/chat/messages/thinking-message.tsx components/task/chat/messages/thinking-message.test.tsx e2e/tests/chat/mobile-markdown-wrap.spec.ts +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check -- docs/specs docs/plans apps/web/components/task/chat/messages apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts +cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/chat/mobile-markdown-wrap.spec.ts -- --grep "thinking preview" --retries=0 +``` + +## Files likely touched + +- `apps/web/components/task/chat/messages/thinking-message.tsx` +- `apps/web/components/task/chat/messages/thinking-message.test.tsx` +- `apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts` +- `docs/specs/ui/requirements/thinking-message-preview.md` +- `docs/specs/ui/system-design/thinking-message-preview.md` +- `docs/specs/ui/README.md` +- `docs/plans/thinking-message-preview/plan.md` +- `docs/plans/thinking-message-preview/task-01-render-thinking-message-previews.md` + +## Dependencies + +None. + +## Risks + +- A Markdown structural line can be a technically valid but less descriptive + preview. Do not add semantic ranking beyond the specified first visible line. +- Flex truncation fails if a header ancestor cannot shrink. Cover the final DOM + geometry in the mobile browser scenario. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-UI-THINKING-MESSAGE-PREVIEW-001` and all acceptance criteria. +- Preview derivation, rendering, streaming, and responsive sections in the + thinking-message-preview system design. +- Existing `ThinkingMessage`, `ExpandableRow`, message-renderer tests, and + mobile Markdown containment scenario. + +## Results + +Implemented a model-agnostic first-meaningful-line helper and rendered its +plain-text result beside the localized Thinking label for expandable rows. +Compact single-line messages remain complete, non-expandable, and readable when +their inline text wraps; expanded Markdown content keeps its existing behavior. +The header uses shrink-safe flex regions and truncates only the expandable +preview; structural Markdown-only lines are skipped and identifier punctuation +is preserved in previews. + +Verification: + +- `cd apps && pnpm --filter @kandev/web test -- components/task/chat/messages/thinking-message.test.tsx` — 7 passed. +- `cd apps/web && pnpm run typecheck` — passed. +- Targeted ESLint and Prettier checks — passed. +- `python3 scripts/lint-spec-files.test.py` and `python3 scripts/lint-spec-files.py --all` — passed. +- `cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/chat/mobile-markdown-wrap.spec.ts -- --grep "thinking preview" --retries=0` — 2 passed, including complete compact text wrapping. +- Disposable PR capture E2E — 1 passed; desktop and 393px mobile screenshots were inspected and compressed. diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index 0c887a2edc..7a76305603 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -40,7 +40,7 @@ with their owning systems. - [Agent-message inline comments](requirements/agent-message-comments.md) - [Agent Todo List Panel](requirements/agent-todo-list-panel.md) - [App Status Bar](requirements/app-status-bar.md) -- [Per-workflow column visibility on the kanban board](requirements/board-step-visibility-filter.md) +- [Workflow column visibility](requirements/board-step-visibility-filter.md) - [Browser inspect annotation submission](requirements/browser-inspect-annotations-save.md) - [Backend-owned cancel-turn progress](requirements/cancel-turn-progress.md) - [Changes File Row Containment](requirements/changes-file-row-containment.md) @@ -64,7 +64,7 @@ with their owning systems. - [Executor settings card spacing](requirements/executor-settings-card-spacing.md) - [External VCS File Links](requirements/external-vcs-file-links.md) - [File Tree Chat Context](requirements/file-tree-chat-context.md) -- [Reload Kandev when a tab is restored from a frozen browser snapshot](requirements/fix-duplicated-tab-stale-data.md) +- [Reload Kandev after frozen-tab restore](requirements/fix-duplicated-tab-stale-data.md) - [GitHub PR Review Actions](requirements/github-pr-review-actions.md) - [GitHub Saved-Query Default Views](requirements/github-saved-query-defaults.md) - [Kandev MCP Tool Results](requirements/kandev-mcp-tool-results.md) @@ -105,7 +105,7 @@ with their owning systems. - [Review File Status Cues](requirements/review-file-status.md) - [Review Markdown Preview](requirements/review-markdown-preview.md) - [Search/filter dropdown scroll reset](requirements/search-filter-scroll-reset.md) -- [Selected option prominence in single-choice pickers](requirements/selected-option-picker-prominence.md) +- [Selected option prominence](requirements/selected-option-picker-prominence.md) - [Session tab delete feedback](requirements/session-tab-delete-feedback.md) - [Settings Discovery](requirements/settings-discovery.md) - [Settings Manual Save](requirements/settings-manual-save.md) @@ -130,10 +130,11 @@ with their owning systems. - [Task Listing Display Preferences](requirements/task-listing-display-preferences.md) - [Task transcript history visibility](requirements/task-prompt-transcript-visibility.md) - [Task Review Shortcut Switcher](requirements/task-review-shortcut.md) -- [Task Surface Foreground Refresh and Mobile Create Action](requirements/task-surface-refresh.md) +- [Task surface refresh and mobile create](requirements/task-surface-refresh.md) - [Task Workspace Content Search](requirements/task-workspace-content-search.md) - [Terminal close feedback](requirements/terminal-close-feedback.md) - [Terminal Rendering](requirements/terminal-rendering.md) +- [Thinking Message Preview](requirements/thinking-message-preview.md) - [Transcript Auto-scroll Stability](requirements/transcript-auto-scroll.md) - [Transcript Navigation Settings](requirements/transcript-navigation-settings.md) - [Voice Mode In Task Behavior](requirements/voice-mode-task-behavior.md) @@ -177,6 +178,7 @@ with their owning systems. - [Task Agent Tab Reconciliation](system-design/task-agent-tab-reconciliation.md) - [Command-panel Sidebar Task Reveal](system-design/command-panel-sidebar-task-reveal.md) - [Terminal Rendering](system-design/terminal-rendering.md) +- [Thinking Message Preview](system-design/thinking-message-preview.md) - [Task Transcript History Visibility](system-design/task-prompt-transcript-visibility.md) - [Transcript Auto-scroll Stability](system-design/transcript-auto-scroll.md) diff --git a/docs/specs/ui/requirements/thinking-message-preview.md b/docs/specs/ui/requirements/thinking-message-preview.md new file mode 100644 index 0000000000..9852d400d1 --- /dev/null +++ b/docs/specs/ui/requirements/thinking-message-preview.md @@ -0,0 +1,75 @@ +--- +status: draft +system: ui +created: 2026-08-30 +owners: + - kandev +--- + +# Thinking Message Preview Requirements + +## Overview + +Thinking messages can contain several concise reasoning headings. Collapsed +rows currently hide those headings behind the generic Thinking label whenever +the source contains a newline. A compact preview helps users scan the agent's +progress without expanding every reasoning block. + +The UI system owns this presentation contract. Agent runtimes and the task +system continue to own reasoning production, delivery, and persistence. + +## Terminology + +- **Thinking message:** An agent transcript message with the `thinking` type. +- **Meaningful line:** The first source line that contains visible text after + surrounding whitespace and inline Markdown decoration are removed. +- **Preview:** The plain-text meaningful line shown beside the localized + Thinking label in the collapsed row. + +## Requirements + +### REQ-UI-THINKING-MESSAGE-PREVIEW-001: Collapsed thinking message preview + +**Intent:** Let users identify the subject of an expandable thinking message +without opening the complete reasoning block. + +**User story:** As a user reading an agent transcript, I want collapsed +thinking rows to describe their subject, so that I can scan the agent's +progress quickly. + +#### Acceptance criteria + +- **AC-UI-THINKING-MESSAGE-PREVIEW-001.1:** When an expandable thinking + message contains a meaningful line, Kandev shall show that line as plain + text beside the localized Thinking label while the message is collapsed. +- **AC-UI-THINKING-MESSAGE-PREVIEW-001.2:** When a thinking message starts + with blank lines or Markdown decoration that produces no visible text, + Kandev shall skip those lines. If no meaningful line exists, the row shall + show only the localized Thinking label. +- **AC-UI-THINKING-MESSAGE-PREVIEW-001.3:** When later reasoning content is + appended to a thinking message after its first meaningful line, Kandev shall + keep the original preview instead of replacing it with a later line. +- **AC-UI-THINKING-MESSAGE-PREVIEW-001.4:** On desktop and mobile, an + expandable thinking preview shall occupy one visual line, truncate within + the available row width, and shall not widen the transcript or document. +- **AC-UI-THINKING-MESSAGE-PREVIEW-001.5:** Expanding a thinking row shall + continue to show the complete original reasoning content with its existing + Markdown rendering. The preview shall not modify persisted or expanded + content. +- **AC-UI-THINKING-MESSAGE-PREVIEW-001.6:** Compact single-line thinking + messages that already render their complete text inline shall keep their + current inline and non-expandable behavior. +- **AC-UI-THINKING-MESSAGE-PREVIEW-001.7:** Preview derivation shall apply to + thinking messages from every agent provider without provider-specific + labels or parsing rules. + +## Out of scope + +- Generating a new summary or choosing a later line because it appears more + descriptive. +- Changing ACP reasoning events, task-message persistence, WebSocket payloads, + or streaming coalescing. +- Changing the expanded Markdown renderer or the compact single-line + threshold. +- Redesigning the shared expandable-row interaction or its accessibility + contract. diff --git a/docs/specs/ui/system-design/thinking-message-preview.md b/docs/specs/ui/system-design/thinking-message-preview.md new file mode 100644 index 0000000000..00a6df9366 --- /dev/null +++ b/docs/specs/ui/system-design/thinking-message-preview.md @@ -0,0 +1,133 @@ +--- +status: draft +system: ui +requirements: + - REQ-UI-THINKING-MESSAGE-PREVIEW-001 +--- + +# Thinking Message Preview System Design + +## Purpose and boundaries + +This design derives a stable collapsed preview from the thinking content that +the web client already receives. It changes only the task-chat presentation. +The ACP adapter, lifecycle coalescer, task-message persistence, boot payload, +and WebSocket message contract remain unchanged. + +An ACP probe against Codex 1.6.0 emitted four thought chunks under one stable +message ID: blank lines, a bold summary, blank lines, and a second bold +summary. Kandev persisted their ordered concatenation. The current +`ThinkingMessage` classifies any text containing a newline as expandable but +only renders inline text for non-expandable messages. The useful first summary +therefore disappears from the collapsed row. + +## Requirement mapping + +| Requirement | Design section | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQ-UI-THINKING-MESSAGE-PREVIEW-001` | [Preview derivation](#preview-derivation), [Rendering contract](#rendering-contract), [Streaming behavior](#streaming-behavior), [Responsive behavior](#responsive-behavior), [Verification](#verification) | + +## Components and responsibilities + +- `ThinkingMessage` reads the existing `metadata.thinking` string, with + `comment.content` as its current fallback, and owns preview derivation and + header layout. +- `ExpandableRow` continues to own collapse and expansion without a new prop or + interaction mode. +- `MemoizedMarkdown` continues to render the complete source only in the + expanded content region. +- The task-message store and WebSocket handlers continue to replace each live + message with the latest complete public state. + +## Preview derivation + +`ThinkingMessage` uses a pure, model-agnostic helper: + +1. Split the source on Unix or Windows line endings. +2. Process lines in source order with conservative preview-specific inline + Markdown normalization. +3. Trim the plain-text result and select the first non-empty value. +4. Return an empty preview when no line produces visible text. + +The helper does not parse provider metadata, translate agent content, generate +new copy, or persist the preview. Markdown links contribute their visible +label, balanced emphasis and code delimiters are removed, structural-only +Markdown lines are skipped, and identifier punctuation is preserved. React +renders the result as escaped text rather than Markdown or HTML. + +Compact single-line detection remains unchanged. Its complete stripped text +continues to render inline, and the row remains non-expandable. The compact +text stays complete and may wrap at narrow widths, but it is not truncated or +replaced by the expandable preview. Expandable messages use the derived +preview in the same header position. + +## Rendering contract + +The header keeps the localized Thinking label as a non-shrinking prefix. The +preview occupies the remaining width in a `min-w-0` flex region and uses +single-line truncation. The complete source remains in the current expanded +Markdown region, so truncation never changes or discards transcript content. + +The compact inline text uses a separate shrinkable, wrapping flex item. Its +complete text remains readable on narrow screens without widening the +transcript. + +When preview derivation returns an empty string, the header renders only the +Thinking label. No placeholder, tooltip, or new user-facing copy is added. + +## Streaming behavior + +Reasoning updates append ordered content to one thinking message. The client +derives the preview from the current complete string on each render. Leading +blank chunks initially produce no preview. The preview appears when the first +meaningful line arrives. Later appended lines cannot precede that line, so the +derived preview remains stable without local state, memoized protocol IDs, or +an additional streaming lifecycle. + +Animation-frame WebSocket replacement can skip intermediate renders, but each +delivered message contains the complete current thinking string. Preview +derivation therefore reaches the same result after replacement or reload. + +## Responsive behavior + +Desktop and mobile use the same `ThinkingMessage` component and derivation. +This is an inline content change inside the existing task-chat row. It adds no +navigation, overlay, scroll owner, control, or touch target. + +The nearest mobile exemplar is the current thinking row in +`apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts`. The localized label +keeps its current priority. An expandable preview truncates before it can +widen the chat or document, while compact text remains complete and can wrap +within the row. Expanding the row continues to use the existing full-width +Markdown region and its local overflow handling. + +## Failure and security behavior + +- Empty, whitespace-only, or decoration-only content produces no preview and + keeps the existing label-only fallback. +- A malformed or unavailable thinking value follows the component's existing + empty-message handling; this change does not broaden accepted message data. +- Preview text is rendered as a React text node. Markdown cannot create links, + raw HTML, executable content, or additional interactive elements in the + header. +- Expanded content continues through the existing Markdown safety boundary. + +## Verification + +Focused component tests cover leading blank lines, conservative Markdown +stripping, structural-only lines, identifier punctuation, the label-only +fallback, compact single-line preservation and wrapping classes, later +appended content, expansion, and the truncating header classes. + +A `mobile-chrome` Playwright scenario seeds Codex-shaped multiline and compact +thinking messages, waits for their persisted content, verifies the meaningful +preview and expansion behavior, checks complete compact text with allowed +wrapping, and checks that the preview, chat, and document remain horizontally +contained. + +No production telemetry is added. This is a deterministic presentation rule +with component and browser evidence. + +## Related decisions + +- [Isolate Replaceable Session Stream Traffic](../../../decisions/2026-08-02-isolate-replaceable-session-stream-traffic.md)