From 19db63e496966e7789fbaaee963e54cf866be7fe Mon Sep 17 00:00:00 2001 From: tycebot <89507652+tycebot@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:19:32 -0700 Subject: [PATCH 1/4] feat(web): labs toggle to expand the live Thinking row into the streaming thought Adds a Labs/Beta setting (liveThinkingEnabled, off by default) that lets the live "Thinking" row expand on click to show the agent's full thought as it streams mid-turn, like a live transcript. The collapsed row keeps showing the one-line preview. - contracts: liveThinkingEnabled in ClientSettingsSchema (default false) and ClientSettingsPatch - settings: LiveThinkingRow in the Labs Beta section + search entry - timeline logic: work-live row gains a liveThinking flag; the bare thinking row carries the streaming thought when the setting is on - timeline rows: ThinkingTimelineRow and LiveWorkEntryTimelineRow expand the thought inline via ReasoningTraceContent (local state, no new global state) - tests: logic + browser coverage for on/off behavior --- .../chat/MessagesTimeline.browser.test.tsx | 73 ++++++++++ .../chat/MessagesTimeline.logic.test.ts | 134 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 12 ++ .../src/components/chat/MessagesTimeline.tsx | 7 + .../components/chat/TimelineWorkGroupRows.tsx | 65 +++++++-- .../components/settings/LabsSettingsPanel.tsx | 19 +++ .../src/components/settings/settingsSearch.ts | 6 + packages/contracts/src/settings.ts | 3 + 8 files changed, 305 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 2c7aa332756cb..284454831d09c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -62,6 +62,23 @@ vi.mock(import("~/assets/assetUrls"), async (original) => { }; }); +const liveThinkingState = vi.hoisted(() => ({ enabled: false })); +vi.mock(import("../../hooks/useSettings"), async (original) => { + const actual = await original(); + const { DEFAULT_UNIFIED_SETTINGS } = await import("@t3tools/contracts/settings"); + const settings = () => ({ + ...DEFAULT_UNIFIED_SETTINGS, + liveThinkingEnabled: liveThinkingState.enabled, + }); + return { + ...actual, + useEnvironmentSettings: (( + _environmentId: EnvironmentId, + selector?: (s: ReturnType) => unknown, + ) => (selector ? selector(settings()) : settings())) as never, + }; +}); + const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const CREATED_AT = "2026-03-17T19:12:28.000Z"; @@ -1165,3 +1182,59 @@ it("keeps concurrent approvals actionable outside collapsed work and restores se .element(screen.getByRole("button", { name: "Open the current tab", exact: true })) .toBeVisible(); }); + +describe("labs live-thinking row", () => { + const runId = RunId.make("run-live-thinking"); + const thoughtDetail = + "The build fails because the fixture is stale. Let me check the fixture before touching anything else."; + const entries: Entry[] = [ + { + id: "entry-live-thinking", + kind: "work", + createdAt: CREATED_AT, + entry: { + id: "live-thinking", + createdAt: CREATED_AT, + runId, + label: "Thinking", + tone: "thinking", + itemType: "reasoning", + toolLifecycleStatus: "inProgress", + detail: thoughtDetail, + }, + }, + ]; + const liveProps = { + isWorking: true, + latestRun: { runId, status: "running" as const, startedAt: CREATED_AT, completedAt: null }, + }; + const liveRow = (screen: Awaited>) => + screen.container.querySelector("[data-timeline-row-kind='work-live'] button"); + const liveRowScroller = (screen: Awaited>) => + screen.container.querySelector("[data-timeline-row-kind='work-live'] .max-h-96"); + + it("keeps the work-group toggle while the labs setting is off", async () => { + liveThinkingState.enabled = false; + await using screen = await renderTimeline(entries, liveProps); + expect(liveRow(screen)).not.toBeNull(); + await click(liveRow(screen) as HTMLElement); + // The click toggles the work group: the group row appears and the thought stays + // folded in the entry row, never in the live row. + expect(screen.container.querySelector("[data-timeline-row-kind='work']")).not.toBeNull(); + expect(liveRowScroller(screen)).toBeNull(); + }); + + it("expands the full streaming thought on click while the labs setting is on", async () => { + liveThinkingState.enabled = true; + await using screen = await renderTimeline(entries, liveProps); + expect(liveRow(screen)).not.toBeNull(); + await click(liveRow(screen) as HTMLElement); + // The thought expands directly below the live row; the work group stays folded. + expect(liveRowScroller(screen)?.textContent).toContain( + "Let me check the fixture before touching anything else", + ); + expect(screen.container.querySelector("[data-timeline-row-kind='work']")).toBeNull(); + await click(liveRow(screen) as HTMLElement); + expect(liveRowScroller(screen)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index bd74694dc46af..09dd8d7bd8227 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2365,6 +2365,140 @@ describe("deriveMessagesTimelineRows", () => { expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); }); + describe("labs live-thinking row", () => { + const liveThinkingBase = { + latestRun: { + runId: "turn-1" as never, + status: "running" as const, + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }; + + it("flags the live work row so its click expands the streaming thought", () => { + const deriveRows = (liveThinkingEnabled?: boolean) => + deriveMessagesTimelineRows({ + ...(liveThinkingEnabled === undefined ? {} : { liveThinkingEnabled }), + ...liveThinkingBase, + timelineEntries: [ + { + id: "streaming-thought-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "streaming-thought", + createdAt: "2026-01-01T00:00:05Z", + runId: "turn-1" as never, + label: "Thinking", + tone: "thinking" as const, + itemType: "reasoning" as const, + toolLifecycleStatus: "inProgress" as const, + detail: "The build fails because the fixture is stale.", + }, + }, + ], + }); + + const enabledRow = deriveRows(true).find((row) => row.kind === "work-live"); + expect(enabledRow).toMatchObject({ + kind: "work-live", + liveThinking: true, + thought: { id: "streaming-thought" }, + }); + const disabledRow = deriveRows(false).find((row) => row.kind === "work-live"); + expect(disabledRow).toMatchObject({ + kind: "work-live", + thought: { id: "streaming-thought" }, + }); + expect(disabledRow?.liveThinking).toBeUndefined(); + const defaultRow = deriveRows().find((row) => row.kind === "work-live"); + expect(defaultRow?.liveThinking).toBeUndefined(); + }); + + it("attaches the thought to the bare thinking row only when the toggle is on", () => { + const deriveRows = (liveThinkingEnabled?: boolean) => + deriveMessagesTimelineRows({ + ...(liveThinkingEnabled === undefined ? {} : { liveThinkingEnabled }), + ...liveThinkingBase, + timelineEntries: [ + { + id: "settled-thought-entry", + kind: "work", + createdAt: "2026-01-01T00:00:04Z", + entry: { + id: "settled-thought", + createdAt: "2026-01-01T00:00:04Z", + runId: "turn-1" as never, + label: "Thinking", + tone: "thinking" as const, + itemType: "reasoning" as const, + toolLifecycleStatus: "completed" as const, + detail: "The build fails because the fixture is stale.", + }, + }, + { + id: "failed-command-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "failed-command", + createdAt: "2026-01-01T00:00:05Z", + runId: "turn-1" as never, + label: "Ran rg", + command: "rg toolCall", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "failed" as const, + }, + }, + ], + }); + + const enabledRow = deriveRows(true).find((row) => row.kind === "thinking"); + expect(enabledRow).toMatchObject({ + kind: "thinking", + thought: { id: "settled-thought" }, + }); + const disabledRow = deriveRows(false).find((row) => row.kind === "thinking"); + expect(disabledRow).toMatchObject({ kind: "thinking" }); + expect(disabledRow?.thought).toBeUndefined(); + const defaultRow = deriveRows().find((row) => row.kind === "thinking"); + expect(defaultRow?.thought).toBeUndefined(); + }); + + it("leaves the live row bare while the thought is still empty", () => { + const rows = deriveMessagesTimelineRows({ + liveThinkingEnabled: true, + ...liveThinkingBase, + timelineEntries: [ + { + id: "empty-thought-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "empty-thought", + createdAt: "2026-01-01T00:00:05Z", + runId: "turn-1" as never, + label: "Thinking", + tone: "thinking" as const, + itemType: "reasoning" as const, + toolLifecycleStatus: "inProgress" as const, + detail: " ", + }, + }, + ], + }); + const liveRow = rows.find((r) => r.kind === "work-live"); + expect(liveRow).toBeUndefined(); + const thinkingRow = rows.find((r) => r.kind === "thinking"); + expect(thinkingRow?.thought).toBeUndefined(); + }); + }); + it("does not fold the session's running turn when latestRun regresses", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c26bcf51a718a..0d985c0c9c17a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -522,6 +522,8 @@ type MessagesTimelineRowContent = expanded: boolean; active: boolean; thought?: WorkLogEntry; + /** Labs live-thinking: the row's click expands the full streaming thought. */ + liveThinking?: true; } | { kind: "working"; @@ -536,6 +538,8 @@ type MessagesTimelineRowContent = createdAt: string | null; groupId?: string; expanded?: boolean; + /** Labs live-thinking: the streaming thought the row expands into. */ + thought?: WorkLogEntry; } | { kind: "work-toggle"; @@ -1392,6 +1396,8 @@ export function deriveMessagesTimelineRows(input: { expandedAttemptIds?: ReadonlySet; expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; + /** Labs live-thinking: the live Thinking row expands into the streaming thought. */ + liveThinkingEnabled?: boolean; /** Native subagent work has no app run; runless entries form the live response. */ runlessWorkActive?: boolean; activeTurnStartedAt?: string | null; @@ -1565,6 +1571,9 @@ export function deriveMessagesTimelineRows(input: { expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, active: latestToolKeepsActivityLive, ...(latestThoughtEntry ? { thought: latestThoughtEntry.entry } : {}), + ...(input.liveThinkingEnabled === true && latestThoughtEntry + ? { liveThinking: true as const } + : {}), }; })() : null; @@ -2102,6 +2111,7 @@ export function deriveMessagesTimelineRows(input: { !compactionOnlyRunFinished && (latestToolFailed || (!hasActivityRow && !input.pendingApprovalRequestIds?.size)) ) { + const liveThought = input.liveThinkingEnabled === true ? latestThoughtEntry?.entry : undefined; const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined; if (failedGroupAnchor) { const groupId = workGroupId(failedGroupAnchor.id); @@ -2112,6 +2122,7 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt ?? null, groupId, expanded, + ...(liveThought ? { thought: liveThought } : {}), }); if (expanded) { nextRows.push( @@ -2127,6 +2138,7 @@ export function deriveMessagesTimelineRows(input: { kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt: input.activeTurnStartedAt ?? null, + ...(liveThought ? { thought: liveThought } : {}), }); } } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a9cab1554a3d3..599df4b1f4d14 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -24,6 +24,7 @@ import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/s import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; import { observeResize } from "~/lib/observeResize"; import { useStableCallback } from "~/hooks/useStableCallback"; +import { useEnvironmentSettings } from "../../hooks/useSettings"; const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; const EMPTY_PENDING_APPROVALS: ReadonlyArray = []; @@ -393,6 +394,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ topFadeEnabled = false, historyControls, }: MessagesTimelineProps) { + // Labs live-thinking: the live Thinking row expands into the streaming thought. + const liveThinkingEnabled = useEnvironmentSettings( + activeThreadEnvironmentId, + (settings) => settings.liveThinkingEnabled, + ); const listIdentityKey = displayThreadKey ?? routeThreadKey; // Snapshot restore positions on identity changes; ongoing scroll writes must not move them. // Find open at the opening is part of the snapshot: opening find later @@ -674,6 +680,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ expandedAttemptIds: visibleExpandedAttemptIds, expandedWorkGroupIds, isWorking, + liveThinkingEnabled, runlessWorkActive, activeTurnStartedAt, turnDiffSummaries, diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index a440b62a584d2..e60efe4fcf81b 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -360,23 +360,40 @@ function CompactingLabel({ createdAt }: { createdAt: string | null }) { export function ThinkingTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); + // Labs live-thinking: local expansion of the streaming thought below the row. + const [thoughtExpanded, setThoughtExpanded] = useState(false); if (isPreparingWorktree || isCompacting) return ; // A failed group keeps this disclosure while an approval waits, but nothing is thinking then. const live = ctx.pendingApprovals.size === 0; const activity = ( ); - const { groupId } = row; - if (groupId === undefined) return activity; + const { groupId, thought } = row; + if (thought === undefined) { + if (groupId === undefined) return activity; + return ( + + ); + } return ( - +
+ + {thoughtExpanded ? : null} +
); } @@ -490,6 +507,8 @@ export function LiveWorkEntryTimelineRow({ row: Extract; }) { const ctx = use(TimelineRowCtx); + // Labs live-thinking: local expansion of the streaming thought below the row. + const [thoughtExpanded, setThoughtExpanded] = useState(false); const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId); const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const threadLabel = threadReadLabel(label, threadTarget); @@ -500,10 +519,16 @@ export function LiveWorkEntryTimelineRow({ if (pendingApproval) return ; const failed = workEntryDisplayIndicatesToolFailure(row.entry); - const thoughtLine = row.thought && !row.expanded ? liveThoughtLine(row.thought.detail ?? "") : ""; + const liveThought = row.liveThinking === true ? row.thought : undefined; + const thoughtLine = + liveThought !== undefined && thoughtExpanded + ? "" + : row.thought && !row.expanded + ? liveThoughtLine(row.thought.detail ?? "") + : ""; const thoughtIsStatus = thoughtLine !== "" && row.entry === row.thought; - return ( + const button = ( ); + + if (liveThought === undefined || !thoughtExpanded) return button; + return ( +
+ {button} + +
+ ); } export function WorkGroupToggleTimelineRow({ diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index ce3f68eac49d0..8bbe539d8db59 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -18,6 +18,7 @@ export function LabsSettingsPanel() { + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -69,6 +70,24 @@ function BoringSidebarMultilineRow() { ); } +function LiveThinkingRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + updateSettings({ liveThinkingEnabled: Boolean(checked) })} + aria-label="Live thinking" + /> + } + /> + ); +} + /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index dc494caf5344d..e8bd4f0dc9621 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -455,6 +455,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"], }, + { + id: "live-thinking", + title: "Live thinking", + to: "/settings/labs", + searchTerms: ["beta reasoning thought stream expand watch agent think transcript"], + }, { id: "guest-links", title: "Guest links", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 179e58b7a0b56..fcb51b1096b84 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -472,6 +472,8 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("steer")), ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Labs: the live "Thinking" row expands to the full streaming thought mid-turn. + liveThinkingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), boringSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), boringSidebarMultilineRows: Schema.Boolean.pipe( @@ -1818,6 +1820,7 @@ export const ClientSettingsPatch = Schema.Struct({ sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), + liveThinkingEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean), From 505e655414a2df2aebd7407dd4dfd4a5f80220fc Mon Sep 17 00:00:00 2001 From: tycebot <89507652+tycebot@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:36:41 -0700 Subject: [PATCH 2/4] polish(web): live-thinking row shows a short status header when expanded When the live Thinking row is expanded, the button now shows a short Thinking/Thought status label instead of repeating the full thought text, which renders once below in the trace. Avoids the thought appearing twice. --- apps/web/src/components/chat/TimelineWorkGroupRows.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index e60efe4fcf81b..5361b3e7284ab 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -527,6 +527,9 @@ export function LiveWorkEntryTimelineRow({ ? liveThoughtLine(row.thought.detail ?? "") : ""; const thoughtIsStatus = thoughtLine !== "" && row.entry === row.thought; + // Expanded, the button is a short status header; the full thought renders below it. + const expandedIsThought = + liveThought !== undefined && thoughtExpanded && row.entry === row.thought; const button = ( + {groupDisclosure} {thoughtExpanded ? : null} ); @@ -531,6 +540,14 @@ export function LiveWorkEntryTimelineRow({ const expandedIsThought = liveThought !== undefined && thoughtExpanded && row.entry === row.thought; + const groupDisclosure = + liveThought === undefined ? null : ( + ctx.onToggleWorkGroup(row.groupId, row.id)} + /> + ); const button = (