Merge branch 'worktree-agent-ace9d6458cc8f30c0'
4 files+80−280/4 viewed
| 215 | 215 | | Section | What's in it | | |
| 216 | 216 | | --- | --- | | |
| 217 | 217 | | **Pinned** | Conversations you pinned (hover over one and choose the pin, or right-click it). Only when you have some. | | |
| 218 | − | | **Channels** | The channels you're in, by name, then **Browse channels** with how many more you could join. **+** creates one. | | |
| 218 | + | | **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. | | |
| 219 | + | | **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. | | |
| 219 | 220 | | **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.** | | |
| 220 | 221 | ||
| 221 | 222 | Unread conversations are in bold with a count; mentions of you are counted |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | /** | |
| 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 | |
| 48 | 49 | * stay folded on this device. All, Unread and Mentions filter every section. It refreshes | |
| 49 | 50 | * itself while the tab is shown, so unread counts move without a reload. | |
| 50 | 51 | */ | |
| ⋯ | |||
| 84 | 85 | // The open conversation stays listed even when it is an old one with an agent alone, which Jump to otherwise finds. | |
| 85 | 86 | const currentId = entries.find((e) => pathname === channelPath(slug, e.channel) || pathname.startsWith(`${channelPath(slug, e.channel)}/`))?.channel.id ?? null; | |
| 86 | 87 | const shown = filterEntries(entries, filter, query, currentId); | |
| 87 | − | const { pinned, channels, dms } = sections(shown); | |
| 88 | + | const { pinned, channels, publicChannels, dms } = sections(shown); | |
| 88 | 89 | const loading = !data; | |
| 89 | 90 | ||
| 90 | 91 | // Folded sections, remembered per workspace on this device. A folded | |
| ⋯ | |||
| 141 | 142 | onChange={(event) => setQuery(event.target.value)} | |
| 142 | 143 | onKeyDown={(event) => { | |
| 143 | 144 | if (event.key === "Enter") { | |
| 144 | − | const first = [...pinned, ...channels, ...dms][0]; | |
| 145 | + | const first = [...pinned, ...channels, ...publicChannels, ...dms][0]; | |
| 145 | 146 | if (first) { | |
| 146 | 147 | navigate(channelPath(slug, first.channel)); | |
| 147 | 148 | setQuery(""); | |
| ⋯ | |||
| 191 | 192 | {pinned.map(conversation)} | |
| 192 | 193 | </Section> | |
| 193 | 194 | )} | |
| 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're in none of its public channels yet.</Empty>} | |
| 217 | + | <BrowseRow slug={slug} count={sidebar.browsable} guest /> | |
| 218 | + | </> | |
| 219 | + | )} | |
| 220 | + | </Section> | |
| 221 | + | )} | |
| 210 | 222 | {(sidebar.invitations?.length ?? 0) > 0 && filter === "all" && !query && ( | |
| 211 | 223 | <Section id="invitations" title="Invitations" folded={folded} onFold={fold}> | |
| 212 | 224 | {sidebar.invitations!.map((invitation) => ( | |
| ⋯ | |||
| 341 | 353 | ); | |
| 342 | 354 | } | |
| 343 | 355 | ||
| 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 | + | ||
| 344 | 365 | function Empty({ children }: { children: ReactNode }) { | |
| 345 | 366 | return <li className="px-2 py-1 text-xs text-faint">{children}</li>; | |
| 346 | 367 | } | |
| 123 | 123 | assert.deepEqual(parsed[2], { t: "code", lang: "ts", v: "const a = 1;" }); | |
| 124 | 124 | }); | |
| 125 | 125 | ||
| 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 { | |
| 127 | 127 | return { | |
| 128 | 128 | channel: { | |
| 129 | 129 | id, | |
| ⋯ | |||
| 131 | 131 | kind: over.kind ?? "channel", | |
| 132 | 132 | name: over.kind === "dm" ? null : id, | |
| 133 | 133 | topic: null, | |
| 134 | − | private: false, | |
| 134 | + | private: over.visibility === "private", | |
| 135 | + | visibility: over.visibility ?? (over.kind === "dm" ? "private" : "workspace"), | |
| 135 | 136 | created_by: { kind: "user", id: "u1" }, | |
| 136 | 137 | created_at: "2026-10-01T00:00:00Z", | |
| 137 | 138 | archived_at: null, | |
| ⋯ | |||
| 156 | 157 | entry("d3", { kind: "dm", title: "ana, reviewer", others: [ana, bot], last: "2026-10-03T00:00:00Z" }), | |
| 157 | 158 | entry("d4", { kind: "dm", title: "notes", others: [], last: "2026-10-01T00:00:00Z" }), | |
| 158 | 159 | ]; | |
| 159 | − | const { pinned, channels, dms } = sections(all); | |
| 160 | + | const { pinned, channels, publicChannels, dms } = sections(all); | |
| 160 | 161 | assert.deepEqual(pinned.map((e) => e.title), ["general"]); | |
| 161 | 162 | assert.deepEqual(channels.map((e) => e.title), ["noisy", "random"]); | |
| 163 | + | assert.deepEqual(publicChannels, []); | |
| 162 | 164 | assert.deepEqual(dms.map((e) => e.title), ["ana", "ana, reviewer", "reviewer", "notes"]); | |
| 163 | 165 | // An old conversation with an agent alone is left out until searched for or open. | |
| 164 | 166 | assert.deepEqual(filterEntries(all, "unread", "").map((e) => e.title), ["general", "noisy"]); | |
| ⋯ | |||
| 167 | 169 | assert.deepEqual(unreadTotals(all), { unread: 3, mentions: 1 }); | |
| 168 | 170 | }); | |
| 169 | 171 | ||
| 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 | + | ||
| 170 | 192 | test("an old conversation with an agent alone opens read-only, under the name the agent had", () => { | |
| 171 | 193 | const deleted = { ...bot, id: "a9", name: "triage", display_name: "Triage", deleted: true }; | |
| 172 | 194 | const all = [ | |
| 7 | 7 | * Message bodies are never HTML: they are parsed here into a small tree | |
| 8 | 8 | * that React renders as text, so nothing anyone writes can run. | |
| 9 | 9 | */ | |
| 10 | − | import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts"; | |
| 10 | + | import type { Channel, ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | /** Two messages from the same author this close together share one header. */ | |
| 13 | 13 | export const GROUP_GAP_MS = 5 * 60 * 1000; | |
| ⋯ | |||
| 182 | 182 | ||
| 183 | 183 | /** | |
| 184 | 184 | * 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. | |
| 187 | 188 | */ | |
| 188 | 189 | export function sections(entries: ChatSidebarEntry[]): { | |
| 189 | 190 | pinned: ChatSidebarEntry[]; | |
| 190 | 191 | channels: ChatSidebarEntry[]; | |
| 192 | + | publicChannels: ChatSidebarEntry[]; | |
| 191 | 193 | dms: ChatSidebarEntry[]; | |
| 192 | 194 | } { | |
| 193 | 195 | const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title); | |
| ⋯ | |||
| 196 | 198 | (b.channel.last_message_at ?? b.channel.created_at).localeCompare(a.channel.last_message_at ?? a.channel.created_at); | |
| 197 | 199 | return { | |
| 198 | 200 | 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), | |
| 200 | 203 | // Your direct message with @g1t heads the list, whenever you last talked. | |
| 201 | 204 | dms: entries.filter((e) => !e.starred && e.channel.kind === "dm").sort((a, b) => Number(g1tEntry(b)) - Number(g1tEntry(a)) || byRecent(a, b)), | |
| 202 | 205 | }; | |
| 203 | 206 | } | |
| 204 | 207 | ||
| 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 | + | ||
| 205 | 213 | /** Whether a sidebar entry is the viewer's direct message with @g1t. */ | |
| 206 | 214 | export function g1tEntry(entry: { channel: { kind: "channel" | "dm" }; others: readonly Pick<MemberProfile, "kind" | "id">[] }): boolean { | |
| 207 | 215 | return entry.channel.kind === "dm" && entry.others.length === 1 && entry.others[0]!.kind === "system" && entry.others[0]!.id === "g1t"; | |
Comments
Sign in to comment.