feat: show previews for expandable thinking messages (#3160)

This commit is contained in:
Carlos Florêncio
2026-08-30 15:28:23 +00:00
committed by GitHub
parent 094cb7ef1d
commit 56138f4a60
8 changed files with 769 additions and 10 deletions
@@ -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(
<ThinkingMessage
comment={thinkingMessage(
"\n## \r\n**Choose the implementation path** [with context](https://example.test/context)\r\n\nLater details remain in full.",
)}
/>,
);
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(<ThinkingMessage comment={thinkingMessage("\n**\n## ")} />);
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(
<ThinkingMessage
comment={thinkingMessage("```\n---\n**First visible reasoning**\n\nLater detail.")}
/>,
);
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(<ThinkingMessage comment={thinkingMessage("foo_bar_baz\n\nLater detail.")} />);
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(
<ThinkingMessage comment={thinkingMessage("**Original subject**\n\nInitial detail.")} />,
);
rerender(
<ThinkingMessage
comment={thinkingMessage("**Original subject**\n\nInitial detail.\n\n**A later subject**")}
/>,
);
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(
<ThinkingMessage
comment={thinkingMessage("**Original subject**\n\nThe later details remain in full.")}
/>,
);
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(
<ThinkingMessage comment={thinkingMessage("**Compact thought**")} />,
);
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();
});
});
@@ -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 (
<ExpandableRow
icon={<IconBrain className="h-4 w-4 text-muted-foreground" />}
header={
<div className="flex items-center gap-2 text-xs">
<span className="inline-flex items-center gap-1.5">
<span className="font-mono text-xs text-muted-foreground">{t("task:thinking")}</span>
{isShort && <span className="text-xs text-muted-foreground/80">{displayText}</span>}
<div className="flex min-w-0 items-center gap-2 text-xs">
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{t("task:thinking")}
</span>
{isShort && (
<span className="min-w-0 break-words whitespace-normal text-xs text-muted-foreground/80">
{displayText}
</span>
)}
</span>
{!isShort && preview && (
<span
data-testid="thinking-message-preview"
className="min-w-0 flex-1 truncate text-xs text-muted-foreground/80"
>
{preview}
</span>
)}
</div>
}
hasExpandableContent={!isShort && !!text}
@@ -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);
});
});
+138
View File
@@ -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.
@@ -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.
+6 -4
View File
@@ -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)
@@ -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.
@@ -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)