canopy-ui 0.2.0 → 0.4.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.
@@ -0,0 +1,92 @@
1
+ import { useState } from "react";
2
+
3
+ /** A candidate runner the user may re-place a queued turn onto. */
4
+ export interface PlacementRunner {
5
+ id: string;
6
+ name: string;
7
+ online: boolean;
8
+ }
9
+
10
+ export interface PlacementBannerProps {
11
+ /** The bound-but-offline runner's display name. */
12
+ runnerName: string;
13
+ /** Alternatives the user may re-place onto (the "Continue on…" picker). */
14
+ eligibleRunners: PlacementRunner[];
15
+ /** True while a placement POST is in flight — disables both actions. */
16
+ busy?: boolean;
17
+ /** A failure message to surface below the actions (e.g. "no pending
18
+ * message to place"). Rendered with destructive styling. */
19
+ error?: string | null;
20
+ /** A non-failure status message (e.g. "Placed — the new runner will pick it
21
+ * up shortly."). Rendered muted, visually distinct from `error`. */
22
+ info?: string | null;
23
+ /** Keep the turn queued for the bound runner to come back online. */
24
+ onWait: () => void;
25
+ /** Re-place the turn onto the given runner id. */
26
+ onPlace: (runnerId: string) => void;
27
+ }
28
+
29
+ /**
30
+ * Offline-runner placement banner: the chat session's bound runner has gone
31
+ * unavailable, and the user must decide whether to wait for it or continue
32
+ * on a different session-capable runner. Presentational only — NO fetch, NO
33
+ * WS. The container (canopy's ChatPage) owns the fleet poll, the derived
34
+ * eligible-runner list, and the placement POST; this component just renders
35
+ * the decision and forwards the pick.
36
+ */
37
+ export function PlacementBanner({
38
+ runnerName,
39
+ eligibleRunners,
40
+ busy = false,
41
+ error,
42
+ info,
43
+ onWait,
44
+ onPlace,
45
+ }: PlacementBannerProps) {
46
+ // Whether the "Continue on…" picker is expanded — purely local UI state,
47
+ // not fetch-driven, so it lives in the kit rather than round-tripping
48
+ // through the container.
49
+ const [showPicker, setShowPicker] = useState(false);
50
+
51
+ return (
52
+ <div className="flex flex-wrap items-center gap-2 border-b border-warning/30 bg-warning/10 px-4 py-2 text-[12px] text-warning">
53
+ <span className="font-medium">{runnerName} is unavailable</span>
54
+ <button
55
+ type="button"
56
+ onClick={onWait}
57
+ disabled={busy}
58
+ className="rounded-md border border-warning/40 px-2 py-0.5 text-warning hover:bg-warning/20 disabled:opacity-50"
59
+ >
60
+ Wait for it
61
+ </button>
62
+ <button
63
+ type="button"
64
+ onClick={() => setShowPicker((v) => !v)}
65
+ disabled={busy}
66
+ className="rounded-md border border-warning/40 px-2 py-0.5 text-warning hover:bg-warning/20 disabled:opacity-50"
67
+ >
68
+ Continue on…
69
+ </button>
70
+ {showPicker && (
71
+ <select
72
+ defaultValue=""
73
+ disabled={busy}
74
+ onChange={(e) => onPlace(e.target.value)}
75
+ className="rounded-md border border-warning/40 bg-card px-1.5 py-0.5 text-[12px] text-foreground disabled:opacity-50"
76
+ aria-label="Continue on"
77
+ >
78
+ <option value="" disabled>
79
+ Choose a runner…
80
+ </option>
81
+ {eligibleRunners.map((r) => (
82
+ <option key={r.id} value={r.id}>
83
+ {r.online ? "●" : "○"} {r.name}
84
+ </option>
85
+ ))}
86
+ </select>
87
+ )}
88
+ {error && <span className="text-destructive">{error}</span>}
89
+ {info && <span className="text-muted-foreground">{info}</span>}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,52 @@
1
+ import type { Participant } from "./protocol";
2
+
3
+ interface Props {
4
+ participants: Participant[];
5
+ presenceUserIds: number[];
6
+ draftHolderId: number | null;
7
+ draftHolderIdle: boolean;
8
+ }
9
+
10
+ export function PresenceChips({
11
+ participants,
12
+ presenceUserIds,
13
+ draftHolderId,
14
+ draftHolderIdle,
15
+ }: Props) {
16
+ const present = participants.filter((p) =>
17
+ presenceUserIds.includes(p.user_id),
18
+ );
19
+ if (present.length === 0) {
20
+ return <div className="text-sm text-muted-foreground">nobody else here</div>;
21
+ }
22
+ return (
23
+ <div className="flex gap-2">
24
+ {present.map((p) => {
25
+ const isHolder = p.user_id === draftHolderId && !draftHolderIdle;
26
+ return (
27
+ <div
28
+ key={p.user_id}
29
+ title={p.display_name + (isHolder ? " — editing…" : "")}
30
+ className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
31
+ isHolder
32
+ ? "bg-primary text-primary-foreground ring-2 ring-primary/30"
33
+ : "bg-muted text-muted-foreground"
34
+ }`}
35
+ >
36
+ {initials(p.display_name)}
37
+ </div>
38
+ );
39
+ })}
40
+ </div>
41
+ );
42
+ }
43
+
44
+ function initials(name: string): string {
45
+ return name
46
+ .split(" ")
47
+ .map((w) => w[0])
48
+ .filter(Boolean)
49
+ .join("")
50
+ .slice(0, 2)
51
+ .toUpperCase();
52
+ }
@@ -0,0 +1,144 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type KeyboardEvent,
6
+ type ReactNode,
7
+ } from "react";
8
+
9
+ import type { Draft } from "./protocol";
10
+ import { isDraftIdle, msUntilDraftIdle } from "./drafts";
11
+ import { Button } from "../ui/button";
12
+
13
+ interface Props {
14
+ draft: Draft | null;
15
+ currentUserId: number;
16
+ holderIsPresent: boolean;
17
+ isStreaming: boolean;
18
+ streamingMessageId: string | null;
19
+ onUpdate: (body: string) => void;
20
+ onSend: () => void;
21
+ onStop: (messageId: string) => void;
22
+ onTakeOver: () => void;
23
+ /** Optional app-supplied banner rendered above the composer (e.g. an
24
+ * imported-session note). The kit itself has no CLI-auth banners. */
25
+ banner?: ReactNode;
26
+ /** When set, sending is disabled and this reason is shown as a hint. */
27
+ disabledReason?: string;
28
+ }
29
+
30
+ export function SendBox({
31
+ draft,
32
+ currentUserId,
33
+ holderIsPresent,
34
+ isStreaming,
35
+ streamingMessageId,
36
+ onUpdate,
37
+ onSend,
38
+ onStop,
39
+ onTakeOver,
40
+ banner,
41
+ disabledReason,
42
+ }: Props) {
43
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
44
+ // Force a re-render when the lock transitions from live to idle.
45
+ // Without this, nothing would trigger a re-render exactly at T+2s
46
+ // after the last edit, and another user's UI would stay locked
47
+ // indefinitely until some unrelated event happens to arrive.
48
+ const [, forceTick] = useState(0);
49
+
50
+ useEffect(() => {
51
+ if (!draft) return;
52
+ const remaining = msUntilDraftIdle(draft);
53
+ if (remaining === 0) return;
54
+ const t = window.setTimeout(() => forceTick((n) => n + 1), remaining + 10);
55
+ return () => window.clearTimeout(t);
56
+ }, [draft?.last_edit_at, draft]);
57
+
58
+ const holderId = draft?.last_editor ?? null;
59
+ const isHolder = holderId != null && holderId === currentUserId;
60
+ const holderIsIdle = isDraftIdle(draft);
61
+
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);
67
+
68
+ useEffect(() => {
69
+ if (canEdit && !isHolder && textareaRef.current) {
70
+ textareaRef.current.focus();
71
+ }
72
+ }, [canEdit, isHolder]);
73
+
74
+ const body = draft?.body ?? "";
75
+ const blocked = Boolean(disabledReason);
76
+ const canSend =
77
+ canEdit && body.trim().length > 0 && !isStreaming && !blocked;
78
+
79
+ const handleKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
80
+ // `isComposing` is true during IME input (CJK, etc.). Pressing
81
+ // Enter to commit a composition must not send the message.
82
+ const isComposing = (e.nativeEvent as unknown as { isComposing?: boolean })
83
+ .isComposing;
84
+ if (e.key === "Enter" && !e.shiftKey && !isComposing) {
85
+ e.preventDefault();
86
+ if (canSend) onSend();
87
+ }
88
+ };
89
+
90
+ const handleStopClick = () => {
91
+ if (streamingMessageId != null) onStop(streamingMessageId);
92
+ };
93
+
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…";
101
+
102
+ return (
103
+ <div className="border-t border-border bg-background">
104
+ {banner}
105
+ <div className="p-2">
106
+ <textarea
107
+ ref={textareaRef}
108
+ value={body}
109
+ disabled={!canEdit || blocked}
110
+ onChange={(e) => onUpdate(e.target.value)}
111
+ onKeyDown={handleKey}
112
+ placeholder={placeholder}
113
+ rows={3}
114
+ 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
+ />
116
+ <div className="mt-1 flex items-center justify-end gap-2">
117
+ {blocked && (
118
+ <span className="mr-auto text-xs text-muted-foreground">
119
+ {disabledReason}
120
+ </span>
121
+ )}
122
+ {isStreaming ? (
123
+ <Button
124
+ type="button"
125
+ variant="destructive"
126
+ size="sm"
127
+ onClick={handleStopClick}
128
+ >
129
+ stop
130
+ </Button>
131
+ ) : null}
132
+ {!canEdit && holderIsPresent && !holderIsIdle ? (
133
+ <Button type="button" variant="outline" size="sm" onClick={onTakeOver}>
134
+ take over
135
+ </Button>
136
+ ) : null}
137
+ <Button type="button" size="sm" disabled={!canSend} onClick={onSend}>
138
+ send
139
+ </Button>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ );
144
+ }
@@ -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,66 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest"
2
+
3
+ import type { Draft } from "./protocol"
4
+ import { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts"
5
+
6
+ const NOW = 1_700_000_000_000
7
+
8
+ function draftEditedAt(msAgo: number): Draft {
9
+ return {
10
+ id: "d1",
11
+ slot: "next",
12
+ status: "open",
13
+ body: "",
14
+ version: 0,
15
+ last_editor: 1,
16
+ last_edit_at: new Date(NOW - msAgo).toISOString(),
17
+ }
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers()
22
+ })
23
+
24
+ describe("isDraftIdle", () => {
25
+ it("treats a null/undefined draft as idle", () => {
26
+ expect(isDraftIdle(null)).toBe(true)
27
+ expect(isDraftIdle(undefined)).toBe(true)
28
+ })
29
+
30
+ it("treats a draft with no last_edit_at as idle", () => {
31
+ const d = { ...draftEditedAt(0), last_edit_at: "" }
32
+ expect(isDraftIdle(d)).toBe(true)
33
+ })
34
+
35
+ it("is NOT idle immediately after an edit", () => {
36
+ vi.useFakeTimers()
37
+ vi.setSystemTime(NOW)
38
+ // edited 500ms ago — well within the 2s threshold
39
+ expect(isDraftIdle(draftEditedAt(500))).toBe(false)
40
+ })
41
+
42
+ it("IS idle once more than the threshold has elapsed", () => {
43
+ vi.useFakeTimers()
44
+ vi.setSystemTime(NOW)
45
+ expect(isDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 1))).toBe(true)
46
+ })
47
+ })
48
+
49
+ describe("msUntilDraftIdle", () => {
50
+ it("returns 0 for a null draft", () => {
51
+ expect(msUntilDraftIdle(null)).toBe(0)
52
+ })
53
+
54
+ it("returns the remaining time before the idle transition", () => {
55
+ vi.useFakeTimers()
56
+ vi.setSystemTime(NOW)
57
+ // edited 500ms ago → 1500ms remain
58
+ expect(msUntilDraftIdle(draftEditedAt(500))).toBe(1500)
59
+ })
60
+
61
+ it("clamps to 0 once past the threshold", () => {
62
+ vi.useFakeTimers()
63
+ vi.setSystemTime(NOW)
64
+ expect(msUntilDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 5000))).toBe(0)
65
+ })
66
+ })
@@ -0,0 +1,19 @@
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
+ * Milliseconds until the draft lock becomes idle. Returns 0 if already
12
+ * idle. Use this to schedule a timer that forces a re-render at the
13
+ * idle transition point.
14
+ */
15
+ export function msUntilDraftIdle(draft: Draft | null | undefined): number {
16
+ if (!draft?.last_edit_at) return 0;
17
+ const elapsed = Date.now() - new Date(draft.last_edit_at).getTime();
18
+ return Math.max(0, IDLE_THRESHOLD_MS - elapsed);
19
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { Message } from "./protocol"
3
+ import { prependHistory } from "./history"
4
+
5
+ function msg(turn_index: number, plaintext = `m${turn_index}`): Message {
6
+ return {
7
+ id: `t${turn_index}`, turn_index, role: "user", content: {}, plaintext,
8
+ status: "complete", error_detail: null, started_at: null, completed_at: null,
9
+ created_at: "",
10
+ }
11
+ }
12
+
13
+ describe("prependHistory", () => {
14
+ it("prepends older messages ahead of current, chronological", () => {
15
+ const current = [msg(30), msg(31)]
16
+ const older = [msg(28), msg(29)]
17
+ expect(prependHistory(current, older).map((m) => m.turn_index)).toEqual([28, 29, 30, 31])
18
+ })
19
+
20
+ it("dedupes on turn_index (an overlapping page is not double-inserted)", () => {
21
+ const current = [msg(30), msg(31)]
22
+ const older = [msg(29), msg(30)] // 30 overlaps
23
+ expect(prependHistory(current, older).map((m) => m.turn_index)).toEqual([29, 30, 31])
24
+ })
25
+
26
+ it("returns the same reference when older is empty", () => {
27
+ const current = [msg(30)]
28
+ expect(prependHistory(current, [])).toBe(current)
29
+ })
30
+
31
+ it("returns the same reference when every older row already exists", () => {
32
+ const current = [msg(30), msg(31)]
33
+ expect(prependHistory(current, [msg(30)])).toBe(current)
34
+ })
35
+ })
@@ -0,0 +1,16 @@
1
+ import type { Message } from "./protocol";
2
+
3
+ /**
4
+ * Merge an older page (from the REST scroll-back endpoint) into the current
5
+ * transcript: prepend, dedupe by turn_index, keep chronological. Pure — no React,
6
+ * no WebSocket — so the container can apply "Load earlier" to the socket's
7
+ * SessionState without a new WS frame. Returns `current` unchanged (same
8
+ * reference) when nothing new prepends, so callers can skip a re-render.
9
+ */
10
+ export function prependHistory(current: Message[], older: Message[]): Message[] {
11
+ if (older.length === 0) return current;
12
+ const seen = new Set(current.map((m) => m.turn_index));
13
+ const fresh = older.filter((m) => !seen.has(m.turn_index));
14
+ if (fresh.length === 0) return current;
15
+ return [...fresh, ...current].sort((a, b) => a.turn_index - b.turn_index);
16
+ }
@@ -0,0 +1,56 @@
1
+ // canopy-ui/chat — a reusable, app-agnostic multiplayer chat kit.
2
+ //
3
+ // Speaks the canonical chat WebSocket protocol (ace-web's contract; string
4
+ // ids). App specifics — the ws URL builder, the markdown renderer, session
5
+ // meta — are injected. The presentational tree is props-in / callbacks-out.
6
+
7
+ // Protocol types
8
+ export type {
9
+ Message,
10
+ MessageRole,
11
+ MessageStatus,
12
+ Draft,
13
+ Participant,
14
+ SessionState,
15
+ WsAction,
16
+ WsEvent,
17
+ } from "./protocol";
18
+
19
+ // Reducer (pure)
20
+ export { sessionReducer } from "./sessionReducer";
21
+ export { prependHistory } from "./history";
22
+
23
+ // Hooks
24
+ export {
25
+ useSessionSocket,
26
+ type UseSessionSocketOptions,
27
+ type UseSessionSocketResult,
28
+ } from "./useSessionSocket";
29
+ export { useStickyBottom } from "./useStickyBottom";
30
+
31
+ // Draft idle helpers
32
+ export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
33
+
34
+ // Tool-message pairing helpers
35
+ export {
36
+ pairToolMessages,
37
+ deriveToolStatus,
38
+ toolPreview,
39
+ toolDisplayName,
40
+ type ChatRow,
41
+ type ToolCallStatus,
42
+ } from "./pairToolMessages";
43
+
44
+ // Presentational components
45
+ export { ChatPanel, type ChatPanelProps } from "./ChatPanel";
46
+ export { MessageList } from "./MessageList";
47
+ export { MessageItem, type RenderMarkdown } from "./MessageItem";
48
+ export { ToolCallPair } from "./ToolCallPair";
49
+ export { SendBox } from "./SendBox";
50
+ export { PresenceChips } from "./PresenceChips";
51
+ export { ConnectionStatus } from "./ConnectionStatus";
52
+ export {
53
+ PlacementBanner,
54
+ type PlacementBannerProps,
55
+ type PlacementRunner,
56
+ } from "./PlacementBanner";