Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Chat's sidebar keeps public channels in their own group (#52)

A public channel (open to anyone on g1t) sat under Channels beside the workspace's own, so #community-support read as one more internal room. The sidebar now has Channels (workspace and private, by name, with + to create one and Browse channels) and, below it, Public channels, by name, shown only when you are in some. It folds and stays folded on the device like the other sections, keeps unread and mention counts, follows All / Unread / Mentions and Jump to (Enter goes to the first match in the order shown). A public channel you pinned stays under Pinned. lib/chat.ts's sections() gives the public ones as their own list. A guest sees Channels only for channels they were asked into; Public channels is always there for them, with "You're in none of its public channels yet." when empty and Browse public channels at its end. The phone's list and the desktop app draw the same ChatSidebar, so they group the same way. The chat guide's sidebar table names the new group.

Checked:
the sidebar rendered in AppShell with mocked chat data (public, workspace and private channels, pinned ones, unread counts and mentions, a muted channel, DMs, another workspace's channel) at 1280 and 390 in dark and light, with Unread and Mentions, with Public channels folded (an unread one stays shown), and as a guest with an invited channel, with only public ones and with none; the site's typecheck and tests (956, with a new sections() test); npm run build (every route loads, 258); the docs build.
G1t-Session:
ses_01m4ynr1peevvs8zwaqakycv2e
syntaqxcommitted Parent2e90f6fBrowse files
4 files+80−280/4 viewed
+2−1
209209 | Section | What's in it |
210210 | --- | --- |
211211 | **Pinned** | Conversations you pinned (hover over one and choose the pin, or right-click it). Only when you have some. |
212−| **Channels** | The channels you're in, by name, then **Browse channels** with how many more you could join. **+** creates one. |
212+| **Channels** | The workspace's own channels you're in, workspace and private, by name, then **Browse channels** with how many more you could join. **+** creates one. |
213+| **Public channels** | The [public channels](#public-channels) you're in, by name, kept apart because anyone on g1t can be in them. Only when you're in some. |
213214 | **Direct messages** | [g1t](#talk-with-g1t) first, then your direct messages, the latest first. The pencil starts a new message. Empty, it says **Message a teammate.** |
214215
215216 Unread conversations are in bold with a count; mentions of you are counted
+41−20
4343 }
4444
4545 /**
46− * Chat mode's sidebar: what you pinned, the channels (and a way to browse
47− * the rest), then direct messages, the latest first. Sections fold, and
46+ * Chat mode's sidebar: what you pinned, the workspace's own channels (and a
47+ * way to browse the rest), its public channels, then direct messages, the
48+ * latest first. Sections fold, and
4849 * stay folded on this device. All, Unread and Mentions filter every section. It refreshes
4950 * itself while the tab is shown, so unread counts move without a reload.
5051 */
8485 // The open conversation stays listed even when it is an old one with an agent alone, which Jump to otherwise finds.
8586 const currentId = entries.find((e) => pathname === channelPath(slug, e.channel) || pathname.startsWith(`${channelPath(slug, e.channel)}/`))?.channel.id ?? null;
8687 const shown = filterEntries(entries, filter, query, currentId);
87− const { pinned, channels, dms } = sections(shown);
88+ const { pinned, channels, publicChannels, dms } = sections(shown);
8889 const loading = !data;
8990
9091 // Folded sections, remembered per workspace on this device. A folded
141142 onChange={(event) => setQuery(event.target.value)}
142143 onKeyDown={(event) => {
143144 if (event.key === "Enter") {
144− const first = [...pinned, ...channels, ...dms][0];
145+ const first = [...pinned, ...channels, ...publicChannels, ...dms][0];
145146 if (first) {
146147 navigate(channelPath(slug, first.channel));
147148 setQuery("");
191192 {pinned.map(conversation)}
192193 </Section>
193194 )}
194− <Section
195− id="channels"
196− title="Channels"
197− folded={folded}
198− onFold={fold}
199− action={sidebar.guest ? undefined : <CreateChannelButton slug={slug} />}
200− keep={channels.filter(keepShown).map(conversation)}
201− >
202− {channels.map(conversation)}
203− {channels.length === 0 && filter === "all" && !query && <Empty>{sidebar.guest ? "You're in none of its channels yet." : "No channels yet."}</Empty>}
204− {filter === "all" && !query && (
205− <ExtraRow to={`/${slug}/-/chat/browse`} icon={<Compass size={15} />} count={sidebar.browsable > 0 ? sidebar.browsable : undefined} countLabel="to join">
206− {sidebar.guest ? "Public channels" : "Browse channels"}
207− </ExtraRow>
208− )}
209− </Section>
195+ {/* A guest has only the channels they were asked into here; with none, the public ones are all there is. */}
196+ {(!sidebar.guest || channels.length > 0) && (
197+ <Section
198+ id="channels"
199+ title="Channels"
200+ folded={folded}
201+ onFold={fold}
202+ action={sidebar.guest ? undefined : <CreateChannelButton slug={slug} />}
203+ keep={channels.filter(keepShown).map(conversation)}
204+ >
205+ {channels.map(conversation)}
206+ {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>}
207+ {!sidebar.guest && filter === "all" && !query && <BrowseRow slug={slug} count={sidebar.browsable} />}
208+ </Section>
209+ )}
210+ {/* Open to anyone on g1t, apart from the workspace's own: only when there are some, or for a guest, to find them. */}
211+ {(publicChannels.length > 0 || (sidebar.guest && filter === "all" && !query)) && (
212+ <Section id="public" title="Public channels" folded={folded} onFold={fold} keep={publicChannels.filter(keepShown).map(conversation)}>
213+ {publicChannels.map(conversation)}
214+ {sidebar.guest && filter === "all" && !query && (
215+ <>
216+ {publicChannels.length === 0 && <Empty>You&apos;re in none of its public channels yet.</Empty>}
217+ <BrowseRow slug={slug} count={sidebar.browsable} guest />
218+ </>
219+ )}
220+ </Section>
221+ )}
210222 {(sidebar.invitations?.length ?? 0) > 0 && filter === "all" && !query && (
211223 <Section id="invitations" title="Invitations" folded={folded} onFold={fold}>
212224 {sidebar.invitations!.map((invitation) => (
341353 );
342354 }
343355
356+/** Browse channels, with how many more there are to join; a guest browses the public ones. */
357+function BrowseRow({ slug, count, guest = false }: { slug: string; count: number; guest?: boolean }) {
358+ return (
359+ <ExtraRow to={`/${slug}/-/chat/browse`} icon={<Compass size={15} />} count={count > 0 ? count : undefined} countLabel="to join">
360+ {guest ? "Browse public channels" : "Browse channels"}
361+ </ExtraRow>
362+ );
363+}
364+
344365 function Empty({ children }: { children: ReactNode }) {
345366 return <li className="px-2 py-1 text-xs text-faint">{children}</li>;
346367 }
+25−3
123123 assert.deepEqual(parsed[2], { t: "code", lang: "ts", v: "const a = 1;" });
124124 });
125125
126−function entry(id: string, over: Partial<ChatSidebarEntry> & { kind?: "channel" | "dm"; last?: string } = {}): ChatSidebarEntry {
126+function entry(id: string, over: Partial<ChatSidebarEntry> & { kind?: "channel" | "dm"; last?: string; visibility?: "private" | "workspace" | "public" } = {}): ChatSidebarEntry {
127127 return {
128128 channel: {
129129 id,
131131 kind: over.kind ?? "channel",
132132 name: over.kind === "dm" ? null : id,
133133 topic: null,
134− private: false,
134+ private: over.visibility === "private",
135+ visibility: over.visibility ?? (over.kind === "dm" ? "private" : "workspace"),
135136 created_by: { kind: "user", id: "u1" },
136137 created_at: "2026-10-01T00:00:00Z",
137138 archived_at: null,
156157 entry("d3", { kind: "dm", title: "ana, reviewer", others: [ana, bot], last: "2026-10-03T00:00:00Z" }),
157158 entry("d4", { kind: "dm", title: "notes", others: [], last: "2026-10-01T00:00:00Z" }),
158159 ];
159− const { pinned, channels, dms } = sections(all);
160+ const { pinned, channels, publicChannels, dms } = sections(all);
160161 assert.deepEqual(pinned.map((e) => e.title), ["general"]);
161162 assert.deepEqual(channels.map((e) => e.title), ["noisy", "random"]);
163+ assert.deepEqual(publicChannels, []);
162164 assert.deepEqual(dms.map((e) => e.title), ["ana", "ana, reviewer", "reviewer", "notes"]);
163165 // An old conversation with an agent alone is left out until searched for or open.
164166 assert.deepEqual(filterEntries(all, "unread", "").map((e) => e.title), ["general", "noisy"]);
167169 assert.deepEqual(unreadTotals(all), { unread: 3, mentions: 1 });
168170 });
169171
172+test("the sidebar keeps public channels apart from the workspace's own", () => {
173+ const all = [
174+ entry("general"),
175+ entry("leads", { visibility: "private", unread: 1 }),
176+ entry("support", { visibility: "public", mentions: 2, unread: 2 }),
177+ entry("announcements", { visibility: "public" }),
178+ entry("launch", { visibility: "public", starred: true }),
179+ entry("d1", { kind: "dm", title: "ana", others: [ana] }),
180+ ];
181+ const { pinned, channels, publicChannels, dms } = sections(all);
182+ // Pinned keeps a public channel you pinned; the rest are by name in their own group.
183+ assert.deepEqual(pinned.map((e) => e.title), ["launch"]);
184+ assert.deepEqual(channels.map((e) => e.title), ["general", "leads"]);
185+ assert.deepEqual(publicChannels.map((e) => e.title), ["announcements", "support"]);
186+ assert.deepEqual(dms.map((e) => e.title), ["ana"]);
187+ // Filters reach both groups.
188+ assert.deepEqual(sections(filterEntries(all, "unread", "")).publicChannels.map((e) => e.title), ["support"]);
189+ assert.deepEqual(sections(filterEntries(all, "mentions", "")).channels, []);
190+});
191+
170192 test("an old conversation with an agent alone opens read-only, under the name the agent had", () => {
171193 const deleted = { ...bot, id: "a9", name: "triage", display_name: "Triage", deleted: true };
172194 const all = [
+12−4
77 * Message bodies are never HTML: they are parsed here into a small tree
88 * that React renders as text, so nothing anyone writes can run.
99 */
10−import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts";
10+import type { Channel, ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts";
1111
1212 /** Two messages from the same author this close together share one header. */
1313 export const GROUP_GAP_MS = 5 * 60 * 1000;
182182
183183 /**
184184 * The sidebar's sections, in the order they show: what is pinned (the
185− * `starred` preference), channels by name, then direct messages, the
186− * latest first.
185+ * `starred` preference), the workspace's own channels (workspace and
186+ * private) by name, its public channels by name, then direct messages, the
187+ * latest first. A pinned channel stays under Pinned, whatever it is.
187188 */
188189 export function sections(entries: ChatSidebarEntry[]): {
189190 pinned: ChatSidebarEntry[];
190191 channels: ChatSidebarEntry[];
192+ publicChannels: ChatSidebarEntry[];
191193 dms: ChatSidebarEntry[];
192194 } {
193195 const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title);
196198 (b.channel.last_message_at ?? b.channel.created_at).localeCompare(a.channel.last_message_at ?? a.channel.created_at);
197199 return {
198200 pinned: entries.filter((e) => e.starred).sort(byName),
199− channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName),
201+ channels: entries.filter((e) => !e.starred && e.channel.kind === "channel" && !isPublic(e.channel)).sort(byName),
202+ publicChannels: entries.filter((e) => !e.starred && e.channel.kind === "channel" && isPublic(e.channel)).sort(byName),
200203 // Your direct message with @g1t heads the list, whenever you last talked.
201204 dms: entries.filter((e) => !e.starred && e.channel.kind === "dm").sort((a, b) => Number(g1tEntry(b)) - Number(g1tEntry(a)) || byRecent(a, b)),
202205 };
203206 }
204207
208+/** Open to anyone on g1t, not only the workspace's members. */
209+function isPublic(channel: Pick<Channel, "private" | "visibility">): boolean {
210+ return !channel.private && channel.visibility === "public";
211+}
212+
205213 /** Whether a sidebar entry is the viewer's direct message with @g1t. */
206214 export function g1tEntry(entry: { channel: { kind: "channel" | "dm" }; others: readonly Pick<MemberProfile, "kind" | "id">[] }): boolean {
207215 return entry.channel.kind === "dm" && entry.others.length === 1 && entry.others[0]!.kind === "system" && entry.others[0]!.id === "g1t";

Comments

Sign in to comment.