@gperezmz/bb-plugin-thread-glance 0.1.2
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/LICENSE +21 -0
- package/README.md +20 -0
- package/THIRD_PARTY_NOTICES.md +230 -0
- package/app.tsx +12 -0
- package/components/ui/alert-dialog.tsx +142 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +83 -0
- package/components/ui/coarse-pointer-sizing.ts +66 -0
- package/components/ui/context-menu.tsx +355 -0
- package/components/ui/dialog.tsx +416 -0
- package/components/ui/dropdown-menu.tsx +640 -0
- package/components/ui/hooks/use-compact-viewport.tsx +37 -0
- package/components/ui/hooks/use-media-query.ts +70 -0
- package/components/ui/hooks/use-pointer-coarse.ts +7 -0
- package/components/ui/hover-card.tsx +36 -0
- package/components/ui/icon-extended.tsx +333 -0
- package/components/ui/icon-registry.ts +189 -0
- package/components/ui/icon.tsx +444 -0
- package/components/ui/input.tsx +30 -0
- package/components/ui/menu-item-hover.tsx +93 -0
- package/components/ui/motion.ts +4 -0
- package/components/ui/overlay-trigger.ts +84 -0
- package/components/ui/popover.tsx +223 -0
- package/components/ui/responsive-overlay.tsx +823 -0
- package/components/ui/tooltip.tsx +63 -0
- package/dist/app.css +2 -0
- package/dist/app.js +66 -0
- package/dist/app.meta.json +11 -0
- package/dist/server.js +71 -0
- package/dist/server.js.map +6 -0
- package/dist/server.meta.json +11 -0
- package/features/thread-list/components/Dialogs.tsx +299 -0
- package/features/thread-list/components/FoldRows.tsx +147 -0
- package/features/thread-list/components/GroupSection.tsx +370 -0
- package/features/thread-list/components/ProviderBadge.tsx +57 -0
- package/features/thread-list/components/PullRequestBadge.tsx +50 -0
- package/features/thread-list/components/Rails.tsx +28 -0
- package/features/thread-list/components/RenameEditor.tsx +118 -0
- package/features/thread-list/components/RowMenu.tsx +168 -0
- package/features/thread-list/components/SplitMiniMap.tsx +55 -0
- package/features/thread-list/components/ThreadDetails.tsx +138 -0
- package/features/thread-list/components/ThreadList.tsx +855 -0
- package/features/thread-list/components/ThreadRowView.tsx +631 -0
- package/features/thread-list/components/Toolbar.tsx +296 -0
- package/features/thread-list/components/controller.ts +64 -0
- package/features/thread-list/components/glyphs.tsx +129 -0
- package/features/thread-list/components/row-card.ts +127 -0
- package/features/thread-list/data/storage.ts +18 -0
- package/features/thread-list/data/useAutoExpand.ts +53 -0
- package/features/thread-list/data/useClientPreferences.ts +20 -0
- package/features/thread-list/data/useNotes.ts +41 -0
- package/features/thread-list/data/useNow.ts +19 -0
- package/features/thread-list/data/usePreferences.ts +126 -0
- package/features/thread-list/data/useScheduled.ts +43 -0
- package/features/thread-list/data/useStamps.ts +68 -0
- package/features/thread-list/icons.ts +44 -0
- package/features/thread-list/index.ts +2 -0
- package/features/thread-list/model/attention.ts +89 -0
- package/features/thread-list/model/counters.ts +67 -0
- package/features/thread-list/model/details.ts +79 -0
- package/features/thread-list/model/drag.ts +104 -0
- package/features/thread-list/model/expansion.ts +71 -0
- package/features/thread-list/model/families.ts +297 -0
- package/features/thread-list/model/groups.ts +263 -0
- package/features/thread-list/model/labels.ts +50 -0
- package/features/thread-list/model/layout.ts +61 -0
- package/features/thread-list/model/menu.ts +78 -0
- package/features/thread-list/model/move.ts +46 -0
- package/features/thread-list/model/notes.ts +52 -0
- package/features/thread-list/model/provider-mark.ts +59 -0
- package/features/thread-list/model/share.ts +101 -0
- package/features/thread-list/model/sort.ts +78 -0
- package/features/thread-list/model/state.ts +330 -0
- package/features/thread-list/model/time.ts +107 -0
- package/features/thread-list/model/toggles.ts +99 -0
- package/features/thread-list/model/view.ts +669 -0
- package/features/thread-list/model/windowing.ts +27 -0
- package/features/thread-list/testing/fixtures.ts +162 -0
- package/hooks/useBrowserDimmingModal.ts +5 -0
- package/lib/portal-scope.ts +15 -0
- package/lib/utils.ts +17 -0
- package/package.json +96 -0
- package/server/cli.ts +107 -0
- package/server/failures.ts +136 -0
- package/server/import.ts +144 -0
- package/server/notes.ts +236 -0
- package/server/preference-store.ts +96 -0
- package/server/scheduled.ts +129 -0
- package/server/serial.ts +12 -0
- package/server/stamps.ts +142 -0
- package/server/startup.ts +121 -0
- package/server.ts +126 -0
- package/shared/contract.ts +73 -0
- package/shared/preferences.ts +241 -0
- package/shared/signals.ts +71 -0
- package/skills/thread-glance/SKILL.md +69 -0
- package/tsconfig.json +36 -0
- package/types/global-jsx.d.ts +10 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Work done once per server start, in a background service because `bb.sdk`
|
|
2
|
+
// is bind-gated in the factory.
|
|
3
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
4
|
+
import type { ScheduledTracker } from "./scheduled";
|
|
5
|
+
|
|
6
|
+
const THREAD_PAGE_SIZE = 500;
|
|
7
|
+
const SEED_RETRY_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
type Sdk = BbPluginApi["sdk"];
|
|
10
|
+
|
|
11
|
+
function errorMessage(error: unknown): string {
|
|
12
|
+
return error instanceof Error ? error.message : String(error);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns the id of every thread bb has, archived and hidden ones included.
|
|
17
|
+
*
|
|
18
|
+
* It lists active and archived threads separately and pages each with
|
|
19
|
+
* limit and offset, since the list is capped per call.
|
|
20
|
+
*/
|
|
21
|
+
export async function listAllThreadIds(sdk: Sdk, signal?: AbortSignal): Promise<Set<string>> {
|
|
22
|
+
const ids = new Set<string>();
|
|
23
|
+
for (const archived of [false, true]) {
|
|
24
|
+
for (let offset = 0; ; offset += THREAD_PAGE_SIZE) {
|
|
25
|
+
const page = await sdk.threads.list({
|
|
26
|
+
archived,
|
|
27
|
+
includeHidden: true,
|
|
28
|
+
limit: THREAD_PAGE_SIZE,
|
|
29
|
+
offset,
|
|
30
|
+
signal,
|
|
31
|
+
});
|
|
32
|
+
for (const thread of page) ids.add(thread.id);
|
|
33
|
+
if (page.length < THREAD_PAGE_SIZE) break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return ids;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A per-thread store, such as stamps or notes, that startup prunes. */
|
|
40
|
+
export interface PrunableStore {
|
|
41
|
+
/** What the store holds, for the log line. */
|
|
42
|
+
name: string;
|
|
43
|
+
prune(liveIds: ReadonlySet<string>, before: number): Promise<string[]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Drops per-thread rows of threads bb no longer has. Logs and returns on failure. */
|
|
47
|
+
export async function pruneDeletedThreads(
|
|
48
|
+
bb: Pick<BbPluginApi, "sdk" | "log">,
|
|
49
|
+
stores: readonly PrunableStore[],
|
|
50
|
+
signal?: AbortSignal,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const before = Date.now();
|
|
53
|
+
let live: Set<string>;
|
|
54
|
+
try {
|
|
55
|
+
live = await listAllThreadIds(bb.sdk, signal);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
bb.log.warn(`could not list threads to prune deleted ones: ${errorMessage(error)}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const store of stores) {
|
|
61
|
+
const pruned = await store.prune(live, before);
|
|
62
|
+
if (pruned.length > 0) {
|
|
63
|
+
bb.log.info(`pruned ${store.name} of ${pruned.length} deleted threads`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Seeds the scheduled-send tracker from every queued row in bb. */
|
|
69
|
+
export function seedScheduled(
|
|
70
|
+
bb: Pick<BbPluginApi, "sdk" | "log">,
|
|
71
|
+
tracker: ScheduledTracker,
|
|
72
|
+
signal?: AbortSignal,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
return tracker.seed(async () => {
|
|
75
|
+
try {
|
|
76
|
+
return await bb.sdk.threads.queue.list({ signal });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
bb.log.warn(`could not list queued messages: ${errorMessage(error)}`);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const timer = setTimeout(done, ms);
|
|
87
|
+
function done(): void {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
signal.removeEventListener("abort", done);
|
|
90
|
+
resolve();
|
|
91
|
+
}
|
|
92
|
+
signal.addEventListener("abort", done, { once: true });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function waitForAbort(signal: AbortSignal): Promise<void> {
|
|
97
|
+
if (signal.aborted) return Promise.resolve();
|
|
98
|
+
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The `startup` service: seeds scheduled sends, retrying every minute until
|
|
103
|
+
* one seed succeeds, prunes `stores` once, then waits for `signal` to abort.
|
|
104
|
+
*/
|
|
105
|
+
export async function runStartup(
|
|
106
|
+
bb: Pick<BbPluginApi, "sdk" | "log">,
|
|
107
|
+
stores: readonly PrunableStore[],
|
|
108
|
+
tracker: ScheduledTracker,
|
|
109
|
+
signal: AbortSignal,
|
|
110
|
+
retryMs = SEED_RETRY_MS,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const seeded = seedScheduled(bb, tracker, signal);
|
|
113
|
+
await pruneDeletedThreads(bb, stores, signal);
|
|
114
|
+
let ok = await seeded;
|
|
115
|
+
while (!ok && !signal.aborted) {
|
|
116
|
+
await sleep(retryMs, signal);
|
|
117
|
+
if (signal.aborted) break;
|
|
118
|
+
ok = await seedScheduled(bb, tracker, signal);
|
|
119
|
+
}
|
|
120
|
+
await waitForAbort(signal);
|
|
121
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Thread Glance server: preferences, first-run import, thread stamps, thread
|
|
2
|
+
// notes and scheduled sends. The app imports the contract's types from shared/contract.ts.
|
|
3
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
4
|
+
import { CHANNELS, rpcContract } from "./shared/contract";
|
|
5
|
+
import { createCli } from "./server/cli";
|
|
6
|
+
import { resolveFailureText } from "./server/failures";
|
|
7
|
+
import { createBbCliReader, importPreferences } from "./server/import";
|
|
8
|
+
import {
|
|
9
|
+
createNoteStore,
|
|
10
|
+
describeDone,
|
|
11
|
+
describeFailure,
|
|
12
|
+
describeInteraction,
|
|
13
|
+
type NoteDraft,
|
|
14
|
+
} from "./server/notes";
|
|
15
|
+
import { createPreferenceStore } from "./server/preference-store";
|
|
16
|
+
import { createScheduledTracker } from "./server/scheduled";
|
|
17
|
+
import { createSerialQueue } from "./server/serial";
|
|
18
|
+
import { createStampStore } from "./server/stamps";
|
|
19
|
+
import { runStartup } from "./server/startup";
|
|
20
|
+
|
|
21
|
+
export default function threadGlance(bb: BbPluginApi): void {
|
|
22
|
+
const preferences = createPreferenceStore(bb);
|
|
23
|
+
const stamps = createStampStore(bb);
|
|
24
|
+
const notes = createNoteStore(bb);
|
|
25
|
+
const scheduled = createScheduledTracker({
|
|
26
|
+
publish: (signal) => bb.realtime.publish(CHANNELS.scheduled, signal),
|
|
27
|
+
});
|
|
28
|
+
const readBbCli = createBbCliReader(bb.log);
|
|
29
|
+
const importOnce = createSerialQueue();
|
|
30
|
+
|
|
31
|
+
bb.rpc.register(rpcContract, {
|
|
32
|
+
async listPreferences() {
|
|
33
|
+
return { preferences: await preferences.readAll() };
|
|
34
|
+
},
|
|
35
|
+
async setPreference({ key, value }) {
|
|
36
|
+
return { key, value: await preferences.write(key, value) };
|
|
37
|
+
},
|
|
38
|
+
async resetPreference({ key }) {
|
|
39
|
+
return { key, value: await preferences.reset(key) };
|
|
40
|
+
},
|
|
41
|
+
importPreferences({ bbMirror }) {
|
|
42
|
+
return importOnce(() =>
|
|
43
|
+
importPreferences({ kv: bb.storage.kv, log: bb.log, store: preferences, readBbCli }, bbMirror),
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
async listStamps() {
|
|
47
|
+
return { stamps: await stamps.list() };
|
|
48
|
+
},
|
|
49
|
+
async markSeen({ threadIds }) {
|
|
50
|
+
const at = Date.now();
|
|
51
|
+
await stamps.stamp("seenAt", threadIds, at);
|
|
52
|
+
return { at };
|
|
53
|
+
},
|
|
54
|
+
async clearSeen({ threadIds }) {
|
|
55
|
+
await stamps.clear("seenAt", threadIds);
|
|
56
|
+
return { ok: true as const };
|
|
57
|
+
},
|
|
58
|
+
async listNotes() {
|
|
59
|
+
return { notes: await notes.list() };
|
|
60
|
+
},
|
|
61
|
+
listScheduled() {
|
|
62
|
+
return scheduled.snapshot();
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
bb.cli.register(createCli(preferences));
|
|
67
|
+
|
|
68
|
+
const note = (draft: NoteDraft, at = Date.now()) => ({ ...draft, at });
|
|
69
|
+
|
|
70
|
+
bb.events.on("thread.active", async ({ thread }) => {
|
|
71
|
+
await stamps.stamp("startedAt", [thread.id], Date.now());
|
|
72
|
+
await notes.update(thread.id, { pending: null, failed: null });
|
|
73
|
+
});
|
|
74
|
+
bb.events.on("thread.idle", async ({ thread, lastAssistantText }) => {
|
|
75
|
+
await stamps.stamp("finishedAt", [thread.id], Date.now());
|
|
76
|
+
const done = describeDone(lastAssistantText);
|
|
77
|
+
await notes.update(thread.id, { pending: null, ...(done ? { done: note(done) } : {}) });
|
|
78
|
+
});
|
|
79
|
+
bb.events.on("thread.failed", async ({ thread, error }) => {
|
|
80
|
+
await stamps.stamp("finishedAt", [thread.id], Date.now());
|
|
81
|
+
const text = await resolveFailureText(bb, {
|
|
82
|
+
threadId: thread.id,
|
|
83
|
+
error,
|
|
84
|
+
errorInfo: null,
|
|
85
|
+
turnId: null,
|
|
86
|
+
});
|
|
87
|
+
// thread.active clears the failed note, so one that is already here
|
|
88
|
+
// belongs to this failure: turn.failed wrote it first.
|
|
89
|
+
if (text === null && (await notes.get(thread.id))?.failed) return;
|
|
90
|
+
await notes.update(thread.id, { failed: note(describeFailure(text)) });
|
|
91
|
+
});
|
|
92
|
+
// turn.failed carries errorInfo and the turn id. It writes only text that
|
|
93
|
+
// says more than "Failed", in whichever order the two events arrive.
|
|
94
|
+
bb.events.on("turn.failed", async ({ threadId, errorInfo, turnId }) => {
|
|
95
|
+
const text = await resolveFailureText(bb, { threadId, error: null, errorInfo, turnId });
|
|
96
|
+
if (text !== null) await notes.update(threadId, { failed: note(describeFailure(text)) });
|
|
97
|
+
});
|
|
98
|
+
bb.events.on("interaction.pending", async ({ thread, interaction }) => {
|
|
99
|
+
await stamps.stamp("pendingAt", [thread.id], Date.now());
|
|
100
|
+
await notes.update(thread.id, {
|
|
101
|
+
pending: note(describeInteraction(interaction), interaction.createdAt),
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
bb.events.on("thread.deleted", async ({ thread }) => {
|
|
105
|
+
scheduled.threadGone(thread.id);
|
|
106
|
+
await stamps.forget([thread.id]);
|
|
107
|
+
await notes.forget([thread.id]);
|
|
108
|
+
});
|
|
109
|
+
bb.events.on("thread.archived", ({ thread }) => scheduled.threadGone(thread.id));
|
|
110
|
+
bb.events.on("message.queued", ({ entry }) => scheduled.queued(entry));
|
|
111
|
+
bb.events.on("message.dispatched", ({ entry }) => scheduled.removed(entry.id));
|
|
112
|
+
bb.events.on("message.cancelled", ({ entry }) => scheduled.removed(entry.id));
|
|
113
|
+
|
|
114
|
+
bb.background.service("startup", {
|
|
115
|
+
start: (signal) =>
|
|
116
|
+
runStartup(
|
|
117
|
+
bb,
|
|
118
|
+
[
|
|
119
|
+
{ name: "stamps", prune: stamps.prune },
|
|
120
|
+
{ name: "notes", prune: notes.prune },
|
|
121
|
+
],
|
|
122
|
+
scheduled,
|
|
123
|
+
signal,
|
|
124
|
+
),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// RPC contract between the app and the server. The app imports this module
|
|
2
|
+
// for its types only; channel names and payload schemas live in signals.ts.
|
|
3
|
+
import { defineRpcContract } from "@get-bb/plugin-sdk";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PREFERENCE_KEYS, PREFERENCES, type PreferenceKey } from "./preferences";
|
|
6
|
+
import { stampsSchema, threadNotesSchema } from "./signals";
|
|
7
|
+
|
|
8
|
+
export * from "./signals";
|
|
9
|
+
|
|
10
|
+
const preferenceKeySchema = z.enum(PREFERENCE_KEYS as [PreferenceKey, ...PreferenceKey[]]);
|
|
11
|
+
const preferencesSchema = z.object(
|
|
12
|
+
Object.fromEntries(PREFERENCE_KEYS.map((key) => [key, PREFERENCES[key].schema])),
|
|
13
|
+
);
|
|
14
|
+
const stampMapSchema = z.record(z.string(), z.number());
|
|
15
|
+
const threadIdsSchema = z.array(z.string().min(1).max(1024)).max(10_000);
|
|
16
|
+
|
|
17
|
+
export const rpcContract = defineRpcContract({
|
|
18
|
+
listPreferences: {
|
|
19
|
+
input: z.null(),
|
|
20
|
+
output: z.object({ preferences: preferencesSchema }).strict(),
|
|
21
|
+
},
|
|
22
|
+
setPreference: {
|
|
23
|
+
input: z.object({ key: preferenceKeySchema, value: z.unknown() }).strict(),
|
|
24
|
+
output: z.object({ key: preferenceKeySchema, value: z.unknown() }).strict(),
|
|
25
|
+
},
|
|
26
|
+
resetPreference: {
|
|
27
|
+
input: z.object({ key: preferenceKeySchema }).strict(),
|
|
28
|
+
output: z.object({ key: preferenceKeySchema, value: z.unknown() }).strict(),
|
|
29
|
+
},
|
|
30
|
+
/**
|
|
31
|
+
* First-run import. `bbMirror` is the parsed value of bb's own
|
|
32
|
+
* localStorage mirror, or null when the app found none; the server then
|
|
33
|
+
* tries `bb thread-list prefs list --json`. Runs at most once.
|
|
34
|
+
*/
|
|
35
|
+
importPreferences: {
|
|
36
|
+
input: z.object({ bbMirror: z.unknown().nullable() }).strict(),
|
|
37
|
+
output: z
|
|
38
|
+
.object({
|
|
39
|
+
status: z.enum(["already-imported", "imported", "defaults"]),
|
|
40
|
+
source: z.enum(["local-storage", "cli", "none"]).nullable(),
|
|
41
|
+
keys: z.array(preferenceKeySchema),
|
|
42
|
+
})
|
|
43
|
+
.strict(),
|
|
44
|
+
},
|
|
45
|
+
listStamps: {
|
|
46
|
+
input: z.null(),
|
|
47
|
+
output: z.object({ stamps: stampsSchema }).strict(),
|
|
48
|
+
},
|
|
49
|
+
/** Records `seenAt` for child threads the user viewed. */
|
|
50
|
+
markSeen: {
|
|
51
|
+
input: z.object({ threadIds: threadIdsSchema }).strict(),
|
|
52
|
+
output: z.object({ at: z.number() }).strict(),
|
|
53
|
+
},
|
|
54
|
+
/** Deletes `seenAt`, so Mark unread shows a finished child as unread. */
|
|
55
|
+
clearSeen: {
|
|
56
|
+
input: z.object({ threadIds: threadIdsSchema }).strict(),
|
|
57
|
+
output: z.object({ ok: z.literal(true) }).strict(),
|
|
58
|
+
},
|
|
59
|
+
/** Notes per thread id (see `noteSchema`). */
|
|
60
|
+
listNotes: {
|
|
61
|
+
input: z.null(),
|
|
62
|
+
output: z.object({ notes: z.record(z.string(), threadNotesSchema) }).strict(),
|
|
63
|
+
},
|
|
64
|
+
/** Earliest future `sendAt` per thread (scheduled sends). */
|
|
65
|
+
listScheduled: {
|
|
66
|
+
input: z.null(),
|
|
67
|
+
output: z
|
|
68
|
+
.object({ status: z.enum(["ready", "error"]), scheduled: stampMapSchema })
|
|
69
|
+
.strict(),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export type RpcContract = typeof rpcContract;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Preference schema shared by the server (kv storage, RPC, CLI) and the app
|
|
2
|
+
// (localStorage mirror, first paint). Keys and defaults: docs/reference/thread-glance-preferences.md.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const MAX_ITEMS = 10_000;
|
|
6
|
+
const idSchema = z.string().min(1).max(1024);
|
|
7
|
+
const idListSchema = z.array(idSchema).max(MAX_ITEMS);
|
|
8
|
+
|
|
9
|
+
export const organizationModeSchema = z.enum(["project", "chronological", "machine"]);
|
|
10
|
+
export const sortFieldSchema = z.enum(["updated", "created", "alpha", "none"]);
|
|
11
|
+
export const sortDirectionSchema = z.enum(["default", "ascending", "descending"]);
|
|
12
|
+
export const nestingSchema = z.enum(["folded", "tree"]);
|
|
13
|
+
export const lifecycleSchema = z.enum(["active", "archived"]);
|
|
14
|
+
export const harnessIconSchema = z.enum(["muted", "colour", "hidden"]);
|
|
15
|
+
export const childAttentionSchema = z.enum(["blocked", "everything"]);
|
|
16
|
+
|
|
17
|
+
const hiddenGroupsSchema = z
|
|
18
|
+
.array(z.union([z.literal("threads"), idSchema.regex(/^(project|section|machine):\S+$/)]))
|
|
19
|
+
.max(MAX_ITEMS)
|
|
20
|
+
.transform((groups) => [...new Set(groups)]);
|
|
21
|
+
|
|
22
|
+
export type OrganizationMode = z.infer<typeof organizationModeSchema>;
|
|
23
|
+
export type SortField = z.infer<typeof sortFieldSchema>;
|
|
24
|
+
export type SortDirection = z.infer<typeof sortDirectionSchema>;
|
|
25
|
+
export type Nesting = z.infer<typeof nestingSchema>;
|
|
26
|
+
export type Lifecycle = z.infer<typeof lifecycleSchema>;
|
|
27
|
+
export type HarnessIcon = z.infer<typeof harnessIconSchema>;
|
|
28
|
+
export type ChildAttention = z.infer<typeof childAttentionSchema>;
|
|
29
|
+
|
|
30
|
+
interface PreferenceDefinition<T> {
|
|
31
|
+
schema: z.ZodType<T, unknown>;
|
|
32
|
+
defaultValue: T;
|
|
33
|
+
description: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function define<T>(
|
|
37
|
+
schema: z.ZodType<T, unknown>,
|
|
38
|
+
defaultValue: T,
|
|
39
|
+
description: string,
|
|
40
|
+
): PreferenceDefinition<T> {
|
|
41
|
+
return { schema, defaultValue, description };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Server-side preferences, shared across windows and devices. */
|
|
45
|
+
export const PREFERENCES = {
|
|
46
|
+
threadLifecycles: define(
|
|
47
|
+
z
|
|
48
|
+
.array(lifecycleSchema)
|
|
49
|
+
.min(1)
|
|
50
|
+
.max(2)
|
|
51
|
+
.refine((values) => new Set(values).size === values.length, "Lifecycles must be unique"),
|
|
52
|
+
["active"] as Lifecycle[],
|
|
53
|
+
"Thread lifecycles shown: active, archived, or both. At least one is required.",
|
|
54
|
+
),
|
|
55
|
+
organizationMode: define(
|
|
56
|
+
organizationModeSchema,
|
|
57
|
+
"project" as OrganizationMode,
|
|
58
|
+
"How top-level groups are formed: project, chronological (custom sections) or machine.",
|
|
59
|
+
),
|
|
60
|
+
environmentGrouping: define(
|
|
61
|
+
z.boolean(),
|
|
62
|
+
false,
|
|
63
|
+
"Whether sibling threads sharing a worktree environment fold into a folder row.",
|
|
64
|
+
),
|
|
65
|
+
chronologicalSort: define(
|
|
66
|
+
sortFieldSchema,
|
|
67
|
+
"updated" as SortField,
|
|
68
|
+
"Sort field inside a group: updated, created or alpha. none is read as updated.",
|
|
69
|
+
),
|
|
70
|
+
sortDirection: define(
|
|
71
|
+
sortDirectionSchema,
|
|
72
|
+
"default" as SortDirection,
|
|
73
|
+
"Sort direction; default keeps the field's natural direction.",
|
|
74
|
+
),
|
|
75
|
+
sectionOrder: define(
|
|
76
|
+
idListSchema,
|
|
77
|
+
["pinned", "projects", "threads"],
|
|
78
|
+
"Top-level group order when organized by project.",
|
|
79
|
+
),
|
|
80
|
+
manualSectionOrder: define(
|
|
81
|
+
idListSchema,
|
|
82
|
+
["pinned", "sections", "threads"],
|
|
83
|
+
"Top-level group order when organized chronologically.",
|
|
84
|
+
),
|
|
85
|
+
machineSectionOrder: define(
|
|
86
|
+
idListSchema,
|
|
87
|
+
["pinned", "machines", "threads"],
|
|
88
|
+
"Top-level group order when organized by machine.",
|
|
89
|
+
),
|
|
90
|
+
hiddenGroups: define(
|
|
91
|
+
hiddenGroupsSchema,
|
|
92
|
+
[] as string[],
|
|
93
|
+
"Groups moved into More: threads, project:<id>, section:<id> or machine:<id>.",
|
|
94
|
+
),
|
|
95
|
+
collapsedSections: define(
|
|
96
|
+
z.array(z.enum(["pinned", "threads"])).max(MAX_ITEMS),
|
|
97
|
+
[] as ("pinned" | "threads")[],
|
|
98
|
+
"Built-in groups (pinned, threads) that are collapsed.",
|
|
99
|
+
),
|
|
100
|
+
collapsedProjects: define(idListSchema, [] as string[], "Project ids whose group is collapsed."),
|
|
101
|
+
collapsedThreadSections: define(
|
|
102
|
+
idListSchema,
|
|
103
|
+
[] as string[],
|
|
104
|
+
"Custom section keys (section:<id>) whose group is collapsed.",
|
|
105
|
+
),
|
|
106
|
+
collapsedMachines: define(idListSchema, [] as string[], "Machine ids whose group is collapsed."),
|
|
107
|
+
collapsedEnvironments: define(
|
|
108
|
+
idListSchema,
|
|
109
|
+
[] as string[],
|
|
110
|
+
"Environment ids whose folder row is collapsed.",
|
|
111
|
+
),
|
|
112
|
+
nesting: define(
|
|
113
|
+
nestingSchema,
|
|
114
|
+
"folded" as Nesting,
|
|
115
|
+
"Child thread nesting: folded (one flat level behind a chip) or tree (bb's tree).",
|
|
116
|
+
),
|
|
117
|
+
foldOlder: define(
|
|
118
|
+
z.boolean(),
|
|
119
|
+
true,
|
|
120
|
+
"Whether quiet roots past the 5 most recent fold behind an N older row.",
|
|
121
|
+
),
|
|
122
|
+
workingFirst: define(
|
|
123
|
+
z.boolean(),
|
|
124
|
+
false,
|
|
125
|
+
"Whether working threads sort first under Updated, as bb's list does.",
|
|
126
|
+
),
|
|
127
|
+
expandedOlder: define(
|
|
128
|
+
idListSchema,
|
|
129
|
+
[] as string[],
|
|
130
|
+
"Group ids and parent thread ids whose N older fold the user opened.",
|
|
131
|
+
),
|
|
132
|
+
expandedChildren: define(
|
|
133
|
+
idListSchema,
|
|
134
|
+
[] as string[],
|
|
135
|
+
"Parent thread ids the user expanded (folded nesting).",
|
|
136
|
+
),
|
|
137
|
+
collapsedChildren: define(
|
|
138
|
+
idListSchema,
|
|
139
|
+
[] as string[],
|
|
140
|
+
"Parent thread ids the user collapsed (tree nesting).",
|
|
141
|
+
),
|
|
142
|
+
showPullRequests: define(z.boolean(), true, "Whether rows show a pull request badge."),
|
|
143
|
+
childAttention: define(
|
|
144
|
+
childAttentionSchema,
|
|
145
|
+
"blocked" as ChildAttention,
|
|
146
|
+
"Which child threads count under Needs attention, in badges and counters, and stay out of a family's fold along with running children: blocked (waiting on you, offline, or an orphaned failure) or everything (also every failed and unread child).",
|
|
147
|
+
),
|
|
148
|
+
harnessIcon: define(
|
|
149
|
+
harnessIconSchema,
|
|
150
|
+
"muted" as HarnessIcon,
|
|
151
|
+
"How rows draw the harness logo: muted (monochrome), colour (the provider's tint) or hidden.",
|
|
152
|
+
),
|
|
153
|
+
} as const;
|
|
154
|
+
|
|
155
|
+
export type PreferenceKey = keyof typeof PREFERENCES;
|
|
156
|
+
export type Preferences = {
|
|
157
|
+
-readonly [K in PreferenceKey]: (typeof PREFERENCES)[K]["defaultValue"];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const PREFERENCE_KEYS = Object.keys(PREFERENCES) as PreferenceKey[];
|
|
161
|
+
|
|
162
|
+
export function isPreferenceKey(key: string): key is PreferenceKey {
|
|
163
|
+
return Object.hasOwn(PREFERENCES, key);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function preferenceDefault<K extends PreferenceKey>(key: K): Preferences[K] {
|
|
167
|
+
return structuredClone(PREFERENCES[key].defaultValue) as Preferences[K];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function defaultPreferences(): Preferences {
|
|
171
|
+
return Object.fromEntries(
|
|
172
|
+
PREFERENCE_KEYS.map((key) => [key, preferenceDefault(key)]),
|
|
173
|
+
) as Preferences;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function describePreference(key: PreferenceKey): string {
|
|
177
|
+
return PREFERENCES[key].description;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type ParseResult<T> = { success: true; value: T } | { success: false; message: string };
|
|
181
|
+
|
|
182
|
+
export function parsePreference<K extends PreferenceKey>(
|
|
183
|
+
key: K,
|
|
184
|
+
value: unknown,
|
|
185
|
+
): ParseResult<Preferences[K]> {
|
|
186
|
+
const result = PREFERENCES[key].schema.safeParse(value);
|
|
187
|
+
if (result.success) return { success: true, value: result.data as Preferences[K] };
|
|
188
|
+
return { success: false, message: result.error.issues.map((issue) => issue.message).join("; ") };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reads a whole preference object, falling back to the default for each key
|
|
193
|
+
* that is missing or invalid. Used for the localStorage mirror.
|
|
194
|
+
*/
|
|
195
|
+
export function coercePreferences(raw: unknown): Preferences {
|
|
196
|
+
const result = defaultPreferences();
|
|
197
|
+
if (raw === null || typeof raw !== "object") return result;
|
|
198
|
+
const record = raw as Record<string, unknown>;
|
|
199
|
+
for (const key of PREFERENCE_KEYS) {
|
|
200
|
+
if (!(key in record)) continue;
|
|
201
|
+
const parsed = parsePreference(key, record[key]);
|
|
202
|
+
if (parsed.success) (result as Record<string, unknown>)[key] = parsed.value;
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Maps bb's own thread-list preferences onto ours for the first-run import
|
|
209
|
+
*. `environmentGrouping: "auto"` becomes off, `collapsedThreads` is
|
|
210
|
+
* skipped because it means the inverse of folded nesting, and invalid values
|
|
211
|
+
* are skipped. Returns only the keys that parsed.
|
|
212
|
+
*/
|
|
213
|
+
export function mapBbPreferences(raw: unknown): Partial<Preferences> {
|
|
214
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
215
|
+
const source = raw as Record<string, unknown>;
|
|
216
|
+
const mapped: Partial<Record<PreferenceKey, unknown>> = {};
|
|
217
|
+
for (const [bbKey, value] of Object.entries(source)) {
|
|
218
|
+
if (bbKey === "collapsedThreads") continue;
|
|
219
|
+
if (!isPreferenceKey(bbKey)) continue;
|
|
220
|
+
const candidate = bbKey === "environmentGrouping" && value === "auto" ? false : value;
|
|
221
|
+
const parsed = parsePreference(bbKey, candidate);
|
|
222
|
+
if (parsed.success) mapped[bbKey] = parsed.value;
|
|
223
|
+
}
|
|
224
|
+
return mapped as Partial<Preferences>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Per-client preferences, kept in localStorage only. */
|
|
228
|
+
export const clientPreferencesSchema = z.object({
|
|
229
|
+
filter: z.enum(["all", "attention"]).catch("all"),
|
|
230
|
+
density: z.enum(["compact", "comfortable"]).catch("compact"),
|
|
231
|
+
});
|
|
232
|
+
export type ClientPreferences = z.infer<typeof clientPreferencesSchema>;
|
|
233
|
+
|
|
234
|
+
export const CLIENT_PREFERENCES_STORAGE_KEY = "bb.thread-glance.client.v1";
|
|
235
|
+
export const PREFERENCES_MIRROR_STORAGE_KEY = "bb.thread-glance.preferences.v1";
|
|
236
|
+
export const BB_PREFERENCES_MIRROR_STORAGE_KEY = "bb.thread-list.preferences.v1";
|
|
237
|
+
|
|
238
|
+
export function parseClientPreferences(raw: unknown): ClientPreferences {
|
|
239
|
+
const result = clientPreferencesSchema.safeParse(raw ?? {});
|
|
240
|
+
return result.success ? result.data : { filter: "all", density: "compact" };
|
|
241
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Realtime channels, their payloads and the schemas the app validates them
|
|
2
|
+
// with. Nothing here imports the SDK, so the app bundle can use its values.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { PreferenceKey } from "./preferences";
|
|
5
|
+
|
|
6
|
+
const stampMapSchema = z.record(z.string(), z.number());
|
|
7
|
+
|
|
8
|
+
/** Per-thread timestamps (epoch ms) kept by the server. */
|
|
9
|
+
export const stampsSchema = z.object({
|
|
10
|
+
startedAt: stampMapSchema,
|
|
11
|
+
finishedAt: stampMapSchema,
|
|
12
|
+
pendingAt: stampMapSchema,
|
|
13
|
+
seenAt: stampMapSchema,
|
|
14
|
+
});
|
|
15
|
+
export type Stamps = z.infer<typeof stampsSchema>;
|
|
16
|
+
export type StampKind = keyof Stamps;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Why a thread is blocked, failed or done, in one line: what the server saw
|
|
20
|
+
* on `interaction.pending`, `thread.failed` and `thread.idle`. Text is whitespace-collapsed and at most NOTE_MAX_LENGTH.
|
|
21
|
+
*/
|
|
22
|
+
export const NOTE_MAX_LENGTH = 140;
|
|
23
|
+
export const noteSchema = z.object({
|
|
24
|
+
/** question: asks the user; approval: a command, file or permission; plan: plan review; input: other requests. */
|
|
25
|
+
kind: z.enum(["question", "approval", "plan", "input", "failed", "done"]),
|
|
26
|
+
text: z.string().max(NOTE_MAX_LENGTH),
|
|
27
|
+
at: z.number(),
|
|
28
|
+
});
|
|
29
|
+
export type Note = z.infer<typeof noteSchema>;
|
|
30
|
+
export const threadNotesSchema = z.object({
|
|
31
|
+
/** The latest pending interaction. */
|
|
32
|
+
pending: noteSchema.optional(),
|
|
33
|
+
/** The latest failure. */
|
|
34
|
+
failed: noteSchema.optional(),
|
|
35
|
+
/** The last assistant text when the thread went idle. */
|
|
36
|
+
done: noteSchema.optional(),
|
|
37
|
+
});
|
|
38
|
+
export type ThreadNotes = z.infer<typeof threadNotesSchema>;
|
|
39
|
+
|
|
40
|
+
/** Realtime channels the server publishes on. */
|
|
41
|
+
export const CHANNELS = {
|
|
42
|
+
preferences: "preferences",
|
|
43
|
+
stamps: "stamps",
|
|
44
|
+
scheduled: "scheduled",
|
|
45
|
+
notes: "notes",
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
/** `notes` payload: one thread's notes replaced; null deletes them. */
|
|
49
|
+
export interface NotesSignal {
|
|
50
|
+
threadId: string;
|
|
51
|
+
notes: ThreadNotes | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `preferences` payload: one key changed. */
|
|
55
|
+
export interface PreferenceSignal {
|
|
56
|
+
key: PreferenceKey;
|
|
57
|
+
value: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `stamps` payload: one thread's stamp changed; null deletes it. */
|
|
61
|
+
export interface StampSignal {
|
|
62
|
+
kind: StampKind;
|
|
63
|
+
threadIds: string[];
|
|
64
|
+
value: number | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `scheduled` payload: the whole map, replaced. */
|
|
68
|
+
export interface ScheduledSignal {
|
|
69
|
+
status: "ready" | "error";
|
|
70
|
+
scheduled: Record<string, number>;
|
|
71
|
+
}
|