Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
Expand All @@ -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}
Expand Down Expand Up @@ -2858,6 +2859,7 @@ interface SidebarProjectsContentProps {
suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void;
projectsLength: number;
navigateToThread: (threadRef: ScopedThreadRef) => void;
}

const SidebarProjectsContent = memo(function SidebarProjectsContent(
Expand Down Expand Up @@ -2923,6 +2925,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(

return (
<SidebarContent
onPendingQuestionNavigate={props.navigateToThread}
className="gap-0"
fixedHeader={
// Lifted above the stage backdrop, whose fade bleeds below the
Expand Down Expand Up @@ -3762,6 +3765,7 @@ export default function LegacySidebar() {
suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
projectsLength={projects.length}
navigateToThread={navigateToThread}
/>
<SidebarChromeFooter />
</>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1321,6 +1322,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
return (
<li
data-thread-item
data-pending-question={thread.hasPendingUserInput || undefined}
className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
>
<Tooltip>
Expand Down Expand Up @@ -1472,6 +1474,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
return (
<li
data-thread-item
data-pending-question={thread.hasPendingUserInput || undefined}
ref={sortable?.setNodeRef}
style={
sortable
Expand Down Expand Up @@ -3604,6 +3607,7 @@ export default function Sidebar() {
<>
<SidebarChromeHeader isElectron={isElectron} />
<SidebarContent
onPendingQuestionNavigate={navigateToThread}
className="gap-0"
fixedHeader={
// Lifted above the stage backdrop, whose fade bleeds below the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CheckIcon } from "lucide-react";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
import { cn } from "~/lib/utils";
import { ComposerBanner } from "./ComposerBanner";
import ChatMarkdown from "../ChatMarkdown";

interface PendingUserInputPanelProps {
pendingUserInputs: PendingUserInput[];
Expand Down Expand Up @@ -202,7 +203,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
</CollapsibleTrigger>
<CollapsiblePanel>
<ComposerBanner.Body className="pe-1 pb-1">
<p className="text-sm text-foreground/85">{activeQuestion.question}</p>
<ChatMarkdown
text={activeQuestion.question}
cwd={undefined}
className="text-sm text-foreground/85"
/>
{activeQuestion.multiSelect ? (
<p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
) : null}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -3192,7 +3194,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
<p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed">
<span
className={cn(
"min-w-0 flex-1",
"min-w-0",
workEntry.userInputSummary ? "shrink-0" : "flex-1",
expanded || (commandMatchesVisibleLabel && !canExpand)
? "whitespace-pre-wrap break-words select-text"
: "truncate",
Expand All @@ -3203,6 +3206,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
>
{previewText}
</span>
{workEntry.userInputSummary ? (
<span className="min-w-0 truncate text-secondary-label">
{workEntry.userInputSummary}
</span>
) : null}
</p>
</div>
{showFailedIndicator &&
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/sidebar/SidebarQuestionIndicators.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const count = pending.length;

return (
<div className="shrink-0 px-2 pb-2">
<Button
variant="outline"
size="sm"
disabled={count === 0}
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]"
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
onClick={() => {
const nextIndex =
(pending.findIndex((ref) => scopedThreadKey(ref) === lastTarget.current) + 1) % count;
const next = pending[nextIndex];
if (!next) return;
lastTarget.current = scopedThreadKey(next);
onNavigate(next);
Comment thread
maria-rcks marked this conversation as resolved.
}}
>
<span>
Needs input · <span className="tabular-nums">{count}</span>
</span>
<ArrowDownIcon aria-hidden="true" className="size-4" />
</Button>
</div>
);
}
30 changes: 19 additions & 11 deletions apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -692,24 +693,31 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
function SidebarContent({
className,
fixedHeader,
onPendingQuestionNavigate,
...props
}: React.ComponentProps<"div"> & {
fixedHeader?: React.ReactNode;
onPendingQuestionNavigate?: React.ComponentProps<typeof SidebarQuestionIndicators>["onNavigate"];
}) {
return (
<>
{fixedHeader ? <div className="w-full shrink-0">{fixedHeader}</div> : null}
<ScrollArea hideScrollbars scrollFade className="h-auto min-h-0 flex-1">
<div
className={cn(
"flex w-full min-w-0 flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",
className,
)}
data-sidebar="content"
data-slot="sidebar-content"
{...props}
/>
</ScrollArea>
<div className="relative flex min-h-0 flex-1 flex-col">
Comment thread
maria-rcks marked this conversation as resolved.
{onPendingQuestionNavigate ? (
<SidebarQuestionIndicators onNavigate={onPendingQuestionNavigate} />
) : null}
<ScrollArea hideScrollbars scrollFade className="h-auto min-h-0 flex-1">
<div
className={cn(
"flex w-full min-w-0 flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",
className,
)}
data-sidebar="content"
data-slot="sidebar-content"
{...props}
/>
</ScrollArea>
</div>
</>
);
}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1909,3 +1909,14 @@ code {
transform: scaleX(0.9);
}
}

/* Static emphasis keeps pending questions visible without ongoing animation. */
.sidebar-question-pending::after {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
content: "";
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
border: 1px solid var(--color-indigo-400);
border-radius: inherit;
}
59 changes: 58 additions & 1 deletion apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface WorkLogEntry {
toolCallId?: string;
label: string;
detail?: string;
userInputSummary?: string;
viewedImagePath?: string;
command?: string;
rawCommand?: string;
Expand Down Expand Up @@ -845,6 +846,7 @@ export function deriveWorkLogEntries(
): WorkLogEntry[] {
const ordered = [...activities].toSorted(compareActivitiesByOrder);
const entries: DerivedWorkLogEntry[] = [];
const questionsByRequestId = new Map<string, ReadonlyArray<UserInputQuestion>>();
for (const activity of ordered) {
if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
if (activity.kind === "tool.started") continue;
Expand All @@ -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);
}
Expand Down
Loading