Skip to content

Commit 641449d

Browse files
committed
feat(web): improve task chat and activity timeline
1 parent 44f8144 commit 641449d

8 files changed

Lines changed: 487 additions & 146 deletions

File tree

apps/web/src/components/ActivityLog.tsx

Lines changed: 91 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { User } from "lucide-react";
22
import { useEffect, useRef, useState } from "react";
3+
import ReactMarkdown from "react-markdown";
4+
import rehypeHighlight from "rehype-highlight";
5+
import remarkGfm from "remark-gfm";
6+
37
import { AgentIdenticon } from "./AgentIdenticon";
48
import { formatRelative } from "./TaskDetailFields";
59
import { Button } from "./ui/button";
@@ -35,56 +39,79 @@ const dotColors: Record<string, string> = {
3539
moved: "bg-zinc-500 border-zinc-500/30",
3640
};
3741

38-
function buildSentence(log: any): { prefix: string; actionText: string; suffix: string } {
39-
const name = log.actor_name || null;
40-
const isAgent = log.actor_type?.startsWith("agent:");
41-
const defaultPrefix = isAgent ? "Agent" : log.actor_type === "user" ? "User" : "System";
42+
const bodyActions = new Set(["commented", "rejected", "completed", "cancelled"]);
43+
44+
const markdownClass =
45+
"overflow-x-auto text-[13px] text-content-secondary [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_h1]:text-base [&_h1]:font-semibold [&_h1]:text-content-primary [&_h1]:mt-3 [&_h1]:mb-1 [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-content-primary [&_h2]:mt-3 [&_h2]:mb-1 [&_h3]:text-[13px] [&_h3]:font-semibold [&_h3]:text-content-primary [&_h3]:mt-2 [&_h3]:mb-1 [&_p]:mb-2 [&_ul]:mb-2 [&_ul]:pl-4 [&_ul]:list-disc [&_ol]:mb-2 [&_ol]:pl-4 [&_ol]:list-decimal [&_li]:mb-0.5 [&_a]:text-accent [&_a]:underline [&_a]:underline-offset-2 [&_pre]:bg-surface-primary [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:font-mono [&_pre]:text-[12px] [&_code]:font-mono [&_code]:text-accent [&_code]:bg-surface-primary [&_code]:px-1 [&_code]:rounded [&_code]:text-[12px] [&_pre_code]:bg-transparent [&_pre_code]:text-content-secondary [&_pre_code]:p-0 [&_table]:w-full [&_table]:border-collapse [&_th]:text-left [&_th]:text-[11px] [&_th]:font-medium [&_th]:text-content-tertiary [&_th]:uppercase [&_th]:tracking-wide [&_th]:border-b [&_th]:border-border [&_th]:pb-1 [&_td]:border-b [&_td]:border-border [&_td]:py-1 [&_td]:pr-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-content-tertiary [&_hr]:border-border";
46+
47+
function actorLabel(log: any): string {
48+
if (log.actor_name) return log.actor_name;
49+
if (log.actor_type?.startsWith("agent:")) return "Agent";
50+
if (log.actor_type === "user") return "User";
51+
return "System";
52+
}
4253

54+
function buildSentence(log: any): { actionText: string; suffix: string } {
4355
switch (log.action) {
4456
case "claimed":
45-
return { prefix: name ?? defaultPrefix, actionText: "claimed this task", suffix: "" };
57+
return { actionText: "claimed this task", suffix: "" };
4658
case "assigned":
47-
return { prefix: name ?? "System", actionText: "assigned to", suffix: log.detail ?? "agent" };
59+
return { actionText: "assigned to", suffix: log.detail ?? "agent" };
4860
case "completed":
49-
return { prefix: name ?? defaultPrefix, actionText: "completed this task", suffix: log.detail ? `— ${log.detail}` : "" };
61+
return { actionText: "completed this task", suffix: "" };
5062
case "released":
51-
return { prefix: name ?? defaultPrefix, actionText: "released this task", suffix: "" };
63+
return { actionText: "released this task", suffix: "" };
5264
case "timed_out":
53-
return { prefix: name ?? defaultPrefix, actionText: "timed out", suffix: "" };
65+
return { actionText: "timed out", suffix: "" };
5466
case "cancelled":
55-
return { prefix: name ?? "System", actionText: "cancelled this task", suffix: log.detail ? `— ${log.detail}` : "" };
67+
return { actionText: "cancelled this task", suffix: "" };
5668
case "rejected":
57-
return { prefix: name ?? "Reviewer", actionText: "rejected — sent back to agent", suffix: log.detail ? `(${log.detail})` : "" };
69+
return { actionText: "rejected this task", suffix: "" };
5870
case "review_requested":
59-
return { prefix: name ?? defaultPrefix, actionText: "submitted for review", suffix: "" };
71+
return { actionText: "submitted for review", suffix: "" };
6072
case "created":
61-
return { prefix: "System", actionText: "created this task", suffix: "" };
73+
return { actionText: "created this task", suffix: "" };
6274
case "moved":
63-
return { prefix: "System", actionText: "moved", suffix: log.detail ?? "" };
75+
return { actionText: "moved", suffix: log.detail ?? "" };
6476
case "commented":
65-
return { prefix: name ?? defaultPrefix, actionText: "commented", suffix: "" };
77+
return { actionText: "commented", suffix: "" };
6678
default:
67-
return { prefix: name ?? "System", actionText: log.action, suffix: log.detail ?? "" };
79+
return { actionText: log.action, suffix: bodyActions.has(log.action) ? "" : (log.detail ?? "") };
6880
}
6981
}
7082

71-
function NoteAvatar({ actorType, actorPublicKey }: { actorType: string | null; actorPublicKey: string | null }) {
72-
if (actorType?.startsWith("agent:") && actorPublicKey) {
73-
return <AgentIdenticon publicKey={actorPublicKey} size={20} />;
83+
function NoteAvatar({ log }: { log: any }) {
84+
if (log.actor_type?.startsWith("agent:") && log.actor_public_key) {
85+
return <AgentIdenticon publicKey={log.actor_public_key} size={28} />;
7486
}
87+
7588
return (
76-
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-zinc-500/10 border border-zinc-500/20 flex items-center justify-center">
77-
<User className="w-3 h-3 text-content-tertiary" />
89+
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-zinc-500/10 border border-zinc-500/20 flex items-center justify-center">
90+
<User className="w-3.5 h-3.5 text-content-tertiary" />
7891
</span>
7992
);
8093
}
8194

95+
function MarkdownBody({ children }: { children: string }) {
96+
return (
97+
<div className={markdownClass}>
98+
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
99+
{children}
100+
</ReactMarkdown>
101+
</div>
102+
);
103+
}
104+
105+
function hasBody(log: any): boolean {
106+
return bodyActions.has(log.action) && !!log.detail;
107+
}
108+
82109
export function ActivityLog({ initialNotes, sseNotes, reconnecting }: ActivityLogProps) {
83110
const containerRef = useRef<HTMLDivElement>(null);
84111
const [autoScroll, setAutoScroll] = useState(true);
85112
const [newCount, setNewCount] = useState(0);
86113

87-
const allNotes = (() => {
114+
const displayed = (() => {
88115
const seen = new Set<string>();
89116
const merged: any[] = [];
90117
for (const note of [...initialNotes, ...sseNotes]) {
@@ -96,25 +123,24 @@ export function ActivityLog({ initialNotes, sseNotes, reconnecting }: ActivityLo
96123
return merged.sort((a, b) => a.created_at.localeCompare(b.created_at));
97124
})();
98125

99-
const displayed = allNotes.slice().reverse();
100-
101126
useEffect(() => {
102127
if (autoScroll && containerRef.current) {
103-
containerRef.current.scrollTop = 0;
128+
containerRef.current.scrollTop = containerRef.current.scrollHeight;
104129
} else if (!autoScroll && sseNotes.length > 0) {
105130
setNewCount((c) => c + 1);
106131
}
107132
}, [autoScroll, sseNotes.length]);
108133

109134
function handleScroll() {
110135
if (!containerRef.current) return;
111-
const atTop = containerRef.current.scrollTop < 20;
112-
setAutoScroll(atTop);
113-
if (atTop) setNewCount(0);
136+
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
137+
const atBottom = scrollHeight - scrollTop - clientHeight < 20;
138+
setAutoScroll(atBottom);
139+
if (atBottom) setNewCount(0);
114140
}
115141

116-
function scrollToTop() {
117-
containerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
142+
function scrollToLatest() {
143+
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: "smooth" });
118144
setNewCount(0);
119145
setAutoScroll(true);
120146
}
@@ -128,54 +154,52 @@ export function ActivityLog({ initialNotes, sseNotes, reconnecting }: ActivityLo
128154
{reconnecting && <div className="text-[10px] text-warning mb-1">Reconnecting...</div>}
129155

130156
{newCount > 0 && !autoScroll && (
131-
<Button onClick={scrollToTop} size="xs" className="absolute top-0 left-1/2 -translate-x-1/2 z-10 text-[11px] font-mono">
132-
{newCount} new
157+
<Button onClick={scrollToLatest} size="xs" className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 text-[11px] font-mono">
158+
{newCount} new
133159
</Button>
134160
)}
135161

136-
<div ref={containerRef} onScroll={handleScroll} className="mt-2 max-h-80 overflow-y-auto" aria-live="polite">
137-
{/* Timeline container */}
138-
<div className="relative ml-2.5">
139-
{/* Vertical line */}
140-
<div className="absolute left-0 top-0 bottom-0 w-px bg-border" />
162+
<div ref={containerRef} onScroll={handleScroll} className="mt-2 max-h-96 overflow-y-auto pr-1" aria-live="polite">
163+
<div className="relative">
164+
<div className="absolute left-3.5 top-0 bottom-0 w-px bg-border" />
141165

142166
{displayed.map((log: any) => {
143-
const { prefix, actionText, suffix } = buildSentence(log);
144-
const isAgent = log.actor_type?.startsWith("agent:") && !!log.actor_public_key;
167+
const actor = actorLabel(log);
168+
const { actionText, suffix } = buildSentence(log);
169+
const isAgent = log.actor_type?.startsWith("agent:");
145170
const dot = dotColors[log.action] || "bg-zinc-500 border-zinc-500/30";
146171
const actionColor = actionStyles[log.action] || "text-content-secondary";
147-
const isComment = log.action === "commented";
172+
const body = hasBody(log);
148173

149174
return (
150-
<div key={log.id} className="relative pl-5 pb-3">
151-
{/* Timeline dot */}
152-
<span className={`absolute left-0 -translate-x-1/2 mt-[3px] w-2 h-2 rounded-full border ${dot}`} style={{ top: "4px" }} />
153-
154-
<div className="flex items-center gap-1.5 flex-wrap">
155-
<NoteAvatar actorType={log.actor_type} actorPublicKey={log.actor_public_key} />
156-
157-
{/* Sentence: prefix (agent name) + action + suffix */}
158-
<span className="text-[12px] leading-snug">
159-
<span className={isAgent ? "font-mono text-accent" : "text-content-tertiary"}>{prefix}</span>{" "}
160-
<span className={isComment ? "text-content-tertiary" : actionColor}>{actionText}</span>
161-
{suffix && (
162-
<>
163-
{" "}
164-
<span className="text-content-tertiary">{suffix}</span>
165-
</>
166-
)}
167-
</span>
168-
169-
{/* Relative time */}
170-
<span className="ml-auto font-mono text-[10px] text-content-tertiary whitespace-nowrap">{formatRelative(log.created_at)}</span>
175+
<div key={log.id} className="relative flex gap-3 pb-4">
176+
<div className="relative z-10 flex h-7 w-7 shrink-0 items-center justify-center">
177+
{body ? <NoteAvatar log={log} /> : <span className={`w-2.5 h-2.5 rounded-full border ${dot}`} />}
171178
</div>
172179

173-
{/* Comment body */}
174-
{isComment && log.detail && (
175-
<div className="mt-1.5 ml-6 bg-surface-primary border border-border rounded px-2.5 py-1.5 font-mono text-[11px] text-content-secondary leading-relaxed">
176-
{log.detail}
177-
</div>
178-
)}
180+
<div className="min-w-0 flex-1">
181+
{body ? (
182+
<div className="overflow-hidden rounded-md border border-border bg-surface-secondary">
183+
<div className="flex items-center gap-1.5 border-b border-border bg-surface-tertiary px-3 py-2 text-[12px]">
184+
<span className={isAgent ? "font-mono text-accent" : "font-medium text-content-primary"}>{actor}</span>
185+
<span className={actionColor}>{actionText}</span>
186+
<span className="ml-auto font-mono text-[10px] text-content-tertiary whitespace-nowrap">
187+
{formatRelative(log.created_at)}
188+
</span>
189+
</div>
190+
<div className="px-3 py-2.5">
191+
<MarkdownBody>{log.detail}</MarkdownBody>
192+
</div>
193+
</div>
194+
) : (
195+
<div className="flex items-center gap-1.5 min-h-7 text-[12px] leading-snug">
196+
<span className={isAgent ? "font-mono text-accent" : "text-content-tertiary"}>{actor}</span>
197+
<span className={actionColor}>{actionText}</span>
198+
{suffix && <span className="text-content-tertiary">{suffix}</span>}
199+
<span className="ml-auto font-mono text-[10px] text-content-tertiary whitespace-nowrap">{formatRelative(log.created_at)}</span>
200+
</div>
201+
)}
202+
</div>
179203
</div>
180204
);
181205
})}

apps/web/src/components/KanbanColumn.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { TaskCard } from "./TaskCard";
44
interface KanbanColumnProps {
55
column: any;
66
onTaskClick: (taskId: string) => void;
7-
onAgentClick?: (agentId: string) => void;
7+
onAgentClick?: (task: any) => void;
88
}
99

1010
export function KanbanColumn({ column, onTaskClick, onAgentClick }: KanbanColumnProps) {

apps/web/src/components/TaskCard.tsx

Lines changed: 49 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Badge } from "./ui/badge";
77
interface TaskCardProps {
88
task: any;
99
onClick: () => void;
10-
onAgentClick?: (agentId: string) => void;
10+
onAgentClick?: (task: any) => void;
1111
isNew?: boolean;
1212
}
1313

@@ -19,13 +19,12 @@ const priorityColors: Record<string, string> = {
1919
};
2020

2121
export function TaskCard({ task, onClick, onAgentClick, isNew }: TaskCardProps) {
22-
const isAssigned = !!task.assigned_to && !!task.agent_public_key;
23-
const isWorking = isAssigned && task.status === "in_progress" && !task.glow_suppressed;
22+
const isAssigned = !!task.assigned_to;
23+
const isWorking = isAssigned && !!task.agent_public_key && task.status === "in_progress" && !task.glow_suppressed;
2424

2525
return (
26-
<button
26+
<div
2727
data-task-id={task.id}
28-
onClick={onClick}
2928
className={`
3029
w-full text-left bg-surface-card border rounded-lg p-3
3130
transition-[border-color,box-shadow,filter,color] duration-150 cursor-pointer
@@ -45,58 +44,59 @@ export function TaskCard({ task, onClick, onAgentClick, isNew }: TaskCardProps)
4544
: undefined
4645
}
4746
>
48-
<div className="flex items-start gap-1.5 mb-2">
49-
<span className="font-mono text-[11px] leading-snug text-content-tertiary shrink-0">#{task.seq}</span>
50-
<div className="text-[13px] font-medium leading-snug text-content-primary flex-1">{task.title}</div>
51-
{task.blocked && (
52-
<Badge variant="destructive" className="text-[10px] font-mono font-semibold uppercase shrink-0">
53-
Blocked
54-
</Badge>
55-
)}
56-
</div>
47+
<button type="button" onClick={onClick} className="w-full text-left">
48+
<div className="flex items-start gap-1.5 mb-2">
49+
<span className="font-mono text-[11px] leading-snug text-content-tertiary shrink-0">#{task.seq}</span>
50+
<div className="text-[13px] font-medium leading-snug text-content-primary flex-1">{task.title}</div>
51+
{task.blocked && (
52+
<Badge variant="destructive" className="text-[10px] font-mono font-semibold uppercase shrink-0">
53+
Blocked
54+
</Badge>
55+
)}
56+
</div>
5757

58-
<div className="flex items-center gap-1.5 flex-wrap">
59-
{task.repository_name && (
60-
<Badge variant="secondary" className="text-[11px] font-mono bg-accent-soft text-accent border-none">
61-
{task.repository_name}
62-
</Badge>
63-
)}
64-
{task.priority && (
65-
<Badge variant="secondary" className={`text-[11px] font-mono border-none ${priorityColors[task.priority]}`}>
66-
{task.priority}
67-
</Badge>
68-
)}
69-
{task.scheduled_at && new Date(task.scheduled_at).getTime() > Date.now() && (
70-
<span className="font-mono text-[11px] text-content-tertiary" title={task.scheduled_at}>
71-
🕐 {dayjs(task.scheduled_at).format("MM-DD HH:mm")}
72-
</span>
73-
)}
74-
</div>
58+
<div className="flex items-center gap-1.5 flex-wrap">
59+
{task.repository_name && (
60+
<Badge variant="secondary" className="text-[11px] font-mono bg-accent-soft text-accent border-none">
61+
{task.repository_name}
62+
</Badge>
63+
)}
64+
{task.priority && (
65+
<Badge variant="secondary" className={`text-[11px] font-mono border-none ${priorityColors[task.priority]}`}>
66+
{task.priority}
67+
</Badge>
68+
)}
69+
{task.scheduled_at && new Date(task.scheduled_at).getTime() > Date.now() && (
70+
<span className="font-mono text-[11px] text-content-tertiary" title={task.scheduled_at}>
71+
🕐 {dayjs(task.scheduled_at).format("MM-DD HH:mm")}
72+
</span>
73+
)}
74+
</div>
75+
</button>
7576

7677
{isAssigned && (
77-
<div
78+
<button
79+
type="button"
7880
data-agent-section
7981
className={`flex items-center gap-1.5 mt-2 transition-colors duration-500 ${isWorking ? "text-accent" : "text-content-tertiary"}`}
82+
onClick={() => onAgentClick?.(task)}
83+
aria-label={`Open chat with ${task.agent_name || task.assigned_to}`}
8084
>
81-
<div className="transition-[filter] duration-500" style={{ filter: isWorking ? "none" : "grayscale(1) opacity(0.5)" }}>
82-
<AgentIdenticon publicKey={task.agent_public_key} size={12} />
83-
</div>
85+
{task.agent_public_key && (
86+
<div className="transition-[filter] duration-500" style={{ filter: isWorking ? "none" : "grayscale(1) opacity(0.5)" }}>
87+
<AgentIdenticon publicKey={task.agent_public_key} size={12} />
88+
</div>
89+
)}
8490
{isWorking && <span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse-glow" />}
85-
<span
86-
className="font-mono text-[11px] hover:underline"
87-
onClick={(e) => {
88-
if (onAgentClick && task.assigned_to) {
89-
e.stopPropagation();
90-
onAgentClick(task.assigned_to);
91-
}
92-
}}
93-
>
94-
{task.agent_name || task.assigned_to}
95-
</span>
96-
</div>
91+
<span className="font-mono text-[11px] hover:underline">{task.agent_name || task.assigned_to}</span>
92+
</button>
9793
)}
9894

99-
{task.result && task.duration_minutes && <div className="font-mono text-[11px] text-success mt-1.5">{task.duration_minutes} min</div>}
100-
</button>
95+
{task.result && task.duration_minutes && (
96+
<button type="button" onClick={onClick} className="font-mono text-[11px] text-success mt-1.5">
97+
{task.duration_minutes} min
98+
</button>
99+
)}
100+
</div>
101101
);
102102
}

0 commit comments

Comments
 (0)