Skip to content

Commit a45c4e7

Browse files
matej21claude
andcommitted
feat: show agent watcher status in canvas UI
Add real-time indication of whether the CLI agent is actively listening for feedback on a session. The daemon broadcasts watcher-status messages to browser clients when CLI waiters connect/disconnect, with ping/pong heartbeat to detect dead connections within ~10s. Three feedback states after submit: - Blue: agent connected, waiting to pick up feedback - Green: feedback received, waiting for next revision - Amber: agent disconnected, tell Claude to check feedback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent af3b612 commit a45c4e7

File tree

5 files changed

+114
-24
lines changed

5 files changed

+114
-24
lines changed

daemon/client/AnnotationSidebar.tsx

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,21 @@ interface AnnotationSidebarProps {
1717
}
1818

1919
export function AnnotationSidebar({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
20-
const { isReadOnly, selectedRevision, currentRevision, revisions } = useContext(RevisionContext);
20+
const { isReadOnly, selectedRevision, currentRevision, revisions, agentWatching } = useContext(RevisionContext);
2121
const sessionId = useContext(SessionContext);
22-
const isCurrentButSubmitted = isReadOnly && selectedRevision === currentRevision;
2322
const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision);
23+
const isCurrentButSubmitted = isReadOnly && selectedRevision === currentRevision;
24+
const feedbackConsumed = !!selectedRevInfo?.feedbackConsumed;
2425
const roundLabel = selectedRevInfo?.label || `Round ${selectedRevision}`;
2526

2627
if (isReadOnly) {
27-
return <ReadOnlyAnnotationSidebar sessionId={sessionId} revision={selectedRevision} label={roundLabel} waitingForUpdate={isCurrentButSubmitted} collapseButton={collapseButton} />;
28+
return <ReadOnlyAnnotationSidebar sessionId={sessionId} revision={selectedRevision} label={roundLabel} waitingForUpdate={isCurrentButSubmitted} feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} collapseButton={collapseButton} />;
2829
}
2930

30-
return <AnnotationSidebarInner onPreview={onPreview} onSubmit={onSubmit} collapseButton={collapseButton} />;
31+
return <AnnotationSidebarInner onPreview={onPreview} onSubmit={onSubmit} agentWatching={agentWatching} collapseButton={collapseButton} />;
3132
}
3233

33-
function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; collapseButton?: React.ReactNode }) {
34+
function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, feedbackConsumed, agentWatching, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; feedbackConsumed?: boolean; agentWatching?: boolean; collapseButton?: React.ReactNode }) {
3435
const [feedback, setFeedback] = useState<string | null>(null);
3536
const [loading, setLoading] = useState(true);
3637

@@ -51,12 +52,7 @@ function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, collaps
5152
{collapseButton}
5253
</div>
5354

54-
{waitingForUpdate && (
55-
<div className="mx-4 mb-3 px-3 py-2.5 rounded-lg bg-accent-green-muted flex items-center gap-2 flex-shrink-0">
56-
<span className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
57-
<span className="text-[12px] font-body text-accent-green">Waiting for next revision...</span>
58-
</div>
59-
)}
55+
{waitingForUpdate && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
6056

6157
<div className="flex-1 overflow-y-auto px-4">
6258
{loading ? (
@@ -71,17 +67,42 @@ function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, collaps
7167
);
7268
}
7369

70+
function WaitingBanner({ feedbackConsumed, agentWatching }: { feedbackConsumed?: boolean; agentWatching?: boolean }) {
71+
if (feedbackConsumed) {
72+
return (
73+
<div className="mx-4 mb-3 px-3 py-2.5 rounded-lg bg-accent-green-muted flex items-center gap-2 flex-shrink-0">
74+
<span className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
75+
<span className="text-[12px] font-body text-accent-green">Feedback received — waiting for next revision...</span>
76+
</div>
77+
);
78+
}
79+
if (agentWatching) {
80+
return (
81+
<div className="mx-4 mb-3 px-3 py-2.5 rounded-lg bg-accent-blue-muted flex items-center gap-2 flex-shrink-0">
82+
<span className="w-2 h-2 rounded-full bg-accent-blue animate-pulse" />
83+
<span className="text-[12px] font-body text-accent-blue">Waiting for agent to pick up feedback...</span>
84+
</div>
85+
);
86+
}
87+
return (
88+
<div className="mx-4 mb-3 px-3 py-2.5 rounded-lg bg-accent-amber-muted flex items-center gap-2 flex-shrink-0">
89+
<span className="w-2 h-2 rounded-full bg-accent-amber" />
90+
<span className="text-[12px] font-body text-accent-amber">Agent disconnected — tell Claude to check feedback</span>
91+
</div>
92+
);
93+
}
94+
7495
type ReadOnlyTab = "feedback" | "annotations";
7596

76-
function ReadOnlyAnnotationSidebar({ sessionId, revision, label, waitingForUpdate, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; collapseButton?: React.ReactNode }) {
97+
function ReadOnlyAnnotationSidebar({ sessionId, revision, label, waitingForUpdate, feedbackConsumed, agentWatching, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; feedbackConsumed?: boolean; agentWatching?: boolean; collapseButton?: React.ReactNode }) {
7798
const { annotations, generalNote, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
7899
const { setActiveView } = useContext(ActiveViewContext);
79100
const hasAnnotations = annotations.length > 0 || generalNote.trim().length > 0;
80101
const [activeTab, setActiveTab] = useState<ReadOnlyTab>("feedback");
81102

82103
// If no annotations in localStorage, just show feedback
83104
if (!hasAnnotations) {
84-
return <FeedbackDisplay sessionId={sessionId} revision={revision} label={label} waitingForUpdate={waitingForUpdate} collapseButton={collapseButton} />;
105+
return <FeedbackDisplay sessionId={sessionId} revision={revision} label={label} waitingForUpdate={waitingForUpdate} feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} collapseButton={collapseButton} />;
85106
}
86107

87108
return (
@@ -114,12 +135,7 @@ function ReadOnlyAnnotationSidebar({ sessionId, revision, label, waitingForUpdat
114135
{collapseButton}
115136
</div>
116137

117-
{waitingForUpdate && (
118-
<div className="mx-4 mb-3 px-3 py-2.5 rounded-lg bg-accent-green-muted flex items-center gap-2 flex-shrink-0">
119-
<span className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
120-
<span className="text-[12px] font-body text-accent-green">Waiting for next revision...</span>
121-
</div>
122-
)}
138+
{waitingForUpdate && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
123139

124140
{activeTab === "feedback" ? (
125141
<FeedbackDisplayContent sessionId={sessionId} revision={revision} />
@@ -287,7 +303,7 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
287303
);
288304
}
289305

290-
function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
306+
function AnnotationSidebarInner({ onPreview, onSubmit, agentWatching, collapseButton }: AnnotationSidebarProps & { agentWatching: boolean }) {
291307
const {
292308
annotations, updateAnnotation, removeAnnotation,
293309
addAnnotationImage, removeAnnotationImage,
@@ -422,6 +438,10 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
422438
{annotations.length > 0 && (
423439
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-border-subtle text-[10px] font-medium text-text-secondary">{annotations.length}</span>
424440
)}
441+
<span
442+
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${agentWatching ? "bg-accent-green" : "bg-accent-amber"}`}
443+
title={agentWatching ? "Agent connected" : "Agent disconnected"}
444+
/>
425445
</span>
426446
{collapseButton}
427447
</div>

daemon/client/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface RevisionInfo {
3232
canvasFiles: CanvasFileInfo[];
3333
createdAt: string;
3434
hasFeedback: boolean;
35+
feedbackConsumed: boolean;
3536
response?: string;
3637
}
3738

@@ -57,6 +58,7 @@ export const RevisionContext = createContext<{
5758
isReadOnly: boolean;
5859
compareRevision: { left: number; right: number } | null;
5960
setCompareRevision: (rev: { left: number; right: number } | null) => void;
61+
agentWatching: boolean;
6062
}>({
6163
currentRevision: 1,
6264
selectedRevision: 1,
@@ -65,6 +67,7 @@ export const RevisionContext = createContext<{
6567
isReadOnly: false,
6668
compareRevision: null,
6769
setCompareRevision: () => {},
70+
agentWatching: false,
6871
});
6972

7073
function resolveTheme(pref: string): "light" | "dark" {
@@ -179,6 +182,7 @@ function App() {
179182
const [revisions, setRevisions] = useState<RevisionInfo[]>([]);
180183
const [compareRevision, setCompareRevision] = useState<{ left: number; right: number } | null>(null);
181184
const [connected, setConnected] = useState(false);
185+
const [agentWatching, setAgentWatching] = useState(false);
182186
const [previewOpen, setPreviewOpen] = useState(false);
183187
const [canvasFiles, setCanvasFiles] = useState<string[]>([]);
184188
const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "overview" });
@@ -314,6 +318,9 @@ function App() {
314318
if (data.type === "revision-updated") {
315319
if (data.revisions) setRevisions(data.revisions);
316320
}
321+
if (data.type === "watcher-status") {
322+
setAgentWatching(!!data.watching);
323+
}
317324
} catch {}
318325
};
319326
};
@@ -336,7 +343,7 @@ function App() {
336343

337344
return (
338345
<SessionContext.Provider value={sessionId}>
339-
<RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision }}>
346+
<RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
340347
<AnnotationProvider key={`${sessionId}:${selectedRevision}`} sessionId={sessionId} revision={selectedRevision} isReadOnly={isReadOnly}>
341348
<ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
342349
<ActiveViewCtx.Provider value={{ setActiveView }}>

daemon/src/handlers/api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import type { Route } from "../router";
88
export interface ApiContext {
99
sessionManager: SessionManager;
1010
broadcastPlanUpdate: (id: string) => void;
11+
broadcastRevisionUpdate: (id: string) => void;
1112
port: number;
1213
}
1314

1415
export function createApiHandlers(ctx: ApiContext): Route[] {
15-
const { sessionManager, broadcastPlanUpdate, port } = ctx;
16+
const { sessionManager, broadcastPlanUpdate, broadcastRevisionUpdate, port } = ctx;
1617

1718
/**
1819
* Read *.jsx canvas files from a directory.
@@ -170,6 +171,7 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
170171
const result = sessionManager.getLatestUnconsumedFeedback(sessionId);
171172
if (!result) return jsonResponse({ found: false });
172173
sessionManager.consumeFeedback(sessionId, result.revision);
174+
broadcastRevisionUpdate(sessionId);
173175
return jsonResponse({ found: true, revision: result.revision, feedback: result.feedback });
174176
}
175177

daemon/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const sessionManager = new SessionManager();
2121
const wsManager = createWebSocketManager(sessionManager);
2222

2323
const routes = [
24-
...createApiHandlers({ sessionManager, broadcastPlanUpdate: wsManager.broadcastPlanUpdate, port: PORT }),
24+
...createApiHandlers({ sessionManager, broadcastPlanUpdate: wsManager.broadcastPlanUpdate, broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate, port: PORT }),
2525
...createFileHandlers(sessionManager),
2626
...createUploadHandlers(sessionManager),
2727
...createStaticHandlers(),

daemon/src/websocket.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,63 @@ export function createWebSocketManager(sessionManager: SessionManager) {
3636
}
3737
}
3838

39+
function broadcastWatcherStatus(sessionId: string) {
40+
const sockets = browserSockets.get(sessionId);
41+
if (!sockets) return;
42+
const watching = (waitSockets.get(sessionId)?.size ?? 0) > 0;
43+
const payload = JSON.stringify({ type: "watcher-status", watching });
44+
for (const ws of sockets) {
45+
ws.send(payload);
46+
}
47+
}
48+
49+
// Ping wait sockets periodically to detect dead connections.
50+
// We can't rely on ws.close() triggering the close handler for dead sockets,
51+
// so we manually remove dead sockets and broadcast status.
52+
const PING_INTERVAL = 5_000;
53+
const pongReceived = new WeakSet<ServerWebSocket<WSData>>();
54+
55+
function removeWaitSocket(ws: ServerWebSocket<WSData>, sessionId: string) {
56+
const sockets = waitSockets.get(sessionId);
57+
if (sockets) {
58+
sockets.delete(ws);
59+
if (sockets.size === 0) waitSockets.delete(sessionId);
60+
}
61+
try { ws.close(); } catch {}
62+
broadcastWatcherStatus(sessionId);
63+
}
64+
65+
setInterval(() => {
66+
for (const [sessionId, sockets] of waitSockets) {
67+
for (const ws of sockets) {
68+
if (!pongReceived.has(ws)) {
69+
// No pong since last ping — connection is dead
70+
removeWaitSocket(ws, sessionId);
71+
continue;
72+
}
73+
pongReceived.delete(ws);
74+
try { ws.ping(); } catch {
75+
removeWaitSocket(ws, sessionId);
76+
}
77+
}
78+
}
79+
}, PING_INTERVAL);
80+
3981
const handlers = {
4082
open(ws: ServerWebSocket<WSData>) {
4183
const { type, sessionId } = ws.data;
4284
const map = type === "browser" ? browserSockets : waitSockets;
4385
if (!map.has(sessionId)) map.set(sessionId, new Set());
4486
map.get(sessionId)!.add(ws);
87+
if (type === "browser") {
88+
// Send current watcher status to newly connected browser
89+
const watching = (waitSockets.get(sessionId)?.size ?? 0) > 0;
90+
ws.send(JSON.stringify({ type: "watcher-status", watching }));
91+
} else {
92+
// CLI waiter connected — notify browsers
93+
pongReceived.add(ws); // Give it a free pass on first interval
94+
broadcastWatcherStatus(sessionId);
95+
}
4596
},
4697
message(ws: ServerWebSocket<WSData>, message: string | Buffer) {
4798
const { type, sessionId } = ws.data;
@@ -59,6 +110,7 @@ export function createWebSocketManager(sessionManager: SessionManager) {
59110
const waiters = waitSockets.get(sessionId);
60111
if (waiters && waiters.size > 0) {
61112
sessionManager.consumeFeedback(sessionId, session.currentRevision);
113+
broadcastRevisionUpdate(sessionId);
62114
const payload = JSON.stringify({ type: "submit", feedback });
63115
for (const waiter of waiters) {
64116
waiter.send(payload);
@@ -71,13 +123,22 @@ export function createWebSocketManager(sessionManager: SessionManager) {
71123
} catch {}
72124
}
73125
},
126+
pong(ws: ServerWebSocket<WSData>) {
127+
if (ws.data.type === "wait") {
128+
pongReceived.add(ws);
129+
}
130+
},
74131
close(ws: ServerWebSocket<WSData>) {
75132
const { type, sessionId } = ws.data;
76133
const map = type === "browser" ? browserSockets : waitSockets;
77134
map.get(sessionId)?.delete(ws);
78135
if (map.get(sessionId)?.size === 0) map.delete(sessionId);
136+
if (type === "wait") {
137+
// CLI waiter disconnected — notify browsers
138+
broadcastWatcherStatus(sessionId);
139+
}
79140
},
80141
};
81142

82-
return { handlers, broadcastPlanUpdate, broadcastRevisionUpdate };
143+
return { handlers, broadcastPlanUpdate, broadcastRevisionUpdate, broadcastWatcherStatus };
83144
}

0 commit comments

Comments
 (0)