canopy-ui 0.6.2 → 0.7.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 +5 -2
- package/src/chat/ChatPanel.pending.test.tsx +136 -0
- package/src/chat/ChatPanel.tsx +48 -2
- package/src/chat/MenuPrompt.test.tsx +24 -0
- package/src/chat/MenuPrompt.tsx +331 -0
- package/src/chat/MessageItem.tsx +21 -8
- package/src/chat/MessageList.tsx +32 -3
- package/src/chat/PlacementBanner.test.tsx +75 -0
- package/src/chat/PlacementBanner.tsx +52 -15
- package/src/chat/PresenceChips.tsx +113 -19
- package/src/chat/SendBox.test.tsx +151 -0
- package/src/chat/SendBox.tsx +139 -10
- package/src/chat/drafts.test.ts +120 -0
- package/src/chat/drafts.ts +134 -0
- package/src/chat/index.ts +13 -0
- package/src/chat/pairToolMessages.ts +1 -1
- package/src/chat/protocol.ts +98 -2
- package/src/chat/sessionReducer.test.ts +143 -0
- package/src/chat/sessionReducer.ts +54 -3
- package/src/presence/PresenceBadge.test.tsx +84 -0
- package/src/presence/PresenceBadge.tsx +111 -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 +199 -0
- package/src/presence/usePresence.ts +188 -0
package/src/chat/SendBox.tsx
CHANGED
|
@@ -8,7 +8,15 @@ import {
|
|
|
8
8
|
import type React from "react";
|
|
9
9
|
|
|
10
10
|
import type { Draft } from "./protocol";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
clearStoredDraft,
|
|
13
|
+
defaultDraftStorage,
|
|
14
|
+
isDraftIdle,
|
|
15
|
+
msUntilDraftIdle,
|
|
16
|
+
readStoredDraft,
|
|
17
|
+
writeStoredDraft,
|
|
18
|
+
type DraftStorage,
|
|
19
|
+
} from "./drafts";
|
|
12
20
|
import { Button } from "../ui/button";
|
|
13
21
|
|
|
14
22
|
/** An attachment the composer is holding, uploaded but not yet sent. */
|
|
@@ -28,6 +36,10 @@ interface Props {
|
|
|
28
36
|
connected: boolean;
|
|
29
37
|
currentUserId: number;
|
|
30
38
|
holderIsPresent: boolean;
|
|
39
|
+
/** Display name of the teammate holding the draft, when it is not you.
|
|
40
|
+
* The composer is where their words appear, so it is where their name
|
|
41
|
+
* belongs — it used to live only in a 28px chip in the opposite corner. */
|
|
42
|
+
holderName?: string | null;
|
|
31
43
|
isStreaming: boolean;
|
|
32
44
|
streamingMessageId: string | null;
|
|
33
45
|
onUpdate: (body: string) => void;
|
|
@@ -36,6 +48,10 @@ interface Props {
|
|
|
36
48
|
* The server cancels every non-terminal turn regardless, so a null id is
|
|
37
49
|
* a valid cancel, not a no-op. */
|
|
38
50
|
onStop: (messageId: string | null) => void;
|
|
51
|
+
/** Whether the stop the human asked for actually landed. Rendered next to the
|
|
52
|
+
* button that asked for it, because that is where the person who pressed it
|
|
53
|
+
* is looking. Undefined = no stop has been asked for on this turn. */
|
|
54
|
+
stopState?: "requested" | "stopped" | "failed";
|
|
39
55
|
onTakeOver: () => void;
|
|
40
56
|
/** Optional app-supplied banner rendered above the composer (e.g. an
|
|
41
57
|
* imported-session note). The kit itself has no CLI-auth banners. */
|
|
@@ -49,6 +65,13 @@ interface Props {
|
|
|
49
65
|
* paths); it re-renders `attachments` as they progress. */
|
|
50
66
|
onAttach?: (files: File[]) => void;
|
|
51
67
|
onRemoveAttachment?: (id: string) => void;
|
|
68
|
+
/** Persist what is typed under this key (the session id) so it survives
|
|
69
|
+
* unmounting — routing away and back, or closing the tab. Omit to keep the
|
|
70
|
+
* purely in-memory behaviour. */
|
|
71
|
+
persistKey?: string;
|
|
72
|
+
/** Storage backing `persistKey`. Defaults to localStorage; inject a fake in
|
|
73
|
+
* tests, or sessionStorage for per-tab drafts. */
|
|
74
|
+
storage?: DraftStorage | null;
|
|
52
75
|
}
|
|
53
76
|
|
|
54
77
|
export function SendBox({
|
|
@@ -56,17 +79,21 @@ export function SendBox({
|
|
|
56
79
|
connected,
|
|
57
80
|
currentUserId,
|
|
58
81
|
holderIsPresent,
|
|
82
|
+
holderName,
|
|
59
83
|
isStreaming,
|
|
60
84
|
streamingMessageId,
|
|
61
85
|
onUpdate,
|
|
62
86
|
onSend,
|
|
63
87
|
onStop,
|
|
88
|
+
stopState,
|
|
64
89
|
onTakeOver,
|
|
65
90
|
banner,
|
|
66
91
|
disabledReason,
|
|
67
92
|
attachments,
|
|
68
93
|
onAttach,
|
|
69
94
|
onRemoveAttachment,
|
|
95
|
+
persistKey,
|
|
96
|
+
storage,
|
|
70
97
|
}: Props) {
|
|
71
98
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
72
99
|
// Force a re-render when the lock transitions from live to idle.
|
|
@@ -94,7 +121,42 @@ export function SendBox({
|
|
|
94
121
|
// wholesale), would rewind the composer to a body from 150ms ago. In
|
|
95
122
|
// single-player that reconciliation protects against nothing at all, since
|
|
96
123
|
// there is no co-editor whose edits could be lost.
|
|
97
|
-
|
|
124
|
+
//
|
|
125
|
+
// Seeded from persisted storage when there is one, because a body typed
|
|
126
|
+
// before an unmount exists NOWHERE else: single-player never mirrors it to
|
|
127
|
+
// the server (see drafts.shouldSyncDraftLive), and the adopt rule below
|
|
128
|
+
// deliberately ignores our own server draft. A stored body wins over
|
|
129
|
+
// `draft.body` — it is strictly newer, being what was in the box when we
|
|
130
|
+
// last left it.
|
|
131
|
+
const store = storage === undefined ? defaultDraftStorage() : storage;
|
|
132
|
+
const [localBody, setLocalBody] = useState(
|
|
133
|
+
() => readStoredDraft(store, persistKey ?? "") ?? draft?.body ?? "",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Persistence is a synchronous localStorage write per keystroke — no
|
|
137
|
+
// debounce on purpose. The payload is a chat message, the write is
|
|
138
|
+
// microseconds, and every timer-based alternative has to solve flush-on-
|
|
139
|
+
// unmount and flush-on-tab-close to be correct at exactly the moments this
|
|
140
|
+
// feature exists for.
|
|
141
|
+
const persist = (body: string) => {
|
|
142
|
+
if (persistKey) writeStoredDraft(store, persistKey, body);
|
|
143
|
+
};
|
|
144
|
+
// Nothing persists a CO-EDITOR's text, deliberately: a draft someone else is
|
|
145
|
+
// editing is by definition being live-synced to the server, so the existing
|
|
146
|
+
// adopt rule below restores it from `session.state` on the way back in. The
|
|
147
|
+
// gap this whole mechanism closes is the single-player one, where the server
|
|
148
|
+
// is never told at all.
|
|
149
|
+
|
|
150
|
+
// The panel can swap sessions without remounting (same route, new :id), so
|
|
151
|
+
// the box must follow the key rather than carry one session's text into the
|
|
152
|
+
// next. Adjusted during render rather than in an effect — React's documented
|
|
153
|
+
// shape for "reset state when a prop changes", and the one that avoids a
|
|
154
|
+
// paint showing the previous session's text.
|
|
155
|
+
const [keyOnScreen, setKeyOnScreen] = useState(persistKey);
|
|
156
|
+
if (keyOnScreen !== persistKey) {
|
|
157
|
+
setKeyOnScreen(persistKey);
|
|
158
|
+
setLocalBody(readStoredDraft(store, persistKey ?? "") ?? "");
|
|
159
|
+
}
|
|
98
160
|
|
|
99
161
|
// The ONE case where the server genuinely knows better than this client:
|
|
100
162
|
// somebody ELSE edited the shared draft. Our own echo is ignored, which is
|
|
@@ -121,6 +183,10 @@ export function SendBox({
|
|
|
121
183
|
|
|
122
184
|
const body = localBody;
|
|
123
185
|
const blocked = Boolean(disabledReason);
|
|
186
|
+
// Locked BY SOMEONE ELSE, as opposed to blocked for an unrelated reason.
|
|
187
|
+
// The two look identical to `disabled` and want opposite treatments: a
|
|
188
|
+
// blocked box is inert, a co-edited one is showing you live content.
|
|
189
|
+
const lockedByTeammate = !canEdit && holderIsPresent && !holderIsIdle && !blocked;
|
|
124
190
|
// Sending needs a draft (`chat.send` commits the SERVER's copy, so there must
|
|
125
191
|
// be one) AND a live socket. The socket check is load-bearing now that the
|
|
126
192
|
// composer clears optimistically: `send()` drops every frame but chat.stop
|
|
@@ -136,6 +202,7 @@ export function SendBox({
|
|
|
136
202
|
|
|
137
203
|
const handleChange = (value: string) => {
|
|
138
204
|
setLocalBody(value);
|
|
205
|
+
persist(value);
|
|
139
206
|
onUpdate(value);
|
|
140
207
|
};
|
|
141
208
|
|
|
@@ -163,6 +230,7 @@ export function SendBox({
|
|
|
163
230
|
// echo back: that echo carries last_editor === us, which the adopt rule
|
|
164
231
|
// above (correctly) ignores, so nothing else would empty the box.
|
|
165
232
|
setLocalBody("");
|
|
233
|
+
if (persistKey) clearStoredDraft(store, persistKey);
|
|
166
234
|
onSend();
|
|
167
235
|
};
|
|
168
236
|
|
|
@@ -239,8 +307,37 @@ export function SendBox({
|
|
|
239
307
|
))}
|
|
240
308
|
</ul>
|
|
241
309
|
)}
|
|
310
|
+
{/* Co-edit attribution, at the box rather than across the screen.
|
|
311
|
+
Their text arrives INSIDE this textarea, and a disabled textarea
|
|
312
|
+
renders it in muted grey — pixel-identical to a placeholder. So the
|
|
313
|
+
single most important thing multiplayer does (showing you what your
|
|
314
|
+
teammate is writing) read as an EMPTY box with a hint in it. */}
|
|
315
|
+
{lockedByTeammate && (
|
|
316
|
+
<div
|
|
317
|
+
data-testid="coedit-banner"
|
|
318
|
+
className="mb-1.5 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-xs"
|
|
319
|
+
>
|
|
320
|
+
<span className="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
|
|
321
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
|
|
322
|
+
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary" />
|
|
323
|
+
</span>
|
|
324
|
+
<span className="text-foreground">
|
|
325
|
+
<span className="font-medium">{holderName ?? "A teammate"}</span> is
|
|
326
|
+
writing — you are seeing their draft
|
|
327
|
+
</span>
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
data-testid="take-over"
|
|
331
|
+
onClick={onTakeOver}
|
|
332
|
+
className="ml-auto shrink-0 rounded border border-border px-1.5 py-0.5 font-medium text-foreground hover:bg-muted"
|
|
333
|
+
>
|
|
334
|
+
take over
|
|
335
|
+
</button>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
242
338
|
<textarea
|
|
243
339
|
ref={textareaRef}
|
|
340
|
+
data-testid="composer"
|
|
244
341
|
value={body}
|
|
245
342
|
disabled={!canEdit || blocked}
|
|
246
343
|
onChange={(e) => handleChange(e.target.value)}
|
|
@@ -248,7 +345,18 @@ export function SendBox({
|
|
|
248
345
|
onPaste={handlePaste}
|
|
249
346
|
placeholder={placeholder}
|
|
250
347
|
rows={3}
|
|
251
|
-
className=
|
|
348
|
+
className={[
|
|
349
|
+
"w-full resize-none rounded-md border bg-transparent p-2 text-sm shadow-sm",
|
|
350
|
+
"placeholder:text-muted-foreground focus-visible:outline-none",
|
|
351
|
+
"focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed",
|
|
352
|
+
// A teammate's draft is CONTENT, not a disabled control. Dimming it
|
|
353
|
+
// to `text-muted-foreground` made their sentence look like the
|
|
354
|
+
// placeholder it sits next to — the one styling choice that made
|
|
355
|
+
// co-editing appear not to work at all.
|
|
356
|
+
lockedByTeammate
|
|
357
|
+
? "border-primary/40 bg-primary/5 text-foreground"
|
|
358
|
+
: "border-input text-foreground disabled:bg-muted disabled:text-muted-foreground",
|
|
359
|
+
].join(" ")}
|
|
252
360
|
/>
|
|
253
361
|
<div className="mt-1 flex items-center justify-end gap-2">
|
|
254
362
|
{canAttach && (
|
|
@@ -281,6 +389,26 @@ export function SendBox({
|
|
|
281
389
|
{disabledReason}
|
|
282
390
|
</span>
|
|
283
391
|
)}
|
|
392
|
+
{stopState === "failed" ? (
|
|
393
|
+
// The one state that MUST be loud. Everything else here is either
|
|
394
|
+
// self-evident (the reply stopped) or transient. A stop that did not
|
|
395
|
+
// take looks exactly like a stop that worked — the agent keeps
|
|
396
|
+
// running either way — so without this it is invisible, which is the
|
|
397
|
+
// whole reason Stop could not be trusted.
|
|
398
|
+
<span
|
|
399
|
+
role="status"
|
|
400
|
+
className="mr-auto text-xs font-medium text-destructive"
|
|
401
|
+
title="Escape was pressed and the agent is still running. Try again, or stop it in the terminal."
|
|
402
|
+
>
|
|
403
|
+
stop didn’t take — still running
|
|
404
|
+
</span>
|
|
405
|
+
) : null}
|
|
406
|
+
{/* Never DISABLED while a stop is in flight, only relabelled. If the
|
|
407
|
+
runner dies between the request and the verdict, `stopState` never
|
|
408
|
+
advances — and a button that latched off would leave the human with
|
|
409
|
+
no way to ask again, the exact trap #519 documents for the composer
|
|
410
|
+
lock. Pressing again is harmless: the runner dedupes by session_key,
|
|
411
|
+
so three impatient presses are still one Escape. */}
|
|
284
412
|
{isStreaming ? (
|
|
285
413
|
<Button
|
|
286
414
|
type="button"
|
|
@@ -288,15 +416,16 @@ export function SendBox({
|
|
|
288
416
|
size="sm"
|
|
289
417
|
onClick={handleStopClick}
|
|
290
418
|
>
|
|
291
|
-
stop
|
|
292
|
-
</Button>
|
|
293
|
-
) : null}
|
|
294
|
-
{!canEdit && holderIsPresent && !holderIsIdle ? (
|
|
295
|
-
<Button type="button" variant="outline" size="sm" onClick={onTakeOver}>
|
|
296
|
-
take over
|
|
419
|
+
{stopState === "requested" ? "stopping…" : "stop"}
|
|
297
420
|
</Button>
|
|
298
421
|
) : null}
|
|
299
|
-
<Button
|
|
422
|
+
<Button
|
|
423
|
+
type="button"
|
|
424
|
+
size="sm"
|
|
425
|
+
data-testid="send"
|
|
426
|
+
disabled={!canSend}
|
|
427
|
+
onClick={handleSend}
|
|
428
|
+
>
|
|
300
429
|
send
|
|
301
430
|
</Button>
|
|
302
431
|
</div>
|
package/src/chat/drafts.test.ts
CHANGED
|
@@ -2,10 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"
|
|
|
2
2
|
|
|
3
3
|
import type { Draft } from "./protocol"
|
|
4
4
|
import {
|
|
5
|
+
DRAFT_STORAGE_TTL_MS,
|
|
5
6
|
IDLE_THRESHOLD_MS,
|
|
7
|
+
clearStoredDraft,
|
|
8
|
+
draftStorageKey,
|
|
6
9
|
isDraftIdle,
|
|
7
10
|
msUntilDraftIdle,
|
|
11
|
+
readStoredDraft,
|
|
8
12
|
shouldSyncDraftLive,
|
|
13
|
+
writeStoredDraft,
|
|
9
14
|
} from "./drafts"
|
|
10
15
|
|
|
11
16
|
const NOW = 1_700_000_000_000
|
|
@@ -88,3 +93,118 @@ describe("shouldSyncDraftLive", () => {
|
|
|
88
93
|
expect(shouldSyncDraftLive([1, 2])).toBe(true)
|
|
89
94
|
})
|
|
90
95
|
})
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Composer persistence
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function fakeStorage(seed: Record<string, string> = {}) {
|
|
102
|
+
const map = new Map(Object.entries(seed))
|
|
103
|
+
return {
|
|
104
|
+
map,
|
|
105
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
106
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
107
|
+
removeItem: (k: string) => void map.delete(k),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const KEY = "sess-1"
|
|
112
|
+
|
|
113
|
+
describe("readStoredDraft", () => {
|
|
114
|
+
it("returns null when there is nothing stored", () => {
|
|
115
|
+
expect(readStoredDraft(fakeStorage(), KEY)).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("round-trips a written body", () => {
|
|
119
|
+
const s = fakeStorage()
|
|
120
|
+
writeStoredDraft(s, KEY, "half a thought", NOW)
|
|
121
|
+
expect(readStoredDraft(s, KEY, NOW + 1000)).toBe("half a thought")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("drops and prunes an entry past the TTL", () => {
|
|
125
|
+
const s = fakeStorage()
|
|
126
|
+
writeStoredDraft(s, KEY, "stale", NOW)
|
|
127
|
+
expect(readStoredDraft(s, KEY, NOW + DRAFT_STORAGE_TTL_MS + 1)).toBeNull()
|
|
128
|
+
expect(s.map.has(draftStorageKey(KEY))).toBe(false)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("keeps an entry right up to the TTL boundary", () => {
|
|
132
|
+
const s = fakeStorage()
|
|
133
|
+
writeStoredDraft(s, KEY, "fresh enough", NOW)
|
|
134
|
+
expect(readStoredDraft(s, KEY, NOW + DRAFT_STORAGE_TTL_MS)).toBe("fresh enough")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("drops and prunes malformed JSON", () => {
|
|
138
|
+
const s = fakeStorage({ [draftStorageKey(KEY)]: "{not json" })
|
|
139
|
+
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
140
|
+
expect(s.map.has(draftStorageKey(KEY))).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("drops an entry of the wrong shape", () => {
|
|
144
|
+
const s = fakeStorage({ [draftStorageKey(KEY)]: JSON.stringify({ body: 42 }) })
|
|
145
|
+
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("reports an empty stored body as nothing to restore", () => {
|
|
149
|
+
// "" must not shadow a server draft the host does want rendered.
|
|
150
|
+
const s = fakeStorage({
|
|
151
|
+
[draftStorageKey(KEY)]: JSON.stringify({ body: "", at: NOW }),
|
|
152
|
+
})
|
|
153
|
+
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("is inert without a storage or without a key", () => {
|
|
157
|
+
expect(readStoredDraft(null, KEY)).toBeNull()
|
|
158
|
+
expect(readStoredDraft(fakeStorage(), "")).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("survives a storage that throws on read", () => {
|
|
162
|
+
// Safari private mode / blocked third-party storage.
|
|
163
|
+
const s = {
|
|
164
|
+
getItem: () => {
|
|
165
|
+
throw new Error("SecurityError")
|
|
166
|
+
},
|
|
167
|
+
setItem: () => {},
|
|
168
|
+
removeItem: () => {},
|
|
169
|
+
}
|
|
170
|
+
expect(() => readStoredDraft(s, KEY)).not.toThrow()
|
|
171
|
+
expect(readStoredDraft(s, KEY)).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe("writeStoredDraft", () => {
|
|
176
|
+
it("clears the entry instead of storing an empty body", () => {
|
|
177
|
+
const s = fakeStorage()
|
|
178
|
+
writeStoredDraft(s, KEY, "typed", NOW)
|
|
179
|
+
writeStoredDraft(s, KEY, "", NOW)
|
|
180
|
+
expect(s.map.has(draftStorageKey(KEY))).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("keeps one entry per session", () => {
|
|
184
|
+
const s = fakeStorage()
|
|
185
|
+
writeStoredDraft(s, "a", "for a", NOW)
|
|
186
|
+
writeStoredDraft(s, "b", "for b", NOW)
|
|
187
|
+
expect(readStoredDraft(s, "a", NOW)).toBe("for a")
|
|
188
|
+
expect(readStoredDraft(s, "b", NOW)).toBe("for b")
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("swallows a quota error rather than breaking a keystroke", () => {
|
|
192
|
+
const s = {
|
|
193
|
+
getItem: () => null,
|
|
194
|
+
setItem: () => {
|
|
195
|
+
throw new Error("QuotaExceededError")
|
|
196
|
+
},
|
|
197
|
+
removeItem: () => {},
|
|
198
|
+
}
|
|
199
|
+
expect(() => writeStoredDraft(s, KEY, "x")).not.toThrow()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe("clearStoredDraft", () => {
|
|
204
|
+
it("removes a stored draft", () => {
|
|
205
|
+
const s = fakeStorage()
|
|
206
|
+
writeStoredDraft(s, KEY, "sent now", NOW)
|
|
207
|
+
clearStoredDraft(s, KEY)
|
|
208
|
+
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
})
|
package/src/chat/drafts.ts
CHANGED
|
@@ -35,3 +35,137 @@ export function msUntilDraftIdle(draft: Draft | null | undefined): number {
|
|
|
35
35
|
const elapsed = Date.now() - new Date(draft.last_edit_at).getTime();
|
|
36
36
|
return Math.max(0, IDLE_THRESHOLD_MS - elapsed);
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Composer persistence — surviving a navigation the server never hears about.
|
|
41
|
+
//
|
|
42
|
+
// The composer is local-first (see SendBox) and, alone in a session,
|
|
43
|
+
// `shouldSyncDraftLive` keeps it that way: nothing is mirrored to the server
|
|
44
|
+
// until the moment before `chat.send`. Those two facts are right on their own
|
|
45
|
+
// and, together, mean a half-typed message dies the instant SendBox unmounts —
|
|
46
|
+
// route away from /chat/:id and back and the box is empty, with no copy of the
|
|
47
|
+
// text anywhere. (Adopting your OWN server draft on mount wouldn't fix it:
|
|
48
|
+
// single-player never put one there.)
|
|
49
|
+
//
|
|
50
|
+
// So the copy has to be local too. localStorage rather than sessionStorage
|
|
51
|
+
// because "come back to it" includes closing the tab; per-browser, deliberately
|
|
52
|
+
// not synced across devices.
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Just the slice of `Storage` this needs — so tests can pass a plain fake and
|
|
56
|
+
* a host can pass sessionStorage if it prefers per-tab drafts. */
|
|
57
|
+
export interface DraftStorage {
|
|
58
|
+
getItem(key: string): string | null;
|
|
59
|
+
setItem(key: string, value: string): void;
|
|
60
|
+
removeItem(key: string): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DRAFT_STORAGE_PREFIX = "canopy.chat.draft.";
|
|
64
|
+
|
|
65
|
+
/** Drafts older than this are treated as gone. A month-old half-sentence is
|
|
66
|
+
* not something you meant to come back to, and restoring it into a live
|
|
67
|
+
* session is worse than losing it. */
|
|
68
|
+
export const DRAFT_STORAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
69
|
+
|
|
70
|
+
export function draftStorageKey(persistKey: string): string {
|
|
71
|
+
return `${DRAFT_STORAGE_PREFIX}${persistKey}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `window.localStorage` when it is usable, else null.
|
|
76
|
+
*
|
|
77
|
+
* Merely TOUCHING the property throws in a sandboxed iframe or with
|
|
78
|
+
* third-party storage blocked, so this is a try/catch and not a truthiness
|
|
79
|
+
* check. Null disables persistence and changes nothing else — typing must
|
|
80
|
+
* never depend on storage being available.
|
|
81
|
+
*/
|
|
82
|
+
export function defaultDraftStorage(): DraftStorage | null {
|
|
83
|
+
try {
|
|
84
|
+
return typeof window === "undefined" ? null : window.localStorage;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The stored body for a session, or null when there is nothing worth
|
|
92
|
+
* restoring. Anything unreadable — absent, malformed, wrong shape, expired —
|
|
93
|
+
* is dropped and reported as null, and an expired or corrupt entry is pruned on
|
|
94
|
+
* the way past so it cannot accumulate.
|
|
95
|
+
*/
|
|
96
|
+
export function readStoredDraft(
|
|
97
|
+
storage: DraftStorage | null,
|
|
98
|
+
persistKey: string,
|
|
99
|
+
now: number = Date.now(),
|
|
100
|
+
): string | null {
|
|
101
|
+
if (!storage || !persistKey) return null;
|
|
102
|
+
const key = draftStorageKey(persistKey);
|
|
103
|
+
let raw: string | null = null;
|
|
104
|
+
try {
|
|
105
|
+
raw = storage.getItem(key);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (raw == null) return null;
|
|
110
|
+
|
|
111
|
+
let parsed: unknown;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(raw);
|
|
114
|
+
} catch {
|
|
115
|
+
remove(storage, key);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const record = parsed as { body?: unknown; at?: unknown } | null;
|
|
120
|
+
const body = record?.body;
|
|
121
|
+
const at = record?.at;
|
|
122
|
+
if (typeof body !== "string" || typeof at !== "number") {
|
|
123
|
+
remove(storage, key);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (now - at > DRAFT_STORAGE_TTL_MS) {
|
|
127
|
+
remove(storage, key);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
// An empty body is not a draft. Returning "" would be indistinguishable from
|
|
131
|
+
// a real restore and would shadow a server draft the host DOES want shown.
|
|
132
|
+
return body === "" ? null : body;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Persist the composer body. Writing an empty body clears the entry rather
|
|
136
|
+
* than storing one, so an emptied box leaves nothing behind to restore. */
|
|
137
|
+
export function writeStoredDraft(
|
|
138
|
+
storage: DraftStorage | null,
|
|
139
|
+
persistKey: string,
|
|
140
|
+
body: string,
|
|
141
|
+
now: number = Date.now(),
|
|
142
|
+
): void {
|
|
143
|
+
if (!storage || !persistKey) return;
|
|
144
|
+
const key = draftStorageKey(persistKey);
|
|
145
|
+
if (body === "") {
|
|
146
|
+
remove(storage, key);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
storage.setItem(key, JSON.stringify({ body, at: now }));
|
|
151
|
+
} catch {
|
|
152
|
+
// Quota exceeded, or storage disabled mid-session. A dropped draft backup
|
|
153
|
+
// is not worth breaking a keystroke over.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function clearStoredDraft(
|
|
158
|
+
storage: DraftStorage | null,
|
|
159
|
+
persistKey: string,
|
|
160
|
+
): void {
|
|
161
|
+
if (!storage || !persistKey) return;
|
|
162
|
+
remove(storage, draftStorageKey(persistKey));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function remove(storage: DraftStorage, key: string): void {
|
|
166
|
+
try {
|
|
167
|
+
storage.removeItem(key);
|
|
168
|
+
} catch {
|
|
169
|
+
// same as above — best effort
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/chat/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type {
|
|
|
11
11
|
MessageStatus,
|
|
12
12
|
Draft,
|
|
13
13
|
Participant,
|
|
14
|
+
SessionMenu,
|
|
14
15
|
SessionState,
|
|
15
16
|
WsAction,
|
|
16
17
|
WsEvent,
|
|
@@ -31,6 +32,17 @@ export { useStickyBottom } from "./useStickyBottom";
|
|
|
31
32
|
// Draft idle helpers
|
|
32
33
|
export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
|
|
33
34
|
|
|
35
|
+
// Composer persistence (survives unmount / tab close)
|
|
36
|
+
export {
|
|
37
|
+
DRAFT_STORAGE_TTL_MS,
|
|
38
|
+
clearStoredDraft,
|
|
39
|
+
defaultDraftStorage,
|
|
40
|
+
draftStorageKey,
|
|
41
|
+
readStoredDraft,
|
|
42
|
+
writeStoredDraft,
|
|
43
|
+
type DraftStorage,
|
|
44
|
+
} from "./drafts";
|
|
45
|
+
|
|
34
46
|
// Tool-message pairing helpers
|
|
35
47
|
export {
|
|
36
48
|
pairToolMessages,
|
|
@@ -43,6 +55,7 @@ export {
|
|
|
43
55
|
|
|
44
56
|
// Presentational components
|
|
45
57
|
export { ChatPanel, type ChatPanelProps } from "./ChatPanel";
|
|
58
|
+
export { MenuPrompt, type MenuPromptProps } from "./MenuPrompt";
|
|
46
59
|
export { MessageList } from "./MessageList";
|
|
47
60
|
export { MessageItem, type RenderMarkdown } from "./MessageItem";
|
|
48
61
|
export { ToolCallPair } from "./ToolCallPair";
|
|
@@ -12,7 +12,7 @@ import type { Message } from "./protocol";
|
|
|
12
12
|
* has no other way to tell which result belongs to which call.
|
|
13
13
|
*
|
|
14
14
|
* Not every producer stamps ids yet: events already in the ledger predate
|
|
15
|
-
* this field, and some runners (see `
|
|
15
|
+
* this field, and some runners (see `runner/canopy_runner`) don't emit it
|
|
16
16
|
* until updated separately. When a ``tool_result`` carries no id at all, it
|
|
17
17
|
* falls back to the FIRST still-open ``tool_use`` that *also* has no id
|
|
18
18
|
* (oldest-first, FIFO) — today's pre-correlation pairing heuristic. That
|