@rsktash/beads-ui 0.1.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.
Files changed (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -0,0 +1,137 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { useSubscription } from "../hooks/use-subscription";
3
+ import { useSearch } from "../hooks/use-search";
4
+ import { StatusBadge } from "./StatusBadge";
5
+ import { PriorityBadge } from "./PriorityBadge";
6
+
7
+ export function SearchDialog() {
8
+ const [open, setOpen] = useState(false);
9
+ const { issues } = useSubscription("all-issues");
10
+ const { query, setQuery, results } = useSearch(issues);
11
+ const [selectedIndex, setSelectedIndex] = useState(0);
12
+ const inputRef = useRef<HTMLInputElement>(null);
13
+
14
+ // Cmd+K to open, Escape to close
15
+ useEffect(() => {
16
+ const handler = (e: KeyboardEvent) => {
17
+ const tag = (e.target as HTMLElement)?.tagName;
18
+ if (e.key === "k" && (e.metaKey || e.ctrlKey) && tag !== "TEXTAREA") {
19
+ e.preventDefault();
20
+ setOpen(true);
21
+ }
22
+ if (e.key === "Escape" && open) {
23
+ e.preventDefault();
24
+ setOpen(false);
25
+ setQuery("");
26
+ setSelectedIndex(0);
27
+ }
28
+ };
29
+ window.addEventListener("keydown", handler);
30
+ return () => window.removeEventListener("keydown", handler);
31
+ }, [open, setQuery]);
32
+
33
+ // Focus input when opened
34
+ useEffect(() => {
35
+ if (open) setTimeout(() => inputRef.current?.focus(), 0);
36
+ }, [open]);
37
+
38
+ // Reset selection when results change
39
+ useEffect(() => setSelectedIndex(0), [results]);
40
+
41
+ const handleKeyDown = (e: React.KeyboardEvent) => {
42
+ const shown = results.slice(0, 20);
43
+ if (e.key === "ArrowDown") {
44
+ e.preventDefault();
45
+ setSelectedIndex((i) => Math.min(i + 1, shown.length - 1));
46
+ }
47
+ if (e.key === "ArrowUp") {
48
+ e.preventDefault();
49
+ setSelectedIndex((i) => Math.max(i - 1, 0));
50
+ }
51
+ if (e.key === "Enter" && shown[selectedIndex]) {
52
+ window.location.hash = `#/detail/${shown[selectedIndex].id}`;
53
+ setOpen(false);
54
+ setQuery("");
55
+ }
56
+ };
57
+
58
+ if (!open) return null;
59
+
60
+ const shown = results.slice(0, 20);
61
+
62
+ return (
63
+ <div
64
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
65
+ onClick={() => {
66
+ setOpen(false);
67
+ setQuery("");
68
+ }}
69
+ >
70
+ <div className="absolute inset-0" style={{ background: "var(--bg-overlay)" }} />
71
+ <div
72
+ className="relative w-full max-w-lg overflow-hidden"
73
+ style={{
74
+ background: "var(--bg-elevated)",
75
+ borderRadius: "var(--radius-lg)",
76
+ border: "1px solid var(--border-subtle)",
77
+ boxShadow: "0 25px 50px -12px rgba(0,0,0,0.15)",
78
+ }}
79
+ onClick={(e) => e.stopPropagation()}
80
+ >
81
+ <input
82
+ ref={inputRef}
83
+ type="text"
84
+ value={query}
85
+ onChange={(e) => setQuery(e.target.value)}
86
+ onKeyDown={handleKeyDown}
87
+ placeholder="Search issues..."
88
+ className="w-full px-4 py-3 text-sm outline-none"
89
+ style={{
90
+ borderBottom: "1px solid var(--border-subtle)",
91
+ background: "transparent",
92
+ color: "var(--text-primary)",
93
+ }}
94
+ />
95
+ <div className="max-h-80 overflow-y-auto">
96
+ {shown.length === 0 && query && (
97
+ <div className="px-4 py-6 text-sm text-center" style={{ color: "var(--text-tertiary)" }}>
98
+ No results
99
+ </div>
100
+ )}
101
+ {shown.map((issue, i) => (
102
+ <button
103
+ key={issue.id}
104
+ onClick={() => {
105
+ window.location.hash = `#/detail/${issue.id}`;
106
+ setOpen(false);
107
+ setQuery("");
108
+ }}
109
+ className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors"
110
+ style={{
111
+ background: i === selectedIndex ? "var(--bg-hover)" : "transparent",
112
+ }}
113
+ onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
114
+ onMouseLeave={(e) => { e.currentTarget.style.background = i === selectedIndex ? "var(--bg-hover)" : "transparent"; }}
115
+ >
116
+ <span className="font-mono text-xs w-28 shrink-0" style={{ color: "var(--text-tertiary)" }}>
117
+ {issue.id}
118
+ </span>
119
+ <StatusBadge status={issue.status} />
120
+ <span className="flex-1 truncate" style={{ color: "var(--text-primary)" }}>{issue.title}</span>
121
+ <PriorityBadge priority={issue.priority} />
122
+ </button>
123
+ ))}
124
+ </div>
125
+ <div
126
+ className="px-4 py-2 text-xs"
127
+ style={{
128
+ color: "var(--text-tertiary)",
129
+ borderTop: "1px solid var(--border-subtle)",
130
+ }}
131
+ >
132
+ &uarr;&darr; navigate &middot; &crarr; open &middot; esc close
133
+ </div>
134
+ </div>
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,212 @@
1
+ import { useState, useCallback, useEffect } from "react";
2
+ import { Markdown } from "./Markdown";
3
+
4
+ interface SectionEditorProps {
5
+ label: string;
6
+ value: string;
7
+ placeholder?: string;
8
+ onSave: (value: string) => Promise<unknown>;
9
+ }
10
+
11
+ export function SectionEditor({
12
+ label,
13
+ value,
14
+ placeholder,
15
+ onSave,
16
+ }: SectionEditorProps) {
17
+ const [editing, setEditing] = useState(false);
18
+ const [draft, setDraft] = useState(value);
19
+ const [tab, setTab] = useState<"edit" | "preview">("edit");
20
+ const [saving, setSaving] = useState(false);
21
+
22
+ // Sync draft with external value when not editing
23
+ useEffect(() => {
24
+ if (!editing) setDraft(value);
25
+ }, [value, editing]);
26
+
27
+ const handleSave = useCallback(async () => {
28
+ setSaving(true);
29
+ try {
30
+ await onSave(draft);
31
+ setEditing(false);
32
+ setTab("edit");
33
+ } finally {
34
+ setSaving(false);
35
+ }
36
+ }, [draft, onSave]);
37
+
38
+ const handleCancel = useCallback(() => {
39
+ setDraft(value);
40
+ setEditing(false);
41
+ setTab("edit");
42
+ }, [value]);
43
+
44
+ const handleEditorKeyDown = useCallback(
45
+ (e: React.KeyboardEvent) => {
46
+ if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
47
+ e.preventDefault();
48
+ handleSave();
49
+ }
50
+ if (e.key === "Escape") {
51
+ e.preventDefault();
52
+ handleCancel();
53
+ }
54
+ },
55
+ [handleSave, handleCancel],
56
+ );
57
+
58
+ if (!editing) {
59
+ return (
60
+ <div
61
+ className="group rounded-lg p-4"
62
+ style={{
63
+ background: "var(--bg-elevated)",
64
+ border: "1px solid var(--border-subtle)",
65
+ boxShadow: "var(--shadow-card)",
66
+ }}
67
+ >
68
+ <div className="flex items-center justify-between mb-2">
69
+ <h3
70
+ className="font-semibold uppercase tracking-wider"
71
+ style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
72
+ >
73
+ {label}
74
+ </h3>
75
+ <button
76
+ onClick={() => setEditing(true)}
77
+ className="opacity-0 group-hover:opacity-100 transition-opacity"
78
+ style={{ color: "var(--text-tertiary)" }}
79
+ onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; }}
80
+ onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-tertiary)"; }}
81
+ title={`Edit ${label}`}
82
+ >
83
+ <svg
84
+ className="w-4 h-4"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ viewBox="0 0 24 24"
88
+ >
89
+ <path
90
+ strokeLinecap="round"
91
+ strokeLinejoin="round"
92
+ strokeWidth={2}
93
+ d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
94
+ />
95
+ </svg>
96
+ </button>
97
+ </div>
98
+ {value ? (
99
+ <Markdown content={value} />
100
+ ) : (
101
+ <p className="text-sm italic" style={{ color: "var(--text-tertiary)" }}>
102
+ {placeholder || `Add ${label.toLowerCase()}...`}
103
+ </p>
104
+ )}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ return (
110
+ <div
111
+ className="rounded-lg p-4"
112
+ style={{
113
+ background: "var(--bg-elevated)",
114
+ border: "1px solid var(--accent)",
115
+ boxShadow: "var(--shadow-card)",
116
+ }}
117
+ onKeyDown={handleEditorKeyDown}
118
+ >
119
+ <div className="flex items-center justify-between mb-2">
120
+ <h3
121
+ className="font-semibold uppercase tracking-wider"
122
+ style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
123
+ >
124
+ {label}
125
+ </h3>
126
+ <div
127
+ className="flex overflow-hidden text-xs rounded-md"
128
+ style={{ border: "1px solid var(--border-default)" }}
129
+ >
130
+ <button
131
+ onClick={() => setTab("edit")}
132
+ className="px-2.5 py-1 font-medium"
133
+ style={{
134
+ background: tab === "edit" ? "var(--bg-hover)" : "var(--bg-elevated)",
135
+ color: tab === "edit" ? "var(--text-primary)" : "var(--text-secondary)",
136
+ }}
137
+ >
138
+ Edit
139
+ </button>
140
+ <button
141
+ onClick={() => setTab("preview")}
142
+ className="px-2.5 py-1 font-medium"
143
+ style={{
144
+ background: tab === "preview" ? "var(--bg-hover)" : "var(--bg-elevated)",
145
+ color: tab === "preview" ? "var(--text-primary)" : "var(--text-secondary)",
146
+ borderLeft: "1px solid var(--border-default)",
147
+ }}
148
+ >
149
+ Preview
150
+ </button>
151
+ </div>
152
+ </div>
153
+ {tab === "edit" ? (
154
+ <textarea
155
+ value={draft}
156
+ onChange={(e) => setDraft(e.target.value)}
157
+ rows={10}
158
+ className="w-full p-3 text-sm font-mono rounded-md resize-y outline-none"
159
+ style={{
160
+ border: "1px solid var(--border-default)",
161
+ background: "var(--bg-base)",
162
+ color: "var(--text-primary)",
163
+ }}
164
+ onFocus={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; }}
165
+ onBlur={(e) => { e.currentTarget.style.borderColor = "var(--border-default)"; }}
166
+ autoFocus
167
+ />
168
+ ) : (
169
+ <div
170
+ className="p-3 rounded-md min-h-[160px]"
171
+ style={{
172
+ border: "1px solid var(--border-default)",
173
+ background: "var(--bg-base)",
174
+ }}
175
+ >
176
+ <Markdown content={draft} />
177
+ </div>
178
+ )}
179
+ <div className="flex items-center justify-end gap-2 mt-3">
180
+ <span className="text-xs mr-auto" style={{ color: "var(--text-tertiary)" }}>
181
+ Cmd+S to save, Esc to cancel
182
+ </span>
183
+ <button
184
+ onClick={handleCancel}
185
+ className="px-3 py-1.5 text-sm rounded-md transition-colors"
186
+ style={{
187
+ color: "var(--text-secondary)",
188
+ border: "1px solid var(--border-default)",
189
+ background: "var(--bg-elevated)",
190
+ }}
191
+ onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
192
+ onMouseLeave={(e) => { e.currentTarget.style.background = "var(--bg-elevated)"; }}
193
+ >
194
+ Cancel
195
+ </button>
196
+ <button
197
+ onClick={handleSave}
198
+ disabled={saving}
199
+ className="px-3 py-1.5 text-sm rounded-md font-medium disabled:opacity-50 transition-colors"
200
+ style={{
201
+ background: "var(--accent)",
202
+ color: "white",
203
+ }}
204
+ onMouseEnter={(e) => { e.currentTarget.style.opacity = "0.9"; }}
205
+ onMouseLeave={(e) => { e.currentTarget.style.opacity = "1"; }}
206
+ >
207
+ {saving ? "Saving..." : "Save"}
208
+ </button>
209
+ </div>
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,64 @@
1
+ const STATUS_CONFIG: Record<string, { dot: string; bg: string; text: string; border: string; label: string }> = {
2
+ open: {
3
+ dot: "var(--status-open)",
4
+ bg: "rgba(59,130,246,0.08)",
5
+ text: "#2563EB",
6
+ border: "rgba(59,130,246,0.2)",
7
+ label: "Open",
8
+ },
9
+ in_progress: {
10
+ dot: "var(--status-in-progress)",
11
+ bg: "rgba(245,158,11,0.08)",
12
+ text: "#D97706",
13
+ border: "rgba(245,158,11,0.2)",
14
+ label: "In Progress",
15
+ },
16
+ blocked: {
17
+ dot: "var(--status-blocked)",
18
+ bg: "rgba(239,68,68,0.08)",
19
+ text: "#DC2626",
20
+ border: "rgba(239,68,68,0.2)",
21
+ label: "Blocked",
22
+ },
23
+ closed: {
24
+ dot: "var(--status-closed)",
25
+ bg: "rgba(34,197,94,0.08)",
26
+ text: "#16A34A",
27
+ border: "rgba(34,197,94,0.2)",
28
+ label: "Closed",
29
+ },
30
+ };
31
+
32
+ const DEFAULT_CONFIG = {
33
+ dot: "#9CA3AF",
34
+ bg: "rgba(156,163,175,0.08)",
35
+ text: "#6B7280",
36
+ border: "rgba(156,163,175,0.2)",
37
+ label: "",
38
+ };
39
+
40
+ export function StatusBadge({ status }: { status: string }) {
41
+ const config = STATUS_CONFIG[status] || DEFAULT_CONFIG;
42
+ return (
43
+ <span
44
+ className="inline-flex items-center gap-1.5 px-2 py-0.5 font-semibold"
45
+ style={{
46
+ fontSize: "11px",
47
+ borderRadius: "20px",
48
+ backgroundColor: config.bg,
49
+ color: config.text,
50
+ border: `1px solid ${config.border}`,
51
+ }}
52
+ >
53
+ <span
54
+ className="rounded-full shrink-0"
55
+ style={{
56
+ width: "6px",
57
+ height: "6px",
58
+ backgroundColor: config.dot,
59
+ }}
60
+ />
61
+ {config.label || status}
62
+ </span>
63
+ );
64
+ }
@@ -0,0 +1,26 @@
1
+ const TYPE_CONFIG: Record<string, { bg: string; text: string }> = {
2
+ epic: { bg: "rgba(124,58,237,0.1)", text: "#7C3AED" },
3
+ feature: { bg: "rgba(99,102,241,0.1)", text: "#6366F1" },
4
+ bug: { bg: "rgba(239,68,68,0.1)", text: "#DC2626" },
5
+ task: { bg: "rgba(22,163,74,0.1)", text: "#16A34A" },
6
+ chore: { bg: "rgba(120,113,108,0.1)", text: "#78716C" },
7
+ };
8
+
9
+ const DEFAULT = { bg: "rgba(120,113,108,0.1)", text: "#78716C" };
10
+
11
+ export function TypeBadge({ type }: { type: string }) {
12
+ const config = TYPE_CONFIG[type] || DEFAULT;
13
+ return (
14
+ <span
15
+ className="inline-flex items-center px-2 py-0.5 font-semibold capitalize"
16
+ style={{
17
+ fontSize: "11px",
18
+ borderRadius: "var(--radius-sm)",
19
+ backgroundColor: config.bg,
20
+ color: config.text,
21
+ }}
22
+ >
23
+ {type}
24
+ </span>
25
+ );
26
+ }
@@ -0,0 +1,55 @@
1
+ import { useCallback } from "react";
2
+ import { useWs } from "../lib/ws-context";
3
+ import type {
4
+ EditTextPayload,
5
+ CreateIssuePayload,
6
+ } from "../lib/types";
7
+
8
+ export function useMutation() {
9
+ const ws = useWs();
10
+
11
+ return {
12
+ updateStatus: useCallback(
13
+ (id: string, status: "open" | "in_progress" | "closed") =>
14
+ ws.updateStatus({ id, status }),
15
+ [ws],
16
+ ),
17
+ editText: useCallback(
18
+ (payload: EditTextPayload) => ws.editText(payload),
19
+ [ws],
20
+ ),
21
+ createIssue: useCallback(
22
+ (payload: CreateIssuePayload) => ws.createIssue(payload),
23
+ [ws],
24
+ ),
25
+ updatePriority: useCallback(
26
+ (id: string, priority: number) => ws.updatePriority(id, priority),
27
+ [ws],
28
+ ),
29
+ updateAssignee: useCallback(
30
+ (id: string, assignee: string) => ws.updateAssignee(id, assignee),
31
+ [ws],
32
+ ),
33
+ addLabel: useCallback(
34
+ (id: string, label: string) => ws.addLabel(id, label),
35
+ [ws],
36
+ ),
37
+ removeLabel: useCallback(
38
+ (id: string, label: string) => ws.removeLabel(id, label),
39
+ [ws],
40
+ ),
41
+ deleteIssue: useCallback((id: string) => ws.deleteIssue(id), [ws]),
42
+ addComment: useCallback(
43
+ (id: string, text: string) => ws.addComment(id, text),
44
+ [ws],
45
+ ),
46
+ addDep: useCallback(
47
+ (a: string, b: string) => ws.addDep(a, b),
48
+ [ws],
49
+ ),
50
+ removeDep: useCallback(
51
+ (a: string, b: string) => ws.removeDep(a, b),
52
+ [ws],
53
+ ),
54
+ };
55
+ }
@@ -0,0 +1,19 @@
1
+ import { useState, useMemo } from "react";
2
+ import type { Issue } from "../lib/types";
3
+
4
+ export function useSearch(issues: Issue[]) {
5
+ const [query, setQuery] = useState("");
6
+
7
+ const results = useMemo(() => {
8
+ if (!query.trim()) return issues;
9
+ const q = query.toLowerCase();
10
+ return issues.filter(
11
+ (i) =>
12
+ i.id.toLowerCase().includes(q) ||
13
+ i.title.toLowerCase().includes(q) ||
14
+ (i.description || "").toLowerCase().includes(q),
15
+ );
16
+ }, [issues, query]);
17
+
18
+ return { query, setQuery, results };
19
+ }
@@ -0,0 +1,187 @@
1
+ import { useEffect, useState, useRef } from "react";
2
+ import { useWs } from "../lib/ws-context";
3
+ import type { Issue, SubscriptionType, PushEvent } from "../lib/types";
4
+ import { WsClient } from "../lib/ws-client";
5
+
6
+ /**
7
+ * Shared subscription cache. Multiple useSubscription() calls with the same
8
+ * type+params share a single server-side subscription. The cache lives on
9
+ * the WsClient instance (via WeakMap) so it's scoped to the connection.
10
+ */
11
+
12
+ interface SharedSub {
13
+ refCount: number;
14
+ subId: string;
15
+ items: Map<string, Issue>;
16
+ listeners: Set<() => void>;
17
+ loading: boolean;
18
+ total: number;
19
+ cleanupPush: (() => void) | null;
20
+ }
21
+
22
+ const clientCaches = new WeakMap<WsClient, Map<string, SharedSub>>();
23
+
24
+ function getCache(ws: WsClient): Map<string, SharedSub> {
25
+ let cache = clientCaches.get(ws);
26
+ if (!cache) {
27
+ cache = new Map();
28
+ clientCaches.set(ws, cache);
29
+ }
30
+ return cache;
31
+ }
32
+
33
+ function cacheKey(
34
+ type: SubscriptionType,
35
+ params?: Record<string, string | number | boolean>,
36
+ ): string {
37
+ return params ? `${type}:${JSON.stringify(params)}` : type;
38
+ }
39
+
40
+ function acquireSubscription(
41
+ ws: WsClient,
42
+ type: SubscriptionType,
43
+ params?: Record<string, string | number | boolean>,
44
+ ): SharedSub {
45
+ const cache = getCache(ws);
46
+ const key = cacheKey(type, params);
47
+
48
+ let shared = cache.get(key);
49
+ if (shared) {
50
+ shared.refCount++;
51
+ return shared;
52
+ }
53
+
54
+ const subId = `sub-${type}-${Date.now()}`;
55
+ shared = {
56
+ refCount: 1,
57
+ subId,
58
+ items: new Map(),
59
+ listeners: new Set(),
60
+ loading: true,
61
+ total: 0,
62
+ cleanupPush: null,
63
+ };
64
+
65
+ const notify = () => {
66
+ for (const listener of shared!.listeners) listener();
67
+ };
68
+
69
+ shared.cleanupPush = ws.onPush((event: PushEvent) => {
70
+ if (event.id !== subId) return;
71
+
72
+ if (event.type === "snapshot") {
73
+ shared!.items = new Map();
74
+ for (const issue of event.issues) shared!.items.set(issue.id, issue);
75
+ shared!.loading = false;
76
+ if (typeof event.total === "number") {
77
+ shared!.total = event.total;
78
+ } else {
79
+ shared!.total = event.issues.length;
80
+ }
81
+ notify();
82
+ } else if (event.type === "upsert") {
83
+ shared!.items.set(event.issue.id, event.issue);
84
+ notify();
85
+ } else if (event.type === "delete") {
86
+ shared!.items.delete(event.issue_id);
87
+ notify();
88
+ }
89
+ });
90
+
91
+ ws.subscribe({ id: subId, type, params });
92
+ cache.set(key, shared);
93
+ return shared;
94
+ }
95
+
96
+ function releaseSubscription(
97
+ ws: WsClient,
98
+ type: SubscriptionType,
99
+ params?: Record<string, string | number | boolean>,
100
+ ): void {
101
+ const cache = getCache(ws);
102
+ const key = cacheKey(type, params);
103
+ const shared = cache.get(key);
104
+ if (!shared) return;
105
+
106
+ shared.refCount--;
107
+ if (shared.refCount <= 0) {
108
+ shared.cleanupPush?.();
109
+ ws.unsubscribe(shared.subId);
110
+ cache.delete(key);
111
+ }
112
+ }
113
+
114
+ export function useSubscription(
115
+ type: SubscriptionType,
116
+ params?: Record<string, string | number | boolean>,
117
+ ): { issues: Issue[]; loading: boolean; refreshing: boolean; total: number } {
118
+ const ws = useWs();
119
+ const paramsKey = params ? JSON.stringify(params) : "";
120
+ const sharedRef = useRef<SharedSub | null>(null);
121
+
122
+ // Track whether we've ever received data (initial load vs refresh)
123
+ const hasLoadedOnceRef = useRef(false);
124
+ const [staleIssues, setStaleIssues] = useState<Issue[]>([]);
125
+ const [staleTotal, setStaleTotal] = useState(0);
126
+
127
+ // Acquire/release shared subscription on mount/unmount
128
+ useEffect(() => {
129
+ const shared = acquireSubscription(ws, type, params);
130
+ sharedRef.current = shared;
131
+ return () => {
132
+ releaseSubscription(ws, type, params);
133
+ sharedRef.current = null;
134
+ };
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ }, [ws, type, paramsKey]);
137
+
138
+ // Subscribe to changes
139
+ const [issues, setIssues] = useState<Issue[]>([]);
140
+ const [loading, setLoading] = useState(true);
141
+ const [total, setTotal] = useState(0);
142
+
143
+ useEffect(() => {
144
+ const shared = sharedRef.current;
145
+ if (!shared) return;
146
+
147
+ const currentIssues = Array.from(shared.items.values());
148
+ setIssues(currentIssues);
149
+ setLoading(shared.loading);
150
+ setTotal(shared.total);
151
+
152
+ // When new data arrives, save it as stale for next transition
153
+ if (!shared.loading && currentIssues.length > 0) {
154
+ hasLoadedOnceRef.current = true;
155
+ setStaleIssues(currentIssues);
156
+ setStaleTotal(shared.total);
157
+ }
158
+
159
+ const listener = () => {
160
+ const items = Array.from(shared.items.values());
161
+ setIssues(items);
162
+ setLoading(shared.loading);
163
+ setTotal(shared.total);
164
+
165
+ if (!shared.loading && items.length > 0) {
166
+ hasLoadedOnceRef.current = true;
167
+ setStaleIssues(items);
168
+ setStaleTotal(shared.total);
169
+ }
170
+ };
171
+ shared.listeners.add(listener);
172
+ return () => {
173
+ shared.listeners.delete(listener);
174
+ };
175
+ // eslint-disable-next-line react-hooks/exhaustive-deps
176
+ }, [ws, type, paramsKey]);
177
+
178
+ const isInitialLoad = loading && !hasLoadedOnceRef.current;
179
+ const isRefreshing = loading && hasLoadedOnceRef.current;
180
+
181
+ return {
182
+ issues: isRefreshing ? staleIssues : issues,
183
+ loading: isInitialLoad,
184
+ refreshing: isRefreshing,
185
+ total: isRefreshing ? staleTotal : total,
186
+ };
187
+ }