Skip to content

Commit d5a3909

Browse files
authored
Merge pull request #19 from tyulyukov/marcode/extract-command-execution-card-ui
refactor(chat): extract command execution and agent group cards
2 parents 3465709 + e54a450 commit d5a3909

9 files changed

Lines changed: 372 additions & 250 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
334334
(store) => store.setStickyModelSelection,
335335
);
336336
const timestampFormat = settings.timestampFormat;
337-
const showInlineDiffs = settings.showInlineDiffs;
338337
const showTodosInComposer = settings.showTodosInComposer;
339338
const navigate = useNavigate();
340339
const rawSearch = useSearch({
@@ -4057,7 +4056,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
40574056
markdownCwd={gitCwd ?? undefined}
40584057
resolvedTheme={resolvedTheme}
40594058
timestampFormat={timestampFormat}
4060-
showInlineDiffs={showInlineDiffs}
40614059
workspaceRoot={activeProject?.cwd ?? undefined}
40624060
/>
40634061
</div>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
2+
import { memo, useState } from "react";
3+
import {
4+
formatTokenCount,
5+
formatToolUseCount,
6+
type AgentGroup,
7+
type AgentTaskSummary,
8+
} from "../../session-logic";
9+
import { normalizeCompactToolLabel } from "./MessagesTimeline.logic";
10+
import { cn } from "~/lib/utils";
11+
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12+
13+
interface AgentGroupCardProps {
14+
agentGroup: AgentGroup;
15+
label: string;
16+
isLive: boolean;
17+
}
18+
19+
function formatAgentTaskType(taskType: string | null): string | null {
20+
if (!taskType) return null;
21+
const normalized = taskType.trim().toLowerCase();
22+
if (normalized === "default" || normalized.length === 0) return null;
23+
return normalized
24+
.split("-")
25+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
26+
.join(" ");
27+
}
28+
29+
function agentTaskStatusAccent(status: AgentTaskSummary["status"]): string {
30+
switch (status) {
31+
case "running":
32+
return "border-l-amber-400/70";
33+
case "failed":
34+
return "border-l-rose-400/70";
35+
case "completed":
36+
return "border-l-emerald-500/70";
37+
case "stopped":
38+
return "border-l-muted-foreground/40";
39+
}
40+
}
41+
42+
function agentTaskActivityLine(task: AgentTaskSummary): string | null {
43+
if (task.status === "completed") return null;
44+
if (task.status === "failed") return "Failed";
45+
if (task.status === "stopped") return "Stopped";
46+
if (task.progressSummary) return task.progressSummary;
47+
if (task.lastToolName) return `Using ${normalizeCompactToolLabel(task.lastToolName)}`;
48+
return "Starting…";
49+
}
50+
51+
function agentTaskMeta(task: AgentTaskSummary): string {
52+
const parts: string[] = [];
53+
if (task.toolUses !== null) parts.push(formatToolUseCount(task.toolUses));
54+
if (task.totalTokens !== null) parts.push(formatTokenCount(task.totalTokens));
55+
return parts.join(" · ");
56+
}
57+
58+
const AgentTaskRow = memo(function AgentTaskRow(props: { task: AgentTaskSummary }) {
59+
const { task } = props;
60+
const typeLabel = formatAgentTaskType(task.agentType);
61+
const isRunning = task.status === "running";
62+
const activityLine = agentTaskActivityLine(task);
63+
const meta = agentTaskMeta(task);
64+
65+
return (
66+
<div
67+
className={cn("rounded-md border-l-2 py-1 pl-2.5 pr-1.5", agentTaskStatusAccent(task.status))}
68+
>
69+
<div className="flex items-center gap-1.5">
70+
{typeLabel && (
71+
<span className="shrink-0 rounded bg-muted/50 px-1 py-px text-[10px] text-muted-foreground/60">
72+
{typeLabel}
73+
</span>
74+
)}
75+
<Tooltip>
76+
<TooltipTrigger
77+
render={
78+
<span className="min-w-0 flex-1 truncate text-[11px] leading-5 text-foreground/80">
79+
{task.description}
80+
</span>
81+
}
82+
/>
83+
<TooltipPopup
84+
side="top"
85+
className="max-w-lg break-words whitespace-pre-wrap leading-tight"
86+
>
87+
{task.description}
88+
</TooltipPopup>
89+
</Tooltip>
90+
{meta && <span className="shrink-0 text-[10px] text-muted-foreground/40">{meta}</span>}
91+
</div>
92+
{activityLine && (
93+
<Tooltip>
94+
<TooltipTrigger
95+
render={
96+
<p
97+
className={cn(
98+
"truncate text-[10px] leading-4",
99+
isRunning ? "text-amber-400/70" : "text-muted-foreground/50",
100+
)}
101+
>
102+
{isRunning && (
103+
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-amber-400/80 align-middle" />
104+
)}
105+
{activityLine}
106+
</p>
107+
}
108+
/>
109+
<TooltipPopup
110+
side="top"
111+
className="max-w-lg break-words whitespace-pre-wrap leading-tight"
112+
>
113+
{activityLine}
114+
</TooltipPopup>
115+
</Tooltip>
116+
)}
117+
</div>
118+
);
119+
});
120+
121+
export const AgentGroupCard = memo(function AgentGroupCard(props: AgentGroupCardProps) {
122+
const { agentGroup, label, isLive } = props;
123+
const [expanded, setExpanded] = useState(false);
124+
const tasks = agentGroup.tasks;
125+
const allSettled = tasks.every(
126+
(t) => t.status === "completed" || t.status === "failed" || t.status === "stopped",
127+
);
128+
129+
const ExpandIcon = expanded ? ChevronDownIcon : ChevronRightIcon;
130+
131+
return (
132+
<div
133+
data-scroll-anchor-target
134+
className={cn(
135+
"overflow-hidden rounded-xl border border-border/40 border-l-2 bg-card/25",
136+
isLive ? "border-l-violet-400/40" : "border-l-violet-400/20",
137+
)}
138+
>
139+
<button
140+
type="button"
141+
aria-expanded={expanded}
142+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-100 hover:bg-muted/20"
143+
onClick={() => setExpanded((prev) => !prev)}
144+
>
145+
<ExpandIcon className="size-3 shrink-0 text-muted-foreground/50" />
146+
<span className="flex size-4 shrink-0 items-center justify-center">
147+
{allSettled ? (
148+
<span className="size-2 rounded-full bg-emerald-500" aria-hidden="true" />
149+
) : (
150+
<span className="size-2 animate-pulse rounded-full bg-amber-400" aria-hidden="true" />
151+
)}
152+
</span>
153+
<Tooltip>
154+
<TooltipTrigger
155+
render={
156+
<span className="min-w-0 flex-1 truncate text-[11px] leading-5 text-foreground/80">
157+
{label}
158+
</span>
159+
}
160+
/>
161+
<TooltipPopup
162+
side="top"
163+
className="max-w-lg break-words whitespace-pre-wrap leading-tight"
164+
>
165+
{label}
166+
</TooltipPopup>
167+
</Tooltip>
168+
</button>
169+
170+
{expanded && (
171+
<div className="space-y-1 border-t border-border/20 px-2 py-1.5">
172+
{tasks.map((task) => (
173+
<AgentTaskRow key={task.taskId} task={task} />
174+
))}
175+
</div>
176+
)}
177+
</div>
178+
);
179+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
CheckIcon,
3+
ChevronDownIcon,
4+
ChevronRightIcon,
5+
CircleXIcon,
6+
TerminalIcon,
7+
} from "lucide-react";
8+
import { memo, useState } from "react";
9+
import { cn } from "~/lib/utils";
10+
import type { WorkLogEntry } from "../../session-logic";
11+
12+
interface CommandExecutionCardProps {
13+
entry: WorkLogEntry;
14+
isLive: boolean;
15+
}
16+
17+
type CommandStatus = "running" | "error" | "success";
18+
19+
function deriveCommandStatus(entry: WorkLogEntry, isLive: boolean): CommandStatus {
20+
if (isLive && !entry.detail && entry.exitCode === undefined) return "running";
21+
if (entry.tone === "error" || (entry.exitCode !== undefined && entry.exitCode !== 0))
22+
return "error";
23+
return "success";
24+
}
25+
26+
const STATUS_ACCENT: Record<CommandStatus, string> = {
27+
running: "border-l-amber-400/40",
28+
error: "border-l-rose-400/40",
29+
success: "border-l-emerald-400/25",
30+
};
31+
32+
function CommandStatusBadge(props: { entry: WorkLogEntry; status: CommandStatus }) {
33+
const { entry, status } = props;
34+
35+
if (status === "running") {
36+
return (
37+
<span className="flex items-center gap-1 text-[10px] text-amber-400/70">
38+
<span className="size-1.5 animate-pulse rounded-full bg-amber-400/80" />
39+
Running
40+
</span>
41+
);
42+
}
43+
44+
if (status === "error") {
45+
return (
46+
<span className="flex items-center gap-1 text-[10px] text-rose-400/60">
47+
<CircleXIcon className="size-3" />
48+
{entry.exitCode !== undefined && entry.exitCode !== 0 ? `Exit ${entry.exitCode}` : "Failed"}
49+
</span>
50+
);
51+
}
52+
53+
return (
54+
<span className="flex items-center gap-1 text-[10px] text-emerald-400/60">
55+
<CheckIcon className="size-3" />
56+
Success
57+
</span>
58+
);
59+
}
60+
61+
export const CommandExecutionCard = memo(function CommandExecutionCard(
62+
props: CommandExecutionCardProps,
63+
) {
64+
const { entry, isLive } = props;
65+
const [expanded, setExpanded] = useState(false);
66+
67+
const status = deriveCommandStatus(entry, isLive);
68+
const hasOutput = !!entry.detail;
69+
70+
const ToggleIcon = expanded ? ChevronDownIcon : ChevronRightIcon;
71+
72+
return (
73+
<div
74+
data-scroll-anchor-target
75+
className={cn(
76+
"overflow-hidden rounded-xl border border-border/40 border-l-2 bg-card/25",
77+
STATUS_ACCENT[status],
78+
)}
79+
>
80+
<button
81+
type="button"
82+
onClick={() => setExpanded((prev) => !prev)}
83+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-100 hover:bg-muted/20"
84+
>
85+
<ToggleIcon className="size-3 shrink-0 text-muted-foreground/50" />
86+
<TerminalIcon className="size-3.5 shrink-0 text-muted-foreground/60" />
87+
<span className="text-[11px] font-medium text-muted-foreground/60">Shell</span>
88+
<span className="min-w-0 flex-1" />
89+
<CommandStatusBadge entry={entry} status={status} />
90+
</button>
91+
92+
<div className="border-t border-border/20 px-3 py-1.5">
93+
{entry.command ? (
94+
<p className="font-mono text-[11px] leading-5 text-foreground/75">
95+
<span className="text-muted-foreground/50">$ </span>
96+
{entry.command}
97+
</p>
98+
) : (
99+
<p className="text-[11px] leading-5 text-muted-foreground/60">{entry.label}</p>
100+
)}
101+
</div>
102+
103+
{expanded && (
104+
<div className="border-t border-border/20 px-3 py-2">
105+
{hasOutput ? (
106+
<pre className="max-h-[300px] overflow-y-auto whitespace-pre-wrap break-words font-mono text-[10px] leading-4 text-muted-foreground/55">
107+
{entry.detail}
108+
</pre>
109+
) : (
110+
<p className="text-[10px] italic text-muted-foreground/35">No output</p>
111+
)}
112+
</div>
113+
)}
114+
</div>
115+
);
116+
});

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ describe("MessagesTimeline", () => {
8686
markdownCwd={undefined}
8787
resolvedTheme="light"
8888
timestampFormat="locale"
89-
showInlineDiffs
9089
workspaceRoot={undefined}
9190
/>,
9291
);
@@ -130,7 +129,6 @@ describe("MessagesTimeline", () => {
130129
markdownCwd={undefined}
131130
resolvedTheme="light"
132131
timestampFormat="locale"
133-
showInlineDiffs
134132
workspaceRoot={undefined}
135133
/>,
136134
);

0 commit comments

Comments
 (0)