Skip to content

Commit

Chat controls, public profiles, shadcn selects, and no Docs tab in a project

- Chat: owners decide who creates public and private channels, who renames and archives them, who adds emoji, and which channels new members join (Workspace settings > Chat); rename, archive and topic in a channel's details; Browse lists private channels you're in and archived ones; channel.updated live; typing in threads; session cards with a live state chip; agents update their own messages in place (update_as_agent) - Profiles: signed out, a public frame with no workspace sidebar; signed in, g1t's own pages light no mode and open no mode sidebar; a contribution calendar counted only in repositories the viewer can read (work: contributions) - Every native select is now the shadcn Select - Code is not docs: a project has no Docs tab - The rail's workspace avatar sits in the top bar's band; rail items centred

syntaqxcommitted Parentbd7b68eBrowse files
52 files+2130−3770/52 viewed
+72−7
8282 ## Channels
8383
8484 A channel is a named conversation, such as `#web`, `#releases` or
85−`#support`. Every workspace starts with `#general`.
85+`#support`. Every workspace starts with `#general`, and everyone new is put
86+in it the first time they open Chat (owners can choose other
87+[default channels](#workspace-chat-settings)). A private channel shows a
88+lock in place of `#`, in the sidebar, in Browse channels and at the top of
89+the channel.
8690
8791 | | Public channel | Private channel |
8892 | --- | --- | --- |
107111
108112 </Steps>
109113
114+A workspace's owners can keep creating public channels, private channels or
115+both to themselves ([chat settings](#workspace-chat-settings)). When they
116+have, **Create a channel** is turned off for everyone else and says why, and
117+someone who may create only one kind gets that kind.
118+
110119 ### Join, leave and browse
111120
112−- **Browse channels** lists every public channel you haven't joined, at
113− `g1t.sh/<workspace>/-/chat/browse`. Open one to read it, and choose
114− **Join** to post in it and see it in your sidebar.
121+- **Browse channels** lists every public channel, and the private ones
122+ you're in, at `g1t.sh/<workspace>/-/chat/browse`. Open one to read it, and
123+ choose **Join** to post in it and see it in your sidebar. A private channel
124+ you're not in never appears.
125+- **Archived**, on the same page, lists archived channels.
115126 - You can leave a channel, and join a public one again at any time.
116127 - When you join, everything already said counts as read, so a busy channel
117128 doesn't greet you with its whole history as unread.
118129
130+### Rename, archive and change the topic
131+
132+Open a channel's details (the members button at the top right) to change
133+it.
134+
135+| Change | Who can |
136+| --- | --- |
137+| **Topic** | Any member of the channel. |
138+| **Rename** | The channel's owners and the workspace's owners, or only the workspace's owners if they chose so in [chat settings](#workspace-chat-settings). The address follows the new name for everyone who has it open. |
139+| **Archive** | The same people as rename. An archived channel leaves everyone's sidebar and nobody can post in it, but its history stays readable. |
140+| **Unarchive** | The same people, from the archived channel itself or **Browse channels → Archived**. |
141+
142+`#general` is never renamed or archived. Everyone looking at a channel sees
143+a change to it the moment it is made.
144+
119145 ### Star and mute
120146
121147 - **Star** a channel to keep it at the top of your sidebar.
177203 as a person. If it is out of budget, it says so in the thread instead of
178204 answering, and tells you who can raise its limit.
179205
206+### Session cards
207+
208+When an agent starts a longer piece of work, it posts a **session card**:
209+the session's title, a line such as *Step 3 · 12 tools · $0.14*, and its
210+state. The card changes in place as the session moves, for everyone looking,
211+and opens the session's page in Agents.
212+
213+| State | Means |
214+| --- | --- |
215+| **Queued** | Waiting to start. |
216+| **Working** | Running now. The chip pulses softly. |
217+| **Waiting on helpers** | Another agent is doing part of the work. |
218+| **Needs approval** | Waiting for someone to approve a step. Shown in amber. |
219+| **Done** | Finished. Shown in green. |
220+| **Stopped** | Someone stopped it. |
221+| **Failed** | It could not finish. Shown in red. |
222+
180223 ## Live
181224
182225 Chat is live. Messages, edits and deletions appear for everyone in the
183226 channel the moment they happen, without reloading. You also see:
184227
185−- **Typing.** Who is writing right now, people and agents.
228+- **Typing.** Who is writing right now, people and agents, in the channel
229+ or in a thread.
230+- **Channel changes.** A new name, topic, or archiving.
231+- **Cards.** An agent's session card, or any other card, changing state.
186232 - **Read state.** Your sidebar shows unread counts per channel, and the
187233 channel scrolls to the first message you haven't read.
188234
256302
257303 ### Who can add emoji
258304
259−Owners choose, on the same page:
305+Owners choose in the workspace's [chat settings](#workspace-chat-settings):
260306
261307 | Setting | Means |
262308 | --- | --- |
263−| **Every member** (the default) | Anyone in the workspace can add emoji and aliases. |
309+| **Any member** (the default) | Anyone in the workspace can add emoji and aliases. |
264310 | **Owners only** | Members use the workspace's emoji; only owners add them. |
265311
266312 ### Remove an emoji
275321 move through it, Enter or Tab puts the emoji in, and Escape closes it. A whole
276322 `:shortcode:` you type yourself works too.
277323
324+## Workspace chat settings
325+
326+A workspace's owners decide what members can do in its chat, under
327+**Workspace → Settings → Chat** (`g1t.sh/<workspace>/-/settings/chat`).
328+Every member can open the page to see what is allowed; only owners can
329+change it. g1t enforces each setting, whichever app or API the request
330+comes from.
331+
332+| Setting | Choices |
333+| --- | --- |
334+| **Public channels** | Who can create them: **Any member** (the default) or **Owners only**. |
335+| **Private channels** | Who can create them: **Any member** (the default) or **Owners only**. |
336+| **Renaming and archiving** | **Channel owners and workspace owners** (the default) or **Workspace owners only**. Any member of a channel can still change its topic. |
337+| **Custom emoji** | Who can add them: **Any member** (the default) or **Owners only**. |
338+| **Default channels** | The public channels someone new is put in the first time they open Chat. `#general` unless owners choose others. People already in the workspace are not moved, and anyone can leave a default channel. |
339+
340+Changing a setting doesn't change what already exists: channels already
341+made stay, whoever made them.
342+
278343 ## Notifications
279344
280345 g1t tells you when something is for you, wherever you are in the app.
+2−0
2424
2525 - **Spaces** hold pages: one per team or project, plus one for the whole
2626 workspace.
27+- Docs is its own mode in the workspace's rail, not a tab in a project. To
28+ see one project's pages, filter Docs by that project.
2729 - **Pages** are edited together, live, with mentions of people, agents,
2830 issues, pull requests, channels and other pages.
2931 - Every page has **history**, **comments**, **backlinks** and **owners**.
+19−5
704704 **What it shows.** Your picture, name, username, pronouns, bio, location,
705705 website and when you joined; then your work in three tabs:
706706
707−- **Overview:** pull requests merged, open pull requests and issues
708− opened, and your most recent activity.
707+- **Overview:** your contribution calendar, then pull requests merged,
708+ open pull requests and issues opened, and your most recent activity.
709709 - **Pull requests** and **Issues:** everything you opened, and what g1t
710710 opened for you, newest first,
711711 with filters beside the list for state (open, closed, merged), type,
712712 repository and sort order. Add `?tab=pulls&state=merged` and the like to
713713 link to a filtered list.
714714
715+**The contribution calendar.** The last year as a square a day, a column a
716+week, shaded more strongly the more you did that day, with the total
717+above it ("128 contributions in the last year"). A contribution is an
718+issue or pull request you opened (or g1t opened for you) and a review you
719+gave. Days are counted in UTC. Hover over a square, or tap it, to see its
720+day and count. On a narrow screen the calendar scrolls sideways inside
721+its card, starting at today.
722+
715723 **Edit it** in [Settings → Profile](https://g1t.sh/settings/profile). Every
716724 field is optional. The bio takes up to 160 characters and is also what a
717725 link to your profile says. The website must be an `https://` address;
731739 | On the profile | Shown to a visitor when |
732740 | --- | --- |
733741 | An issue or pull request, and its title | They can read its repository: it is public, or they are a member of its workspace |
734−| The counts | Only what they could see is counted |
742+| The counts, and the contribution calendar | Only what they could see is counted |
735743 | A workspace | They are a member of it too, or you made a public project in it, whose page shows that already |
736744
737745 Someone signed out sees your public work and the workspaces where you made
738−a public project; nothing else. The link preview for a profile uses only
739−public work.
746+a public project; nothing else, and their calendar counts only work in
747+public repositories. The link preview for a profile uses only public work.
748+
749+**How it is laid out.** Signed out, a profile, Explore and Search are
750+shown with g1t's public top bar (search, Explore, signing in) and the page
751+at full width, with no workspace sidebar. Signed in, the rail stays, but
752+no mode is lit and no mode's sidebar opens beside these pages: they are
753+nobody's workspace, and the profile's own left column says whose it is.
740754
741755 ### The card over a name
742756
+12−12
1−import { ArrowUpRight, ChevronDown, LoaderCircle, Sparkles } from "lucide-react";
1+import { ArrowUpRight, LoaderCircle, Sparkles } from "lucide-react";
22 import { type ReactNode, useEffect, useRef, useState } from "react";
33 import { Link, useFetcher } from "react-router";
44
88 import type { NotStarted } from "../lib/delegate";
99 import { Avatar } from "./ui";
1010 import { CONTROL } from "./ui/input";
11+import { SelectField } from "./ui/select";
1112
1213 /** What the composer's action said back: why nothing opened, or the issue that opened without its agent. */
1314 export type ComposerResult = { error: string | null; notStarted: NotStarted | null } | null;
4647 // Closed by Escape or by a click outside it, as a menu is.
4748 useEffect(() => {
4849 if (!open) return;
49− const onKey = (event: KeyboardEvent) => event.key === "Escape" && setOpen(false);
50+ // A select's list, open inside it, takes its own Escape and clicks.
51+ const onKey = (event: KeyboardEvent) => event.key === "Escape" && !event.defaultPrevented && setOpen(false);
5052 const onClick = (event: MouseEvent) => {
53+ if ((event.target as Element | null)?.closest?.("[data-radix-popper-content-wrapper]")) return;
5154 if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
5255 };
5356 document.addEventListener("keydown", onKey);
8689 <div className="mt-4 space-y-3">
8790 <label className="block">
8891 <span className="mb-1.5 block text-xs font-medium text-muted">Project</span>
89− <span className="relative block">
90− <select name="repo" required className={cn(CONTROL, "h-9 appearance-none pr-8")} defaultValue={repos[0] ? `${repos[0].namespace}/${repos[0].name}` : ""}>
91− {repos.map((repo) => (
92− <option key={`${repo.namespace}/${repo.name}`} value={`${repo.namespace}/${repo.name}`}>
93− {repo.name}
94− </option>
95− ))}
96− </select>
97− <ChevronDown size={14} className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-faint" />
98− </span>
92+ <SelectField
93+ name="repo"
94+ required
95+ defaultValue={repos[0] ? `${repos[0].namespace}/${repos[0].name}` : undefined}
96+ placeholder="Choose a project"
97+ options={repos.map((repo) => ({ value: `${repo.namespace}/${repo.name}`, label: repo.name }))}
98+ />
9999 </label>
100100 <label className="block">
101101 <span className="mb-1.5 block text-xs font-medium text-muted">Title</span>
+15−14
1616 import { money } from "../lib/usage";
1717 import { Card } from "./billing";
1818 import { ErrorText, SubmitButton } from "./ui";
19+import { SelectField } from "./ui/select";
1920 import { Skeleton } from "./ui/skeleton";
2021
2122 const FIELD =
623624 <label className="block">
624625 <span className="text-xs text-muted">Tax ID</span>
625626 <span className="mt-1 flex gap-2">
626− <select name="taxIdType" defaultValue={details?.taxIdType ?? ""} className={`${FIELD} w-auto`} aria-label="Kind of tax ID">
627− {TAX_IDS.map(([value, label]) => (
628− <option key={value} value={value}>
629− {label}
630− </option>
631− ))}
632− </select>
627+ <SelectField
628+ name="taxIdType"
629+ defaultValue={details?.taxIdType ?? ""}
630+ aria-label="Kind of tax ID"
631+ className="h-auto w-auto shrink-0 px-2.5 py-1.5"
632+ options={TAX_IDS.map(([value, label]) => ({ value, label }))}
633+ />
633634 <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} />
634635 </span>
635636 {details?.taxId && details.taxIdStatus && (
642643 </label>
643644 <label className="block">
644645 <span className="text-xs text-muted">Invoice language</span>
645− <select name="language" defaultValue={details?.language ?? ""} className={`mt-1 ${FIELD}`}>
646− {LANGUAGES.map(([value, label]) => (
647− <option key={value} value={value}>
648− {label}
649− </option>
650− ))}
651− </select>
646+ <SelectField
647+ name="language"
648+ defaultValue={details?.language ?? ""}
649+ aria-label="Invoice language"
650+ className="mt-1 h-auto px-2.5 py-1.5"
651+ options={LANGUAGES.map(([value, label]) => ({ value, label }))}
652+ />
652653 </label>
653654 {!disabled && (
654655 <div className="flex items-end">
+55−6
9191 const ICON_BUTTON =
9292 "flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent";
9393
94−/** Making a channel: its name, what it is for, and whether it is private. */
94+/** Why someone may not make a channel: the workspace keeps it to its owners. */
95+export const OWNERS_ONLY_CHANNELS = "Only workspace owners can create channels here.";
96+
97+/**
98+ * What the viewer may make, from the workspace's chat settings (Settings,
99+ * Chat). Until the sidebar arrives, everything: the chat service checks.
100+ */
101+export function useChannelCreation(): { public: boolean; private: boolean; any: boolean } {
102+ const { sidebar } = useChatSidebar();
103+ const open = sidebar?.can?.create_public_channels ?? true;
104+ const closed = sidebar?.can?.create_private_channels ?? true;
105+ return { public: open, private: closed, any: open || closed };
106+}
107+
108+/**
109+ * Making a channel: its name, what it is for, and whether it is private.
110+ * Someone the workspace's settings do not let make one sees the button
111+ * turned off, with why; someone who may make only one kind gets that kind.
112+ */
95113 export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) {
96114 const [open, setOpen] = useState(false);
97115 const [name, setName] = useState("");
101119 const [busy, setBusy] = useState(false);
102120 const send = useChatSend(slug);
103121 const navigate = useNavigate();
122+ const may = useChannelCreation();
123+ // Only one kind allowed: that kind, and the switch says why it is fixed.
124+ const isPrivate = !may.public ? true : !may.private ? false : secret;
104125 const clean = channelName(name);
105126 const submit = async () => {
106127 if (!clean) return setError("Give the channel a name.");
107128 setBusy(true);
108129 setError(null);
109− const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: secret });
130+ const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: isPrivate });
110131 setBusy(false);
111132 if (!made.ok) return setError(made.error.message);
112133 setOpen(false);
114135 setTopic("");
115136 navigate(made.value.to);
116137 };
138+ if (!may.any) {
139+ // Shown off, not hidden, so nobody wonders where it went.
140+ return (
141+ <Hint label={OWNERS_ONLY_CHANNELS} disabled>
142+ {variant === "icon" ? (
143+ <button type="button" aria-label="Create a channel" disabled className={`${ICON_BUTTON} opacity-40`}>
144+ <Plus size={14} />
145+ </button>
146+ ) : (
147+ <button type="button" disabled className={`${QUIET_BUTTON} opacity-50`}>
148+ <Lock size={14} />
149+ Create a channel
150+ </button>
151+ )}
152+ </Hint>
153+ );
154+ }
117155 return (
118156 <>
119157 {variant === "icon" ? (
144182 <label className="grid gap-1.5">
145183 <span className="text-sm font-medium text-muted">Name</span>
146184 <span className="flex h-10 items-center gap-1.5 rounded-md border border-line bg-bg px-3 focus-within:border-accent-dim">
147− {secret ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />}
185+ {isPrivate ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />}
148186 <input
149187 autoFocus
150188 value={name}
174212 </label>
175213 <label className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg/60 p-3">
176214 <span>
177− <span className="block text-sm font-medium">Private</span>
178− <span className="mt-0.5 block text-xs text-muted">Only people and agents you invite can find and read it.</span>
215+ <span className="flex items-center gap-1.5 text-sm font-medium">
216+ {isPrivate ? <Lock size={13} className="text-muted" /> : <Hash size={13} className="text-muted" />}
217+ {isPrivate ? "Private" : "Public"}
218+ </span>
219+ <span className="mt-0.5 block text-xs text-muted">
220+ {!may.public
221+ ? "Only workspace owners can create public channels here, so this one is private: only people and agents you invite can find and read it."
222+ : !may.private
223+ ? "Only workspace owners can create private channels here, so this one is public: anyone in the workspace can find, read and join it."
224+ : isPrivate
225+ ? "Only people and agents you invite can find and read it."
226+ : "Anyone in the workspace can find, read and join it. Turn on to keep it to the people and agents you invite."}
227+ </span>
179228 </span>
180− <Switch checked={secret} onCheckedChange={setSecret} aria-label="Private" />
229+ <Switch checked={isPrivate} onCheckedChange={setSecret} disabled={!may.public || !may.private} aria-label="Private" />
181230 </label>
182231 {error && <p className="text-sm text-danger">{error}</p>}
183232 <DialogFooter>
+296−32
11 import {
2+ Archive,
3+ ArchiveRestore,
4+ Bot,
25 ChevronLeft,
36 Copy,
47 Pencil,
2023 X,
2124 } from "lucide-react";
2225 import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
23−import { Link, useRouteLoaderData, useSearchParams } from "react-router";
26+import { Link, useNavigate, useRouteLoaderData, useSearchParams } from "react-router";
2427
25−import { type ChannelMember, type ChatLiveEvent, type ChatMessage, type MemberProfile, type MessageCard, type Result, hasCodeAccess } from "@g1t/contracts";
28+import {
29+ type Channel,
30+ type ChannelChange,
31+ type ChannelMember,
32+ type ChatLiveEvent,
33+ type ChatMessage,
34+ type MemberProfile,
35+ type MessageCard,
36+ type Result,
37+ hasCodeAccess,
38+} from "@g1t/contracts";
2639
2740 import { useChatData, useChatSend, useChatSidebar } from "./actions";
2841 import { Composer } from "./composer";
3346 import { localTime } from "../../lib/time-zone";
3447 import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile";
3548 import { MessageText, type TextContext } from "./text";
49+import {
50+ AlertDialog,
51+ AlertDialogAction,
52+ AlertDialogCancel,
53+ AlertDialogContent,
54+ AlertDialogDescription,
55+ AlertDialogFooter,
56+ AlertDialogHeader,
57+ AlertDialogTitle,
58+} from "../ui/alert-dialog";
3659 import { Badge, type BadgeTone } from "../ui/badge";
3760 import { Hint } from "../ui/hint";
3861 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
39−import { type Mentionable, type ShownMessage, mergeMessages, shownName, timeline } from "../../lib/chat";
62+import { type Mentionable, type ShownMessage, channelPath, mergeMessages, shownName, timeline } from "../../lib/chat";
63+import { sessionChip } from "../../lib/session-card";
4064 import { codeAccessPath } from "../../lib/workspace-nav";
4165 import { conversationCache } from "./conversation-cache";
4266 // Reactions and the workspace's own emoji (components/emoji).
97121 const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map());
98122 const [joined, setJoined] = useState(data.joined);
99123 const [members, setMembers] = useState<ChannelMember[]>(data.members);
124+ // The channel as it is now: renamed, its topic changed, archived, live.
125+ const [channel, setChannel] = useState<Channel>(data.channel);
126+ const [canManage, setCanManage] = useState(data.can_manage === true);
127+ const view = useMemo<Loaded>(() => ({ ...data, channel, title: channel.kind === "channel" ? `#${channel.name}` : data.title }), [data, channel]);
100128 // The server's answer behind a cached draw: merged in by id.
101129 useEffect(() => {
102130 const onFresh = (event: Event) => {
105133 setMessages((now) => mergeMessages(now, value.messages));
106134 setMembers(value.members);
107135 setJoined(value.joined);
136+ setChannel(value.channel);
137+ setCanManage(value.can_manage === true);
108138 };
109139 window.addEventListener("g1t:chat-fresh", onFresh);
110140 return () => window.removeEventListener("g1t:chat-fresh", onFresh);
113143 useEffect(() => {
114144 const path = window.location.pathname;
115145 const timer = setTimeout(
116− () => conversationCache.set(path, { ...data, messages: messages.filter((m) => !m.pending).slice(-80), members, joined }),
146+ () => conversationCache.set(path, { ...view, messages: messages.filter((m) => !m.pending).slice(-80), members, joined, can_manage: canManage }),
117147 400,
118148 );
119149 return () => clearTimeout(timer);
120− }, [data, messages, members, joined]);
150+ }, [view, messages, members, joined, canManage]);
151+ // Renamed while open, here or by someone else: the address follows the name.
152+ const navigate = useNavigate();
153+ const nameShown = useRef(data.channel.name);
154+ useEffect(() => {
155+ if (channel.kind !== "channel" || channel.name === nameShown.current || !slug) return;
156+ nameShown.current = channel.name;
157+ navigate(`${channelPath(slug, channel)}${window.location.search}`, { replace: true, preventScrollReset: true });
158+ }, [channel, slug, navigate]);
121159 const [info, setInfo] = useState(false);
122160 // A phone: back to the list, by the chevron or a swipe from the edge;
123161 // and a long-pressed message's actions.
269307 if (event.channel_id !== data.channel.id) return;
270308 if (event.member.kind === "user" && event.member.id === me?.id) return;
271309 setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() }));
310+ } else if (event.type === "channel.updated") {
311+ // Renamed, a new topic, archived or back: everyone looking sees it at once.
312+ if (event.channel.id !== data.channel.id) return;
313+ setChannel(event.channel);
314+ refresh();
272315 }
273316 },
274− [data.channel.id, threadId, me?.id, countReply],
317+ [data.channel.id, threadId, me?.id, countReply, refresh],
275318 );
276319 // Back after a drop: what was missed.
277320 const onReconnect = useCallback(async () => {
438481 setParams(next, { preventScrollReset: true, replace: !!threadId && !!id });
439482 };
440483
484+ const updateChannel = useCallback(
485+ async (change: ChannelChange): Promise<Result<unknown>> => {
486+ const done = await send<{ channel: Channel }>({ intent: "update_channel", channel_id: data.channel.id, ...change });
487+ if (done.ok) setChannel(done.value.channel);
488+ return done;
489+ },
490+ [send, data.channel.id],
491+ );
492+
441493 const toggleStar = async () => {
442494 await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred });
443495 refresh();
466518
467519 const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]);
468520 const typers = [...typing.values()].map((t) => t.member);
469− const name = data.channel.name ?? "";
521+ const name = channel.name ?? "";
522+ const archived = channel.kind === "channel" && !!channel.archived_at;
470523 const placeholder = isDm
471524 ? `Message ${others.map(shownName).join(", ") || "yourself"}`
472525 : `Message #${name}. @ a teammate or an agent`;
496549 <section aria-label={data.title} className="flex min-w-0 grow flex-col">
497550 <ChannelHeader
498551 onBack={back}
499− data={data}
552+ data={view}
500553 others={others}
501554 starred={starred}
502555 muted={muted}
526579 </button>
527580 </div>
528581 ) : (
529− <ConversationStart data={data} others={others} />
582+ <ConversationStart data={view} others={others} />
530583 )}
531584 {rows.map((row) =>
532585 row.kind === "day" ? (
552605 <div className="shrink-0 px-2 pb-3 sm:px-4 sm:pb-4 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))] in-data-[keyboard=open]:pb-2">
553606 <div className="mx-auto max-w-[56rem]">
554607 <TypingLine members={typers} />
555− {joined ? (
608+ {archived ? (
609+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
610+ <p className="flex items-center gap-2 text-sm text-muted">
611+ <Archive size={15} className="shrink-0 text-faint" />
612+ <span>
613+ <span className="font-medium text-fg">#{name}</span> is archived. Its history stays here to read; nobody can post in it.
614+ </span>
615+ </p>
616+ {canManage && (
617+ <button
618+ type="button"
619+ onClick={() => void updateChannel({ archived: false })}
620+ className="inline-flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised"
621+ >
622+ <ArchiveRestore size={15} />
623+ Unarchive
624+ </button>
625+ )}
626+ </div>
627+ ) : joined ? (
556628 <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
557629 ) : (
558630 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
581653 code={code}
582654 slug={slug}
583655 people={people}
584− joined={joined}
656+ joined={joined && !archived}
585657 onSend={(body) => void post(body, threadId)}
586658 onRetry={retry}
659+ onTyping={onTyping}
587660 draftKey={`${data.channel.id}:${threadId}`}
588661 />
589662 </SidePanel>
594667 ) : (
595668 info && (
596669 <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}>
597− <InfoPanel data={data} members={members} slug={slug} people={people} onAdded={(m) => setMembers((now) => [...now, m])} joined={joined} />
670+ <InfoPanel
671+ data={view}
672+ members={members}
673+ slug={slug}
674+ people={people}
675+ onAdded={(m) => setMembers((now) => [...now, m])}
676+ joined={joined}
677+ canManage={canManage}
678+ onUpdate={updateChannel}
679+ />
598680 </SidePanel>
599681 )
600682 )}
694776 {channel.kind === "channel" ? (
695777 <>
696778 <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold">
697− {channel.private ? <Lock size={15} className="text-faint" /> : <Hash size={16} className="text-faint" />}
779+ {channel.private ? (
780+ <Hint label="Private channel: only its members can find and read it">
781+ <span className="flex">
782+ <Lock size={15} className="text-faint" aria-label="Private channel" />
783+ </span>
784+ </Hint>
785+ ) : (
786+ <Hash size={16} className="text-faint" aria-label="Public channel" />
787+ )}
698788 <span className="truncate">{channel.name}</span>
699789 </h1>
700790 <StarButton starred={starred} onClick={onStar} />
705795 </span>
706796 </Hint>
707797 )}
798+ {channel.archived_at && (
799+ <Badge tone="neutral" className="shrink-0">
800+ <Archive size={11} />
801+ Archived
802+ </Badge>
803+ )}
708804 {channel.topic && (
709805 <>
710806 <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" />
851947 task: <ListChecks size={16} />,
852948 deploy: <Rocket size={16} />,
853949 approval: <ShieldCheck size={16} />,
950+ session: <Bot size={16} />,
854951 };
855952
953+/**
954+ * An agent session's state, as a chip: Working pulses softly in lavender,
955+ * Needs approval is amber, Done green, Failed red, Stopped and Queued quiet.
956+ * The agent changes the card in place, so this follows the session live.
957+ */
958+function SessionStateChip({ state }: { state: string }) {
959+ const chip = sessionChip(state);
960+ return (
961+ <Badge tone={chip.tone} className="mt-0.5">
962+ {chip.live && (
963+ <span aria-hidden="true" className="relative flex size-1.5">
964+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-60 motion-reduce:animate-none" />
965+ <span className="relative inline-flex size-1.5 rounded-full bg-current" />
966+ </span>
967+ )}
968+ {state}
969+ </Badge>
970+ );
971+}
972+
856973 /** How loud a card's state reads, from what it says. */
857974 function stateTone(state: string): BadgeTone {
858975 const s = state.toLowerCase();
866983
867984 /** A card g1t or an agent posted: what it is about, a line of detail, and its state. */
868985 export function CardBox({ card, href }: { card: MessageCard; href: string | null }) {
986+ // An agent session's card: updated in place as the session moves (`message.updated`).
987+ const session = card.kind === "session";
869988 const inner = (
870989 <>
871− <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-raised text-muted group-hover:text-fg">
990+ <span
991+ className={`mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors group-hover:text-fg ${
992+ session && sessionChip(card.state).live ? "bg-accent/10 text-accent" : "bg-raised text-muted"
993+ }`}
994+ >
872995 {CARD_ICONS[card.kind] ?? <Sparkles size={16} />}
873996 </span>
874997 <span className="min-w-0 grow">
875998 <span className="line-clamp-2 text-sm font-medium text-fg">{card.title}</span>
876− {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted">{card.detail}</span>}
999+ {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted tabular-nums">{card.detail}</span>}
8771000 </span>
878− {card.state && (
879− <Badge tone={stateTone(card.state)} className="mt-0.5">
880− {card.state}
881− </Badge>
882− )}
1001+ {card.state &&
1002+ (session ? (
1003+ <SessionStateChip state={card.state} />
1004+ ) : (
1005+ <Badge tone={stateTone(card.state)} className="mt-0.5">
1006+ {card.state}
1007+ </Badge>
1008+ ))}
8831009 </>
8841010 );
8851011 const box = "group mt-1.5 flex max-w-xl items-start gap-3 rounded-xl border border-line bg-surface px-3.5 py-3 transition-colors";
12691395 joined,
12701396 onSend,
12711397 onRetry,
1398+ onTyping,
12721399 draftKey,
12731400 }: {
12741401 root: ShownMessage | null;
12811408 joined: boolean;
12821409 onSend: (body: string) => void;
12831410 onRetry: (message: ShownMessage) => void;
1411+ /** Says "is typing" to the others in the channel, as the main composer does. */
1412+ onTyping: () => void;
12841413 draftKey: string;
12851414 }) {
12861415 const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
13141443 </div>
13151444 {joined && (
13161445 <div className="shrink-0 p-3">
1317− <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} compact autoFocus />
1446+ <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} onTyping={onTyping} compact autoFocus />
1447+ </div>
1448+ )}
1449+ </>
1450+ );
1451+}
1452+
1453+/**
1454+ * A channel's name, topic and visibility, and changing them: any member
1455+ * edits the topic; renaming and archiving are for whoever the workspace's
1456+ * chat settings allow (`canManage`). #general keeps its name and stays.
1457+ */
1458+function ChannelAbout({
1459+ channel,
1460+ joined,
1461+ canManage,
1462+ onUpdate,
1463+}: {
1464+ channel: Channel;
1465+ joined: boolean;
1466+ canManage: boolean;
1467+ onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
1468+}) {
1469+ const [editing, setEditing] = useState<"name" | "topic" | null>(null);
1470+ const [value, setValue] = useState("");
1471+ const [error, setError] = useState<string | null>(null);
1472+ const [busy, setBusy] = useState(false);
1473+ const [confirm, setConfirm] = useState(false);
1474+ const general = channel.name === "general";
1475+ const archived = !!channel.archived_at;
1476+ const mayRename = canManage && !general && !archived;
1477+ const mayTopic = joined && !archived;
1478+ const save = async (change: ChannelChange) => {
1479+ setBusy(true);
1480+ setError(null);
1481+ const done = await onUpdate(change);
1482+ setBusy(false);
1483+ if (!done.ok) return setError(done.error.message);
1484+ setEditing(null);
1485+ setConfirm(false);
1486+ };
1487+ const start = (field: "name" | "topic") => {
1488+ setEditing(field);
1489+ setValue(field === "name" ? (channel.name ?? "") : (channel.topic ?? ""));
1490+ setError(null);
1491+ };
1492+ const edit = (field: "name" | "topic") => (
1493+ <form
1494+ className="mt-1.5 space-y-2"
1495+ onSubmit={(event) => {
1496+ event.preventDefault();
1497+ void save(field === "name" ? { name: value } : { topic: value });
1498+ }}
1499+ >
1500+ <span className="flex h-9 items-center gap-1.5 rounded-md border border-line bg-bg px-2.5 focus-within:border-accent-dim">
1501+ {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)}
1502+ <input
1503+ autoFocus
1504+ value={value}
1505+ onChange={(event) => setValue(event.target.value)}
1506+ onKeyDown={(event) => event.key === "Escape" && setEditing(null)}
1507+ maxLength={field === "name" ? 80 : 250}
1508+ placeholder={field === "name" ? "channel-name" : "What this channel is for"}
1509+ aria-label={field === "name" ? "Channel name" : "Topic"}
1510+ autoComplete="off"
1511+ data-1p-ignore
1512+ className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
1513+ />
1514+ </span>
1515+ <div className="flex justify-end gap-1.5">
1516+ <button type="button" onClick={() => setEditing(null)} className="h-7 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg">
1517+ Cancel
1518+ </button>
1519+ <button type="submit" disabled={busy} className="h-7 rounded-md bg-accent px-2.5 text-xs font-medium text-bg hover:bg-accent-hover disabled:opacity-50">
1520+ {busy ? "Saving…" : "Save"}
1521+ </button>
1522+ </div>
1523+ </form>
1524+ );
1525+ const editButton = (field: "name" | "topic", label: string) => (
1526+ <button
1527+ type="button"
1528+ onClick={() => start(field)}
1529+ aria-label={label}
1530+ className="rounded px-1.5 py-0.5 text-xs text-muted opacity-0 transition-opacity group-hover/about:opacity-100 hover:bg-raised hover:text-fg focus-visible:opacity-100 [@media(hover:none)]:opacity-100"
1531+ >
1532+ Edit
1533+ </button>
1534+ );
1535+ return (
1536+ <>
1537+ <section className="group/about rounded-xl border border-line bg-surface p-3.5">
1538+ <div className="flex items-center justify-between">
1539+ <h3 className="text-xs font-medium text-faint">Name</h3>
1540+ {mayRename && editing !== "name" && editButton("name", "Rename channel")}
13181541 </div>
1542+ {editing === "name" ? edit("name") : <p className="mt-1 text-sm text-fg-soft">#{channel.name}</p>}
1543+ <div className="mt-3 flex items-center justify-between">
1544+ <h3 className="text-xs font-medium text-faint">Topic</h3>
1545+ {mayTopic && editing !== "topic" && editButton("topic", "Edit topic")}
1546+ </div>
1547+ {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>}
1548+ <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
1549+ <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
1550+ {channel.private ? <Lock size={13} /> : <Hash size={13} />}
1551+ {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"}
1552+ </p>
1553+ {error && <p className="mt-2 text-xs text-danger">{error}</p>}
1554+ </section>
1555+ {canManage && !general && (
1556+ <button
1557+ type="button"
1558+ onClick={() => (archived ? void save({ archived: false }) : setConfirm(true))}
1559+ disabled={busy}
1560+ className="mt-2 flex h-8 w-full items-center justify-center gap-2 rounded-md text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
1561+ >
1562+ {archived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
1563+ {archived ? "Unarchive channel" : "Archive channel"}
1564+ </button>
13191565 )}
1566+ <AlertDialog open={confirm} onOpenChange={setConfirm}>
1567+ <AlertDialogContent>
1568+ <AlertDialogHeader>
1569+ <AlertDialogTitle>Archive #{channel.name}?</AlertDialogTitle>
1570+ <AlertDialogDescription>
1571+ It leaves everyone&apos;s sidebar and nobody can post in it. Its history stays readable, and it can be unarchived from Browse
1572+ channels, Archived.
1573+ </AlertDialogDescription>
1574+ </AlertDialogHeader>
1575+ <AlertDialogFooter>
1576+ <AlertDialogCancel>Cancel</AlertDialogCancel>
1577+ <AlertDialogAction
1578+ onClick={(event) => {
1579+ event.preventDefault();
1580+ void save({ archived: true });
1581+ }}
1582+ disabled={busy}
1583+ >
1584+ {busy ? "Archiving…" : "Archive"}
1585+ </AlertDialogAction>
1586+ </AlertDialogFooter>
1587+ </AlertDialogContent>
1588+ </AlertDialog>
13201589 </>
13211590 );
13221591 }
13281597 people,
13291598 onAdded,
13301599 joined,
1600+ canManage,
1601+ onUpdate,
13311602 }: {
13321603 data: Loaded;
13331604 members: ChannelMember[];
13351606 people: Mentionable[];
13361607 onAdded: (member: ChannelMember) => void;
13371608 joined: boolean;
1609+ /** Whether the viewer may rename, archive and unarchive it. */
1610+ canManage: boolean;
1611+ onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
13381612 }) {
13391613 const channel = data.channel;
13401614 const agents = members.filter((m) => m.member.kind === "agent");
13411615 const humans = members.filter((m) => m.member.kind === "user");
13421616 return (
13431617 <div className="min-h-0 grow overflow-y-auto p-4 [scrollbar-width:thin]">
1344− {channel.kind === "channel" && (
1345− <section className="rounded-xl border border-line bg-surface p-3.5">
1346− <h3 className="text-xs font-medium text-faint">Topic</h3>
1347− <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>
1348− <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
1349− <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
1350− {channel.private ? <Lock size={13} /> : <Hash size={13} />}
1351− {channel.private ? "Private: invited members only" : "Public to the workspace"}
1352− </p>
1353− </section>
1354− )}
1618+ {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />}
13551619 {agents.length > 0 && (
13561620 <section className="mt-5">
13571621 <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3>
+136−0
1+/**
2+ * A person's last year on their profile: a square a day, a column a week,
3+ * shaded in the accent by how much they did (issues and pull requests
4+ * opened, reviews given) on repositories the viewer can see. The layout is
5+ * lib/contribution-calendar.ts; the counts come from the work service's
6+ * `contributions`, which never counts work the viewer could not open.
7+ *
8+ * One hint serves every square: it follows the pointer, so a year of days
9+ * costs one tooltip, not 365.
10+ */
11+import { type PointerEvent, useEffect, useMemo, useRef, useState } from "react";
12+
13+import { type Level, buildCalendar, dayLabel, totalLabel } from "../lib/contribution-calendar";
14+import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
15+
16+/** A square's side and the space between squares, in pixels. */
17+const CELL = 10;
18+const GAP = 3;
19+const STEP = CELL + GAP;
20+
21+/** Each level's shade: the raised gray for nothing, then the accent, stronger with more. */
22+const SHADES: Record<Level, string> = {
23+ 0: "bg-raised",
24+ 1: "bg-accent/25",
25+ 2: "bg-accent/45",
26+ 3: "bg-accent/70",
27+ 4: "bg-accent",
28+};
29+
30+const WEEKDAYS = ["", "Mon", "", "Wed", "", "Fri", ""];
31+
32+type Hovered = { date: string; count: number; left: number; top: number };
33+
34+export function ContributionCalendar({
35+ days,
36+ total,
37+ today,
38+ from,
39+}: {
40+ days: readonly { date: string; count: number }[];
41+ total: number;
42+ /** `YYYY-MM-DD`, UTC: the last day shown. */
43+ today: string;
44+ /** The first day counted; a year before `today` when absent. */
45+ from?: string;
46+}) {
47+ const calendar = useMemo(() => buildCalendar(days, today, from), [days, today, from]);
48+ const scroller = useRef<HTMLDivElement>(null);
49+ const [hovered, setHovered] = useState<Hovered | null>(null);
50+
51+ // On a narrow screen the calendar scrolls inside its card; start at today.
52+ useEffect(() => {
53+ const element = scroller.current;
54+ if (element) element.scrollLeft = element.scrollWidth;
55+ }, []);
56+
57+ const over = (event: PointerEvent<HTMLDivElement>) => {
58+ const cell = (event.target as HTMLElement).closest<HTMLElement>("[data-date]");
59+ if (!cell) return setHovered(null);
60+ const date = cell.dataset.date!;
61+ if (hovered?.date === date) return;
62+ setHovered({ date, count: Number(cell.dataset.count ?? 0), left: cell.offsetLeft, top: cell.offsetTop });
63+ };
64+
65+ const heading = totalLabel(total);
66+ return (
67+ <section aria-label="Contributions" className="rounded-xl border border-line bg-surface/50 px-4 pt-3.5 pb-3">
68+ <h2 className="text-sm font-medium">{heading}</h2>
69+ <div ref={scroller} className="mt-3 overflow-x-auto overflow-y-hidden pb-1 [scrollbar-width:thin]">
70+ <div className="flex w-max text-[0.625rem] leading-none text-faint">
71+ {/* The weekdays, under the months' row. */}
72+ <div aria-hidden="true" className="mr-1.5 flex shrink-0 flex-col" style={{ gap: GAP, paddingTop: 16 }}>
73+ {WEEKDAYS.map((label, index) => (
74+ <span key={index} className="flex items-center" style={{ height: CELL }}>
75+ {label}
76+ </span>
77+ ))}
78+ </div>
79+ <div
80+ role="img"
81+ aria-label={heading}
82+ className="relative"
83+ onPointerOver={over}
84+ // A tap's pointer leaves as the finger lifts: its hint stays until the next tap elsewhere.
85+ onPointerLeave={(event) => event.pointerType === "mouse" && setHovered(null)}
86+ >
87+ <div aria-hidden="true" className="relative h-4" style={{ width: calendar.weeks.length * STEP - GAP }}>
88+ {calendar.months.map((month) => (
89+ <span key={`${month.week}:${month.label}`} className="absolute top-0" style={{ left: month.week * STEP }}>
90+ {month.label}
91+ </span>
92+ ))}
93+ </div>
94+ <div className="flex" style={{ gap: GAP }}>
95+ {calendar.weeks.map((week, column) => (
96+ <div key={column} className="flex flex-col" style={{ gap: GAP }}>
97+ {week.map((day, row) =>
98+ day ? (
99+ <span
100+ key={row}
101+ data-date={day.date}
102+ data-count={day.count}
103+ className={`block rounded-[2px] ${SHADES[day.level]} ${hovered?.date === day.date ? "ring-1 ring-fg/60" : ""}`}
104+ style={{ width: CELL, height: CELL }}
105+ />
106+ ) : (
107+ <span key={row} className="block" style={{ width: CELL, height: CELL }} />
108+ ),
109+ )}
110+ </div>
111+ ))}
112+ </div>
113+ {/* The one hint, over whichever square the pointer is on. */}
114+ <Tooltip open={hovered != null} onOpenChange={(open) => !open && setHovered(null)}>
115+ <TooltipTrigger asChild>
116+ <span
117+ aria-hidden="true"
118+ className="pointer-events-none absolute"
119+ style={{ left: hovered?.left ?? 0, top: hovered?.top ?? 0, width: CELL, height: CELL }}
120+ />
121+ </TooltipTrigger>
122+ <TooltipContent side="top">{hovered ? dayLabel(hovered.count, hovered.date) : null}</TooltipContent>
123+ </Tooltip>
124+ </div>
125+ </div>
126+ </div>
127+ <div aria-hidden="true" className="mt-2 flex items-center justify-end gap-1 text-[0.6875rem] text-faint">
128+ <span className="mr-1">Less</span>
129+ {([0, 1, 2, 3, 4] as Level[]).map((level) => (
130+ <span key={level} className={`block rounded-[2px] ${SHADES[level]}`} style={{ width: CELL, height: CELL }} />
131+ ))}
132+ <span className="ml-1">More</span>
133+ </div>
134+ </section>
135+ );
136+}
+8−13
66 import { addressActions, backupChoices } from "../lib/emails";
77 import type { EmailActionData } from "../lib/emails.server";
88 import { ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui";
9+import { SelectField } from "./ui/select";
910 import { SwitchCard } from "./ui/switch";
1011
1112 /** One small form posting one intent about one address. */
181182 </p>
182183 <Form method="post" className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end">
183184 <input type="hidden" name="intent" value="backup-email" />
184− <label className="block grow">
185− <span className="sr-only">Backup address</span>
186− <select
185+ <div className="block grow">
186+ <SelectField
187187 name="backup"
188+ aria-label="Backup address"
188189 defaultValue={backup?.email ?? ""}
189190 disabled={backups.length === 0}
190− className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors hover:border-line-strong focus:border-accent-dim disabled:opacity-60"
191− >
192− <option value="">Primary address only</option>
193− {backups.map((email) => (
194− <option key={email.email} value={email.email}>
195− {email.email}
196− </option>
197− ))}
198− </select>
199− </label>
191+ className="h-auto py-2"
192+ options={[{ value: "", label: "Primary address only" }, ...backups.map((email) => ({ value: email.email, label: email.email }))]}
193+ />
194+ </div>
200195 <SubmitButton variant="quiet" pending="Saving…" match={{ intent: "backup-email" }} disabled={backups.length === 0}>
201196 Save
202197 </SubmitButton>
+34−48
11 import { ImagePlus, Lock, Plus, Search, Trash2, Upload } from "lucide-react";
22 import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
3−import { useFetcher } from "react-router";
3+import { Link, useFetcher } from "react-router";
44
55 import { type CustomEmoji, type EmojiList, type EmojiUpload, MAX_EMOJI_BYTES, MAX_EMOJI_SIDE } from "@g1t/contracts";
66
1111 import { Button, EmptyState, ErrorText, TimeAgo, notACredential } from "../ui";
1212 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
1313 import { Hint } from "../ui/hint";
14+import { SelectField } from "../ui/select";
1415 import { cleanEmojiName, emojiNameProblem, standardCodes } from "../../lib/emoji";
1516
1617 /** What the page's action answers (routes/workspace/emoji.tsx). */
2223 /**
2324 * A workspace's own emoji: every one with its name, who added it and when;
2425 * adding one from an image, giving one another name, and removing them.
25− * Owners also choose who may add them.
26+ * Who may add them is a chat setting (routes/workspace/chat-settings.tsx).
2627 */
2728 export function EmojiSettings({ slug, list, usercontent, meId }: { slug: string; list: EmojiList; usercontent: string; meId: string }) {
2829 const [query, setQuery] = useState("");
99100 </section>
100101 )}
101102
102− {list.can_manage && <UploadSetting value={list.emoji_upload} />}
103+ {list.can_manage && <UploadSetting slug={slug} value={list.emoji_upload} />}
103104 </div>
104105 <UploadDialog open={adding} onOpenChange={setAdding} taken={taken} />
105106 <AliasDialog open={aliasing} onOpenChange={setAliasing} emoji={list.emoji} taken={taken} usercontent={usercontent} />
169170 }
170171
171172 /** Owners: who may add emoji. */
172−function UploadSetting({ value }: { value: EmojiUpload }) {
173− const fetcher = useFetcher<EmojiActionResult>();
174− const current = (fetcher.formData?.get("value") as EmojiUpload | null) ?? value;
175− const choices: { value: EmojiUpload; label: string; detail: string }[] = [
176− { value: "members", label: "Every member", detail: "Anyone in the workspace can add emoji and aliases." },
177− { value: "admins", label: "Owners only", detail: "Members use the workspace’s emoji; only owners add them." },
178− ];
173+/** Who may add emoji: one of the workspace's chat settings, changed in Settings, Chat. */
174+function UploadSetting({ slug, value }: { slug: string; value: EmojiUpload }) {
179175 return (
180− <section aria-labelledby="emoji-upload" className="rounded-xl border border-line p-4">
181− <h2 id="emoji-upload" className="text-sm font-medium text-fg">
182− Who can add emoji
183− </h2>
184− <p className="mt-1 text-xs text-muted">Whoever added an emoji, and owners, can remove it.</p>
185− <div role="radiogroup" aria-labelledby="emoji-upload" className="mt-3 grid gap-2 sm:grid-cols-2">
186− {choices.map((choice) => (
187− <button
188− key={choice.value}
189− type="button"
190− role="radio"
191− aria-checked={current === choice.value}
192− onClick={() => current !== choice.value && fetcher.submit({ intent: "setting", value: choice.value }, { method: "post" })}
193− className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
194− current === choice.value ? "border-accent/60 bg-accent/10" : "border-line hover:border-line-strong hover:bg-surface"
195− }`}
196− >
197− <span className="flex items-center gap-2 text-sm font-medium text-fg">
198− <span className={`size-3.5 rounded-full border ${current === choice.value ? "border-4 border-accent" : "border-line-strong"}`} aria-hidden />
199− {choice.label}
200− </span>
201− <span className="mt-0.5 block pl-5.5 text-xs text-muted">{choice.detail}</span>
202− </button>
203− ))}
176+ <section aria-labelledby="emoji-upload" className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line p-4">
177+ <div>
178+ <h2 id="emoji-upload" className="text-sm font-medium text-fg">
179+ Who can add emoji: {value === "admins" ? "owners only" : "any member"}
180+ </h2>
181+ <p className="mt-1 text-xs text-muted">Whoever added an emoji, and owners, can remove it. Who can add them is one of the workspace&apos;s chat settings.</p>
204182 </div>
205− {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>}
183+ <Link
184+ to={`/${slug}/-/settings/chat`}
185+ className="inline-flex h-8 items-center rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-surface"
186+ >
187+ Chat settings
188+ </Link>
206189 </section>
207190 );
208191 }
403386 }
404387 // eslint-disable-next-line react-hooks/exhaustive-deps
405388 }, [fetcher.state, fetcher.data]);
406− const picked = originals.find((e) => e.name === target);
407389 return (
408390 <Dialog open={open} onOpenChange={onOpenChange}>
409391 <DialogContent className="max-w-md">
411393 <DialogTitle>Add an alias</DialogTitle>
412394 <DialogDescription>Another name for an emoji: both show the same image. Removing the emoji removes its aliases.</DialogDescription>
413395 </DialogHeader>
414− <label className="block">
415− <span className="text-sm font-medium text-fg">Emoji</span>
416− <span className="mt-1.5 flex items-center gap-2 rounded-md border border-line-strong bg-bg px-2.5">
417− {picked && <CustomEmojiImage name={picked.name} file={picked.file} usercontent={usercontent} size={20} />}
418− <select value={target} onChange={(event) => setTarget(event.target.value)} className="min-w-0 grow bg-transparent py-1.5 font-mono text-sm text-fg outline-none">
419− {originals.map((e) => (
420− <option key={e.name} value={e.name}>
421− :{e.name}:
422− </option>
423− ))}
424− </select>
396+ <div className="block">
397+ <span id="alias-target-label" className="text-sm font-medium text-fg">
398+ Emoji
425399 </span>
426− </label>
400+ <SelectField
401+ aria-labelledby="alias-target-label"
402+ value={target}
403+ onValueChange={setTarget}
404+ className="mt-1.5 border-line-strong font-mono"
405+ itemClassName="font-mono"
406+ options={originals.map((e) => ({
407+ value: e.name,
408+ label: `:${e.name}:`,
409+ icon: <CustomEmojiImage name={e.name} file={e.file} usercontent={usercontent} size={18} />,
410+ }))}
411+ />
412+ </div>
427413 <label className="block">
428414 <span className="text-sm font-medium text-fg">Alias</span>
429415 <input
+32−26
77 import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
88 import { CheckboxOption } from "./ui/checkbox";
99 import { Hint } from "./ui/hint";
10+import { SelectField } from "./ui/select";
1011 import { G1T_INVITES, inviteFor, inviteKind, inviteLink, invitesPage, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites";
1112 import type { BringIntoChoices } from "../lib/invites.server";
1213
13−const SELECT =
14− "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim sm:w-auto";
14+const SELECT = "h-auto w-full py-2 sm:w-auto sm:min-w-40";
1515
1616 const TONE: Record<"pending" | "done" | "dead", string> = {
1717 pending: "border-accent/40 text-accent",
9999 {workspaces.length > 0 && (
100100 <label className="block">
101101 <span className="mb-1.5 block text-sm font-medium text-muted">Use</span>
102− <select name="charge" className={SELECT}>
103− <option value="mine">Your invites</option>
104− {workspaces
105− .filter((workspace) => workspace.allowance.remaining !== 0)
106− .map((workspace) => (
107− <option key={workspace.slug} value={workspace.slug}>
108− {workspace.slug}'s invites
109− </option>
110− ))}
111− </select>
102+ <SelectField
103+ name="charge"
104+ defaultValue="mine"
105+ className={SELECT}
106+ options={[
107+ { value: "mine", label: "Your invites" },
108+ ...workspaces
109+ .filter((workspace) => workspace.allowance.remaining !== 0)
110+ .map((workspace) => ({ value: workspace.slug, label: `${workspace.slug}'s invites` })),
111+ ]}
112+ />
112113 </label>
113114 )}
114115 <SubmitButton match={{ intent: "create-invite" }} pending="Creating…">
130131 <div className="flex flex-col gap-3 pl-6 sm:flex-row sm:items-end">
131132 <label className="block">
132133 <span className="mb-1.5 block text-sm font-medium text-muted">Workspace</span>
133− <select name="join" required defaultValue="" className={SELECT}>
134− <option value="" disabled>
135− Choose a workspace
136− </option>
137− {bringInto.options.map((workspace) => (
138− <option key={workspace.slug} value={workspace.slug}>
139− {workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`}
140− </option>
141− ))}
142− </select>
134+ <SelectField
135+ name="join"
136+ required
137+ placeholder="Choose a workspace"
138+ className={SELECT}
139+ options={bringInto.options.map((workspace) => ({
140+ value: workspace.slug,
141+ label: workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`,
142+ }))}
143+ />
143144 </label>
144145 <label className="block">
145146 <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
146− <select name="join_role" defaultValue="member" className={SELECT}>
147− <option value="member">Member</option>
148− <option value="owner">Owner</option>
149− </select>
147+ <SelectField
148+ name="join_role"
149+ defaultValue="member"
150+ className={SELECT}
151+ options={[
152+ { value: "member", label: "Member" },
153+ { value: "owner", label: "Owner" },
154+ ]}
155+ />
150156 </label>
151157 </div>
152158 )}
+18−8
5252 );
5353 }
5454
55+/** A mode's name under its icon: centred on the rail, whatever its length. */
5556 function RailLabel({ current, children }: { current: boolean; children: ReactNode }) {
5657 return (
57− <span className={`text-[0.6875rem] leading-none font-medium transition-colors ${current ? "text-fg" : "text-faint group-hover:text-muted"}`}>
58+ <span
59+ className={`block w-full truncate text-center text-[0.6875rem] leading-none font-medium transition-colors ${current ? "text-fg" : "text-faint group-hover:text-muted"}`}
60+ >
5861 {children}
5962 </span>
6063 );
6164 }
6265
63−const RAIL_ITEM = "group flex w-full flex-col items-center gap-1 rounded-lg py-1 outline-none focus-visible:ring-2 focus-visible:ring-accent";
66+// Each item fills the rail's width and centres its icon and name on it.
67+const RAIL_ITEM =
68+ "group flex w-full flex-col items-center justify-center gap-1 rounded-lg py-1 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent";
6469
6570 function RailButton({ mode, to, current }: { mode: Mode; to: string; current: boolean }) {
6671 return (
9499 <Hint label={displayName(workspace)} side="right">
95100 <DropdownMenuTrigger
96101 aria-label={`${displayName(workspace)}: switch workspace`}
97− className="rounded-[11px] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:ring-2 data-[state=open]:ring-line-strong"
102+ className="flex rounded-[10px] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:ring-2 data-[state=open]:ring-line-strong"
98103 >
99− <Avatar name={workspace.slug} image={workspace.avatar} size={40} square />
104+ <Avatar name={workspace.slug} image={workspace.avatar} size={36} square />
100105 </DropdownMenuTrigger>
101106 </Hint>
102107 <DropdownMenuContent side="right" align="start" className="w-72 p-1.5">
265270 ];
266271 const workspaceMode: Mode = { key: "workspace", label: "Workspace", icon: <Building2 size={19} /> };
267272 return (
273+ // The right-hand rule is an inset shadow, not a border, so the rail's
274+ // whole width is its content box and every item centres on it exactly.
268275 <nav
269276 aria-label="Modes"
270277 style={{ width: RAIL_WIDTH, ["--rail-bg" as string]: "#0b0b0d" }}
271− className="flex h-full shrink-0 flex-col items-center overflow-y-auto border-r border-line bg-[var(--rail-bg)] pt-3 pb-3 [scrollbar-width:none]"
278+ className="flex h-full shrink-0 flex-col items-center overflow-y-auto bg-[var(--rail-bg)] pb-3 shadow-[inset_-1px_0_0_var(--color-line)] [scrollbar-width:none]"
272279 >
273− <WorkspaceButton user={user} workspace={workspace} />
274− <div className="mt-4 flex w-full flex-col items-center gap-2 px-2">
280+ {/* The top bar's height and rule, so the workspace sits on the same line as the sidebar's heading and the page's top bar. */}
281+ <div className="flex h-14 w-full shrink-0 items-center justify-center border-b border-line">
282+ <WorkspaceButton user={user} workspace={workspace} />
283+ </div>
284+ <div className="mt-3 flex w-full flex-col items-center gap-2 px-1.5">
275285 {modes.map((mode) => (
276286 <RailButton key={mode.key} mode={mode} to={modeHome(mode.key, workspace.slug)} current={here === mode.key} />
277287 ))}
278288 </div>
279− <div className="mt-auto flex w-full flex-col items-center gap-2 px-2 pt-4">
289+ <div className="mt-auto flex w-full flex-col items-center gap-2 px-1.5 pt-4">
280290 <RailButton mode={workspaceMode} to={modeHome("workspace", workspace.slug)} current={here === "workspace"} />
281291 {help}
282292 <div className="mt-1.5">{account}</div>
+7−7
66
77 import type { RunnersAction, RunnersData } from "../lib/runners.server";
88 import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui";
9+import { SelectField } from "./ui/select";
910 import { Switch } from "./ui/switch";
1011 import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
1112
152153 <input type="hidden" name="intent" value="token" />
153154 {!repoScoped && groups.length > 1 && (
154155 <Field label="Group">
155− <select name="group" className="h-9 rounded-md border border-line bg-bg px-2.5 text-sm" defaultValue={groups.find((g) => g.default)?.name}>
156− {groups.map((group) => (
157− <option key={group.id} value={group.name}>
158− {group.name}
159− </option>
160− ))}
161− </select>
156+ <SelectField
157+ name="group"
158+ defaultValue={groups.find((g) => g.default)?.name ?? groups[0]?.name}
159+ className="w-auto min-w-40"
160+ options={groups.map((group) => ({ value: group.name, label: group.name }))}
161+ />
162162 </Field>
163163 )}
164164 <SubmitButton match={{ intent: "token" }} pending="Making a token…">
+22−8
16091609 <BackRow to={`/${slug}/-/workspace`} label="Settings" context={slug} />
16101610 <div className="mt-2 space-y-px">
16111611 {owner && (
1612− <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
1612+ <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />} end>
16131613 General
16141614 </SidebarLink>
16151615 )}
1616+ <SidebarLink to={`/${slug}/-/settings/chat`} icon={<MessagesSquare size={15} />}>
1617+ Chat
1618+ </SidebarLink>
16161619 <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}>
16171620 Repositories
16181621 </SidebarLink>
17641767 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
17651768 }
17661769 // A person's profile, by their handle.
1767− if (parts[0] === "u" && parts[1]) {
1768− return <span className="truncate font-mono text-[0.8125rem] font-medium">@{parts[1]}</span>;
1769− }
1770+ if (parts[0] === "u" && parts[1]) return <ProfileCrumb username={parts[1]} />;
17701771 const [owner, second, third, fourth] = parts;
17711772 const trail: Crumb[] = [{ label: owner!, to: `/${owner}`, mono: true }];
17721773 if (second === "-") {
17921793 return <Trail trail={trail} />;
17931794 }
17941795
1796+/** A profile's place in the top bar: the person's avatar and @handle, from the page once it has loaded. */
1797+function ProfileCrumb({ username }: { username: string }) {
1798+ const page = useRouteLoaderData("routes/user") as { profile?: { username: string; avatar: string | null } } | undefined;
1799+ const profile = page?.profile?.username.toLowerCase() === username.toLowerCase() ? page.profile : null;
1800+ return (
1801+ <Link to={`/u/${profile?.username ?? username}`} aria-current="page" className="flex min-w-0 items-center gap-2 rounded px-1 py-0.5 transition-colors hover:bg-raised">
1802+ <Avatar name={profile?.username ?? username} image={profile?.avatar ?? null} size={20} />
1803+ <span className="truncate font-mono text-[0.8125rem] font-medium">@{profile?.username ?? username}</span>
1804+ </Link>
1805+ );
1806+}
1807+
17951808 /** One link of the trail; `short` is what a phone shows when it is the page itself. */
17961809 type Crumb = { label: string; to: string; mono?: boolean; short?: string };
17971810
20322045 const SIDEBAR_BOX = "h-full border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]";
20332046
20342047 /**
2035− * Which sidebar sits beside the rail: each mode's own, or none (Docs, and
2036− * the Inbox, which is a page of its own). Without a workspace (a visitor),
2037− * the one sidebar there always was.
2048+ * Which sidebar sits beside the rail: each mode's own, or none (Docs, the
2049+ * Inbox, which is a page of its own, and g1t's public pages, such as a
2050+ * profile, which are no workspace's and carry their own left column).
2051+ * Without a workspace (a visitor), the one sidebar there always was.
20382052 */
20392053 function sidebarFor(mode: ModeKey | null): Panel | null {
20402054 if (mode == null) return "code";
2041− if (mode === "docs" || mode === "inbox") return null;
2055+ if (mode === "docs" || mode === "inbox" || mode === "site") return null;
20422056 return mode;
20432057 }
20442058
+96−1
11 import { Check, ChevronDown, ChevronUp } from "lucide-react";
22 import { Select as Primitive } from "radix-ui";
3−import type { ComponentProps, ReactNode } from "react";
3+import { type ComponentProps, type ReactNode, useState } from "react";
44
55 import { cn } from "../../lib/cn";
66
157157 </Primitive.ScrollDownButton>
158158 );
159159 }
160+
161+export type SelectOption = { value: string; label: ReactNode; disabled?: boolean; description?: ReactNode; icon?: ReactNode };
162+
163+/** Radix items cannot have "" as a value; this stands in for it inside. */
164+const EMPTY = "__g1t_empty__";
165+const inside = (value: string | undefined) => (value === "" ? EMPTY : value);
166+const outside = (value: string) => (value === EMPTY ? "" : value);
167+
168+/**
169+ * A select for what used to be a native <select>: a list of options, posted
170+ * by `name` with its form. Options may have "" as their value (Any, None),
171+ * which posts "" as a native select would. `onValueChange` sees the new
172+ * value at once; `afterChange` runs once the form's fields hold it, so it
173+ * can submit the form (filters that apply as they change).
174+ */
175+export function SelectField({
176+ options,
177+ name,
178+ form,
179+ value,
180+ defaultValue,
181+ onValueChange,
182+ afterChange,
183+ placeholder,
184+ required,
185+ disabled,
186+ size,
187+ id,
188+ className,
189+ contentClassName,
190+ itemClassName,
191+ align,
192+ "aria-label": ariaLabel,
193+ "aria-labelledby": ariaLabelledBy,
194+}: {
195+ options: SelectOption[];
196+ name?: string;
197+ form?: string;
198+ value?: string;
199+ defaultValue?: string;
200+ onValueChange?: (value: string) => void;
201+ afterChange?: (value: string) => void;
202+ placeholder?: ReactNode;
203+ required?: boolean;
204+ disabled?: boolean;
205+ size?: "sm" | "default" | "lg";
206+ id?: string;
207+ className?: string;
208+ contentClassName?: string;
209+ itemClassName?: string;
210+ align?: "start" | "center" | "end";
211+ "aria-label"?: string;
212+ "aria-labelledby"?: string;
213+}) {
214+ const [own, setOwn] = useState(defaultValue);
215+ const current = value !== undefined ? value : own;
216+ // With a "" option, a hidden input posts the value, so "" posts as "".
217+ const empty = options.some((option) => option.value === "");
218+ return (
219+ <>
220+ <Select
221+ value={(empty ? inside(current) : current) ?? ""}
222+ onValueChange={(next) => {
223+ const chosen = outside(next);
224+ setOwn(chosen);
225+ onValueChange?.(chosen);
226+ if (afterChange) setTimeout(() => afterChange(chosen), 0);
227+ }}
228+ name={empty ? undefined : name}
229+ form={empty ? undefined : form}
230+ required={required}
231+ disabled={disabled}
232+ >
233+ <SelectTrigger id={id} size={size} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} className={className}>
234+ <SelectValue placeholder={placeholder} />
235+ </SelectTrigger>
236+ <SelectContent className={contentClassName} align={align}>
237+ {options.map((option) => (
238+ <SelectItem
239+ key={option.value}
240+ value={inside(option.value)!}
241+ disabled={option.disabled}
242+ description={option.description}
243+ icon={option.icon}
244+ className={itemClassName}
245+ >
246+ {option.label}
247+ </SelectItem>
248+ ))}
249+ </SelectContent>
250+ </Select>
251+ {empty && name && <input type="hidden" name={name} form={form} value={current ?? ""} disabled={disabled} />}
252+ </>
253+ );
254+}
+25−12
3232 import { cn } from "../lib/cn";
3333 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
3434 import { Hint } from "./ui/hint";
35+import { SelectField } from "./ui/select";
3536 import { Skeleton } from "./ui/skeleton";
3637
3738 const SELECT =
117118 };
118119 return (
119120 <Form ref={form} method="get" preventScrollReset className="flex flex-wrap items-center gap-2">
120− <select name="period" defaultValue={filters.period} onChange={submit} className={SELECT} aria-label="Period">
121− {(Object.keys(PERIODS) as Period[]).map((key) => (
122− <option key={key} value={key}>
123− {PERIODS[key]}
124− </option>
125− ))}
126− </select>
121+ <SelectField
122+ key={`period-${filters.period}`}
123+ name="period"
124+ defaultValue={filters.period}
125+ afterChange={submit}
126+ size="sm"
127+ className="w-auto"
128+ aria-label="Period"
129+ options={(Object.keys(PERIODS) as Period[]).map((key) => ({ value: key, label: PERIODS[key] }))}
130+ />
127131 {filters.period === "custom" ? (
128132 <span className="flex items-center gap-1 text-sm">
129133 <input type="date" name="from" defaultValue={filters.from} aria-label="From" className={SELECT} />
135139 )}
136140 <MultiSelect name="product" label="Products" options={products.map((p) => ({ value: p.key, label: p.label }))} chosen={filters.products} />
137141 {!fixedProject && <MultiSelect name="project" label="Projects" options={projects.map((p) => ({ value: p, label: p }))} chosen={filters.projects} />}
138− <select name="group" defaultValue={filters.group} onChange={submit} className={SELECT} aria-label="Group by">
139− <option value="product">Group by product</option>
140− {!fixedProject && <option value="project">Group by project</option>}
141− <option value="day">Group by day</option>
142− </select>
142+ <SelectField
143+ key={`group-${filters.group}`}
144+ name="group"
145+ defaultValue={filters.group}
146+ afterChange={submit}
147+ size="sm"
148+ className="w-auto"
149+ aria-label="Group by"
150+ options={[
151+ { value: "product", label: "Group by product" },
152+ ...(fixedProject ? [] : [{ value: "project", label: "Group by project" }]),
153+ { value: "day", label: "Group by day" },
154+ ]}
155+ />
143156 <input type="hidden" name="grain" value={filters.grain} />
144157 {filters.cumulative && <input type="hidden" name="cumulative" value="1" />}
145158 <noscript>
+9−3
1414 "/acme/web",
1515 "/acme/web/code",
1616 "/acme/web/issues/4",
17− "/explore",
18− "/search",
19− "/u/ada",
2017 "/acme",
2118 "/nothing/here/at/all",
2219 ]) {
3027 }
3128 });
3229
30+test("a visitor reads profiles, Explore and Search in the public frame, with no sidebar", () => {
31+ for (const path of ["/u/ada", "/u/ada/", "/explore", "/explore/", "/search"]) {
32+ assert.equal(usesAppShell(path, false), false, path);
33+ }
34+ // A workspace named like a page is still a workspace.
35+ assert.equal(usesAppShell("/u", false), true);
36+ assert.equal(usesAppShell("/explorers", false), true);
37+});
38+
3339 test("a visitor reads the policies, security, support and status in the marketing frame", () => {
3440 for (const path of ["/policies", "/policies/terms", "/policies/privacy/", "/security", "/support", "/status"]) {
3541 assert.equal(usesAppShell(path, false), false, path);
+13−6
22 * Which frame a page is drawn in, and what the sidebar offers someone who
33 * is not signed in.
44 *
5− * g1t is a sidebar site: projects, Explore, Search, profiles and
6− * not-found pages are drawn in the app's sidebar for everyone, so a
7− * visitor browsing public projects finds their way the same as a member.
8− * Only the front page and the pages about signing in or paying keep the
9− * marketing header and footer.
5+ * Someone signed in always gets the app's frame: the rail and its modes.
6+ * A visitor gets it on projects, workspaces and not-found pages, where the
7+ * sidebar is the project's own menu. g1t's public pages that belong to no
8+ * workspace (a person's profile, Explore, Search) are drawn for a visitor
9+ * in the public frame instead: the top bar with the mark, search and
10+ * signing in, the page at full width, and the footer. So do the front page
11+ * and the pages about signing in, paying and trust.
1012 */
1113
1214 import type { Abilities, Capability } from "@g1t/contracts";
3537 "/workspaces/new",
3638 ]);
3739
40+/** g1t's public pages that are no workspace's: a visitor reads them in the public frame. */
41+const PUBLIC_PAGES = new Set(["/explore", "/search"]);
42+
3843 /** Whether the page is drawn in the app's sidebar frame. */
3944 export function usesAppShell(pathname: string, signedIn: boolean): boolean {
4045 if (signedIn) return true;
4146 const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
42− if (MARKETING.has(path)) return false;
47+ if (MARKETING.has(path) || PUBLIC_PAGES.has(path)) return false;
48+ // A person's profile: theirs, not any workspace's.
49+ if (/^\/u\/[^/]+$/.test(path)) return false;
4350 if (path === "/oauth" || path.startsWith("/oauth/")) return false;
4451 // An invite link is the front door: the marketing frame, like /register.
4552 if (path.startsWith("/invite/")) return false;
+92−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { CALENDAR_WEEKS, buildCalendar, dayLabel, lastDay, levelOf, totalLabel } from "./contribution-calendar.ts";
5+
6+test("the calendar is 53 weeks of seven days, ending with today's week", () => {
7+ // 2026-10-08 is a Thursday.
8+ const calendar = buildCalendar([], "2026-10-08", "2025-10-09");
9+ assert.equal(calendar.weeks.length, CALENDAR_WEEKS);
10+ for (const week of calendar.weeks) assert.equal(week.length, 7);
11+ const last = calendar.weeks.at(-1)!;
12+ assert.equal(last[4]?.date, "2026-10-08");
13+ // Friday and Saturday are still to come.
14+ assert.equal(last[5], null);
15+ assert.equal(last[6], null);
16+ // Sunday at the top of every column.
17+ assert.equal(new Date(`${last[0]!.date}T00:00:00Z`).getUTCDay(), 0);
18+ // Days before the year starts are blank; the year has 365 days in it.
19+ const shown = calendar.weeks.flat().filter((day) => day != null);
20+ assert.equal(shown.length, 365);
21+ assert.equal(shown[0]!.date, "2025-10-09");
22+ assert.equal(calendar.max, 0);
23+ assert.ok(shown.every((day) => day.count === 0 && day.level === 0));
24+});
25+
26+test("each day is shaded in quarters of the busiest", () => {
27+ assert.equal(levelOf(0, 10), 0);
28+ assert.equal(levelOf(1, 10), 1);
29+ assert.equal(levelOf(3, 10), 2);
30+ assert.equal(levelOf(6, 10), 3);
31+ assert.equal(levelOf(10, 10), 4);
32+ assert.equal(levelOf(5, 0), 0);
33+ const calendar = buildCalendar(
34+ [
35+ { date: "2026-10-01", count: 8 },
36+ { date: "2026-10-02", count: 1 },
37+ // Outside the year: neither shown nor the busiest.
38+ { date: "2024-01-01", count: 99 },
39+ ],
40+ "2026-10-08",
41+ "2025-10-09",
42+ );
43+ assert.equal(calendar.max, 8);
44+ const days = calendar.weeks.flat();
45+ assert.deepEqual(days.find((day) => day?.date === "2026-10-01"), { date: "2026-10-01", count: 8, level: 4 });
46+ assert.equal(days.find((day) => day?.date === "2026-10-02")?.level, 1);
47+});
48+
49+test("months are named over the week they start in", () => {
50+ const { months, weeks } = buildCalendar([], "2026-10-08", "2025-10-09");
51+ // The year starts partway through October, which still gets its name:
52+ // November is three weeks on, room enough for both.
53+ assert.deepEqual(months[0], { label: "Oct", week: 0 });
54+ assert.deepEqual(
55+ months.map((month) => month.label),
56+ ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct"],
57+ );
58+ for (const { label, week } of months.slice(1)) {
59+ assert.ok(weeks[week]!.some((day) => day?.date.endsWith("-01")), label);
60+ }
61+ // Increasing, and never two in one column.
62+ for (let i = 1; i < months.length; i++) assert.ok(months[i]!.week > months[i - 1]!.week);
63+});
64+
65+test("a month that starts too close to the first column takes its place", () => {
66+ // From Tue 2025-10-21: October's column is the one before November's,
67+ // too narrow for its name.
68+ const squeezed = buildCalendar([], "2026-10-20", "2025-10-21");
69+ assert.deepEqual(squeezed.months[0], { label: "Nov", week: 1 });
70+});
71+
72+test("a year that starts early in a month keeps its first label", () => {
73+ // From Jan 2: January has room before February.
74+ const { months } = buildCalendar([], "2027-01-01", "2026-01-02");
75+ assert.equal(months[0]!.label, "Jan");
76+ assert.equal(months[1]!.label, "Feb");
77+});
78+
79+test("a bad date draws nothing", () => {
80+ assert.deepEqual(buildCalendar([], "not a date"), { weeks: [], months: [], max: 0 });
81+});
82+
83+test("hints and the heading read as sentences", () => {
84+ assert.equal(dayLabel(3, "2026-10-04"), "3 contributions on Oct 4, 2026");
85+ assert.equal(dayLabel(1, "2026-10-04"), "1 contribution on Oct 4, 2026");
86+ assert.equal(dayLabel(0, "2026-01-01"), "No contributions on Jan 1, 2026");
87+ assert.equal(totalLabel(0), "0 contributions in the last year");
88+ assert.equal(totalLabel(1), "1 contribution in the last year");
89+ assert.equal(totalLabel(1204), "1,204 contributions in the last year");
90+ assert.equal(lastDay("2025-10-09"), "2026-10-08");
91+ assert.equal(lastDay("nope"), null);
92+});
+108−0
1+/**
2+ * The contribution calendar on a profile, laid out: the last year as
3+ * columns of weeks (Sunday at the top), each day shaded by how much was
4+ * done that day, with the months named above the week each one starts in.
5+ * Pure, so the page and its tests agree; every date is UTC, as the work
6+ * service counts them (services/work/src/contributions.rs).
7+ */
8+
9+/** How many weeks the calendar shows: enough for a whole year and the current week. */
10+export const CALENDAR_WEEKS = 53;
11+
12+/** The shades a day can take: 0 is nothing, 4 the busiest. */
13+export type Level = 0 | 1 | 2 | 3 | 4;
14+
15+/** One square: a day of the year, or null outside it (before it starts, after today). */
16+export type CalendarDay = { date: string; count: number; level: Level } | null;
17+
18+export type Calendar = {
19+ /** `CALENDAR_WEEKS` columns of seven days, Sunday first. */
20+ weeks: CalendarDay[][];
21+ /** Each month's name, over the first column it starts in. */
22+ months: { label: string; week: number }[];
23+ /** The most on any one day. */
24+ max: number;
25+};
26+
27+const DAY_MS = 86_400_000;
28+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
29+
30+/** `YYYY-MM-DD` as milliseconds at its UTC midnight, or NaN. */
31+function parse(date: string): number {
32+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
33+ return match ? Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : Number.NaN;
34+}
35+
36+function format(ms: number): string {
37+ return new Date(ms).toISOString().slice(0, 10);
38+}
39+
40+/** The shade for `count` on a calendar whose busiest day had `max`: quarters of it. */
41+export function levelOf(count: number, max: number): Level {
42+ if (count <= 0 || max <= 0) return 0;
43+ return Math.min(4, Math.max(1, Math.ceil((count / max) * 4))) as Level;
44+}
45+
46+/**
47+ * The calendar ending with the week of `today` (`YYYY-MM-DD`). Days before
48+ * `from`, when given, and after `today` are left blank; days with nothing
49+ * in `days` are zero.
50+ */
51+export function buildCalendar(days: readonly { date: string; count: number }[], today: string, from?: string): Calendar {
52+ const end = parse(today);
53+ if (Number.isNaN(end)) return { weeks: [], months: [], max: 0 };
54+ const first = from && !Number.isNaN(parse(from)) ? parse(from) : end - 364 * DAY_MS;
55+ const counts = new Map<string, number>();
56+ for (const day of days) counts.set(day.date, (counts.get(day.date) ?? 0) + day.count);
57+ let max = 0;
58+ for (const [date, count] of counts) {
59+ const at = parse(date);
60+ if (at >= first && at <= end) max = Math.max(max, count);
61+ }
62+ // The Sunday that starts the first column.
63+ const start = end - new Date(end).getUTCDay() * DAY_MS - (CALENDAR_WEEKS - 1) * 7 * DAY_MS;
64+ const weeks: CalendarDay[][] = [];
65+ const months: Calendar["months"] = [];
66+ for (let week = 0; week < CALENDAR_WEEKS; week++) {
67+ const column: CalendarDay[] = [];
68+ for (let weekday = 0; weekday < 7; weekday++) {
69+ const at = start + (week * 7 + weekday) * DAY_MS;
70+ if (at < first || at > end) {
71+ column.push(null);
72+ continue;
73+ }
74+ const date = format(at);
75+ const count = counts.get(date) ?? 0;
76+ column.push({ date, count, level: levelOf(count, max) });
77+ }
78+ weeks.push(column);
79+ // A month is named over the first column holding its first day, or over
80+ // the first column at all when the year starts partway through it.
81+ const named = column.find((day) => day?.date.endsWith("-01")) ?? (months.length === 0 ? column.find((day) => day != null) : null);
82+ if (named) months.push({ label: MONTHS[Number(named.date.slice(5, 7)) - 1]!, week });
83+ }
84+ // A label squeezed in before the next one (too few columns to fit) goes.
85+ if (months.length > 1 && months[1]!.week - months[0]!.week < 3) months.shift();
86+ return { weeks, months, max };
87+}
88+
89+/** What a day's hint says: "3 contributions on Oct 4, 2026". */
90+export function dayLabel(count: number, date: string): string {
91+ const at = parse(date);
92+ const when = Number.isNaN(at)
93+ ? date
94+ : new Date(at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
95+ if (count === 0) return `No contributions on ${when}`;
96+ return `${count.toLocaleString("en-US")} ${count === 1 ? "contribution" : "contributions"} on ${when}`;
97+}
98+
99+/** The heading: "1,204 contributions in the last year". */
100+export function totalLabel(total: number): string {
101+ return `${total.toLocaleString("en-US")} ${total === 1 ? "contribution" : "contributions"} in the last year`;
102+}
103+
104+/** The day `from` (`YYYY-MM-DD`) plus 364: the last day of a year counted from it. */
105+export function lastDay(from: string): string | null {
106+ const at = parse(from);
107+ return Number.isNaN(at) ? null : format(at + 364 * DAY_MS);
108+}
+2−1
245245 assert.match(section, /name="also_join"/);
246246 // The workspace and role fields are drawn only once the box is ticked, so nothing else is sent.
247247 assert.match(section, /\{alsoJoin && \(\s*<div[^]*?name="join"[^]*?name="join_role"/);
248− assert.match(section, /<option value="" disabled>\s*Choose a workspace/);
248+ // No workspace is chosen for them: the select starts on its placeholder, and is required.
249+ assert.match(section, /name="join"\s*required\s*placeholder="Choose a workspace"/);
249250 assert.doesNotMatch(section, /bringInto\.chosen|Bring them into/);
250251 });
251252
+1−1
108108 */
109109 const READS = new Set(
110110 (
111− "account active_agents all_ids get list queue pulls_for_repos blame blob branches by_author by_repo catalog check_invite check_limit " +
111+ "account active_agents all_ids get list queue pulls_for_repos blame blob branches by_author by_repo contributions catalog check_invite check_limit " +
112112 "check_workspace_deletion check_workspace_rename collaborator_permission compare counts deleted deliveries " +
113113 "dependencies domains entitlements entity explore features git_access graph has_feature invoices ledger limit " +
114114 "limit_requests links log logs managed_pulls memories_by_id memory_context my_repo_invitations " +
+12−0
1+import assert from "node:assert/strict";
2+import { readFileSync } from "node:fs";
3+import { test } from "node:test";
4+
5+import { ROADMAP } from "./roadmap.ts";
6+
7+test("code is not docs: a project never gets a Docs tab", () => {
8+ // Docs is its own workspace mode, filtered by project there.
9+ assert.equal(ROADMAP.some((item) => item.section !== "Workspace" && /docs/i.test(item.key)), false);
10+ const nav = readFileSync(new URL("./project-nav.ts", import.meta.url), "utf8");
11+ assert.doesNotMatch(nav, /label: "Docs"|soon\("Code"\)/);
12+});
+1−1
4343 { label: "Tags", path: "tags" },
4444 { label: "Releases", path: "releases" },
4545 { label: "Compare", path: "compare" },
46− ...soon("Code"),
46+ // No Docs tab: code is not docs. Docs is a workspace mode, filtered by project there.
4747 ],
4848 },
4949 {
+2−14
7171 ],
7272 },
7373 // --- Code ---------------------------------------------------------------
74− {
75− key: "docs",
76− title: "Docs",
77− section: "Code",
78− summary: "Pages about the project that agents keep current as the code changes.",
79− why: "A wiki goes stale the day it is written. g1t's docs live with the code, and when a change makes a page wrong, an agent proposes the fix in the same pull request.",
80− plans: [
81− "Pages written in Markdown, kept in the repository",
82− "Agents update pages a change makes wrong, in the same pull request",
83− "Architecture pages drawn from the code itself",
84− "Search across every project's docs",
85− ],
86− today: { label: "Files", path: "code" },
87− },
74+ // Nothing: code is not docs. Docs is its own workspace mode, filterable
75+ // by project there, and a project never gets a Docs tab.
8876
8977 // --- Agents -------------------------------------------------------------
9078 // At work, Sessions and Memory are built (see project-nav.ts), and so is
+20−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { SESSION_STATES, sessionChip } from "./session-card.ts";
5+
6+test("a session card's state reads as what is happening", () => {
7+ assert.deepEqual(sessionChip("Working"), { tone: "accent", live: true });
8+ assert.deepEqual(sessionChip("Needs approval"), { tone: "warn", live: false });
9+ assert.deepEqual(sessionChip("Done"), { tone: "success", live: false });
10+ assert.deepEqual(sessionChip("Failed"), { tone: "danger", live: false });
11+ assert.deepEqual(sessionChip("Stopped"), { tone: "neutral", live: false });
12+ assert.deepEqual(sessionChip("Queued"), { tone: "neutral", live: false });
13+ assert.equal(sessionChip("Waiting on helpers").live, true);
14+});
15+
16+test("every state has a chip, whatever its case; an unknown one is quiet", () => {
17+ for (const state of SESSION_STATES) assert.ok(sessionChip(state.toUpperCase()));
18+ assert.deepEqual(sessionChip("Exploding"), { tone: "neutral", live: false });
19+ assert.deepEqual(sessionChip(null), { tone: "neutral", live: false });
20+});
+28−0
1+/**
2+ * An agent session's live card in chat (`card.kind === "session"`): the
3+ * agent posts it when the session starts and changes it in place as the
4+ * session moves (`message.updated`), so its state chip is always current.
5+ */
6+
7+/** The states an agent session's card shows. */
8+export const SESSION_STATES = ["Queued", "Working", "Waiting on helpers", "Needs approval", "Done", "Stopped", "Failed"] as const;
9+
10+export type SessionState = (typeof SESSION_STATES)[number];
11+
12+/** How a state reads: its badge tone, and whether it is happening now (a soft pulse). */
13+export type SessionChip = { tone: "neutral" | "accent" | "info" | "warn" | "success" | "danger"; live: boolean };
14+
15+const CHIPS: Record<string, SessionChip> = {
16+ queued: { tone: "neutral", live: false },
17+ working: { tone: "accent", live: true },
18+ "waiting on helpers": { tone: "info", live: true },
19+ "needs approval": { tone: "warn", live: false },
20+ done: { tone: "success", live: false },
21+ stopped: { tone: "neutral", live: false },
22+ failed: { tone: "danger", live: false },
23+};
24+
25+/** A session card's state chip; a state it does not know reads quietly. */
26+export function sessionChip(state: string | null | undefined): SessionChip {
27+ return CHIPS[String(state ?? "").trim().toLowerCase()] ?? { tone: "neutral", live: false };
28+}
+4−1
9595 assert.equal(modeOf("/", "acme"), "home");
9696 assert.equal(modeOf("/acme", "acme"), "home");
9797 assert.equal(modeOf("/acme/-/home", "acme"), "home");
98− assert.equal(modeOf("/explore", "acme"), "home");
98+ // g1t's own public pages light no mode.
99+ assert.equal(modeOf("/explore", "acme"), "site");
100+ assert.equal(modeOf("/u/ada", "acme"), "site");
101+ assert.equal(modeOf("/search.data", "acme"), "site");
99102 assert.equal(modeOf("/inbox", "acme"), "inbox");
100103 assert.equal(modeOf("/settings/emails", "acme"), "account");
101104 assert.equal(modeOf("/acme/-/chat/general", "acme"), "chat");
+9−5
171171 * The rail's modes (docs/WORKSPACE.md, "Shell"). Home, Code, Chat, Docs
172172 * and Agents are where work happens; the Inbox spans them; Workspace is the
173173 * workspace itself: its people, money, policies and settings. `account` is
174− * your own settings, under the avatar. Each has a sidebar of its own, or
174+ * your own settings, under the avatar. `site` is g1t's own public pages
175+ * (a profile, Explore, Search): no workspace's, so no mode is lit and no
176+ * mode's sidebar sits beside them. Each mode has a sidebar of its own, or
175177 * none, and which one is lit follows the address.
176178 */
177−export type ModeKey = "home" | "chat" | "docs" | "agents" | "code" | "inbox" | "workspace" | "account";
179+export type ModeKey = "home" | "chat" | "docs" | "agents" | "code" | "inbox" | "workspace" | "account" | "site";
178180
179181 /** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
180182 const PAGE_MODES: Record<string, ModeKey> = {
206208 emoji: "workspace",
207209 };
208210
209−/** First segments that are g1t's own pages, never a workspace: none is Code's. */
211+/** First segments that are g1t's own pages, never a workspace: none is any mode's. */
210212 const SITE_PAGES = new Set(["explore", "search", "support", "policies", "security", "status", "u", "invite", "workspaces"]);
211213
212214 /**
213215 * The mode a path is in, for the workspace `slug`. A repository (anyone's)
214216 * and a new project are Code's; g1t's own pages (Explore, search, a
215− * profile) sit under Home, which is never Code.
217+ * profile) are `site`: public, and no workspace's mode.
216218 */
217219 export function modeOf(pathname: string, slug: string | null): ModeKey {
218220 const path = pagePath(pathname);
220222 if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox";
221223 if (path === "/settings" || path.startsWith("/settings/")) return "account";
222224 const parts = path.split("/").filter(Boolean);
223− if (SITE_PAGES.has(parts[0] ?? "")) return "home";
225+ if (SITE_PAGES.has(parts[0] ?? "")) return "site";
224226 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
225227 if (parts.length === 1) return "home";
226228 if (parts[1] === "-") {
251253 return `/${slug}/-/workspace`;
252254 case "account":
253255 return "/settings";
256+ case "site":
257+ return "/explore";
254258 }
255259 }
256260
+2−0
119119 route("-/packages", "routes/workspace/packages.tsx"),
120120 route("-/packages/:ecosystem/*", "routes/workspace/package.tsx"),
121121 route("-/settings", "routes/workspace/settings.tsx"),
122+ // What members may do in chat: channels, emoji, default channels.
123+ route("-/settings/chat", "routes/workspace/chat-settings.tsx"),
122124 route("-/repositories", "routes/workspace/repositories.tsx"),
123125 // Agents mode: the fleet's runs first, then each of the workspace's own
124126 // agents (docs/WORKSPACE.md). `new` is no agent's handle.
+18−15
88 import { CommitAvatars, CommitNames } from "../../components/commit-person";
99 import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
1010 import { Hint } from "../../components/ui/hint";
11+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1112 import { page } from "../../lib/meta";
1213 import { showCommits } from "../../lib/commit-people.server";
1314 import { repos } from "../../lib/services.server";
7374 function RefPicker({ name, label, value, branches }: { name: string; label: string; value: string | null; branches: string[] }) {
7475 const options = value && !branches.includes(value) ? [value, ...branches] : branches;
7576 return (
76− <label className="inline-flex items-center gap-2 text-sm">
77− <span className="text-muted">{label}</span>
78− <select
79− name={name}
80− defaultValue={value ?? ""}
81− className="h-8 max-w-56 rounded-md border border-line bg-surface px-2 font-mono text-[0.8125rem] outline-none hover:border-line-strong focus:border-accent-dim"
82− >
83− {value == null && <option value="">Choose a branch</option>}
84− {options.map((option) => (
85− <option key={option} value={option}>
86− {option}
87− </option>
88− ))}
89− </select>
90− </label>
77+ <div className="inline-flex items-center gap-2 text-sm">
78+ <span className="text-muted" id={`${name}-label`}>
79+ {label}
80+ </span>
81+ <Select key={value ?? ""} name={name} defaultValue={value ?? undefined}>
82+ <SelectTrigger size="sm" aria-labelledby={`${name}-label`} className="w-auto max-w-56 font-mono">
83+ <SelectValue placeholder="Choose a branch" />
84+ </SelectTrigger>
85+ <SelectContent>
86+ {options.map((option) => (
87+ <SelectItem key={option} value={option} className="font-mono text-[0.8125rem]">
88+ {option}
89+ </SelectItem>
90+ ))}
91+ </SelectContent>
92+ </Select>
93+ </div>
9194 );
9295 }
9396
+10−11
88 import { page } from "../../lib/meta";
99 import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui";
1010 import { CheckboxOption } from "../../components/ui/checkbox";
11+import { SelectField } from "../../components/ui/select";
1112 import { LabelChip } from "../../components/labels";
1213 import { integrations, work } from "../../lib/services.server";
1314 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
165166 </fieldset>
166167 {loaderData.canTriage && loaderData.milestones.length > 0 && (
167168 <Field label="Milestone">
168− <select
169+ <SelectField
169170 name="milestone"
170171 defaultValue=""
171− className="h-9 w-full rounded-md border border-line bg-bg px-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
172− >
173− <option value="">None</option>
174− {loaderData.milestones.map((milestone) => (
175− <option key={milestone.number} value={milestone.number}>
176− {milestone.title}
177− {milestone.dueOn ? ` (due ${milestone.dueOn})` : ""}
178− </option>
179− ))}
180− </select>
172+ options={[
173+ { value: "", label: "None" },
174+ ...loaderData.milestones.map((milestone) => ({
175+ value: String(milestone.number),
176+ label: milestone.dueOn ? `${milestone.title} (due ${milestone.dueOn})` : milestone.title,
177+ })),
178+ ]}
179+ />
181180 </Field>
182181 )}
183182 {loaderData.canAssign && (
+23−15
66 import { page } from "../../lib/meta";
77 import { ActivationPrompt, CARD, SectionHeader } from "../../components/security-suite";
88 import { Hint } from "../../components/ui/hint";
9+import { SelectField } from "../../components/ui/select";
910 import { Switch } from "../../components/ui/switch";
1011 import { securitySuite } from "../../lib/services.server";
1112 import { assertSameOrigin, getViewer, managesSecurity, requireUser, unwrap } from "../../lib/session.server";
5859 return saved.ok ? { ok: true } : { ok: false, error: saved.error.message };
5960 }
6061
61−const SELECT = "h-9 w-full rounded-md border border-line bg-bg px-2.5 text-sm text-fg outline-none hover:border-line-strong focus:border-accent-dim sm:w-72";
62+const SELECT = "w-full sm:w-72";
6263
6364 export default function SecuritySettings({ loaderData, params }: Route.ComponentProps) {
6465 const { view, price, can, owner } = loaderData;
7879 <legend className="sr-only">Code scanning</legend>
7980 <p className="text-sm font-medium">Code scanning results</p>
8081 <p className="text-sm text-muted">The Code scanning check fails when a pull request brings new results on the lines it changes at this level.</p>
81− <select name="codeScanningGate" defaultValue={settings.codeScanningGate} className={SELECT} aria-label="When code scanning fails">
82− {CODE_SCANNING_GATES.map((gate) => (
83− <option key={gate.gate} value={gate.gate}>
84− {gate.label}
85− </option>
86− ))}
87− </select>
82+ <SelectField
83+ name="codeScanningGate"
84+ defaultValue={settings.codeScanningGate}
85+ disabled={disabled}
86+ className={SELECT}
87+ aria-label="When code scanning fails"
88+ options={CODE_SCANNING_GATES.map((gate) => ({ value: gate.gate, label: gate.label }))}
89+ />
8890 </fieldset>
8991 <fieldset disabled={disabled} className={`${CARD} space-y-3 p-4 disabled:opacity-60`}>
9092 <legend className="sr-only">Dependency review</legend>
100102 </label>
101103 <label className="block">
102104 <span className="mb-1.5 block text-xs font-medium text-muted">Fail on vulnerabilities of</span>
103− <select name="reviewFailOn" defaultValue={settings.reviewFailOn} className={SELECT}>
104− <option value="critical">Critical severity</option>
105− <option value="high">High severity or higher</option>
106− <option value="medium">Medium severity or higher</option>
107− <option value="low">Any severity</option>
108− <option value="none">Never fail on vulnerabilities</option>
109− </select>
105+ <SelectField
106+ name="reviewFailOn"
107+ defaultValue={settings.reviewFailOn}
108+ disabled={disabled}
109+ className={SELECT}
110+ options={[
111+ { value: "critical", label: "Critical severity" },
112+ { value: "high", label: "High severity or higher" },
113+ { value: "medium", label: "Medium severity or higher" },
114+ { value: "low", label: "Any severity" },
115+ { value: "none", label: "Never fail on vulnerabilities" },
116+ ]}
117+ />
110118 </label>
111119 <label className="block">
112120 <span className="mb-1.5 block text-xs font-medium text-muted">Licenses not allowed (SPDX ids)</span>
+25−5
33 * `/<workspace>` (as Docker Hub keeps people under `/u/`).
44 *
55 * The left column is who they are; the main column is their work, as
6− * tabs: an overview, then their pull requests and issues with a filter
7− * aside. Everything is filtered on the server by `work.byAuthor`, which
6+ * tabs: an overview (their contribution calendar first), then their pull
7+ * requests and issues with a filter aside. Everything is filtered on the server by `work.byAuthor`, which
88 * only ever returns work on repositories the viewer may read, and the
99 * workspaces shown are only those the viewer could know about anyway (see
1010 * `profile_workspaces` in services/identity/src/profiles.rs).
2323 import type { ReactNode } from "react";
2424 import { Form, Link, redirect, useNavigate, useSearchParams } from "react-router";
2525
26−import type { Authored, AuthoredItem, AuthoredSort, AuthoredState, Profile, StarredRepo } from "@g1t/contracts";
26+import type { Authored, AuthoredItem, AuthoredSort, AuthoredState, Contributions, Profile, StarredRepo } from "@g1t/contracts";
2727
2828 import type { Route } from "./+types/user";
2929 import { page } from "../lib/meta";
30+import { ContributionCalendar } from "../components/contribution-calendar";
3031 import { Avatar, Button, ButtonLink, EmptyState, TimeAgo } from "../components/ui";
3132 import { RadioGroup, RadioOption } from "../components/ui/radio-group";
3233 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
3334 import { IssueIcon, PullIcon } from "../components/work-icons";
35+import { lastDay } from "../lib/contribution-calendar";
3436 import { notFound } from "../lib/not-found.server";
3537 import { identity, repos, work } from "../lib/services.server";
3638 import { getViewer } from "../lib/session.server";
9698 .then((publicIn) => identity.profileWorkspaces(username, viewer, publicIn));
9799 // The card is drawn for no one in particular, so its version uses what
98100 // everyone sees; for a signed-out viewer that is these counts already.
99− const [authored, shown, starred] = await Promise.all([
101+ const [authored, shown, starred, year] = await Promise.all([
100102 work.byAuthor(username, viewer, filter),
101103 workspaces,
102104 // What they starred that the viewer can see, on its own tab.
103105 tab === "stars" ? repos.starred(username, viewer).catch(() => [] as StarredRepo[]) : ([] as StarredRepo[]),
106+ // Their year, on the overview: counted only where the viewer can see.
107+ tab === "overview"
108+ ? work
109+ .contributions(username, viewer)
110+ .then((result) => (result.ok ? result.value : null))
111+ .catch(() => null as Contributions | null)
112+ : null,
104113 ]);
105114 const activity = authored.ok ? authored.value : EMPTY;
115+ // The calendar ends today, as the work service counted it (UTC).
116+ const today = (year && lastDay(year.from)) || new Date().toISOString().slice(0, 10);
106117 return {
107118 profile,
108119 tab,
111122 failed: !authored.ok,
112123 workspaces: shown,
113124 starred,
125+ contributions: year ? { ...year, today } : null,
114126 isSelf: viewer?.username === profile.username,
115127 publicCounts: viewer ? null : activity.counts,
116128 };
324336
325337 type Data = Route.ComponentProps["loaderData"];
326338
327−function Overview({ profile, activity, failed }: Data) {
339+function Overview({ profile, activity, failed, contributions }: Data) {
328340 const { counts, items } = activity;
329341 const who = profile.name ?? profile.username;
330342 return (
331343 <div className="space-y-8">
344+ {contributions && (
345+ <ContributionCalendar
346+ days={contributions.days}
347+ total={contributions.total}
348+ today={contributions.today}
349+ from={contributions.from}
350+ />
351+ )}
332352 <dl className="grid grid-cols-1 gap-3 sm:grid-cols-3">
333353 <Stat
334354 label="Pull requests merged"
+26−13
55 import { page } from "../../lib/meta";
66 import { AuditTable } from "../../components/audit";
77 import { Button, ButtonLink, EmptyState, Field, Input } from "../../components/ui";
8+import { SelectField } from "../../components/ui/select";
89 import { type AuditFilters, filterHref, parseFilters, toQuery } from "../../lib/audit";
910 import { auditPage, auditRetention } from "../../lib/audit.server";
1011 import { repos } from "../../lib/services.server";
7374 );
7475 }
7576
76−const SELECT =
77− "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
77+/** As tall as the inputs beside it. */
78+const SELECT = "h-auto py-2";
7879
7980 export default function WorkspaceAudit({ loaderData }: Route.ComponentProps) {
8081 const { workspace, role, filters, entries, next, projects, retention } = loaderData;
101102 <FilterField label="Action" name="action" value={filters.action} placeholder="git.push" list="audit-actions" />
102103 <FilterField label="Project" name="project" value={filters.project} placeholder="Any" list="audit-projects" />
103104 <Field label="Outcome">
104− <select name="outcome" defaultValue={filters.outcome} className={SELECT}>
105− <option value="">Any</option>
106− <option value="allowed">Allowed</option>
107− <option value="denied">Denied</option>
108− </select>
105+ <SelectField
106+ key={`outcome-${filters.outcome}`}
107+ name="outcome"
108+ defaultValue={filters.outcome}
109+ className={SELECT}
110+ options={[
111+ { value: "", label: "Any" },
112+ { value: "allowed", label: "Allowed" },
113+ { value: "denied", label: "Denied" },
114+ ]}
115+ />
109116 </Field>
110117 <Field label="Who">
111− <select name="kind" defaultValue={filters.kind} className={SELECT}>
112− <option value="">Anyone</option>
113− <option value="agent">Agents</option>
114− <option value="person">People</option>
115− <option value="workspace">Workspace tokens</option>
116− </select>
118+ <SelectField
119+ key={`kind-${filters.kind}`}
120+ name="kind"
121+ defaultValue={filters.kind}
122+ className={SELECT}
123+ options={[
124+ { value: "", label: "Anyone" },
125+ { value: "agent", label: "Agents" },
126+ { value: "person", label: "People" },
127+ { value: "workspace", label: "Workspace tokens" },
128+ ]}
129+ />
117130 </Field>
118131 <Field label="From">
119132 <Input type="date" name="from" defaultValue={filters.from} />
+253−0
1+import { Hash } from "lucide-react";
2+import { useEffect, useState } from "react";
3+import { Form, Link, data, useNavigation } from "react-router";
4+
5+import type { ChannelManagers, ChatAllowed, ChatSettings, EmojiUpload } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/chat-settings";
8+import { Button, ErrorText } from "../../components/ui";
9+import { CheckboxOption } from "../../components/ui/checkbox";
10+import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
11+import { page } from "../../lib/meta";
12+import { chat } from "../../lib/services.server";
13+import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server";
14+
15+export function meta({ params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `Chat settings · ${params.owner} · g1t` });
17+}
18+
19+/**
20+ * Settings, Chat: what members may do in the workspace's chat. Every
21+ * member can read it, so nobody has to guess why a button is off; only
22+ * owners change it. The chat service enforces each setting.
23+ */
24+export async function loader({ params, context, request }: Route.LoaderArgs) {
25+ const viewer = requireUser(context, request);
26+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
27+ const slug = params.owner.toLowerCase();
28+ const view = await chat.chatSettings(slug, viewer).catch(() => null);
29+ if (!view?.ok) return { slug, view: null, error: view ? view.error.message : "Chat didn't answer. Try again in a moment." };
30+ return { slug, view: view.value, error: null };
31+}
32+
33+export async function action({ params, context, request }: Route.ActionArgs) {
34+ assertSameOrigin(request);
35+ const viewer = requireUser(context, request);
36+ const slug = params.owner.toLowerCase();
37+ const form = await request.formData();
38+ const who = (key: string): ChatAllowed => (form.get(key) === "owners" ? "owners" : "members");
39+ const change: Partial<ChatSettings> = {
40+ public_channels: who("public_channels"),
41+ private_channels: who("private_channels"),
42+ manage_channels: (form.get("manage_channels") === "owners" ? "owners" : "channel_owners") as ChannelManagers,
43+ emoji_upload: (form.get("emoji_upload") === "admins" ? "admins" : "members") as EmojiUpload,
44+ default_channels: form.getAll("default_channels").map(String),
45+ };
46+ try {
47+ const saved = await chat.setChatSettings(slug, viewer, change);
48+ return saved.ok ? { ok: true as const, settings: saved.value } : { ok: false as const, error: saved.error.message };
49+ } catch (error) {
50+ console.error("chat settings: the chat service did not answer", error);
51+ return { ok: false as const, error: "Chat didn't answer. Try again in a moment." };
52+ }
53+}
54+
55+const SECTION = "border-t border-line pt-8 first:border-t-0 first:pt-0";
56+
57+export default function ChatSettingsPage({ loaderData, actionData }: Route.ComponentProps) {
58+ const { slug, view } = loaderData;
59+ const navigation = useNavigation();
60+ const saving = navigation.state !== "idle" && navigation.formMethod === "POST";
61+ const [settings, setSettings] = useState<ChatSettings | null>(view?.settings ?? null);
62+ // What the service saved, once it answers.
63+ useEffect(() => {
64+ if (actionData?.ok) setSettings(actionData.settings);
65+ }, [actionData]);
66+ if (!view || !settings) {
67+ return (
68+ <div className="max-w-2xl">
69+ <Header />
70+ <ErrorText>{loaderData.error}</ErrorText>
71+ </div>
72+ );
73+ }
74+ const owner = view.can.manage_settings;
75+ const saved = actionData?.ok ? actionData.settings : view.settings;
76+ const changed = JSON.stringify(settings) !== JSON.stringify(saved);
77+ const set = <K extends keyof ChatSettings>(key: K, value: ChatSettings[K]) => setSettings((now) => (now ? { ...now, [key]: value } : now));
78+ const defaults = new Set(settings.default_channels);
79+ return (
80+ <div className="max-w-2xl">
81+ <Header />
82+ {!owner && (
83+ <p className="mb-8 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-muted">
84+ Only workspace owners can change these. They are shown so you know what members can do here.
85+ </p>
86+ )}
87+ <Form method="post" className="space-y-8">
88+ <fieldset disabled={!owner} className="space-y-8">
89+ <section className={SECTION}>
90+ <h2 className="font-medium">Creating channels</h2>
91+ <p className="mt-1.5 text-xs text-faint">Who can create channels. Whoever creates one becomes its first owner.</p>
92+ <div className="mt-5 grid gap-6 sm:grid-cols-2">
93+ <Choice
94+ name="public_channels"
95+ title="Public channels"
96+ value={settings.public_channels}
97+ onChange={(value) => set("public_channels", value as ChatAllowed)}
98+ options={[
99+ { value: "members", label: "Any member", description: "The default." },
100+ { value: "owners", label: "Owners only", description: "Members join and read public channels, but ask an owner for a new one." },
101+ ]}
102+ disabled={!owner}
103+ />
104+ <Choice
105+ name="private_channels"
106+ title="Private channels"
107+ value={settings.private_channels}
108+ onChange={(value) => set("private_channels", value as ChatAllowed)}
109+ options={[
110+ { value: "members", label: "Any member", description: "The default." },
111+ { value: "owners", label: "Owners only", description: "Only owners can make a channel that is kept to the people invited." },
112+ ]}
113+ disabled={!owner}
114+ />
115+ </div>
116+ </section>
117+
118+ <section className={SECTION}>
119+ <h2 className="font-medium">Renaming and archiving channels</h2>
120+ <p className="mt-1.5 text-xs text-faint">
121+ Who can rename a channel, archive it, and bring it back. Any member of a channel can change its topic. #general is never renamed or
122+ archived.
123+ </p>
124+ <div className="mt-5">
125+ <Choice
126+ name="manage_channels"
127+ title="Who can rename and archive"
128+ hideTitle
129+ value={settings.manage_channels}
130+ onChange={(value) => set("manage_channels", value as ChannelManagers)}
131+ options={[
132+ { value: "channel_owners", label: "Channel owners and workspace owners", description: "Whoever created a channel looks after it. The default." },
133+ { value: "owners", label: "Workspace owners only" },
134+ ]}
135+ disabled={!owner}
136+ />
137+ </div>
138+ </section>
139+
140+ <section className={SECTION}>
141+ <h2 className="font-medium">Custom emoji</h2>
142+ <p className="mt-1.5 text-xs text-faint">
143+ Who can add the workspace&apos;s own emoji and aliases. Whoever added one, and owners, can remove it.{" "}
144+ <Link to={`/${slug}/-/emoji`} className="text-muted underline decoration-line-strong underline-offset-2 hover:text-fg">
145+ Manage emoji
146+ </Link>
147+ </p>
148+ <div className="mt-5">
149+ <Choice
150+ name="emoji_upload"
151+ title="Who can add emoji"
152+ hideTitle
153+ value={settings.emoji_upload}
154+ onChange={(value) => set("emoji_upload", value as EmojiUpload)}
155+ options={[
156+ { value: "members", label: "Any member", description: "The default." },
157+ { value: "admins", label: "Owners only", description: "Members use the workspace's emoji; only owners add them." },
158+ ]}
159+ disabled={!owner}
160+ />
161+ </div>
162+ </section>
163+
164+ <section className={SECTION}>
165+ <h2 className="font-medium">Default channels</h2>
166+ <p className="mt-1.5 text-xs text-faint">
167+ The public channels someone new is put in the first time they open Chat. They can leave any of them. People already here are not
168+ moved.
169+ </p>
170+ {view.channels.length === 0 ? (
171+ <p className="mt-5 text-sm text-muted">No public channels yet.</p>
172+ ) : (
173+ <ul className="mt-5 grid gap-2.5 sm:grid-cols-2">
174+ {view.channels.map((channel) => (
175+ <li key={channel.id}>
176+ {defaults.has(channel.id) && <input type="hidden" name="default_channels" value={channel.id} />}
177+ <CheckboxOption
178+ checked={defaults.has(channel.id)}
179+ disabled={!owner}
180+ onCheckedChange={(on) =>
181+ set("default_channels", on === true ? [...settings.default_channels, channel.id] : settings.default_channels.filter((id) => id !== channel.id))
182+ }
183+ label={
184+ <span className="inline-flex items-center gap-1">
185+ <Hash size={13} className="text-faint" />
186+ {channel.name}
187+ </span>
188+ }
189+ description={channel.topic ?? undefined}
190+ />
191+ </li>
192+ ))}
193+ </ul>
194+ )}
195+ </section>
196+ </fieldset>
197+
198+ {owner && (
199+ <div className="flex items-center gap-3 border-t border-line pt-6">
200+ <Button type="submit" variant="accent" disabled={saving || !changed}>
201+ {saving ? "Saving…" : "Save"}
202+ </Button>
203+ {actionData && !actionData.ok && <ErrorText>{actionData.error}</ErrorText>}
204+ {actionData?.ok && !changed && (
205+ <p role="status" className="text-xs text-muted">
206+ Saved.
207+ </p>
208+ )}
209+ </div>
210+ )}
211+ </Form>
212+ </div>
213+ );
214+}
215+
216+function Header() {
217+ return (
218+ <header className="mb-8 border-b border-line pb-6">
219+ <h1 className="text-2xl font-semibold tracking-tight">Chat</h1>
220+ <p className="mt-1.5 text-sm text-muted">What members can do in the workspace&apos;s chat: channels, emoji, and where someone new starts.</p>
221+ </header>
222+ );
223+}
224+
225+/** One setting's choices, posted as `name`. */
226+function Choice({
227+ name,
228+ title,
229+ hideTitle,
230+ value,
231+ onChange,
232+ options,
233+ disabled,
234+}: {
235+ name: string;
236+ title: string;
237+ hideTitle?: boolean;
238+ value: string;
239+ onChange: (value: string) => void;
240+ options: { value: string; label: string; description?: string }[];
241+ disabled?: boolean;
242+}) {
243+ return (
244+ <div>
245+ {!hideTitle && <h3 className="mb-2.5 text-sm font-medium text-fg-soft">{title}</h3>}
246+ <RadioGroup name={name} value={value} onValueChange={onChange} aria-label={title} disabled={disabled}>
247+ {options.map((option) => (
248+ <RadioOption key={option.value} value={option.value} label={option.label} description={option.description} disabled={disabled} />
249+ ))}
250+ </RadioGroup>
251+ </div>
252+ );
253+}
+17−0
5050 member?: string;
5151 message_id?: string;
5252 emoji?: string;
53+ archived?: boolean;
5354 };
5455
5556 export async function action({ params, context, request }: Route.ActionArgs) {
9798 const made = await chat.createChannel(slug, viewer, { name, topic: sent.topic?.trim() || null, private: Boolean(sent.private) });
9899 return made.ok ? { ok: true, value: { channel: made.value, to: channelPath(slug, made.value) } } : made;
99100 }
101+ case "update_channel": {
102+ // A rename, a new topic, archiving or unarchiving; the chat service checks who may.
103+ const change: { name?: string; topic?: string | null; archived?: boolean } = {};
104+ if (typeof sent.name === "string") {
105+ const name = channelName(sent.name);
106+ if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } };
107+ if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) {
108+ return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } };
109+ }
110+ change.name = name;
111+ }
112+ if (typeof sent.topic === "string") change.topic = sent.topic.trim() || null;
113+ if (typeof sent.archived === "boolean") change.archived = sent.archived;
114+ const updated = await chat.updateChannel(slug, channel, viewer, change);
115+ return updated.ok ? { ok: true, value: { channel: updated.value, to: channelPath(slug, updated.value) } } : updated;
116+ }
100117 case "dm": {
101118 const members = await principalsFrom(slug, viewer, sent.members ?? []);
102119 if (members.length === 0) return { ok: false, error: { code: "invalid", message: "Choose who to message." } };
+44−19
1−import { Hash, Search } from "lucide-react";
1+import { Archive, Hash, Lock, Search } from "lucide-react";
22 import { useMemo, useState } from "react";
33 import { Link, useNavigate } from "react-router";
4−
5−import type { Channel } from "@g1t/contracts";
64
75 import type { Route } from "./+types/browse";
86 import { ChatUnavailable } from "../../../components/chat/empty";
9−import { CreateChannelButton, useChatSend } from "../../../components/chat/actions";
7+import { CreateChannelButton, OWNERS_ONLY_CHANNELS, useChannelCreation, useChatSend } from "../../../components/chat/actions";
108 import { channelPath } from "../../../lib/chat";
119 import { sidebarOrNull } from "../../../lib/chat.server";
1210 import { page } from "../../../lib/meta";
1715 return page(args, { title: `Browse channels · ${params.owner} · g1t` });
1816 }
1917
20−/** Every public channel in the workspace, joined or not. */
18+/**
19+ * Every public channel in the workspace, joined or not, and the private
20+ * ones the viewer is in (the chat service leaves out the rest). With
21+ * `?archived=1`, the archived ones instead.
22+ */
2123 export async function loader({ params, context, request }: Route.LoaderArgs) {
2224 const viewer = requireUser(context, request);
2325 const slug = params.owner.toLowerCase();
24− const [open, sidebar] = await Promise.all([chat.browse(slug, viewer).catch(() => null), sidebarOrNull(slug, viewer)]);
25− if (!open?.ok) return { slug, unavailable: true as const };
26− const mine = new Set((sidebar?.entries ?? []).map((entry) => entry.channel.id));
27− const listed = new Map<string, Channel>();
28− for (const channel of open.value) listed.set(channel.id, channel);
29− for (const entry of sidebar?.entries ?? []) if (entry.channel.kind === "channel") listed.set(entry.channel.id, entry.channel);
30− const channels = [...listed.values()].filter((channel) => !channel.archived_at).sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
31− return { slug, unavailable: false as const, channels, joined: [...mine] };
26+ const archived = new URL(request.url).searchParams.get("archived") === "1";
27+ const [listed, sidebar] = await Promise.all([chat.browse(slug, viewer, { archived }).catch(() => null), sidebarOrNull(slug, viewer)]);
28+ if (!listed?.ok) return { slug, unavailable: true as const };
29+ const joined = (sidebar?.entries ?? []).map((entry) => entry.channel.id);
30+ return { slug, unavailable: false as const, archived, channels: listed.value, joined };
3231 }
3332
3433 export default function Browse({ loaderData }: Route.ComponentProps) {
3635 const navigate = useNavigate();
3736 const send = useChatSend(loaderData.slug);
3837 const [joining, setJoining] = useState<string | null>(null);
38+ const may = useChannelCreation();
3939 const shown = useMemo(() => {
4040 if (loaderData.unavailable) return [];
4141 const q = query.trim().toLowerCase().replace(/^#/, "");
4747 <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-8 lg:py-12">
4848 <div className="flex flex-wrap items-center justify-between gap-3">
4949 <div>
50− <h1 className="text-2xl font-semibold tracking-tight">Browse channels</h1>
51− <p className="mt-1 text-sm text-muted">Every public channel in {loaderData.slug}. Anyone in the workspace can read and join them.</p>
50+ <h1 className="text-2xl font-semibold tracking-tight">{loaderData.archived ? "Archived channels" : "Browse channels"}</h1>
51+ <p className="mt-1 text-sm text-muted">
52+ {loaderData.archived
53+ ? "Channels no one posts in any more. Their history stays readable; whoever may archive a channel may bring it back."
54+ : `Every public channel in ${loaderData.slug}, which anyone in the workspace can read and join, and the private ones you're in.`}
55+ </p>
56+ {!loaderData.archived && !may.any && <p className="mt-1 text-xs text-faint">{OWNERS_ONLY_CHANNELS}</p>}
57+ </div>
58+ <div className="flex items-center gap-2">
59+ <Link
60+ to={loaderData.archived ? `/${loaderData.slug}/-/chat/browse` : `/${loaderData.slug}/-/chat/browse?archived=1`}
61+ className="inline-flex h-9 items-center gap-2 rounded-md px-3 text-sm text-muted transition-colors hover:bg-surface hover:text-fg"
62+ >
63+ {loaderData.archived ? <Hash size={15} /> : <Archive size={15} />}
64+ {loaderData.archived ? "Current channels" : "Archived"}
65+ </Link>
66+ {!loaderData.archived && <CreateChannelButton slug={loaderData.slug} variant="button" />}
5267 </div>
53− <CreateChannelButton slug={loaderData.slug} variant="button" />
5468 </div>
5569 <label className="mt-6 flex h-10 items-center gap-2 rounded-lg border border-line bg-surface px-3 text-sm focus-within:border-accent-dim">
5670 <Search size={15} className="text-faint" />
6377 />
6478 </label>
6579 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
66− {shown.length === 0 && <li className="px-4 py-10 text-center text-sm text-muted">No channel matches.</li>}
80+ {shown.length === 0 && (
81+ <li className="px-4 py-10 text-center text-sm text-muted">
82+ {query ? "No channel matches." : loaderData.archived ? "No archived channels." : "No channels yet."}
83+ </li>
84+ )}
6785 {shown.map((channel) => {
6886 const isIn = joined.has(channel.id);
6987 return (
7088 <li key={channel.id} className="flex items-center gap-3 px-4 py-3">
71− <Hash size={16} className="shrink-0 text-faint" />
89+ {channel.private ? (
90+ <Lock size={15} className="shrink-0 text-faint" aria-label="Private" />
91+ ) : (
92+ <Hash size={16} className="shrink-0 text-faint" aria-label="Public" />
93+ )}
7294 <div className="min-w-0 grow">
7395 <Link to={channelPath(loaderData.slug, channel)} className="font-medium hover:text-accent">
7496 {channel.name}
7597 </Link>
98+ {channel.private && <span className="ml-2 text-xs text-faint">Private</span>}
7699 {channel.topic && <p className="truncate text-sm text-muted">{channel.topic}</p>}
77100 </div>
78− {isIn ? (
101+ {loaderData.archived ? (
102+ <span className="text-xs text-faint">Archived</span>
103+ ) : isIn ? (
79104 <span className="text-xs text-faint">Joined</span>
80105 ) : (
81106 <button
+3−0
3030 older: string | null;
3131 /** Whether the viewer is in it: a public channel can be read before joining. */
3232 joined: boolean;
33+ /** Whether the viewer may rename, archive and unarchive it (the workspace's chat settings). */
34+ can_manage?: boolean;
3335 };
3436
3537 /**
8486 messages: history.ok ? history.value.messages : [],
8587 older: history.ok ? history.value.older : null,
8688 joined,
89+ can_manage: detail.value.can_manage === true,
8790 };
8891 }
8992
+8−7
3838 } from "../../components/ui/dropdown-menu";
3939 import { Hint } from "../../components/ui/hint";
4040 import { forgetWorkspace } from "../../lib/workspace-choice";
41−import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
41+import { Select, SelectContent, SelectField, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
4242 import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
4343 import { inviteLink, inviteState, moreInvitesMailto, workspaceInviteCopy } from "../../lib/invites";
4444 import { registrationMode } from "../../lib/registration.server";
397397 </div>
398398 <label className="block">
399399 <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
400− <select
400+ <SelectField
401401 name="role"
402402 defaultValue="member"
403− className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim sm:w-auto"
404− >
405− <option value="member">Member</option>
406− <option value="owner">Owner</option>
407− </select>
403+ className="h-auto w-full py-2 sm:w-auto sm:min-w-32"
404+ options={[
405+ { value: "member", label: "Member" },
406+ { value: "owner", label: "Owner" },
407+ ]}
408+ />
408409 </label>
409410 <div className="sm:pt-[1.625rem]">
410411 <SubmitButton match={{ action: "add" }} pending="Inviting…">
+69−33
66 import { host } from "../../components/deploy";
77 import { PinButton } from "../../components/pin-button";
88 import { EmptyState, Pill, TimeAgo, notACredential } from "../../components/ui";
9+import { SelectField } from "../../components/ui/select";
910 import { page as pageMeta } from "../../lib/meta";
1011 import {
1112 LANGUAGE_OF,
8586
8687 const CONTROL =
8788 "h-9 rounded-md border border-line bg-surface px-2.5 text-sm text-fg outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
89+/** A filter's select, on the same surface as the other controls. */
90+const FILTER = "w-auto bg-surface";
8891
8992 /** Its language, its kind when it is not an app, and its state, as small words. */
9093 function Facts({ project }: { project: Item }) {
331334 /
332335 </kbd>
333336 </div>
334− <select name="visibility" aria-label="Visibility" defaultValue={query.visibility} onChange={() => apply()} className={CONTROL}>
335− <option value="all">All visibility</option>
336− <option value="public">{option("Public", facets.visibility.public)}</option>
337− <option value="private">{option("Private", facets.visibility.private)}</option>
338− </select>
339− <select name="kind" aria-label="Kind" defaultValue={query.kind} onChange={() => apply()} className={CONTROL}>
340− <option value="all">All kinds</option>
341− <option value="app">{option("Apps", facets.kind.app)}</option>
342− <option value="library">{option("Libraries", facets.kind.library)}</option>
343− {(facets.kind.tool > 0 || query.kind === "tool") && <option value="tool">{option("Tools", facets.kind.tool)}</option>}
344− {(facets.kind.docs > 0 || query.kind === "docs") && <option value="docs">{option("Docs", facets.kind.docs)}</option>}
345− {(facets.kind.other > 0 || query.kind === "other") && <option value="other">{option("Other", facets.kind.other)}</option>}
346− </select>
337+ <SelectField
338+ key={`visibility-${query.visibility}`}
339+ name="visibility"
340+ aria-label="Visibility"
341+ defaultValue={query.visibility}
342+ afterChange={() => apply()}
343+ className={FILTER}
344+ options={[
345+ { value: "all", label: "All visibility" },
346+ { value: "public", label: option("Public", facets.visibility.public) },
347+ { value: "private", label: option("Private", facets.visibility.private) },
348+ ]}
349+ />
350+ <SelectField
351+ key={`kind-${query.kind}`}
352+ name="kind"
353+ aria-label="Kind"
354+ defaultValue={query.kind}
355+ afterChange={() => apply()}
356+ className={FILTER}
357+ options={[
358+ { value: "all", label: "All kinds" },
359+ { value: "app", label: option("Apps", facets.kind.app) },
360+ { value: "library", label: option("Libraries", facets.kind.library) },
361+ ...(facets.kind.tool > 0 || query.kind === "tool" ? [{ value: "tool", label: option("Tools", facets.kind.tool) }] : []),
362+ ...(facets.kind.docs > 0 || query.kind === "docs" ? [{ value: "docs", label: option("Docs", facets.kind.docs) }] : []),
363+ ...(facets.kind.other > 0 || query.kind === "other" ? [{ value: "other", label: option("Other", facets.kind.other) }] : []),
364+ ]}
365+ />
347366 {(facets.languages.length > 0 || query.language) && (
348− <select name="language" aria-label="Language" defaultValue={query.language ?? ""} onChange={() => apply()} className={CONTROL}>
349− <option value="">Any language</option>
350− {facets.languages.map((language) => (
351− <option key={language.name} value={language.name}>
352− {option(language.name, language.count)}
353− </option>
354− ))}
355− </select>
367+ <SelectField
368+ key={`language-${query.language ?? ""}`}
369+ name="language"
370+ aria-label="Language"
371+ defaultValue={query.language ?? ""}
372+ afterChange={() => apply()}
373+ className={FILTER}
374+ options={[
375+ { value: "", label: "Any language" },
376+ ...facets.languages.map((language) => ({ value: language.name, label: option(language.name, language.count) })),
377+ ]}
378+ />
356379 )}
357380 {(facets.archived > 0 || query.archived !== "hide") && (
358− <select name="archived" aria-label="Archived" defaultValue={query.archived} onChange={() => apply()} className={CONTROL}>
359− <option value="hide">Without archived</option>
360− <option value="include">With archived</option>
361− <option value="only">{option("Only archived", facets.archived)}</option>
362− </select>
381+ <SelectField
382+ key={`archived-${query.archived}`}
383+ name="archived"
384+ aria-label="Archived"
385+ defaultValue={query.archived}
386+ afterChange={() => apply()}
387+ className={FILTER}
388+ options={[
389+ { value: "hide", label: "Without archived" },
390+ { value: "include", label: "With archived" },
391+ { value: "only", label: option("Only archived", facets.archived) },
392+ ]}
393+ />
363394 )}
364395 {member && (
365396 <label className={`${CONTROL} flex cursor-pointer items-center gap-2 select-none`}>
414445 )}
415446 <div className="ml-auto flex items-center gap-2">
416447 {/* Part of the filters' form, beside the view it orders. */}
417− <select form="project-filters" name="sort" aria-label="Sort" defaultValue={query.sort} onChange={() => apply()} className={`${CONTROL} h-8`}>
418− {SORTS.map((sort) => (
419− <option key={sort.value} value={sort.value}>
420− {sort.label}
421− </option>
422− ))}
423− </select>
448+ <SelectField
449+ key={`sort-${query.sort}`}
450+ form="project-filters"
451+ name="sort"
452+ aria-label="Sort"
453+ defaultValue={query.sort}
454+ afterChange={() => apply()}
455+ size="sm"
456+ className={FILTER}
457+ align="end"
458+ options={SORTS.map((sort) => ({ value: sort.value, label: sort.label }))}
459+ />
424460 <nav aria-label="View" className="flex items-center gap-0.5 rounded-lg border border-line bg-bg p-0.5">
425461 {(
426462 [
+31−0
25382538 pub repos: Vec<AuthoredRepo>,
25392539 }
25402540
2541+/// How many days `contributions` covers: today and the 364 before it.
2542+pub const CONTRIBUTION_DAYS: u64 = 365;
2543+
2544+/// `contributions`: what a person did each day of the last year (issues
2545+/// and pull requests opened, reviews given), only on repositories `viewer`
2546+/// may read, for the calendar on their profile. Returns
2547+/// `Outcome<Contributions>`; not found for an account that does not exist.
2548+#[derive(Debug, Serialize, Deserialize)]
2549+pub struct ContributionsArgs {
2550+ pub username: String,
2551+ pub viewer: Viewer,
2552+}
2553+
2554+/// One day with something on it. Days with nothing are left out.
2555+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2556+pub struct ContributionDay {
2557+ /// `YYYY-MM-DD`, UTC.
2558+ pub date: String,
2559+ pub count: u32,
2560+}
2561+
2562+/// A person's year, as far as the viewer may see.
2563+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
2564+pub struct Contributions {
2565+ /// Oldest first.
2566+ pub days: Vec<ContributionDay>,
2567+ pub total: u32,
2568+ /// The first day counted, `YYYY-MM-DD`; the last is today.
2569+ pub from: String,
2570+}
2571+
25412572 #[cfg(test)]
25422573 mod required_tests {
25432574 use super::*;
+92−5
151151 can_manage: boolean;
152152 };
153153
154+/** Who may do something in a workspace's chat: every member, or only its owners. */
155+export type ChatAllowed = "members" | "owners";
156+
157+/**
158+ * Who may rename and archive a channel: its owners (whoever made it) and
159+ * the workspace's owners (the default), or the workspace's owners only.
160+ */
161+export type ChannelManagers = "channel_owners" | "owners";
162+
163+/**
164+ * A workspace's chat settings, which its owners choose (Workspace,
165+ * Settings, Chat). The chat service enforces each one; the page only
166+ * hides what the viewer may not do.
167+ */
168+export type ChatSettings = {
169+ /** Who may create public channels. */
170+ public_channels: ChatAllowed;
171+ /** Who may create private channels. */
172+ private_channels: ChatAllowed;
173+ /** Who may rename, archive and unarchive channels. */
174+ manage_channels: ChannelManagers;
175+ /** Who may add custom emoji (`admins` is the workspace's owners). */
176+ emoji_upload: EmojiUpload;
177+ /**
178+ * The public channels someone new is put in the first time they open
179+ * Chat, by id. #general unless the owners chose otherwise.
180+ */
181+ default_channels: string[];
182+};
183+
184+/** What the viewer may do in a workspace's chat, from its settings and their role. */
185+export type ChatPermissions = {
186+ create_public_channels: boolean;
187+ create_private_channels: boolean;
188+ add_emoji: boolean;
189+ /** Whether they may change the workspace's chat settings: owners only. */
190+ manage_settings: boolean;
191+};
192+
193+/** The chat settings page: the settings, what the viewer may do, and the public channels to choose defaults from. */
194+export type ChatSettingsView = {
195+ settings: ChatSettings;
196+ can: ChatPermissions;
197+ channels: Channel[];
198+};
199+
200+/** A change to a channel: its name, its topic, or whether it is archived. Anything left out stays. */
201+export type ChannelChange = { name?: string; topic?: string | null; archived?: boolean };
202+
154203 /** An image for a new emoji, as base64. Its type is read from its bytes, never taken from here. */
155204 export type EmojiFile = { data: string };
156205
175224 entries: ChatSidebarEntry[];
176225 /** Public channels in the workspace the viewer has not joined. */
177226 browsable: number;
227+ /** What the viewer may do, from the workspace's chat settings: the page hides what they may not. */
228+ can: ChatPermissions;
178229 };
179230
180231 export type MessagePage = {
248299 | { type: "message.deleted"; channel_id: string; id: string }
249300 | { type: "typing"; channel_id: string; member: MemberProfile; until: string }
250301 | { type: "read"; channel_id: string; principal: Principal; last_read_id: string }
302+ | { type: "channel.updated"; channel: Channel }
251303 | { type: "reaction.added" | "reaction.removed"; channel_id: string; message_id: string; emoji: string; member: MemberProfile };
252304
253305 /**
258310 */
259311 export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer";
260312
313+/** A channel with its members, and whether the viewer may rename or archive it. */
314+export type ChannelDetail = { channel: Channel; members: ChannelMember[]; can_manage: boolean };
315+
261316 export type ChatApi = {
262317 sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>;
263318 channel(
264319 workspace: string,
265320 channelId: string,
266321 viewer: User,
267− ): Promise<Result<{ channel: Channel; members: ChannelMember[] }>>;
322+ ): Promise<Result<ChannelDetail>>;
268323 /** The same, found by its name in the workspace (`#general` or `general`), as the site's URLs name channels. */
269324 channelByName(
270325 workspace: string,
271326 name: string,
272327 viewer: User,
273− ): Promise<Result<{ channel: Channel; members: ChannelMember[] }>>;
274− /** Public channels, for Browse channels. */
275− browse(workspace: string, viewer: User): Promise<Result<Channel[]>>;
328+ ): Promise<Result<ChannelDetail>>;
329+ /**
330+ * Browse channels: every public channel, and the private ones the viewer
331+ * is in. With `archived`, the archived ones instead.
332+ */
333+ browse(workspace: string, viewer: User, options?: { archived?: boolean }): Promise<Result<Channel[]>>;
334+ /** Makes a channel. Who may make a public or a private one is the workspace's setting. */
276335 createChannel(workspace: string, viewer: User, input: NewChannel): Promise<Result<Channel>>;
277336 /**
337+ * Renames, archives or unarchives a channel (the workspace's
338+ * `manage_channels` setting says who may), or changes its topic (any
339+ * member). #general is never renamed or archived. Everyone looking at it
340+ * gets `channel.updated`.
341+ */
342+ updateChannel(workspace: string, channelId: string, viewer: User, change: ChannelChange): Promise<Result<Channel>>;
343+ /** The workspace's chat settings, for any member; only owners may change them. */
344+ chatSettings(workspace: string, viewer: User): Promise<Result<ChatSettingsView>>;
345+ /** Owners only: changes some of the workspace's chat settings, and answers all of them. */
346+ setChatSettings(workspace: string, viewer: User, change: Partial<ChatSettings>): Promise<Result<ChatSettings>>;
347+ /**
278348 * The direct message between the viewer and these members, created on
279349 * first use. The same set of members always gets the same channel.
280350 */
318388 agentId: string,
319389 message: AgentPostMessage,
320390 ): Promise<Result<ChatMessage>>;
391+ /**
392+ * Changes a message an agent posted: its body, its card, or both. Only
393+ * the agents service calls this, to keep a session's live card current.
394+ * Changing a message wakes nobody.
395+ */
396+ updateAsAgent(
397+ workspace: string,
398+ channelId: string,
399+ agentId: string,
400+ id: string,
401+ change: { body?: string; card?: MessageCard | null },
402+ ): Promise<Result<ChatMessage>>;
321403 /** Shows "is typing" for an agent while it works on a reply. */
322404 agentTyping(workspace: string, channelId: string, agentId: string): Promise<Result<null>>;
323405 /**
398480 sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }),
399481 channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }),
400482 channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }),
401− browse: (workspace, viewer) => call("browse", { workspace, viewer }),
483+ browse: (workspace, viewer, options) => call("browse", { workspace, viewer, archived: options?.archived ?? false }),
402484 createChannel: (workspace, viewer, input) => call("create_channel", { workspace, viewer, input }),
485+ updateChannel: (workspace, channelId, viewer, change) => call("update_channel", { workspace, channel_id: channelId, viewer, change }),
486+ chatSettings: (workspace, viewer) => call("chat_settings", { workspace, viewer }),
487+ setChatSettings: (workspace, viewer, change) => call("set_chat_settings", { workspace, viewer, change }),
403488 openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }),
404489 join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }),
405490 leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }),
423508 call("set_preferences", { workspace, channel_id: channelId, viewer, prefs }),
424509 postAsAgent: (workspace, channelId, agentId, message) =>
425510 call("post_as_agent", { workspace, channel_id: channelId, agent_id: agentId, message }),
511+ updateAsAgent: (workspace, channelId, agentId, id, change) =>
512+ call("update_as_agent", { workspace, channel_id: channelId, agent_id: agentId, id, change }),
426513 agentTyping: (workspace, channelId, agentId) =>
427514 call("agent_typing", { workspace, channel_id: channelId, agent_id: agentId }),
428515 audience: (workspace, channelId) => call("audience", { workspace, channel_id: channelId }),
+1−0
530530 call("merge_pull", { actor, repo, number, ...options }),
531531 listActivePulls: (viewer) => call("list_active_pulls", { viewer }),
532532 byAuthor: (username, viewer, filter = {}) => call("by_author", { username, viewer, ...filter }),
533+ contributions: (username, viewer) => call("contributions", { username, viewer }),
533534 startPlan: (actor, repo, brief) => call("start_plan", { actor, repo, brief }),
534535 failPlan: (planId, token, error) => call("report_plan", { planId, token, error }),
535536 getPlan: (repo, viewer, id) => call("get_plan", { repo, viewer, id }),
+23−0
10661066 * on repositories the viewer may read. Not found for no such account.
10671067 */
10681068 byAuthor(username: string, viewer: Viewer, filter?: AuthoredFilter): Promise<Result<Authored>>;
1069+ /**
1070+ * What a person did each day of the last year (issues and pull requests
1071+ * opened, reviews given), only on repositories the viewer may read: the
1072+ * calendar on their profile. Not found for no such account.
1073+ */
1074+ contributions(username: string, viewer: Viewer): Promise<Result<Contributions>>;
10691075
10701076 /**
10711077 * Records an outcome to plan for. Members only. For the runner service,
12821288 /** The repositories they worked in that the viewer may read, most work first. */
12831289 repos: { repo: RepoPath; count: number }[];
12841290 };
1291+
1292+// Mirrors `Contributions` in `crates/contracts/src/work.rs`.
1293+
1294+/** How many days `contributions` covers: today and the 364 before it. */
1295+export const CONTRIBUTION_DAYS = 365;
1296+
1297+/** One day with something on it; days with nothing are left out. */
1298+export type ContributionDay = { /** `YYYY-MM-DD`, UTC. */ date: string; count: number };
1299+
1300+/** A person's year, as far as the viewer may see. */
1301+export type Contributions = {
1302+ /** Oldest first. */
1303+ days: ContributionDay[];
1304+ total: number;
1305+ /** The first day counted, `YYYY-MM-DD`; the last is today (UTC). */
1306+ from: string;
1307+};
+15−0
1+-- What a workspace's owners decide members may do in chat (src/settings.ts).
2+-- Every column has the default a workspace without a row gets, so
3+-- existing rows (made for `emoji_upload`) keep working unchanged.
4+
5+-- Who may create public channels, and private ones: every member, or only owners.
6+ALTER TABLE chat_settings ADD COLUMN public_channels TEXT NOT NULL DEFAULT 'members' CHECK (public_channels IN ('members', 'owners'));
7+ALTER TABLE chat_settings ADD COLUMN private_channels TEXT NOT NULL DEFAULT 'members' CHECK (private_channels IN ('members', 'owners'));
8+
9+-- Who may rename, archive and unarchive a channel: its owners and the
10+-- workspace's owners, or the workspace's owners only.
11+ALTER TABLE chat_settings ADD COLUMN manage_channels TEXT NOT NULL DEFAULT 'channel_owners' CHECK (manage_channels IN ('channel_owners', 'owners'));
12+
13+-- The public channels someone new is put in the first time they open
14+-- Chat, as a JSON array of channel ids. NULL: #general.
15+ALTER TABLE chat_settings ADD COLUMN default_channels TEXT;
+235−23
2828 type AgentPostMessage,
2929 type AskerAccess,
3030 type Channel,
31+ type ChannelChange,
32+ type ChannelDetail,
3133 type ChannelMember,
3234 type ChatAudience,
3335 type ChatLiveEvent,
3739 type EmojiFile,
3840 type EmojiList,
3941 type EmojiUpload,
42+ type ChatSettings,
43+ type ChatSettingsView,
4044 type ChatSidebar,
4145 type ChatSidebarEntry,
4246
7478 import { AGENT_TYPING_MS, historyOf, historySize, messageBody, meterDay, pageOf, pageSize } from "./messages.ts";
7579 import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts";
7680 import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts";
81+import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts";
7782 import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts";
7883 // Live notifications and counts (services/notify).
7984 import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts";
232237 private readonly people = new Map<string, Promise<Map<string, Member>>>();
233238 private readonly usernames = new Map<string, string>();
234239 private readonly agents = new Map<string, WorkspaceAgent | null>();
240+ private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>();
235241
236242 /** `defer` runs work after the answer is sent: the request's waitUntil. */
237243 constructor(
505511 .prepare("INSERT OR IGNORE INTO general_joined (workspace_id, principal, joined_at) VALUES (?, ?, ?)")
506512 .bind(workspace.id, me, at),
507513 ];
508− if (general && !general.private && !general.archived_at) {
509− statements.push(this.joinStatement(general.id, me, general.created_by === me ? "owner" : "member", at));
514+ // The workspace's default channels (#general unless its owners chose
515+ // others): public and not archived only, whatever was kept.
516+ const settings = await this.settings(workspace, general?.id ?? null);
517+ const defaults = settings.default_channels.length
518+ ? await this.db
519+ .prepare(
520+ "SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))",
521+ )
522+ .bind(workspace.id, JSON.stringify(settings.default_channels))
523+ .all<ChannelRow>()
524+ : { results: [] as ChannelRow[] };
525+ for (const channel of defaults.results) {
526+ statements.push(this.joinStatement(channel.id, me, channel.created_by === me ? "owner" : "member", at));
510527 }
511528 await this.db.batch(statements);
512529 }
598615 mentions: count.mentions,
599616 };
600617 });
601− return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0 });
618+ const settings = await this.settings(workspace);
619+ return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0, can: permissionsFor(settings, roleOf(a.viewer, slug)) });
602620 }
603621
604622 // ── Channels ────────────────────────────────────────────────────────────
605623
606− async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<{ channel: Channel; members: ChannelMember[] }>> {
624+ async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
607625 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
608626 if (!found.ok) return found;
609− const { slug, workspace, channel } = found.value;
610− const rows = await this.db
611− .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
612− .bind(channel.id)
613− .all<MemberRow>();
627+ const { slug, workspace, channel, member } = found.value;
628+ const [rows, settings] = await Promise.all([
629+ this.db
630+ .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
631+ .bind(channel.id)
632+ .all<MemberRow>(),
633+ this.settings(workspace),
634+ ]);
614635 const profiles = await this.profiles(slug, workspace, rows.results.map((r) => r.principal));
615636 return ok({
616637 channel: toChannel(channel),
638+ can_manage: channel.kind === "channel" && mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null),
617639 members: rows.results.map((row) => ({
618640 channel_id: row.channel_id,
619641 member: profiles.get(row.principal)!,
627649 }
628650
629651 /** A channel by its name, as the site's URLs name them; read like `channel`. */
630− async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<{ channel: Channel; members: ChannelMember[] }>> {
652+ async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
631653 const found = await this.viewerWorkspace(a.workspace, a.viewer);
632654 if (!found.ok) return found;
633655 const named = channelName(a.name ?? "");
641663 return this.channel({ workspace: a.workspace, channel_id: row.id, viewer: a.viewer });
642664 }
643665
644− async browse(a: { workspace: string; viewer: Viewer }): Promise<Result<Channel[]>> {
666+ /**
667+ * Every public channel, and the private ones the viewer is in; a private
668+ * one they are not in stays unseen. With `archived`, the archived ones.
669+ */
670+ async browse(a: { workspace: string; viewer: Viewer; archived?: boolean }): Promise<Result<Channel[]>> {
645671 const found = await this.viewerWorkspace(a.workspace, a.viewer);
646672 if (!found.ok) return found;
647673 const rows = await this.db
648674 .prepare(
649− "SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name",
675+ `SELECT * FROM channels c
676+ WHERE c.workspace_id = ?1 AND c.kind = 'channel'
677+ AND (c.archived_at IS NULL) = (?3 = 0)
678+ AND (c.private = 0 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2))
679+ ORDER BY c.name`,
650680 )
651− .bind(found.value.id)
681+ .bind(found.value.id, userKey(a.viewer!), a.archived === true ? 1 : 0)
652682 .all<ChannelRow>();
653683 return ok(rows.results.map(toChannel));
654684 }
661691 if (!named.ok) return fail("invalid", named.message);
662692 const topic = typeof a.input?.topic === "string" ? a.input.topic.trim() : "";
663693 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
694+ const isPrivate = !!a.input?.private;
695+ if (!mayCreateChannel(await this.settings(workspace), roleOf(a.viewer, a.workspace), isPrivate)) {
696+ return fail("forbidden", `Only owners can create ${isPrivate ? "private" : "public"} channels in this workspace.`);
697+ }
664698 const taken = await this.db
665699 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ?")
666700 .bind(workspace.id, named.name)
673707 kind: "channel",
674708 name: named.name,
675709 topic: topic || null,
676− private: a.input?.private ? 1 : 0,
710+ private: isPrivate ? 1 : 0,
677711 dm_key: null,
678712 created_by: me,
679713 created_at: now(),
811845 return ok(null);
812846 }
813847
848+ // ── What owners decide (src/settings.ts) ────────────────────────────────
849+
850+ /** The row of a workspace's chat settings, read once per request. */
851+ private settingsRow(workspace: Workspace): Promise<SettingsRow | null> {
852+ let found = this.settingsRows.get(workspace.id);
853+ if (!found) {
854+ found = this.db
855+ .prepare("SELECT emoji_upload, public_channels, private_channels, manage_channels, default_channels FROM chat_settings WHERE workspace_id = ?")
856+ .bind(workspace.id)
857+ .first<SettingsRow>();
858+ this.settingsRows.set(workspace.id, found);
859+ }
860+ return found;
861+ }
862+
863+ /**
864+ * A workspace's chat settings. Default channels never chosen are
865+ * #general, looked up unless `generalId` is given.
866+ */
867+ private async settings(workspace: Workspace, generalId?: string | null): Promise<ChatSettings> {
868+ const row = await this.settingsRow(workspace);
869+ if (row?.default_channels != null || generalId !== undefined) return settingsOf(row, generalId ?? null);
870+ const general = await this.db
871+ .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ? AND kind = 'channel'")
872+ .bind(workspace.id, GENERAL)
873+ .first<{ id: string }>();
874+ return settingsOf(row, general?.id ?? null);
875+ }
876+
877+ async chatSettings(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSettingsView>> {
878+ const found = await this.viewerWorkspace(a.workspace, a.viewer);
879+ if (!found.ok) return found;
880+ const workspace = found.value;
881+ const [settings, channels] = await Promise.all([
882+ this.settings(workspace),
883+ this.db
884+ .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name")
885+ .bind(workspace.id)
886+ .all<ChannelRow>(),
887+ ]);
888+ return ok({ settings, can: permissionsFor(settings, roleOf(a.viewer, a.workspace)), channels: channels.results.map(toChannel) });
889+ }
890+
891+ async setChatSettings(a: { workspace: string; viewer: Viewer; change: Partial<ChatSettings> }): Promise<Result<ChatSettings>> {
892+ const found = await this.viewerWorkspace(a.workspace, a.viewer);
893+ if (!found.ok) return found;
894+ const workspace = found.value;
895+ if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change the workspace's chat settings.");
896+ const checked = settingsChange(a.change);
897+ if (!checked.ok) return fail("invalid", checked.message);
898+ const change = checked.change;
899+ if (change.default_channels) {
900+ // Only the workspace's public channels that are not archived: a private
901+ // one would put people somewhere they were never invited.
902+ const rows = await this.db
903+ .prepare(
904+ "SELECT id FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))",
905+ )
906+ .bind(workspace.id, JSON.stringify(change.default_channels))
907+ .all<{ id: string }>();
908+ const open = new Set(rows.results.map((r) => r.id));
909+ if (change.default_channels.some((id) => !open.has(id))) return fail("invalid", "Default channels must be public channels that are not archived.");
910+ }
911+ const next: ChatSettings = { ...(await this.settings(workspace)), ...change };
912+ const row = rowFor(next);
913+ await this.db
914+ .prepare(
915+ `INSERT INTO chat_settings (workspace_id, emoji_upload, public_channels, private_channels, manage_channels, default_channels)
916+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
917+ ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2, public_channels = ?3, private_channels = ?4, manage_channels = ?5, default_channels = ?6`,
918+ )
919+ .bind(workspace.id, row.emoji_upload, row.public_channels, row.private_channels, row.manage_channels, row.default_channels)
920+ .run();
921+ this.settingsRows.delete(workspace.id);
922+ return ok(next);
923+ }
924+
925+ /**
926+ * Renames, archives or unarchives a channel, or changes its topic. The
927+ * workspace's `manage_channels` setting says who may do the first three;
928+ * any member may change the topic. #general stays #general, and stays.
929+ */
930+ async updateChannel(a: { workspace: string; channel_id: string; viewer: Viewer; change: ChannelChange }): Promise<Result<Channel>> {
931+ // Archived or not, a channel is found the same way; a private one only by its members.
932+ const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
933+ if (!found.ok) return found;
934+ const { slug, workspace, channel, member } = found.value;
935+ if (channel.kind === "dm") return fail("invalid", "A direct message has no name or topic to change.");
936+ const change = a.change ?? {};
937+ const next: ChannelRow = { ...channel };
938+ if (change.name !== undefined || change.archived !== undefined) {
939+ const settings = await this.settings(workspace);
940+ if (!mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null)) {
941+ return fail(
942+ "forbidden",
943+ settings.manage_channels === "owners"
944+ ? "Only workspace owners can rename or archive channels here."
945+ : "Only this channel's owners and workspace owners can rename or archive it.",
946+ );
947+ }
948+ if (channel.name === GENERAL) return fail("invalid", "#general is where everyone is: it can't be renamed or archived.");
949+ }
950+ if (change.name !== undefined) {
951+ const named = channelName(String(change.name ?? ""));
952+ if (!named.ok) return fail("invalid", named.message);
953+ if (named.name !== channel.name) {
954+ const taken = await this.db
955+ .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ? AND id != ?")
956+ .bind(workspace.id, named.name, channel.id)
957+ .first();
958+ if (taken || named.name === GENERAL) return fail("conflict", `#${named.name} already exists.`);
959+ }
960+ next.name = named.name;
961+ }
962+ if (change.topic !== undefined) {
963+ if (!member) return fail("forbidden", `Join #${channel.name} first.`);
964+ const topic = typeof change.topic === "string" ? change.topic.trim() : "";
965+ if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
966+ next.topic = topic || null;
967+ }
968+ if (change.archived !== undefined) {
969+ next.archived_at = change.archived ? (channel.archived_at ?? now()) : null;
970+ } else if (channel.archived_at && (change.name !== undefined || change.topic !== undefined)) {
971+ return fail("invalid", "This channel is archived. Unarchive it first.");
972+ }
973+ try {
974+ await this.db
975+ .prepare("UPDATE channels SET name = ?, topic = ?, archived_at = ? WHERE id = ?")
976+ .bind(next.name, next.topic, next.archived_at, channel.id)
977+ .run();
978+ } catch (error) {
979+ if (String(error).includes("UNIQUE")) return fail("conflict", `#${next.name} already exists.`);
980+ throw error;
981+ }
982+ const updated = toChannel(next);
983+ this.broadcast(channel.id, { type: "channel.updated", channel: updated });
984+ return ok(updated);
985+ }
986+
814987 // ── Messages ────────────────────────────────────────────────────────────
815988
816989 /**
11661339 }
11671340
11681341 /**
1342+ * Changes a message the agent posted (a session's live card, say): its
1343+ * body, its card, or both. Only the agent's own messages; it wakes
1344+ * nobody, and everyone in the conversation sees it change.
1345+ */
1346+ async updateAsAgent(a: {
1347+ workspace: string;
1348+ channel_id: string;
1349+ agent_id: string;
1350+ id: string;
1351+ change: { body?: string; card?: MessageCard | null };
1352+ }): Promise<Result<ChatMessage>> {
1353+ const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1354+ if (!found.ok) return found;
1355+ const { place, agent } = found.value;
1356+ const row = await this.messageRow(place.channel.id, String(a.id ?? ""));
1357+ if (!row || row.deleted_at) return fail("not_found", "No such message.");
1358+ if (row.author !== principalKey({ kind: "agent", id: agent.id })) return fail("forbidden", "An agent can change only its own messages.");
1359+ const change = a.change ?? {};
1360+ const card = change.card === undefined ? (row.card ? (JSON.parse(row.card) as MessageCard) : null) : change.card === null ? null : cleanCard(change.card);
1361+ if (change.card && !card) return fail("invalid", "A card needs a kind and a title.");
1362+ const body = messageBody(change.body === undefined ? row.body : change.body, !!card);
1363+ if (!body.ok) return fail("invalid", body.message);
1364+ const cardJson = card ? JSON.stringify(card) : null;
1365+ const at = now();
1366+ // A card's live state is not an edit a person made: no "edited" mark for it.
1367+ const edited = change.body !== undefined && change.body !== row.body ? at : row.edited_at;
1368+ await this.db
1369+ .prepare("UPDATE messages SET body = ?, card = ?, kind = ?, edited_at = ? WHERE id = ?")
1370+ .bind(body.body, cardJson, card ? "card" : "text", edited, row.id)
1371+ .run();
1372+ const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, card: cardJson, kind: card ? "card" : "text", edited_at: edited }]);
1373+ this.broadcast(place.channel.id, { type: "message.updated", message });
1374+ return ok(message);
1375+ }
1376+
1377+ /**
11691378 * What an agent reads before replying, oldest first: a thread (its root,
11701379 * then its latest replies), or the channel's latest top-level messages.
11711380 * Only where the agent is a member, so it reads only what was said where
14211630 // ── A workspace's own emoji (src/emoji.ts) ──────────────────────────────
14221631
14231632 private async emojiUpload(workspace: Workspace): Promise<EmojiUpload> {
1424− const row = await this.db.prepare("SELECT emoji_upload FROM chat_settings WHERE workspace_id = ?").bind(workspace.id).first<{ emoji_upload: EmojiUpload }>();
1425− return row?.emoji_upload === "admins" ? "admins" : "members";
1633+ return settingsOf(await this.settingsRow(workspace)).emoji_upload;
14261634 }
14271635
14281636 private async toEmoji(slug: string, workspace: Workspace, rows: EmojiRow[]): Promise<CustomEmoji[]> {
15741782 if (!found.ok) return found;
15751783 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change who adds emoji.");
15761784 if (a.value !== "members" && a.value !== "admins") return fail("invalid", "Choose members or owners.");
1577− await this.db
1578− .prepare(
1579− "INSERT INTO chat_settings (workspace_id, emoji_upload) VALUES (?1, ?2) ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2",
1580− )
1581− .bind(found.value.id, a.value)
1582− .run();
1583− return ok(a.value);
1785+ // The same setting as Settings → Chat (`setChatSettings`).
1786+ const set = await this.setChatSettings({ workspace: a.workspace, viewer: a.viewer, change: { emoji_upload: a.value } });
1787+ return set.ok ? ok(set.value.emoji_upload) : set;
15841788 }
15851789
15861790 // ── The live socket ─────────────────────────────────────────────────────
16381842 return Response.json(await service.browse(args));
16391843 case "create_channel":
16401844 return Response.json(await service.createChannel(args));
1845+ case "update_channel":
1846+ return Response.json(await service.updateChannel(args));
1847+ case "chat_settings":
1848+ return Response.json(await service.chatSettings(args));
1849+ case "set_chat_settings":
1850+ return Response.json(await service.setChatSettings(args));
16411851 case "open_dm":
16421852 return Response.json(await service.openDm(args));
16431853 case "join":
16601870 return Response.json(await service.setPreferences(args));
16611871 case "post_as_agent":
16621872 return Response.json(await service.postAsAgent(args));
1873+ case "update_as_agent":
1874+ return Response.json(await service.updateAsAgent(args));
16631875 case "agent_typing":
16641876 return Response.json(await service.agentTyping(args));
16651877 case "history_for_agent":
+85−0
1+import { test } from "node:test";
2+import assert from "node:assert/strict";
3+
4+import { MAX_DEFAULT_CHANNELS, mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf } from "./settings.ts";
5+
6+test("a workspace without settings lets every member make channels, and new people land in #general", () => {
7+ const settings = settingsOf(null, "chn_general");
8+ assert.deepEqual(settings, {
9+ public_channels: "members",
10+ private_channels: "members",
11+ manage_channels: "channel_owners",
12+ emoji_upload: "members",
13+ default_channels: ["chn_general"],
14+ });
15+ assert.deepEqual(settingsOf(null).default_channels, []);
16+});
17+
18+test("default channels once chosen are kept as chosen, even none", () => {
19+ const row = { emoji_upload: "admins", public_channels: "owners", private_channels: "members", manage_channels: "owners", default_channels: "[]" };
20+ const settings = settingsOf(row, "chn_general");
21+ assert.deepEqual(settings.default_channels, []);
22+ assert.equal(settings.emoji_upload, "admins");
23+ assert.equal(settings.public_channels, "owners");
24+ assert.equal(settings.manage_channels, "owners");
25+ assert.deepEqual(settingsOf({ ...row, default_channels: '["a","b"]' }).default_channels, ["a", "b"]);
26+ // Anything unreadable is the defaults, never an error.
27+ assert.deepEqual(settingsOf({ ...row, default_channels: "{nope" }).default_channels, []);
28+ assert.equal(settingsOf({ ...row, public_channels: "everyone" }).public_channels, "members");
29+});
30+
31+test("who may make a channel follows its visibility's setting", () => {
32+ const settings = settingsOf({ emoji_upload: null, public_channels: "members", private_channels: "owners", manage_channels: null, default_channels: null });
33+ assert.equal(mayCreateChannel(settings, "member", false), true);
34+ assert.equal(mayCreateChannel(settings, "member", true), false);
35+ assert.equal(mayCreateChannel(settings, "owner", true), true);
36+ assert.equal(mayCreateChannel(settings, null, false), false);
37+});
38+
39+test("renaming and archiving: workspace owners always, a channel's owners unless kept to workspace owners", () => {
40+ const open = settingsOf(null);
41+ assert.equal(mayManageChannel(open, "owner", null), true);
42+ assert.equal(mayManageChannel(open, "member", "owner"), true);
43+ assert.equal(mayManageChannel(open, "member", "member"), false);
44+ assert.equal(mayManageChannel(open, null, "owner"), false);
45+ const strict = { ...open, manage_channels: "owners" as const };
46+ assert.equal(mayManageChannel(strict, "member", "owner"), false);
47+ assert.equal(mayManageChannel(strict, "owner", "member"), true);
48+});
49+
50+test("what a role may do, for the page", () => {
51+ const settings = { ...settingsOf(null), public_channels: "owners" as const, emoji_upload: "admins" as const };
52+ assert.deepEqual(permissionsFor(settings, "member"), {
53+ create_public_channels: false,
54+ create_private_channels: true,
55+ add_emoji: false,
56+ manage_settings: false,
57+ });
58+ assert.deepEqual(permissionsFor(settings, "owner"), {
59+ create_public_channels: true,
60+ create_private_channels: true,
61+ add_emoji: true,
62+ manage_settings: true,
63+ });
64+});
65+
66+test("a change is checked field by field; what is left out stays", () => {
67+ assert.deepEqual(settingsChange({ public_channels: "owners" }), { ok: true, change: { public_channels: "owners" } });
68+ assert.deepEqual(settingsChange({ default_channels: ["a", "a", "b"] }), { ok: true, change: { default_channels: ["a", "b"] } });
69+ assert.equal(settingsChange({ public_channels: "admins" }).ok, false);
70+ assert.equal(settingsChange({ manage_channels: "members" }).ok, false);
71+ assert.equal(settingsChange({ emoji_upload: "owners" }).ok, false);
72+ assert.equal(settingsChange({ default_channels: "general" }).ok, false);
73+ assert.equal(settingsChange({ default_channels: Array.from({ length: MAX_DEFAULT_CHANNELS + 1 }, (_, i) => `c${i}`) }).ok, false);
74+ assert.equal(settingsChange(null).ok, false);
75+});
76+
77+test("settings are kept with their default channels as JSON", () => {
78+ assert.deepEqual(rowFor({ ...settingsOf(null), default_channels: ["x"] }), {
79+ emoji_upload: "members",
80+ public_channels: "members",
81+ private_channels: "members",
82+ manage_channels: "channel_owners",
83+ default_channels: '["x"]',
84+ });
85+});
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.