diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ea910905efe0..a24955f54a64 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} @@ -2858,6 +2859,7 @@ interface SidebarProjectsContentProps { suppressProjectClickForContextMenuRef: React.RefObject; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; + navigateToThread: (threadRef: ScopedThreadRef) => void; } const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -2923,6 +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 4509fde6ceb9..c74d6a908e1b 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 36c90c89563c..0da14a8e7048 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 4e55991f8703..f46f767fddd6 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 000000000000..11b051653cca --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx @@ -0,0 +1,50 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { ArrowDownIcon } from "lucide-react"; +import { useMemo, useRef } from "react"; +import { Button } from "~/components/ui/button"; +import { useThreadShells } from "~/state/entities"; + +/** Keeps all pending questions reachable, including threads in collapsed lists. */ +export function SidebarQuestionIndicators({ + onNavigate, +}: { + onNavigate: (threadRef: ScopedThreadRef) => void; +}) { + 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 ( +
    + +
    + ); +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 624798e19f54..9341ce94f891 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, @@ -692,24 +693,31 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps & { fixedHeader?: React.ReactNode; + onPendingQuestionNavigate?: React.ComponentProps["onNavigate"]; }) { return ( <> {fixedHeader ?
    {fixedHeader}
    : null} - -
    - +
    + {onPendingQuestionNavigate ? ( + + ) : null} + +
    + +
    ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 54bb5b70d5fa..226eaa9bda2f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1909,3 +1909,14 @@ code { transform: scaleX(0.9); } } + +/* Static emphasis keeps pending questions visible without ongoing animation. */ +.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; +} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 47fe1b49ac32..31c350c2ce79 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); }