From 2681bcb6d4f0806209695875586d9bcd8b28655c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 22:01:17 +0000 Subject: [PATCH 1/5] feat(web): highlight pending questions and show submitted answers --- apps/web/src/components/LegacySidebar.tsx | 3 +- apps/web/src/components/Sidebar.tsx | 3 + .../chat/ComposerPendingUserInputPanel.tsx | 7 +- .../components/chat/MessagesTimeline.logic.ts | 1 + .../src/components/chat/MessagesTimeline.tsx | 12 ++- .../sidebar/SidebarQuestionIndicators.tsx | 88 +++++++++++++++++++ apps/web/src/components/ui/sidebar.tsx | 27 +++--- apps/web/src/index.css | 37 ++++++++ apps/web/src/session-logic.ts | 59 ++++++++++++- 9 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ea910905efe..dcf96216554 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -702,6 +702,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ref={rowRef} className="w-full" data-thread-item + data-pending-question={thread.hasPendingUserInput || undefined} onMouseLeave={handleMouseLeave} onBlurCapture={handleBlurCapture} > @@ -713,7 +714,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr className={`${resolveThreadRowClassName({ isActive, isSelected, - })} relative isolate`} + })} relative isolate${thread.hasPendingUserInput ? " sidebar-question-pending" : ""}`} onClick={handleRowClick} onDoubleClick={handleRowDoubleClick} onKeyDown={handleRowKeyDown} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4509fde6ceb..4bfecf320d3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1176,6 +1176,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", + thread.hasPendingUserInput && "sidebar-question-pending", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected @@ -1321,6 +1322,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • @@ -1472,6 +1474,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • -

    {activeQuestion.question}

    + {activeQuestion.multiSelect ? (

    Select one or more options.

    ) : null} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 36c90c89563..0da14a8e704 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -48,6 +48,7 @@ function singleToolCallLabel(entry: WorkLogEntry): string { } export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) { + if (entry.userInputSummary) return entry.label; const toolPresentation = resolveWorkEntryToolPresentation(entry); if (toolPresentation) return toolPresentation.displayName; if (entry.command) return entry.command; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4e55991f870..f46f767fddd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3147,7 +3147,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { : "text-foreground/80"; const accessibleDisplayText = showFailedIndicator ? `${previewText}, tool call failed` - : previewText; + : workEntry.userInputSummary + ? `${previewText}: ${workEntry.userInputSummary}` + : previewText; const rowToggleProps = canExpand ? { role: "button" as const, @@ -3192,7 +3194,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

    {previewText} + {workEntry.userInputSummary ? ( + + {workEntry.userInputSummary} + + ) : null}

    {showFailedIndicator && diff --git a/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx b/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx new file mode 100644 index 00000000000..a215d7159d2 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx @@ -0,0 +1,88 @@ +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import { useEffect, useRef, useState, type RefObject } from "react"; + +/** Tracks only pending rows, and only when the list or its viewport changes. */ +export function SidebarQuestionIndicators({ + containerRef, +}: { + containerRef: RefObject; +}) { + const targets = useRef<{ above: HTMLElement | null; below: HTMLElement | null }>({ + above: null, + below: null, + }); + const [directions, setDirections] = useState({ above: false, below: false }); + + useEffect(() => { + const container = containerRef.current; + const viewport = container?.querySelector('[data-slot="scroll-area-viewport"]'); + const content = container?.querySelector('[data-sidebar="content"]'); + if (!viewport || !content) return; + let rows: HTMLElement[] = []; + const measure = () => { + const bounds = viewport.getBoundingClientRect(); + // A row inside the scroll fade is no longer a useful visible target. + const top = bounds.top + 24; + const bottom = bounds.bottom - 24; + let above: HTMLElement | null = null; + let below: HTMLElement | null = null; + for (const row of rows) { + const rect = row.getBoundingClientRect(); + const visible = rect.height > 0 && rect.bottom > top && rect.top < bottom; + row.toggleAttribute("data-question-visible", visible); + if (rect.height === 0) continue; + if (rect.bottom <= top) above = row; + if (rect.top >= bottom && below === null) below = row; + } + targets.current = { above, below }; + setDirections((current) => + current.above === (above !== null) && current.below === (below !== null) + ? current + : { above: above !== null, below: below !== null }, + ); + }; + const reconcile = () => { + rows = Array.from(content.querySelectorAll("[data-pending-question]")); + measure(); + }; + const mutations = new MutationObserver(reconcile); + mutations.observe(content, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-pending-question"], + }); + const resize = new ResizeObserver(measure); + resize.observe(viewport); + resize.observe(content); + viewport.addEventListener("scroll", measure, { passive: true }); + reconcile(); + return () => { + mutations.disconnect(); + resize.disconnect(); + viewport.removeEventListener("scroll", measure); + }; + }, [containerRef]); + + return (["above", "below"] as const).map((direction) => { + if (!directions[direction]) return null; + const Icon = direction === "above" ? ArrowUpIcon : ArrowDownIcon; + return ( + + ); + }); +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 624798e19f5..871ff9e1fc9 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -7,6 +7,7 @@ import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { SidebarQuestionIndicators } from "~/components/sidebar/SidebarQuestionIndicators"; import { Separator } from "~/components/ui/separator"; import { Sheet, @@ -696,20 +697,24 @@ function SidebarContent({ }: React.ComponentProps<"div"> & { fixedHeader?: React.ReactNode; }) { + const containerRef = React.useRef(null); return ( <> {fixedHeader ?
    {fixedHeader}
    : null} - -
    - +
    + +
    + + +
    ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 54bb5b70d5f..92cf11a4b6a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1909,3 +1909,40 @@ code { transform: scaleX(0.9); } } + +/* Match terminal status cadence; only visible pending rows animate. */ +.sidebar-question-pending::after { + content: ""; + position: absolute; + inset: 0; + z-index: 20; + pointer-events: none; + border: 1px solid var(--color-indigo-400); + border-radius: inherit; + animation: status-pulse 2s infinite paused; +} + +[data-question-visible] .sidebar-question-pending::after { + animation-play-state: running; +} + +.sidebar-question-arrow { + animation: sidebar-question-nudge 2s steps(4) infinite; +} + +@keyframes sidebar-question-nudge { + 0%, + 100% { + transform: translateY(-2px); + } + 50% { + transform: translateY(2px); + } +} + +@media (prefers-reduced-motion: reduce) { + .sidebar-question-pending::after, + .sidebar-question-arrow { + animation: none; + } +} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 47fe1b49ac3..31c350c2ce7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -88,6 +88,7 @@ export interface WorkLogEntry { toolCallId?: string; label: string; detail?: string; + userInputSummary?: string; viewedImagePath?: string; command?: string; rawCommand?: string; @@ -845,6 +846,7 @@ export function deriveWorkLogEntries( ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; + const questionsByRequestId = new Map>(); for (const activity of ordered) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; @@ -861,7 +863,62 @@ export function deriveWorkLogEntries( if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; - entries.push(toDerivedWorkLogEntry(activity)); + const entry = toDerivedWorkLogEntry(activity); + if (activity.kind === "user-input.requested" || activity.kind === "user-input.resolved") { + const payload = asRecord(activity.payload); + const requestId = asTrimmedString(payload?.requestId); + if (activity.kind === "user-input.requested" && requestId) { + const questions = parseUserInputQuestions(payload); + if (questions) questionsByRequestId.set(requestId, questions); + } else if (activity.kind === "user-input.resolved") { + const answers = asRecord(payload?.answers); + if (answers) { + const questions = requestId ? questionsByRequestId.get(requestId) : undefined; + const submittedAnswers = Object.entries(answers).flatMap(([id, value]) => { + const question = questions?.find((question) => question.id === id); + const values = + typeof value === "string" + ? [value] + : Array.isArray(value) + ? value + : asRecord(value)?.answers; + const answer = Array.isArray(values) + ? values + .filter((part): part is string => typeof part === "string") + .map( + (part) => + question?.options.find((option) => (option.value ?? option.label) === part) + ?.label ?? part, + ) + .join(", ") + : ""; + return answer.trim() ? [{ id, answer }] : []; + }); + const detail = submittedAnswers + .map(({ id, answer }) => { + const question = questions?.find((question) => question.id === id); + if (!question) return `${id}\nAnswer: ${answer}`; + const options = question.options.map( + (option) => + `- ${option.label}${option.description ? `: ${option.description}` : ""}`, + ); + return [question.header, question.question, ...options, `Answer: ${answer}`].join( + "\n", + ); + }) + .join("\n\n"); + if (detail) { + entries.push({ + ...entry, + userInputSummary: submittedAnswers.map(({ answer }) => answer).join("; "), + detail: [detail, entry.detail].filter(Boolean).join("\n\n"), + }); + continue; + } + } + } + } + entries.push(entry); } return collapseDerivedWorkLogEntries(entries); } From 163e630ee2a35fe6acd938299ce57e046aabd8da Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 22:06:23 +0000 Subject: [PATCH 2/5] fix(web): use shared button for question indicators --- .../components/sidebar/SidebarQuestionIndicators.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx b/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx index a215d7159d2..94659b092d5 100644 --- a/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx +++ b/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx @@ -1,5 +1,6 @@ import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; import { useEffect, useRef, useState, type RefObject } from "react"; +import { Button } from "~/components/ui/button"; /** Tracks only pending rows, and only when the list or its viewport changes. */ export function SidebarQuestionIndicators({ @@ -68,11 +69,12 @@ export function SidebarQuestionIndicators({ if (!directions[direction]) return null; const Icon = direction === "above" ? ArrowUpIcon : ArrowDownIcon; return ( - + ); }); } From 0e0f7a09d738e159f268c19df69ee6b6c137fdeb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 22:54:37 +0000 Subject: [PATCH 3/5] fix(web): keep pending question navigation still --- .../sidebar/SidebarQuestionIndicators.tsx | 93 +++++++------------ apps/web/src/components/ui/sidebar.tsx | 2 +- apps/web/src/index.css | 28 +----- 3 files changed, 34 insertions(+), 89 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx b/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx index 94659b092d5..8fa13d84a82 100644 --- a/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx +++ b/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx @@ -1,50 +1,23 @@ -import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import { ArrowDownIcon } from "lucide-react"; import { useEffect, useRef, useState, type RefObject } from "react"; import { Button } from "~/components/ui/button"; -/** Tracks only pending rows, and only when the list or its viewport changes. */ +/** Keeps pending questions reachable without changing the list's order. */ export function SidebarQuestionIndicators({ containerRef, }: { containerRef: RefObject; }) { - const targets = useRef<{ above: HTMLElement | null; below: HTMLElement | null }>({ - above: null, - below: null, - }); - const [directions, setDirections] = useState({ above: false, below: false }); + const rows = useRef([]); + const lastTarget = useRef(null); + const [count, setCount] = useState(0); useEffect(() => { - const container = containerRef.current; - const viewport = container?.querySelector('[data-slot="scroll-area-viewport"]'); - const content = container?.querySelector('[data-sidebar="content"]'); - if (!viewport || !content) return; - let rows: HTMLElement[] = []; - const measure = () => { - const bounds = viewport.getBoundingClientRect(); - // A row inside the scroll fade is no longer a useful visible target. - const top = bounds.top + 24; - const bottom = bounds.bottom - 24; - let above: HTMLElement | null = null; - let below: HTMLElement | null = null; - for (const row of rows) { - const rect = row.getBoundingClientRect(); - const visible = rect.height > 0 && rect.bottom > top && rect.top < bottom; - row.toggleAttribute("data-question-visible", visible); - if (rect.height === 0) continue; - if (rect.bottom <= top) above = row; - if (rect.top >= bottom && below === null) below = row; - } - targets.current = { above, below }; - setDirections((current) => - current.above === (above !== null) && current.below === (below !== null) - ? current - : { above: above !== null, below: below !== null }, - ); - }; + const content = containerRef.current?.querySelector('[data-sidebar="content"]'); + if (!content) return; const reconcile = () => { - rows = Array.from(content.querySelectorAll("[data-pending-question]")); - measure(); + rows.current = Array.from(content.querySelectorAll("[data-pending-question]")); + setCount(rows.current.length); }; const mutations = new MutationObserver(reconcile); mutations.observe(content, { @@ -53,38 +26,36 @@ export function SidebarQuestionIndicators({ attributes: true, attributeFilter: ["data-pending-question"], }); - const resize = new ResizeObserver(measure); - resize.observe(viewport); - resize.observe(content); - viewport.addEventListener("scroll", measure, { passive: true }); reconcile(); - return () => { - mutations.disconnect(); - resize.disconnect(); - viewport.removeEventListener("scroll", measure); - }; + return () => mutations.disconnect(); }, [containerRef]); - return (["above", "below"] as const).map((direction) => { - if (!directions[direction]) return null; - const Icon = direction === "above" ? ArrowUpIcon : ArrowDownIcon; - return ( + return ( +
    - ); - }); +
    + ); } diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 871ff9e1fc9..88857a124b3 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -702,6 +702,7 @@ function SidebarContent({ <> {fixedHeader ?
    {fixedHeader}
    : null}
    +
    -
    ); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 92cf11a4b6a..226eaa9bda2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1910,7 +1910,7 @@ code { } } -/* Match terminal status cadence; only visible pending rows animate. */ +/* Static emphasis keeps pending questions visible without ongoing animation. */ .sidebar-question-pending::after { content: ""; position: absolute; @@ -1919,30 +1919,4 @@ code { pointer-events: none; border: 1px solid var(--color-indigo-400); border-radius: inherit; - animation: status-pulse 2s infinite paused; -} - -[data-question-visible] .sidebar-question-pending::after { - animation-play-state: running; -} - -.sidebar-question-arrow { - animation: sidebar-question-nudge 2s steps(4) infinite; -} - -@keyframes sidebar-question-nudge { - 0%, - 100% { - transform: translateY(-2px); - } - 50% { - transform: translateY(2px); - } -} - -@media (prefers-reduced-motion: reduce) { - .sidebar-question-pending::after, - .sidebar-question-arrow { - animation: none; - } } From 1ca06f03a8c6a7e5beac410dd498660de9de46d1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 22:57:42 +0000 Subject: [PATCH 4/5] fix(web): limit question navigation to thread sidebars --- apps/web/src/components/LegacySidebar.tsx | 1 + apps/web/src/components/Sidebar.tsx | 1 + apps/web/src/components/ui/sidebar.tsx | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index dcf96216554..a06953e42a2 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2924,6 +2924,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( return ( & { fixedHeader?: React.ReactNode; + showPendingQuestions?: boolean; }) { const containerRef = React.useRef(null); return ( <> {fixedHeader ?
    {fixedHeader}
    : null}
    - + {showPendingQuestions ? : null}
    Date: Fri, 4 Sep 2026 23:05:00 +0000 Subject: [PATCH 5/5] fix(web): navigate pending questions from full thread state --- apps/web/src/components/LegacySidebar.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 2 +- .../sidebar/SidebarQuestionIndicators.tsx | 57 ++++++++----------- apps/web/src/components/ui/sidebar.tsx | 11 ++-- 4 files changed, 33 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index a06953e42a2..a24955f54a6 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2859,6 +2859,7 @@ interface SidebarProjectsContentProps { suppressProjectClickForContextMenuRef: React.RefObject; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; + navigateToThread: (threadRef: ScopedThreadRef) => void; } const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -2924,7 +2925,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( return ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 41fe63214d3..c74d6a908e1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3607,7 +3607,7 @@ export default function Sidebar() { <> ; + onNavigate: (threadRef: ScopedThreadRef) => void; }) { - const rows = useRef([]); - const lastTarget = useRef(null); - const [count, setCount] = useState(0); - - useEffect(() => { - const content = containerRef.current?.querySelector('[data-sidebar="content"]'); - if (!content) return; - const reconcile = () => { - rows.current = Array.from(content.querySelectorAll("[data-pending-question]")); - setCount(rows.current.length); - }; - const mutations = new MutationObserver(reconcile); - mutations.observe(content, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ["data-pending-question"], - }); - reconcile(); - return () => mutations.disconnect(); - }, [containerRef]); + const threads = useThreadShells(); + const pending = useMemo( + () => + threads + .filter((thread) => thread.archivedAt === null && thread.hasPendingUserInput) + .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map((thread) => scopeThreadRef(thread.environmentId, thread.id)), + [threads], + ); + const lastTarget = useRef(null); + const count = pending.length; return (
    @@ -39,16 +32,12 @@ export function SidebarQuestionIndicators({ aria-label={`Next pending question (${count})`} className="w-full justify-between border-indigo-400/50 bg-sidebar text-indigo-600 transition-none active:scale-100 dark:bg-sidebar dark:text-indigo-300 [--control-icon-color:currentColor]" onClick={() => { - const candidates = rows.current.filter((row) => row.getClientRects().length > 0); const nextIndex = - (candidates.findIndex((row) => row === lastTarget.current) + 1) % candidates.length; - const row = candidates[nextIndex]; - if (!row) return; - lastTarget.current = row; - row.scrollIntoView({ block: "center", behavior: "instant" }); - const control = row.querySelector('[role="button"], a, button'); - control?.focus({ preventScroll: true }); - control?.click(); + (pending.findIndex((ref) => scopedThreadKey(ref) === lastTarget.current) + 1) % count; + const next = pending[nextIndex]; + if (!next) return; + lastTarget.current = scopedThreadKey(next); + onNavigate(next); }} > diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 5c127f097f2..9341ce94f89 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -693,18 +693,19 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps & { fixedHeader?: React.ReactNode; - showPendingQuestions?: boolean; + onPendingQuestionNavigate?: React.ComponentProps["onNavigate"]; }) { - const containerRef = React.useRef(null); return ( <> {fixedHeader ?
    {fixedHeader}
    : null} -
    - {showPendingQuestions ? : null} +
    + {onPendingQuestionNavigate ? ( + + ) : null}