Skip to content
Merged
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
4 changes: 3 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2568,8 +2568,10 @@ describe("ClaudeAdapterLive", () => {
index: 1,
delta: {
type: "input_json_delta",
// The model sometimes mis-closes the subject tag and the harness
// folds the next parameters into it; the plan step must stay clean.
partial_json:
'{"subject":"Investigate flaky login test","description":"Find the race in the login spec"}',
'{"subject":"Investigate flaky login test</subject>\\n<parameter name=\\"description\\">Find the race in the login spec</parameter>\\n<parameter name=\\"activeForm\\">Investigating","description":"Find the race in the login spec"}',
},
},
} as unknown as SDKMessage);
Expand Down
24 changes: 18 additions & 6 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,18 @@ function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}

/** When the model mis-closes the subject parameter of a task tool call
* (`</subject>` instead of `</parameter>`), Claude Code's lenient tool-call
* parser folds the following parameters into the subject string, so the whole
* description lands in the plan step. Cut a stray closing tag that is followed
* by another parameter or by the end of the string. */
const LEAKED_PARAMETER_TAIL_PATTERN = /<\/[\w-]+>\s*(?:<parameter\b[\s\S]*)?$/;

function taskSubject(value: unknown): string | undefined {
const subject = nonEmptyString(value);
return subject ? nonEmptyString(subject.replace(LEAKED_PARAMETER_TAIL_PATTERN, "")) : undefined;
}

type PlanTrackerTask = {
readonly subject: string;
readonly status: "pending" | "inProgress" | "completed";
Expand Down Expand Up @@ -1238,7 +1250,7 @@ function applyPlanTrackerToolInput(
): boolean {
if (kind === "create") {
const key = `${PROVISIONAL_PLAN_TASK_KEY_PREFIX}${toolUseId}`;
const subject = nonEmptyString(input.subject) ?? "Task";
const subject = taskSubject(input.subject) ?? "Task";
const existing = planTracker.get(key);
if (existing?.subject === subject) {
return false;
Expand All @@ -1257,7 +1269,7 @@ function applyPlanTrackerToolInput(
return planTracker.delete(taskId);
}
const status = planTrackerStatus(input.status);
const subject = nonEmptyString(input.subject);
const subject = taskSubject(input.subject);
const existing = planTracker.get(taskId);
if (!existing) {
// Updates can reference tasks created before this process attached
Expand Down Expand Up @@ -1303,7 +1315,7 @@ function applyPlanTrackerToolResult(
return false;
}
planTracker.set(taskId, {
subject: nonEmptyString(match?.[2]) ?? `Task #${taskId}`,
subject: taskSubject(match?.[2]) ?? `Task #${taskId}`,
status: "pending",
});
return true;
Expand Down Expand Up @@ -1331,7 +1343,7 @@ function applyPlanTrackerToolResult(
parsed.push([
match[1] as string,
{
subject: nonEmptyString(match[3]) ?? `Task #${match[1]}`,
subject: taskSubject(match[3]) ?? `Task #${match[1]}`,
status: planTrackerStatus(match[2]) ?? "pending",
},
]);
Expand Down Expand Up @@ -1551,7 +1563,7 @@ function summarizeToolRequest(
break;
}
case "taskcreate": {
const subject = text(input.subject);
const subject = taskSubject(input.subject);
if (subject) {
return `Add task: ${subject.slice(0, 200)}`;
}
Expand All @@ -1570,7 +1582,7 @@ function summarizeToolRequest(
if (status === "inProgress") {
return `Task #${taskId} started`;
}
const subject = text(input.subject);
const subject = taskSubject(input.subject);
if (subject) {
return `Task #${taskId}: ${subject.slice(0, 200)}`;
}
Expand Down
46 changes: 3 additions & 43 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ import {
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { Textarea } from "../ui/textarea";
import { SpineRow, spineAccentRowStyle } from "../ui/threadline";
import { SpineNode, SpineRow, spineAccentRowStyle, type SpineNodeKind } from "../ui/threadline";
import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
import type { FilePreviewRequest } from "./FilePreviewDialog";
import { loadChatAttachmentBlob } from "../../lib/attachmentPreviewQuery";
Expand Down Expand Up @@ -2713,48 +2713,6 @@ function LiveMessageMeta({
// re-render only the affected row, not the entire list.
// ---------------------------------------------------------------------------

/** Owns its own expand/collapse state so toggling re-renders only this row.
* State resets on unmount which is fine — work groups start collapsed. */
type SpineNodeKind = "done" | "running" | "warning" | "error" | "group";

const TONE_SPINE_DOT_CLASS_NAME = {
warning: "size-[6px] rounded-full bg-warning",
error: "size-[6px] rounded-full bg-destructive",
} as const satisfies Record<"warning" | "error", string>;

/** The glyph that sits on the activity spine for one row. The accent halo is
* reserved for the turn's working row below the timeline tail; a still-running
* step gets a small accent tick, settled steps are quiet solid dots,
* warnings/errors are compact tone dots, and a collapsed group of steps is a
* hollow ring (same family, reads as "openable"). */
function SpineNode({ kind }: { kind: SpineNodeKind }) {
if (kind === "running") {
return (
<span
aria-hidden="true"
className="size-[5px] animate-status-pulse rounded-full bg-primary-graph/80"
/>
);
}
if (kind === "warning" || kind === "error") {
return <span aria-hidden="true" className={TONE_SPINE_DOT_CLASS_NAME[kind]} />;
}
if (kind === "group") {
return (
<span
aria-hidden="true"
className="size-[7px] rounded-full border border-muted-foreground/45 bg-background"
/>
);
}
return (
<span
aria-hidden="true"
className="relative z-10 size-[5px] rounded-full bg-[color-mix(in_oklab,var(--muted-foreground)_42%,var(--background))]"
/>
);
}

function workEntryNodeKind(entry: TimelineWorkEntry): SpineNodeKind {
if (entry.tone === "error") {
return "error";
Expand Down Expand Up @@ -2785,6 +2743,8 @@ function spineStyle(): CSSProperties {
} as CSSProperties;
}

/** Owns its own expand/collapse state so toggling re-renders only this row.
* State resets on unmount which is fine — work groups start collapsed. */
const WorkGroupSection = memo(function WorkGroupSection({
row,
}: {
Expand Down
73 changes: 34 additions & 39 deletions apps/web/src/components/chat/ThreadActivityPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
type RefObject,
} from "react";
import {
CheckIcon,
ChevronDownIcon,
ClockIcon,
ExternalLinkIcon,
Expand All @@ -30,6 +29,7 @@ import { type ActivePlanState, type LatestProposedPlanState } from "../../sessio
import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { SpineNode, SpineRow, spineAccentRowStyle, type SpineNodeKind } from "../ui/threadline";
import { Tooltip, TooltipPopup, TooltipTrigger, TooltipWrapper } from "../ui/tooltip";
import {
backgroundRunCommandText,
Expand Down Expand Up @@ -338,36 +338,26 @@ function TriggerContent({ state }: { state: ActivityTriggerState }) {
);
}

function taskStatusIcon(status: ActivePlanState["steps"][number]["status"]): ReactNode {
if (status === "completed") {
return (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-success/20 text-success">
<CheckIcon className="size-2.5" aria-hidden="true" />
</span>
);
}

if (status === "inProgress") {
return (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary/20 text-primary-readable">
<LoaderIcon className="size-2.5 animate-spin" aria-hidden="true" />
</span>
);
}
type PlanStepStatus = ActivePlanState["steps"][number]["status"];

return (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full border border-border/70 bg-muted/45">
<span className="size-1.5 rounded-full bg-muted-foreground/45" />
</span>
);
function taskStepNodeKind(status: PlanStepStatus): SpineNodeKind {
if (status === "completed") return "done";
if (status === "inProgress") return "running";
return "pending";
}

function taskStatusLabel(status: ActivePlanState["steps"][number]["status"]): string {
/** Spoken status for each step; the spine node carries it visually. */
function taskStatusLabel(status: PlanStepStatus): string {
if (status === "completed") return "Done";
if (status === "inProgress") return "Now";
return "Next";
}

// Step rows are 12px text on a 16px line over 4px of top padding, so the node
// lands on the first line's centre.
const TASK_STEP_NODE_OFFSET_PX = 12;
const TASK_SPINE_STYLE = { ["--spine"]: "var(--border)" } as CSSProperties;

function taskSummary(activePlan: ActivePlanState | null, activeProposedPlan: boolean): string {
if (!activePlan) {
return activeProposedPlan ? "Plan ready to implement" : "No current tasks";
Expand Down Expand Up @@ -532,6 +522,7 @@ function TaskSection({
shouldCollapsePlanSteps && !expanded && collapsedWindow
? planStepRows.slice(collapsedWindow.start, collapsedWindow.end)
: planStepRows;
const liveStepIndex = visiblePlanStepRows.findIndex(({ step }) => step.status === "inProgress");

return (
<section className="min-w-0 space-y-1.5">
Expand Down Expand Up @@ -594,33 +585,37 @@ function TaskSection({

{activePlan && activePlan.steps.length > 0 ? (
<div className="space-y-1.5">
<div className={cn("space-y-1 pr-1", expanded && "max-h-56 overflow-y-auto")}>
{visiblePlanStepRows.map(({ key, step }) => (
<div
<div
className={cn("pr-1", expanded && "max-h-56 overflow-y-auto")}
style={TASK_SPINE_STYLE}
>
{visiblePlanStepRows.map(({ key, step }, index) => (
<SpineRow
key={key}
className={cn(
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-2 rounded-md px-2 py-1.5 transition-colors",
step.status === "inProgress" && "bg-primary/10",
step.status === "completed" && "bg-success/10",
)}
node={<SpineNode kind={taskStepNodeKind(step.status)} />}
nodeOffset={TASK_STEP_NODE_OFFSET_PX}
connectTop={index > 0}
connectBottom={index < visiblePlanStepRows.length - 1}
style={
liveStepIndex >= 0
? spineAccentRowStyle(Math.abs(index - liveStepIndex))
: undefined
}
>
<div className="mt-0.5">{taskStatusIcon(step.status)}</div>
<div
className={cn(
"min-w-0 text-[12px] leading-snug",
"min-w-0 py-1 text-[12px] leading-4 break-words",
step.status === "completed"
? "text-muted-foreground/65 line-through decoration-muted-foreground/30"
? "text-muted-foreground/70"
: step.status === "inProgress"
? "font-medium text-foreground/95"
? "font-medium text-foreground"
: "text-muted-foreground/85",
)}
>
<span className="sr-only">{taskStatusLabel(step.status)}: </span>
{step.step}
</div>
<div className="pt-0.5 text-[10px] text-muted-foreground/65">
{taskStatusLabel(step.status)}
</div>
</div>
</SpineRow>
))}
</div>
{shouldCollapsePlanSteps ? (
Expand Down
54 changes: 53 additions & 1 deletion apps/web/src/components/ui/threadline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,49 @@ function SpineRow({
);
}

type SpineNodeKind = "done" | "running" | "warning" | "error" | "group" | "pending";

const TONE_SPINE_DOT_CLASS_NAME = {
warning: "size-[6px] rounded-full bg-warning",
error: "size-[6px] rounded-full bg-destructive",
} as const satisfies Record<"warning" | "error", string>;

/** The glyph that sits on a spine for one row. The accent halo is reserved for
* the surface's single live node; a still-running step gets a small accent
* tick, settled steps are quiet solid dots, warnings/errors are compact tone
* dots, a collapsed group of steps is a hollow ring (same family, reads as
* "openable"), and a step not yet started is a fainter hollow ring. */
function SpineNode({ kind }: { kind: SpineNodeKind }) {
if (kind === "running") {
return (
<span
aria-hidden="true"
className="size-[5px] animate-status-pulse rounded-full bg-primary-graph/80"
/>
);
}
if (kind === "warning" || kind === "error") {
return <span aria-hidden="true" className={TONE_SPINE_DOT_CLASS_NAME[kind]} />;
}
if (kind === "group" || kind === "pending") {
return (
<span
aria-hidden="true"
className={cn(
"size-[7px] rounded-full border bg-background",
kind === "group" ? "border-muted-foreground/45" : "border-muted-foreground/30",
)}
/>
);
}
return (
<span
aria-hidden="true"
className="relative z-10 size-[5px] rounded-full bg-[color-mix(in_oklab,var(--muted-foreground)_42%,var(--background))]"
/>
);
}

/**
* Accent fade for the connectors approaching a live terminus: the spine warms
* toward accent as it nears the row where work is happening now, and settles
Expand Down Expand Up @@ -198,4 +241,13 @@ function CurrentMarker({ className, ...props }: React.ComponentPropsWithoutRef<"
);
}

export { CurrentMarker, LiveNode, SectionLabel, SectionTick, SpineRow, spineAccentRowStyle };
export {
CurrentMarker,
LiveNode,
SectionLabel,
SectionTick,
SpineNode,
SpineRow,
spineAccentRowStyle,
type SpineNodeKind,
};
Loading