feat: show previews for expandable thinking messages (#3160)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user