Skip to content
167 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { ChatSidebarEntry, FeedCounts, FeedNotification } from "@g1t/contracts";
5
6import {
7 MAX_TOASTS,
8 addToast,
9 attentionCount,
10 badgesOf,
11 canQuickReply,
12 dismissToast,
13 hiddenToFit,
14 isViewing,
15 markReadLocally,
16 offerPush,
17 overlayEntries,
18 quickReplyRequest,
19 reconnectDelay,
20 titleWith,
21 unlisted,
22} from "./notify-store.ts";
23
24const note = (id: string, over: Partial<FeedNotification> = {}): FeedNotification => ({
25 id,
26 kind: "dm",
27 workspace: "acme",
28 title: "Ana Lima",
29 body: "ship it?",
30 href: "/acme/-/chat/dm/chn_1",
31 actor: { kind: "user", id: "u1", name: "Ana Lima" },
32 channel_id: "chn_1",
33 created_at: "2026-10-08T00:00:00Z",
34 ...over,
35});
36const away = { path: "/acme/api" };
37
38test("toasts: newest last, at most three, never twice", () => {
39 let toasts = addToast([], note("1", { channel_id: "a", href: "/acme/-/chat/a" }), away, 1);
40 toasts = addToast(toasts, note("1", { channel_id: "a", href: "/acme/-/chat/a" }), away, 2);
41 assert.equal(toasts.length, 1);
42 for (const id of ["2", "3", "4"]) toasts = addToast(toasts, note(id, { channel_id: `c${id}`, href: `/acme/-/chat/c${id}` }), away, Number(id));
43 assert.equal(toasts.length, MAX_TOASTS);
44 assert.deepEqual(toasts.map((t) => t.notification.id), ["2", "3", "4"]);
45 assert.deepEqual(dismissToast(toasts, "3").map((t) => t.notification.id), ["2", "4"]);
46});
47
48test("a newer message in the same conversation replaces its toast", () => {
49 let toasts = addToast([], note("1"), away, 1);
50 toasts = addToast(toasts, note("2"), away, 2);
51 assert.deepEqual(toasts.map((t) => t.notification.id), ["2"]);
52});
53
54test("no toast for the conversation on screen, by page or by channel; inbox items always", () => {
55 assert.deepEqual(addToast([], note("1"), { path: "/acme/-/chat/dm/chn_1/" }, 1), []);
56 assert.deepEqual(addToast([], note("1", { href: "/acme/-/chat/ops?thread=m1", channel_id: "c9" }), { path: "/ACME/-/chat/ops" }, 1), []);
57 assert.ok(isViewing(note("1"), { path: "/elsewhere", channel_id: "chn_1" }));
58 assert.equal(addToast([], note("1", { kind: "inbox", channel_id: null, href: "/acme/api/pull/4" }), { path: "/acme/api/pull/4" }, 1).length, 1);
59});
60
61test("quick replies post to the conversation, in its thread when there is one", () => {
62 assert.ok(canQuickReply(note("1")));
63 assert.ok(!canQuickReply(note("1", { kind: "inbox" })));
64 assert.ok(!canQuickReply(note("1", { channel_id: null })));
65 assert.equal(quickReplyRequest(note("1"), " "), null);
66 assert.deepEqual(quickReplyRequest(note("1", { kind: "mention", thread_root: "m1" }), " on it "), {
67 url: "/acme/-/chat/api",
68 body: { intent: "post", channel_id: "chn_1", body: "on it", thread_root: "m1" },
69 });
70});
71
72const counts = (over: Partial<FeedCounts> = {}): FeedCounts => ({
73 workspace: "acme",
74 chat_unread: 4,
75 chat_mentions: 1,
76 inbox_unread: 2,
77 per_channel: [
78 { channel_id: "a", unread: 3, mentions: 1 },
79 { channel_id: "b", unread: 1, mentions: 0 },
80 ],
81 complete: true,
82 ...over,
83});
84
85test("badges: chat only once the feed's counts are complete; the inbox whenever known", () => {
86 assert.deepEqual(badgesOf(counts(), null), { chat: 4, mentions: 1, inbox: 2 });
87 assert.deepEqual(badgesOf(counts({ complete: false }), null), { inbox: 2 });
88 assert.deepEqual(badgesOf(null, 7), { inbox: 7 });
89 assert.equal(badgesOf(null, null), null);
90 assert.equal(attentionCount({ chat: 4, mentions: 1, inbox: 2 }), 6);
91 assert.equal(attentionCount({ chat: 0, mentions: 2 }), 2);
92 assert.equal(attentionCount(null), 0);
93});
94
95test("reading a conversation here zeroes it at once", () => {
96 const after = markReadLocally(counts(), "a");
97 assert.equal(after.chat_unread, 1);
98 assert.equal(after.chat_mentions, 0);
99 assert.deepEqual(after.per_channel.map((c) => c.channel_id), ["b"]);
100 assert.equal(markReadLocally(counts(), "zzz").chat_unread, 4);
101});
102
103const entry = (id: string, unread: number, mentions = 0): ChatSidebarEntry =>
104 ({
105 channel: { id, workspace_id: "w", kind: "channel", name: id, topic: null, private: false, created_by: { kind: "user", id: "u" }, created_at: "", archived_at: null, last_message_at: null },
106 title: id,
107 others: [],
108 starred: false,
109 muted: false,
110 unread,
111 mentions,
112 }) as ChatSidebarEntry;
113
114test("the sidebar takes the feed's counts once they are complete, and spots conversations it does not list", () => {
115 const entries = [entry("a", 0), entry("b", 5), entry("c", 2)];
116 const shown = overlayEntries(entries, counts());
117 assert.deepEqual(shown.map((e) => [e.unread, e.mentions]), [[3, 1], [1, 0], [0, 0]]);
118 assert.equal(overlayEntries(entries, counts({ complete: false })), entries);
119 const same = [entry("a", 3, 1), entry("b", 1)];
120 assert.equal(overlayEntries(same, counts()), same);
121 assert.deepEqual(unlisted([entry("a", 0)], counts()), ["b"]);
122});
123
124test("the tab title counts what needs you, once", () => {
125 assert.equal(titleWith("Chat · g1t", 3), "(3) Chat · g1t");
126 assert.equal(titleWith("(3) Chat · g1t", 5), "(5) Chat · g1t");
127 assert.equal(titleWith("(5) Chat · g1t", 0), "Chat · g1t");
128 assert.equal(titleWith("Inbox", 250), "(99+) Inbox");
129 assert.equal(titleWith("(99+) Inbox", 1), "(1) Inbox");
130});
131
132test("browser notifications are offered once, after a DM or mention, never after a no", () => {
133 const base = { kind: "dm" as const, supported: true, permission: "default" as const, choice: null, hasKey: true, desktop: false };
134 assert.ok(offerPush(base));
135 assert.ok(offerPush({ ...base, kind: "mention" }));
136 assert.ok(!offerPush({ ...base, kind: "inbox" }));
137 assert.ok(!offerPush({ ...base, choice: "declined" }));
138 assert.ok(!offerPush({ ...base, choice: "off" }));
139 assert.ok(!offerPush({ ...base, permission: "denied" }));
140 assert.ok(!offerPush({ ...base, permission: "granted" }));
141 assert.ok(!offerPush({ ...base, supported: false }));
142 assert.ok(!offerPush({ ...base, hasKey: false }));
143 assert.ok(!offerPush({ ...base, desktop: true }));
144});
145
146test("reconnecting waits a jittered, growing time, never past half a minute", () => {
147 assert.equal(reconnectDelay(0, () => 0), 500);
148 assert.equal(reconnectDelay(0, () => 1), 1000);
149 assert.equal(reconnectDelay(3, () => 1), 8000);
150 assert.equal(reconnectDelay(50, () => 1), 30_000);
151 const spread = new Set(Array.from({ length: 20 }, () => reconnectDelay(4)));
152 assert.ok(spread.size > 1);
153});
154
155test("a stack taller than the screen folds its oldest toasts into a pill, never the newest", () => {
156 // 3 toasts of 120 and 2 gaps: 376.
157 assert.equal(hiddenToFit([120, 120, 120], 400), 0);
158 // Too tall: the oldest folds; 2 toasts, the pill (30) and 2 gaps = 286.
159 assert.equal(hiddenToFit([120, 120, 120], 300), 1);
160 assert.equal(hiddenToFit([120, 120, 120], 200), 2);
161 // However small the screen, the newest stays.
162 assert.equal(hiddenToFit([120, 120, 120], 10), 2);
163 assert.equal(hiddenToFit([], 10), 0);
164 // The offer always shows and takes its room first.
165 assert.equal(hiddenToFit([100, 100], 276, 60), 0);
166 assert.equal(hiddenToFit([100, 100], 275, 60), 1);
167});

This file's history is long; its oldest lines are credited to the oldest commit read.