canopy-ui 0.4.0 → 0.6.1

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.
@@ -5,30 +5,55 @@ import {
5
5
  type KeyboardEvent,
6
6
  type ReactNode,
7
7
  } from "react";
8
+ import type React from "react";
8
9
 
9
10
  import type { Draft } from "./protocol";
10
11
  import { isDraftIdle, msUntilDraftIdle } from "./drafts";
11
12
  import { Button } from "../ui/button";
12
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
+
13
25
  interface Props {
14
26
  draft: Draft | null;
27
+ /** Live socket. Typing never depends on this; SENDING does — see canSend. */
28
+ connected: boolean;
15
29
  currentUserId: number;
16
30
  holderIsPresent: boolean;
17
31
  isStreaming: boolean;
18
32
  streamingMessageId: string | null;
19
33
  onUpdate: (body: string) => void;
20
34
  onSend: () => void;
21
- onStop: (messageId: string) => 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;
22
39
  onTakeOver: () => void;
23
40
  /** Optional app-supplied banner rendered above the composer (e.g. an
24
41
  * imported-session note). The kit itself has no CLI-auth banners. */
25
42
  banner?: ReactNode;
26
43
  /** When set, sending is disabled and this reason is shown as a hint. */
27
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;
28
52
  }
29
53
 
30
54
  export function SendBox({
31
55
  draft,
56
+ connected,
32
57
  currentUserId,
33
58
  holderIsPresent,
34
59
  isStreaming,
@@ -39,6 +64,9 @@ export function SendBox({
39
64
  onTakeOver,
40
65
  banner,
41
66
  disabledReason,
67
+ attachments,
68
+ onAttach,
69
+ onRemoveAttachment,
42
70
  }: Props) {
43
71
  const textareaRef = useRef<HTMLTextAreaElement>(null);
44
72
  // Force a re-render when the lock transitions from live to idle.
@@ -59,11 +87,31 @@ export function SendBox({
59
87
  const isHolder = holderId != null && holderId === currentUserId;
60
88
  const holderIsIdle = isDraftIdle(draft);
61
89
 
62
- // Gate on draft existence: during the pre-session.state window the
63
- // textarea would otherwise accept keystrokes that silently drop
64
- // because the hook's updateDraft no-ops when active_draft is null.
65
- const canEdit =
66
- draft != null && (isHolder || holderIsIdle || !holderIsPresent);
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;
67
115
 
68
116
  useEffect(() => {
69
117
  if (canEdit && !isHolder && textareaRef.current) {
@@ -71,10 +119,52 @@ export function SendBox({
71
119
  }
72
120
  }, [canEdit, isHolder]);
73
121
 
74
- const body = draft?.body ?? "";
122
+ const body = localBody;
75
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.
76
129
  const canSend =
77
- canEdit && body.trim().length > 0 && !isStreaming && !blocked;
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
+ };
78
168
 
79
169
  const handleKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
80
170
  // `isComposing` is true during IME input (CJK, etc.). Pressing
@@ -83,37 +173,109 @@ export function SendBox({
83
173
  .isComposing;
84
174
  if (e.key === "Enter" && !e.shiftKey && !isComposing) {
85
175
  e.preventDefault();
86
- if (canSend) onSend();
176
+ if (canSend) handleSend();
87
177
  }
88
178
  };
89
179
 
90
180
  const handleStopClick = () => {
91
- if (streamingMessageId != null) onStop(streamingMessageId);
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);
92
184
  };
93
185
 
94
- const placeholder = !draft
95
- ? "Connecting…"
96
- : blocked
97
- ? disabledReason
98
- : canEdit
99
- ? "Type a message… (Enter to send, Shift+Enter for newline)"
100
- : "Another teammate is editing…";
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 ?? [];
101
195
 
102
196
  return (
103
- <div className="border-t border-border bg-background">
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
+ >
104
212
  {banner}
105
- <div className="p-2">
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
+ )}
106
242
  <textarea
107
243
  ref={textareaRef}
108
244
  value={body}
109
245
  disabled={!canEdit || blocked}
110
- onChange={(e) => onUpdate(e.target.value)}
246
+ onChange={(e) => handleChange(e.target.value)}
111
247
  onKeyDown={handleKey}
248
+ onPaste={handlePaste}
112
249
  placeholder={placeholder}
113
250
  rows={3}
114
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"
115
252
  />
116
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
+ )}
117
279
  {blocked && (
118
280
  <span className="mr-auto text-xs text-muted-foreground">
119
281
  {disabledReason}
@@ -134,7 +296,7 @@ export function SendBox({
134
296
  take over
135
297
  </Button>
136
298
  ) : null}
137
- <Button type="button" size="sm" disabled={!canSend} onClick={onSend}>
299
+ <Button type="button" size="sm" disabled={!canSend} onClick={handleSend}>
138
300
  send
139
301
  </Button>
140
302
  </div>
@@ -1,7 +1,12 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest"
2
2
 
3
3
  import type { Draft } from "./protocol"
4
- import { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts"
4
+ import {
5
+ IDLE_THRESHOLD_MS,
6
+ isDraftIdle,
7
+ msUntilDraftIdle,
8
+ shouldSyncDraftLive,
9
+ } from "./drafts"
5
10
 
6
11
  const NOW = 1_700_000_000_000
7
12
 
@@ -64,3 +69,22 @@ describe("msUntilDraftIdle", () => {
64
69
  expect(msUntilDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 5000))).toBe(0)
65
70
  })
66
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
+ })
@@ -7,6 +7,24 @@ export function isDraftIdle(draft: Draft | null | undefined): boolean {
7
7
  return Date.now() - new Date(draft.last_edit_at).getTime() > IDLE_THRESHOLD_MS;
8
8
  }
9
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
+
10
28
  /**
11
29
  * Milliseconds until the draft lock becomes idle. Returns 0 if already
12
30
  * idle. Use this to schedule a timer that forces a re-render at the
@@ -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
+ }
package/src/chat/index.ts CHANGED
@@ -46,7 +46,7 @@ export { ChatPanel, type ChatPanelProps } from "./ChatPanel";
46
46
  export { MessageList } from "./MessageList";
47
47
  export { MessageItem, type RenderMarkdown } from "./MessageItem";
48
48
  export { ToolCallPair } from "./ToolCallPair";
49
- export { SendBox } from "./SendBox";
49
+ export { SendBox, type PendingAttachment } from "./SendBox";
50
50
  export { PresenceChips } from "./PresenceChips";
51
51
  export { ConnectionStatus } from "./ConnectionStatus";
52
52
  export {