canopy-ui 0.3.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/chat/ChatPanel.tsx +155 -0
- package/src/chat/ConnectionStatus.tsx +30 -0
- package/src/chat/MessageItem.tsx +198 -0
- package/src/chat/MessageList.tsx +145 -0
- package/src/chat/PlacementBanner.test.tsx +112 -0
- package/src/chat/PlacementBanner.tsx +92 -0
- package/src/chat/PresenceChips.tsx +52 -0
- package/src/chat/SendBox.test.tsx +359 -0
- package/src/chat/SendBox.tsx +306 -0
- package/src/chat/ToolCallPair.tsx +86 -0
- package/src/chat/drafts.test.ts +90 -0
- package/src/chat/drafts.ts +37 -0
- package/src/chat/groupToolRuns.test.ts +81 -0
- package/src/chat/groupToolRuns.ts +82 -0
- package/src/chat/history.test.ts +35 -0
- package/src/chat/history.ts +16 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/pairToolMessages.test.ts +360 -0
- package/src/chat/pairToolMessages.ts +238 -0
- package/src/chat/protocol.ts +112 -0
- package/src/chat/sessionReducer.test.ts +541 -0
- package/src/chat/sessionReducer.ts +368 -0
- package/src/chat/useSessionSocket.ts +332 -0
- package/src/chat/useStickyBottom.ts +77 -0
- package/src/presence/PresenceBadge.test.tsx +58 -0
- package/src/presence/PresenceBadge.tsx +105 -0
- package/src/presence/avatar.test.ts +42 -0
- package/src/presence/avatar.ts +36 -0
- package/src/presence/index.ts +4 -0
- package/src/presence/pageKey.test.ts +53 -0
- package/src/presence/pageKey.ts +37 -0
- package/src/presence/usePresence.test.ts +130 -0
- package/src/presence/usePresence.ts +170 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import type React from "react";
|
|
9
|
+
|
|
10
|
+
import type { Draft } from "./protocol";
|
|
11
|
+
import { isDraftIdle, msUntilDraftIdle } from "./drafts";
|
|
12
|
+
import { Button } from "../ui/button";
|
|
13
|
+
|
|
14
|
+
/** An attachment the composer is holding, uploaded but not yet sent. */
|
|
15
|
+
export interface PendingAttachment {
|
|
16
|
+
id: string;
|
|
17
|
+
filename: string;
|
|
18
|
+
/** Set while the upload is still in flight — the chip renders as busy and
|
|
19
|
+
* cannot be removed yet, because there is no id on the server to remove. */
|
|
20
|
+
uploading?: boolean;
|
|
21
|
+
/** Upload failed; the chip explains why and is dismissible. */
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
draft: Draft | null;
|
|
27
|
+
/** Live socket. Typing never depends on this; SENDING does — see canSend. */
|
|
28
|
+
connected: boolean;
|
|
29
|
+
currentUserId: number;
|
|
30
|
+
holderIsPresent: boolean;
|
|
31
|
+
isStreaming: boolean;
|
|
32
|
+
streamingMessageId: string | null;
|
|
33
|
+
onUpdate: (body: string) => void;
|
|
34
|
+
onSend: () => void;
|
|
35
|
+
/** messageId is null when the turn is still QUEUED — no reply exists yet.
|
|
36
|
+
* The server cancels every non-terminal turn regardless, so a null id is
|
|
37
|
+
* a valid cancel, not a no-op. */
|
|
38
|
+
onStop: (messageId: string | null) => void;
|
|
39
|
+
onTakeOver: () => void;
|
|
40
|
+
/** Optional app-supplied banner rendered above the composer (e.g. an
|
|
41
|
+
* imported-session note). The kit itself has no CLI-auth banners. */
|
|
42
|
+
banner?: ReactNode;
|
|
43
|
+
/** When set, sending is disabled and this reason is shown as a hint. */
|
|
44
|
+
disabledReason?: string;
|
|
45
|
+
/** Files staged for the next send. Omit to hide attaching entirely — the kit
|
|
46
|
+
* stays usable by hosts that have no upload endpoint. */
|
|
47
|
+
attachments?: PendingAttachment[];
|
|
48
|
+
/** Hand off chosen files. The host owns the upload (the kit knows no REST
|
|
49
|
+
* paths); it re-renders `attachments` as they progress. */
|
|
50
|
+
onAttach?: (files: File[]) => void;
|
|
51
|
+
onRemoveAttachment?: (id: string) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function SendBox({
|
|
55
|
+
draft,
|
|
56
|
+
connected,
|
|
57
|
+
currentUserId,
|
|
58
|
+
holderIsPresent,
|
|
59
|
+
isStreaming,
|
|
60
|
+
streamingMessageId,
|
|
61
|
+
onUpdate,
|
|
62
|
+
onSend,
|
|
63
|
+
onStop,
|
|
64
|
+
onTakeOver,
|
|
65
|
+
banner,
|
|
66
|
+
disabledReason,
|
|
67
|
+
attachments,
|
|
68
|
+
onAttach,
|
|
69
|
+
onRemoveAttachment,
|
|
70
|
+
}: Props) {
|
|
71
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
72
|
+
// Force a re-render when the lock transitions from live to idle.
|
|
73
|
+
// Without this, nothing would trigger a re-render exactly at T+2s
|
|
74
|
+
// after the last edit, and another user's UI would stay locked
|
|
75
|
+
// indefinitely until some unrelated event happens to arrive.
|
|
76
|
+
const [, forceTick] = useState(0);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!draft) return;
|
|
80
|
+
const remaining = msUntilDraftIdle(draft);
|
|
81
|
+
if (remaining === 0) return;
|
|
82
|
+
const t = window.setTimeout(() => forceTick((n) => n + 1), remaining + 10);
|
|
83
|
+
return () => window.clearTimeout(t);
|
|
84
|
+
}, [draft?.last_edit_at, draft]);
|
|
85
|
+
|
|
86
|
+
const holderId = draft?.last_editor ?? null;
|
|
87
|
+
const isHolder = holderId != null && holderId === currentUserId;
|
|
88
|
+
const holderIsIdle = isDraftIdle(draft);
|
|
89
|
+
|
|
90
|
+
// LOCAL-FIRST: the textarea's value is local state, never server state.
|
|
91
|
+
// Rendering `draft.body` directly made every inbound frame a chance to
|
|
92
|
+
// overwrite the user mid-keystroke — a stale echo of your own debounced
|
|
93
|
+
// update, or a `session.state` snapshot on reconnect (which replaces state
|
|
94
|
+
// wholesale), would rewind the composer to a body from 150ms ago. In
|
|
95
|
+
// single-player that reconciliation protects against nothing at all, since
|
|
96
|
+
// there is no co-editor whose edits could be lost.
|
|
97
|
+
const [localBody, setLocalBody] = useState(draft?.body ?? "");
|
|
98
|
+
|
|
99
|
+
// The ONE case where the server genuinely knows better than this client:
|
|
100
|
+
// somebody ELSE edited the shared draft. Our own echo is ignored, which is
|
|
101
|
+
// also what stops two clients on one account (phone + desktop) from
|
|
102
|
+
// fighting — each keeps its own text instead of clobbering the other.
|
|
103
|
+
const theirEdit =
|
|
104
|
+
draft != null && draft.last_editor !== currentUserId ? draft.body : null;
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (theirEdit != null) setLocalBody(theirEdit);
|
|
107
|
+
}, [theirEdit]);
|
|
108
|
+
|
|
109
|
+
// Typing is ALWAYS allowed unless a teammate is actively holding the draft.
|
|
110
|
+
// It used to require `draft != null`, so the composer was disabled until
|
|
111
|
+
// session.state landed — locking you out of your own input on first paint
|
|
112
|
+
// and again on every reconnect. Keystrokes typed early are held locally and
|
|
113
|
+
// flushed when the draft exists (see useSessionSocket.sendChat).
|
|
114
|
+
const canEdit = isHolder || holderIsIdle || !holderIsPresent;
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (canEdit && !isHolder && textareaRef.current) {
|
|
118
|
+
textareaRef.current.focus();
|
|
119
|
+
}
|
|
120
|
+
}, [canEdit, isHolder]);
|
|
121
|
+
|
|
122
|
+
const body = localBody;
|
|
123
|
+
const blocked = Boolean(disabledReason);
|
|
124
|
+
// Sending needs a draft (`chat.send` commits the SERVER's copy, so there must
|
|
125
|
+
// be one) AND a live socket. The socket check is load-bearing now that the
|
|
126
|
+
// composer clears optimistically: `send()` drops every frame but chat.stop
|
|
127
|
+
// when the socket is closed, so an allowed-but-undeliverable send would clear
|
|
128
|
+
// the box and lose the message outright.
|
|
129
|
+
const canSend =
|
|
130
|
+
canEdit &&
|
|
131
|
+
connected &&
|
|
132
|
+
draft != null &&
|
|
133
|
+
body.trim().length > 0 &&
|
|
134
|
+
!isStreaming &&
|
|
135
|
+
!blocked;
|
|
136
|
+
|
|
137
|
+
const handleChange = (value: string) => {
|
|
138
|
+
setLocalBody(value);
|
|
139
|
+
onUpdate(value);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const canAttach = typeof onAttach === "function" && canEdit && !blocked;
|
|
143
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
144
|
+
const [dragging, setDragging] = useState(false);
|
|
145
|
+
|
|
146
|
+
const take = (files: FileList | null | undefined) => {
|
|
147
|
+
if (!canAttach || !files || files.length === 0) return;
|
|
148
|
+
onAttach!(Array.from(files));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Paste is the point on desktop: a screenshot goes to the clipboard, and
|
|
152
|
+
// making people save it to disk first is most of the friction.
|
|
153
|
+
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
154
|
+
if (!canAttach) return;
|
|
155
|
+
const files = Array.from(e.clipboardData?.files ?? []);
|
|
156
|
+
if (files.length === 0) return;
|
|
157
|
+
e.preventDefault(); // else the filename lands in the textarea as text
|
|
158
|
+
onAttach!(files);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handleSend = () => {
|
|
162
|
+
// Clear locally rather than waiting for the server's cleared draft to
|
|
163
|
+
// echo back: that echo carries last_editor === us, which the adopt rule
|
|
164
|
+
// above (correctly) ignores, so nothing else would empty the box.
|
|
165
|
+
setLocalBody("");
|
|
166
|
+
onSend();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const handleKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
170
|
+
// `isComposing` is true during IME input (CJK, etc.). Pressing
|
|
171
|
+
// Enter to commit a composition must not send the message.
|
|
172
|
+
const isComposing = (e.nativeEvent as unknown as { isComposing?: boolean })
|
|
173
|
+
.isComposing;
|
|
174
|
+
if (e.key === "Enter" && !e.shiftKey && !isComposing) {
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
if (canSend) handleSend();
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const handleStopClick = () => {
|
|
181
|
+
// Fires even with no streamingMessageId: while a turn sits QUEUED there is
|
|
182
|
+
// no assistant message to name, and that is exactly when you want out.
|
|
183
|
+
onStop(streamingMessageId);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const placeholder = blocked
|
|
187
|
+
? disabledReason
|
|
188
|
+
: !canEdit
|
|
189
|
+
? "Another teammate is editing…"
|
|
190
|
+
: !draft
|
|
191
|
+
? "Type a message… (connecting…)"
|
|
192
|
+
: "Type a message… (Enter to send, Shift+Enter for newline)";
|
|
193
|
+
|
|
194
|
+
const staged = attachments ?? [];
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
className="border-t border-border bg-background"
|
|
199
|
+
onDragOver={(e) => {
|
|
200
|
+
if (!canAttach) return;
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
setDragging(true);
|
|
203
|
+
}}
|
|
204
|
+
onDragLeave={() => setDragging(false)}
|
|
205
|
+
onDrop={(e) => {
|
|
206
|
+
if (!canAttach) return;
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
setDragging(false);
|
|
209
|
+
take(e.dataTransfer?.files);
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{banner}
|
|
213
|
+
<div className={`p-2 ${dragging ? "bg-primary/5 ring-1 ring-inset ring-primary/40" : ""}`}>
|
|
214
|
+
{staged.length > 0 && (
|
|
215
|
+
<ul className="mb-1.5 flex flex-wrap gap-1.5" data-testid="attachment-chips">
|
|
216
|
+
{staged.map((a) => (
|
|
217
|
+
<li
|
|
218
|
+
key={a.id}
|
|
219
|
+
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs ${
|
|
220
|
+
a.error
|
|
221
|
+
? "border-destructive/40 bg-destructive/10 text-destructive"
|
|
222
|
+
: "border-border bg-muted text-foreground-secondary"
|
|
223
|
+
}`}
|
|
224
|
+
>
|
|
225
|
+
<span className="max-w-[14rem] truncate">{a.filename}</span>
|
|
226
|
+
{a.uploading && <span className="text-muted-foreground">uploading…</span>}
|
|
227
|
+
{a.error && <span title={a.error}>· {a.error}</span>}
|
|
228
|
+
{!a.uploading && onRemoveAttachment && (
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
aria-label={`Remove ${a.filename}`}
|
|
232
|
+
onClick={() => onRemoveAttachment(a.id)}
|
|
233
|
+
className="text-muted-foreground hover:text-foreground"
|
|
234
|
+
>
|
|
235
|
+
×
|
|
236
|
+
</button>
|
|
237
|
+
)}
|
|
238
|
+
</li>
|
|
239
|
+
))}
|
|
240
|
+
</ul>
|
|
241
|
+
)}
|
|
242
|
+
<textarea
|
|
243
|
+
ref={textareaRef}
|
|
244
|
+
value={body}
|
|
245
|
+
disabled={!canEdit || blocked}
|
|
246
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
247
|
+
onKeyDown={handleKey}
|
|
248
|
+
onPaste={handlePaste}
|
|
249
|
+
placeholder={placeholder}
|
|
250
|
+
rows={3}
|
|
251
|
+
className="w-full resize-none rounded-md border border-input bg-transparent p-2 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
|
|
252
|
+
/>
|
|
253
|
+
<div className="mt-1 flex items-center justify-end gap-2">
|
|
254
|
+
{canAttach && (
|
|
255
|
+
<>
|
|
256
|
+
<input
|
|
257
|
+
ref={fileInputRef}
|
|
258
|
+
type="file"
|
|
259
|
+
multiple
|
|
260
|
+
accept="image/*"
|
|
261
|
+
className="hidden"
|
|
262
|
+
data-testid="attachment-input"
|
|
263
|
+
onChange={(e) => {
|
|
264
|
+
take(e.target.files);
|
|
265
|
+
e.target.value = ""; // same file twice in a row must re-fire
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
<Button
|
|
269
|
+
type="button"
|
|
270
|
+
variant="outline"
|
|
271
|
+
size="sm"
|
|
272
|
+
className="mr-auto"
|
|
273
|
+
onClick={() => fileInputRef.current?.click()}
|
|
274
|
+
>
|
|
275
|
+
attach
|
|
276
|
+
</Button>
|
|
277
|
+
</>
|
|
278
|
+
)}
|
|
279
|
+
{blocked && (
|
|
280
|
+
<span className="mr-auto text-xs text-muted-foreground">
|
|
281
|
+
{disabledReason}
|
|
282
|
+
</span>
|
|
283
|
+
)}
|
|
284
|
+
{isStreaming ? (
|
|
285
|
+
<Button
|
|
286
|
+
type="button"
|
|
287
|
+
variant="destructive"
|
|
288
|
+
size="sm"
|
|
289
|
+
onClick={handleStopClick}
|
|
290
|
+
>
|
|
291
|
+
stop
|
|
292
|
+
</Button>
|
|
293
|
+
) : null}
|
|
294
|
+
{!canEdit && holderIsPresent && !holderIsIdle ? (
|
|
295
|
+
<Button type="button" variant="outline" size="sm" onClick={onTakeOver}>
|
|
296
|
+
take over
|
|
297
|
+
</Button>
|
|
298
|
+
) : null}
|
|
299
|
+
<Button type="button" size="sm" disabled={!canSend} onClick={handleSend}>
|
|
300
|
+
send
|
|
301
|
+
</Button>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Check, ChevronRight, Loader2, X } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import type { Message } from "./protocol";
|
|
4
|
+
import { deriveToolStatus, toolDisplayName, toolPreview } from "./pairToolMessages";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
use: Message;
|
|
8
|
+
result: Message | null;
|
|
9
|
+
/** Controlled-open state: true to force open, false to force closed,
|
|
10
|
+
* undefined to let the user control via the native <details> toggle. */
|
|
11
|
+
forceOpen?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Renders a tool_use + tool_result as a single collapsible row.
|
|
16
|
+
*
|
|
17
|
+
* Header line: ``{status icon} {tool name} · {preview}`` — readable when
|
|
18
|
+
* collapsed so the user can scan a long stream of tool calls without
|
|
19
|
+
* expanding any. Expanded view stacks the input JSON on top of the
|
|
20
|
+
* result body, both monospace.
|
|
21
|
+
*/
|
|
22
|
+
export function ToolCallPair({ use, result, forceOpen }: Props) {
|
|
23
|
+
const status = deriveToolStatus(use, result);
|
|
24
|
+
const name = toolDisplayName(use);
|
|
25
|
+
const preview = toolPreview(use, result);
|
|
26
|
+
|
|
27
|
+
const StatusIcon =
|
|
28
|
+
status.kind === "success" ? Check : status.kind === "error" ? X : Loader2;
|
|
29
|
+
const iconColor =
|
|
30
|
+
status.kind === "success"
|
|
31
|
+
? "text-success"
|
|
32
|
+
: status.kind === "error"
|
|
33
|
+
? "text-destructive"
|
|
34
|
+
: "text-muted-foreground animate-spin";
|
|
35
|
+
|
|
36
|
+
const input = (use.content as { input?: unknown } | undefined)?.input;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<details
|
|
40
|
+
// ``open`` controls the row when forceOpen is set; otherwise
|
|
41
|
+
// ``open={undefined}`` lets the native toggle take over so single
|
|
42
|
+
// rows still expand/collapse on click.
|
|
43
|
+
open={forceOpen}
|
|
44
|
+
className="group my-1 rounded border border-border bg-muted/40 text-sm"
|
|
45
|
+
>
|
|
46
|
+
<summary className="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-muted-foreground hover:bg-muted/60 select-none [&::-webkit-details-marker]:hidden">
|
|
47
|
+
<ChevronRight className="h-3 w-3 shrink-0 transition-transform group-open:rotate-90" />
|
|
48
|
+
<StatusIcon className={`h-3.5 w-3.5 shrink-0 ${iconColor}`} />
|
|
49
|
+
<span className="font-mono text-xs font-medium text-foreground">
|
|
50
|
+
{name}
|
|
51
|
+
</span>
|
|
52
|
+
{preview && (
|
|
53
|
+
<span className="truncate text-xs italic text-muted-foreground">
|
|
54
|
+
· {preview}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
</summary>
|
|
58
|
+
<div className="space-y-2 border-t border-border/60 p-2">
|
|
59
|
+
{input !== undefined && (
|
|
60
|
+
<div>
|
|
61
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
62
|
+
input
|
|
63
|
+
</div>
|
|
64
|
+
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded bg-background p-2 text-xs">
|
|
65
|
+
{JSON.stringify(input, null, 2)}
|
|
66
|
+
</pre>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
<div>
|
|
70
|
+
<div className="mb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
71
|
+
result
|
|
72
|
+
</div>
|
|
73
|
+
{result === null ? (
|
|
74
|
+
<div className="text-xs italic text-muted-foreground">
|
|
75
|
+
(running…)
|
|
76
|
+
</div>
|
|
77
|
+
) : (
|
|
78
|
+
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded bg-background p-2 text-xs">
|
|
79
|
+
{result.plaintext}
|
|
80
|
+
</pre>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</details>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
import type { Draft } from "./protocol"
|
|
4
|
+
import {
|
|
5
|
+
IDLE_THRESHOLD_MS,
|
|
6
|
+
isDraftIdle,
|
|
7
|
+
msUntilDraftIdle,
|
|
8
|
+
shouldSyncDraftLive,
|
|
9
|
+
} from "./drafts"
|
|
10
|
+
|
|
11
|
+
const NOW = 1_700_000_000_000
|
|
12
|
+
|
|
13
|
+
function draftEditedAt(msAgo: number): Draft {
|
|
14
|
+
return {
|
|
15
|
+
id: "d1",
|
|
16
|
+
slot: "next",
|
|
17
|
+
status: "open",
|
|
18
|
+
body: "",
|
|
19
|
+
version: 0,
|
|
20
|
+
last_editor: 1,
|
|
21
|
+
last_edit_at: new Date(NOW - msAgo).toISOString(),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("isDraftIdle", () => {
|
|
30
|
+
it("treats a null/undefined draft as idle", () => {
|
|
31
|
+
expect(isDraftIdle(null)).toBe(true)
|
|
32
|
+
expect(isDraftIdle(undefined)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("treats a draft with no last_edit_at as idle", () => {
|
|
36
|
+
const d = { ...draftEditedAt(0), last_edit_at: "" }
|
|
37
|
+
expect(isDraftIdle(d)).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("is NOT idle immediately after an edit", () => {
|
|
41
|
+
vi.useFakeTimers()
|
|
42
|
+
vi.setSystemTime(NOW)
|
|
43
|
+
// edited 500ms ago — well within the 2s threshold
|
|
44
|
+
expect(isDraftIdle(draftEditedAt(500))).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("IS idle once more than the threshold has elapsed", () => {
|
|
48
|
+
vi.useFakeTimers()
|
|
49
|
+
vi.setSystemTime(NOW)
|
|
50
|
+
expect(isDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 1))).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("msUntilDraftIdle", () => {
|
|
55
|
+
it("returns 0 for a null draft", () => {
|
|
56
|
+
expect(msUntilDraftIdle(null)).toBe(0)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("returns the remaining time before the idle transition", () => {
|
|
60
|
+
vi.useFakeTimers()
|
|
61
|
+
vi.setSystemTime(NOW)
|
|
62
|
+
// edited 500ms ago → 1500ms remain
|
|
63
|
+
expect(msUntilDraftIdle(draftEditedAt(500))).toBe(1500)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("clamps to 0 once past the threshold", () => {
|
|
67
|
+
vi.useFakeTimers()
|
|
68
|
+
vi.setSystemTime(NOW)
|
|
69
|
+
expect(msUntilDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 5000))).toBe(0)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("shouldSyncDraftLive", () => {
|
|
74
|
+
it("does not mirror keystrokes when you are alone", () => {
|
|
75
|
+
// The single-player case: a per-keystroke draft.update costs a round trip,
|
|
76
|
+
// an echo that can rewind the textarea, and a version to disagree about —
|
|
77
|
+
// and protects no co-editor, because there isn't one.
|
|
78
|
+
expect(shouldSyncDraftLive([1])).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("treats an empty presence set as alone", () => {
|
|
82
|
+
// Pre-connect: presence has not landed yet, which is precisely when there
|
|
83
|
+
// is nobody to sync with.
|
|
84
|
+
expect(shouldSyncDraftLive([])).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("mirrors keystrokes once somebody else is present", () => {
|
|
88
|
+
expect(shouldSyncDraftLive([1, 2])).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Draft } from "./protocol";
|
|
2
|
+
|
|
3
|
+
export const IDLE_THRESHOLD_MS = 2_000;
|
|
4
|
+
|
|
5
|
+
export function isDraftIdle(draft: Draft | null | undefined): boolean {
|
|
6
|
+
if (!draft?.last_edit_at) return true;
|
|
7
|
+
return Date.now() - new Date(draft.last_edit_at).getTime() > IDLE_THRESHOLD_MS;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether keystrokes need to be mirrored to the server AS YOU TYPE.
|
|
12
|
+
*
|
|
13
|
+
* The co-edited draft only earns its cost when somebody else is looking at it.
|
|
14
|
+
* Alone in a session — the overwhelmingly common case — a per-keystroke
|
|
15
|
+
* `draft.update` buys nothing and costs a round trip, a re-render on the echo,
|
|
16
|
+
* and a version to disagree about. The body still reaches the server once,
|
|
17
|
+
* right before `chat.send` (which commits the SERVER's copy), so sending is
|
|
18
|
+
* unaffected; see useSessionSocket.sendChat.
|
|
19
|
+
*
|
|
20
|
+
* Presence includes yourself, so "alone" is a set of 0 or 1. An empty set means
|
|
21
|
+
* presence has not arrived yet — treated as alone, since the pre-connect window
|
|
22
|
+
* is exactly when there is no one to sync with.
|
|
23
|
+
*/
|
|
24
|
+
export function shouldSyncDraftLive(presenceUserIds: readonly number[]): boolean {
|
|
25
|
+
return presenceUserIds.length > 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Milliseconds until the draft lock becomes idle. Returns 0 if already
|
|
30
|
+
* idle. Use this to schedule a timer that forces a re-render at the
|
|
31
|
+
* idle transition point.
|
|
32
|
+
*/
|
|
33
|
+
export function msUntilDraftIdle(draft: Draft | null | undefined): number {
|
|
34
|
+
if (!draft?.last_edit_at) return 0;
|
|
35
|
+
const elapsed = Date.now() - new Date(draft.last_edit_at).getTime();
|
|
36
|
+
return Math.max(0, IDLE_THRESHOLD_MS - elapsed);
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { MIN_RUN_TO_GROUP, groupToolRuns, runHasError, runIsActive, summariseRun } from "./groupToolRuns"
|
|
4
|
+
import type { ChatRow } from "./pairToolMessages"
|
|
5
|
+
import type { Message } from "./protocol"
|
|
6
|
+
|
|
7
|
+
function msg(over: Partial<Message> = {}): Message {
|
|
8
|
+
return {
|
|
9
|
+
id: "m", turn_index: 0, role: "tool_use", content: {}, plaintext: "",
|
|
10
|
+
status: "complete", error_detail: null, started_at: null, completed_at: null,
|
|
11
|
+
created_at: "", ...over,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const pair = (i: number, name = "Bash", result?: Partial<Message>): ChatRow => ({
|
|
16
|
+
kind: "tool_pair",
|
|
17
|
+
use: msg({ id: `u${i}`, content: { id: `t${i}`, name } }),
|
|
18
|
+
result: result ? msg({ id: `r${i}`, role: "tool_result", ...result }) : msg({ id: `r${i}`, role: "tool_result" }),
|
|
19
|
+
key: `pair-${i}`,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const prose = (i: number): ChatRow => ({
|
|
23
|
+
kind: "message",
|
|
24
|
+
message: msg({ id: `p${i}`, role: "assistant", plaintext: "words" }),
|
|
25
|
+
key: `msg-${i}`,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("groupToolRuns", () => {
|
|
29
|
+
it("collapses a run of consecutive tool calls into one row", () => {
|
|
30
|
+
const out = groupToolRuns([pair(1), pair(2), pair(3), pair(4)])
|
|
31
|
+
expect(out).toHaveLength(1)
|
|
32
|
+
expect(out[0].kind).toBe("tool_run")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("prose breaks a run, so the conversation stays legible", () => {
|
|
36
|
+
// The whole point: an agent's own words must never be buried inside a
|
|
37
|
+
// collapsed group of the calls that surrounded them.
|
|
38
|
+
const out = groupToolRuns([pair(1), pair(2), pair(3), prose(1), pair(4), pair(5), pair(6)])
|
|
39
|
+
expect(out.map((r) => r.kind)).toEqual(["tool_run", "message", "tool_run"])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("leaves short runs alone", () => {
|
|
43
|
+
// Wrapping one or two calls costs a click and hides nothing.
|
|
44
|
+
const out = groupToolRuns([pair(1), pair(2)])
|
|
45
|
+
expect(out.map((r) => r.kind)).toEqual(["tool_pair", "tool_pair"])
|
|
46
|
+
expect(MIN_RUN_TO_GROUP).toBe(3)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("keeps every call, in order, inside the group", () => {
|
|
50
|
+
const out = groupToolRuns([pair(1), pair(2), pair(3)])
|
|
51
|
+
const run = out[0] as { rows: ChatRow[] }
|
|
52
|
+
expect(run.rows.map((r) => r.key)).toEqual(["pair-1", "pair-2", "pair-3"])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("summarises a run by count and the tools it used", () => {
|
|
56
|
+
expect(summariseRun([pair(1, "Bash"), pair(2, "Read"), pair(3, "Bash")]))
|
|
57
|
+
.toBe("3 tool calls · Bash, Read")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("flags a run containing a failure", () => {
|
|
61
|
+
// A collapsed group must not hide the one thing worth stopping for.
|
|
62
|
+
expect(runHasError([pair(1), pair(2)])).toBe(false)
|
|
63
|
+
expect(runHasError([pair(1), pair(2, "Bash", { status: "error" })])).toBe(true)
|
|
64
|
+
expect(runHasError([pair(1, "Bash", { content: { is_error: true } })])).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("a session of only prose is untouched", () => {
|
|
68
|
+
const out = groupToolRuns([prose(1), prose(2)])
|
|
69
|
+
expect(out.map((r) => r.kind)).toEqual(["message", "message"])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("runIsActive", () => {
|
|
74
|
+
it("a run with a call still in flight reports active", () => {
|
|
75
|
+
// Collapsed, an agent mid-task would otherwise look idle — which is the
|
|
76
|
+
// exact question "is this session working?" asks.
|
|
77
|
+
const pending: ChatRow = { kind: "tool_pair", use: msg({ id: "u9" }), result: null, key: "pair-9" }
|
|
78
|
+
expect(runIsActive([pair(1), pending])).toBe(true)
|
|
79
|
+
expect(runIsActive([pair(1), pair(2)])).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ChatRow } from "./pairToolMessages";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collapse a run of consecutive tool calls into ONE row.
|
|
5
|
+
*
|
|
6
|
+
* An agent working on something emits long stretches of back-to-back tool
|
|
7
|
+
* calls. Rendered one per row they push everything you actually read — the
|
|
8
|
+
* agent's prose, your own messages — off the screen, and a session you glance
|
|
9
|
+
* at becomes a wall of `Bash` you have to scroll past. Claude Code's own answer
|
|
10
|
+
* is a single "Running 5 shell commands…" line you can open if you care, and
|
|
11
|
+
* this is that.
|
|
12
|
+
*
|
|
13
|
+
* A run is broken by any non-tool row, so prose always separates groups and the
|
|
14
|
+
* conversation stays legible. Runs of one are left alone: wrapping a single
|
|
15
|
+
* call in a group adds a click without hiding anything.
|
|
16
|
+
*/
|
|
17
|
+
export type GroupedRow =
|
|
18
|
+
| ChatRow
|
|
19
|
+
| { kind: "tool_run"; rows: ChatRow[]; key: string };
|
|
20
|
+
|
|
21
|
+
/** Below this, a run renders as individual rows — grouping one or two calls
|
|
22
|
+
* costs a click and saves no space. */
|
|
23
|
+
export const MIN_RUN_TO_GROUP = 3;
|
|
24
|
+
|
|
25
|
+
export function groupToolRuns(
|
|
26
|
+
rows: ChatRow[],
|
|
27
|
+
minRun: number = MIN_RUN_TO_GROUP,
|
|
28
|
+
): GroupedRow[] {
|
|
29
|
+
const out: GroupedRow[] = [];
|
|
30
|
+
let run: ChatRow[] = [];
|
|
31
|
+
|
|
32
|
+
const flush = () => {
|
|
33
|
+
if (run.length === 0) return;
|
|
34
|
+
if (run.length >= minRun) {
|
|
35
|
+
out.push({ kind: "tool_run", rows: run, key: `run-${run[0].key}` });
|
|
36
|
+
} else {
|
|
37
|
+
out.push(...run);
|
|
38
|
+
}
|
|
39
|
+
run = [];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
if (row.kind === "tool_pair") {
|
|
44
|
+
run.push(row);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
flush();
|
|
48
|
+
out.push(row);
|
|
49
|
+
}
|
|
50
|
+
flush();
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when any call in the run is still running — a collapsed group should say
|
|
55
|
+
* so, or an agent mid-task looks idle. */
|
|
56
|
+
export function runIsActive(rows: ChatRow[]): boolean {
|
|
57
|
+
return rows.some((row) => row.kind === "tool_pair" && row.result === null);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A short label for a collapsed run: "5 tool calls · Bash, Read". */
|
|
61
|
+
export function summariseRun(rows: ChatRow[]): string {
|
|
62
|
+
const names = new Set<string>();
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
if (row.kind !== "tool_pair") continue;
|
|
65
|
+
const name = (row.use.content as { name?: unknown } | undefined)?.name;
|
|
66
|
+
if (typeof name === "string" && name) names.add(name);
|
|
67
|
+
}
|
|
68
|
+
const kinds = [...names].slice(0, 3).join(", ");
|
|
69
|
+
const plural = rows.length === 1 ? "call" : "calls";
|
|
70
|
+
return kinds ? `${rows.length} tool ${plural} · ${kinds}` : `${rows.length} tool ${plural}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when any call in the run failed — a collapsed run must not hide an
|
|
74
|
+
* error, or you'd scroll past the one thing worth stopping for. */
|
|
75
|
+
export function runHasError(rows: ChatRow[]): boolean {
|
|
76
|
+
return rows.some(
|
|
77
|
+
(row) =>
|
|
78
|
+
row.kind === "tool_pair" &&
|
|
79
|
+
(row.result?.status === "error" ||
|
|
80
|
+
(row.result?.content as { is_error?: unknown } | undefined)?.is_error === true),
|
|
81
|
+
);
|
|
82
|
+
}
|