Skip to content

Commit

Chat and workspace agents: channels, DMs and named agents you talk to

g1t becomes one workspace where a team and its agents talk, write things down and ship. Plan: docs/WORKSPACE.md (supersedes docs/CHANNELS.md). - services/chat (new): channels, DMs, threads, read state, live delivery through one hibernating Durable Object per channel, mention and DM delivery to agents with a hop limit, raw message metering. D1 g1t-chat. - services/agents (new): workspace agents with a role, job, personality, routing limits (floor, ceiling, providers incl. the workspace's own), budgets, autonomy and capacity; seven templates; a desk per agent; chat replies without a sandbox, routed and billed through the existing model and billing paths, capped by the asker's access. D1 g1t-agents. - Site: a rail (Home, Code, Chat, Docs, Agents, Inbox), Chat mode with live channels, threads and DMs, Agents mode with profile, spend and the template gallery, and members without Code access (Membership.code_access). - Billing: chat replies get their own ledger label. Models deploys in the core stage so agents can bind to it. - Marketing and docs: chat leads the landing page and pricing; new guides for Chat, Agents, What agents can do for whom, Docs and Agents in your chat app; unbuilt parts marked Coming soon.

syntaqxcommitted Parent4a3e60bBrowse files
116 files+9021−3580/116 viewed
+28−22
11 # g1t
22
3−The open-source git platform where people and agents ship software
4−together, from the first issue to production on the edge. It runs on
5−Cloudflare Workers and Artifacts.
3+One open-source workspace where a team and its agents talk, work, write
4+things down and ship. No separate chat app, wiki or forge to stitch
5+together. It runs on Cloudflare Workers and Artifacts.
66
7−- **Collaborate.** Git over HTTPS, public and private repositories, issues,
8− pull requests, line comments and reviews, protected branches, workspaces,
9− profiles and site-wide search.
10−- **Agents as teammates.** Assign an issue to g1t or mention `@g1t`, or
11− connect Claude Code, Codex, OpenCode or Cursor over MCP. Hand g1t an
12− outcome and a planner splits it into issues with dependencies that agents
13− take up as they unblock. Agents see what the others are changing, ask each
14− other and you, and work under guardrails, with their own credentials and
15− an audit log.
16−- **Ship safely.** Checks run by g1t in clean sandboxes, GitHub Actions
17− workflows as they are, a merge queue that tests changes together, conflicts
18− found on every push, and why-blame from any line to the session that
19− wrote it.
20−- **Run it.** A preview of every pull request and production on merge, on
21− `g1t.page`, with custom domains. Apps nobody visits cost nothing.
22−- **Secure and healthy.** Push protection, history scanning, dependency
23− upkeep that an agent lands, and an audit log on every workspace.
7+- **Chat.** Channels, direct messages and threads, live. People and agents
8+ are members alike: DM an agent, or mention it in a thread, and it answers
9+ there. Chat is included on every plan, with no seats and no history
10+ cutoff.
11+- **Agents.** A workspace's own agents, each with a role, a job, a
12+ personality, limits on which models Auto may route it to (including the
13+ workspace's own providers) and a budget. Start from templates: planner,
14+ implementer, reviewer, triage, documenter, release manager, on-call.
15+- **Docs** (coming soon). Specs, runbooks and decisions, written together,
16+ read by agents and kept current by them.
17+- **Code.** Git over HTTPS, issues, pull requests and reviews. Assign an
18+ issue to g1t or connect any coding agent over MCP; hand g1t an outcome and
19+ a planner splits it into issues that agents take up as they unblock.
20+ Checks run by g1t in clean sandboxes, workflows from `.g1t/workflows`, a
21+ merge queue that tests changes together, why-blame from any line to the
22+ session that wrote it, and a preview of every pull request on `g1t.page`.
23+- **Rails.** Budgets per workspace, agent and task; agents act with the
24+ asker's access and answer only with what their audience may see;
25+ approvals for merges and production deploys; an audit log on every
26+ workspace.
2427 - **Open and fair.** MIT licensed and self-hostable (an early Docker Compose
25− version of the core forge, in `deploy/self-host`). The forge is free; compute is what it costs
26− plus 20%, never per seat.
28+ version of the core forge, in `deploy/self-host`). People chat free and
29+ the forge is free; agents pay the model's price plus a flat agent rate,
30+ and other compute is what it costs plus 20%, never per seat.
31+
32+The plan for the workspace is [docs/WORKSPACE.md](docs/WORKSPACE.md).
2733
2834 g1t is made by Flagon, Inc. It is also an entry in Cloudflare's **Build the
2935 Next-Gen Git Platform** competition, which asks what a git platform looks
+12−2
1616 integrations: [
1717 starlight({
1818 title: 'g1t docs',
19− description: 'Guides and reference for g1t, where people and agents ship software together.',
19+ description: 'Guides and reference for g1t, the workspace where a team and its agents talk, work and ship.',
2020 components: {
2121 Footer: './src/components/Footer.astro',
2222 Head: './src/components/Head.astro',
7070 ],
7171 },
7272 {
73+ label: 'Chat and agents',
74+ items: [
75+ { label: 'Chat', slug: 'guides/chat' },
76+ { label: 'Agents', slug: 'guides/agents' },
77+ { label: 'What agents can do for whom', slug: 'guides/agent-access' },
78+ { label: 'Docs', slug: 'guides/docs', badge: { text: 'Soon', variant: 'default' } },
79+ { label: 'Agents in your chat app', slug: 'guides/chat-app', badge: { text: 'Soon', variant: 'default' } },
80+ ],
81+ },
82+ {
7383 label: 'Projects',
7484 items: [
7585 { label: 'Projects', slug: 'guides/projects' },
100110 ],
101111 },
102112 {
103− label: 'Agents',
113+ label: 'Agents on code',
104114 items: [
105115 { label: "g1t's agent", slug: 'guides/working-with-g1t' },
106116 { label: 'Guardrails', slug: 'guides/guardrails' },
+51−0
1+---
2+// A conversation in Chat, drawn the way the product shows it: a channel or
3+// DM header, then <Message> children. For examples in the guides.
4+interface Props {
5+ /** `#web`, or a DM's title. */
6+ title: string;
7+ /** One line under the title, such as the channel's topic. */
8+ topic?: string;
9+}
10+const { title, topic } = Astro.props;
11+---
12+
13+<figure class="conversation not-content" aria-label={`An example conversation in ${title}`}>
14+ <header>
15+ <span class="title">{title}</span>
16+ {topic && <span class="topic">{topic}</span>}
17+ </header>
18+ <div class="messages"><slot /></div>
19+</figure>
20+
21+<style>
22+ .conversation {
23+ margin-block: 1.5rem;
24+ border: 1px solid var(--g1t-line);
25+ border-radius: 0.875rem;
26+ background: var(--g1t-surface);
27+ overflow: hidden;
28+ }
29+ header {
30+ display: flex;
31+ align-items: baseline;
32+ gap: 0.6rem;
33+ padding: 0.7rem 1rem;
34+ border-bottom: 1px solid var(--g1t-line);
35+ }
36+ .title {
37+ color: var(--g1t-fg);
38+ font-weight: 600;
39+ font-size: 0.875rem;
40+ }
41+ .topic {
42+ color: var(--g1t-faint);
43+ font-size: 0.8125rem;
44+ overflow: hidden;
45+ text-overflow: ellipsis;
46+ white-space: nowrap;
47+ }
48+ .messages {
49+ padding-block: 0.4rem;
50+ }
51+</style>
+99−0
1+---
2+// One message in a <Conversation>: who wrote it, when, and the body (the
3+// slot, written in Markdown). Agents get a square lavender face and a tag.
4+interface Props {
5+ name: string;
6+ time: string;
7+ agent?: boolean;
8+ /** An agent's one-line role, shown beside its name. */
9+ role?: string;
10+}
11+const { name, time, agent = false, role } = Astro.props;
12+---
13+
14+<div class="message">
15+ <span class:list={['face', { agent }]} aria-hidden="true">{name.charAt(0)}</span>
16+ <div class="main">
17+ <p class="meta">
18+ <span class="name">{name}</span>
19+ {agent && <span class="tag">Agent</span>}
20+ {role && <span class="role">{role}</span>}
21+ <span class="time">{time}</span>
22+ </p>
23+ <div class="body"><slot /></div>
24+ </div>
25+</div>
26+
27+<style>
28+ .message {
29+ display: flex;
30+ gap: 0.75rem;
31+ padding: 0.6rem 1rem;
32+ }
33+ .face {
34+ flex: none;
35+ display: flex;
36+ align-items: center;
37+ justify-content: center;
38+ width: 2rem;
39+ height: 2rem;
40+ border-radius: 999px;
41+ background: var(--g1t-raised);
42+ border: 1px solid var(--g1t-line-strong);
43+ color: var(--g1t-fg-soft);
44+ font-size: 0.75rem;
45+ font-weight: 600;
46+ }
47+ .face.agent {
48+ border-radius: 0.5rem;
49+ background: color-mix(in srgb, var(--g1t-accent) 15%, transparent);
50+ border-color: color-mix(in srgb, var(--g1t-accent) 30%, transparent);
51+ color: var(--g1t-accent);
52+ }
53+ .main {
54+ min-width: 0;
55+ flex: 1;
56+ }
57+ .meta {
58+ display: flex;
59+ flex-wrap: wrap;
60+ align-items: baseline;
61+ gap: 0 0.4rem;
62+ margin: 0;
63+ font-size: 0.8125rem;
64+ }
65+ .name {
66+ color: var(--g1t-fg);
67+ font-weight: 600;
68+ }
69+ .tag {
70+ padding: 0 0.3rem;
71+ border-radius: 0.25rem;
72+ background: color-mix(in srgb, var(--g1t-accent) 15%, transparent);
73+ color: var(--g1t-accent);
74+ font-family: var(--g1t-font-mono);
75+ font-size: 0.5625rem;
76+ letter-spacing: 0.06em;
77+ text-transform: uppercase;
78+ }
79+ .role,
80+ .time {
81+ color: var(--g1t-faint);
82+ font-size: 0.75rem;
83+ }
84+ .body {
85+ color: var(--g1t-fg-soft);
86+ font-size: 0.875rem;
87+ line-height: 1.6;
88+ }
89+ .body :global(p),
90+ .body :global(ul) {
91+ margin: 0.25rem 0 0;
92+ }
93+ .body :global(ul) {
94+ padding-left: 1.1rem;
95+ }
96+ .body :global(li) {
97+ margin: 0;
98+ }
99+</style>
+26−0
1+---
2+// "Coming soon": marks a feature that is planned and not built yet, so no
3+// page promises what g1t can't do. Inline, beside a heading or in a line.
4+---
5+
6+<span class="soon not-content">Coming soon</span>
7+
8+<style>
9+ .soon {
10+ display: inline-flex;
11+ align-items: center;
12+ padding: 0.1rem 0.5rem;
13+ border-radius: 999px;
14+ border: 1px solid var(--g1t-line-strong);
15+ background: var(--g1t-raised);
16+ color: var(--g1t-muted);
17+ font-family: var(--g1t-font-mono);
18+ font-size: 0.6875rem;
19+ font-weight: 500;
20+ letter-spacing: 0.04em;
21+ text-transform: uppercase;
22+ white-space: nowrap;
23+ vertical-align: middle;
24+ line-height: 1.5;
25+ }
26+</style>
+11−7
11 ---
22 title: How g1t works
3−description: What g1t is for, and how issues, pull requests, checks, review, merging and sessions fit together.
3+description: How Code works on g1t, and how issues, pull requests, checks, review, merging and sessions fit together.
44 ---
55
6−g1t is where people and agents ship software together. You hand g1t an
7−outcome, and agents converge it onto `main`: each change is made in a pull
8−request of its own, checked by your workflows, reviewed, revised and merged
9−under your repository's rules. People work alongside the agents in the same
10−repositories, issues, pull requests and reviews, and every change can deploy
11−to the edge.
6+g1t is one workspace where a team and its agents talk, work and ship.
7+Most work starts in [Chat](/guides/chat/): you ask a teammate or an
8+[agent](/guides/agents/), in a channel or a DM. This page is about
9+**Code**, where changes are made and landed.
10+
11+In Code, you hand g1t an issue or a whole outcome, and agents converge it
12+onto `main`: each change is made in a pull request of its own, checked by
13+your workflows, reviewed, revised and merged under your repository's rules.
14+People work alongside the agents in the same repositories, issues, pull
15+requests and reviews, and every change can deploy to the edge.
1216
1317 Underneath it is ordinary git: repositories, commits, branches, clone, push
1418 and pull all work as they do anywhere. On top of that it has the two things
+185−0
1+---
2+title: What agents can do for whom
3+description: Agents act with the access of the person asking and answer only with what their audience may see. How that works for engineers, for the rest of the company, and for members without Code access.
4+---
5+
6+import { Steps } from '@astrojs/starlight/components';
7+import Soon from '../../../components/Soon.astro';
8+import Conversation from '../../../components/Conversation.astro';
9+import Message from '../../../components/Message.astro';
10+import Aside from '../../../components/Aside.astro';
11+
12+Chat is for the whole company: support, sales, finance, design and
13+leadership as well as the people who build. Most of them never change
14+code, and an agent must never become a way around that. They still get the
15+full value of the same agents: they ask questions, get answers, and have
16+their requests reach the right team.
17+
18+Two rules make that safe. Neither needs a new role or setting; both follow
19+from the access people already have.
20+
21+## Rule one: the asker's access caps the agent
22+
23+**An agent never does more for someone than that person could do
24+themselves.**
25+
26+An agent acts with the *overlap* of its own access and the access of the
27+person who asked. An agent that may open pull requests on `acme/web` opens
28+one for an engineer with write access to `acme/web`. Asked by someone with
29+read access only, it can explain the code, but not change it, whatever the
30+agent's own settings say.
31+
32+| Who asks | What the agent may do for them |
33+| --- | --- |
34+| A member with write access to the repository | Anything the agent itself may do there, under [what it may do alone](/guides/agents/#what-it-may-do-alone). |
35+| A member with read access | Explain and answer. A change becomes a request for the team that owns it. |
36+| An outside collaborator | Only what their [repository roles](/guides/access-and-roles/) allow. |
37+| A member without Code access <Soon /> | Answer about the product without showing source code. Changes become requests. |
38+
39+Every agent knows who asked and whether they can change code, and every
40+action it takes records the person who asked. When an approval is needed,
41+it goes to people who hold the access the work needs, not to whoever
42+happened to ask.
43+
44+## Rule two: the audience caps the answer
45+
46+**An agent answers only with what everyone who can read the reply may
47+see.**
48+
49+| Where you ask | Who can read the answer | What the agent may draw on |
50+| --- | --- | --- |
51+| A direct message | You (and anyone else in the DM) | What every member of the DM may see. |
52+| A private channel | The channel's members | What every member of the channel may see. |
53+| A public channel | The whole workspace | What every member of the workspace may see. |
54+
55+So a private repository, a private channel or a private doc space never
56+leaks into a place with a wider audience. When an agent can't answer
57+somewhere because of who else is there, it will say so and offer to answer
58+you in a DM instead.
59+
60+<Aside type="note" title="Today">
61+A reply reads only the conversation it is in, which everyone there can
62+already read, so nothing wider can leak. When replies gain tools to look up
63+code, issues and docs <Soon />, every
64+lookup is filtered by the audience before its result reaches the model.
65+</Aside>
66+
67+## A support lead asks for a change
68+
69+Dana leads support. She has read access to `acme/billing` and no write
70+access. She DMs the workspace's triage agent:
71+
72+<Conversation title="DM with Triage" topic="Dana has read access to acme/billing">
73+<Message name="Dana Ruiz" time="14:05">
74+
75+Customers on annual plans get charged twice when they downgrade mid-month. Can you fix it?
76+
77+</Message>
78+<Message name="Triage" agent role="Sorts new issues and requests to the right team" time="14:05">
79+
80+I can't change the billing code for you, but I can make sure the right people see this. Here is a bug report in your words:
81+
82+**Annual plans charged twice on mid-month downgrade.** Customers on annual plans who downgrade before the month ends see two charges. Reported by support.
83+
84+Shall I send it to the billing team? Anything to add, such as a customer or an invoice number?
85+
86+</Message>
87+</Conversation>
88+
89+The agent didn't refuse, and didn't pretend it could change the code. It
90+wrote the request up and offered to route it.
91+
92+### Requests become intake
93+
94+When someone who can't change the code asks for a change, the agent turns
95+it into intake:
96+
97+<Steps>
98+
99+1. **It drafts the request**: a bug or a feature request, in the asker's
100+ words, with the agent's understanding of it.
101+2. **It routes the request** to the team that owns the area, from code
102+ owners, the project's team or the workspace's intake settings.
103+ <Soon />
104+3. **It tells the asker where it went.**
105+4. **It tells them again** when the request is triaged, scheduled and
106+ shipped: *the export fix you asked for is live.*
107+ <Soon />
108+
109+</Steps>
110+
111+Today the agent drafts the request in the conversation, and a person files
112+it. People with write access can still say "just do it".
113+
114+## Members without Code access
115+
116+<Soon />
117+
118+Not everyone in a company needs the code. **Code access** is a switch on
119+each membership, set by owners:
120+
121+- **On by default** for everyone, so nothing changes for existing members.
122+- **Turn it off** for people who don't work on code: support, sales,
123+ finance, leadership.
124+- **It costs nothing either way.** There are no seats. Adding the whole
125+ company costs nothing until people use agents, and then the agent's
126+ reply is charged like any other.
127+
128+A member without Code access:
129+
130+| | |
131+| --- | --- |
132+| **Sees** | Chat, Docs, Agents and the inbox. The rail has no Code, and Home shows their channels, DMs, docs and inbox instead of projects. |
133+| **Can't open** | Any repository, issue, pull request, check or deploy page. The workspace's base permission and team grants don't apply to them. |
134+| **Still sees work reach them** | Cards about issues, pull requests and deploys appear in their channels as summaries: title, state and who is on it. Opening one asks for Code access. |
135+| **Can ask agents** | Anything about the product. Agents explain how things work and what changed, but never show them source code. Owners can tighten this so agents answer only from Docs. |
136+
137+Turning Code access back on restores what their teams and roles give them.
138+
139+## Files and customer data
140+
141+<Soon />
142+
143+People will upload what their work needs: spreadsheets, contracts,
144+exports, screenshots. Each file gets a level, set by the uploader:
145+**Public**, **Internal** (the default), **Confidential** or **Customer
146+data**.
147+
148+- Each level lists which model providers may process it. For example,
149+ customer data may go only to the workspace's own provider. An agent that
150+ may not send a file to any allowed model says so; it never quietly skips
151+ it.
152+- Agents never write customer data into their memory, or into issues, pull
153+ requests or channels with a wider audience than the file's.
154+- Every time an agent reads a Confidential or customer-data file, the
155+ [audit log](/guides/audit-log/) records it, with the person who asked.
156+
157+## Agents that listen
158+
159+<Soon />
160+
161+A channel can let agents **listen**. It is off by default and shown in the
162+channel's header. A listening agent doesn't answer every message. It
163+watches for complaints, bug reports, feature requests and unanswered
164+questions, groups related ones (*three customers hit the CSV export timeout
165+this week*), and files one request linked to every message. It speaks only
166+when asked, or to close a loop: *this was fixed yesterday in #418*.
167+
168+## Beyond code
169+
170+<Soon />
171+
172+Agents will work across the company's other systems through connectors the
173+workspace adds, such as a customer database or a help desk. The same rules
174+apply there: the asker's access caps the agent, the audience caps the
175+answer, and file levels decide which models see the data.
176+
177+## Summary
178+
179+| Question | Decided by |
180+| --- | --- |
181+| What can an agent see? | Its own access, the channels it was invited to, and the audience of the conversation. An invite grants read, never write. |
182+| What can it change? | The overlap of its access and the asker's, then rules, protected branches and environment rules. |
183+| When must it ask? | [What it may do alone](/guides/agents/#what-it-may-do-alone), plus policy. |
184+| What can it spend? | The workspace, then the agent, then the task. See [budgets](/guides/agents/#budgets). |
185+| Who did what? | The [audit log](/guides/audit-log/): the agent, the person who asked, and what was allowed. |
+363−0
1+---
2+title: Agents
3+description: Create agents that are members of your workspace, with a role, a job, a personality, limits on which models they use, and a budget. DM them, invite them to channels, and see what they cost.
4+---
5+
6+import { Steps, CardGrid } from '@astrojs/starlight/components';
7+import Soon from '../../../components/Soon.astro';
8+import Conversation from '../../../components/Conversation.astro';
9+import Message from '../../../components/Message.astro';
10+import Aside from '../../../components/Aside.astro';
11+import Card from '../../../components/Card.astro';
12+
13+**An agent is a teammate your workspace creates.** It has a name, a handle
14+you mention it by, a one-line role, a job it is responsible for, and a voice
15+of its own. It sits in the member list next to the people, you DM it and
16+invite it to channels, and it answers where you asked. Its spending is
17+capped by a budget you set, and its model is chosen for each step by g1t,
18+within limits you give it.
19+
20+A workspace can have as many agents as it likes. An agent costs nothing
21+while nobody talks to it.
22+
23+<Aside type="note" title="Workspace agents and @g1t">
24+`@g1t` is g1t's own agent: it is in every workspace with no setup, and
25+works on issues and pull requests (see [g1t's agent](/guides/working-with-g1t/)).
26+The agents on this page are your workspace's own. You name them, decide
27+what they do, and they belong only to your workspace. `g1t` is never a
28+workspace agent's handle.
29+</Aside>
30+
31+## Create an agent
32+
33+Only a workspace's **owners** can create, change or archive its agents,
34+because an agent spends the workspace's money and speaks in its name. Every
35+member can see its agents and talk to them.
36+
37+<Steps>
38+
39+1. Open **Agents** in the rail, then choose **New agent**, or go to
40+ `g1t.sh/<workspace>/-/agents/new`.
41+2. **Start from a template**, or from a blank agent. A template fills in
42+ every field below; you can change any of them. See [templates](#templates).
43+3. Give it an **identity**: a display name (*Ship*), a handle (`ship`,
44+ mentioned as `@ship`) and a role (*Release manager for the web app*).
45+4. Write its **job**: what it is responsible for, how it works and what
46+ good looks like. See [job and personality](#job-and-personality).
47+5. Pick its **personality**, and add a line of your own if you like.
48+6. Set its **model limits** and **budget**, or keep the defaults: Auto with
49+ no limits, and only the workspace's spend limit. See
50+ [model routing](#model-routing) and [budgets](#budgets).
51+7. Choose **Create agent**.
52+
53+</Steps>
54+
55+The agent appears on the Agents page as **Idle**. Open a DM with it from
56+Chat's **New message**, or invite it to the channels it works in.
57+
58+### The fields
59+
60+| Field | What it is | Limits |
61+| --- | --- | --- |
62+| Display name | What people see: *Ship*, *Lint*. | Up to 64 characters. |
63+| Handle | How it is mentioned: `@ship`. | 2 to 32 lowercase letters, digits and single hyphens, starting and ending with a letter or digit. Unique in the workspace. Never `g1t` or another reserved name. |
64+| Role | One line under its name: *Release manager for the web app*. | Up to 120 characters. |
65+| Job | Its instructions. | Up to 8,000 characters. |
66+| Personality | A preset, plus free text that refines the voice. | Free text up to 1,000 characters. |
67+| Models | A floor, a ceiling and the providers it may use. | See [model routing](#model-routing). |
68+| Budget | A monthly cap, a daily cap and a cap per task. | Each optional, up to $100,000. |
69+| What it may do alone | Pull requests, merging, production deploys, doc edits. | See [what it may do alone](#what-it-may-do-alone). |
70+| Capacity | How many tasks it works on at once. | 1 to 10. Default 3. |
71+
72+Every change to an agent is saved as a new **version**, so what it ran
73+with is never lost.
74+
75+## Templates
76+
77+Templates are ordinary agents you adopt, rename and change. Each comes with
78+a job, a personality and model limits that suit it.
79+
80+| Template | Handle | Role | Personality | Models |
81+| --- | --- | --- | --- | --- |
82+| Planner | `@planner` | Turns goals into planned, sized issues | Socratic | Never below Standard |
83+| Implementer | `@builder` | Implements issues and opens pull requests | Crisp | Auto |
84+| Reviewer | `@reviewer` | Reviews pull requests for correctness and risk | Crisp | Never below Standard |
85+| Triage | `@triage` | Sorts new issues and requests to the right team | Friendly | Never above Standard |
86+| Documenter | `@scribe` | Keeps the docs true after every change | Friendly | Never above Standard |
87+| Release manager | `@ship` | Cuts releases and keeps deploys safe | Terse operator | Never below Standard |
88+| On-call | `@oncall` | First responder for alerts and incidents | Terse operator | Never below Standard |
89+
90+A reviewer is careful, so it never runs on the fast tier. Triage is high
91+volume and simple, so it never runs on the most capable one. Change either
92+if your team works differently.
93+
94+### Example: a release manager from a template
95+
96+<Steps>
97+
98+1. On **New agent**, choose **Release manager**. The handle is `ship`, the
99+ personality is *Terse operator*, and its models never go below Standard.
100+2. Change the role to *Release manager for acme/web*.
101+3. Add one line to its job: *We release on Thursdays at 15:00 UTC. Post the
102+ release notes in #releases.*
103+4. Set a monthly budget of **$40** and a per-task cap of **$5**.
104+5. Choose **Create agent**, then invite `@ship` to `#web` and `#releases`.
105+
106+</Steps>
107+
108+Now anyone in those channels can write `@ship what is going out on
109+Thursday?` and get an answer in the thread.
110+
111+## Job and personality
112+
113+An agent's **job** decides what it does. Its **personality** decides how it
114+sounds. They are kept apart on purpose: personality is voice only, and
115+nothing written there can widen what the agent may do.
116+
117+| | Job | Personality |
118+| --- | --- | --- |
119+| Answers | What is it responsible for? What does good look like? | How does it talk? |
120+| Example | *Cut releases of acme/web on Thursdays. Check required checks pass. Ask a person before tagging.* | *Terse operator. No emoji. Always say what you are waiting on.* |
121+| Changes | What it pays attention to, what it asks, when it stops | Tone, length, formality, how it asks questions |
122+| Never changes | | What it may read, change or spend |
123+
124+The same question, answered by two agents with the same job and different
125+personalities:
126+
127+<Conversation title="DM with Ship" topic="Personality: Crisp">
128+<Message name="Priya Shah" time="09:15">
129+
130+Can we release on a Friday?
131+
132+</Message>
133+<Message name="Ship" agent role="Release manager" time="09:15">
134+
135+We can, but I'd rather not. If something breaks, fewer people are around to fix it. Thursday gives us a day to watch it.
136+
137+</Message>
138+</Conversation>
139+
140+<Conversation title="DM with Ship" topic="Personality: Terse operator">
141+<Message name="Priya Shah" time="09:15">
142+
143+Can we release on a Friday?
144+
145+</Message>
146+<Message name="Ship" agent role="Release manager" time="09:15">
147+
148+Possible. Not advised: thin weekend cover. Prefer Thursday.
149+
150+</Message>
151+</Conversation>
152+
153+### Personality presets
154+
155+| Preset | Voice |
156+| --- | --- |
157+| **Crisp** (default) | Clear and direct, short sentences, no filler. Warm but businesslike. |
158+| **Friendly** | Warm and encouraging, plain words, the occasional light touch. Still to the point. |
159+| **Socratic** | Helps people think. Asks a good question when it moves things forward, then gives a clear view. |
160+| **Terse operator** | As few words as the job needs. Facts, status, next step. No pleasantries. |
161+
162+Add free text to refine the preset: *Answers in Spanish when asked in
163+Spanish.* or *Uses British spelling.*
164+
165+## Talk to an agent
166+
167+There are two ways to reach an agent in [Chat](/guides/chat/):
168+
169+- **DM it.** In a direct message, it answers every message you send.
170+- **Mention it** in a channel it is a member of: `@ship …`. In a channel,
171+ an agent answers only when it is mentioned, and replies in the thread.
172+
173+Agents can mention each other too. Each agent-to-agent mention is a *hop*,
174+and a chain started by one person's message stops after six hops, so agents
175+can't keep each other busy without a person.
176+
177+While it writes, the agent shows as typing. Its answer is charged to its
178+own budget; see [what an agent costs](#what-an-agent-costs).
179+
180+<Aside type="note" title="What a reply can see today">
181+A reply reads the conversation it is in: the thread, or the latest 30
182+messages of the channel or DM. It knows who asked and whether they can
183+change code. Looking things up in code, issues, checks and docs while it
184+answers is <Soon />
185+</Aside>
186+
187+## Model routing
188+
189+Nobody picks a model to get work done. **Auto** sends each step of an
190+agent's work to the least costly tier that can do it:
191+
192+| Tier | Used for |
193+| --- | --- |
194+| **Fast** | Chat replies, triage, answering questions. |
195+| **Standard** | Making and revising changes, most reviews. |
196+| **Most capable** | Planning, very large reviews, and work that failed before. |
197+
198+Chat replies start on the fast tier. The models behind each tier are
199+listed under [Auto](/guides/models/#auto), and move to newer models as
200+providers release them, with nothing for you to change.
201+
202+An agent's definition does not pick a model. It **limits** Auto:
203+
204+| Limit | Means | For example |
205+| --- | --- | --- |
206+| **Floor** | Never route below this tier. | A reviewer that must be careful: *never below Standard*. Its chat replies run on Standard too. |
207+| **Ceiling** | Never route above this tier. | A cheap triage agent: *never above Standard*. |
208+| **Providers** | Where its model calls may go: g1t's models, your workspace's own provider, or both. | A support agent restricted to your own provider, so customer conversations never reach g1t's model accounts. |
209+
210+When a floor and a ceiling disagree, you are asked to fix them before
211+saving. If an old definition has them crossed, the ceiling wins: it is the
212+spending limit, and a limit is never crossed.
213+
214+### Your own providers
215+
216+Connect a model provider, or any compatible endpoint, in **Settings →
217+Integrations**. See [model providers](/guides/models/#connect-a-provider).
218+Then, on an agent:
219+
220+- **Both** (the default, when nothing is chosen): the agent may use
221+ whatever the workspace allows.
222+- **Only your own provider**: every model call goes to your account. Your
223+ provider bills you for the model, and g1t charges only the agent rate.
224+ The agent never touches g1t's models.
225+- **Only g1t's models**: the agent never uses your keys.
226+
227+For own endpoints, an advanced setting **pins** one model, written
228+`provider/model`. Pinning replaces the tier's model and is not shown by
229+default; most agents should leave it empty.
230+
231+Every reply records which model ran it.
232+
233+## Budgets
234+
235+An agent's budget is checked before every reply and every piece of work. A
236+cap left empty means no cap of its own, and only the workspace's limits
237+apply.
238+
239+| Cap | Resets | What happens when it is reached |
240+| --- | --- | --- |
241+| **Monthly** | On the 1st, UTC | The agent stops taking new work and says so in the thread: *I'm out of budget for October. An owner can raise my monthly limit on my profile.* |
242+| **Daily** | At midnight UTC | The agent says it has used today's budget and will be back tomorrow. |
243+| **Per task** | Each task | One reply or task can't spend more than this. |
244+
245+Above the agent's own caps, the workspace's limits still hold:
246+
247+1. The workspace's [spend limit](/guides/usage-and-billing/#your-spend-limit)
248+ and its [AI credit](/guides/usage-and-billing/#ai-credit).
249+2. The agent's monthly and daily caps.
250+3. The agent's per-task cap.
251+4. The plan's [per-run caps](/guides/usage-and-billing/#caps).
252+
253+The lowest of these is what one reply may spend. The agent's page shows
254+its spend this month against its monthly cap, and its status turns to
255+**Out of budget** when a cap stops it.
256+
257+## What an agent costs
258+
259+An agent costs nothing until someone talks to it or gives it work. Each
260+reply is charged to the workspace, against that agent's budget:
261+
262+| On | You pay |
263+| --- | --- |
264+| g1t's models | The model provider's price for the tokens, with no markup, plus the [g1t agent rate](/guides/usage-and-billing/#the-agent-rate) on the same tokens. |
265+| Your own provider | Your provider bills you for the model. g1t charges the agent rate for your own model key only. |
266+| Work in a sandbox | The above, plus [sandbox time](/guides/usage-and-billing/#sandbox-time) at cost plus 20%. |
267+
268+The live agent rate is on [g1t.sh/pricing](https://g1t.sh/pricing).
269+
270+### A worked example
271+
272+Ship answers *what is going out on Thursday?* in a thread of a dozen
273+messages. The reply reads about 3,000 tokens and writes about 300, on the
274+fast tier. Say the fast model costs $1 per million input tokens and $5 per
275+million output tokens, and the agent rate is $0.25 per million tokens
276+(these are example numbers; the real ones are on the pricing page):
277+
278+| Line | Calculation | Cost |
279+| --- | --- | --- |
280+| Model, input | 3,000 × $1 / 1,000,000 | $0.0030 |
281+| Model, output | 300 × $5 / 1,000,000 | $0.0015 |
282+| Agent rate | 3,300 × $0.25 / 1,000,000 | $0.0008 |
283+| **The reply** | | **about $0.005** |
284+
285+At that size, a $40 monthly budget pays for about 8,000 replies. On your own
286+provider, the same reply costs $0.0008 at g1t, and your provider bills the
287+model.
288+
289+## What it may do alone
290+
291+Each agent says what it may do by itself and what needs a person first.
292+These apply when the agent works on code and docs; rules, protected
293+branches and required checks still apply on top.
294+
295+| Action | Choices | Default |
296+| --- | --- | --- |
297+| Open pull requests | Alone, or after approval | Alone |
298+| Merge | Alone, after approval, or never | After approval |
299+| Deploy to production | After approval, or never | After approval |
300+| Edit docs | Alone, or as a suggestion | As a suggestion |
301+
302+An agent also never does more for someone than that person could do
303+themselves. See [what agents can do for whom](/guides/agent-access/).
304+
305+## The agent's page
306+
307+Each agent has a page at `g1t.sh/<workspace>/-/agents/<handle>`:
308+
309+| Tab | What it shows |
310+| --- | --- |
311+| **Desk** | What it is working on now, and what it is waiting on. |
312+| **Profile** | Its definition: identity, job, personality, models, budget and what it may do alone. Owners edit it here. |
313+| **Spend** | Spend this month against its budget. |
314+| **Activity** | What it did, from the audit log. |
315+
316+Its status is one of **Idle**, **Working**, **Waiting on you**, **Out of
317+budget** or **Paused**, on the Agents page and beside its name in Chat.
318+
319+To retire an agent, an owner **archives** it from its profile. It stops
320+answering, leaves the member lists, and its history stays.
321+
322+## Coming soon
323+
324+<CardGrid>
325+ <Card title="Updates on their own" icon="megaphone">
326+ When work starts, opens a pull request, gets stuck or ships, the agent
327+ says so in the thread that asked for it. Turn on a daily or weekly
328+ summary of what it shipped, what it is waiting on and what it spent.
329+ <Soon />
330+ </Card>
331+ <Card title="Members of teams" icon="users">
332+ Add an agent to a [team](/guides/teams/) like a person: it gets the
333+ team's channels and mentions, can be asked to review through the team,
334+ and shows on the team's page. <Soon />
335+ </Card>
336+ <Card title="Work from a message" icon="square-terminal">
337+ Ask for a change in chat and the agent opens a task with a live card in
338+ the thread: steps, files touched, cost so far. Sessions pause when idle
339+ and resume with full context. <Soon />
340+ </Card>
341+ <Card title="Create one in chat" icon="wand-sparkles">
342+ Describe the agent you want in a message, and confirm the draft card g1t
343+ answers with. Or commit `.g1t/agents/<handle>.md`. <Soon />
344+ </Card>
345+ <Card title="Coordination" icon="git-branch">
346+ Agents claim the issues, branches, environments and paths they work on,
347+ and agree in a visible thread when their work would overlap.
348+ <Soon />
349+ </Card>
350+ <Card title="Skills and triggers" icon="calendar-clock">
351+ Saved procedures an agent repeats, and schedules, events and webhooks
352+ that wake it without a mention. <Soon />
353+ </Card>
354+</CardGrid>
355+
356+## Next
357+
358+- [Chat](/guides/chat/): channels, DMs, threads and mentions.
359+- [What agents can do for whom](/guides/agent-access/): access, audiences
360+ and requests from people who don't work on code.
361+- [Model providers](/guides/models/): connect your own.
362+- [Usage and billing](/guides/usage-and-billing/): AI credit, spend limits
363+ and the agent rate.
+100−0
1+---
2+title: Agents in your chat app
3+description: Coming soon. Keep the chat app your company already lives in and use your g1t agents there, with the same memory, tasks, budget and approvals.
4+---
5+
6+import { Steps } from '@astrojs/starlight/components';
7+import Soon from '../../../components/Soon.astro';
8+import Aside from '../../../components/Aside.astro';
9+
10+<Soon />
11+
12+Some companies keep the chat app they already use for everyone, and only
13+the engineers use g1t, or nobody does at first. That should work well. The
14+**chat app integration** brings your g1t agents into that app. They are the
15+same agents wherever you talk to them, and the work lands in the same
16+place.
17+
18+This is not built yet. This page says how it will work.
19+
20+## One app
21+
22+An owner installs one app, named **g1t**, into the company's chat
23+workspace, from **Settings → Integrations** in g1t. That one app speaks for
24+every agent in your g1t workspace.
25+
26+## Each agent speaks as itself
27+
28+Messages are posted with the agent's own name and avatar, so `@ship` looks
29+like Ship in both places.
30+
31+| To reach an agent | Write |
32+| --- | --- |
33+| Mention the app and name the agent | `@g1t ask @reviewer to look at #418` |
34+| Use the agent's shortcut | `/g1t reviewer can you look at #418?` |
35+| DM the app | Pick which agent you are talking to. |
36+
37+Invite the app to a channel to let agents answer there when they are
38+mentioned. Cards for tasks, pull requests, checks, deploys and approvals
39+show in the app with their buttons, and **Open in g1t** goes to the full
40+view.
41+
42+## The same agent everywhere
43+
44+Whichever app a message arrives in, these are shared:
45+
46+- the agent itself, its job and its personality;
47+- its memory;
48+- its tasks;
49+- its budget;
50+- the [audit log](/guides/audit-log/).
51+
52+Every conversation in the chat app that an agent takes part in gets a
53+linked conversation in g1t, with links back. A pull request made from it
54+links to that thread, so *why was this changed?* leads back to the request.
55+
56+A task started in the chat app posts its updates there, and you can steer
57+it from a reply in that thread or from the task in g1t.
58+
59+## Approvals settle everywhere
60+
61+An approval is a button in the chat app message, a card in g1t and an item
62+in your inbox. Approving in any one of them settles all three.
63+
64+## Account linking
65+
66+The rules in [what agents can do for whom](/guides/agent-access/) depend on
67+knowing who is asking.
68+
69+<Steps>
70+
71+1. The first time someone talks to an agent from the chat app, the agent
72+ asks them to link their account.
73+2. They sign in to g1t once, in the browser.
74+3. From then on, the agent acts with their own g1t access.
75+
76+</Steps>
77+
78+**Unlinked people** are treated like
79+[members without Code access](/guides/agent-access/#members-without-code-access):
80+they can ask questions and get answers, and their change requests become
81+requests for the right team. They never get code changed, or see code an
82+agent wouldn't show them. Owners can require linking before an agent
83+answers at all.
84+
85+In a channel of the chat app, an agent answers only with what every linked
86+member of that channel may see, and treats unlinked members as having no
87+Code access. Private g1t content stays out of those channels unless an
88+owner allows it for that channel.
89+
90+## Bridged channels
91+
92+Optionally, link a channel in the chat app to a g1t channel. The two
93+mirror each other: messages, threads, edits and reactions. Engineers stay
94+in g1t while the rest of the company stays where it is, in one
95+conversation, and each message shows where it came from.
96+
97+<Aside type="note" title="Your data">
98+Messages from the chat app are kept only as part of linked conversations,
99+under the workspace's retention and file rules.
100+</Aside>
+230−0
1+---
2+title: Chat
3+description: Talk to your team and your agents in channels, direct messages and threads, live. Mention an agent and it answers in the thread.
4+---
5+
6+import { Steps } from '@astrojs/starlight/components';
7+import Soon from '../../../components/Soon.astro';
8+import Conversation from '../../../components/Conversation.astro';
9+import Message from '../../../components/Message.astro';
10+import Aside from '../../../components/Aside.astro';
11+import Card from '../../../components/Card.astro';
12+import { CardGrid } from '@astrojs/starlight/components';
13+
14+**Chat is where a workspace talks.** People and agents are members of the
15+same channels, write in the same threads and show up in the same
16+direct-message list. You can ask a teammate a question, DM an agent, or
17+mention one in the middle of a conversation, and the answer arrives in the
18+place you asked.
19+
20+Chat is open to every member of a workspace, on every plan, the free one
21+too. People chatting is never charged: no seats, no per-message price and
22+no history cutoff. Agents' replies are charged to the agent that wrote
23+them; see [what an agent costs](/guides/agents/#what-an-agent-costs).
24+
25+Open **Chat** in the rail on the left, or go to
26+`g1t.sh/<workspace>/-/chat`.
27+
28+## A conversation
29+
30+Here is a morning in `#web`, a channel with three people and two agents
31+in it, `@ship` (a release manager) and `@lint` (a reviewer):
32+
33+<Conversation title="# web" topic="The web app, its releases and its bugs">
34+<Message name="Priya Shah" time="10:42">
35+
36+@ship draft the note for Thursday's release. In it: #412 invite emails use the team name, #415 the CSV export is faster, #417 charts are fixed in dark mode.
37+
38+</Message>
39+<Message name="Ship" agent role="Release manager" time="10:42">
40+
41+**Thursday release**
42+
43+- Invite emails now use your team's name. (#412)
44+- CSV exports are faster. (#415)
45+- Charts read correctly in dark mode. (#417)
46+
47+#412 changes what customers receive. Want @lint to look at it first?
48+
49+</Message>
50+<Message name="Priya Shah" time="10:43">
51+
52+Yes please.
53+
54+</Message>
55+<Message name="Ship" agent role="Release manager" time="10:43">
56+
57+@lint can you review #412 today? Thursday's release waits on it.
58+
59+</Message>
60+</Conversation>
61+
62+Three things happened there:
63+
64+- Priya **mentioned** `@ship`, so Ship answered. In a channel, an agent
65+ answers only when it is mentioned. Nobody else's message woke it.
66+- Ship answered **in its own voice**: short and to the point, because its
67+ personality is *Terse operator*. See
68+ [job and personality](/guides/agents/#job-and-personality).
69+- Ship **mentioned another agent**. `@lint` is woken the same way, as one
70+ more hop in a chain that Priya started. Chains stop after six hops and
71+ hand back to a person, so two agents can't talk to each other forever.
72+
73+<Aside type="note" title="What a reply can see today">
74+A reply reads the conversation it is in: the thread, or the latest messages
75+of the channel or DM. Looking things up in code, issues, checks and docs
76+while answering is coming soon. Until then, paste what the agent needs, or
77+link it.
78+</Aside>
79+
80+## Channels
81+
82+A channel is a named conversation, such as `#web`, `#releases` or
83+`#support`. Every workspace starts with `#general`.
84+
85+| | Public channel | Private channel |
86+| --- | --- | --- |
87+| Who can find it | Every member, under **Browse channels** | Only its members |
88+| Who can join | Any member, by opening it and choosing **Join** | Only people a member adds |
89+| Who can read it | Every member | Only its members |
90+| Who agents answer for | Everyone in the workspace can read the reply | Only the channel's members can read the reply |
91+
92+### Create a channel
93+
94+<Steps>
95+
96+1. In Chat's sidebar, choose **Create a channel**.
97+2. Give it a **name**. Names are what people type after `#`: up to 80
98+ lowercase letters, digits, dashes and underscores. `Web Team` becomes
99+ `web-team`.
100+3. Optionally, say what the channel is for. That line shows under its name.
101+4. Turn on **Private** if only the people and agents you invite should
102+ find and read it.
103+5. Choose **Create channel**. You are its owner and first member. Invite
104+ people and agents once it is made.
105+
106+</Steps>
107+
108+### Join, leave and browse
109+
110+- **Browse channels** lists every public channel you haven't joined, at
111+ `g1t.sh/<workspace>/-/chat/browse`. Open one to read it, and choose
112+ **Join** to post in it and see it in your sidebar.
113+- You can leave a channel, and join a public one again at any time.
114+- When you join, everything already said counts as read, so a busy channel
115+ doesn't greet you with its whole history as unread.
116+
117+### Star and mute
118+
119+- **Star** a channel to keep it at the top of your sidebar.
120+- **Mute** a channel to stop its unread count from drawing your eye. You
121+ still see mentions of you.
122+
123+Both are yours alone; nobody else's sidebar changes.
124+
125+## Direct messages
126+
127+A direct message is a private conversation between you and up to eight
128+other members, people or agents. Start one with **New message** in Chat's
129+sidebar and pick who to message.
130+
131+- The same group of members always gets the same conversation, whatever
132+ order you pick them in.
133+- A DM with only yourself is a place for notes.
134+- **In a DM, every agent in it answers every message from a person.** You
135+ don't need to mention it. That is the quickest way to talk to an agent:
136+ open a DM with it and say what you need.
137+
138+## Threads
139+
140+Reply to any message to start a thread under it. The thread keeps a side
141+conversation out of the channel, and the message shows how many replies it
142+has and when the last one came.
143+
144+When you mention an agent in a thread, it reads that thread, not the whole
145+channel, and answers in it. Keep one request to one thread, and the agent
146+always has the context it needs.
147+
148+## Mentions
149+
150+Type `@` and a name to mention a person by username or an agent by handle:
151+`@priya`, `@ship`. The sidebar counts mentions of you separately from other
152+unread messages, so they stand out.
153+
154+| You mention | What happens |
155+| --- | --- |
156+| A person | It is counted as a mention in their sidebar. |
157+| An agent that is in the channel | The agent answers in the thread. |
158+| An agent that is not in the channel | Nothing. Invite the agent first. |
159+| `@g1t` | Nothing in Chat yet. `@g1t` is g1t's own agent and works on issues and pull requests; see [g1t's agent](/guides/working-with-g1t/). |
160+
161+An address such as `me@example.com` is never read as a mention.
162+
163+## Agents in channels
164+
165+Invite an agent to a channel the way you invite a person. Only agents of
166+the same workspace can be invited.
167+
168+<Aside type="note" title="An invite grants read, never write">
169+Adding an agent to a channel lets it read that channel and answer there. It
170+does not give the agent access to anything else. What an agent may change is
171+set on the agent itself; see [what agents can do for whom](/guides/agent-access/).
172+</Aside>
173+
174+While an agent works on a reply, the channel shows it as typing, the same
175+as a person. If it is out of budget, it says so in the thread instead of
176+answering, and tells you who can raise its limit.
177+
178+## Live
179+
180+Chat is live. Messages, edits and deletions appear for everyone in the
181+channel the moment they happen, without reloading. You also see:
182+
183+- **Typing.** Who is writing right now, people and agents.
184+- **Read state.** Your sidebar shows unread counts per channel, and the
185+ channel scrolls to the first message you haven't read.
186+
187+If your connection drops, Chat reconnects on its own and fills in what you
188+missed.
189+
190+## Edit and delete
191+
192+You can edit or delete your own messages. An edited message says so. A deleted message is removed for everyone; replies under it stay.
193+A message can be up to 40,000 characters, and is written in Markdown.
194+
195+## Coming soon
196+
197+These are planned and not built yet:
198+
199+<CardGrid>
200+ <Card title="Cards for everything" icon="layout-list">
201+ Pull requests, checks, deploys and approvals as cards in the channels
202+ linked to a project, with their buttons working in place. <Soon />
203+ </Card>
204+ <Card title="Mentions in your inbox" icon="inbox">
205+ Mentions and approvals as items in [your inbox](/guides/inbox/), so
206+ acting in either place settles both. <Soon />
207+ </Card>
208+ <Card title="Reactions and files" icon="paperclip">
209+ Reactions on messages, and files uploaded to a conversation, under the
210+ workspace's file rules. <Soon />
211+ </Card>
212+ <Card title="Your chat app" icon="messages-square">
213+ Keep the chat app your company already lives in and use your agents
214+ there. See [agents in your chat app](/guides/chat-app/). <Soon />
215+ </Card>
216+ <Card title="Search" icon="search">
217+ Messages in [site-wide search](/guides/search/), with only what you can
218+ read. <Soon />
219+ </Card>
220+ <Card title="Desktop and mobile apps" icon="smartphone">
221+ Installable apps with notifications. <Soon />
222+ </Card>
223+</CardGrid>
224+
225+## Next
226+
227+- [Agents](/guides/agents/): create one, give it a job and a voice, and set
228+ its budget.
229+- [What agents can do for whom](/guides/agent-access/): how an agent's
230+ answers depend on who is asking and who can read them.
+57−0
1+---
2+title: Docs
3+description: Docs is coming to g1t. A workspace's specs, runbooks and decisions, written together, read by agents and kept current by them.
4+---
5+
6+import Soon from '../../../components/Soon.astro';
7+import Aside from '../../../components/Aside.astro';
8+
9+<Soon />
10+
11+**Docs is the workspace's written knowledge**: specs, runbooks, decisions
12+and onboarding, in the same place as the conversations and the code they
13+describe. It is not built yet. This page says what it will do, so you can
14+plan for it.
15+
16+<Aside type="note" title="Until then">
17+Keep documentation in your repositories (`README.md`, a `docs/` folder),
18+where it changes through pull requests like any code. When Docs ships, a
19+project's `docs/` folder can appear as a read-only space beside the
20+workspace's own, with one search and one tree over both.
21+</Aside>
22+
23+## What it will be
24+
25+- **Spaces** hold pages: one per team or project, plus one for the whole
26+ workspace.
27+- **Pages** are edited together, live, with mentions of people, agents,
28+ issues, pull requests, channels and other pages.
29+- Every page has **history**, **comments**, **backlinks** and **owners**.
30+
31+## Agents and docs
32+
33+This is why Docs belongs next to Chat and Code:
34+
35+| | |
36+| --- | --- |
37+| **Agents read it** | Pages are indexed with the rest of the workspace's context, so what is written down reaches every answer. A space can be pinned to an agent as required reading. |
38+| **Agents write it** | An agent with write access to a space edits pages directly. Without it, its edit is a **suggestion**: tracked changes a person accepts or rejects inline, like a review. Every edit is attributed and in the page's history. |
39+| **Pages know what they describe** | A page can cite code: paths, symbols, endpoints, settings. When a merged pull request changes something a page cites, the page is marked possibly stale and its owners are told. If an agent owns the page, it drafts the update. |
40+| **Conversations become pages** | "Write this up" in a thread makes a page from it, linked both ways. Decisions made in chat get a home. |
41+
42+The **Documenter** [template](/guides/agents/#templates) is already
43+available. Today it works in chat; with Docs it will keep a space current:
44+updating pages after merges, writing release notes and the weekly summary,
45+and turning incident threads into postmortems.
46+
47+## How it will be stored
48+
49+Each space is a git repository in g1t's own storage, so history, blame and
50+export come with it, agents edit pages with the same tools they use on
51+code, and a self-hosted g1t keeps everything in git.
52+
53+## Pricing
54+
55+Docs will be included on every plan, like [Chat](/guides/chat/): pages,
56+editing and history, with no seats. Files in pages are on the storage
57+meter. See [pricing](https://g1t.sh/pricing).
+16−1
1919
2020 ## What is free
2121
22−Every workspace, with or without the plan, has the whole forge:
22+Every workspace, with or without the plan, has [Chat](/guides/chat/) and
23+the whole forge:
2324
25+- Channels, direct messages, threads, mentions and live delivery, with no
26+ limit on history. People chatting is never charged.
2427 - Public and private repositories, git, issues, pull requests and reviews.
2528 - Protected branches, code owners and secret push protection.
2629 - [Security scans](#storage-search-embeddings-and-scans) of history and
251254 and the month's statement totals it under **Paid by g1t's open-source
252255 pool**.
253256
257+## What agents cost in chat
258+
259+A workspace's own [agents](/guides/agents/) are charged only when they
260+answer or work. Each reply is charged like a run: the model provider's
261+price with no markup, plus the [agent rate](#the-agent-rate) on the tokens
262+it used, from the plan's included usage and then [AI credit](#ai-credit).
263+On your [own model provider](/guides/models/), only the agent rate is
264+charged here. Every reply also counts against that agent's own
265+[budget](/guides/agents/#budgets). An idle agent costs nothing. See
266+[what an agent costs](/guides/agents/#what-an-agent-costs) for a worked
267+example.
268+
254269 ## What is charged
255270
256271 | | Charged |
+78−30
11 ---
22 title: What is g1t?
3−description: g1t is where people and agents ship software together. Plan in issues, assign work to agents like teammates, land it through checks that hold, and deploy every change to the edge.
3+description: g1t is one workspace where a team and its agents talk, work, write things down and ship. Chat with people and agents, give agents a job and a budget, and land their work through checks that hold.
44 tableOfContents: false
55 prev: false
66 ---
77
88 import { CardGrid } from '@astrojs/starlight/components';
9+import Soon from '../../components/Soon.astro';
910 import Card from '../../components/Card.astro';
1011 import LinkCard from '../../components/LinkCard.astro';
1112
12−**g1t hosts your repositories and runs agents on them like teammates.** Write what you want
13−done; agents split it into issues, make a pull request for each, run your
14−workflows on it, review it, fix what the review finds and merge it under
15−your repository's rules. People work alongside them in the same issues,
16−pull requests and reviews, and every change can deploy to the edge.
13+**g1t is one workspace where your team and its agents work together.** You
14+talk in channels and direct messages, and agents are members there like
15+anyone else: each has a name, a job, a personality and a budget. You DM
16+them or mention them in a thread. Assign an issue to g1t, and the code it
17+changes lands through pull requests, checks and review. No separate chat
18+app, wiki or forge to stitch together.
19+
20+| Mode | What it is |
21+| --- | --- |
22+| **[Chat](/guides/chat/)** | Channels, direct messages and threads, live. People and agents are members alike. |
23+| **[Agents](/guides/agents/)** | Your workspace's agents: who they are, what they are doing, what they cost. |
24+| **[Docs](/guides/docs/)** <Soon /> | Specs, runbooks and decisions, written together and kept current by agents. |
25+| **[Code](/concepts/overview/)** | Repositories, issues, pull requests, checks, the merge queue and deployments. |
1726
1827 <CardGrid>
1928 <LinkCard
2029 title="Quickstart"
21− description="Create a workspace, push a repository and land your first change with an agent, in five minutes."
30+ description="Create a workspace, open Chat and talk to your first agent in a few minutes. Then put one on your code."
2231 href="/quickstart/"
2332 />
2433 <LinkCard
25− title="Bring a repository"
26− description="Push any git repository, rename .github to .g1t, and your Actions workflows run here, most without changes."
27− href="/guides/actions/"
34+ title="Create an agent"
35+ description="Start from a template, give it a job and a voice, limit its models and set its budget."
36+ href="/guides/agents/#create-an-agent"
2837 />
2938 <LinkCard
30− title="Start from GitHub"
31− description="Sign in with GitHub, then import, mirror or move your repositories, private ones too."
32− href="/guides/github/"
39+ title="Bring the whole company"
40+ description="Agents act with the asker's access and answer only with what the audience may see."
41+ href="/guides/agent-access/"
3342 />
3443 </CardGrid>
3544
36−## What you can do
45+## Talk and work
46+
47+<CardGrid>
48+ <Card title="Chat with your team" icon="messages-square">
49+ [Channels, DMs and threads](/guides/chat/), live, with typing and read
50+ state. Chat is included on every plan, with no seats and no history
51+ cutoff.
52+ </Card>
53+ <Card title="Agents as teammates" icon="bot">
54+ Each [agent](/guides/agents/) has a role, a job and a personality. DM it
55+ or mention it, and it answers in the thread, charged to its own budget.
56+ </Card>
57+ <Card title="Models, chosen for you" icon="route">
58+ Auto routes each step to the cheapest model that can do it. You set a
59+ [floor, a ceiling and the providers](/guides/agents/#model-routing) an
60+ agent may use, including your own.
61+ </Card>
62+ <Card title="Rails you can see" icon="shield-check">
63+ Budgets, [what an agent may do alone](/guides/agents/#what-it-may-do-alone),
64+ [who it acts for](/guides/agent-access/) and the
65+ [audit log](/guides/audit-log/), decided by policy, not by the agent.
66+ </Card>
67+</CardGrid>
68+
69+## Ship code
3770
3871 <CardGrid>
72+ <Card title="Issues still work" icon="circle-dot">
73+ Open an issue and [assign it to g1t](/guides/working-with-g1t/). It opens
74+ a pull request, runs your workflows, gets a review and revises until it
75+ passes. You step in only when asked, and [your inbox](/guides/inbox/)
76+ puts that first.
77+ </Card>
3978 <Card title="Hand off an outcome" icon="rocket">
4079 Describe a result. A planner turns it into issues in the order they land, and
4180 [agents work them in parallel](/guides/outcomes/).
4281 </Card>
43− <Card title="Agents that finish" icon="circle-check">
44− Each change runs your workflows, is reviewed and revised until its
45− [required checks](/guides/pull-requests/#required-status-checks) pass, then merged,
46− through the [merge queue](/guides/merge-queue/) if you turn it on. You step in only when asked,
47− and [your inbox](/guides/inbox/) puts that first.
82+ <Card title="Main stays green" icon="circle-check">
83+ Each change must pass its
84+ [required checks](/guides/pull-requests/#required-status-checks) to merge,
85+ through the [merge queue](/guides/merge-queue/) if you turn it on.
4886 </Card>
49− <Card title="Bring your own agent" icon="bot">
50− Claude Code, Codex, OpenCode, Cursor or any MCP client works on g1t as a teammate, and
87+ <Card title="Bring your own agent" icon="plug">
88+ Any coding agent or MCP client works on g1t as a teammate, and
5189 [records its session](/guides/bring-your-own-agent/) onto its pull requests.
5290 </Card>
53− <Card title="Actions workflows" icon="workflow">
54− Workflows written for GitHub Actions [run from `.g1t/workflows`](/guides/actions/),
55− most without changes, and failing ones send the agent back to fix the cause.
91+ <Card title="Workflows from .g1t" icon="workflow">
92+ Workflows [run from `.g1t/workflows`](/guides/actions/), most without
93+ changes, and failing ones send the agent back to fix the cause.
5694 </Card>
5795 <Card title="A preview for every change" icon="globe">
5896 On the g1t plan, every pull request gets a [live preview on g1t.page](/guides/deployments/), and
5997 the default branch goes to production. Apps cost nothing while no one visits.
6098 </Card>
61− <Card title="Secrets kept out, fixes opened" icon="shield-check">
99+ <Card title="Secrets kept out, fixes opened" icon="lock">
62100 Pushes that add a key or a token are refused, and each vulnerable dependency
63101 gets a [pull request that upgrades it](/guides/security/).
64102 </Card>
65− <Card title="Your models, your tools" icon="plug">
66− Use g1t's models or [your own providers](/guides/models/), call the same
67− models from your own code through the [AI Gateway](/guides/ai-gateway/), and pull context
68− from [Sentry, Jira and Linear](/guides/integrations/).
69− </Card>
70103 <Card title="Everything has an API" icon="book-open">
71104 Repositories, issues, pull requests, agents and workflows are all a [REST endpoint](/reference/api/) and an [MCP tool action](/reference/mcp/) away, with
72105 [webhooks](/guides/webhooks/) for every event.
73106 </Card>
74107 </CardGrid>
75108
109+## Bring a repository
110+
111+<CardGrid>
112+ <LinkCard
113+ title="Push any repository"
114+ description="Push any git repository, rename .github to .g1t, and your workflows run here, most without changes."
115+ href="/guides/actions/"
116+ />
117+ <LinkCard
118+ title="Import from another host"
119+ description="Sign in with your existing git host, then import, mirror or move your repositories, private ones too."
120+ href="/guides/github/"
121+ />
122+</CardGrid>
123+
76124 ## Look after a repository
77125
78126 <CardGrid>
+65−24
11 ---
22 title: Quickstart
3−description: From nothing to an agent landing a change on your repository, in five minutes.
3+description: Create a workspace, open Chat and talk to your first agent. Then put an agent on your code.
44 ---
55
66 import { Steps, Tabs, TabItem } from '@astrojs/starlight/components';
77 import Aside from '../../components/Aside.astro';
88 import AgentSetup from '../../components/AgentSetup.astro';
99
10−g1t hosts your git repositories, tracks work as
11−**issues**, and puts agents on them: each opens a **pull request**, which is
12−checked, reviewed and merged under your repository's rules.
10+A g1t workspace is where your team and its agents talk, work and ship.
11+This page gets you to your first conversation with an agent in a few
12+minutes, then shows the second path: an agent working an issue on your
13+repository.
1314
1415 <Aside type="tip" title="Let an assistant do it">
1516 Give your AI assistant [g1t.sh/llms.txt](https://g1t.sh/llms.txt) and ask it
1718 and approve it. It never sees your password.
1819 </Aside>
1920
21+## Talk to an agent
22+
2023 <Steps>
2124
2225 1. **Create an account and a workspace**
2427 [Sign up](https://g1t.sh/register) with your invite (g1t is invite-only
2528 for now; see [invites](/guides/authentication/#invites)) and confirm your
2629 email with the code g1t sends it (or the link in the same email). Then create a
27− **workspace**: it owns repositories and is the first part of their
28− address, `g1t.sh/<workspace>/<repo>`. See [workspaces](/guides/workspaces/).
30+ **workspace**: it holds your channels, agents and repositories. See
31+ [workspaces](/guides/workspaces/).
32+
33+2. **Open Chat**
2934
30−2. **Create an access token**
35+ Choose **Chat** in the rail, or go to `g1t.sh/<workspace>/-/chat`. Every
36+ workspace starts with `#general`. Say hello; people chatting is free on
37+ every plan. See [Chat](/guides/chat/).
38+
39+3. **Turn on agents**
3140
41+ Agents use models, and models cost money from the first token, so they
42+ need the [g1t plan](/guides/usage-and-billing/#the-g1t-plan) ($20 a month
43+ per workspace, with $10 of usage included) or a card check. A card check
44+ is never charged, and gives the workspace a one-time **$5 trial**. An
45+ owner does either on **Settings → Billing**. See
46+ [no card, no compute](/guides/usage-and-billing/#no-card-no-compute).
47+
48+4. **Create an agent**
49+
50+ Open **Agents** in the rail and choose **New agent**. Start from the
51+ **Release manager** template, or any other, and keep its defaults: it
52+ comes with a job, a personality and sensible model limits. Set a monthly
53+ budget, such as $10, so you know the most it can spend. See
54+ [create an agent](/guides/agents/#create-an-agent).
55+
56+5. **DM it**
57+
58+ In Chat, choose **New message**, pick your agent, and ask it something:
59+
60+ > What would you check before a release, and what do you need from me?
61+
62+ It answers in its own voice. In a DM it answers every message; in a
63+ channel, invite it first, then mention it by its handle, such as `@ship`.
64+ Each reply is charged to the agent's budget, usually a fraction of a
65+ cent. See [what an agent costs](/guides/agents/#what-an-agent-costs).
66+
67+</Steps>
68+
69+## Put an agent on your code
70+
71+Chat is the quickest way in. Issues and pull requests work exactly as they
72+always have, and are how agents change code today.
73+
74+<Steps>
75+
76+1. **Create an access token**
77+
3278 Git and the API authenticate with a token. Create one in
3379 [Settings → Access tokens](https://g1t.sh/settings/tokens) and copy it: it is shown once.
3480
3682 export G1T_TOKEN=g1t_…
3783 ```
3884
39−3. **Push a repository**
85+2. **Push a repository**
4086
4187 Pushing to a repository that does not exist creates it, as a **private**
4288 repository; make it public later if you want to. When git asks, give it your username and, as the password,
58104 [what is not supported yet](/about/limitations/#actions-and-runners).
59105 </Aside>
60106
61−4. **Open an issue**
107+3. **Open an issue**
62108
63109 On the repository's **Issues** page, choose **New issue**. Say what is
64110 wrong or wanted in the title and description; an agent works from that.
66112 merge is your workflows' checks: see
67113 [required status checks](/guides/pull-requests/#required-status-checks).
68114
69−5. **Start the plan, or check a card for the trial**
115+4. **Choose where models run**
70116
71− g1t's agents, and the sandboxes your workflows run in, are
72− compute, and compute needs the
73− [g1t plan](/guides/usage-and-billing/#the-g1t-plan) ($20 a month per
74− workspace, with $10 of usage included) or a card check. A card check is
75− never charged, and gives the workspace a one-time **$5 trial**. An owner
76− does either on **Settings → Billing**. See
77− [no card, no compute](/guides/usage-and-billing/#no-card-no-compute).
117+ Agent work on code uses the same plan or trial as step 3 above, plus
118+ sandbox time. To use your own model provider's key, or any compatible
119+ endpoint, connect it in your workspace's **Settings → Integrations**.
120+ Your provider bills you for the model; g1t charges the agent rate and
121+ the sandbox time. See [model providers](/guides/models/).
78122
79− To choose models, connect your own model provider's key, or any
80− compatible endpoint, in your workspace's **Settings → Integrations**.
81− Your provider bills you for the model; g1t charges the sandbox time.
82− See [model providers](/guides/models/).
83−
84−6. **Put an agent on it**
123+5. **Put an agent on it**
85124
86125 <Tabs>
87126 <TabItem label="g1t's agent">
101140 </TabItem>
102141 </Tabs>
103142
104−7. **Merge**
143+6. **Merge**
105144
106145 Open the pull request, read **Changes** and **Session**, and choose
107146 **Merge**, or turn on auto-merge in the repository's settings and let g1t
112151
113152 ## Next
114153
154+- [Agents](/guides/agents/): templates, personality, model limits and budgets.
155+- [What agents can do for whom](/guides/agent-access/): bring the whole company.
115156 - [How g1t works](/concepts/overview/): issues, pull requests, checks, review and merging.
116157 - [Hand off an outcome](/guides/outcomes/): an agent plans the issues and g1t lands them in order.
117158 - [GitHub Actions](/guides/actions/): your workflows, unchanged.
+12−0
442442 font-size: 0.625rem;
443443 line-height: 1.6;
444444 }
445+
446+/* "Soon" beside a sidebar entry for a feature not built yet: quiet, like
447+ the site's Coming soon tag, not a call to action. */
448+.sidebar-content .sl-badge {
449+ background: var(--g1t-raised);
450+ border: 1px solid var(--g1t-line-strong);
451+ color: var(--g1t-muted);
452+ font-family: var(--g1t-font-mono);
453+ font-size: 0.625rem;
454+ text-transform: uppercase;
455+ letter-spacing: 0.04em;
456+}
+503−0
1+import { Activity, ChevronRight, Plus, Route as RouteIcon, Sparkles } from "lucide-react";
2+import { type ReactNode, useState } from "react";
3+import { Form, NavLink, useLocation, useNavigation, useRouteLoaderData } from "react-router";
4+
5+import type { AgentTemplate, ModelTier, WorkspaceAgent } from "@g1t/contracts";
6+
7+import { AgentMark, StatusDot, statusLabel } from "./chat/marks";
8+import { Avatar } from "./ui";
9+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
10+import { Skeleton } from "./ui/skeleton";
11+import { AUTONOMY, type AgentDraft, PRESETS, TIER_LABELS, dollarsField } from "../lib/agent-form";
12+import type { AgentsLayoutData } from "../routes/workspace/agents/layout";
13+
14+/** The workspace's agents while an Agents page is open; undefined elsewhere. */
15+export function useAgentsData(): AgentsLayoutData | undefined {
16+ return useRouteLoaderData("routes/workspace/agents/layout") as AgentsLayoutData | undefined;
17+}
18+
19+/** An agent's face: its picture, or the sparkle mark. */
20+export function AgentFace({ agent, size = 20 }: { agent: Pick<WorkspaceAgent, "handle" | "avatar">; size?: number }) {
21+ return agent.avatar ? <Avatar name={agent.handle} image={agent.avatar} size={size} square /> : <AgentMark size={size} />;
22+}
23+
24+function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
25+ return (
26+ <NavLink
27+ to={to}
28+ end={end}
29+ prefetch="intent"
30+ className={({ isActive }) =>
31+ `group flex h-9 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
32+ isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
33+ }`
34+ }
35+ >
36+ <span className="flex w-5 shrink-0 justify-center">{icon}</span>
37+ <span className="min-w-0 grow truncate">{children}</span>
38+ {trailing}
39+ </NavLink>
40+ );
41+}
42+
43+/**
44+ * Agents mode's sidebar: who is working, who is waiting on you, and every
45+ * agent, each with its status; and the way to make a new one.
46+ */
47+export function AgentsSidebar({ slug }: { slug: string }) {
48+ const data = useAgentsData();
49+ const agents = data?.agents ?? [];
50+ const busy = agents.filter((a) => a.status === "working" || a.status === "waiting");
51+ const row = (agent: WorkspaceAgent) => (
52+ <li key={agent.id}>
53+ <NavLink
54+ to={`/${slug}/-/agents/${agent.handle}`}
55+ prefetch="intent"
56+ className={({ isActive }) =>
57+ `group flex h-11 items-center gap-2.5 rounded-md px-2 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
58+ }
59+ >
60+ <span className="relative shrink-0">
61+ <AgentFace agent={agent} size={26} />
62+ <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 ring-2 ring-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]" />
63+ </span>
64+ <span className="min-w-0 grow leading-tight">
65+ <span className="block truncate text-[0.8125rem] font-medium text-fg">{agent.display_name}</span>
66+ <span className="block truncate text-[0.6875rem] text-faint">
67+ {agent.status === "idle" ? `@${agent.handle}` : statusLabel(agent.status)}
68+ </span>
69+ </span>
70+ </NavLink>
71+ </li>
72+ );
73+ return (
74+ <div className="flex h-full flex-col">
75+ <div className="flex h-14 shrink-0 items-center justify-between border-b border-line pr-2.5 pl-4">
76+ <h2 className="text-[0.9375rem] font-semibold">Agents</h2>
77+ <NavLink
78+ to={`/${slug}/-/agents/new`}
79+ aria-label="New agent"
80+ className="flex size-8 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
81+ >
82+ <Plus size={16} />
83+ </NavLink>
84+ </div>
85+ <nav aria-label="Agents" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]">
86+ <div className="space-y-px">
87+ <SideLink to={`/${slug}/-/agents`} end icon={<Activity size={15} className="text-faint" />}>
88+ Fleet and runs
89+ </SideLink>
90+ </div>
91+ {!data ? (
92+ <div className="mt-5 space-y-3 px-2" aria-busy="true">
93+ {[0, 1, 2].map((i) => (
94+ <div key={i} className="flex items-center gap-2.5">
95+ <Skeleton className="size-6 rounded-md" />
96+ <Skeleton className="h-3 w-28" />
97+ </div>
98+ ))}
99+ </div>
100+ ) : data.agents == null ? (
101+ <p className="mt-4 px-2 text-xs leading-relaxed text-faint">The agents service didn't answer. Your agents will show here once it does.</p>
102+ ) : (
103+ <>
104+ {busy.length > 0 && (
105+ <Group title="At work">
106+ {busy.map(row)}
107+ </Group>
108+ )}
109+ <Group title={`All agents · ${agents.length}`}>
110+ {agents.map(row)}
111+ {agents.length === 0 && <li className="px-2 py-1 text-xs text-faint">None yet. Make one from a template.</li>}
112+ </Group>
113+ </>
114+ )}
115+ <NavLink
116+ to={`/${slug}/-/agents/new`}
117+ className={({ isActive }) =>
118+ `mt-2 flex h-9 items-center gap-2.5 rounded-md border border-dashed px-2 text-[0.8125rem] transition-colors ${
119+ isActive ? "border-accent/50 bg-accent/10 text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
120+ }`
121+ }
122+ >
123+ <span className="flex w-5 justify-center">
124+ <Plus size={15} />
125+ </span>
126+ New agent
127+ </NavLink>
128+ </nav>
129+ </div>
130+ );
131+}
132+
133+function Group({ title, children }: { title: string; children: ReactNode }) {
134+ return (
135+ <section className="mt-4">
136+ <h3 className="mb-1 px-2 text-xs font-medium text-faint">{title}</h3>
137+ <ul className="space-y-px">{children}</ul>
138+ </section>
139+ );
140+}
141+
142+/** The templates g1t ships, and a blank one, to start a new agent from. */
143+export function TemplateGallery({ templates, chosen, onChoose }: { templates: AgentTemplate[]; chosen: string | null; onChoose: (id: string | null) => void }) {
144+ return (
145+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
146+ {templates.map((template) => (
147+ <TemplateCard key={template.id} selected={chosen === template.id} onClick={() => onChoose(template.id)} title={template.display_name} handle={template.handle}>
148+ {template.role}
149+ </TemplateCard>
150+ ))}
151+ <TemplateCard selected={chosen === "blank"} onClick={() => onChoose("blank")} title="Blank agent" blank>
152+ Start from nothing: give it a name, a job and a voice.
153+ </TemplateCard>
154+ </div>
155+ );
156+}
157+
158+function TemplateCard({
159+ selected,
160+ onClick,
161+ title,
162+ handle,
163+ blank,
164+ children,
165+}: {
166+ selected: boolean;
167+ onClick: () => void;
168+ title: string;
169+ handle?: string;
170+ blank?: boolean;
171+ children: ReactNode;
172+}) {
173+ return (
174+ <button
175+ type="button"
176+ onClick={onClick}
177+ aria-pressed={selected}
178+ className={`group flex flex-col items-start rounded-xl border p-4 text-left transition-all ${
179+ selected
180+ ? "border-accent/60 bg-accent/[0.07] ring-1 ring-accent/40"
181+ : "border-line bg-surface hover:-translate-y-px hover:border-line-strong hover:bg-raised/40"
182+ }`}
183+ >
184+ <span className="flex w-full items-center gap-2.5">
185+ {blank ? (
186+ <span className="flex size-9 items-center justify-center rounded-[10px] border border-dashed border-line-strong text-muted">
187+ <Plus size={16} />
188+ </span>
189+ ) : (
190+ <AgentMark size={36} />
191+ )}
192+ <span className="min-w-0 grow">
193+ <span className="block truncate text-sm font-semibold">{title}</span>
194+ {handle && <span className="block truncate font-mono text-xs text-faint">@{handle}</span>}
195+ </span>
196+ <ChevronRight size={15} className={`shrink-0 transition-colors ${selected ? "text-accent" : "text-faint group-hover:text-muted"}`} />
197+ </span>
198+ <span className="mt-3 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{children}</span>
199+ </button>
200+ );
201+}
202+
203+const FIELD = "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
204+
205+function Label({ children, hint, error, htmlFor }: { children: ReactNode; hint?: ReactNode; error?: string; htmlFor?: string }) {
206+ return (
207+ <div className="mb-1.5 flex items-baseline justify-between gap-3">
208+ <label htmlFor={htmlFor} className="text-sm font-medium text-fg-soft">
209+ {children}
210+ </label>
211+ {error ? <span className="text-xs text-danger">{error}</span> : hint ? <span className="text-xs text-faint">{hint}</span> : null}
212+ </div>
213+ );
214+}
215+
216+/** A section of the form: what it is on the left, its fields on the right, on a wide screen. */
217+function FormSection({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) {
218+ return (
219+ <section className="grid gap-x-10 gap-y-4 border-t border-line py-8 first-of-type:border-t-0 first-of-type:pt-0 md:grid-cols-[13rem_1fr]">
220+ <div>
221+ <h2 className="text-sm font-semibold">{title}</h2>
222+ <p className="mt-1 text-[0.8125rem] leading-relaxed text-muted">{about}</p>
223+ </div>
224+ <div className="min-w-0 space-y-5">{children}</div>
225+ </section>
226+ );
227+}
228+
229+function TierSelect({ name, value, none }: { name: string; value: ModelTier | null; none: string }) {
230+ return (
231+ <Select name={name} defaultValue={value ?? "none"}>
232+ <SelectTrigger id={name}>
233+ <SelectValue />
234+ </SelectTrigger>
235+ <SelectContent>
236+ <SelectItem value="none">{none}</SelectItem>
237+ {(["small", "large", "frontier"] as const).map((tier) => (
238+ <SelectItem key={tier} value={tier} description={TIER_LABELS[tier].split(": ")[1]}>
239+ {TIER_LABELS[tier].split(":")[0]}
240+ </SelectItem>
241+ ))}
242+ </SelectContent>
243+ </Select>
244+ );
245+}
246+
247+function Money({ name, value, label, hint, error }: { name: string; value: number | null; label: string; hint: string; error?: string }) {
248+ return (
249+ <div>
250+ <Label htmlFor={name} hint={hint} error={error}>
251+ {label}
252+ </Label>
253+ <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
254+ <span className="pl-3 text-sm text-faint">$</span>
255+ <input
256+ id={name}
257+ name={name}
258+ inputMode="decimal"
259+ defaultValue={dollarsField(value)}
260+ placeholder="No cap"
261+ autoComplete="off"
262+ data-1p-ignore
263+ className="min-w-0 grow bg-transparent px-2 py-2 text-sm tabular-nums outline-none placeholder:text-faint"
264+ />
265+ </div>
266+ </div>
267+ );
268+}
269+
270+/**
271+ * Everything that defines an agent, for making one and for its Profile:
272+ * who it is, its job, its voice, the limits on routing, its budget, what it
273+ * may do alone, and how much it takes on at once.
274+ */
275+export function AgentForm({
276+ draft,
277+ errors,
278+ submit,
279+ intent,
280+ formKey,
281+}: {
282+ draft: AgentDraft;
283+ errors?: Record<string, string>;
284+ submit: string;
285+ intent: string;
286+ formKey?: string;
287+}) {
288+ const navigation = useNavigation();
289+ const { pathname } = useLocation();
290+ const busy = navigation.state === "submitting" && navigation.formAction === pathname;
291+ const [preset, setPreset] = useState(draft.personality_preset);
292+ const [advanced, setAdvanced] = useState(Boolean(draft.routing.pinned));
293+ const e = errors ?? {};
294+ const presetAbout = PRESETS.find((p) => p.value === preset)?.about;
295+ return (
296+ <Form method="post" key={formKey} className="pb-24">
297+ <input type="hidden" name="intent" value={intent} />
298+ <input type="hidden" name="template" value={draft.template ?? ""} />
299+ <FormSection title="Identity" about="How it shows in chat, mentions, assignees and the audit log.">
300+ <div className="grid gap-5 sm:grid-cols-2">
301+ <div>
302+ <Label htmlFor="display_name" error={e.display_name}>
303+ Display name
304+ </Label>
305+ <input id="display_name" name="display_name" defaultValue={draft.display_name} placeholder="Ship" className={FIELD} autoComplete="off" data-1p-ignore />
306+ </div>
307+ <div>
308+ <Label htmlFor="handle" error={e.handle} hint="Mentioned as @handle">
309+ Handle
310+ </Label>
311+ <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
312+ <span className="pl-3 font-mono text-sm text-faint">@</span>
313+ <input
314+ id="handle"
315+ name="handle"
316+ defaultValue={draft.handle}
317+ placeholder="ship"
318+ autoComplete="off"
319+ data-1p-ignore
320+ className="min-w-0 grow bg-transparent py-2 pr-3 pl-0.5 font-mono text-sm outline-none placeholder:text-faint"
321+ />
322+ </div>
323+ </div>
324+ </div>
325+ <div>
326+ <Label htmlFor="role" error={e.role} hint="One line">
327+ Role
328+ </Label>
329+ <input id="role" name="role" defaultValue={draft.role} placeholder="Release manager for g1t" className={FIELD} autoComplete="off" data-1p-ignore />
330+ </div>
331+ </FormSection>
332+
333+ <FormSection title="Job" about="What it is responsible for, how it works, and what good looks like. It reads this before every reply and task.">
334+ <div>
335+ <Label htmlFor="instructions" error={e.instructions}>
336+ Instructions
337+ </Label>
338+ <textarea
339+ id="instructions"
340+ name="instructions"
341+ defaultValue={draft.instructions}
342+ rows={9}
343+ placeholder={"Cut a release of g1t every Tuesday.\n- Read the merged pull requests since the last tag.\n- Write the release notes.\n- Ask in #releases before tagging."}
344+ className={`${FIELD} resize-y font-mono text-[0.8125rem] leading-relaxed`}
345+ />
346+ </div>
347+ </FormSection>
348+
349+ <FormSection title="Personality" about="Its voice only: tone, length, how it asks. A personality never changes what it may do.">
350+ <div>
351+ <input type="hidden" name="personality_preset" value={preset} />
352+ <div role="radiogroup" aria-label="Voice" className="grid grid-cols-2 gap-1 rounded-lg bg-surface p-1 ring-1 ring-line sm:grid-cols-4">
353+ {PRESETS.map((option) => (
354+ <button
355+ key={option.value}
356+ type="button"
357+ role="radio"
358+ aria-checked={preset === option.value}
359+ onClick={() => setPreset(option.value)}
360+ className={`h-8 rounded-md text-[0.8125rem] font-medium transition-colors ${
361+ preset === option.value ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg"
362+ }`}
363+ >
364+ {option.label}
365+ </button>
366+ ))}
367+ </div>
368+ <p className="mt-2 text-xs text-muted">{presetAbout}</p>
369+ </div>
370+ <div>
371+ <Label htmlFor="personality" hint="Optional">
372+ In its own words
373+ </Label>
374+ <textarea
375+ id="personality"
376+ name="personality"
377+ defaultValue={draft.personality}
378+ rows={3}
379+ placeholder="Dry humor is fine. No emoji. Asks one question at a time."
380+ className={`${FIELD} resize-y`}
381+ />
382+ </div>
383+ </FormSection>
384+
385+ <FormSection
386+ title="Models"
387+ about="Nobody picks a model to get work done. g1t routes each step to the tier it needs and records which ran. These only limit the routing."
388+ >
389+ <div className="flex items-start gap-3 rounded-xl border border-accent/25 bg-accent/[0.06] p-4">
390+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/15 text-accent">
391+ <RouteIcon size={16} />
392+ </span>
393+ <div>
394+ <p className="text-sm font-semibold">Auto: routed per step by need</p>
395+ <p className="mt-0.5 text-[0.8125rem] leading-relaxed text-muted">
396+ Replies and triage run on Fast; making changes on Standard; planning, hard reviews and retries after a failure on Most capable. It steps up after a failure and back down when the cheaper tier works.
397+ </p>
398+ </div>
399+ </div>
400+ <div className="grid gap-5 sm:grid-cols-3">
401+ <div>
402+ <Label htmlFor="floor">Floor</Label>
403+ <TierSelect name="floor" value={draft.routing.floor} none="No floor" />
404+ </div>
405+ <div>
406+ <Label htmlFor="ceiling" error={e.ceiling}>
407+ Ceiling
408+ </Label>
409+ <TierSelect name="ceiling" value={draft.routing.ceiling} none="No ceiling" />
410+ </div>
411+ <div>
412+ <Label htmlFor="providers">Providers</Label>
413+ <Select name="providers" defaultValue={draft.routing.providers[0] ?? "any"}>
414+ <SelectTrigger id="providers">
415+ <SelectValue />
416+ </SelectTrigger>
417+ <SelectContent>
418+ <SelectItem value="any" description="g1t's hosted models and the workspace's own">
419+ Any the workspace allows
420+ </SelectItem>
421+ <SelectItem value="g1t" description="Never the workspace's own keys">
422+ g1t's hosted models
423+ </SelectItem>
424+ <SelectItem value="workspace" description="Set up under Integrations; billed by the provider">
425+ The workspace's own providers
426+ </SelectItem>
427+ </SelectContent>
428+ </Select>
429+ </div>
430+ </div>
431+ <div className="rounded-lg border border-line">
432+ <button
433+ type="button"
434+ onClick={() => setAdvanced(!advanced)}
435+ aria-expanded={advanced}
436+ className="flex w-full items-center gap-2 px-3.5 py-2.5 text-left text-[0.8125rem] font-medium text-muted hover:text-fg"
437+ >
438+ <ChevronRight size={14} className={`transition-transform ${advanced ? "rotate-90" : ""}`} />
439+ Advanced
440+ <span className="font-normal text-faint">Pin one model</span>
441+ </button>
442+ <div hidden={!advanced} className="border-t border-line px-3.5 py-3.5">
443+ <Label htmlFor="pinned" error={e.pinned} hint="For your own endpoints. Turns routing off.">
444+ Pinned model
445+ </Label>
446+ <input id="pinned" name="pinned" defaultValue={draft.routing.pinned ?? ""} placeholder="provider/model" className={`${FIELD} font-mono`} autoComplete="off" data-1p-ignore />
447+ </div>
448+ </div>
449+ </FormSection>
450+
451+ <FormSection title="Budget" about="Checked before work starts and enforced while it runs. At 80% of the month it tells you; at 100% it takes no new tasks.">
452+ <div className="grid gap-5 sm:grid-cols-3">
453+ <Money name="monthly" label="Monthly" hint="Per calendar month" value={draft.budget.monthly_micros} error={e.monthly} />
454+ <Money name="daily" label="Daily" hint="Optional" value={draft.budget.daily_micros} error={e.daily} />
455+ <Money name="task" label="Per task" hint="Going over asks" value={draft.budget.task_micros} error={e.task} />
456+ </div>
457+ </FormSection>
458+
459+ <FormSection title="Autonomy" about="What it may do alone and what needs a person. Rulesets and branch protection still apply on top.">
460+ <div className="grid gap-5 sm:grid-cols-2">
461+ {(Object.keys(AUTONOMY) as (keyof typeof AUTONOMY)[]).map((key) => (
462+ <div key={key}>
463+ <Label htmlFor={key}>{AUTONOMY[key].label}</Label>
464+ <Select name={key} defaultValue={draft.autonomy[key]}>
465+ <SelectTrigger id={key}>
466+ <SelectValue />
467+ </SelectTrigger>
468+ <SelectContent>
469+ {AUTONOMY[key].options.map(([value, label]) => (
470+ <SelectItem key={value} value={value}>
471+ {label}
472+ </SelectItem>
473+ ))}
474+ </SelectContent>
475+ </Select>
476+ </div>
477+ ))}
478+ </div>
479+ </FormSection>
480+
481+ <FormSection title="Capacity" about="How many tasks it works at once. More wait on its desk, each showing its place in line.">
482+ <div className="max-w-40">
483+ <Label htmlFor="capacity" error={e.capacity}>
484+ Tasks at once
485+ </Label>
486+ <input id="capacity" name="capacity" type="number" min={1} max={20} defaultValue={draft.capacity} className={`${FIELD} tabular-nums`} />
487+ </div>
488+ </FormSection>
489+
490+ <div className="sticky bottom-0 z-10 -mx-4 flex items-center justify-end gap-3 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur sm:-mx-8 sm:px-8">
491+ {Object.keys(e).length > 0 && <p className="mr-auto text-sm text-danger">Check the fields marked above.</p>}
492+ <button
493+ type="submit"
494+ disabled={busy}
495+ className="inline-flex h-9 items-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-60"
496+ >
497+ <Sparkles size={15} />
498+ {busy ? "Saving…" : submit}
499+ </button>
500+ </div>
501+ </Form>
502+ );
503+}
+311−0
1+/**
2+ * Static pictures of the product for the landing page: a channel where a
3+ * person and an agent talk over a pull request, and an agent's profile.
4+ * Drawn in the app's own tokens so they read as the real thing; nothing
5+ * here loads data.
6+ */
7+import {
8+ AtSign,
9+ Bot,
10+ BookOpen,
11+ Check,
12+ Code2,
13+ GitMerge,
14+ GitPullRequest,
15+ Hash,
16+ Home,
17+ Inbox,
18+ MessagesSquare,
19+ Paperclip,
20+ SendHorizontal,
21+} from "lucide-react";
22+import type { ReactNode } from "react";
23+
24+import { cn } from "../lib/cn";
25+
26+/** A round letter avatar: lavender for agents, gray for people. */
27+function Face({ letter, agent, size = "md" }: { letter: string; agent?: boolean; size?: "sm" | "md" | "lg" }) {
28+ return (
29+ <span
30+ aria-hidden="true"
31+ className={cn(
32+ "flex shrink-0 items-center justify-center font-semibold",
33+ size === "sm" && "size-5 text-[0.625rem]",
34+ size === "md" && "size-8 text-xs",
35+ size === "lg" && "size-12 text-lg",
36+ agent ? "rounded-lg bg-accent/15 text-accent ring-1 ring-accent/30" : "rounded-full bg-raised text-fg-soft ring-1 ring-line-strong",
37+ )}
38+ >
39+ {letter}
40+ </span>
41+ );
42+}
43+
44+/** The small tag beside an agent's name. */
45+function AgentTag() {
46+ return (
47+ <span className="rounded bg-accent/15 px-1 py-px font-mono text-[0.5625rem] tracking-wide text-accent uppercase">Agent</span>
48+ );
49+}
50+
51+function Message({
52+ face,
53+ name,
54+ agent,
55+ role,
56+ time,
57+ children,
58+}: {
59+ face: ReactNode;
60+ name: string;
61+ agent?: boolean;
62+ role?: string;
63+ time: string;
64+ children: ReactNode;
65+}) {
66+ return (
67+ <div className="flex gap-3 px-4 py-2.5">
68+ {face}
69+ <div className="min-w-0 flex-1">
70+ <p className="flex flex-wrap items-baseline gap-x-1.5 text-[0.8125rem]">
71+ <span className="font-semibold text-fg">{name}</span>
72+ {agent && <AgentTag />}
73+ {role && <span className="text-xs text-faint">{role}</span>}
74+ <span className="text-[0.6875rem] text-faint">{time}</span>
75+ </p>
76+ <div className="mt-0.5 space-y-2 text-[0.8125rem] leading-6 text-fg-soft">{children}</div>
77+ </div>
78+ </div>
79+ );
80+}
81+
82+function Mention({ children }: { children: ReactNode }) {
83+ return <span className="rounded bg-accent/15 px-0.5 text-accent">{children}</span>;
84+}
85+
86+function Code({ children }: { children: ReactNode }) {
87+ return <code className="rounded bg-raised px-1 py-px font-mono text-[0.75rem] text-fg">{children}</code>;
88+}
89+
90+/** The pull request card an agent posts into the thread. */
91+function PullCard() {
92+ return (
93+ <div className="max-w-md rounded-xl bg-bg ring-1 ring-line">
94+ <div className="flex items-start gap-3 px-3.5 py-3">
95+ <GitPullRequest size={16} className="mt-0.5 shrink-0 text-success" />
96+ <div className="min-w-0 flex-1">
97+ <p className="text-[0.8125rem] font-medium text-fg">Invite emails use the team name</p>
98+ <p className="mt-0.5 font-mono text-[0.6875rem] text-faint">acme/web #418 · fixes #412</p>
99+ </div>
100+ <span className="shrink-0 rounded-full bg-success/15 px-2 py-0.5 text-[0.6875rem] font-medium text-success">Ready</span>
101+ </div>
102+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-line px-3.5 py-2 text-[0.6875rem] text-muted">
103+ <span className="flex items-center gap-1.5">
104+ <Check size={12} className="text-success" />3 of 3 checks passed
105+ </span>
106+ <span className="font-mono">
107+ <span className="text-success">+24</span> <span className="text-danger">−6</span>
108+ </span>
109+ <span>Reviewed by @lint</span>
110+ </div>
111+ </div>
112+ );
113+}
114+
115+const RAIL: { icon: ReactNode; label: string; on?: boolean }[] = [
116+ { icon: <Home size={16} />, label: "Home" },
117+ { icon: <Code2 size={16} />, label: "Code" },
118+ { icon: <MessagesSquare size={16} />, label: "Chat", on: true },
119+ { icon: <BookOpen size={16} />, label: "Docs" },
120+ { icon: <Bot size={16} />, label: "Agents" },
121+ { icon: <Inbox size={16} />, label: "Inbox" },
122+];
123+
124+const CHANNELS: { name: string; on?: boolean; unread?: number }[] = [
125+ { name: "web", on: true },
126+ { name: "releases", unread: 2 },
127+ { name: "support" },
128+ { name: "design" },
129+];
130+
131+const DMS: { name: string; agent?: boolean; letter: string }[] = [
132+ { name: "Ship", agent: true, letter: "S" },
133+ { name: "Lint", agent: true, letter: "L" },
134+ { name: "Priya Shah", letter: "P" },
135+];
136+
137+/**
138+ * A channel in the Chat mode: the rail, the channel list, and a short
139+ * exchange where an agent answers, links the pull request it made, and
140+ * waits for a person to approve the merge.
141+ */
142+export function ChatShot({ className }: { className?: string }) {
143+ return (
144+ <figure
145+ aria-label="A channel in g1t: a person asks an agent about a release, and the agent answers with the pull request it opened"
146+ className={cn("overflow-hidden rounded-2xl bg-surface text-left shadow-2xl shadow-black/40 ring-1 ring-line", className)}
147+ >
148+ <div className="flex min-h-[27rem]">
149+ {/* The rail. */}
150+ <nav aria-hidden="true" className="hidden w-14 shrink-0 flex-col items-center gap-1.5 border-r border-line bg-bg py-3 md:flex">
151+ {RAIL.map((item) => (
152+ <span
153+ key={item.label}
154+ className={cn(
155+ "flex size-9 items-center justify-center rounded-lg",
156+ item.on ? "bg-accent/15 text-accent" : "text-faint",
157+ )}
158+ >
159+ {item.icon}
160+ </span>
161+ ))}
162+ </nav>
163+
164+ {/* The channel list. */}
165+ <div aria-hidden="true" className="hidden w-52 shrink-0 border-r border-line px-2 py-3 lg:block">
166+ <p className="px-2 text-[0.8125rem] font-semibold text-fg">Acme</p>
167+ <p className="mt-4 px-2 text-[0.6875rem] font-medium text-faint">Channels</p>
168+ <ul className="mt-1 space-y-px">
169+ {CHANNELS.map((channel) => (
170+ <li
171+ key={channel.name}
172+ className={cn(
173+ "flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.8125rem]",
174+ channel.on ? "bg-raised text-fg" : "text-muted",
175+ )}
176+ >
177+ <Hash size={13} className="text-faint" />
178+ <span className="flex-1">{channel.name}</span>
179+ {channel.unread && (
180+ <span className="rounded-full bg-accent px-1.5 text-[0.625rem] font-semibold text-bg">{channel.unread}</span>
181+ )}
182+ </li>
183+ ))}
184+ </ul>
185+ <p className="mt-4 px-2 text-[0.6875rem] font-medium text-faint">Direct messages</p>
186+ <ul className="mt-1 space-y-px">
187+ {DMS.map((dm) => (
188+ <li key={dm.name} className="flex items-center gap-2 rounded-md px-2 py-1 text-[0.8125rem] text-muted">
189+ <Face letter={dm.letter} agent={dm.agent} size="sm" />
190+ <span className="flex-1">{dm.name}</span>
191+ {dm.agent && <span className="size-1.5 rounded-full bg-success" />}
192+ </li>
193+ ))}
194+ </ul>
195+ </div>
196+
197+ {/* The channel. */}
198+ <div className="flex min-w-0 flex-1 flex-col">
199+ <div className="flex items-center gap-2 border-b border-line px-4 py-3">
200+ <Hash size={15} className="text-faint" />
201+ <span className="text-sm font-semibold text-fg">web</span>
202+ <span className="hidden truncate text-xs text-faint sm:inline">The web app, its releases and its bugs</span>
203+ <span className="ml-auto flex -space-x-1.5" aria-hidden="true">
204+ <Face letter="P" size="sm" />
205+ <Face letter="S" agent size="sm" />
206+ <Face letter="L" agent size="sm" />
207+ <Face letter="D" size="sm" />
208+ </span>
209+ </div>
210+
211+ <div className="flex-1 py-2">
212+ <Message face={<Face letter="P" />} name="Priya Shah" time="10:42">
213+ <p>
214+ <Mention>@ship</Mention> what is left before Thursday&apos;s release?
215+ </p>
216+ </Message>
217+ <Message face={<Face letter="S" agent />} name="Ship" agent role="Release manager" time="10:42">
218+ <p>
219+ One thing: <span className="text-accent">#412</span>, invite emails show the workspace name instead of the
220+ team&apos;s. The fix is ready. The subject now comes from <Code>invite_subject()</Code>, with a test for both
221+ names.
222+ </p>
223+ <PullCard />
224+ <p>It needs one approval from a person, then it lands through the merge queue.</p>
225+ </Message>
226+ <Message face={<Face letter="P" />} name="Priya Shah" time="10:44">
227+ <p>Approved. Thanks!</p>
228+ </Message>
229+ <div className="flex items-center gap-2 px-4 pt-1 text-xs text-faint">
230+ <GitMerge size={13} className="text-merged" />
231+ <span>
232+ <span className="text-fg-soft">#418</span> merged to main · deploying to production
233+ </span>
234+ </div>
235+ </div>
236+
237+ <div className="px-4 pb-4">
238+ <div className="flex items-center gap-2 rounded-xl bg-bg px-3 py-2.5 text-[0.8125rem] text-faint ring-1 ring-line">
239+ <span className="flex-1">Message #web</span>
240+ <AtSign size={15} />
241+ <Paperclip size={15} />
242+ <SendHorizontal size={15} className="text-accent" />
243+ </div>
244+ </div>
245+ </div>
246+ </div>
247+ </figure>
248+ );
249+}
250+
251+function Field({ label, children, soon }: { label: string; children: ReactNode; soon?: boolean }) {
252+ return (
253+ <div className="grid grid-cols-[6.5rem_1fr] gap-3 border-t border-line px-5 py-3 text-[0.8125rem]">
254+ <dt className="text-faint">
255+ {label}
256+ {soon && <span className="sr-only"> (coming soon)</span>}
257+ </dt>
258+ <dd className="min-w-0 text-fg-soft">{children}</dd>
259+ </div>
260+ );
261+}
262+
263+/** An agent's profile: who it is, how it talks, what it may use and spend. */
264+export function AgentCardShot({ className }: { className?: string }) {
265+ const spent = 12.4;
266+ const budget = 40;
267+ return (
268+ <figure
269+ aria-label="An agent's profile in g1t: its role, job, personality, model routing, budget and what it may do alone"
270+ className={cn("overflow-hidden rounded-2xl bg-surface text-left ring-1 ring-line", className)}
271+ >
272+ <div className="flex items-center gap-4 px-5 py-5">
273+ <Face letter="S" agent size="lg" />
274+ <div className="min-w-0">
275+ <p className="flex items-center gap-2 font-semibold text-fg">
276+ Ship <AgentTag />
277+ </p>
278+ <p className="text-sm text-muted">@ship · Release manager for the web app</p>
279+ </div>
280+ <span className="ml-auto hidden items-center gap-1.5 rounded-full bg-success/10 px-2.5 py-1 text-xs text-success sm:flex">
281+ <span className="size-1.5 rounded-full bg-success" />
282+ Idle
283+ </span>
284+ </div>
285+ <dl>
286+ <Field label="Job">Cuts releases of acme/web on Thursdays, writes the notes, and asks before tagging.</Field>
287+ <Field label="Personality">
288+ <span className="text-fg">Crisp.</span> Short answers, no emoji, says what it is waiting on.
289+ </Field>
290+ <Field label="Models">
291+ <span className="text-fg">Auto</span>, never below Standard
292+ <span className="mt-1.5 flex flex-wrap gap-1.5">
293+ <span className="rounded-md bg-raised px-1.5 py-0.5 text-xs text-muted ring-1 ring-line">g1t&apos;s models</span>
294+ <span className="rounded-md bg-raised px-1.5 py-0.5 text-xs text-muted ring-1 ring-line">Your own provider</span>
295+ </span>
296+ </Field>
297+ <Field label="Budget">
298+ <span className="font-mono text-xs text-fg tabular-nums">
299+ ${spent.toFixed(2)} of ${budget} this month
300+ </span>
301+ <span className="mt-2 block h-1.5 overflow-hidden rounded-full bg-raised">
302+ <span className="block h-full rounded-full bg-accent" style={{ width: `${(spent / budget) * 100}%` }} />
303+ </span>
304+ <span className="mt-1.5 block text-xs text-faint">$5 a task</span>
305+ </Field>
306+ <Field label="Alone">Open pull requests</Field>
307+ <Field label="Asks first">Merge · Deploy to production</Field>
308+ </dl>
309+ </figure>
310+ );
311+}
+372−0
1+import { Check, Hash, Lock, Plus, Search, SquarePen, X } from "lucide-react";
2+import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
3+import { useFetcher, useNavigate, useRouteLoaderData } from "react-router";
4+
5+import type { ChatSidebar, Result } from "@g1t/contracts";
6+
7+import { AgentMark, AgentPill } from "./marks";
8+import { Avatar } from "../ui";
9+import { Hint } from "../ui/hint";
10+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
11+import { Switch } from "../ui/switch";
12+import { channelName } from "../../lib/chat";
13+import type { ChatLayoutData } from "../../routes/workspace/chat/layout";
14+
15+/** Chat mode's data, while a chat page is open; undefined elsewhere. */
16+export function useChatData(): ChatLayoutData | undefined {
17+ return useRouteLoaderData("routes/workspace/chat/layout") as ChatLayoutData | undefined;
18+}
19+
20+/** The sidebar's own fetcher: every component that refreshes it shares this one. */
21+export const SIDEBAR_FETCHER = "chat-sidebar";
22+
23+/** The sidebar as last read: refreshed in place, else what the page loaded. */
24+export function useChatSidebar(): { sidebar: ChatSidebar | null; refresh: () => void } {
25+ const data = useChatData();
26+ const fetcher = useFetcher<Result<ChatSidebar>>({ key: SIDEBAR_FETCHER });
27+ const slug = data?.slug;
28+ const refresh = useCallback(() => {
29+ if (slug) fetcher.load(`/${slug}/-/chat/api`);
30+ // eslint-disable-next-line react-hooks/exhaustive-deps
31+ }, [slug]);
32+ const fresh = fetcher.data?.ok ? fetcher.data.value : null;
33+ const sidebar = fresh ?? data?.sidebar ?? null;
34+ // A conversation read on its page reads as read everywhere at once, until
35+ // the service's next answer says so itself.
36+ const [read, setRead] = useState<ReadonlySet<string>>(new Set());
37+ useEffect(() => {
38+ const onRead = (event: Event) => setRead((now) => new Set(now).add(String((event as CustomEvent).detail)));
39+ window.addEventListener("g1t:chat-read", onRead);
40+ return () => window.removeEventListener("g1t:chat-read", onRead);
41+ }, []);
42+ useEffect(() => setRead(new Set()), [sidebar]);
43+ const shown = useMemo(
44+ () =>
45+ sidebar && read.size > 0
46+ ? { ...sidebar, entries: sidebar.entries.map((e) => (read.has(e.channel.id) ? { ...e, unread: 0, mentions: 0 } : e)) }
47+ : sidebar,
48+ [sidebar, read],
49+ );
50+ return { sidebar: shown, refresh };
51+}
52+
53+/**
54+ * Sends one request to the chat's JSON route and answers with the
55+ * service's result; a network failure is a failure like any other. Doing
56+ * something that changes the sidebar refreshes it.
57+ */
58+export function useChatSend(slug: string) {
59+ const fetcher = useFetcher<Result<ChatSidebar>>({ key: SIDEBAR_FETCHER });
60+ return useCallback(
61+ async <T = unknown,>(body: Record<string, unknown>): Promise<Result<T>> => {
62+ try {
63+ const response = await fetch(`/${slug}/-/chat/api`, {
64+ method: "POST",
65+ headers: { "content-type": "application/json" },
66+ body: JSON.stringify(body),
67+ });
68+ const result = (await response.json()) as Result<T>;
69+ if (result.ok && body.intent !== "post" && body.intent !== "read") fetcher.load(`/${slug}/-/chat/api`);
70+ return result;
71+ } catch {
72+ return { ok: false, error: { code: "conflict", message: "Couldn't reach g1t. Check your connection and try again." } };
73+ }
74+ },
75+ // eslint-disable-next-line react-hooks/exhaustive-deps
76+ [slug],
77+ );
78+}
79+
80+const QUIET_BUTTON =
81+ "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-surface hover:text-fg";
82+const ICON_BUTTON =
83+ "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";
84+
85+/** Making a channel: its name, what it is for, and whether it is private. */
86+export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) {
87+ const [open, setOpen] = useState(false);
88+ const [name, setName] = useState("");
89+ const [topic, setTopic] = useState("");
90+ const [secret, setSecret] = useState(false);
91+ const [error, setError] = useState<string | null>(null);
92+ const [busy, setBusy] = useState(false);
93+ const send = useChatSend(slug);
94+ const navigate = useNavigate();
95+ const clean = channelName(name);
96+ const submit = async () => {
97+ if (!clean) return setError("Give the channel a name.");
98+ setBusy(true);
99+ setError(null);
100+ const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: secret });
101+ setBusy(false);
102+ if (!made.ok) return setError(made.error.message);
103+ setOpen(false);
104+ setName("");
105+ setTopic("");
106+ navigate(made.value.to);
107+ };
108+ return (
109+ <>
110+ {variant === "icon" ? (
111+ <Hint label="Create a channel">
112+ <button type="button" aria-label="Create a channel" onClick={() => setOpen(true)} className={ICON_BUTTON}>
113+ <Plus size={14} />
114+ </button>
115+ </Hint>
116+ ) : (
117+ <button type="button" onClick={() => setOpen(true)} className={QUIET_BUTTON}>
118+ <Hash size={15} />
119+ Create a channel
120+ </button>
121+ )}
122+ <Dialog open={open} onOpenChange={setOpen}>
123+ <DialogContent>
124+ <DialogHeader>
125+ <DialogTitle>Create a channel</DialogTitle>
126+ <DialogDescription>A place for one topic: a project, a team, a launch. Invite people and agents once it's made.</DialogDescription>
127+ </DialogHeader>
128+ <form
129+ className="grid gap-4"
130+ onSubmit={(event) => {
131+ event.preventDefault();
132+ void submit();
133+ }}
134+ >
135+ <label className="grid gap-1.5">
136+ <span className="text-sm font-medium text-muted">Name</span>
137+ <span className="flex h-10 items-center gap-1.5 rounded-md border border-line bg-bg px-3 focus-within:border-accent-dim">
138+ {secret ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />}
139+ <input
140+ autoFocus
141+ value={name}
142+ onChange={(event) => setName(event.target.value)}
143+ placeholder="release-train"
144+ maxLength={80}
145+ autoComplete="off"
146+ data-1p-ignore
147+ className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
148+ />
149+ </span>
150+ {name && clean !== name && <span className="text-xs text-faint">It will be #{clean || "…"}</span>}
151+ </label>
152+ <label className="grid gap-1.5">
153+ <span className="text-sm font-medium text-muted">
154+ Topic <span className="font-normal text-faint">(optional)</span>
155+ </span>
156+ <input
157+ value={topic}
158+ onChange={(event) => setTopic(event.target.value)}
159+ placeholder="What this channel is for"
160+ maxLength={250}
161+ autoComplete="off"
162+ data-1p-ignore
163+ className="h-10 rounded-md border border-line bg-bg px-3 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
164+ />
165+ </label>
166+ <label className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg/60 p-3">
167+ <span>
168+ <span className="block text-sm font-medium">Private</span>
169+ <span className="mt-0.5 block text-xs text-muted">Only people and agents you invite can find and read it.</span>
170+ </span>
171+ <Switch checked={secret} onCheckedChange={setSecret} aria-label="Private" />
172+ </label>
173+ {error && <p className="text-sm text-danger">{error}</p>}
174+ <DialogFooter>
175+ <button type="button" onClick={() => setOpen(false)} className={QUIET_BUTTON}>
176+ Cancel
177+ </button>
178+ <button
179+ type="submit"
180+ disabled={busy || !clean}
181+ className="inline-flex h-9 items-center justify-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-50"
182+ >
183+ {busy ? "Creating…" : "Create channel"}
184+ </button>
185+ </DialogFooter>
186+ </form>
187+ </DialogContent>
188+ </Dialog>
189+ </>
190+ );
191+}
192+
193+/** Someone a new message can go to. */
194+type Pickable = { key: string; kind: "user" | "agent"; name: string; display: string; avatar: string | null; role?: string | null };
195+
196+/**
197+ * A new direct message: choose people, agents or both, then open the
198+ * conversation between you (the same one each time for the same people).
199+ */
200+export function NewMessageButton({ slug, variant = "icon", children }: { slug: string; variant?: "icon" | "button"; children?: ReactNode }) {
201+ const [open, setOpen] = useState(false);
202+ return (
203+ <>
204+ {variant === "icon" ? (
205+ <Hint label="New message">
206+ <button type="button" aria-label="New message" onClick={() => setOpen(true)} className={ICON_BUTTON.replace("size-7", "size-8")}>
207+ <SquarePen size={15} />
208+ </button>
209+ </Hint>
210+ ) : (
211+ <button type="button" onClick={() => setOpen(true)} className={QUIET_BUTTON}>
212+ <SquarePen size={15} />
213+ {children ?? "New message"}
214+ </button>
215+ )}
216+ <Dialog open={open} onOpenChange={setOpen}>
217+ <DialogContent className="max-w-md gap-0 p-0" showClose={false}>
218+ {open && <NewMessage slug={slug} onDone={() => setOpen(false)} />}
219+ </DialogContent>
220+ </Dialog>
221+ </>
222+ );
223+}
224+
225+function NewMessage({ slug, onDone }: { slug: string; onDone: () => void }) {
226+ const data = useChatData();
227+ const [query, setQuery] = useState("");
228+ const [chosen, setChosen] = useState<Pickable[]>([]);
229+ const [active, setActive] = useState(0);
230+ const [error, setError] = useState<string | null>(null);
231+ const [busy, setBusy] = useState(false);
232+ const input = useRef<HTMLInputElement>(null);
233+ const send = useChatSend(slug);
234+ const navigate = useNavigate();
235+ const everyone = useMemo<Pickable[]>(() => {
236+ const me = data?.me.username.toLowerCase();
237+ return [
238+ ...(data?.agents ?? []).map((agent) => ({
239+ key: `agent:${agent.id}`,
240+ kind: "agent" as const,
241+ name: agent.handle,
242+ display: agent.display_name,
243+ avatar: agent.avatar,
244+ role: agent.role,
245+ })),
246+ ...(data?.people ?? [])
247+ .filter((person) => person.name.toLowerCase() !== me)
248+ .map((person) => ({ key: `user:${person.name}`, kind: "user" as const, name: person.name, display: person.display_name, avatar: person.avatar })),
249+ ];
250+ }, [data]);
251+ const q = query.trim().toLowerCase().replace(/^@/, "");
252+ const picked = new Set(chosen.map((p) => p.key));
253+ const options = everyone.filter(
254+ (p) => !picked.has(p.key) && (!q || p.name.toLowerCase().includes(q) || p.display.toLowerCase().includes(q)),
255+ );
256+ const take = (person: Pickable) => {
257+ setChosen((now) => [...now, person]);
258+ setQuery("");
259+ setActive(0);
260+ input.current?.focus();
261+ };
262+ const go = async () => {
263+ if (chosen.length === 0) return;
264+ setBusy(true);
265+ setError(null);
266+ const opened = await send<{ to: string }>({ intent: "dm", members: chosen.map((p) => p.key) });
267+ setBusy(false);
268+ if (!opened.ok) return setError(opened.error.message);
269+ onDone();
270+ navigate(opened.value.to);
271+ };
272+ return (
273+ <div className="flex max-h-[min(36rem,calc(100dvh-2rem))] flex-col">
274+ <div className="border-b border-line px-5 pt-5 pb-4">
275+ <DialogTitle>New message</DialogTitle>
276+ <DialogDescription className="mt-1">To one person, a few, or an agent. Agents answer in the conversation.</DialogDescription>
277+ <div className="mt-4 flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1.5 focus-within:border-accent-dim">
278+ <span className="pl-1 text-sm text-faint">To:</span>
279+ {chosen.map((person) => (
280+ <span key={person.key} className="flex h-6 items-center gap-1.5 rounded-md bg-raised pr-1 pl-1 text-[0.8125rem]">
281+ {person.kind === "agent" ? <AgentMark size={16} /> : <Avatar name={person.name} image={person.avatar} size={16} />}
282+ {person.display}
283+ <button
284+ type="button"
285+ aria-label={`Remove ${person.display}`}
286+ onClick={() => setChosen((now) => now.filter((p) => p.key !== person.key))}
287+ className="rounded p-0.5 text-faint hover:bg-line hover:text-fg"
288+ >
289+ <X size={12} />
290+ </button>
291+ </span>
292+ ))}
293+ <input
294+ ref={input}
295+ autoFocus
296+ value={query}
297+ onChange={(event) => {
298+ setQuery(event.target.value);
299+ setActive(0);
300+ }}
301+ onKeyDown={(event) => {
302+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
303+ event.preventDefault();
304+ const count = Math.max(1, options.length);
305+ setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
306+ } else if (event.key === "Enter") {
307+ event.preventDefault();
308+ const person = options[active];
309+ if (person && q) take(person);
310+ else void go();
311+ } else if (event.key === "Backspace" && !query && chosen.length) {
312+ setChosen((now) => now.slice(0, -1));
313+ }
314+ }}
315+ placeholder={chosen.length ? "" : "Name or @handle"}
316+ aria-label="Who to message"
317+ autoComplete="off"
318+ data-1p-ignore
319+ className="h-7 min-w-24 grow bg-transparent px-1 text-sm outline-none placeholder:text-faint"
320+ />
321+ </div>
322+ </div>
323+ <ul role="listbox" aria-label="People and agents" className="min-h-0 grow overflow-y-auto p-1.5">
324+ {options.length === 0 && (
325+ <li className="flex items-center gap-2 px-3 py-6 text-sm text-muted">
326+ <Search size={14} />
327+ {everyone.length === 0 ? "No one else is in this workspace yet." : "No one matches."}
328+ </li>
329+ )}
330+ {options.map((person, index) => (
331+ <li
332+ key={person.key}
333+ role="option"
334+ aria-selected={index === active}
335+ onMouseEnter={() => setActive(index)}
336+ onClick={() => take(person)}
337+ className={`flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 ${index === active ? "bg-raised" : ""}`}
338+ >
339+ {person.kind === "agent" ? <AgentMark size={28} /> : <Avatar name={person.name} image={person.avatar} size={28} />}
340+ <span className="min-w-0 grow">
341+ <span className="flex items-center gap-1.5 text-sm font-medium">
342+ <span className="truncate">{person.display}</span>
343+ {person.kind === "agent" && <AgentPill />}
344+ </span>
345+ <span className="block truncate text-xs text-faint">
346+ @{person.name}
347+ {person.role ? ` · ${person.role}` : ""}
348+ </span>
349+ </span>
350+ {index === active && <Check size={14} className="text-faint" />}
351+ </li>
352+ ))}
353+ </ul>
354+ <div className="flex items-center justify-between gap-3 border-t border-line px-5 py-3">
355+ <span className="min-w-0 truncate text-sm text-danger">{error}</span>
356+ <div className="flex shrink-0 gap-2">
357+ <button type="button" onClick={onDone} className={QUIET_BUTTON}>
358+ Cancel
359+ </button>
360+ <button
361+ type="button"
362+ disabled={busy || chosen.length === 0}
363+ onClick={() => void go()}
364+ className="inline-flex h-9 items-center justify-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-50"
365+ >
366+ {busy ? "Opening…" : "Open conversation"}
367+ </button>
368+ </div>
369+ </div>
370+ </div>
371+ );
372+}
+1097−0
1+import {
2+ ArrowLeft,
3+ BellOff,
4+ CircleDot,
5+ GitPullRequest,
6+ Hash,
7+ ListChecks,
8+ Lock,
9+ MessageSquareText,
10+ PanelRight,
11+ Rocket,
12+ ShieldCheck,
13+ Sparkles,
14+ Star,
15+ UserPlus,
16+ Users,
17+ X,
18+} from "lucide-react";
19+import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
20+import { Link, useRouteLoaderData, useSearchParams } from "react-router";
21+
22+import { type ChannelMember, type ChatLiveEvent, type ChatMessage, type MemberProfile, type MessageCard, type Result, hasCodeAccess } from "@g1t/contracts";
23+
24+import { useChatData, useChatSend, useChatSidebar } from "./actions";
25+import { Composer } from "./composer";
26+import { type LiveState, useChatLive } from "./live";
27+import { AgentPill, MemberAvatar } from "./marks";
28+import { MessageText, type TextContext } from "./text";
29+import { Badge, type BadgeTone } from "../ui/badge";
30+import { Hint } from "../ui/hint";
31+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
32+import { type Mentionable, type ShownMessage, mergeMessages, shownName, timeline } from "../../lib/chat";
33+import { codeAccessPath } from "../../lib/workspace-nav";
34+import type { ChannelData } from "../../routes/workspace/chat/channel";
35+
36+type Loaded = Extract<ChannelData, { unavailable: false }>;
37+
38+/** True once the page has hydrated: times and days are the reader's own from then on. */
39+function useHydrated(): boolean {
40+ const [hydrated, setHydrated] = useState(false);
41+ useEffect(() => setHydrated(true), []);
42+ return hydrated;
43+}
44+
45+function clock(at: string, zone: string | undefined): string {
46+ return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: zone });
47+}
48+
49+/** Whether the viewer uses Code in this workspace (docs/WORKSPACE.md, "Members without Code"). */
50+export function useCodeAccess(slug: string): boolean {
51+ const root = useRouteLoaderData("root") as { user?: { workspaces?: { slug: string; code_access?: boolean }[] } | null } | undefined;
52+ return hasCodeAccess(root?.user?.workspaces?.find((m) => m.slug === slug) ?? null);
53+}
54+
55+/** The project a channel of the same name is about, for a bare `#123`. */
56+function useProjectNamed(name: string | null): string | null {
57+ const root = useRouteLoaderData("root") as { shell?: { repos?: { name: string }[] } } | undefined;
58+ if (!name) return null;
59+ return root?.shell?.repos?.some((repo) => repo.name === name) ? name : null;
60+}
61+
62+function newClientId(): string {
63+ return `tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
64+}
65+
66+/** A conversation: its header, its messages, the box to write in, and its thread and details beside it. */
67+export function ChannelView({ data }: { data: Loaded }) {
68+ const chatData = useChatData();
69+ const slug = chatData?.slug ?? "";
70+ const me = chatData?.me ?? null;
71+ const send = useChatSend(slug);
72+ const { sidebar, refresh } = useChatSidebar();
73+ const hydrated = useHydrated();
74+ const zone = hydrated ? undefined : "UTC";
75+ const code = useCodeAccess(slug);
76+ const project = useProjectNamed(data.channel.kind === "channel" ? data.channel.name : null);
77+ const [params, setParams] = useSearchParams();
78+ const threadId = params.get("thread");
79+
80+ const [messages, setMessages] = useState<ShownMessage[]>(() => mergeMessages([], data.messages));
81+ const [older, setOlder] = useState<string | null>(data.older);
82+ const [loadingOlder, setLoadingOlder] = useState(false);
83+ const [thread, setThread] = useState<ShownMessage[] | null>(null);
84+ const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map());
85+ const [joined, setJoined] = useState(data.joined);
86+ const [members, setMembers] = useState<ChannelMember[]>(data.members);
87+ const [info, setInfo] = useState(false);
88+ const scroller = useRef<HTMLDivElement>(null);
89+ const stick = useRef(true);
90+
91+ const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id);
92+ const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false;
93+ const muted = entry?.muted ?? false;
94+ const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member);
95+ const isDm = data.channel.kind === "dm";
96+
97+ // The details panel: open by default on a wide screen, as last left.
98+ useEffect(() => {
99+ try {
100+ const saved = localStorage.getItem("chat-info");
101+ setInfo(saved ? saved === "1" : window.matchMedia("(min-width: 1280px)").matches && !isDm);
102+ } catch {
103+ setInfo(false);
104+ }
105+ }, [isDm]);
106+ const toggleInfo = () =>
107+ setInfo((now) => {
108+ try {
109+ localStorage.setItem("chat-info", now ? "0" : "1");
110+ } catch {
111+ // Not kept: fine.
112+ }
113+ return !now;
114+ });
115+
116+ const people = useMemo<Mentionable[]>(
117+ () => [
118+ ...(chatData?.agents ?? []).map((agent) => ({ kind: "agent" as const, name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role })),
119+ ...(chatData?.people ?? []).filter((p) => p.name !== me?.username),
120+ ],
121+ [chatData, me?.username],
122+ );
123+ const context = useMemo<TextContext>(
124+ () => ({
125+ slug,
126+ me: me?.username ?? null,
127+ agents: new Set((chatData?.agents ?? []).map((a) => a.handle.toLowerCase())),
128+ channels: new Set((sidebar?.entries ?? []).filter((e) => e.channel.kind === "channel").map((e) => e.channel.name ?? "")),
129+ project,
130+ codeLink: code ? undefined : (href: string) => (/^\/[^/]+\/(?!-\/)[^/]+/.test(href) ? codeAccessPath(slug, href) : href),
131+ }),
132+ [slug, me?.username, chatData?.agents, sidebar?.entries, project, code],
133+ );
134+
135+ // Keeps the newest message in view while the reader is at the bottom.
136+ const toBottom = useCallback((smooth = false) => {
137+ const element = scroller.current;
138+ if (element) element.scrollTo({ top: element.scrollHeight, behavior: smooth ? "smooth" : "auto" });
139+ }, []);
140+ useEffect(() => toBottom(), [toBottom]);
141+ useEffect(() => {
142+ if (stick.current) toBottom(true);
143+ }, [messages.length, typing.size, toBottom]);
144+
145+ // Read up to the newest message whenever it is in view.
146+ const lastRead = useRef<string | null>(null);
147+ const markRead = useCallback(() => {
148+ if (!joined || document.visibilityState !== "visible") return;
149+ const newest = [...messages].reverse().find((m) => !m.pending);
150+ if (!newest || newest.id === lastRead.current) return;
151+ lastRead.current = newest.id;
152+ void send({ intent: "read", channel_id: data.channel.id, id: newest.id });
153+ window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: data.channel.id }));
154+ }, [messages, joined, send, data.channel.id]);
155+ useEffect(() => {
156+ const timer = setTimeout(markRead, 400);
157+ document.addEventListener("visibilitychange", markRead);
158+ window.addEventListener("focus", markRead);
159+ return () => {
160+ clearTimeout(timer);
161+ document.removeEventListener("visibilitychange", markRead);
162+ window.removeEventListener("focus", markRead);
163+ };
164+ }, [markRead]);
165+
166+ // The thread open beside the conversation, from `?thread=`.
167+ const loadThread = useCallback(async () => {
168+ if (!threadId) return setThread(null);
169+ try {
170+ const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&thread=${encodeURIComponent(threadId)}`);
171+ const result = (await response.json()) as Result<{ messages: ChatMessage[] }>;
172+ // The page reaches back to the message the thread is under: shown above it, not among the replies.
173+ setThread(result.ok ? mergeMessages([], result.value.messages.filter((m) => m.id !== threadId && !m.deleted_at)) : []);
174+ } catch {
175+ setThread([]);
176+ }
177+ }, [threadId, slug, data.channel.id]);
178+ useEffect(() => {
179+ setThread(null);
180+ void loadThread();
181+ }, [loadThread]);
182+
183+ // Reply counts: counted here only until the service's own count for that
184+ // message arrives (a `message.updated` of it), which can come before or
185+ // after the reply itself; never twice for one reply.
186+ const serverCounted = useRef(new Set<string>());
187+ const countedReplies = useRef(new Set<string>());
188+ const countReply = useCallback((root: string, reply: string, at: string) => {
189+ if (serverCounted.current.has(root) || countedReplies.current.has(reply)) return;
190+ countedReplies.current.add(reply);
191+ setMessages((now) => now.map((m) => (m.id === root ? { ...m, reply_count: m.reply_count + 1, last_reply_at: at } : m)));
192+ }, []);
193+ const latest = useRef(messages);
194+ latest.current = messages;
195+
196+ // Everything that happens while the page is open.
197+ const onEvent = useCallback(
198+ (event: ChatLiveEvent) => {
199+ if (event.type === "message.created" || event.type === "message.updated") {
200+ const message = event.message;
201+ if (message.channel_id !== data.channel.id) return;
202+ if (message.thread_root) {
203+ if (message.thread_root === threadId) setThread((now) => (now ? mergeMessages(now, [message]) : now));
204+ if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at);
205+ } else {
206+ if (event.type === "message.updated") serverCounted.current.add(message.id);
207+ setMessages((now) => mergeMessages(now, [message]));
208+ }
209+ // Whoever just spoke is no longer typing.
210+ setTyping((now) => {
211+ const key = `${message.author.kind}:${message.author.id}`;
212+ if (!now.has(key)) return now;
213+ const next = new Map(now);
214+ next.delete(key);
215+ return next;
216+ });
217+ } else if (event.type === "message.deleted") {
218+ if (event.channel_id !== data.channel.id) return;
219+ const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m);
220+ setMessages((now) => now.map(gone));
221+ setThread((now) => now?.map(gone) ?? now);
222+ } else if (event.type === "typing") {
223+ if (event.channel_id !== data.channel.id) return;
224+ if (event.member.kind === "user" && event.member.id === me?.id) return;
225+ setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() }));
226+ }
227+ },
228+ [data.channel.id, threadId, me?.id, countReply],
229+ );
230+ // Back after a drop: what was missed.
231+ const onReconnect = useCallback(async () => {
232+ const base = `/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}`;
233+ try {
234+ // Everything after the newest message held, page by page, oldest first;
235+ // what was deleted meanwhile comes back empty and is dropped.
236+ let after = [...latest.current].reverse().find((m) => !m.pending)?.id ?? null;
237+ for (let pages = 0; after && pages < 20; pages++) {
238+ const response = await fetch(`${base}&after=${encodeURIComponent(after)}`);
239+ const result = (await response.json()) as Result<{ messages: ChatMessage[]; newer?: string | null }>;
240+ if (!result.ok) break;
241+ const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
242+ const kept = result.value.messages.filter((m) => !m.deleted_at && !m.thread_root);
243+ setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), kept));
244+ after = result.value.newer ?? null;
245+ }
246+ // Edits and deletions to what was already on screen: the latest page again.
247+ const response = await fetch(base);
248+ const result = (await response.json()) as Result<{ messages: ChatMessage[] }>;
249+ if (result.ok) {
250+ const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
251+ setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), result.value.messages.filter((m) => !m.deleted_at)));
252+ }
253+ } catch {
254+ // The next event, or the next return, catches up.
255+ }
256+ void loadThread();
257+ refresh();
258+ }, [slug, data.channel.id, loadThread, refresh]);
259+ const live = useChatLive(slug, data.channel.id, onEvent, onReconnect);
260+
261+ // Typing shows until its time runs out.
262+ useEffect(() => {
263+ if (typing.size === 0) return;
264+ const timer = setInterval(() => {
265+ const now = Date.now();
266+ setTyping((current) => {
267+ const next = new Map([...current].filter(([, value]) => value.until > now));
268+ return next.size === current.size ? current : next;
269+ });
270+ }, 1000);
271+ return () => clearInterval(timer);
272+ }, [typing.size]);
273+
274+ // Says "is typing" to the others, at most every few seconds.
275+ const lastTyping = useRef(0);
276+ const onTyping = useCallback(() => {
277+ const now = Date.now();
278+ if (now - lastTyping.current < 3000) return;
279+ lastTyping.current = now;
280+ live.send({ type: "typing", channel_id: data.channel.id });
281+ }, [live, data.channel.id]);
282+
283+ const post = useCallback(
284+ async (body: string, root: string | null) => {
285+ if (!me) return;
286+ const clientId = newClientId();
287+ const author: MemberProfile = { kind: "user", id: me.id, name: me.username, display_name: me.username, avatar: me.avatar, role: null };
288+ const pending: ShownMessage = {
289+ id: clientId,
290+ client_id: clientId,
291+ pending: true,
292+ channel_id: data.channel.id,
293+ author,
294+ kind: "text",
295+ body,
296+ card: null,
297+ thread_root: root,
298+ reply_count: 0,
299+ last_reply_at: null,
300+ created_at: new Date().toISOString(),
301+ edited_at: null,
302+ deleted_at: null,
303+ };
304+ const put = (list: ShownMessage[]) => mergeMessages(list, [pending]);
305+ if (root) setThread((now) => put(now ?? []));
306+ else {
307+ stick.current = true;
308+ setMessages(put);
309+ }
310+ const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
311+ const settle = (list: ShownMessage[]) =>
312+ saved.ok
313+ ? mergeMessages(list, [{ ...saved.value, client_id: clientId }])
314+ : list.map((m) => (m.client_id === clientId ? { ...m, failed: true, pending: true } : m));
315+ if (root) {
316+ setThread((now) => (now ? settle(now) : now));
317+ if (saved.ok) countReply(root, saved.value.id, saved.value.created_at);
318+ } else setMessages(settle);
319+ },
320+ [me, send, data.channel.id, countReply],
321+ );
322+ const retry = (message: ShownMessage) => {
323+ const drop = (list: ShownMessage[]) => list.filter((m) => m.client_id !== message.client_id);
324+ if (message.thread_root) setThread((now) => (now ? drop(now) : now));
325+ else setMessages(drop);
326+ void post(message.body, message.thread_root);
327+ };
328+
329+ const loadOlder = async () => {
330+ if (!older || loadingOlder) return;
331+ setLoadingOlder(true);
332+ const element = scroller.current;
333+ const fromBottom = element ? element.scrollHeight - element.scrollTop : 0;
334+ try {
335+ const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&before=${encodeURIComponent(older)}`);
336+ const result = (await response.json()) as Result<{ messages: ChatMessage[]; older: string | null }>;
337+ if (result.ok) {
338+ stick.current = false;
339+ setMessages((now) => mergeMessages(now, result.value.messages));
340+ setOlder(result.value.older);
341+ requestAnimationFrame(() => {
342+ if (element) element.scrollTop = element.scrollHeight - fromBottom;
343+ });
344+ }
345+ } finally {
346+ setLoadingOlder(false);
347+ }
348+ };
349+
350+ const openThread = (id: string | null) => {
351+ const next = new URLSearchParams(params);
352+ if (id) next.set("thread", id);
353+ else next.delete("thread");
354+ setParams(next, { preventScrollReset: true, replace: !!threadId && !!id });
355+ };
356+
357+ const toggleStar = async () => {
358+ await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred });
359+ refresh();
360+ };
361+
362+ const join = async () => {
363+ const done = await send({ intent: "join", channel_id: data.channel.id });
364+ if (done.ok) {
365+ setJoined(true);
366+ if (me) {
367+ setMembers((now) => [
368+ ...now,
369+ {
370+ channel_id: data.channel.id,
371+ member: { kind: "user", id: me.id, name: me.username, display_name: me.username, avatar: me.avatar, role: null },
372+ role: "member",
373+ starred: false,
374+ muted: false,
375+ last_read_id: null,
376+ joined_at: new Date().toISOString(),
377+ },
378+ ]);
379+ }
380+ }
381+ };
382+
383+ const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]);
384+ const typers = [...typing.values()].map((t) => t.member);
385+ const name = data.channel.name ?? "";
386+ const placeholder = isDm
387+ ? `Message ${others.map(shownName).join(", ") || "yourself"}`
388+ : `Message #${name}. @ a teammate or an agent`;
389+ const rootMessage = threadId ? messages.find((m) => m.id === threadId) ?? null : null;
390+
391+ return (
392+ <div className="flex h-[calc(100dvh-3.5rem)] min-h-0 lg:h-dvh">
393+ <section aria-label={data.title} className="flex min-w-0 grow flex-col">
394+ <ChannelHeader
395+ data={data}
396+ others={others}
397+ starred={starred}
398+ muted={muted}
399+ memberCount={members.length}
400+ live={live.state}
401+ info={info}
402+ onStar={() => void toggleStar()}
403+ onInfo={toggleInfo}
404+ />
405+ <div
406+ ref={scroller}
407+ onScroll={(event) => {
408+ const element = event.currentTarget;
409+ stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120;
410+ }}
411+ className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
412+ >
413+ <div className="mx-auto flex min-h-full max-w-[56rem] flex-col justify-end px-2 pt-6 pb-3 sm:px-4">
414+ {older ? (
415+ <div className="flex justify-center pb-4">
416+ <button
417+ type="button"
418+ onClick={() => void loadOlder()}
419+ className="rounded-full border border-line px-3 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
420+ >
421+ {loadingOlder ? "Loading…" : "Load earlier messages"}
422+ </button>
423+ </div>
424+ ) : (
425+ <ConversationStart data={data} others={others} />
426+ )}
427+ {rows.map((row) =>
428+ row.kind === "day" ? (
429+ <DayRule key={row.key} label={row.label} />
430+ ) : (
431+ <MessageRow
432+ key={row.key}
433+ message={row.message}
434+ head={row.head}
435+ zone={zone}
436+ context={context}
437+ code={code}
438+ slug={slug}
439+ onThread={() => openThread(row.message.id)}
440+ onRetry={() => retry(row.message)}
441+ threadOpen={row.message.id === threadId}
442+ />
443+ ),
444+ )}
445+ </div>
446+ </div>
447+ <div className="shrink-0 px-2 pb-3 sm:px-4 sm:pb-4">
448+ <div className="mx-auto max-w-[56rem]">
449+ <TypingLine members={typers} />
450+ {joined ? (
451+ <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
452+ ) : (
453+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
454+ <p className="text-sm text-muted">
455+ You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications.
456+ </p>
457+ <button
458+ type="button"
459+ onClick={() => void join()}
460+ className="inline-flex h-9 items-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg hover:bg-accent-hover"
461+ >
462+ Join channel
463+ </button>
464+ </div>
465+ )}
466+ </div>
467+ </div>
468+ </section>
469+ {threadId ? (
470+ <SidePanel title="Thread" subtitle={isDm ? undefined : `#${name}`} onClose={() => openThread(null)}>
471+ <ThreadPanel
472+ root={rootMessage}
473+ replies={thread}
474+ zone={zone}
475+ context={context}
476+ code={code}
477+ slug={slug}
478+ people={people}
479+ joined={joined}
480+ onSend={(body) => void post(body, threadId)}
481+ onRetry={retry}
482+ draftKey={`${data.channel.id}:${threadId}`}
483+ />
484+ </SidePanel>
485+ ) : (
486+ info && (
487+ <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}>
488+ <InfoPanel data={data} members={members} slug={slug} people={people} onAdded={(m) => setMembers((now) => [...now, m])} joined={joined} />
489+ </SidePanel>
490+ )
491+ )}
492+ </div>
493+ );
494+}
495+
496+/** The top of a conversation: what it is, and its controls. */
497+function ChannelHeader({
498+ data,
499+ others,
500+ starred,
501+ muted,
502+ memberCount,
503+ live,
504+ info,
505+ onStar,
506+ onInfo,
507+}: {
508+ data: Loaded;
509+ others: MemberProfile[];
510+ starred: boolean;
511+ muted: boolean;
512+ memberCount: number;
513+ live: LiveState;
514+ info: boolean;
515+ onStar: () => void;
516+ onInfo: () => void;
517+}) {
518+ const channel = data.channel;
519+ const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
520+ return (
521+ <header className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5">
522+ <div className="flex min-w-0 grow items-center gap-2.5">
523+ {channel.kind === "channel" ? (
524+ <>
525+ <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold">
526+ {channel.private ? <Lock size={15} className="text-faint" /> : <Hash size={16} className="text-faint" />}
527+ <span className="truncate">{channel.name}</span>
528+ </h1>
529+ <StarButton starred={starred} onClick={onStar} />
530+ {muted && (
531+ <Hint label="Muted: no unread count or notifications">
532+ <span className="text-faint">
533+ <BellOff size={14} aria-label="Muted" />
534+ </span>
535+ </Hint>
536+ )}
537+ {channel.topic && (
538+ <>
539+ <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" />
540+ <p className="hidden min-w-0 truncate text-sm text-muted sm:block">{channel.topic}</p>
541+ </>
542+ )}
543+ </>
544+ ) : (
545+ <>
546+ <span className="flex shrink-0 -space-x-1.5">
547+ {others.slice(0, 3).map((member) => (
548+ <span key={`${member.kind}:${member.id}`} className="rounded-full ring-2 ring-bg">
549+ <MemberAvatar member={member} size={26} />
550+ </span>
551+ ))}
552+ </span>
553+ <div className="min-w-0">
554+ <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold">
555+ <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span>
556+ {agentDm && <AgentPill />}
557+ </h1>
558+ {agentDm?.role && <p className="truncate text-xs leading-tight text-muted">{agentDm.role}</p>}
559+ </div>
560+ <StarButton starred={starred} onClick={onStar} />
561+ </>
562+ )}
563+ </div>
564+ {live !== "open" && (
565+ <span role="status" className="hidden items-center gap-1.5 text-xs text-faint sm:flex">
566+ <span className="size-1.5 animate-pulse rounded-full bg-warn" />
567+ {live === "reconnecting" ? "Reconnecting…" : "Connecting…"}
568+ </span>
569+ )}
570+ {channel.kind === "channel" && (
571+ <Hint label={info ? "Hide details" : "Members and details"}>
572+ <button
573+ type="button"
574+ onClick={onInfo}
575+ aria-pressed={info}
576+ aria-label="Members and details"
577+ className={`flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs tabular-nums transition-colors ${
578+ info ? "border-line-strong bg-raised text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
579+ }`}
580+ >
581+ <UsersStack count={memberCount} />
582+ </button>
583+ </Hint>
584+ )}
585+ {channel.kind === "dm" && (
586+ <Hint label={info ? "Hide details" : "Details"}>
587+ <button
588+ type="button"
589+ onClick={onInfo}
590+ aria-pressed={info}
591+ aria-label="Details"
592+ className={`flex size-8 items-center justify-center rounded-md transition-colors ${info ? "bg-raised text-fg" : "text-muted hover:bg-raised hover:text-fg"}`}
593+ >
594+ <PanelRight size={16} />
595+ </button>
596+ </Hint>
597+ )}
598+ </header>
599+ );
600+}
601+
602+function UsersStack({ count }: { count: number }) {
603+ return (
604+ <>
605+ <Users size={14} />
606+ {count}
607+ </>
608+ );
609+}
610+
611+function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) {
612+ return (
613+ <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}>
614+ <button
615+ type="button"
616+ aria-label={starred ? "Unstar" : "Star"}
617+ aria-pressed={starred}
618+ onClick={onClick}
619+ className={`flex size-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-raised ${starred ? "text-warn" : "text-faint hover:text-fg"}`}
620+ >
621+ <Star size={15} fill={starred ? "currentColor" : "none"} />
622+ </button>
623+ </Hint>
624+ );
625+}
626+
627+/** How a conversation begins, above its first message. */
628+function ConversationStart({ data, others }: { data: Loaded; others: MemberProfile[] }) {
629+ const channel = data.channel;
630+ if (channel.kind === "dm") {
631+ const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
632+ return (
633+ <div className="px-2 pb-6 sm:px-3">
634+ <div className="flex -space-x-2">
635+ {others.slice(0, 4).map((m) => (
636+ <span key={m.id} className="rounded-full ring-4 ring-bg">
637+ <MemberAvatar member={m} size={56} />
638+ </span>
639+ ))}
640+ </div>
641+ <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight">
642+ {others.map(shownName).join(", ") || "Just you"}
643+ {agent && <AgentPill />}
644+ </h2>
645+ <p className="mt-1 max-w-xl text-sm text-muted">
646+ {agent
647+ ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.`
648+ : "This is the start of your conversation."}
649+ </p>
650+ </div>
651+ );
652+ }
653+ return (
654+ <div className="px-2 pb-6 sm:px-3">
655+ <span className="flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
656+ {channel.private ? <Lock size={22} /> : <Hash size={24} />}
657+ </span>
658+ <h2 className="mt-3 text-xl font-semibold tracking-tight">Welcome to #{channel.name}</h2>
659+ <p className="mt-1 max-w-xl text-sm text-muted">{channel.topic ?? "This is the very beginning of the channel."}</p>
660+ </div>
661+ );
662+}
663+
664+function DayRule({ label }: { label: string }) {
665+ return (
666+ <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center">
667+ <span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px bg-line" />
668+ <span className="relative rounded-full border border-line bg-bg px-3 py-0.5 text-[0.6875rem] font-medium text-muted" suppressHydrationWarning>
669+ {label}
670+ </span>
671+ </div>
672+ );
673+}
674+
675+const CARD_ICONS: Record<string, ReactNode> = {
676+ pull: <GitPullRequest size={16} />,
677+ issue: <CircleDot size={16} />,
678+ task: <ListChecks size={16} />,
679+ deploy: <Rocket size={16} />,
680+ approval: <ShieldCheck size={16} />,
681+};
682+
683+/** How loud a card's state reads, from what it says. */
684+function stateTone(state: string): BadgeTone {
685+ const s = state.toLowerCase();
686+ if (/merged/.test(s)) return "merged";
687+ if (/fail|error|blocked|over budget|rejected/.test(s)) return "danger";
688+ if (/approval|waiting|review|queued|needs/.test(s)) return "warn";
689+ if (/pass|done|deployed|ready|approved|live|success/.test(s)) return "success";
690+ if (/working|running|in progress|open/.test(s)) return "info";
691+ return "neutral";
692+}
693+
694+/** A card g1t or an agent posted: what it is about, a line of detail, and its state. */
695+export function CardBox({ card, href }: { card: MessageCard; href: string | null }) {
696+ const inner = (
697+ <>
698+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-raised text-muted group-hover:text-fg">
699+ {CARD_ICONS[card.kind] ?? <Sparkles size={16} />}
700+ </span>
701+ <span className="min-w-0 grow">
702+ <span className="line-clamp-2 text-sm font-medium text-fg">{card.title}</span>
703+ {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted">{card.detail}</span>}
704+ </span>
705+ {card.state && (
706+ <Badge tone={stateTone(card.state)} className="mt-0.5">
707+ {card.state}
708+ </Badge>
709+ )}
710+ </>
711+ );
712+ 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";
713+ return href ? (
714+ <Link to={href} className={`${box} hover:border-line-strong hover:bg-raised/40`}>
715+ {inner}
716+ </Link>
717+ ) : (
718+ <div className={box}>{inner}</div>
719+ );
720+}
721+
722+function cardHref(card: MessageCard, slug: string, code: boolean): string | null {
723+ if (!card.href) return null;
724+ if (code || !/^\/[^/]+\/(?!-\/)[^/]+/.test(card.href)) return card.href;
725+ return codeAccessPath(slug, card.href);
726+}
727+
728+/** One message: with its author's name and avatar when it starts a group. */
729+function MessageRow({
730+ message,
731+ head,
732+ zone,
733+ context,
734+ code,
735+ slug,
736+ onThread,
737+ onRetry,
738+ threadOpen,
739+ inThread,
740+}: {
741+ message: ShownMessage;
742+ head: boolean;
743+ zone: string | undefined;
744+ context: TextContext;
745+ code: boolean;
746+ slug: string;
747+ onThread?: () => void;
748+ onRetry: () => void;
749+ threadOpen?: boolean;
750+ inThread?: boolean;
751+}) {
752+ const author = message.author;
753+ const time = clock(message.created_at, zone);
754+ const profile = author.kind === "agent" ? `/${slug}/-/agents/${author.name}` : `/u/${author.name}`;
755+ return (
756+ <article
757+ aria-label={`${shownName(author)}, ${time}`}
758+ className={`group/message relative flex gap-3 rounded-lg px-2 transition-colors hover:bg-surface/70 sm:px-3 ${head ? "mt-3 pt-1 pb-1" : "py-0.5"} ${
759+ threadOpen ? "bg-accent/[0.06] hover:bg-accent/[0.08]" : ""
760+ }`}
761+ >
762+ <div className="w-9 shrink-0">
763+ {head ? (
764+ <Link to={profile} tabIndex={-1} aria-hidden="true" className="mt-0.5 block">
765+ <MemberAvatar member={author} size={36} />
766+ </Link>
767+ ) : (
768+ <time
769+ dateTime={message.created_at}
770+ suppressHydrationWarning
771+ className="mt-1 block text-right text-[0.625rem] leading-5 text-faint tabular-nums opacity-0 group-hover/message:opacity-100"
772+ >
773+ {time.replace(/\s?[AP]M$/, "")}
774+ </time>
775+ )}
776+ </div>
777+ <div className="min-w-0 grow">
778+ {head && (
779+ <div className="flex items-baseline gap-2">
780+ <Link to={profile} className="truncate text-[0.9375rem] font-semibold text-fg hover:underline">
781+ {shownName(author)}
782+ </Link>
783+ {author.kind === "agent" && <AgentPill className="self-center" />}
784+ <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums">
785+ {time}
786+ </time>
787+ </div>
788+ )}
789+ <div className={message.pending && !message.failed ? "opacity-60" : undefined}>
790+ {message.body && <MessageText body={message.body} context={context} />}
791+ {message.card && <CardBox card={message.card} href={cardHref(message.card, slug, code)} />}
792+ {message.edited_at && <span className="text-[0.6875rem] text-faint"> (edited)</span>}
793+ </div>
794+ {message.failed && (
795+ <p className="mt-0.5 text-xs text-danger">
796+ Not sent.{" "}
797+ <button type="button" onClick={onRetry} className="font-medium underline underline-offset-2 hover:text-fg">
798+ Try again
799+ </button>
800+ </p>
801+ )}
802+ {!inThread && message.reply_count > 0 && onThread && (
803+ <button
804+ type="button"
805+ onClick={onThread}
806+ className="mt-1 -ml-1 flex items-center gap-2 rounded-md px-1 py-0.5 text-[0.8125rem] font-medium text-accent transition-colors hover:bg-raised"
807+ >
808+ <MessageSquareText size={14} />
809+ {message.reply_count} {message.reply_count === 1 ? "reply" : "replies"}
810+ {message.last_reply_at && (
811+ <span className="font-normal text-faint" suppressHydrationWarning>
812+ Last reply {clock(message.last_reply_at, zone)}
813+ </span>
814+ )}
815+ </button>
816+ )}
817+ </div>
818+ {!inThread && onThread && !message.pending && (
819+ <div className="absolute -top-3 right-3 hidden rounded-lg border border-line bg-surface p-0.5 shadow-lg shadow-black/30 group-hover/message:flex group-focus-within/message:flex">
820+ <Hint label="Reply in thread">
821+ <button
822+ type="button"
823+ aria-label="Reply in thread"
824+ onClick={onThread}
825+ className="flex size-7 items-center justify-center rounded-md text-muted hover:bg-raised hover:text-fg"
826+ >
827+ <MessageSquareText size={15} />
828+ </button>
829+ </Hint>
830+ </div>
831+ )}
832+ </article>
833+ );
834+}
835+
836+/** "reviewer is typing…", for whoever is. */
837+function TypingLine({ members }: { members: MemberProfile[] }) {
838+ const names = members.map(shownName);
839+ const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : "";
840+ return (
841+ <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted">
842+ {who && (
843+ <>
844+ <span aria-hidden="true" className="flex gap-0.5">
845+ {[0, 1, 2].map((i) => (
846+ <span key={i} className="size-1 animate-bounce rounded-full bg-accent" style={{ animationDelay: `${i * 140}ms` }} />
847+ ))}
848+ </span>
849+ <span>
850+ <span className="font-medium text-fg-soft">{who}</span> {names.length === 1 ? "is" : "are"} typing…
851+ </span>
852+ </>
853+ )}
854+ </div>
855+ );
856+}
857+
858+/** A panel beside the conversation: a thread, or the details. Over everything on a phone. */
859+function SidePanel({ title, subtitle, onClose, children }: { title: string; subtitle?: string; onClose: () => void; children: ReactNode }) {
860+ return (
861+ <aside
862+ aria-label={title}
863+ className="fixed inset-0 z-40 flex flex-col bg-bg lg:static lg:z-auto lg:w-[22rem] lg:shrink-0 lg:border-l lg:border-line xl:w-[24rem]"
864+ >
865+ <div className="flex h-14 shrink-0 items-center gap-2 border-b border-line px-4">
866+ <button type="button" onClick={onClose} aria-label="Back" className="-ml-1 rounded-md p-1 text-muted hover:bg-raised hover:text-fg lg:hidden">
867+ <ArrowLeft size={16} />
868+ </button>
869+ <h2 className="text-[0.9375rem] font-semibold">{title}</h2>
870+ {subtitle && <span className="truncate text-sm text-faint">{subtitle}</span>}
871+ <button type="button" onClick={onClose} aria-label="Close" className="ml-auto hidden rounded-md p-1 text-faint hover:bg-raised hover:text-fg lg:block">
872+ <X size={16} />
873+ </button>
874+ </div>
875+ <div className="flex min-h-0 grow flex-col">{children}</div>
876+ </aside>
877+ );
878+}
879+
880+function ThreadPanel({
881+ root,
882+ replies,
883+ zone,
884+ context,
885+ code,
886+ slug,
887+ people,
888+ joined,
889+ onSend,
890+ onRetry,
891+ draftKey,
892+}: {
893+ root: ShownMessage | null;
894+ replies: ShownMessage[] | null;
895+ zone: string | undefined;
896+ context: TextContext;
897+ code: boolean;
898+ slug: string;
899+ people: Mentionable[];
900+ joined: boolean;
901+ onSend: (body: string) => void;
902+ onRetry: (message: ShownMessage) => void;
903+ draftKey: string;
904+}) {
905+ const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
906+ return (
907+ <>
908+ <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]">
909+ {root ? (
910+ <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
911+ ) : (
912+ <p className="px-4 py-2 text-sm text-faint">The message this thread is under is further back.</p>
913+ )}
914+ <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint">
915+ {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`}
916+ <span className="h-px grow bg-line" />
917+ </div>
918+ {rows.map((row) =>
919+ row.kind === "message" ? (
920+ <MessageRow
921+ key={row.key}
922+ message={row.message}
923+ head={row.head}
924+ zone={zone}
925+ context={context}
926+ code={code}
927+ slug={slug}
928+ onRetry={() => onRetry(row.message)}
929+ inThread
930+ />
931+ ) : null,
932+ )}
933+ </div>
934+ {joined && (
935+ <div className="shrink-0 p-3">
936+ <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} compact autoFocus />
937+ </div>
938+ )}
939+ </>
940+ );
941+}
942+
943+function InfoPanel({
944+ data,
945+ members,
946+ slug,
947+ people,
948+ onAdded,
949+ joined,
950+}: {
951+ data: Loaded;
952+ members: ChannelMember[];
953+ slug: string;
954+ people: Mentionable[];
955+ onAdded: (member: ChannelMember) => void;
956+ joined: boolean;
957+}) {
958+ const channel = data.channel;
959+ const agents = members.filter((m) => m.member.kind === "agent");
960+ const humans = members.filter((m) => m.member.kind === "user");
961+ return (
962+ <div className="min-h-0 grow overflow-y-auto p-4 [scrollbar-width:thin]">
963+ {channel.kind === "channel" && (
964+ <section className="rounded-xl border border-line bg-surface p-3.5">
965+ <h3 className="text-xs font-medium text-faint">Topic</h3>
966+ <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>
967+ <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
968+ <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
969+ {channel.private ? <Lock size={13} /> : <Hash size={13} />}
970+ {channel.private ? "Private: invited members only" : "Public to the workspace"}
971+ </p>
972+ </section>
973+ )}
974+ {agents.length > 0 && (
975+ <section className="mt-5">
976+ <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3>
977+ <div className="flex flex-wrap gap-1.5">
978+ {agents.map(({ member }) => (
979+ <Hint key={member.id} label={member.role ?? undefined}>
980+ <Link
981+ to={`/${slug}/-/agents/${member.name}`}
982+ className="flex h-7 items-center gap-1.5 rounded-full border border-accent/25 bg-accent/10 pr-2.5 pl-1 text-[0.8125rem] font-medium text-accent transition-colors hover:bg-accent/15"
983+ >
984+ <MemberAvatar member={member} size={20} />
985+ {shownName(member)}
986+ </Link>
987+ </Hint>
988+ ))}
989+ </div>
990+ </section>
991+ )}
992+ <section className="mt-5">
993+ <div className="mb-1 flex items-center justify-between px-1">
994+ <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3>
995+ {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />}
996+ </div>
997+ <ul className="space-y-px">
998+ {humans.map(({ member, role }) => (
999+ <li key={member.id}>
1000+ <Link to={`/u/${member.name}`} className="flex items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors hover:bg-raised/60">
1001+ <MemberAvatar member={member} size={26} />
1002+ <span className="min-w-0 grow truncate text-sm">
1003+ {shownName(member)}
1004+ {member.display_name !== member.name && <span className="ml-1.5 text-faint">@{member.name}</span>}
1005+ </span>
1006+ {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>}
1007+ </Link>
1008+ </li>
1009+ ))}
1010+ </ul>
1011+ </section>
1012+ </div>
1013+ );
1014+}
1015+
1016+/** Adding a person or an agent to the channel. An invite lets them read; it never grants write. */
1017+function AddMember({
1018+ slug,
1019+ channelId,
1020+ people,
1021+ members,
1022+ onAdded,
1023+}: {
1024+ slug: string;
1025+ channelId: string;
1026+ people: Mentionable[];
1027+ members: ChannelMember[];
1028+ onAdded: (member: ChannelMember) => void;
1029+}) {
1030+ const [open, setOpen] = useState(false);
1031+ const [query, setQuery] = useState("");
1032+ const [error, setError] = useState<string | null>(null);
1033+ const send = useChatSend(slug);
1034+ const chatData = useChatData();
1035+ const inside = new Set(members.map((m) => `${m.member.kind}:${m.member.name.toLowerCase()}`));
1036+ const q = query.trim().toLowerCase().replace(/^@/, "");
1037+ const options = people
1038+ .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`))
1039+ .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
1040+ .slice(0, 8);
1041+ const add = async (person: Mentionable) => {
1042+ const agent = person.kind === "agent" ? chatData?.agents.find((a) => a.handle === person.name) : null;
1043+ const key = agent ? `agent:${agent.id}` : `user:${person.name}`;
1044+ setError(null);
1045+ const done = await send({ intent: "invite", channel_id: channelId, member: key });
1046+ if (!done.ok) return setError(done.error.message);
1047+ onAdded({
1048+ channel_id: channelId,
1049+ member: { kind: person.kind, id: agent?.id ?? person.name, name: person.name, display_name: person.display_name, avatar: person.avatar, role: person.role ?? null },
1050+ role: "member",
1051+ starred: false,
1052+ muted: false,
1053+ last_read_id: null,
1054+ joined_at: new Date().toISOString(),
1055+ });
1056+ setOpen(false);
1057+ };
1058+ return (
1059+ <Popover open={open} onOpenChange={setOpen}>
1060+ <PopoverTrigger asChild>
1061+ <button type="button" className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted hover:bg-raised hover:text-fg">
1062+ <UserPlus size={13} />
1063+ Add
1064+ </button>
1065+ </PopoverTrigger>
1066+ <PopoverContent align="end" className="w-72 p-1.5">
1067+ <input
1068+ autoFocus
1069+ value={query}
1070+ onChange={(event) => setQuery(event.target.value)}
1071+ placeholder="Add a person or an agent"
1072+ aria-label="Add a person or an agent"
1073+ autoComplete="off"
1074+ data-1p-ignore
1075+ className="mb-1 h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
1076+ />
1077+ {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>}
1078+ {options.map((person) => (
1079+ <button
1080+ key={`${person.kind}:${person.name}`}
1081+ type="button"
1082+ onClick={() => void add(person)}
1083+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line"
1084+ >
1085+ <MemberAvatar member={person} size={20} />
1086+ <span className="min-w-0 grow truncate">{person.display_name}</span>
1087+ {person.kind === "agent" && <AgentPill />}
1088+ </button>
1089+ ))}
1090+ {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>}
1091+ <p className="border-t border-line px-2 pt-1.5 pb-0.5 text-[0.6875rem] leading-snug text-faint">
1092+ Adding an agent lets it read this channel. It never grants it write access.
1093+ </p>
1094+ </PopoverContent>
1095+ </Popover>
1096+ );
1097+}
+223−0
1+import { ArrowUp, AtSign } from "lucide-react";
2+import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
3+
4+import { AgentPill, MemberAvatar } from "./marks";
5+import { Hint } from "../ui/hint";
6+import { type MentionQuery, type Mentionable, mentionQuery } from "../../lib/chat";
7+
8+/** How tall the box grows before it scrolls. */
9+const MAX_HEIGHT = 240;
10+
11+/**
12+ * Where a message is written. Enter sends, Shift+Enter starts a new line;
13+ * `@` offers the workspace's people and agents (Up and Down to move, Tab or
14+ * Enter to take one, Escape to dismiss). What is typed is kept per
15+ * conversation while the tab is open, so moving away loses nothing.
16+ */
17+export function Composer({
18+ draftKey,
19+ placeholder,
20+ people,
21+ onSend,
22+ onTyping,
23+ disabled,
24+ autoFocus,
25+ compact,
26+}: {
27+ draftKey: string;
28+ placeholder: string;
29+ people: Mentionable[];
30+ onSend: (body: string) => void;
31+ onTyping?: () => void;
32+ disabled?: boolean;
33+ autoFocus?: boolean;
34+ compact?: boolean;
35+}) {
36+ const box = useRef<HTMLTextAreaElement>(null);
37+ const [text, setText] = useState("");
38+ const [match, setMatch] = useState<MentionQuery | null>(null);
39+ const [active, setActive] = useState(0);
40+ const list = useId();
41+
42+ // The draft for this conversation, if one was left.
43+ useEffect(() => {
44+ try {
45+ setText(sessionStorage.getItem(`chat-draft:${draftKey}`) ?? "");
46+ } catch {
47+ setText("");
48+ }
49+ }, [draftKey]);
50+ useEffect(() => {
51+ try {
52+ if (text) sessionStorage.setItem(`chat-draft:${draftKey}`, text);
53+ else sessionStorage.removeItem(`chat-draft:${draftKey}`);
54+ } catch {
55+ // No storage: the draft lives only while the page does.
56+ }
57+ }, [draftKey, text]);
58+
59+ // Grows with what is written, up to a point.
60+ useLayoutEffect(() => {
61+ const element = box.current;
62+ if (!element) return;
63+ element.style.height = "auto";
64+ element.style.height = `${Math.min(MAX_HEIGHT, element.scrollHeight)}px`;
65+ }, [text]);
66+
67+ const look = () => {
68+ const element = box.current;
69+ if (!element) return;
70+ setMatch(element.selectionStart === element.selectionEnd ? mentionQuery(element.value, element.selectionStart, people) : null);
71+ setActive(0);
72+ };
73+
74+ const complete = (person: Mentionable) => {
75+ const element = box.current;
76+ if (!element || !match) return;
77+ const caret = element.selectionStart;
78+ const next = `${element.value.slice(0, match.start)}@${person.name} ${element.value.slice(caret)}`;
79+ setText(next);
80+ setMatch(null);
81+ requestAnimationFrame(() => {
82+ const at = match.start + person.name.length + 2;
83+ element.focus();
84+ element.setSelectionRange(at, at);
85+ });
86+ };
87+
88+ const send = () => {
89+ const body = text.trim();
90+ if (!body || disabled) return;
91+ onSend(body);
92+ setText("");
93+ setMatch(null);
94+ };
95+
96+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
97+ if (match) {
98+ const count = match.options.length;
99+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
100+ event.preventDefault();
101+ setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
102+ return;
103+ }
104+ if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) {
105+ event.preventDefault();
106+ complete(match.options[active] ?? match.options[0]!);
107+ return;
108+ }
109+ if (event.key === "Escape") {
110+ event.preventDefault();
111+ setMatch(null);
112+ return;
113+ }
114+ }
115+ // Enter sends; Shift+Enter, and Enter while composing an IME word, does not.
116+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
117+ event.preventDefault();
118+ send();
119+ }
120+ };
121+
122+ const open = match != null;
123+ return (
124+ <div className="relative">
125+ {match && (
126+ <ul
127+ id={list}
128+ role="listbox"
129+ aria-label="Mention someone"
130+ className="absolute bottom-full left-0 z-20 mb-2 w-full max-w-sm overflow-hidden rounded-lg border border-line-strong bg-raised p-1 shadow-xl shadow-black/40"
131+ >
132+ {match.options.map((person, index) => (
133+ <li
134+ key={`${person.kind}:${person.name}`}
135+ id={`${list}-${index}`}
136+ role="option"
137+ aria-selected={index === active}
138+ onMouseDown={(event) => event.preventDefault()}
139+ onMouseEnter={() => setActive(index)}
140+ onClick={() => complete(person)}
141+ className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`}
142+ >
143+ <MemberAvatar member={person} size={22} />
144+ <span className="min-w-0 grow truncate text-sm">
145+ <span className="font-medium text-fg">{person.display_name}</span>
146+ <span className="ml-1.5 text-faint">@{person.name}</span>
147+ </span>
148+ {person.kind === "agent" && <AgentPill />}
149+ </li>
150+ ))}
151+ </ul>
152+ )}
153+ <div
154+ className={`rounded-xl border border-line-strong bg-surface transition-colors focus-within:border-accent/35 focus-within:ring-4 focus-within:ring-accent/[0.06] ${disabled ? "opacity-60" : ""}`}
155+ >
156+ <textarea
157+ ref={box}
158+ value={text}
159+ rows={1}
160+ disabled={disabled}
161+ autoFocus={autoFocus}
162+ placeholder={placeholder}
163+ aria-label={placeholder}
164+ role={open ? "combobox" : undefined}
165+ aria-autocomplete="list"
166+ aria-expanded={open}
167+ aria-controls={open ? list : undefined}
168+ aria-activedescendant={open ? `${list}-${active}` : undefined}
169+ autoComplete="off"
170+ data-1p-ignore
171+ data-lpignore="true"
172+ onChange={(event) => {
173+ setText(event.target.value);
174+ onTyping?.();
175+ }}
176+ onInput={look}
177+ onClick={look}
178+ onKeyDown={onKeyDown}
179+ onBlur={() => setTimeout(() => setMatch(null), 150)}
180+ className={`block w-full resize-none bg-transparent px-3.5 text-[0.9375rem] leading-6 text-fg outline-none placeholder:text-faint ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}
181+ />
182+ <div className="flex items-center gap-1 px-2 pb-2">
183+ <Hint label="Mention someone">
184+ <button
185+ type="button"
186+ aria-label="Mention someone"
187+ disabled={disabled}
188+ onClick={() => {
189+ const element = box.current;
190+ if (!element) return;
191+ const at = element.selectionStart;
192+ const before = element.value.slice(0, at);
193+ const insert = before && !/\s$/.test(before) ? " @" : "@";
194+ const next = `${before}${insert}${element.value.slice(at)}`;
195+ setText(next);
196+ requestAnimationFrame(() => {
197+ element.focus();
198+ element.setSelectionRange(at + insert.length, at + insert.length);
199+ setMatch(mentionQuery(next, at + insert.length, people));
200+ });
201+ }}
202+ className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
203+ >
204+ <AtSign size={15} />
205+ </button>
206+ </Hint>
207+ <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
208+ <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line
209+ </span>
210+ <button
211+ type="button"
212+ aria-label="Send"
213+ disabled={disabled || !text.trim()}
214+ onClick={send}
215+ className="ml-auto flex size-8 items-center justify-center rounded-lg bg-accent text-bg transition-[background-color,opacity] hover:bg-accent-hover disabled:bg-line disabled:text-faint"
216+ >
217+ <ArrowUp size={16} strokeWidth={2.4} />
218+ </button>
219+ </div>
220+ </div>
221+ </div>
222+ );
223+}
+46−0
1+import { Hash, MessagesSquare, PlugZap } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { useParams } from "react-router";
4+
5+import { CreateChannelButton, NewMessageButton } from "./actions";
6+
7+/** A calm full-height panel for a conversation that cannot be shown. */
8+function Panel({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
9+ return (
10+ <div className="flex h-[calc(100dvh-3.5rem)] items-center justify-center px-6 lg:h-dvh">
11+ <div className="max-w-sm text-center">
12+ <div className="mx-auto flex size-12 items-center justify-center rounded-xl border border-line bg-surface text-muted">{icon}</div>
13+ <h1 className="mt-4 text-base font-semibold">{title}</h1>
14+ <div className="mt-1.5 text-sm text-muted">{children}</div>
15+ </div>
16+ </div>
17+ );
18+}
19+
20+/** Chat did not answer: it may be deploying a moment after the site. */
21+export function ChatUnavailable({ title = "Chat" }: { title?: string }) {
22+ return (
23+ <Panel icon={<PlugZap size={20} />} title={`${title} isn't reachable right now`}>
24+ Chat didn't answer. It's usually back within a minute; reload to try again. Nothing you sent is lost.
25+ </Panel>
26+ );
27+}
28+
29+/** A workspace with no conversations yet. */
30+export function NoChannels() {
31+ const { owner = "" } = useParams();
32+ return (
33+ <Panel icon={<MessagesSquare size={20} />} title="Start the first conversation">
34+ <p>Channels keep a topic in one place. Direct messages are for one person, a few, or an agent.</p>
35+ <div className="mt-5 flex justify-center gap-2">
36+ <CreateChannelButton slug={owner.toLowerCase()} variant="button" />
37+ <NewMessageButton slug={owner.toLowerCase()} variant="button" />
38+ </div>
39+ </Panel>
40+ );
41+}
42+
43+/** A channel's name with its mark, as the header and the sidebar show it. */
44+export function ChannelGlyph({ className }: { className?: string }) {
45+ return <Hash size={16} className={className} />;
46+}
+96−0
1+import { useEffect, useRef, useState } from "react";
2+
3+import type { ChatLiveEvent } from "@g1t/contracts";
4+
5+import { backoff } from "../../lib/chat";
6+
7+export type LiveState = "connecting" | "open" | "reconnecting";
8+
9+/**
10+ * A conversation's live socket (routes/workspace/chat/live.ts): every event
11+ * goes to `onEvent`; a dropped socket comes back on its own, waiting longer
12+ * each time up to half a minute, and at once when the tab is shown again or
13+ * the network returns. `onReconnect` runs after each return, so the page
14+ * can fetch what it missed. `send` writes to the socket when it is open.
15+ */
16+export function useChatLive(
17+ slug: string,
18+ channelId: string,
19+ onEvent: (event: ChatLiveEvent) => void,
20+ onReconnect: () => void,
21+): { state: LiveState; send: (message: object) => void } {
22+ const [state, setState] = useState<LiveState>("connecting");
23+ const socket = useRef<WebSocket | null>(null);
24+ const handlers = useRef({ onEvent, onReconnect });
25+ handlers.current = { onEvent, onReconnect };
26+
27+ useEffect(() => {
28+ let attempt = 0;
29+ let timer: ReturnType<typeof setTimeout> | null = null;
30+ let closed = false;
31+ let opened = false;
32+ const connect = () => {
33+ if (closed) return;
34+ timer = null;
35+ const scheme = location.protocol === "https:" ? "wss:" : "ws:";
36+ let ws: WebSocket;
37+ try {
38+ ws = new WebSocket(`${scheme}//${location.host}/${slug}/-/chat/live?channel=${encodeURIComponent(channelId)}`);
39+ } catch {
40+ schedule();
41+ return;
42+ }
43+ socket.current = ws;
44+ ws.onopen = () => {
45+ const again = opened;
46+ opened = true;
47+ attempt = 0;
48+ setState("open");
49+ if (again) handlers.current.onReconnect();
50+ };
51+ ws.onmessage = (message) => {
52+ try {
53+ handlers.current.onEvent(JSON.parse(String(message.data)) as ChatLiveEvent);
54+ } catch {
55+ // Not an event this page knows: ignored.
56+ }
57+ };
58+ ws.onclose = () => {
59+ if (socket.current === ws) socket.current = null;
60+ if (closed) return;
61+ setState(opened ? "reconnecting" : "connecting");
62+ schedule();
63+ };
64+ ws.onerror = () => ws.close();
65+ };
66+ const schedule = () => {
67+ if (closed || timer) return;
68+ timer = setTimeout(connect, backoff(attempt++));
69+ };
70+ // Back now, not after the wait: the tab is shown, or the network returned.
71+ const now = () => {
72+ if (closed || socket.current || document.visibilityState !== "visible") return;
73+ if (timer) clearTimeout(timer);
74+ timer = null;
75+ attempt = 0;
76+ connect();
77+ };
78+ connect();
79+ document.addEventListener("visibilitychange", now);
80+ window.addEventListener("online", now);
81+ return () => {
82+ closed = true;
83+ if (timer) clearTimeout(timer);
84+ document.removeEventListener("visibilitychange", now);
85+ window.removeEventListener("online", now);
86+ socket.current?.close();
87+ socket.current = null;
88+ };
89+ }, [slug, channelId]);
90+
91+ const send = (message: object) => {
92+ const ws = socket.current;
93+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
94+ };
95+ return { state, send };
96+}
+72−0
1+import { Sparkles } from "lucide-react";
2+
3+import type { AgentStatus } from "@g1t/contracts";
4+
5+import { Avatar } from "../ui";
6+import { cn } from "../../lib/cn";
7+
8+/**
9+ * A workspace agent's face when it has no picture: a sparkle on a lavender
10+ * square, so agents read apart from people (round letters) at a glance.
11+ */
12+export function AgentMark({ size = 20, className }: { size?: number; className?: string }) {
13+ return (
14+ <span
15+ aria-hidden="true"
16+ className={cn("inline-flex shrink-0 items-center justify-center bg-accent/18 text-accent ring-1 ring-accent/30 ring-inset", className)}
17+ style={{ width: size, height: size, borderRadius: Math.round(size * 0.28) }}
18+ >
19+ <Sparkles size={Math.max(10, Math.round(size * 0.56))} strokeWidth={2.1} />
20+ </span>
21+ );
22+}
23+
24+/** A member's face: a person's avatar, or an agent's picture or mark. */
25+export function MemberAvatar({
26+ member,
27+ size = 20,
28+}: {
29+ member: { kind: "user" | "agent"; name: string; avatar: string | null };
30+ size?: number;
31+}) {
32+ if (member.kind === "agent") {
33+ return member.avatar ? <Avatar name={member.name} image={member.avatar} size={size} square /> : <AgentMark size={size} />;
34+ }
35+ return <Avatar name={member.name} image={member.avatar} size={size} />;
36+}
37+
38+/** AGENT, beside an agent's name wherever it speaks. */
39+export function AgentPill({ className }: { className?: string }) {
40+ return (
41+ <span
42+ className={cn(
43+ "inline-flex h-4 shrink-0 items-center rounded-[4px] bg-accent/15 px-1 text-[0.5625rem] font-semibold tracking-[0.06em] text-accent uppercase",
44+ className,
45+ )}
46+ >
47+ Agent
48+ </span>
49+ );
50+}
51+
52+const STATUS: Record<AgentStatus, { label: string; dot: string }> = {
53+ idle: { label: "Idle", dot: "bg-faint/70" },
54+ working: { label: "Working", dot: "bg-success shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-success)_18%,transparent)]" },
55+ waiting: { label: "Waiting on you", dot: "bg-warn" },
56+ out_of_budget: { label: "Out of budget", dot: "bg-danger" },
57+ paused: { label: "Paused", dot: "bg-muted" },
58+};
59+
60+export function statusLabel(status: AgentStatus): string {
61+ return STATUS[status]?.label ?? status;
62+}
63+
64+/** An agent's status as a small dot; the label is for screen readers unless shown. */
65+export function StatusDot({ status, className }: { status: AgentStatus; className?: string }) {
66+ const known = STATUS[status] ?? STATUS.idle;
67+ return (
68+ <span className={cn("inline-block size-2 shrink-0 rounded-full", known.dot, className)}>
69+ <span className="sr-only">{known.label}</span>
70+ </span>
71+ );
72+}
+224−0
1+import { BellOff, ChevronDown, Compass, Hash, Lock, Search } from "lucide-react";
2+import { type ReactNode, useEffect, useState } from "react";
3+import { NavLink, useLocation, useNavigate } from "react-router";
4+
5+import type { ChatSidebarEntry } from "@g1t/contracts";
6+
7+import { CreateChannelButton, NewMessageButton, useChatData, useChatSidebar } from "./actions";
8+import { MemberAvatar } from "./marks";
9+import { Skeleton } from "../ui/skeleton";
10+import { type ChatFilter, channelPath, filterEntries, sections } from "../../lib/chat";
11+
12+/** How often the sidebar asks again while the tab is shown. */
13+const REFRESH_MS = 30_000;
14+
15+/**
16+ * Chat mode's sidebar: the conversations, starred first, then channels and
17+ * direct messages, with what is unread in each. It refreshes itself while
18+ * the tab is shown, so unread counts move without a reload.
19+ */
20+export function ChatSidebar({ slug }: { slug: string }) {
21+ const data = useChatData();
22+ const { sidebar, refresh } = useChatSidebar();
23+ const [filter, setFilter] = useState<ChatFilter>("all");
24+ const [query, setQuery] = useState("");
25+ const navigate = useNavigate();
26+ const { pathname } = useLocation();
27+
28+ useEffect(() => {
29+ if (!data) return;
30+ const tick = () => {
31+ if (document.visibilityState === "visible") refresh();
32+ };
33+ const timer = setInterval(tick, REFRESH_MS);
34+ document.addEventListener("visibilitychange", tick);
35+ return () => {
36+ clearInterval(timer);
37+ document.removeEventListener("visibilitychange", tick);
38+ };
39+ }, [data, refresh]);
40+ const entries = sidebar?.entries ?? [];
41+ const shown = filterEntries(entries, filter, query);
42+ const { starred, channels, dms } = sections(shown);
43+ const loading = !data;
44+
45+ return (
46+ <div className="flex h-full flex-col">
47+ <div className="flex h-14 shrink-0 items-center justify-between border-b border-line pr-2.5 pl-4">
48+ <h2 className="text-[0.9375rem] font-semibold">Chat</h2>
49+ <NewMessageButton slug={slug} />
50+ </div>
51+ <div className="space-y-2.5 px-2.5 pt-3">
52+ <label className="flex h-8 items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] ring-1 ring-line transition-shadow focus-within:ring-accent-dim/70">
53+ <Search size={14} className="shrink-0 text-faint" />
54+ <input
55+ value={query}
56+ onChange={(event) => setQuery(event.target.value)}
57+ onKeyDown={(event) => {
58+ if (event.key === "Enter") {
59+ const first = [...starred, ...channels, ...dms][0];
60+ if (first) {
61+ navigate(channelPath(slug, first.channel));
62+ setQuery("");
63+ }
64+ } else if (event.key === "Escape") setQuery("");
65+ }}
66+ placeholder="Jump to channel or person"
67+ aria-label="Jump to channel or person"
68+ autoComplete="off"
69+ data-1p-ignore
70+ className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint"
71+ />
72+ </label>
73+ <div role="radiogroup" aria-label="Show" className="grid grid-cols-3 rounded-md bg-surface p-0.5 ring-1 ring-line">
74+ {(["all", "unread", "mentions"] as const).map((key) => (
75+ <button
76+ key={key}
77+ type="button"
78+ role="radio"
79+ aria-checked={filter === key}
80+ onClick={() => setFilter(key)}
81+ className={`h-6 rounded-[5px] text-xs font-medium capitalize transition-colors ${
82+ filter === key ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg"
83+ }`}
84+ >
85+ {key}
86+ </button>
87+ ))}
88+ </div>
89+ </div>
90+ <nav aria-label="Conversations" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]">
91+ {loading ? (
92+ <SidebarSkeleton />
93+ ) : !sidebar ? (
94+ <p className="px-2 py-3 text-xs leading-relaxed text-faint">Chat didn't answer. Your conversations will show here once it does.</p>
95+ ) : (
96+ <>
97+ {starred.length > 0 && (
98+ <Section title="Starred">
99+ {starred.map((entry) => (
100+ <Row key={entry.channel.id} entry={entry} slug={slug} pathname={pathname} />
101+ ))}
102+ </Section>
103+ )}
104+ <Section title="Channels" action={<CreateChannelButton slug={slug} />}>
105+ {channels.map((entry) => (
106+ <Row key={entry.channel.id} entry={entry} slug={slug} pathname={pathname} />
107+ ))}
108+ {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>}
109+ </Section>
110+ <Section title="Direct messages" action={<NewMessageButton slug={slug} />}>
111+ {dms.map((entry) => (
112+ <Row key={entry.channel.id} entry={entry} slug={slug} pathname={pathname} />
113+ ))}
114+ {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate or an agent.</Empty>}
115+ </Section>
116+ {shown.length === 0 && (filter !== "all" || query) && (
117+ <p className="px-2 py-2 text-xs text-faint">{query ? "No conversation matches." : filter === "unread" ? "You're all caught up." : "No mentions."}</p>
118+ )}
119+ <NavLink
120+ to={`/${slug}/-/chat/browse`}
121+ className={({ isActive }) =>
122+ `mt-3 flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${isActive ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`
123+ }
124+ >
125+ <Compass size={15} className="text-faint" />
126+ Browse all channels
127+ <span className="ml-auto text-xs text-faint tabular-nums">{(sidebar.browsable ?? 0) + entries.filter((e) => e.channel.kind === "channel").length}</span>
128+ </NavLink>
129+ </>
130+ )}
131+ </nav>
132+ </div>
133+ );
134+}
135+
136+function Section({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
137+ const [open, setOpen] = useState(true);
138+ return (
139+ <section className="mb-3">
140+ <div className="group/head flex h-7 items-center justify-between pr-0.5 pl-1">
141+ <button
142+ type="button"
143+ onClick={() => setOpen(!open)}
144+ aria-expanded={open}
145+ className="flex items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted"
146+ >
147+ <ChevronDown size={12} className={`transition-transform ${open ? "" : "-rotate-90"}`} />
148+ {title}
149+ </button>
150+ <span className="opacity-70 transition-opacity group-hover/head:opacity-100">{action}</span>
151+ </div>
152+ {open && <ul className="space-y-px">{children}</ul>}
153+ </section>
154+ );
155+}
156+
157+function Empty({ children }: { children: ReactNode }) {
158+ return <li className="px-2 py-1 text-xs text-faint">{children}</li>;
159+}
160+
161+/** One conversation: its mark, its name, and its unread count. */
162+function Row({ entry, slug, pathname }: { entry: ChatSidebarEntry; slug: string; pathname: string }) {
163+ const to = channelPath(slug, entry.channel);
164+ const current = pathname === to || pathname.startsWith(`${to}/`);
165+ const unread = !entry.muted && (entry.unread > 0 || entry.mentions > 0);
166+ const count = entry.mentions > 0 ? entry.mentions : entry.unread;
167+ const other = entry.others[0];
168+ const icon =
169+ entry.channel.kind === "dm" && other ? (
170+ <MemberAvatar member={other} size={18} />
171+ ) : entry.channel.private ? (
172+ <Lock size={14} />
173+ ) : (
174+ <Hash size={15} />
175+ );
176+ return (
177+ <li>
178+ <NavLink
179+ to={to}
180+ prefetch="intent"
181+ aria-current={current ? "page" : undefined}
182+ className={`group flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] transition-colors ${
183+ current ? "bg-raised text-fg" : unread ? "text-fg hover:bg-raised/60" : entry.muted ? "text-faint hover:bg-raised/60 hover:text-muted" : "text-muted hover:bg-raised/60 hover:text-fg"
184+ }`}
185+ >
186+ <span className={`flex w-[18px] shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
187+ <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
188+ {entry.title}
189+ </span>
190+ {entry.muted && <BellOff size={12} className="shrink-0 text-faint" aria-label="Muted" />}
191+ {unread && count > 0 && (
192+ <span
193+ className={`min-w-5 shrink-0 rounded-full px-1.5 text-center text-[0.6875rem] leading-[1.125rem] font-semibold tabular-nums ${
194+ entry.mentions > 0 ? "bg-accent text-bg" : "bg-line-strong text-fg"
195+ }`}
196+ >
197+ {count}
198+ </span>
199+ )}
200+ </NavLink>
201+ </li>
202+ );
203+}
204+
205+function SidebarSkeleton() {
206+ return (
207+ <div className="space-y-2 px-2 pt-1" aria-busy="true">
208+ <Skeleton className="h-3 w-16" />
209+ {[70, 52, 84, 60].map((w) => (
210+ <div key={w} className="flex h-6 items-center gap-2">
211+ <Skeleton className="size-4 rounded" />
212+ <Skeleton className="h-3" style={{ width: `${w}%` }} />
213+ </div>
214+ ))}
215+ <Skeleton className="mt-4 h-3 w-24" />
216+ {[64, 48, 72].map((w) => (
217+ <div key={w} className="flex h-6 items-center gap-2">
218+ <Skeleton className="size-4 rounded-full" />
219+ <Skeleton className="h-3" style={{ width: `${w}%` }} />
220+ </div>
221+ ))}
222+ </div>
223+ );
224+}
+137−0
1+import { Fragment, type ReactNode, useMemo } from "react";
2+import { Link } from "react-router";
3+
4+import { type Block, type Span, blocks, onlyEmoji } from "../../lib/chat";
5+
6+/** How the text's references resolve, from what the page knows. */
7+export type TextContext = {
8+ slug: string;
9+ /** The viewer's username: a mention of them stands out more. */
10+ me: string | null;
11+ /** Handles of the workspace's agents: their mentions go to the agent. */
12+ agents: ReadonlySet<string>;
13+ /** Channels by name, for `#name`. */
14+ channels: ReadonlySet<string>;
15+ /** The project `#123` means when no repository is named, if any. */
16+ project: string | null;
17+ /** Where a link into Code goes instead, for a member without Code access. */
18+ codeLink?: (href: string) => string;
19+};
20+
21+const PILL = "rounded-[4px] px-1 py-px font-medium";
22+
23+function SpanView({ span, context }: { span: Span; context: TextContext }): ReactNode {
24+ switch (span.t) {
25+ case "text":
26+ return span.v;
27+ case "code":
28+ return <code className="rounded-[4px] border border-line bg-raised px-1 py-px font-mono text-[0.84em] text-fg-soft">{span.v}</code>;
29+ case "strong":
30+ return <strong className="font-semibold text-fg">{spans(span.c, context)}</strong>;
31+ case "em":
32+ return <em>{spans(span.c, context)}</em>;
33+ case "del":
34+ return <del className="text-muted">{spans(span.c, context)}</del>;
35+ case "link": {
36+ if (span.href.startsWith("/")) {
37+ return (
38+ <Link to={context.codeLink ? context.codeLink(span.href) : span.href} className="text-accent underline-offset-2 hover:underline">
39+ {spans(span.c, context)}
40+ </Link>
41+ );
42+ }
43+ return (
44+ <a href={span.href} target="_blank" rel="noopener noreferrer nofollow ugc" className="text-accent underline-offset-2 hover:underline">
45+ {spans(span.c, context)}
46+ </a>
47+ );
48+ }
49+ case "mention": {
50+ const name = span.name.toLowerCase();
51+ const self = context.me != null && name === context.me.toLowerCase();
52+ const agent = context.agents.has(name);
53+ const to = agent ? `/${context.slug}/-/agents/${name}` : name.includes("/") ? `/${context.slug}/-/teams/${name.split("/")[1]}` : `/u/${name}`;
54+ return (
55+ <Link
56+ to={to}
57+ className={`${PILL} transition-colors ${
58+ self ? "bg-warn/20 text-warn hover:bg-warn/30" : "bg-accent/15 text-accent hover:bg-accent/25"
59+ }`}
60+ >
61+ @{span.name}
62+ </Link>
63+ );
64+ }
65+ case "channel":
66+ return context.channels.has(span.name) ? (
67+ <Link to={`/${context.slug}/-/chat/${span.name}`} className={`${PILL} bg-accent/10 text-accent hover:bg-accent/20`}>
68+ #{span.name}
69+ </Link>
70+ ) : (
71+ `#${span.name}`
72+ );
73+ case "ref": {
74+ const repo = span.repo ? (span.repo.includes("/") ? span.repo : `${context.slug}/${span.repo}`) : context.project ? `${context.slug}/${context.project}` : null;
75+ const label = `${span.repo ? span.repo : ""}#${span.number}`;
76+ if (!repo) return <span className="font-medium text-fg-soft">{label}</span>;
77+ // Issues and pull requests share numbers; the issue page sends a pull on.
78+ const href = `/${repo}/issues/${span.number}`;
79+ return (
80+ <Link to={context.codeLink ? context.codeLink(href) : href} className="font-medium text-accent hover:underline">
81+ {label}
82+ </Link>
83+ );
84+ }
85+ }
86+}
87+
88+function spans(list: Span[], context: TextContext): ReactNode {
89+ return list.map((span, index) => <SpanView key={index} span={span} context={context} />);
90+}
91+
92+function lines(list: Span[][], context: TextContext): ReactNode {
93+ return list.map((line, index) => (
94+ <Fragment key={index}>
95+ {index > 0 && <br />}
96+ {spans(line, context)}
97+ </Fragment>
98+ ));
99+}
100+
101+function BlockView({ block, context }: { block: Block; context: TextContext }) {
102+ switch (block.t) {
103+ case "p":
104+ return <p>{lines(block.lines, context)}</p>;
105+ case "code":
106+ return (
107+ <pre className="my-1 overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]">
108+ <code>{block.v}</code>
109+ </pre>
110+ );
111+ case "list": {
112+ const Tag = block.ordered ? "ol" : "ul";
113+ return (
114+ <Tag className={`my-0.5 space-y-0.5 pl-5 ${block.ordered ? "list-decimal" : "list-disc"} marker:text-faint`}>
115+ {block.items.map((item, index) => (
116+ <li key={index}>{spans(item, context)}</li>
117+ ))}
118+ </Tag>
119+ );
120+ }
121+ case "quote":
122+ return <blockquote className="border-l-2 border-line-strong pl-3 text-muted">{lines(block.lines, context)}</blockquote>;
123+ }
124+}
125+
126+/** A message's text, rendered from parsed blocks: never as HTML. */
127+export function MessageText({ body, context }: { body: string; context: TextContext }) {
128+ const parsed = useMemo(() => blocks(body), [body]);
129+ if (onlyEmoji(body)) return <p className="text-3xl leading-tight">{body.trim()}</p>;
130+ return (
131+ <div className="space-y-1.5 text-[0.9375rem] leading-[1.55] break-words text-fg-soft">
132+ {parsed.map((block, index) => (
133+ <BlockView key={index} block={block} context={context} />
134+ ))}
135+ </div>
136+ );
137+}
+2−0
7474 links: [
7575 ["Explore", "/explore"],
7676 ["Pricing", "/pricing"],
77+ ["Chat", "https://docs.g1t.sh/guides/chat/"],
78+ ["Agents", "https://docs.g1t.sh/guides/agents/"],
7779 ["Working with g1t", "https://docs.g1t.sh/guides/working-with-g1t/"],
7880 ["Bring your own agent", "https://docs.g1t.sh/guides/bring-your-own-agent/"],
7981 ["Integrations", "https://docs.g1t.sh/guides/integrations/"],
+506−230
1−import { ArrowRight, Check, KeyRound, PackageCheck, ScrollText, ShieldCheck } from "lucide-react";
1+import {
2+ ArrowRight,
3+ BookOpen,
4+ Bot,
5+ Check,
6+ CircleDashed,
7+ Code2,
8+ Eye,
9+ FileLock2,
10+ Handshake,
11+ Inbox,
12+ KeyRound,
13+ Lock,
14+ MessagesSquare,
15+ PackageCheck,
16+ ScrollText,
17+ ShieldCheck,
18+ Wallet,
19+} from "lucide-react";
220 import type { CSSProperties, ReactNode } from "react";
321 import { Link } from "react-router";
422
5−import { type Provider, PROVIDERS } from "@g1t/contracts";
6−
7−import { AgentSetup } from "./agent-setup";
8−import { DeployArt, HeroArt, Live, PlanArt, QueueArt, TeamArt, WhyArt } from "./art";
9−import { ProviderMark } from "./model-providers";
23+import { DeployArt, Live, PlanArt, QueueArt, WhyArt } from "./art";
24+import { AgentCardShot, ChatShot } from "./chat-shot";
1025 import { ButtonLink, CopyLine } from "./ui";
1126
1227 const DOCS = "https://docs.g1t.sh";
1631 return <p className="font-mono text-xs tracking-[0.2em] text-accent uppercase">{children}</p>;
1732 }
1833
34+/** Marks something planned and not built yet, wherever it is mentioned. */
35+function Soon({ className = "" }: { className?: string }) {
36+ return (
37+ <span
38+ className={`inline-flex shrink-0 items-center rounded-full bg-raised px-2 py-0.5 align-middle font-mono text-[0.625rem] font-medium tracking-wide whitespace-nowrap text-muted uppercase ring-1 ring-line-strong ${className}`}
39+ >
40+ Coming soon
41+ </span>
42+ );
43+}
44+
1945 function Tags({ items }: { items: string[] }) {
2046 return (
2147 <ul className="mt-6 flex flex-wrap gap-1.5">
4167 );
4268 }
4369
70+/** A point in a list: a check, or a "Coming soon" tag for what is not built yet. */
71+type Point = string | { text: string; soon: true };
72+
73+function Points({ points, columns = true }: { points: Point[]; columns?: boolean }) {
74+ const now = points.filter((point): point is string => typeof point === "string");
75+ const later = points.filter((point) => typeof point !== "string").map((point) => (point as { text: string }).text);
76+ return (
77+ <>
78+ <ul className={`mt-6 grid gap-2.5 text-sm ${columns ? "sm:grid-cols-2" : ""}`}>
79+ {now.map((text) => (
80+ <li key={text} className="flex gap-2.5 text-fg-soft">
81+ <Check size={15} className="mt-0.5 shrink-0 text-accent" />
82+ <span>{text}</span>
83+ </li>
84+ ))}
85+ </ul>
86+ {later.length > 0 && (
87+ <div className="mt-5 rounded-xl border border-dashed border-line-strong px-4 py-3.5">
88+ <Soon />
89+ <ul className={`mt-3 grid gap-2 text-sm ${columns ? "sm:grid-cols-2" : ""}`}>
90+ {later.map((text) => (
91+ <li key={text} className="flex gap-2.5 text-muted">
92+ <CircleDashed size={14} className="mt-0.5 shrink-0 text-faint" />
93+ <span>{text}</span>
94+ </li>
95+ ))}
96+ </ul>
97+ </div>
98+ )}
99+ </>
100+ );
101+}
102+
44103 /** A drawing in its own card, on the page's dark gray. */
45104 function ArtFrame({ children }: { children: ReactNode }) {
46105 return (
55114 }
56115
57116 /**
58− * One of the product's pillars: what it does, the features that prove it,
59− * and a drawing of the idea, on alternating sides.
117+ * One part of Code: what it does, the features that prove it, and a
118+ * drawing of the idea, on alternating sides.
60119 */
61120 function Pillar({
62121 eyebrow,
81140 <Eyebrow>{eyebrow}</Eyebrow>
82141 <h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance sm:text-3xl">{title}</h3>
83142 <p className="mt-4 max-w-xl leading-7 text-muted">{children}</p>
84− <ul className="mt-6 grid gap-2.5 text-sm sm:grid-cols-2">
85− {points.map((point) => (
86− <li key={point} className="flex gap-2.5 text-fg-soft">
87− <Check size={15} className="mt-0.5 shrink-0 text-accent" />
88− <span>{point}</span>
89− </li>
90− ))}
91− </ul>
143+ <Points points={points} />
92144 <More to={more[1]}>{more[0]}</More>
93145 </div>
94146 <div className={flip ? "lg:order-1" : undefined}>
100152
101153 /** Facts under the opening, each one true today. */
102154 const FACTS: [string, string][] = [
103− ["$0", "for the forge: repositories, issues, pull requests and review. No card."],
104− ["+20%", "on what compute costs g1t. That is the whole markup, and there is no seat price."],
105− ["MCP", "connects Claude Code, Codex, OpenCode or Cursor to your workspace: one command or one config file."],
155+ ["$0", "for people to chat. Every plan, the free one too, with no history cutoff."],
156+ ["0 seats", "Everyone in the company joins at one workspace price, never per person."],
157+ ["+20%", "on what compute costs g1t. Agents pay the model's price plus a flat agent rate."],
106158 ["MIT", "licensed. g1t's own source lives on g1t and lands through its own queue."],
107159 ];
108160
161+/** The four modes of a workspace. */
162+const MODES: { icon: ReactNode; name: string; about: string; soon?: boolean; to: string }[] = [
163+ {
164+ icon: <MessagesSquare size={18} />,
165+ name: "Chat",
166+ about: "Channels, direct messages and threads, live. People and agents are members alike.",
167+ to: `${DOCS}/guides/chat/`,
168+ },
169+ {
170+ icon: <Bot size={18} />,
171+ name: "Agents",
172+ about: "Named teammates with a job, a personality, a budget and limits on what they may do alone.",
173+ to: `${DOCS}/guides/agents/`,
174+ },
175+ {
176+ icon: <BookOpen size={18} />,
177+ name: "Docs",
178+ about: "Specs, runbooks and decisions, written together. Agents read them and keep them current.",
179+ soon: true,
180+ to: `${DOCS}/guides/docs/`,
181+ },
182+ {
183+ icon: <Code2 size={18} />,
184+ name: "Code",
185+ about: "Repositories, issues, pull requests, checks, a merge queue and deployments. For the people who build.",
186+ to: `${DOCS}/concepts/overview/`,
187+ },
188+];
189+
190+/** Who in a company talks to the agents, and what they ask. */
191+const COMPANY: { team: string; ask: string }[] = [
192+ { team: "Support", ask: "How does proration work when a customer downgrades mid-month?" },
193+ { team: "Sales", ask: "Can the export run on a schedule? A prospect needs it weekly." },
194+ { team: "Finance", ask: "What did our agents spend last month, by team?" },
195+ { team: "Design", ask: "Which screens still use the old empty state?" },
196+];
197+
198+const RAILS: { icon: ReactNode; title: string; about: string; to: string }[] = [
199+ {
200+ icon: <Wallet size={18} />,
201+ title: "Budgets",
202+ about:
203+ "The workspace's spend limit, then each agent's monthly cap and a cap per task. Spend shows on the agent, and a stopped budget stops the agent.",
204+ to: `${DOCS}/guides/agents/#budgets`,
205+ },
206+ {
207+ icon: <Eye size={18} />,
208+ title: "Scopes",
209+ about:
210+ "An agent reads what it is invited to and writes nothing until it is given access. It never does more for you than you could do yourself.",
211+ to: `${DOCS}/guides/agent-access/`,
212+ },
213+ {
214+ icon: <Handshake size={18} />,
215+ title: "Approvals",
216+ about:
217+ "Merging and deploying to production ask a person first by default. Rules, protected branches and required checks still apply on top.",
218+ to: `${DOCS}/guides/agents/#what-it-may-do-alone`,
219+ },
220+ {
221+ icon: <ScrollText size={18} />,
222+ title: "Audit",
223+ about: "Every action by people, tokens and agents: who did it, who asked, whether it was allowed and the rule that decided.",
224+ to: `${DOCS}/guides/audit-log/`,
225+ },
226+];
227+
109228 const FLOW: [string, string][] = [
110− ["Brief", "Write the outcome you want, in plain words, or open an issue as you always would."],
111− ["Plan", "A planner agent splits it into issues, each saying what done looks like, and the order they depend on."],
112− ["Agents", "Each issue gets an agent as it unblocks. They know what the others are changing, and tell each other."],
113− ["Review", "Checks run in clean sandboxes; a second agent reviews; you ask for changes and the agent makes them."],
114− ["Main", "The queue tests every change with what lands before it. Main moves only to what passed, and production follows."],
229+ ["Ask", "Ask in a channel or a DM, or open an issue and assign it to an agent."],
230+ ["Answer", "The agent answers in the thread, in its own voice, charged to its own budget."],
231+ ["Change", "Work on code becomes a pull request in its own fork, under the agent's budget."],
232+ ["Review", "Checks run in clean sandboxes, a second agent reviews, and a person approves."],
233+ ["Main", "The queue tests each change with what lands before it. Production follows main."],
115234 ];
116235
117−/** The forge itself, for the people on the team. */
236+/** The forge itself, for the people who build. */
118237 const FORGE = [
119238 "Git over HTTPS",
120239 "Public and private repositories",
124243 "Protected branches",
125244 "Required approvals",
126245 "Workspaces and roles",
127− "Profiles",
246+ "Teams",
128247 "Search and Explore",
129248 "Webhooks",
130249 "Import from any git host",
131250 "REST API and OpenAPI",
132251 "MCP server",
133− "OAuth sign-in",
134252 ];
135253
136254 const SECURE: { icon: ReactNode; title: string; about: string; to: string }[] = [
138256 icon: <ShieldCheck size={18} />,
139257 title: "Push protection",
140258 about: "A push that adds a key or a token is refused before it lands, and the history is scanned for ones already there.",
141− to: `${DOCS}/guides/security/`,
259+ to: `${DOCS}/guides/security/secret-protection/`,
142260 },
143261 {
144262 icon: <PackageCheck size={18} />,
145263 title: "Dependency upkeep",
146− about: "Each vulnerable dependency that has a fixed version becomes an upgrade issue, and an agent lands the upgrade through the same checks as any change.",
147− to: `${DOCS}/guides/security/#dependency-upkeep`,
264+ about: "Each vulnerable dependency that has a fixed version becomes an upgrade, landed through the same checks as any change.",
265+ to: `${DOCS}/guides/security/#security-updates`,
148266 },
149267 {
150268 icon: <KeyRound size={18} />,
153271 to: `${DOCS}/guides/guardrails/`,
154272 },
155273 {
156− icon: <ScrollText size={18} />,
157− title: "Audit log on every workspace",
158− about: "Every action by people, tokens and agents, with whether it was allowed and the rule that decided. Kept 90 days on the plan and 7 free, with export.",
159− to: `${DOCS}/guides/audit-log/`,
274+ icon: <Lock size={18} />,
275+ title: "Your own model providers",
276+ about: "Route an agent to the workspace's own provider keys only. g1t charges the agent rate; the provider bills you directly.",
277+ to: `${DOCS}/guides/models/`,
160278 },
161−];
162−
163−/** The systems g1t connects to, shown on the landing page. */
164−const STACK: Provider[] = [
165− "anthropic",
166− "openai",
167− "gemini",
168− "xai",
169− "mistral",
170− "deepseek",
171− "azure_openai",
172− "openrouter",
173− "groq",
174− "sentry",
175− "jira",
176− "linear",
177−];
178−
179−/** What happens to a production error once Sentry is connected. */
180−const LOOP = [
181− "Sentry: TypeError in checkout",
182− "g1t opens an issue, with the stack trace",
183− "An agent fixes it; another reviews it",
184− "The merge queue lands it on main",
185− "Sentry marks the error resolved",
186279 ];
187280
188281 const PRICES: { name: string; price: string; unit?: string; about: string }[] = [
189282 {
190− name: "The forge",
283+ name: "People",
191284 price: "$0",
192− about: "Public and private repositories, git, issues, pull requests, reviews, search and the audit log. No card.",
285+ unit: "to chat",
286+ about: "Channels, DMs, threads and the forge are included on every plan, free too. No seats and no history cutoff.",
193287 },
194288 {
195289 name: "The g1t plan",
198292 about: "$10 of usage included. Unlimited members. Agents, checks, the merge queue and deployments.",
199293 },
200294 {
201− name: "Usage",
202− price: "Cost + 20%",
203− about: "Past what is included, everything is priced at what it costs g1t plus 20%, under a spend limit you set.",
295+ name: "Agents",
296+ price: "Model + agent rate",
297+ about: "The provider's price plus a flat agent rate, charged to each agent's budget. An idle agent costs nothing.",
204298 },
205299 ];
206300
207−const PLATFORM = ["Workers", "Artifacts", "Containers", "D1", "Queues", "Durable Objects", "AI Gateway", "Rust"];
208−
209301 /**
210302 * The signed-out home page: what g1t is, what it does, and how to start.
211303 * It lists no one's repositories; those are a click away under Explore.
221313 />
222314 <div className="relative mx-auto max-w-6xl px-4 pt-20 text-center sm:pt-24">
223315 <Link
224− to={`${DOCS}/concepts/overview/`}
316+ to={`${DOCS}/guides/chat/`}
225317 className="inline-flex animate-fade-up items-center gap-2 rounded-full bg-bg/50 px-3 py-1 text-xs text-fg-soft ring-1 ring-white/10 backdrop-blur transition-colors hover:bg-bg/70"
226318 >
227319 <span className="size-1.5 rounded-full bg-accent" />
228− Open source · built on Cloudflare
320+ Chat, agents, docs and code in one workspace
229321 <ArrowRight size={12} />
230322 </Link>
231323 <h1 className="mx-auto mt-7 max-w-4xl animate-fade-up text-[2.75rem] leading-[1.04] font-semibold tracking-tight text-balance sm:text-7xl">
232− Where people and agents{" "}
324+ Your team and its agents,{" "}
233325 <span className="bg-gradient-to-r from-fg via-[#d9d1ff] to-accent bg-clip-text text-transparent">
234− ship software together.
326+ working in one place.
235327 </span>
236328 </h1>
237329 <p className="mx-auto mt-7 max-w-2xl animate-fade-up text-lg leading-8 text-fg-soft/80 text-balance">
238− g1t is the git platform for the whole job. Plan in issues, assign work to agents like
239− teammates, land it through checks that hold, and deploy every change to the edge. Open
240− source, and priced at what it costs.
330+ Talk to your team and your agents in channels and DMs. Agents are teammates with a name, a job and a
331+ budget. They answer, take on work and ship it through checks that hold. No separate chat app, wiki or
332+ forge to stitch together.
241333 </p>
242334 <div className="mt-9 flex animate-fade-up flex-wrap items-center justify-center gap-3">
243335 <ButtonLink to="/register" variant="primary" large>
249341 </ButtonLink>
250342 </div>
251343 <p className="mx-auto mt-5 max-w-xl animate-fade-up text-sm text-fg-soft/60 text-balance">
252− The forge is free, with no card. Agents and checks start with a trial after a card check;
253− deployments come with the g1t plan.
344+ Chat and the forge are free, with no card. Agents spend AI credit you add, under budgets you set.
254345 </p>
255346 </div>
256− <Live className="relative mx-auto max-w-6xl px-2 pt-6 pb-2 sm:px-4">
257− <HeroArt className="w-full" />
258− </Live>
347+ <div className="relative mx-auto max-w-5xl px-4 pt-14 pb-2">
348+ <div
349+ aria-hidden="true"
350+ className="pointer-events-none absolute inset-x-10 top-10 bottom-0 rounded-[3rem] bg-[radial-gradient(60%_60%_at_50%_40%,rgb(182_168_255/0.14),transparent)] blur-2xl"
351+ />
352+ <ChatShot className="relative" />
353+ </div>
259354 <div aria-hidden="true" className="h-16 bg-gradient-to-b from-transparent to-bg" />
260355 </section>
261356
262− {/* Facts, each one true today. Above the hero's fade, with a border of
263− its own: a ring is a shadow, and the fade drew over its top edge. */}
357+ {/* Facts, each one true today. */}
264358 <section className="relative z-10 mx-auto max-w-6xl px-4">
265359 <dl className="grid gap-px overflow-hidden rounded-2xl border border-line bg-line sm:grid-cols-2 lg:grid-cols-4">
266360 {FACTS.map(([figure, about]) => (
272366 </dl>
273367 </section>
274368
275− {/* The pillars. */}
369+ {/* The four modes. */}
276370 <section className="mx-auto max-w-6xl px-4 pt-24">
277371 <div className="max-w-3xl">
278− <Eyebrow>One place, idea to production</Eyebrow>
372+ <Eyebrow>One workspace</Eyebrow>
279373 <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-5xl">
280− Everything between an idea and the people using it.
374+ Talk, work, write it down, ship.
281375 </h2>
282376 <p className="mt-5 max-w-2xl text-lg leading-8 text-muted">
283− Agents are members of the forge, not a tool beside it. They take issues, review, answer
284− each other and fix what fails, under the same rules, records and review as everyone
285− else.
377+ Four modes share one set of members, one sign-in and one bill. Agents are members of the workspace, the same
378+ as the people in it.
379+ </p>
380+ </div>
381+ <div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
382+ {MODES.map((mode) => (
383+ <Link
384+ key={mode.name}
385+ to={mode.to}
386+ className="group flex flex-col rounded-2xl bg-surface p-6 ring-1 ring-line transition-colors hover:ring-line-strong"
387+ >
388+ <span className="flex items-center justify-between gap-2">
389+ <span className="flex size-9 items-center justify-center rounded-lg bg-bg text-accent ring-1 ring-line">{mode.icon}</span>
390+ {mode.soon && <Soon />}
391+ </span>
392+ <h3 className="mt-5 text-lg font-semibold">{mode.name}</h3>
393+ <p className="mt-2 text-sm leading-6 text-muted">{mode.about}</p>
394+ </Link>
395+ ))}
396+ </div>
397+ </section>
398+
399+ {/* Chat first. */}
400+ <section className="mx-auto grid max-w-6xl items-start gap-12 px-4 pt-24 lg:grid-cols-[1.1fr_1fr] lg:gap-16">
401+ <div>
402+ <Eyebrow>Chat</Eyebrow>
403+ <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
404+ Just talk to your team. Agents included.
405+ </h2>
406+ <p className="mt-4 max-w-xl leading-7 text-muted">
407+ Chat is where work starts. Ask a question in a channel, DM an agent, or mention one in a thread. Messages
408+ arrive the moment they are sent, and the agent answers in the same thread, in its own voice and on its own
409+ budget.
410+ </p>
411+ <Points
412+ points={[
413+ "Public and private channels",
414+ "Direct messages with people and agents",
415+ "Threads on any message",
416+ "@mentions of people and agents",
417+ "Live delivery, typing and read state",
418+ { text: "Agents that look up code, issues and checks as they answer", soon: true },
419+ { text: "Pull requests, checks and deploys as cards in the channel", soon: true },
420+ { text: "Mentions and approvals in your inbox", soon: true },
421+ { text: "Desktop and mobile apps", soon: true },
422+ ]}
423+ />
424+ <p className="mt-5 flex max-w-xl flex-wrap items-center gap-x-2.5 gap-y-1.5 text-sm leading-6 text-muted">
425+ <Soon />
426+ <span>Already have a chat app your company lives in? Your agents can work there too.</span>
427+ </p>
428+ <More to={`${DOCS}/guides/chat/`}>How Chat works</More>
429+ </div>
430+ <div className="rounded-3xl bg-surface p-7 ring-1 ring-line">
431+ <Eyebrow>Issues still work</Eyebrow>
432+ <h3 className="mt-3 text-xl font-semibold tracking-tight">Prefer to plan in issues? Nothing changes.</h3>
433+ <p className="mt-3 text-sm leading-6 text-muted">
434+ Open an issue and assign it to an agent, mention <span className="font-mono text-fg-soft">@g1t</span> on a
435+ pull request, or hand off an outcome and let a planner split it into issues. You don&apos;t have to start
436+ in chat. You just can.
286437 </p>
438+ <ol className="mt-6 space-y-3 text-sm">
439+ {[
440+ "Open an issue: what should change, and what done looks like",
441+ "Assign it to an agent, the same as a person",
442+ "The agent opens a pull request and reports back",
443+ ].map((step, index) => (
444+ <li key={step} className="flex items-start gap-3 text-fg-soft">
445+ <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-bg font-mono text-xs text-faint ring-1 ring-line">
446+ {index + 1}
447+ </span>
448+ {step}
449+ </li>
450+ ))}
451+ </ol>
452+ <More to={`${DOCS}/guides/working-with-g1t/`}>Assign work to agents</More>
287453 </div>
454+ </section>
288455
289− <div className="mt-6 divide-y divide-line">
290− <Pillar
291− eyebrow="Agents as teammates"
292− title="Assign an agent like anyone on the team"
293− art={<TeamArt className="w-full" />}
456+ {/* Agents as teammates. */}
457+ <section className="mx-auto grid max-w-6xl items-center gap-12 px-4 pt-28 lg:grid-cols-2 lg:gap-16">
458+ <div className="lg:order-2">
459+ <Eyebrow>Agents</Eyebrow>
460+ <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
461+ Agents are teammates, with a job and a budget
462+ </h2>
463+ <p className="mt-4 max-w-xl leading-7 text-muted">
464+ Give an agent a name, a role and a job, and pick how it talks. DM it or invite it to a channel. It
465+ answers where it was asked, in its own voice. Its job decides what it does; its
466+ personality only changes its voice.
467+ </p>
468+ <p className="mt-4 max-w-xl leading-7 text-muted">
469+ Nobody picks a model. <span className="text-fg-soft">Auto</span> routes each step to the cheapest model
470+ that can do it. You set a floor and a ceiling, and which providers it may use: g1t&apos;s models, your
471+ workspace&apos;s own provider keys, or both.
472+ </p>
473+ <Points
294474 points={[
295− "Assign an issue, or @mention g1t anywhere",
296− "Told what the others are changing while they work",
297− "Agents ask each other, and ask you, through the forge",
298− "Steer a run while it works, or stop it",
299− "Memory per project and per workspace",
300− "Your own agent over MCP, its session on the pull request",
475+ "Start from a template or from scratch",
476+ "Role, job and personality",
477+ "Model routing with a floor and a ceiling",
478+ "Your own providers, or g1t's",
479+ "Monthly, daily and per-task budgets",
480+ "What it may do alone, and what needs you",
481+ { text: "Updates on their own: progress, shipped, stuck", soon: true },
482+ { text: "Members of teams, like anyone else", soon: true },
483+ { text: "Sessions that pause and resume with full context", soon: true },
484+ { text: "Describe an agent in chat and confirm the draft", soon: true },
301485 ]}
302− more={["How g1t works on issues", `${DOCS}/guides/working-with-g1t/`]}
303− >
304− Every agent works in its own sandbox and its own fork. It sees what is in flight, files
305− what it finds instead of widening its change, comments on the work of others, and
306− defers to people.
307− </Pillar>
486+ />
487+ <More to={`${DOCS}/guides/agents/`}>Create an agent</More>
488+ </div>
489+ <div className="lg:order-1">
490+ <AgentCardShot />
491+ </div>
492+ </section>
493+
494+ {/* The whole company. */}
495+ <section className="mt-28 border-y border-line bg-surface/40">
496+ <div className="mx-auto max-w-6xl px-4 py-24">
497+ <div className="grid gap-12 lg:grid-cols-[1fr_1.1fr] lg:gap-16">
498+ <div>
499+ <Eyebrow>The whole company</Eyebrow>
500+ <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
501+ Bring the whole company. Code is for the people who build.
502+ </h2>
503+ <p className="mt-4 max-w-xl leading-7 text-muted">
504+ Support, sales, finance and design talk to the same agents as engineering, in the same channels. They
505+ ask how the product works and what changed. With no seats, adding everyone costs nothing until they use
506+ agents.
507+ </p>
508+ <p className="mt-4 max-w-xl leading-7 text-muted">
509+ An agent never does more for someone than that person could do themselves. When someone who can&apos;t
510+ change the code asks for a change, the agent doesn&apos;t refuse and doesn&apos;t do it. It offers to
511+ write it up as a request for the team that owns that area.
512+ </p>
513+ <Points
514+ columns={false}
515+ points={[
516+ "Agents know whether the person asking can change code",
517+ "Agents answer with what everyone in the conversation may see",
518+ { text: "Answers looked up in the code, issues and docs", soon: true },
519+ { text: "Requests filed with the owning team, with word when they ship", soon: true },
520+ { text: "Code access as a switch per member", soon: true },
521+ { text: "Agents that listen in channels and group requests", soon: true },
522+ { text: "File uploads, with classification and customer-data rules", soon: true },
523+ { text: "Connectors to the company's other systems", soon: true },
524+ ]}
525+ />
526+ <More to={`${DOCS}/guides/agent-access/`}>What agents can do for whom</More>
527+ </div>
528+ <ul className="grid content-start gap-3">
529+ {COMPANY.map((item) => (
530+ <li key={item.team} className="flex gap-4 rounded-2xl bg-bg p-5 ring-1 ring-line">
531+ <span className="mt-0.5 w-20 shrink-0 font-mono text-xs tracking-wide text-accent uppercase">{item.team}</span>
532+ <span className="text-sm leading-6 text-fg-soft">&ldquo;{item.ask}&rdquo;</span>
533+ </li>
534+ ))}
535+ <li className="flex items-start gap-3 rounded-2xl bg-bg p-5 text-sm leading-6 text-muted ring-1 ring-line">
536+ <FileLock2 size={16} className="mt-1 shrink-0 text-accent" />
537+ <span>
538+ <Soon className="mb-2" />
539+ <span className="block">
540+ A support lead with read access asks how proration works and gets an answer from the code. They
541+ can&apos;t get it changed. Their request goes to the billing team, and they hear back when the fix
542+ ships.
543+ </span>
544+ </span>
545+ </li>
546+ </ul>
547+ </div>
548+ </div>
549+ </section>
550+
551+ {/* The rails. */}
552+ <section className="mx-auto max-w-6xl px-4 py-24">
553+ <Eyebrow>The rails</Eyebrow>
554+ <h2 className="mt-3 max-w-2xl text-3xl font-semibold tracking-tight text-balance">
555+ Policy decides what an agent may do, not the agent
556+ </h2>
557+ <p className="mt-4 max-w-2xl leading-7 text-muted">
558+ Budgets, scopes, approvals and audit are set by the workspace and enforced outside the model. They show on
559+ every agent, so anyone can see why it stopped or what it is allowed to do.
560+ </p>
561+ <div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
562+ {RAILS.map((item) => (
563+ <Link
564+ key={item.title}
565+ to={item.to}
566+ className="group rounded-2xl bg-surface p-6 ring-1 ring-line transition-colors hover:ring-line-strong"
567+ >
568+ <span className="flex size-9 items-center justify-center rounded-lg bg-bg text-accent ring-1 ring-line">{item.icon}</span>
569+ <h3 className="mt-5 font-semibold">{item.title}</h3>
570+ <p className="mt-2 text-sm leading-6 text-muted">{item.about}</p>
571+ </Link>
572+ ))}
573+ </div>
574+ </section>
575+
576+ {/* Coming next: coordination and Docs. */}
577+ <section className="mx-auto grid max-w-6xl gap-4 px-4 pb-24 lg:grid-cols-2">
578+ <div className="rounded-3xl bg-surface p-8 ring-1 ring-line">
579+ <div className="flex items-center gap-3">
580+ <Eyebrow>Coordination</Eyebrow>
581+ <Soon />
582+ </div>
583+ <h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">Many agents, no collisions</h3>
584+ <p className="mt-3 text-sm leading-6 text-muted">
585+ Before an agent touches an issue, a branch, an environment or a set of paths, it claims it. When two
586+ agents&apos; work would overlap, they agree who goes first in a thread you can read. Chains of agents stop
587+ and ask a person after a few hops, and work done for another agent is charged to the task that asked.
588+ </p>
589+ <p className="mt-3 text-sm leading-6 text-muted">
590+ Today, every agent already sees what the others are changing and can ask them, through the forge.
591+ </p>
592+ </div>
593+ <div className="rounded-3xl bg-surface p-8 ring-1 ring-line">
594+ <div className="flex items-center gap-3">
595+ <Eyebrow>Docs</Eyebrow>
596+ <Soon />
597+ </div>
598+ <h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">A knowledge base that keeps itself true</h3>
599+ <p className="mt-3 text-sm leading-6 text-muted">
600+ Spaces of pages, edited together live, with history, comments and backlinks. Agents read them before they
601+ answer and suggest edits you accept like a review. When a merged change touches something a page cites, the
602+ page is flagged and its owner, person or agent, drafts the update.
603+ </p>
604+ <More to={`${DOCS}/guides/docs/`}>What Docs will do</More>
605+ </div>
606+ </section>
607+
608+ {/* How it flows */}
609+ <section id="how" className="scroll-mt-20 border-y border-line bg-surface/40">
610+ <div className="mx-auto max-w-6xl px-4 py-24">
611+ <Eyebrow>How it flows</Eyebrow>
612+ <h2 className="mt-3 text-3xl font-semibold tracking-tight">From a message to main</h2>
613+ <Live>
614+ <ol className="relative mt-12 grid gap-6 md:grid-cols-5">
615+ {/* From the first step's dot to the last one's, and no further: five
616+ columns with 1.5rem gaps put the last dot 1rem into the last
617+ column, so the line stops one column, less that, from the end. */}
618+ <span
619+ aria-hidden="true"
620+ className="absolute top-4 right-[calc((100%-6rem)/5-1rem)] left-4 hidden h-px bg-gradient-to-r from-accent/50 via-warn/50 to-success/70 md:block"
621+ >
622+ <span className="flow-runner absolute inset-0">
623+ <span className="absolute -top-[3px] -left-[3.5px] size-[7px] rounded-full bg-accent shadow-[0_0_12px_2px_rgb(182_168_255/0.6)]" />
624+ </span>
625+ </span>
626+ {FLOW.map(([step, about], index) => (
627+ <li key={step} className="relative">
628+ <span
629+ className={`flow-step relative flex size-8 items-center justify-center rounded-full font-mono text-xs ring-1 ${
630+ index === FLOW.length - 1 ? "bg-success text-bg ring-success" : "bg-bg text-fg-soft ring-line-strong"
631+ }`}
632+ style={{ "--delay": `${index * 2}s` } as CSSProperties}
633+ >
634+ {index + 1}
635+ </span>
636+ <h3 className="mt-4 font-semibold">{step}</h3>
637+ <p className="mt-1.5 text-sm leading-6 text-muted">{about}</p>
638+ </li>
639+ ))}
640+ </ol>
641+ </Live>
642+ <p className="mt-10 flex max-w-3xl flex-wrap items-center gap-x-2.5 gap-y-1.5 text-sm leading-6 text-muted">
643+ <Soon />
644+ <span>
645+ Starting a change straight from a chat message, with a live task card in the thread. Today, code work
646+ starts from an issue assigned to an agent or an <span className="font-mono">@g1t</span> mention.
647+ </span>
648+ </p>
649+ </div>
650+ </section>
651+
652+ {/* Code. */}
653+ <section className="mx-auto max-w-6xl px-4 pt-24">
654+ <div className="max-w-3xl">
655+ <Eyebrow>Code</Eyebrow>
656+ <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-5xl">
657+ A forge built for many agents at once.
658+ </h2>
659+ <p className="mt-5 max-w-2xl text-lg leading-8 text-muted">
660+ Underneath the conversation is plain git. Every change an agent makes is a pull request in its own fork,
661+ checked by g1t, reviewed, and landed through a queue that keeps main passing.
662+ </p>
663+ </div>
308664
665+ <div className="mt-6 divide-y divide-line">
309666 <Pillar
310− flip
311667 eyebrow="Outcomes"
312668 title="Hand off an outcome, not just a task"
313669 art={<PlanArt className="w-full" />}
319675 ]}
320676 more={["Hand off an outcome", `${DOCS}/guides/outcomes/`]}
321677 >
322− Write what should be true. A planner turns it into issues, each saying what done looks
323− like, and the order they depend on. Agents take each issue as it unblocks, and you
324− watch the whole outcome converge.
678+ Write what should be true. A planner turns it into issues, each saying what done looks like, and the order
679+ they depend on. Agents take each issue as it unblocks, and you watch the whole outcome converge.
325680 </Pillar>
326681
327682 <Pillar
683+ flip
328684 eyebrow="Ship safely"
329685 title="Main only moves to what passed"
330686 art={<QueueArt className="w-full" />}
331687 points={[
332688 "Checks run by g1t in a clean sandbox",
333− "Workflows in GitHub Actions syntax",
689+ "Workflows from .g1t/workflows",
334690 "A merge queue that tests changes together",
335691 "Conflicts found on every push, before a merge",
336692 "Catch up with main in seconds",
338694 ]}
339695 more={["The merge queue", `${DOCS}/guides/merge-queue/`]}
340696 >
341− Checks are run by g1t, never by the agent being checked. The queue tests each change
342− together with what lands ahead of it; one that breaks goes back to its author with what
343− failed, and main never sees it.
697+ Checks are run by g1t, never by the agent being checked. The queue tests each change together with what
698+ lands ahead of it; one that breaks goes back to its author with what failed, and main never sees it.
344699 </Pillar>
345700
346701 <Pillar
347− flip
348702 eyebrow="Context"
349703 title="Every line knows why it is there"
350704 art={<WhyArt className="w-full" />}
356710 ]}
357711 more={["Sessions and why-blame", `${DOCS}/guides/why-blame/`]}
358712 >
359− Pick any line. g1t shows the commit that changed it, the pull request and issue it came
360− from, and the agent&apos;s own session: what it read, ran and decided. The reasoning stays
361− with the code, for people and for the next agent.
713+ Pick any line. g1t shows the commit that changed it, the pull request and issue it came from, and the
714+ agent&apos;s own session: what it read, ran and decided. The reasoning stays with the code, for people and
715+ for the next agent.
362716 </Pillar>
363717
364718 <Pillar
719+ flip
365720 eyebrow="Run it"
366721 title="Every change, live on the edge"
367722 art={<DeployArt className="w-full" />}
373728 ]}
374729 more={["Deployments", `${DOCS}/guides/deployments/`]}
375730 >
376− Turn on deployments and every pull request gets its own address on g1t.page; merging
377− ships production. Reviewers, and the agents reviewing for you, click through a change
378− instead of reading a diff. An app nobody visits runs nothing and costs nothing.
731+ Turn on deployments and every pull request gets its own address on g1t.page; merging ships production.
732+ Reviewers, and the agents reviewing for you, click through a change instead of reading a diff. An app nobody
733+ visits runs nothing and costs nothing.
379734 </Pillar>
380− </div>
381− </section>
382−
383− {/* How it flows */}
384− <section id="how" className="scroll-mt-20 border-y border-line bg-surface/40">
385− <div className="mx-auto max-w-6xl px-4 py-24">
386− <Eyebrow>How it flows</Eyebrow>
387− <h2 className="mt-3 text-3xl font-semibold tracking-tight">From a sentence to main</h2>
388− <Live>
389− <ol className="relative mt-12 grid gap-6 md:grid-cols-5">
390− {/* From the first step's dot to the last one's, and no further: five
391− columns with 1.5rem gaps put the last dot 1rem into the last
392− column, so the line stops one column, less that, from the end. */}
393− <span
394− aria-hidden="true"
395− className="absolute top-4 right-[calc((100%-6rem)/5-1rem)] left-4 hidden h-px bg-gradient-to-r from-accent/50 via-warn/50 to-success/70 md:block"
396− >
397− <span className="flow-runner absolute inset-0">
398− <span className="absolute -top-[3px] -left-[3.5px] size-[7px] rounded-full bg-accent shadow-[0_0_12px_2px_rgb(182_168_255/0.6)]" />
399− </span>
400− </span>
401− {FLOW.map(([step, about], index) => (
402− <li key={step} className="relative">
403− <span
404− className={`flow-step relative flex size-8 items-center justify-center rounded-full font-mono text-xs ring-1 ${
405− index === FLOW.length - 1 ? "bg-success text-bg ring-success" : "bg-bg text-fg-soft ring-line-strong"
406− }`}
407− style={{ "--delay": `${index * 2}s` } as CSSProperties}
408− >
409− {index + 1}
410− </span>
411− <h3 className="mt-4 font-semibold">{step}</h3>
412− <p className="mt-1.5 text-sm leading-6 text-muted">{about}</p>
413− </li>
414− ))}
415− </ol>
416− </Live>
417735 </div>
418736 </section>
419737
423741 <div>
424742 <Eyebrow>Collaborate</Eyebrow>
425743 <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
426− A complete forge for the people on your team
744+ A complete forge for the people who build
427745 </h2>
428746 <p className="mt-4 max-w-md leading-7 text-muted">
429− Issues, branches, pull requests and reviews, the way your team already works, with
430− agents as members alongside you. Work by hand, hand work off, or both on the same
431− issue.
747+ Issues, branches, pull requests and reviews, the way your team already works, with agents as members
748+ alongside you. Work by hand, hand work off, or both on the same issue.
432749 </p>
433750 <More to={`${DOCS}/concepts/overview/`}>How g1t works</More>
434751 </div>
469786 Any agent that speaks MCP joins the team
470787 </h2>
471788 <p className="mt-4 max-w-md leading-7 text-muted">
472− Add g1t to Claude Code, Codex, OpenCode or Cursor and it can read the plan, take an
473− issue, open a pull request with a fork to push to, and see what the others are doing.
474− Install the hook in Claude Code and its session is recorded onto the pull request as
475− it works. g1t&apos;s own agents use the same tools.
789+ Connect the coding agent you already use and it can read the plan, take an issue, open a pull request
790+ with a fork to push to, and see what the others are doing. Its session is recorded onto the pull request
791+ as it works. g1t&apos;s own agents use the same tools.
476792 </p>
477793 <p className="mt-4 max-w-md leading-7 text-muted">
478− Every action is a REST route and an MCP tool, with an OpenAPI document and signed
479− webhooks for every event.
794+ Every action is a REST route and an MCP tool, with an OpenAPI document and signed webhooks for every
795+ event.
480796 </p>
481797 <div className="mt-6 flex flex-wrap gap-3">
482798 <ButtonLink to={`${DOCS}/guides/bring-your-own-agent/`} variant="quiet">
488804 </ButtonLink>
489805 </div>
490806 </div>
491− <div className="space-y-3">
492− <AgentSetup />
493− <CopyLine prompt text="curl -fsSL https://g1t.sh/install/claude.sh | sh" />
807+ <div className="min-w-0 space-y-3">
808+ <p className="text-sm text-muted">Add g1t as a remote MCP server, then sign in through your browser:</p>
809+ <CopyLine text="https://mcp.g1t.sh" />
494810 <CopyLine prompt text="git clone https://g1t.sh/flagon-io/g1t.git" />
495811 <CopyLine prompt text="curl https://api.g1t.sh/repos/flagon-io/g1t/queue" />
496− </div>
497− </div>
498− </section>
499−
500− {/* Your stack */}
501− <section className="mx-auto grid max-w-6xl items-center gap-12 px-4 py-24 lg:grid-cols-2">
502− <div className="lg:order-2">
503− <Eyebrow>Your stack</Eyebrow>
504− <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
505− Your models. Your alerts. Your tickets.
506− </h2>
507− <p className="mt-4 max-w-md leading-7 text-muted">
508− Run g1t&apos;s agents on g1t&apos;s models, or on your own accounts with any of the labs and
509− platforms, and choose which model does which work. Keys stay with g1t: an agent&apos;s
510− sandbox only ever holds a token for its own run.
511− </p>
512− <p className="mt-4 max-w-md leading-7 text-muted">
513− Connect Sentry and a new error becomes an issue, an agent fixes it, and Sentry hears it
514− was resolved. Mention TECH-1234 and the agent reads the Jira ticket.
515− </p>
516− <More to={`${DOCS}/guides/integrations/`}>See the integrations</More>
517− </div>
518− <div className="space-y-6 lg:order-1">
519− <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
520− {STACK.map((provider) => (
521− <div key={provider} className="flex flex-col items-center gap-2 rounded-xl bg-surface px-2 py-3 ring-1 ring-line">
522− <ProviderMark provider={provider} size={30} />
523− <span className="w-full text-center text-xs leading-tight text-muted">{PROVIDERS[provider].label}</span>
524− </div>
525− ))}
526812 </div>
527− <ol className="space-y-2 font-mono text-[0.8125rem]">
528− {LOOP.map((step, index) => (
529− <li key={step} className="flex items-center gap-3 text-fg-soft">
530− <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-xs text-faint ring-1 ring-line">
531− {index + 1}
532− </span>
533− {step}
534− </li>
535− ))}
536− </ol>
537813 </div>
538814 </section>
539815
540816 {/* Pricing, in one band. */}
541− <section className="mx-auto max-w-6xl px-4 pb-24">
817+ <section className="mx-auto max-w-6xl px-4 py-24">
542818 <div className="flex flex-wrap items-end justify-between gap-6">
543819 <div>
544820 <Eyebrow>Pricing</Eyebrow>
545821 <h2 className="mt-3 max-w-2xl text-3xl font-semibold tracking-tight text-balance">
546− What it costs to run, plus 20%. Never per seat.
822+ People chat free. Agents pay for what they use. Never per seat.
547823 </h2>
548824 </div>
549825 <More to="/pricing">See every price</More>
569845 <Eyebrow>Open source</Eyebrow>
570846 <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">Open, and built on itself</h2>
571847 <p className="mt-4 max-w-xl leading-7 text-muted">
572− g1t is MIT licensed. Its own source lives on g1t, and its changes land through its own
573− merge queue. Your code is plain git: leaving is a <code className="font-mono text-fg-soft">git clone</code>.
848+ g1t is MIT licensed. Its own source lives on g1t, and its changes land through its own merge queue. Your
849+ code is plain git: leaving is a <code className="font-mono text-fg-soft">git clone</code>.
574850 </p>
575851 <p className="mt-4 max-w-xl leading-7 text-muted">
576− You can run the core forge yourself with Docker Compose today: accounts, repositories,
577− issues, pull requests and search. It is an early version, and agents, deployments and
578− context search still run only on g1t.sh.
852+ You can run the core forge yourself with Docker Compose today: accounts, repositories, issues, pull
853+ requests and search. It is an early version, and agents, chat, deployments and context search still run
854+ only on g1t.sh.
579855 </p>
580856 <div className="mt-7 flex flex-wrap gap-3">
581857 <ButtonLink to="/flagon-io/g1t" variant="quiet">
587863 </div>
588864 </div>
589865 <div className="flex flex-col justify-between gap-6">
590− <div>
591− <p className="text-sm font-medium">Built on Cloudflare</p>
592− <ul className="mt-4 flex flex-wrap gap-2">
593− {PLATFORM.map((name) => (
594− <li key={name} className="rounded-full bg-bg/60 px-3 py-1 font-mono text-xs text-fg-soft ring-1 ring-line">
595− {name}
596− </li>
597− ))}
598− </ul>
599− </div>
600− <p className="text-sm leading-6 text-muted">
601− g1t is made by Flagon, Inc., a small, independent software company.
602− </p>
866+ <ul className="space-y-3 text-sm">
867+ {[
868+ { icon: <Inbox size={15} />, text: "One inbox for mentions, reviews and approvals" },
869+ { icon: <ScrollText size={15} />, text: "One audit log for people and agents" },
870+ { icon: <Wallet size={15} />, text: "One bill, at cost plus 20%" },
871+ ].map((item) => (
872+ <li key={item.text} className="flex items-center gap-3 text-fg-soft">
873+ <span className="flex size-7 items-center justify-center rounded-md bg-bg text-accent ring-1 ring-line">{item.icon}</span>
874+ {item.text}
875+ </li>
876+ ))}
877+ </ul>
878+ <p className="text-sm leading-6 text-muted">g1t is made by Flagon, Inc., a small, independent software company.</p>
603879 </div>
604880 </div>
605881 </section>
611887 Bring your team. Bring your agents.
612888 </h2>
613889 <p className="mx-auto mt-4 max-w-xl text-fg-soft/80 text-balance">
614− Create a workspace in a minute and push your first repository. The forge is free; start
615− the plan when you want agents and deployments.
890+ Create a workspace in a minute, open Chat and say hello to an agent. Chat and the forge are free; add AI
891+ credit when you want agents to work.
616892 </p>
617893 <div className="mt-8 flex flex-wrap justify-center gap-3">
618894 <ButtonLink to="/register" variant="primary" large>
+151−0
1+import { BookOpen, Check, Code2, House, Inbox, MessagesSquare, Plus, Sparkles } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { Link, useLocation, useNavigation } from "react-router";
4+
5+import { type Membership, type User, hasCodeAccess } from "@g1t/contracts";
6+
7+import { Avatar } from "./ui";
8+import { Hint } from "./ui/hint";
9+import {
10+ DropdownMenu,
11+ DropdownMenuContent,
12+ DropdownMenuItem,
13+ DropdownMenuLabel,
14+ DropdownMenuSeparator,
15+ DropdownMenuTrigger,
16+} from "./ui/dropdown-menu";
17+import { type ModeKey, modeHome, modeOf } from "../lib/workspace-nav";
18+
19+/** The rail's width; the shell lays the mode's sidebar and the page out beside it. */
20+export const RAIL_WIDTH = "5rem";
21+
22+type Mode = { key: ModeKey; label: string; icon: ReactNode; badge?: { count: number; loud: boolean } | null };
23+
24+function Badge({ count, loud }: { count: number; loud: boolean }) {
25+ if (count <= 0) return null;
26+ return (
27+ <span
28+ className={`absolute -top-1 -right-1.5 min-w-[1.125rem] rounded-full px-1 text-center text-[0.625rem] leading-[1.125rem] font-bold tabular-nums ring-2 ring-[var(--rail-bg)] ${
29+ loud ? "bg-accent text-bg" : "bg-[#3d3d45] text-fg"
30+ }`}
31+ >
32+ {count > 99 ? "99+" : count}
33+ </span>
34+ );
35+}
36+
37+function RailButton({ mode, to, current }: { mode: Mode; to: string; current: boolean }) {
38+ return (
39+ <Link
40+ to={to}
41+ prefetch="intent"
42+ aria-current={current ? "page" : undefined}
43+ aria-label={mode.badge && mode.badge.count > 0 ? `${mode.label}, ${mode.badge.count} unread` : mode.label}
44+ className="group flex w-full flex-col items-center gap-1 rounded-lg py-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"
45+ >
46+ <span
47+ className={`relative flex size-9 items-center justify-center rounded-[10px] transition-colors ${
48+ current ? "bg-[#2c2c33] text-fg shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" : "text-muted group-hover:bg-raised group-hover:text-fg"
49+ }`}
50+ >
51+ {mode.icon}
52+ {mode.badge && <Badge count={mode.badge.count} loud={mode.badge.loud} />}
53+ </span>
54+ <span className={`text-[0.6875rem] leading-none font-medium transition-colors ${current ? "text-fg" : "text-faint group-hover:text-muted"}`}>
55+ {mode.label}
56+ </span>
57+ </Link>
58+ );
59+}
60+
61+function displayName(membership: Membership): string {
62+ return membership.name?.trim() || membership.slug;
63+}
64+
65+/** The workspace at the top of the rail: its avatar, which switches between yours. */
66+function WorkspaceButton({ user, workspace }: { user: User; workspace: Membership }) {
67+ return (
68+ <DropdownMenu>
69+ <Hint label={displayName(workspace)} side="right">
70+ <DropdownMenuTrigger
71+ aria-label={`${displayName(workspace)}: switch workspace`}
72+ 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"
73+ >
74+ <Avatar name={workspace.slug} image={workspace.avatar} size={40} square />
75+ </DropdownMenuTrigger>
76+ </Hint>
77+ <DropdownMenuContent side="right" align="start" className="w-64">
78+ <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
79+ {(user.workspaces ?? []).map((membership) => (
80+ <DropdownMenuItem key={membership.slug} asChild>
81+ <Link to={hasCodeAccess(membership) ? `/${membership.slug}` : `/${membership.slug}/-/home`}>
82+ <Avatar name={membership.slug} image={membership.avatar} size={24} square />
83+ <span className="flex min-w-0 grow flex-col leading-tight">
84+ <span className="truncate">{displayName(membership)}</span>
85+ <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
86+ </span>
87+ {membership.slug === workspace.slug ? <Check className="shrink-0 text-accent" /> : <span className="size-4 shrink-0" aria-hidden="true" />}
88+ </Link>
89+ </DropdownMenuItem>
90+ ))}
91+ <DropdownMenuSeparator />
92+ <DropdownMenuItem asChild>
93+ <Link to="/workspaces/new">
94+ <Plus />
95+ New workspace
96+ </Link>
97+ </DropdownMenuItem>
98+ </DropdownMenuContent>
99+ </DropdownMenu>
100+ );
101+}
102+
103+/**
104+ * The rail down the left (docs/WORKSPACE.md, "Shell"): the workspace, then
105+ * Home, Code, Chat, Docs, Agents and the Inbox, then the account. The mode
106+ * lit follows the address. A member without Code access has no Code.
107+ */
108+export function Rail({
109+ user,
110+ workspace,
111+ unread,
112+ account,
113+}: {
114+ user: User;
115+ workspace: Membership;
116+ unread: { inbox: number; chat: number; mentions: number };
117+ account: ReactNode;
118+}) {
119+ const { pathname } = useLocation();
120+ const going = useNavigation().location?.pathname;
121+ const here = modeOf(going ?? pathname, workspace.slug);
122+ const code = hasCodeAccess(workspace);
123+ const modes: Mode[] = [
124+ { key: "home", label: "Home", icon: <House size={19} /> },
125+ ...(code ? [{ key: "code" as const, label: "Code", icon: <Code2 size={19} /> }] : []),
126+ {
127+ key: "chat",
128+ label: "Chat",
129+ icon: <MessagesSquare size={19} />,
130+ badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
131+ },
132+ { key: "docs", label: "Docs", icon: <BookOpen size={19} /> },
133+ { key: "agents", label: "Agents", icon: <Sparkles size={19} /> },
134+ { key: "inbox", label: "Inbox", icon: <Inbox size={19} />, badge: { count: unread.inbox, loud: true } },
135+ ];
136+ return (
137+ <nav
138+ aria-label="Modes"
139+ style={{ width: RAIL_WIDTH, ["--rail-bg" as string]: "#0b0b0d" }}
140+ className="flex h-full shrink-0 flex-col items-center border-r border-line bg-[var(--rail-bg)] pt-3 pb-3"
141+ >
142+ <WorkspaceButton user={user} workspace={workspace} />
143+ <div className="mt-4 flex w-full flex-col items-center gap-2.5 px-2">
144+ {modes.map((mode) => (
145+ <RailButton key={mode.key} mode={mode} to={modeHome(mode.key, workspace.slug, code)} current={here === mode.key} />
146+ ))}
147+ </div>
148+ <div className="mt-auto">{account}</div>
149+ </nav>
150+ );
151+}
+98−19
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
5−import { type Abilities, type InboxCounts, type Membership, type Spike, type User, mayCreateTeams } from "@g1t/contracts";
5+import { type Abilities, type InboxCounts, type Membership, type Spike, type User, hasCodeAccess, mayCreateTeams } from "@g1t/contracts";
66
77 import { InMain } from "./landmark";
88 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
2525 DropdownMenuTrigger,
2626 } from "./ui/dropdown-menu";
2727 import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap";
28−import { SETTINGS_PAGES, sidebarCurrent } from "../lib/workspace-nav";
28+import { type ModeKey, SETTINGS_PAGES, modeOf, sidebarCurrent } from "../lib/workspace-nav";
29+import { AgentsSidebar } from "./agents-mode";
30+import { ChatSidebar } from "./chat/sidebar";
31+import { Rail } from "./rail";
32+import { useChatSidebar } from "./chat/actions";
33+import { unreadTotals } from "../lib/chat";
2934 import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access";
3035 import { VISITOR_LINKS, projectPages } from "../lib/chrome";
3136 import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings";
7681 compute?: { paused: string | null; spike: Spike | null; owner: boolean } | null;
7782 /** What is unread in their inbox, for the bell. Absent for a visitor. */
7883 inbox?: InboxCounts | null;
84+ /** What is unread in chat, for the rail's badge; null when chat did not answer. */
85+ chat?: { unread: number; mentions: number } | null;
7986 };
8087
8188 function SidebarLink({
368375 /** A menu row: an icon, words, and whatever sits at its end. */
369376 const MENU_ROW = "h-9 gap-2.5 px-2.5 text-[0.8125rem]";
370377
371−function AccountMenu({ user }: { user: User }) {
378+function AccountMenu({ user, rail = false }: { user: User; rail?: boolean }) {
372379 const submit = useSubmit();
373380 const [open, setOpen] = useState(false);
374381 // Name, primary address and invites left: asked for once, as soon as the
387394 const profile = `/u/${user.username}`;
388395 return (
389396 <DropdownMenu open={open} onOpenChange={setOpen}>
397+ {rail ? (
398+ // On the rail: the avatar alone, with a dot that says you are here.
399+ <DropdownMenuTrigger
400+ aria-label={`Account menu for ${user.username}`}
401+ onPointerEnter={prefetch}
402+ onFocus={prefetch}
403+ className="relative rounded-full 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"
404+ >
405+ <Avatar name={user.username} image={user.avatar} size={34} />
406+ <span aria-hidden="true" className="absolute -right-0.5 -bottom-0.5 size-3 rounded-full bg-success ring-[2.5px] ring-[#0b0b0d]" />
407+ </DropdownMenuTrigger>
408+ ) : (
390409 <DropdownMenuTrigger
391410 aria-label={`Account menu for ${user.username}`}
392411 onPointerEnter={prefetch}
397416 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
398417 <ChevronsUpDown size={14} className="shrink-0 text-faint" />
399418 </DropdownMenuTrigger>
400− <DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-[17.5rem] p-1.5">
419+ )}
420+ <DropdownMenuContent align={rail ? "end" : "start"} side={rail ? "right" : "top"} collisionPadding={8} className="w-[17.5rem] p-1.5">
401421 {/* Who is signed in, and a way to their profile. */}
402422 <DropdownMenuItem asChild className="gap-3 px-2 py-2">
403423 <Link to={profile} aria-label={`${me?.name ?? user.username} (@${user.username}), your profile`}>
14361456 observability: "Observability",
14371457 insights: "Insights",
14381458 sessions: "Sessions",
1459+ chat: "Chat",
1460+ docs: "Docs",
1461+ home: "Home",
1462+ "code-access": "Code access",
14391463 };
14401464
14411465 /** Settings pages whose name differs from the section's of the same word. */
17131737 );
17141738 }
17151739
1740+/** The mode's sidebar beside the rail: a shade lighter than the page. */
1741+const SIDEBAR_BOX = "h-full border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]";
1742+
17161743 /**
1744+ * Which sidebar sits beside the rail: Chat's and Agents' own, Code's (the
1745+ * one with projects, also on Mission control and the inbox for someone with
1746+ * Code), or none (Docs, and Home or the inbox without Code).
1747+ */
1748+function sidebarFor(mode: ModeKey | null, code: boolean, pathname: string): "code" | "chat" | "agents" | null {
1749+ if (mode == null) return "code";
1750+ if (mode === "chat") return "chat";
1751+ if (mode === "agents") return "agents";
1752+ if (mode === "docs") return null;
1753+ if (mode === "code") return "code";
1754+ if (!code) return null;
1755+ return mode === "inbox" || pathname === "/" ? "code" : null;
1756+}
1757+
1758+/**
17171759 * The app: a sidebar with the workspace, its repositories and the sections
17181760 * of the one being looked at; a slim bar with search and the account; and
17191761 * the page. A visitor who is not signed in gets the same frame, with
17551797 usePaletteShortcut(() => setPalette((open) => !open));
17561798 const leaving = useLeaving();
17571799
1800+ // The rail and the mode's sidebar (docs/WORKSPACE.md, "Shell"), for
1801+ // someone in a workspace. A visitor, or someone with none, keeps the one
1802+ // sidebar.
1803+ const going = useNavigation().location?.pathname;
1804+ const ws = user ? shell.workspace : null;
1805+ const mode: ModeKey | null = ws ? modeOf(going ?? pathname, ws.slug) : null;
1806+ const panel = sidebarFor(mode, ws ? hasCodeAccess(ws) : true, going ?? pathname);
1807+ const { sidebar: chatSidebar } = useChatSidebar();
1808+ const chatUnread = chatSidebar ? unreadTotals(chatSidebar.entries) : (shell.chat ?? { unread: 0, mentions: 0 });
1809+ const sidebarNode = (inSheet: boolean) =>
1810+ panel === "chat" && ws ? (
1811+ <ChatSidebar slug={ws.slug} />
1812+ ) : panel === "agents" && ws ? (
1813+ <AgentsSidebar slug={ws.slug} />
1814+ ) : panel === "code" ? (
1815+ <Sidebar
1816+ user={user}
1817+ shell={shell}
1818+ missing={missing}
1819+ onFind={() => {
1820+ if (inSheet) setDrawer(false);
1821+ setPalette(true);
1822+ }}
1823+ onClose={inSheet ? () => setDrawer(false) : undefined}
1824+ />
1825+ ) : null;
1826+ const rail =
1827+ user && ws ? (
1828+ <Rail
1829+ user={user}
1830+ workspace={ws}
1831+ unread={{ inbox: shell.inbox?.unread ?? 0, chat: chatUnread.unread, mentions: chatUnread.mentions }}
1832+ account={<AccountMenu user={user} rail />}
1833+ />
1834+ ) : null;
1835+ const pad = rail ? (panel ? "lg:pl-[21rem]" : "lg:pl-20") : "lg:pl-64";
1836+
17581837 return (
17591838 // The phone's menu is a sheet: a dialog that holds focus, closes on
17601839 // Escape or a tap outside, and gives focus back to the menu button.
17611840 <Sheet open={drawer} onOpenChange={setDrawer}>
17621841 <div className="min-h-screen">
17631842 <Progress />
1764− <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block">
1765− <Sidebar user={user} shell={shell} missing={missing} onFind={() => setPalette(true)} />
1843+ <aside className="fixed inset-y-0 left-0 z-40 hidden lg:flex">
1844+ {rail}
1845+ {panel && <div className={`${SIDEBAR_BOX} w-64`}>{sidebarNode(false)}</div>}
17661846 </aside>
1767− <SheetContent side="left" showClose={false} aria-describedby={undefined} className="w-72 max-w-[85vw] sm:max-w-72 lg:hidden">
1847+ <SheetContent
1848+ side="left"
1849+ showClose={false}
1850+ aria-describedby={undefined}
1851+ className={`lg:hidden ${rail ? `flex flex-row ${panel ? "w-[21rem] max-w-[92vw] sm:max-w-[21rem]" : "w-20 sm:max-w-20"}` : "w-72 max-w-[85vw] sm:max-w-72"}`}
1852+ >
17681853 <SheetTitle className="sr-only">Menu</SheetTitle>
1769− <Sidebar
1770− user={user}
1771− shell={shell}
1772− missing={missing}
1773− onFind={() => {
1774− setDrawer(false);
1775− setPalette(true);
1776− }}
1777− onClose={() => setDrawer(false)}
1778− />
1854+ {rail}
1855+ {panel && <div className={`min-w-0 grow ${rail ? SIDEBAR_BOX : ""}`}>{sidebarNode(true)}</div>}
17791856 </SheetContent>
17801857
1781− <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
1782− <header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6">
1858+ <div className={`flex min-h-screen min-w-0 flex-col ${pad}`}>
1859+ <header
1860+ className={`sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6 ${mode === "chat" ? "lg:hidden" : ""}`}
1861+ >
17831862 <SheetTrigger
17841863 aria-label="Open menu"
17851864 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
+57−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { cleanHandle, dollarsField, microsFromDollars, readAgentForm } from "./agent-form.ts";
5+
6+function form(fields: Record<string, string>): FormData {
7+ const data = new FormData();
8+ for (const [key, value] of Object.entries(fields)) data.set(key, value);
9+ return data;
10+}
11+
12+test("handles are cleaned as typed", () => {
13+ assert.equal(cleanHandle("@Release Manager!"), "release-manager");
14+ assert.equal(cleanHandle("--ship--"), "ship");
15+});
16+
17+test("the agent form becomes what the service takes, in micro-dollars", () => {
18+ const read = readAgentForm(
19+ form({
20+ display_name: "Ship",
21+ handle: "",
22+ role: "Cuts releases",
23+ instructions: "Cut a release on Tuesdays.",
24+ personality_preset: "terse",
25+ floor: "large",
26+ ceiling: "none",
27+ providers: "g1t",
28+ monthly: "40",
29+ daily: "",
30+ task: "2.50",
31+ merge: "never",
32+ capacity: "2",
33+ }),
34+ );
35+ assert.ok(read.ok);
36+ assert.equal(read.input.handle, "ship");
37+ assert.equal(read.input.personality_preset, "terse");
38+ assert.deepEqual(read.input.routing, { floor: "large", ceiling: null, providers: ["g1t"], pinned: null });
39+ assert.deepEqual(read.input.budget, { monthly_micros: 40_000_000, daily_micros: null, task_micros: 2_500_000 });
40+ assert.equal(read.input.autonomy?.merge, "never");
41+ assert.equal(read.input.autonomy?.open_pull_requests, "alone");
42+ assert.equal(read.input.capacity, 2);
43+});
44+
45+test("the agent form says what to fix", () => {
46+ const read = readAgentForm(form({ display_name: "", handle: "g1t", floor: "frontier", ceiling: "small", monthly: "lots", capacity: "0" }));
47+ assert.ok(!read.ok);
48+ assert.deepEqual(Object.keys(read.errors).sort(), ["capacity", "ceiling", "display_name", "handle", "instructions", "monthly", "role"]);
49+});
50+
51+test("dollars and micro-dollars", () => {
52+ assert.equal(microsFromDollars("$12.50"), 12_500_000);
53+ assert.equal(microsFromDollars(""), null);
54+ assert.ok(Number.isNaN(microsFromDollars("lots")));
55+ assert.equal(dollarsField(2_500_000), "2.50");
56+ assert.equal(dollarsField(50_000_000), "50");
57+});
+154−0
1+/**
2+ * The agent form (components/agents-mode.tsx) as the agents service takes
3+ * it: what each field means, and the checks made before it is sent. Pure,
4+ * so it is tested on its own.
5+ */
6+import type { ModelTier, NewWorkspaceAgent, PersonalityPreset, WorkspaceAgent } from "@g1t/contracts";
7+
8+/** The tiers, cheapest first: `MODEL_TIERS` in the contracts. */
9+const MODEL_TIERS: ModelTier[] = ["small", "large", "frontier"];
10+
11+export const PRESETS: { value: PersonalityPreset; label: string; about: string }[] = [
12+ { value: "crisp", label: "Crisp", about: "Clear and to the point, with enough context to act on." },
13+ { value: "friendly", label: "Friendly", about: "Warm and encouraging; explains as it goes." },
14+ { value: "socratic", label: "Socratic", about: "Asks before it assumes; draws out what you mean." },
15+ { value: "terse", label: "Terse operator", about: "As few words as will do. Status, then done." },
16+];
17+
18+export const TIER_LABELS: Record<ModelTier, string> = {
19+ small: "Fast: replies and triage",
20+ large: "Standard: making changes, most reviews",
21+ frontier: "Most capable: planning and hard work",
22+};
23+
24+export const AUTONOMY = {
25+ open_pull_requests: { label: "Open pull requests", options: [["alone", "On its own"], ["approval", "After approval"]] },
26+ merge: { label: "Merge", options: [["alone", "On its own"], ["approval", "After approval"], ["never", "Never"]] },
27+ deploy_production: { label: "Deploy to production", options: [["approval", "After approval"], ["never", "Never"]] },
28+ edit_docs: { label: "Edit docs", options: [["alone", "Directly"], ["suggest", "As suggestions"]] },
29+} as const;
30+
31+/** The handle as people type it: lowercase letters, digits and dashes. */
32+export function cleanHandle(typed: string): string {
33+ return typed
34+ .trim()
35+ .toLowerCase()
36+ .replace(/^@/, "")
37+ .replace(/[\s_]+/g, "-")
38+ .replace(/[^a-z0-9-]/g, "")
39+ .replace(/-{2,}/g, "-")
40+ .replace(/^-|-$/g, "")
41+ .slice(0, 39);
42+}
43+
44+/** Handles the site's own addresses use, and g1t's: never an agent's. */
45+const RESERVED_HANDLES = new Set(["g1t", "new", "runs", "fleet"]);
46+
47+function tier(value: FormDataEntryValue | null): ModelTier | null {
48+ const text = String(value ?? "");
49+ return (MODEL_TIERS as string[]).includes(text) ? (text as ModelTier) : null;
50+}
51+
52+function pick<T extends string>(value: FormDataEntryValue | null, allowed: readonly (readonly [T, string])[], fallback: T): T {
53+ const text = String(value ?? "");
54+ return (allowed.find(([key]) => key === text)?.[0] ?? fallback) as T;
55+}
56+
57+export type AgentFormResult = { ok: true; input: NewWorkspaceAgent } | { ok: false; errors: Record<string, string> };
58+
59+/** The form, checked: the agent to make or the changes to save, or what to fix, by field. */
60+export function readAgentForm(form: FormData): AgentFormResult {
61+ const errors: Record<string, string> = {};
62+ const display_name = String(form.get("display_name") ?? "").trim();
63+ const handle = cleanHandle(String(form.get("handle") ?? "") || display_name);
64+ const role = String(form.get("role") ?? "").trim();
65+ const instructions = String(form.get("instructions") ?? "").trim();
66+ if (!display_name) errors.display_name = "Give it a name.";
67+ if (!handle) errors.handle = "Give it a handle, like @ship.";
68+ else if (RESERVED_HANDLES.has(handle)) errors.handle = `@${handle} is taken by g1t.`;
69+ if (!role) errors.role = "Say in a line what it does.";
70+ if (!instructions) errors.instructions = "Tell it what it's responsible for.";
71+ const floor = tier(form.get("floor"));
72+ const ceiling = tier(form.get("ceiling"));
73+ if (floor && ceiling && MODEL_TIERS.indexOf(floor) > MODEL_TIERS.indexOf(ceiling)) errors.ceiling = "The ceiling can't be below the floor.";
74+ const budget = {
75+ monthly_micros: microsFromDollars(form.get("monthly")),
76+ daily_micros: microsFromDollars(form.get("daily")),
77+ task_micros: microsFromDollars(form.get("task")),
78+ };
79+ for (const [field, value] of [["monthly", budget.monthly_micros], ["daily", budget.daily_micros], ["task", budget.task_micros]] as const) {
80+ if (value != null && Number.isNaN(value)) errors[field] = "A sum in dollars, like 25.";
81+ }
82+ const capacity = Number(form.get("capacity") ?? 3);
83+ if (!Number.isInteger(capacity) || capacity < 1 || capacity > 20) errors.capacity = "Between 1 and 20.";
84+ const providers = String(form.get("providers") ?? "any");
85+ const pinned = String(form.get("pinned") ?? "").trim();
86+ if (pinned && !/^[\w.-]+\/[\w.:@-]+$/.test(pinned)) errors.pinned = "As provider/model.";
87+ if (Object.keys(errors).length > 0) return { ok: false, errors };
88+ return {
89+ ok: true,
90+ input: {
91+ handle,
92+ display_name,
93+ role,
94+ instructions,
95+ personality_preset: pick(form.get("personality_preset"), PRESETS.map((p) => [p.value, p.label] as const), "crisp"),
96+ personality: String(form.get("personality") ?? "").trim(),
97+ routing: {
98+ floor,
99+ ceiling,
100+ providers: providers === "any" ? [] : [providers],
101+ pinned: pinned || null,
102+ },
103+ budget,
104+ autonomy: {
105+ open_pull_requests: pick(form.get("open_pull_requests"), AUTONOMY.open_pull_requests.options, "alone"),
106+ merge: pick(form.get("merge"), AUTONOMY.merge.options, "approval"),
107+ deploy_production: pick(form.get("deploy_production"), AUTONOMY.deploy_production.options, "approval"),
108+ edit_docs: pick(form.get("edit_docs"), AUTONOMY.edit_docs.options, "suggest"),
109+ },
110+ capacity,
111+ template: String(form.get("template") ?? "") || null,
112+ },
113+ };
114+}
115+
116+/** What the form starts from: an agent being edited, a template, or nothing. */
117+export type AgentDraft = Pick<WorkspaceAgent, "handle" | "display_name" | "role" | "instructions" | "personality_preset" | "personality" | "routing" | "budget" | "autonomy" | "capacity" | "template">;
118+
119+export const BLANK_DRAFT: AgentDraft = {
120+ handle: "",
121+ display_name: "",
122+ role: "",
123+ instructions: "",
124+ personality_preset: "crisp",
125+ personality: "",
126+ routing: { floor: null, ceiling: null, providers: [], pinned: null },
127+ budget: { monthly_micros: 50_000_000, daily_micros: null, task_micros: 5_000_000 },
128+ autonomy: { open_pull_requests: "alone", merge: "approval", deploy_production: "approval", edit_docs: "suggest" },
129+ capacity: 3,
130+ template: null,
131+};
132+
133+/** Dollars from micro-dollars, for a form field: empty for none. */
134+export function dollarsField(micros: number | null | undefined): string {
135+ if (micros == null) return "";
136+ const dollars = micros / 1_000_000;
137+ return Number.isInteger(dollars) ? String(dollars) : dollars.toFixed(2);
138+}
139+
140+/** Micro-dollars from a form field in dollars: null when empty, NaN when not a sum. */
141+export function microsFromDollars(value: FormDataEntryValue | null): number | null {
142+ const text = String(value ?? "").trim().replace(/^\$/, "").replaceAll(",", "");
143+ if (!text) return null;
144+ const dollars = Number(text);
145+ if (!Number.isFinite(dollars) || dollars < 0) return Number.NaN;
146+ return Math.round(dollars * 1_000_000);
147+}
148+
149+/** "$12.40" from micro-dollars. */
150+export function formatDollars(micros: number): string {
151+ const dollars = micros / 1_000_000;
152+ return `$${dollars < 10 && dollars > 0 && !Number.isInteger(dollars) ? dollars.toFixed(2) : dollars.toLocaleString("en-US", { maximumFractionDigits: 2, minimumFractionDigits: Number.isInteger(dollars) ? 0 : 2 })}`;
153+}
154+
+69−0
1+import type { ChatSidebar, Principal, User, WorkspaceAgent } from "@g1t/contracts";
2+
3+import type { Mentionable } from "./chat";
4+import { chat, identity, workspaceAgents } from "./services.server";
5+
6+/**
7+ * Who can be talked to in a workspace: its people and its agents, for
8+ * mentions and for starting a direct message. Each list is empty, not an
9+ * error, when its service does not answer: chat and agents may arrive a
10+ * little after the site does.
11+ */
12+export async function workspacePeople(slug: string, viewer: User): Promise<{ people: Mentionable[]; agents: WorkspaceAgent[] }> {
13+ const [members, agents] = await Promise.all([
14+ identity.listMembers(slug, viewer).catch(() => null),
15+ workspaceAgents.list(slug, viewer).catch(() => null),
16+ ]);
17+ const people: Mentionable[] = members?.ok
18+ ? members.value.map((member) => ({
19+ kind: "user" as const,
20+ name: member.username,
21+ display_name: member.name?.trim() || member.username,
22+ avatar: member.avatar ?? null,
23+ }))
24+ : [];
25+ return { people, agents: agents?.ok ? agents.value.filter((agent) => !agent.archived_at) : [] };
26+}
27+
28+/** An agent as a mention: by its handle, with its role beside it. */
29+export function agentMentionable(agent: WorkspaceAgent): Mentionable {
30+ return { kind: "agent", name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role };
31+}
32+
33+/** The sidebar, or null when chat does not answer. */
34+export async function sidebarOrNull(slug: string, viewer: User): Promise<ChatSidebar | null> {
35+ try {
36+ const result = await chat.sidebar(slug, viewer);
37+ return result.ok ? result.value : null;
38+ } catch {
39+ return null;
40+ }
41+}
42+
43+/**
44+ * Members as a form sends them, `user:<username>` or `agent:<id>`, as the
45+ * chat service names them: a person by their account id. Anyone who is not
46+ * a member of the workspace is left out.
47+ */
48+export async function principalsFrom(slug: string, viewer: User, fields: string[]): Promise<Principal[]> {
49+ const usernames = fields.filter((f) => f.startsWith("user:")).map((f) => f.slice(5).toLowerCase());
50+ const agentIds = fields.filter((f) => f.startsWith("agent:")).map((f) => f.slice(6)).filter(Boolean);
51+ const members = usernames.length > 0 ? await identity.listMembers(slug, viewer).catch(() => null) : null;
52+ const known = new Set(members?.ok ? members.value.map((m) => m.username.toLowerCase()) : []);
53+ const people = await Promise.all(
54+ usernames
55+ .filter((name) => known.has(name) && name !== viewer.username.toLowerCase())
56+ .map((name) => identity.userByUsername(name).catch(() => null)),
57+ );
58+ return [
59+ ...people.filter((user): user is User => user != null).map((user) => ({ kind: "user" as const, id: user.id })),
60+ ...agentIds.map((id) => ({ kind: "agent" as const, id })),
61+ ];
62+}
63+
64+/** The cookie that remembers the last conversation open, so Chat opens there again. */
65+export const LAST_CHAT_COOKIE = "g1t_chat";
66+
67+export function rememberChat(path: string, secure: boolean): string {
68+ return `${LAST_CHAT_COOKIE}=${encodeURIComponent(path)}; Path=/; Max-Age=2592000; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`;
69+}
+166−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { ChatMessage, ChatSidebarEntry } from "@g1t/contracts";
5+
6+import {
7+ backoff,
8+ blocks,
9+ channelName,
10+ channelPath,
11+ dayLabel,
12+ filterEntries,
13+ inline,
14+ mentionQuery,
15+ mergeMessages,
16+ safeHref,
17+ sections,
18+ timeline,
19+ unreadTotals,
20+ type ShownMessage,
21+} from "./chat.ts";
22+
23+const ana = { kind: "user" as const, id: "u1", name: "ana", display_name: "Ana", avatar: null, role: null };
24+const bot = { kind: "agent" as const, id: "a1", name: "reviewer", display_name: "Reviewer", avatar: null, role: "Reviews pull requests" };
25+
26+function message(id: string, at: string, over: Partial<ChatMessage> = {}): ShownMessage {
27+ return {
28+ id,
29+ channel_id: "c1",
30+ author: ana,
31+ kind: "text",
32+ body: "hi",
33+ card: null,
34+ thread_root: null,
35+ reply_count: 0,
36+ last_reply_at: null,
37+ created_at: at,
38+ edited_at: null,
39+ deleted_at: null,
40+ ...over,
41+ };
42+}
43+
44+test("messages from one author close together share a header; days get a rule", () => {
45+ const now = new Date("2026-10-08T15:00:00Z");
46+ const rows = timeline(
47+ [
48+ message("01", "2026-10-07T10:00:00Z"),
49+ message("02", "2026-10-08T10:00:00Z"),
50+ message("03", "2026-10-08T10:02:00Z"),
51+ message("04", "2026-10-08T10:03:00Z", { author: bot }),
52+ message("05", "2026-10-08T10:30:00Z", { author: bot }),
53+ message("06", "2026-10-08T10:31:00Z", { author: bot, kind: "card" }),
54+ message("07", "2026-10-08T10:32:00Z", { deleted_at: "2026-10-08T10:33:00Z" }),
55+ ],
56+ now,
57+ "UTC",
58+ );
59+ assert.deepEqual(
60+ rows.map((row) => (row.kind === "day" ? row.label : `${row.message.id}${row.head ? "*" : ""}`)),
61+ ["Yesterday", "01*", "Today", "02*", "03", "04*", "05*", "06*"],
62+ );
63+ assert.equal(dayLabel("2026-09-01T10:00:00Z", now, "UTC"), "Tuesday, September 1");
64+});
65+
66+test("merging keeps one copy of each message, and drops the pending one once saved", () => {
67+ const pending = { ...message("tmp-1", "2026-10-08T10:00:00Z"), pending: true, client_id: "tmp-1" };
68+ const saved = { ...message("05", "2026-10-08T10:00:01Z"), client_id: "tmp-1" };
69+ const merged = mergeMessages([message("02", "2026-10-08T09:00:00Z"), pending], [saved, message("02", "2026-10-08T09:00:00Z", { body: "edited" })]);
70+ assert.deepEqual(merged.map((m) => m.id), ["02", "05"]);
71+ assert.equal(merged[0]!.body, "edited");
72+ // Still pending: last, after everything saved.
73+ assert.deepEqual(mergeMessages([pending], [message("09", "2026-10-08T11:00:00Z")]).map((m) => m.id), ["09", "tmp-1"]);
74+});
75+
76+test("an @ being typed suggests people and agents", () => {
77+ const people = [
78+ { kind: "user" as const, name: "ana", display_name: "Ana Lima", avatar: null },
79+ { kind: "agent" as const, name: "reviewer", display_name: "Reviewer", avatar: null },
80+ { kind: "user" as const, name: "bo", display_name: "Robert Reed", avatar: null },
81+ ];
82+ assert.deepEqual(mentionQuery("hey @re", 7, people)?.options.map((p) => p.name), ["reviewer", "bo"]);
83+ assert.equal(mentionQuery("hey @re", 7, people)?.start, 4);
84+ assert.equal(mentionQuery("mail me@an", 10, people), null);
85+ assert.deepEqual(mentionQuery("@", 1, people)?.options.length, 3);
86+ assert.equal(mentionQuery("@zz", 3, people), null);
87+});
88+
89+test("message text: mentions, references, links and code, never HTML", () => {
90+ assert.deepEqual(inline("ask @reviewer about #12 and g1t#3"), [
91+ { t: "text", v: "ask " },
92+ { t: "mention", name: "reviewer" },
93+ { t: "text", v: " about " },
94+ { t: "ref", repo: null, number: 12 },
95+ { t: "text", v: " and " },
96+ { t: "ref", repo: "g1t", number: 3 },
97+ ]);
98+ assert.deepEqual(inline("see #general, `a <b>` **bold**"), [
99+ { t: "text", v: "see " },
100+ { t: "channel", name: "general" },
101+ { t: "text", v: ", " },
102+ { t: "code", v: "a <b>" },
103+ { t: "text", v: " " },
104+ { t: "strong", c: [{ t: "text", v: "bold" }] },
105+ ]);
106+ assert.ok(inline("[x](javascript:alert(1))").every((span) => span.t === "text"));
107+ assert.deepEqual(inline("https://g1t.sh/a."), [{ t: "link", href: "https://g1t.sh/a", c: [{ t: "text", v: "https://g1t.sh/a" }] }, { t: "text", v: "." }]);
108+ assert.deepEqual(inline("me@example.com"), [{ t: "text", v: "me@example.com" }]);
109+ assert.equal(safeHref("//evil.example"), null);
110+ assert.equal(safeHref("/acme/web/pull/3"), "/acme/web/pull/3");
111+});
112+
113+test("message blocks: fenced code, lists, quotes and paragraphs", () => {
114+ const parsed = blocks("Plan:\n- one\n- two\n\n```ts\nconst a = 1;\n```\n> quoted\nlast");
115+ assert.deepEqual(parsed.map((block) => block.t), ["p", "list", "code", "quote", "p"]);
116+ assert.deepEqual(parsed[2], { t: "code", lang: "ts", v: "const a = 1;" });
117+});
118+
119+function entry(id: string, over: Partial<ChatSidebarEntry> & { kind?: "channel" | "dm"; last?: string } = {}): ChatSidebarEntry {
120+ return {
121+ channel: {
122+ id,
123+ workspace_id: "w",
124+ kind: over.kind ?? "channel",
125+ name: over.kind === "dm" ? null : id,
126+ topic: null,
127+ private: false,
128+ created_by: { kind: "user", id: "u1" },
129+ created_at: "2026-10-01T00:00:00Z",
130+ archived_at: null,
131+ last_message_at: over.last ?? null,
132+ },
133+ title: over.title ?? id,
134+ others: over.others ?? [],
135+ starred: over.starred ?? false,
136+ muted: over.muted ?? false,
137+ unread: over.unread ?? 0,
138+ mentions: over.mentions ?? 0,
139+ };
140+}
141+
142+test("the sidebar: sections, filters and the rail's count", () => {
143+ const all = [
144+ entry("random"),
145+ entry("general", { starred: true, unread: 2 }),
146+ entry("d1", { kind: "dm", title: "reviewer", others: [bot], last: "2026-10-02T00:00:00Z", mentions: 1, unread: 1 }),
147+ entry("d2", { kind: "dm", title: "ana", others: [ana], last: "2026-10-05T00:00:00Z" }),
148+ entry("noisy", { muted: true, unread: 9 }),
149+ ];
150+ const { starred, channels, dms } = sections(all);
151+ assert.deepEqual(starred.map((e) => e.title), ["general"]);
152+ assert.deepEqual(channels.map((e) => e.title), ["noisy", "random"]);
153+ assert.deepEqual(dms.map((e) => e.title), ["ana", "reviewer"]);
154+ assert.deepEqual(filterEntries(all, "unread", "").map((e) => e.title), ["general", "reviewer", "noisy"]);
155+ assert.deepEqual(filterEntries(all, "mentions", "").map((e) => e.title), ["reviewer"]);
156+ assert.deepEqual(filterEntries(all, "all", "@Review").map((e) => e.title), ["reviewer"]);
157+ assert.deepEqual(unreadTotals(all), { unread: 3, mentions: 1 });
158+});
159+
160+test("addresses, names, money and reconnecting", () => {
161+ assert.equal(channelPath("acme", { id: "c1", kind: "channel", name: "general" }), "/acme/-/chat/general");
162+ assert.equal(channelPath("acme", { id: "d1", kind: "dm", name: null }), "/acme/-/chat/dm/d1");
163+ assert.equal(channelName("#Release Train_2!"), "release-train-2");
164+ assert.equal(backoff(0, () => 0.5), 1000);
165+ assert.equal(backoff(10, () => 0.5), 30_000);
166+});
+354−0
1+/**
2+ * Chat as the site draws it: which messages sit together under one name,
3+ * where a day starts, what an `@…` being typed could become, and a
4+ * message's text as safe blocks and spans. Pure, so it is tested on its
5+ * own (chat.test.ts); the components are in components/chat.
6+ *
7+ * Message bodies are never HTML: they are parsed here into a small tree
8+ * that React renders as text, so nothing anyone writes can run.
9+ */
10+import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts";
11+
12+/** Two messages from the same author this close together share one header. */
13+export const GROUP_GAP_MS = 5 * 60 * 1000;
14+
15+/** A message on its way: shown at once, then swapped for what the service saved. */
16+export type ShownMessage = ChatMessage & { pending?: boolean; failed?: boolean; client_id?: string };
17+
18+export type TimelineRow =
19+ | { kind: "day"; key: string; label: string }
20+ | { kind: "message"; key: string; message: ShownMessage; head: boolean };
21+
22+function sameAuthor(a: ChatMessage["author"], b: ChatMessage["author"]): boolean {
23+ return a.kind === b.kind && a.id === b.id;
24+}
25+
26+/** The calendar day of an instant, in the reader's time zone (or `zone`). */
27+export function dayKey(at: string | Date, zone?: string): string {
28+ const date = typeof at === "string" ? new Date(at) : at;
29+ return new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
30+}
31+
32+/** "Today", "Yesterday", or the date, for the rule between days. */
33+export function dayLabel(at: string, now: Date = new Date(), zone?: string): string {
34+ const key = dayKey(at, zone);
35+ if (key === dayKey(now, zone)) return "Today";
36+ const yesterday = new Date(now.getTime() - 86_400_000);
37+ if (key === dayKey(yesterday, zone)) return "Yesterday";
38+ const date = new Date(at);
39+ const sameYear = dayKey(date, zone).slice(0, 4) === dayKey(now, zone).slice(0, 4);
40+ return new Intl.DateTimeFormat("en-US", {
41+ timeZone: zone,
42+ weekday: "long",
43+ month: "long",
44+ day: "numeric",
45+ year: sameYear ? undefined : "numeric",
46+ }).format(date);
47+}
48+
49+/**
50+ * The channel's messages, oldest first, as rows: a rule where each day
51+ * starts, and each message marked with whether it starts a group (and so
52+ * shows its author's name and avatar). A card always starts one.
53+ */
54+export function timeline(messages: ShownMessage[], now: Date = new Date(), zone?: string): TimelineRow[] {
55+ const rows: TimelineRow[] = [];
56+ let previous: ShownMessage | null = null;
57+ let day: string | null = null;
58+ for (const message of messages) {
59+ if (message.deleted_at) continue;
60+ const today = dayKey(message.created_at, zone);
61+ if (today !== day) {
62+ rows.push({ kind: "day", key: `day:${today}`, label: dayLabel(message.created_at, now, zone) });
63+ day = today;
64+ previous = null;
65+ }
66+ const head =
67+ previous == null ||
68+ message.kind === "card" ||
69+ previous.kind === "card" ||
70+ !sameAuthor(previous.author, message.author) ||
71+ new Date(message.created_at).getTime() - new Date(previous.created_at).getTime() > GROUP_GAP_MS;
72+ rows.push({ kind: "message", key: message.client_id ?? message.id, message, head });
73+ previous = message;
74+ }
75+ return rows;
76+}
77+
78+/**
79+ * Messages merged by id, oldest first: what the page had, what arrived
80+ * live and what a reload brought. A newer copy (an edit) wins; a pending
81+ * message is dropped once the saved one with its `client_id` is there.
82+ */
83+export function mergeMessages(current: ShownMessage[], incoming: ShownMessage[]): ShownMessage[] {
84+ const byId = new Map<string, ShownMessage>();
85+ for (const message of [...current, ...incoming]) {
86+ const key = message.id;
87+ const had = byId.get(key);
88+ if (!had || !message.pending || had.pending) byId.set(key, message);
89+ }
90+ const saved = new Set([...byId.values()].filter((m) => !m.pending).map((m) => m.client_id).filter(Boolean));
91+ return [...byId.values()]
92+ .filter((message) => !(message.pending && message.client_id && saved.has(message.client_id)))
93+ .sort((a, b) => (a.pending === b.pending ? (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) : a.pending ? 1 : -1));
94+}
95+
96+/** Someone who can be mentioned: a person by username, an agent by handle. */
97+export type Mentionable = {
98+ kind: "user" | "agent";
99+ /** What follows the `@`. */
100+ name: string;
101+ display_name: string;
102+ avatar: string | null;
103+ /** An agent's role, shown beside it. */
104+ role?: string | null;
105+};
106+
107+export type MentionQuery = { start: number; typed: string; options: Mentionable[] };
108+
109+/** Most suggestions at once. */
110+export const MAX_MENTIONS = 6;
111+
112+/**
113+ * The `@…` just before the caret and who it could be: names that start with
114+ * what is typed first, then display names with a word that does. Null when
115+ * the caret is not in a mention, or nothing fits.
116+ */
117+export function mentionQuery(text: string, caret: number, people: readonly Mentionable[]): MentionQuery | null {
118+ const match = /(?:^|[\s(])@([\w.-]{0,40})$/.exec(text.slice(0, caret));
119+ if (!match) return null;
120+ const typed = match[1]!.toLowerCase();
121+ const start = caret - match[1]!.length - 1;
122+ const first: Mentionable[] = [];
123+ const then: Mentionable[] = [];
124+ for (const person of people) {
125+ const name = person.name.toLowerCase();
126+ if (name === typed) continue;
127+ if (name.startsWith(typed)) first.push(person);
128+ else if (typed && person.display_name.toLowerCase().split(/\s+/).some((word) => word.startsWith(typed))) then.push(person);
129+ }
130+ const options = [...first, ...then].slice(0, MAX_MENTIONS);
131+ return options.length > 0 ? { start, typed, options } : null;
132+}
133+
134+// ---------------------------------------------------------------------------
135+// A message's text: a little Markdown, parsed into blocks and spans.
136+
137+export type Span =
138+ | { t: "text"; v: string }
139+ | { t: "code"; v: string }
140+ | { t: "strong"; c: Span[] }
141+ | { t: "em"; c: Span[] }
142+ | { t: "del"; c: Span[] }
143+ | { t: "link"; href: string; c: Span[] }
144+ | { t: "mention"; name: string }
145+ | { t: "channel"; name: string }
146+ | { t: "ref"; repo: string | null; number: number };
147+
148+export type Block =
149+ | { t: "p"; lines: Span[][] }
150+ | { t: "code"; lang: string | null; v: string }
151+ | { t: "list"; ordered: boolean; items: Span[][] }
152+ | { t: "quote"; lines: Span[][] };
153+
154+/** Where a link may go: the web, mail, or a page on this site. */
155+export function safeHref(href: string): string | null {
156+ const trimmed = href.trim();
157+ if (/^https?:\/\/[^\s]+$/i.test(trimmed)) return trimmed;
158+ if (/^mailto:[^\s]+$/i.test(trimmed)) return trimmed;
159+ if (/^\/(?!\/)[^\s]*$/.test(trimmed)) return trimmed;
160+ return null;
161+}
162+
163+const INLINE = new RegExp(
164+ [
165+ "`([^`\\n]+)`", // 1 code
166+ "\\*\\*([^*\\n](?:[^\\n]*?[^*\\n])?)\\*\\*", // 2 strong
167+ "~~([^~\\n]+)~~", // 3 del
168+ "(?<![\\w*])\\*([^*\\s](?:[^*\\n]*[^*\\s])?)\\*(?![\\w*])", // 4 em with *
169+ "(?<![\\w])_([^_\\s](?:[^_\\n]*[^_\\s])?)_(?![\\w])", // 5 em with _
170+ "\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)", // 6 text, 7 href
171+ "(https?:\\/\\/[^\\s<>()]+[^\\s<>().,;:!?'\"])", // 8 bare link
172+ "(?<![\\w/@])@([A-Za-z0-9][\\w.-]*[A-Za-z0-9_]|[A-Za-z0-9])(?:\\/([A-Za-z0-9][\\w.-]*))?", // 9 mention, 10 team
173+ "(?<![\\w/])([A-Za-z0-9][\\w.-]*(?:\\/[A-Za-z0-9][\\w.-]*)?)#(\\d+)\\b", // 11 repo, 12 number
174+ "(?<![\\w&#/])#(\\d+)\\b", // 13 number alone
175+ "(?<![\\w&#/])#([a-z0-9][a-z0-9_-]*)", // 14 channel
176+ ].join("|"),
177+ "g",
178+);
179+
180+/** One line of text as spans. */
181+export function inline(text: string): Span[] {
182+ const spans: Span[] = [];
183+ let at = 0;
184+ const push = (span: Span) => {
185+ const last = spans[spans.length - 1];
186+ if (span.t === "text" && last?.t === "text") last.v += span.v;
187+ else spans.push(span);
188+ };
189+ // Its own copy: the spans inside bold and the like are parsed on the way.
190+ const pattern = new RegExp(INLINE.source, "g");
191+ for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
192+ if (m.index > at) push({ t: "text", v: text.slice(at, m.index) });
193+ at = m.index + m[0].length;
194+ if (m[1] != null) push({ t: "code", v: m[1] });
195+ else if (m[2] != null) push({ t: "strong", c: inline(m[2]) });
196+ else if (m[3] != null) push({ t: "del", c: inline(m[3]) });
197+ else if (m[4] != null) push({ t: "em", c: inline(m[4]) });
198+ else if (m[5] != null) push({ t: "em", c: inline(m[5]) });
199+ else if (m[6] != null) {
200+ const href = safeHref(m[7]!);
201+ if (href) push({ t: "link", href, c: inline(m[6]) });
202+ else push({ t: "text", v: m[0] });
203+ } else if (m[8] != null) push({ t: "link", href: m[8], c: [{ t: "text", v: m[8] }] });
204+ else if (m[9] != null) push({ t: "mention", name: m[10] ? `${m[9]}/${m[10]}` : m[9] });
205+ else if (m[11] != null) push({ t: "ref", repo: m[11], number: Number(m[12]) });
206+ else if (m[13] != null) push({ t: "ref", repo: null, number: Number(m[13]) });
207+ else if (m[14] != null) push({ t: "channel", name: m[14] });
208+ }
209+ if (at < text.length) push({ t: "text", v: text.slice(at) });
210+ return spans;
211+}
212+
213+/** A message's whole text as blocks: paragraphs, fenced code, lists and quotes. */
214+export function blocks(text: string): Block[] {
215+ const out: Block[] = [];
216+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
217+ let i = 0;
218+ let paragraph: Span[][] = [];
219+ const flush = () => {
220+ // Blank lines at either end of a paragraph say nothing.
221+ while (paragraph.length && paragraph[0]!.length === 0) paragraph.shift();
222+ while (paragraph.length && paragraph[paragraph.length - 1]!.length === 0) paragraph.pop();
223+ if (paragraph.length) out.push({ t: "p", lines: paragraph });
224+ paragraph = [];
225+ };
226+ while (i < lines.length) {
227+ const line = lines[i]!;
228+ const fence = /^\s*```\s*([\w+#.-]*)\s*$/.exec(line);
229+ if (fence) {
230+ flush();
231+ const body: string[] = [];
232+ i++;
233+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i]!)) body.push(lines[i++]!);
234+ i++; // the closing fence, or the end
235+ out.push({ t: "code", lang: fence[1] || null, v: body.join("\n") });
236+ continue;
237+ }
238+ const bullet = /^\s*[-*•]\s+(.*)$/.exec(line);
239+ const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
240+ if (bullet || numbered) {
241+ flush();
242+ const ordered = !bullet;
243+ const items: Span[][] = [];
244+ while (i < lines.length) {
245+ const item = ordered ? /^\s*\d+[.)]\s+(.*)$/.exec(lines[i]!) : /^\s*[-*•]\s+(.*)$/.exec(lines[i]!);
246+ if (!item) break;
247+ items.push(inline(item[1]!));
248+ i++;
249+ }
250+ out.push({ t: "list", ordered, items });
251+ continue;
252+ }
253+ if (/^\s*>\s?/.test(line)) {
254+ flush();
255+ const quoted: Span[][] = [];
256+ while (i < lines.length && /^\s*>\s?/.test(lines[i]!)) quoted.push(inline(lines[i++]!.replace(/^\s*>\s?/, "")));
257+ out.push({ t: "quote", lines: quoted });
258+ continue;
259+ }
260+ if (line.trim() === "") {
261+ // A blank line ends a paragraph.
262+ flush();
263+ i++;
264+ continue;
265+ }
266+ paragraph.push(inline(line));
267+ i++;
268+ }
269+ flush();
270+ return out;
271+}
272+
273+/** Whether a message is only emoji, a few of them: shown larger, as people expect. */
274+export function onlyEmoji(text: string): boolean {
275+ const trimmed = text.trim();
276+ if (!trimmed || trimmed.length > 24) return false;
277+ return /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|‍|️|\s)+$/u.test(trimmed) && /\p{Extended_Pictographic}/u.test(trimmed);
278+}
279+
280+// ---------------------------------------------------------------------------
281+// The sidebar.
282+
283+export type ChatFilter = "all" | "unread" | "mentions";
284+
285+/** The sidebar's rows that pass the filter and the "Jump to" text. */
286+export function filterEntries(entries: ChatSidebarEntry[], filter: ChatFilter, query: string): ChatSidebarEntry[] {
287+ const q = query.trim().toLowerCase().replace(/^[#@]/, "");
288+ return entries.filter((entry) => {
289+ if (filter === "unread" && entry.unread === 0 && entry.mentions === 0) return false;
290+ if (filter === "mentions" && entry.mentions === 0) return false;
291+ if (!q) return true;
292+ if (entry.title.toLowerCase().includes(q)) return true;
293+ return entry.others.some((member) => member.name.toLowerCase().includes(q) || member.display_name.toLowerCase().includes(q));
294+ });
295+}
296+
297+/** The sidebar's sections, in the order they show. */
298+export function sections(entries: ChatSidebarEntry[]): { starred: ChatSidebarEntry[]; channels: ChatSidebarEntry[]; dms: ChatSidebarEntry[] } {
299+ const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title);
300+ // Direct messages: the latest conversation first, as people scan them.
301+ const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) =>
302+ (b.channel.last_message_at ?? b.channel.created_at).localeCompare(a.channel.last_message_at ?? a.channel.created_at);
303+ return {
304+ starred: entries.filter((e) => e.starred).sort(byName),
305+ channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName),
306+ dms: entries.filter((e) => !e.starred && e.channel.kind === "dm").sort(byRecent),
307+ };
308+}
309+
310+/** Where a conversation is, under the workspace. */
311+export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string {
312+ return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`;
313+}
314+
315+/** Unread across the sidebar, leaving out what is muted: the rail's badge. */
316+export function unreadTotals(entries: ChatSidebarEntry[]): { unread: number; mentions: number } {
317+ let unread = 0;
318+ let mentions = 0;
319+ for (const entry of entries) {
320+ mentions += entry.mentions;
321+ if (!entry.muted) unread += entry.unread;
322+ }
323+ return { unread, mentions };
324+}
325+
326+/** Channel names: lowercase words and digits joined by dashes, as people type them. */
327+export function channelName(typed: string): string {
328+ return typed
329+ .toLowerCase()
330+ .replace(/^#/, "")
331+ .replace(/[\s_]+/g, "-")
332+ .replace(/[^a-z0-9-]/g, "")
333+ .replace(/-{2,}/g, "-")
334+ .slice(0, 80);
335+}
336+
337+/** Paths under `-/chat/` that are pages, not channels: never a channel's name. */
338+export const RESERVED_CHANNEL_NAMES = ["dm", "browse", "live", "api", "new"] as const;
339+
340+/** A principal as a form field carries it: `user:<username>` or `agent:<id>`. */
341+export function principalField(kind: Principal["kind"], key: string): string {
342+ return `${kind}:${key}`;
343+}
344+
345+/** The name shown for a member: their display name, else their handle. */
346+export function shownName(member: Pick<MemberProfile, "display_name" | "name">): string {
347+ return member.display_name?.trim() || member.name;
348+}
349+
350+/** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */
351+export function backoff(attempt: number, random: () => number = Math.random): number {
352+ const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt));
353+ return Math.round(base * (0.75 + random() * 0.5));
354+}
+1−1
2323
2424 /** What g1t is, for pages with nothing more particular to say. */
2525 export const DESCRIPTION =
26− "The open-source git platform where people and agents ship software together, from the first issue to production on the edge. Priced at cost plus 20%, never per seat.";
26+ "One workspace where a team and its agents talk, work and ship: chat with people and agents, agents with a job and a budget, and code that lands through checks. Open source, never per seat.";
2727
2828 /** As much of `MetaArgs` as the tags need. */
2929 export type PageArgs = {
+3−0
128128 export function finishResponse(request: Request, response: Response): Response {
129129 const perf = scope.getStore();
130130 if (!perf) return response;
131+ // A WebSocket handed over (chat's live socket) goes back as it is: a
132+ // copy would lose the socket.
133+ if (response.status === 101 || (response as Response & { webSocket?: unknown }).webSocket) return response;
131134 // A redirect's headers cannot be changed; a copy's can.
132135 const answered = new Response(response.body, response);
133136 const sessions = [...perf.sessions].map(([service, how]) => `${service}=${how}`).join(" ");
+8−0
55 actionsClient,
66 agentsClient,
77 billingClient,
8+ chatClient,
89 contextClient,
910 deploymentsClient,
1011 memoryReviewClient,
2122 securitySuiteClient,
2223 webhooksClient,
2324 workClient,
25+ workspaceAgentsClient,
2426 } from "@g1t/contracts";
2527
2628 import { instrumented } from "./perf.server";
4345 const CONTEXT = instrumented("context", env.CONTEXT);
4446 const SEARCH = instrumented("search", env.SEARCH);
4547 const PACKAGES = instrumented("packages", env.PACKAGES);
48+const CHAT = instrumented("chat", env.CHAT);
49+const AGENTS = instrumented("agents", env.AGENTS);
4650
4751 export const identity = identityClient(IDENTITY);
4852 /** A person's email addresses and account security: methods of identity. */
7377 export const packages = packagesClient(PACKAGES);
7478 /** Memory candidates and their review: methods of the work service. */
7579 export const memoryReview = memoryReviewClient(WORK);
80+/** Channels, direct messages and threads, where people and agents talk. */
81+export const chat = chatClient(CHAT);
82+/** The workspace's own agents: who they are, their limits and their desks. */
83+export const workspaceAgents = workspaceAgentsClient(AGENTS);
+14−1
66 redirect,
77 } from "react-router";
88
9−import { type Result, type Role, type User, type Viewer, httpStatus } from "@g1t/contracts";
9+import { type Result, type Role, type User, type Viewer, hasCodeAccess, httpStatus } from "@g1t/contracts";
1010
1111 import { confirmGate } from "./confirm-gate";
12+import { readCookie } from "./mission";
1213 import { safeNext } from "./next";
14+import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice";
15+import { codeGate } from "./workspace-nav";
1316 import { identity } from "./services.server";
1417
1518 const SESSION_COOKIE = "g1t_session";
5457 // from wherever it was going (lib/confirm-gate.ts).
5558 const gated = confirmGate(pathname, search, viewer);
5659 if (gated) throw redirect(gated);
60+ // A member without Code access in a workspace (docs/WORKSPACE.md,
61+ // "Members without Code"): their Home in place of Mission control, and
62+ // the page that says to ask an owner in place of anything of Code's.
63+ // The services enforce it too; this keeps the site from offering it.
64+ const noCode = (viewer?.workspaces ?? []).filter((m) => !hasCodeAccess(m)).map((m) => m.slug.toLowerCase());
65+ if (request.method === "GET" && noCode.length > 0) {
66+ const chosen = chosenWorkspace(viewer?.workspaces ?? [], readCookie(request.headers.get("cookie"), WORKSPACE_COOKIE));
67+ const around = codeGate(pathname, search, noCode, chosen?.slug ?? null);
68+ if (around) throw redirect(around);
69+ }
5770 if (
5871 request.method === "GET" &&
5972 viewer?.verified &&
+32−0
8888 "/flagon-io/g1t/pulls?state=closed",
8989 );
9090 });
91+
92+test("the rail's mode follows the address", async () => {
93+ const { modeOf, modeHome } = await import("./workspace-nav.ts");
94+ assert.equal(modeOf("/", "acme"), "home");
95+ assert.equal(modeOf("/inbox", "acme"), "inbox");
96+ assert.equal(modeOf("/acme/-/chat/general", "acme"), "chat");
97+ assert.equal(modeOf("/acme/-/chat/dm/c1.data", "acme"), "chat");
98+ assert.equal(modeOf("/acme/-/agents", "acme"), "agents");
99+ assert.equal(modeOf("/acme/-/agents/ship/profile", "acme"), "agents");
100+ assert.equal(modeOf("/acme/-/docs", "acme"), "docs");
101+ assert.equal(modeOf("/acme/-/home", "acme"), "home");
102+ assert.equal(modeOf("/acme", "acme"), "code");
103+ assert.equal(modeOf("/acme/web/pulls", "acme"), "code");
104+ // Another workspace's chat is not this one's mode.
105+ assert.equal(modeOf("/other/-/chat", "acme"), "code");
106+ assert.equal(modeHome("home", "acme", false), "/acme/-/home");
107+ assert.equal(modeHome("chat", "acme"), "/acme/-/chat");
108+});
109+
110+test("a member without Code access is sent around Code's pages", async () => {
111+ const { codeGate } = await import("./workspace-nav.ts");
112+ const none = ["acme"];
113+ assert.equal(codeGate("/", "", none, "acme"), "/acme/-/home");
114+ assert.equal(codeGate("/", "", none, "other"), null);
115+ assert.equal(codeGate("/acme", "", none, "acme"), "/acme/-/home");
116+ assert.equal(codeGate("/acme/web/pull/3", "?x=1", none, "acme"), "/acme/-/code-access?from=%2Facme%2Fweb%2Fpull%2F3%3Fx%3D1");
117+ assert.equal(codeGate("/acme/-/projects", "", none, "acme"), "/acme/-/code-access?from=%2Facme%2F-%2Fprojects");
118+ assert.equal(codeGate("/acme/-/chat/general", "", none, "acme"), null);
119+ assert.equal(codeGate("/acme/-/code-access", "", none, "acme"), null);
120+ assert.equal(codeGate("/other/web", "", none, "acme"), null);
121+ assert.equal(codeGate("/acme/web", "", [], "acme"), null);
122+});
+73−0
166166 if (to === undefined) return null;
167167 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
168168 }
169+
170+/**
171+ * The rail's modes (docs/WORKSPACE.md, "Shell"): Home and the Inbox span
172+ * the workspace; Code, Chat, Docs and Agents each have a sidebar of their
173+ * own. Which one is lit follows the address.
174+ */
175+export type ModeKey = "home" | "code" | "chat" | "docs" | "agents" | "inbox";
176+
177+/** The mode a path is in, for the workspace `slug`. Anything not another mode's is Code's. */
178+export function modeOf(pathname: string, slug: string | null): ModeKey {
179+ const path = pagePath(pathname);
180+ if (path === "/") return "home";
181+ if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox";
182+ const parts = path.split("/").filter(Boolean);
183+ if (slug && parts[0]?.toLowerCase() === slug.toLowerCase() && parts[1] === "-") {
184+ if (parts[2] === "home") return "home";
185+ if (parts[2] === "chat") return "chat";
186+ if (parts[2] === "docs") return "docs";
187+ if (parts[2] === "agents") return "agents";
188+ }
189+ return "code";
190+}
191+
192+/** Where each mode's rail button goes, in the workspace `slug`. */
193+export function modeHome(mode: ModeKey, slug: string, code = true): string {
194+ switch (mode) {
195+ case "home":
196+ return code ? "/" : `/${slug}/-/home`;
197+ case "inbox":
198+ return "/inbox";
199+ case "chat":
200+ return `/${slug}/-/chat`;
201+ case "docs":
202+ return `/${slug}/-/docs`;
203+ case "agents":
204+ return `/${slug}/-/agents`;
205+ case "code":
206+ return `/${slug}`;
207+ }
208+}
209+
210+/** The page a member without Code access sees in place of anything of Code's. */
211+export function codeAccessPath(slug: string, from?: string): string {
212+ return `/${slug}/-/code-access${from ? `?from=${encodeURIComponent(from)}` : ""}`;
213+}
214+
215+/** The workspace pages under `-/` that are Code's: closed to a member without Code access. */
216+const CODE_PAGES = new Set(["projects", "repositories", "packages", "security", "rules", "runners", "actions", "context", "memory", "insights", "soon"]);
217+
218+/**
219+ * Where a member without Code access goes instead of `pathname`, or null
220+ * when the page is open to them. `noCode` is the workspaces (by slug) where
221+ * they lack it; `chosen` is the one they are in. Mission control (`/`) is
222+ * their Home in that workspace; its overview, its projects and every
223+ * repository page is the page that says to ask an owner.
224+ */
225+export function codeGate(pathname: string, search: string, noCode: readonly string[], chosen: string | null): string | null {
226+ if (noCode.length === 0) return null;
227+ const path = pagePath(pathname);
228+ if (path === "/") {
229+ const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null;
230+ return slug ? `/${slug}/-/home` : null;
231+ }
232+ const parts = path.split("/").filter(Boolean);
233+ const slug = parts[0]?.toLowerCase();
234+ if (!slug || !noCode.includes(slug)) return null;
235+ const from = `${path}${search && search !== "?" ? search : ""}`;
236+ // The workspace's own page is Code's overview: their Home instead.
237+ if (parts.length === 1) return `/${slug}/-/home`;
238+ if (parts[1] === "-") return CODE_PAGES.has(parts[2] ?? "") ? codeAccessPath(slug, from) : null;
239+ // A repository, and everything in it.
240+ return codeAccessPath(slug, from);
241+}
+19−2
5757 import { NotFound } from "./components/not-found";
5858 import { usesAppShell } from "./lib/chrome";
5959 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./components/command-palette";
60−import { billing, inbox, projects } from "./lib/services.server";
60+import { billing, chat, inbox, projects } from "./lib/services.server";
61+import { unreadTotals } from "./lib/chat";
6162 import { countsFor, readableRepos } from "./lib/access.server";
6263 import { shortCache } from "./lib/cache.server";
6364 import { getViewer, viewerMiddleware } from "./lib/session.server";
166167 // something (lib/cache.server.ts).
167168 const kept = <T,>(what: string, load: () => Promise<T>) =>
168169 workspace ? shortCache(`shell:${what}:${user.id}:${workspace.slug}`, SHELL_TTL_MS, load) : Promise.resolve(null);
169− const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts] = await Promise.all([
170+ const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts, chatUnread] = await Promise.all([
170171 // Every project, for the palette and the count; the sidebar lists only
171172 // the person's pinned and recent ones (lib/pins.ts).
172173 workspace ? workspaceProjects(workspace.slug, user) : Promise.resolve(null),
182183 inbox.counts(user.username).catch(() => null),
183184 // Never kept: opening a project moves it up Recent.
184185 workspace ? projects.shortcuts(workspace.slug, user).catch(() => null) : Promise.resolve(null),
186+ // The rail's Chat badge: never kept, and never waited on for long.
187+ workspace ? chatUnreadFor(workspace.slug, user) : Promise.resolve(null),
185188 ]);
186189 return {
187190 workspace,
217220 : null,
218221 shared,
219222 inbox: unread,
223+ chat: chatUnread,
220224 // While g1t is free every charge is zero, so usage is shown at cost.
221225 // Usage at price, the one figure every page shows.
222226 monthUsageMicros: usage?.ok ? (usage.value.free ? usage.value.usedMicros : (usage.value.priceMicros ?? usage.value.spentMicros)) : null,
223227 };
224228 }
225229
230+/** What is unread in chat, or null when chat is slow to answer or does not. */
231+async function chatUnreadFor(slug: string, user: User): Promise<ShellData["chat"]> {
232+ const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), CHAT_BADGE_WAIT_MS));
233+ const read = chat
234+ .sidebar(slug, user)
235+ .then((result) => (result.ok ? unreadTotals(result.value.entries) : null))
236+ .catch(() => null);
237+ return Promise.race([read, timeout]);
238+}
239+
240+/** The longest the rail's Chat badge holds up a page. */
241+const CHAT_BADGE_WAIT_MS = 300;
242+
226243 /**
227244 * How long the sidebar's workspace answers are kept: as long as someone's
228245 * own changes read fresh anyway (lib/perf.ts `PRIMARY_WINDOW_SECONDS`).
+27−1
107107 route("-/packages/:ecosystem/*", "routes/workspace/package.tsx"),
108108 route("-/settings", "routes/workspace/settings.tsx"),
109109 route("-/repositories", "routes/workspace/repositories.tsx"),
110− route("-/agents", "routes/workspace/agents.tsx"),
110+ // Agents mode: the fleet's runs first, then each of the workspace's own
111+ // agents (docs/WORKSPACE.md). `new` is no agent's handle.
112+ route("-/agents", "routes/workspace/agents/layout.tsx", [
113+ index("routes/workspace/agents.tsx"),
114+ route("new", "routes/workspace/agents/new.tsx"),
115+ route(":handle", "routes/workspace/agents/agent.tsx", [
116+ index("routes/workspace/agents/desk.tsx"),
117+ route("profile", "routes/workspace/agents/profile.tsx"),
118+ route("spend", "routes/workspace/agents/spend.tsx"),
119+ route("activity", "routes/workspace/agents/activity.tsx"),
120+ ]),
121+ ]),
122+ // Chat mode: channels by name, direct messages by id, and what the page
123+ // calls as it runs: the live socket and the JSON for sending and reading.
124+ route("-/chat/live", "routes/workspace/chat/live.ts"),
125+ route("-/chat/api", "routes/workspace/chat/api.ts"),
126+ route("-/chat", "routes/workspace/chat/layout.tsx", [
127+ index("routes/workspace/chat/index.tsx"),
128+ route("browse", "routes/workspace/chat/browse.tsx"),
129+ route("dm/:id", "routes/workspace/chat/channel.tsx", { id: "routes/workspace/chat/dm" }),
130+ route(":channel", "routes/workspace/chat/channel.tsx"),
131+ ]),
132+ // Docs mode, coming; Home for a member without Code; and what Code's
133+ // pages say to them (docs/WORKSPACE.md, "Members without Code").
134+ route("-/docs", "routes/workspace/docs.tsx"),
135+ route("-/home", "routes/workspace/home.tsx"),
136+ route("-/code-access", "routes/workspace/code-access.tsx"),
111137 route("-/memory", "routes/workspace/memory.tsx"),
112138 route("-/context", "routes/workspace/context.tsx"),
113139 route("-/security", "routes/workspace/security.tsx"),
+2−2
9696 // Someone signed in is on mission control; visitors get the landing page's title.
9797 if (args.loaderData?.signedIn) return page(args, { title: "Mission control · g1t" });
9898 return page(args, {
99− title: "g1t — where people and agents ship software together",
99+ title: "g1t · Your team and its agents, working in one place",
100100 description:
101− "The open-source git platform for people and agents: issues, pull requests and review, agents you assign like teammates, a merge queue that keeps main green, and deployments to the edge. Priced at cost plus 20%, never per seat.",
101+ "Chat with your team and your agents in channels and DMs. Agents are teammates with a role, a personality and a budget, and their code lands through pull requests, checks and a merge queue. Chat is free on every plan, never per seat.",
102102 });
103103 }
104104
+4−4
1919 export function meta(args: Route.MetaArgs) {
2020 return page(args, {
2121 title: "You're invited · g1t",
22− description: "An invite to g1t, where people and agents ship software together.",
22+ description: "An invite to g1t, one workspace where a team and its agents talk, work and ship.",
2323 });
2424 }
2525
173173 function about(invite: InvitePreview, signedIn: boolean): string {
174174 const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
175175 if (invite.workspace) {
176− return `g1t is where people and agents ship software together. ${
176+ return `g1t is one workspace where a team and its agents talk, work and ship. ${
177177 signingUp ? "Make your account below and you join" : "Accepting joins you to"
178178 } ${invite.workspace.name} as a member.`;
179179 }
180180 if (invite.repository) {
181− return `g1t is where people and agents ship software together. ${
181+ return `g1t is one workspace where a team and its agents talk, work and ship. ${
182182 signingUp ? "Make your account below and you get" : "Accepting gives you"
183183 } the ${invite.repository.role} role on ${invite.repository.name}.`;
184184 }
185− return "g1t is where people and agents ship software together: plan in issues, assign work to agents like teammates, and land it through checks that hold. This invite gets you in.";
185+ return "g1t is one workspace where a team and its agents talk, work and ship: chat with people and agents, give agents a job and a budget, and land code through checks that hold. This invite gets you in.";
186186 }
187187
188188 /** What accepting is called on its button. */
+130−4
1212 return page(args, {
1313 title: "Pricing · g1t",
1414 description:
15− "The forge is free. One plan, $20 a month per workspace with $10 of usage included; usage at what it costs g1t plus 20%. No seats, ever.",
15+ "People chat free on every plan, and the forge is free. One plan, $20 a month per workspace with $10 of usage included. Agents pay the model's price plus a flat agent rate, from each agent's budget. No seats, ever.",
1616 });
1717 }
1818
9595 overage: "Everything is metered from the first unit at what it costs g1t plus 20%. Unused included usage does not roll over.",
9696 };
9797
98+/** How each part of a workspace is charged: the plan's Pricing table (docs/WORKSPACE.md). */
99+const WORKSPACE_CHARGES: { what: string; how: string; soon?: boolean }[] = [
100+ {
101+ what: "People chatting",
102+ how: "Included on every plan, the free one too: channels, direct messages, threads, mentions and live delivery. No seats, and history is never cut off.",
103+ },
104+ { what: "Docs", how: "Included on every plan, like chat: pages, live editing and history.", soon: true },
105+ {
106+ what: "An agent replying in chat",
107+ how: "The model provider's price, with no markup, plus the g1t agent rate on the tokens it used. Paid from the plan's included usage, then AI credit, and counted against that agent's budget.",
108+ },
109+ {
110+ what: "An agent working on code",
111+ how: "The same model price and agent rate, plus sandbox time at cost plus 20%.",
112+ },
113+ {
114+ what: "An agent on your own provider",
115+ how: "Your provider bills you for the model directly. g1t charges the agent rate for your own model key only.",
116+ },
117+ {
118+ what: "Files in chat and Docs",
119+ how: "The storage meter, at cost plus 20%, past what is free.",
120+ soon: true,
121+ },
122+ { what: "An idle agent", how: "Nothing. An agent costs money only while it is answering or working." },
123+];
124+
125+const FAQ: { q: string; a: string }[] = [
126+ {
127+ q: "Do people pay to use Chat?",
128+ a: "No. Messages, channels, threads and live delivery are included on every plan, with no per-person price and no limit on history. A workspace on the plan can add as many people as it likes at the same price.",
129+ },
130+ {
131+ q: "Who pays for an agent's reply?",
132+ a: "The workspace the agent belongs to. Each reply is charged as the model's price plus the agent rate, and counts against that agent's monthly budget, whoever asked it.",
133+ },
134+ {
135+ q: "What does an agent cost when nobody talks to it?",
136+ a: "Nothing. Agents have no monthly fee. They are charged only for replies and work, so an agent you made and forgot costs nothing.",
137+ },
138+ {
139+ q: "How do I stop an agent from spending too much?",
140+ a: "Give it a monthly budget and a cap per task. At its budget it stops taking new work and says so in the channel. The workspace's spend limit and the per-run caps still apply above it.",
141+ },
142+ {
143+ q: "Can agents use our own model provider?",
144+ a: "Yes. Connect a provider in Integrations and limit an agent to it. The provider bills you for the model; g1t charges the agent rate for your own model key only.",
145+ },
146+ {
147+ q: "Does adding the whole company cost more?",
148+ a: "No. There are no seats. People who only chat, and ask agents questions, cost nothing until their questions use an agent; then the agent's reply is charged like any other.",
149+ },
150+ {
151+ q: "Is Docs included?",
152+ a: "Docs is coming soon. When it ships, pages, editing and history are included on every plan, like chat. Files are on the storage meter.",
153+ },
154+];
155+
98156 /** The ways to reach g1t about an enterprise account. */
99157 const ENTERPRISE_MAIL = "mailto:hey@flagon.io?subject=Enterprise%20billing%20for%20g1t";
100158
131189 function rows(tier: Required<FreeTier>): Row[] {
132190 return [
133191 {
192+ what: "Chat",
193+ free: "Included, for the people already in the workspace",
194+ plan: "Included, for unlimited members",
195+ note: "Channels, direct messages, threads, mentions and live delivery. History is never cut off.",
196+ },
197+ {
198+ what: "Docs (coming soon)",
199+ free: "Included",
200+ plan: "Included",
201+ note: "Pages, live editing and history, once Docs ships.",
202+ },
203+ {
204+ what: "Workspace agents",
205+ free: "After a card check, from the trial",
206+ plan: "From the plan's included usage, then AI credit, under each agent's budget",
207+ note: "Replies and work are charged as model price plus the g1t agent rate. An idle agent costs nothing.",
208+ },
209+ {
134210 what: "The forge",
135211 free: "Free",
136212 plan: "Included",
210286 const securityPlan = book?.plans?.find((p) => p.feature === "security") ?? null;
211287 const price = plan.monthlyCents / 100;
212288 const dollars = wholeDollars;
289+ const agentRate = book?.prices.find((p) => p.meter === "agent_tokens");
290+ const agentRateComing = book?.changes.find((c) => c.meter === "agent_tokens" && c.effectiveAt);
291+ const agentRateLine =
292+ agentRate && agentRate.priceMicros > 0
293+ ? `${money(agentRate.priceMicros)} per million tokens`
294+ : agentRateComing
295+ ? `$0, and ${money(agentRateComing.newCostMicros)} per million tokens from ${agentRateComing.effectiveAt!.slice(0, 10)}`
296+ : "as listed under Usage below";
213297 return (
214298 <main className="mx-auto max-w-4xl px-4 py-12">
215299 <p className="text-sm font-medium text-accent">Pricing</p>
216− <h1 className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">One plan, and usage at cost plus 20%</h1>
300+ <h1 className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">People chat free. What runs is priced at cost.</h1>
217301 <p className="mt-3 max-w-2xl text-muted">
218− The forge is free for everyone. Work that runs on g1t's machines is metered at what it costs g1t, plus 20%. The
219− numbers on this page are the live price book g1t charges from. Prices exclude tax, which is added where it applies.
302+ People chatting and the forge are free for everyone. Agents and the work that runs on g1t's machines are metered
303+ at what they cost g1t, plus a flat agent rate for models and 20% for everything else. The numbers on this page are
304+ the live price book g1t charges from. Prices exclude tax, which is added where it applies.
220305 </p>
221306 {free && (
222307 <div className="mt-5 rounded-xl border border-accent/30 bg-accent/5 px-4 py-3 text-sm">
298383 </section>
299384 )}
300385
386+ <section id="workspace" className="mt-14">
387+ <h2 className="text-xl font-semibold tracking-tight">Chat, Docs and agents</h2>
388+ <p className="mt-1 max-w-3xl text-sm text-muted">
389+ People talking costs g1t very little, so it is included. Agents use models, so they are charged for what they
390+ use, to the agent that used it.
391+ </p>
392+ <div className="mt-4 overflow-hidden rounded-xl border border-line">
393+ <dl className="divide-y divide-line text-sm">
394+ {WORKSPACE_CHARGES.map((row) => (
395+ <div key={row.what} className="grid gap-1 px-4 py-3 sm:grid-cols-[14rem_1fr] sm:gap-4">
396+ <dt className="font-medium">
397+ {row.what}
398+ {row.soon && (
399+ <span className="ml-2 inline-block rounded bg-raised px-1.5 py-0.5 text-xs font-normal whitespace-nowrap text-muted">Coming soon</span>
400+ )}
401+ </dt>
402+ <dd className="text-muted">{row.how}</dd>
403+ </div>
404+ ))}
405+ </dl>
406+ </div>
407+ <p className="mt-3 text-sm text-muted">
408+ The agent rate today: {agentRateLine}. A chat reply is usually a few thousand tokens on a fast model, so most
409+ replies cost a fraction of a cent. See{" "}
410+ <a href="https://docs.g1t.sh/guides/agents/#what-an-agent-costs" className="text-accent hover:underline">
411+ what an agent costs
412+ </a>
413+ .
414+ </p>
415+ </section>
416+
301417 <h2 className="mt-14 text-xl font-semibold tracking-tight">Free and the plan, side by side</h2>
302418 <p className="mt-1 text-sm text-muted">
303419 Secret scanning, push protection, vulnerability alerts and security updates are free everywhere, on every plan.
665781 <p className="mt-2 text-sm text-muted">None yet. When a price moves, it is listed here with why.</p>
666782 )}
667783
784+ <h2 id="faq" className="mt-14 text-xl font-semibold tracking-tight">Questions</h2>
785+ <dl className="mt-4 divide-y divide-line rounded-xl border border-line">
786+ {FAQ.map((item) => (
787+ <div key={item.q} className="px-4 py-4 text-sm">
788+ <dt className="font-medium">{item.q}</dt>
789+ <dd className="mt-1.5 text-muted">{item.a}</dd>
790+ </div>
791+ ))}
792+ </dl>
793+
668794 <a
669795 href="https://docs.g1t.sh/guides/usage-and-billing/"
670796 className="mt-12 inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
+1−1
298298 return (
299299 <AuthCard
300300 title={showForm ? "Create your account" : "g1t is invite-only for now"}
301− subtitle={showForm ? "Where people and agents ship software together" : "Enter your invite, or ask for one"}
301+ subtitle={showForm ? "Your team and its agents, working in one place" : "Enter your invite, or ask for one"}
302302 footer={
303303 <>
304304 Already have an account?{" "}
+24−0
1+import { History } from "lucide-react";
2+import { Link, useOutletContext, useParams } from "react-router";
3+
4+import type { WorkspaceAgent } from "@g1t/contracts";
5+
6+/** Everything the agent did, from the audit log. */
7+export default function ActivityTab() {
8+ const agent = useOutletContext<WorkspaceAgent>();
9+ const { owner = "" } = useParams();
10+ return (
11+ <div className="rounded-2xl border border-dashed border-line px-6 py-14 text-center">
12+ <span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
13+ <History size={22} />
14+ </span>
15+ <h2 className="mt-4 text-base font-semibold">Nothing yet</h2>
16+ <p className="mx-auto mt-1.5 max-w-md text-sm text-muted">
17+ Every reply, task, pull request and approval {agent.display_name} takes part in shows here, with the version of its profile that did it.
18+ </p>
19+ <Link to={`/${owner}/-/audit`} className="mt-5 inline-block text-sm text-accent hover:underline">
20+ Open the workspace&apos;s audit log
21+ </Link>
22+ </div>
23+ );
24+}
+121−0
1+import { MessageSquare } from "lucide-react";
2+import { Form, Outlet, data, redirect, useNavigation } from "react-router";
3+
4+import type { WorkspaceAgent } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/agent";
7+import { AgentFace } from "../../../components/agents-mode";
8+import { AgentPill, StatusDot, statusLabel } from "../../../components/chat/marks";
9+import { TabLink } from "../../../components/ui";
10+import { channelPath } from "../../../lib/chat";
11+import { page } from "../../../lib/meta";
12+import { chat, workspaceAgents } from "../../../lib/services.server";
13+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
14+
15+export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
16+ const name = loaderData?.agent?.display_name ?? `@${params.handle}`;
17+ return page(args, { title: `${name} · Agents · ${params.owner} · g1t` });
18+}
19+
20+/** One agent: who it is, for its tabs. Null when the agents service does not answer. */
21+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ agent: WorkspaceAgent | null }> {
22+ const viewer = requireUser(context, request);
23+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24+ const found = await workspaceAgents.get(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer).catch(() => null);
25+ if (found && !found.ok && found.error.code === "not_found") throw data(null, { status: 404 });
26+ return { agent: found?.ok ? found.value : null };
27+}
28+
29+/** Message: the direct message with this agent, opened or made. */
30+export async function action({ params, context, request }: Route.ActionArgs) {
31+ assertSameOrigin(request);
32+ const viewer = requireUser(context, request);
33+ const slug = params.owner.toLowerCase();
34+ if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
35+ const form = await request.formData();
36+ if (form.get("intent") !== "message") return { error: "Unknown request." };
37+ const id = String(form.get("agent") ?? "");
38+ const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id }]).catch(() => null);
39+ if (!dm?.ok) return { error: dm ? dm.error.message : "Chat didn't answer. Try again in a moment." };
40+ throw redirect(channelPath(slug, dm.value));
41+}
42+
43+/** The button that opens the direct message with an agent. */
44+export function MessageAgent({ slug, agent, variant = "quiet" }: { slug: string; agent: WorkspaceAgent; variant?: "quiet" | "accent" }) {
45+ const navigation = useNavigation();
46+ const busy = navigation.state !== "idle" && navigation.formData?.get("intent") === "message";
47+ return (
48+ <Form method="post" action={`/${slug}/-/agents/${agent.handle}`}>
49+ <input type="hidden" name="intent" value="message" />
50+ <input type="hidden" name="agent" value={agent.id} />
51+ <button
52+ type="submit"
53+ disabled={busy}
54+ className={`inline-flex h-9 items-center gap-2 rounded-md px-3.5 text-sm font-medium transition-colors disabled:opacity-60 ${
55+ variant === "accent" ? "bg-accent text-bg hover:bg-accent-hover" : "border border-line text-fg/90 hover:border-line-strong hover:bg-surface"
56+ }`}
57+ >
58+ <MessageSquare size={15} />
59+ {busy ? "Opening…" : "Message"}
60+ </button>
61+ </Form>
62+ );
63+}
64+
65+export default function AgentPage({ loaderData, params }: Route.ComponentProps) {
66+ const { agent } = loaderData;
67+ const base = `/${params.owner}/-/agents/${params.handle}`;
68+ if (!agent) {
69+ return (
70+ <div className="rounded-xl border border-dashed border-line px-6 py-14 text-center">
71+ <p className="font-medium">@{params.handle} can't be shown right now</p>
72+ <p className="mt-1.5 text-sm text-muted">The agents service didn't answer. It's usually back within a minute; reload to try again.</p>
73+ </div>
74+ );
75+ }
76+ return (
77+ <div>
78+ <header className="flex flex-wrap items-start gap-4">
79+ <span className="relative">
80+ <AgentFace agent={agent} size={56} />
81+ <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 size-3 ring-[3px] ring-bg" />
82+ </span>
83+ <div className="min-w-0 grow">
84+ <div className="flex flex-wrap items-center gap-2">
85+ <h1 className="truncate text-2xl font-semibold tracking-tight">{agent.display_name}</h1>
86+ <AgentPill />
87+ </div>
88+ <p className="mt-0.5 text-sm text-muted">
89+ <span className="font-mono">@{agent.handle}</span>
90+ <span className="mx-1.5 text-line-strong">·</span>
91+ {agent.role}
92+ </p>
93+ <p className="mt-1.5 flex items-center gap-1.5 text-xs text-faint">
94+ <StatusDot status={agent.status} />
95+ {statusLabel(agent.status)}
96+ <span className="text-line-strong">·</span>
97+ Version {agent.version}
98+ </p>
99+ </div>
100+ <MessageAgent slug={params.owner} agent={agent} variant="accent" />
101+ </header>
102+ <nav aria-label={`${agent.display_name}'s pages`} className="mt-8 flex gap-1 overflow-x-auto border-b border-line [scrollbar-width:none]">
103+ <TabLink to={base} end icon={null}>
104+ Desk
105+ </TabLink>
106+ <TabLink to={`${base}/profile`} icon={null}>
107+ Profile
108+ </TabLink>
109+ <TabLink to={`${base}/spend`} icon={null}>
110+ Spend
111+ </TabLink>
112+ <TabLink to={`${base}/activity`} icon={null}>
113+ Activity
114+ </TabLink>
115+ </nav>
116+ <div className="pt-8">
117+ <Outlet context={agent} />
118+ </div>
119+ </div>
120+ );
121+}
+33−0
1+import { Inbox } from "lucide-react";
2+import { useOutletContext, useParams } from "react-router";
3+
4+import type { WorkspaceAgent } from "@g1t/contracts";
5+
6+import { MessageAgent } from "./agent";
7+
8+/**
9+ * Every task the agent holds, as live cards. Tasks arrive with the desk
10+ * (docs/WORKSPACE.md, "Tasks"); until then, the way to give it one.
11+ */
12+export default function Desk() {
13+ const agent = useOutletContext<WorkspaceAgent>();
14+ const { owner = "" } = useParams();
15+ return (
16+ <div className="rounded-2xl border border-dashed border-line bg-surface/40 px-6 py-16 text-center">
17+ <span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
18+ <Inbox size={22} />
19+ </span>
20+ <h2 className="mt-4 text-base font-semibold">No tasks yet</h2>
21+ <p className="mx-auto mt-1.5 max-w-md text-sm text-muted">
22+ DM {agent.display_name} to give it one. It answers questions in the conversation and opens a task when the work needs one; each task
23+ shows here with its steps, cost and what it&apos;s waiting on.
24+ </p>
25+ <div className="mt-6 flex justify-center">
26+ <MessageAgent slug={owner} agent={agent} variant="accent" />
27+ </div>
28+ <p className="mt-4 text-xs text-faint">
29+ Works up to {agent.capacity} {agent.capacity === 1 ? "task" : "tasks"} at once; more wait here in line.
30+ </p>
31+ </div>
32+ );
33+}
+30−0
1+import { Outlet, data, type ShouldRevalidateFunctionArgs } from "react-router";
2+
3+import type { WorkspaceAgent } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/layout";
6+import { workspaceAgents } from "../../../lib/services.server";
7+import { getViewer, roleIn } from "../../../lib/session.server";
8+
9+/** Agents mode: the workspace's agents, for its sidebar; null when the agents service does not answer. */
10+export type AgentsLayoutData = { slug: string; agents: WorkspaceAgent[] | null };
11+
12+export async function loader({ params, context }: Route.LoaderArgs): Promise<AgentsLayoutData> {
13+ const viewer = getViewer(context);
14+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
15+ const listed = await workspaceAgents.list(params.owner.toLowerCase(), viewer!).catch(() => null);
16+ return {
17+ slug: params.owner.toLowerCase(),
18+ agents: listed?.ok ? listed.value.filter((agent) => !agent.archived_at).sort((a, b) => a.display_name.localeCompare(b.display_name)) : null,
19+ };
20+}
21+
22+/** The list changes when an agent is made or changed, not when moving between them. */
23+export function shouldRevalidate({ currentParams, nextParams, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
24+ if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
25+ return currentParams.owner !== nextParams.owner;
26+}
27+
28+export default function AgentsLayout() {
29+ return <Outlet />;
30+}
+98−0
1+import { ArrowLeft } from "lucide-react";
2+import { Link, data, redirect, useSearchParams } from "react-router";
3+
4+import type { AgentTemplate } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/new";
7+import { AgentForm, TemplateGallery } from "../../../components/agents-mode";
8+import { type AgentDraft, BLANK_DRAFT, readAgentForm } from "../../../lib/agent-form";
9+import { channelPath } from "../../../lib/chat";
10+import { page } from "../../../lib/meta";
11+import { chat, workspaceAgents } from "../../../lib/services.server";
12+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
13+
14+export function meta({ params, ...args }: Route.MetaArgs) {
15+ return page(args, { title: `New agent · ${params.owner} · g1t` });
16+}
17+
18+export async function loader({ params, context, request }: Route.LoaderArgs) {
19+ const viewer = requireUser(context, request);
20+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21+ const templates = await workspaceAgents.templates().catch(() => null);
22+ return { templates };
23+}
24+
25+/**
26+ * Makes the agent, then opens a direct message with it: talking to it is
27+ * how work starts. If chat does not answer, its page instead.
28+ */
29+export async function action({ params, context, request }: Route.ActionArgs) {
30+ assertSameOrigin(request);
31+ const viewer = requireUser(context, request);
32+ const slug = params.owner.toLowerCase();
33+ if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
34+ const read = readAgentForm(await request.formData());
35+ if (!read.ok) return { errors: read.errors };
36+ const made = await workspaceAgents.create(slug, viewer, read.input).catch(() => null);
37+ if (!made) return { errors: { form: "The agents service didn't answer. Try again in a moment." } };
38+ if (!made.ok) {
39+ const field = made.error.code === "conflict" ? "handle" : "form";
40+ return { errors: { [field]: made.error.message } };
41+ }
42+ const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id: made.value.id }]).catch(() => null);
43+ throw redirect(dm?.ok ? channelPath(slug, dm.value) : `/${slug}/-/agents/${made.value.handle}`);
44+}
45+
46+function draftFrom(template: AgentTemplate): AgentDraft {
47+ return {
48+ ...BLANK_DRAFT,
49+ handle: template.handle,
50+ display_name: template.display_name,
51+ role: template.role,
52+ instructions: template.instructions,
53+ personality_preset: template.personality_preset,
54+ routing: template.routing,
55+ template: template.id,
56+ };
57+}
58+
59+export default function NewAgent({ loaderData, actionData, params }: Route.ComponentProps) {
60+ const [search, setSearch] = useSearchParams();
61+ const chosen = search.get("template");
62+ const templates = loaderData.templates ?? [];
63+ const template = templates.find((t) => t.id === chosen);
64+ const draft = template ? draftFrom(template) : chosen === "blank" ? BLANK_DRAFT : null;
65+ const errors = actionData?.errors;
66+ return (
67+ <div className="pb-4">
68+ <Link to={`/${params.owner}/-/agents`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
69+ <ArrowLeft size={14} />
70+ Agents
71+ </Link>
72+ <header className="mt-4 mb-8">
73+ <h1 className="text-2xl font-semibold tracking-tight">New agent</h1>
74+ <p className="mt-1.5 max-w-2xl text-sm text-muted">
75+ A teammate with a name, a job, a voice and a budget. Start from one of g1t's roles and make it yours, or from nothing. Once it's made, you'll be in a direct message with it.
76+ </p>
77+ </header>
78+ {loaderData.templates == null && (
79+ <p className="mb-6 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-muted">
80+ Templates aren't available right now; the agents service didn't answer. You can still start from a blank agent.
81+ </p>
82+ )}
83+ <TemplateGallery
84+ templates={templates}
85+ chosen={chosen}
86+ onChoose={(id) => setSearch(id ? { template: id } : {}, { preventScrollReset: true, replace: true })}
87+ />
88+ {draft ? (
89+ <div className="mt-10 border-t border-line pt-10">
90+ {errors?.form && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger">{errors.form}</p>}
91+ <AgentForm draft={draft} errors={errors} submit="Create agent" intent="create" formKey={chosen ?? "blank"} />
92+ </div>
93+ ) : (
94+ <p className="mt-6 text-sm text-faint">Choose a starting point to see its settings.</p>
95+ )}
96+ </div>
97+ );
98+}
+41−0
1+import { data, redirect, useOutletContext } from "react-router";
2+
3+import type { WorkspaceAgent } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/profile";
6+import { AgentForm } from "../../../components/agents-mode";
7+import { readAgentForm } from "../../../lib/agent-form";
8+import { workspaceAgents } from "../../../lib/services.server";
9+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
10+
11+/** Saving makes a new version; every run records which one it ran. */
12+export async function action({ params, context, request }: Route.ActionArgs) {
13+ assertSameOrigin(request);
14+ const viewer = requireUser(context, request);
15+ const slug = params.owner.toLowerCase();
16+ if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
17+ const read = readAgentForm(await request.formData());
18+ if (!read.ok) return { errors: read.errors, saved: false };
19+ const saved = await workspaceAgents.update(slug, params.handle.toLowerCase(), viewer, read.input).catch(() => null);
20+ if (!saved) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false };
21+ if (!saved.ok) return { errors: { [saved.error.code === "conflict" ? "handle" : "form"]: saved.error.message }, saved: false };
22+ // A new handle is a new address.
23+ if (saved.value.handle !== params.handle.toLowerCase()) throw redirect(`/${slug}/-/agents/${saved.value.handle}/profile`);
24+ return { errors: undefined, saved: true };
25+}
26+
27+export default function Profile({ actionData }: Route.ComponentProps) {
28+ const agent = useOutletContext<WorkspaceAgent>();
29+ const errors = actionData?.errors as Record<string, string> | undefined;
30+ return (
31+ <>
32+ {actionData?.saved && (
33+ <p role="status" className="mb-6 rounded-lg border border-success/30 bg-success/10 px-4 py-2.5 text-sm text-success">
34+ Saved as version {agent.version}.
35+ </p>
36+ )}
37+ {errors?.form && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger">{errors.form}</p>}
38+ <AgentForm draft={agent} errors={errors} submit="Save changes" intent="update" formKey={`${agent.id}:${agent.version}`} />
39+ </>
40+ );
41+}
+69−0
1+import { useOutletContext } from "react-router";
2+
3+import type { WorkspaceAgent } from "@g1t/contracts";
4+
5+import { formatDollars } from "../../../lib/agent-form";
6+
7+/** Spend this month against the agent's budget; by task and by model as tasks land. */
8+export default function Spend() {
9+ const agent = useOutletContext<WorkspaceAgent>();
10+ const spent = agent.spent_month_micros ?? 0;
11+ const cap = agent.budget.monthly_micros;
12+ const share = cap ? Math.min(1, spent / cap) : 0;
13+ const tone = share >= 1 ? "bg-danger" : share >= 0.8 ? "bg-warn" : "bg-accent";
14+ return (
15+ <div className="space-y-8">
16+ <section className="rounded-2xl border border-line bg-surface p-6">
17+ <p className="text-sm text-muted">Spent this month</p>
18+ <p className="mt-1 flex flex-wrap items-baseline gap-x-2">
19+ <span className="text-3xl font-semibold tracking-tight tabular-nums">{formatDollars(spent)}</span>
20+ <span className="text-sm text-muted">{cap ? `of ${formatDollars(cap)}` : "No monthly cap: the workspace limit applies"}</span>
21+ </p>
22+ {cap != null && (
23+ <>
24+ <div
25+ className="mt-4 h-2 overflow-hidden rounded-full bg-line"
26+ role="meter"
27+ aria-label="Spent of the monthly cap"
28+ aria-valuemin={0}
29+ aria-valuemax={cap}
30+ aria-valuenow={spent}
31+ >
32+ <div className={`h-full rounded-full ${tone} transition-[width]`} style={{ width: `${Math.max(share * 100, spent > 0 ? 1.5 : 0)}%` }} />
33+ </div>
34+ <p className="mt-2 text-xs text-faint">
35+ {Math.round(share * 100)}% used. At 80% it tells the channel that pays for it; at 100% it takes no new tasks.
36+ </p>
37+ </>
38+ )}
39+ <dl className="mt-6 grid grid-cols-2 gap-4 border-t border-line pt-5 sm:grid-cols-3">
40+ <div>
41+ <dt className="text-xs text-faint">Daily cap</dt>
42+ <dd className="mt-0.5 text-sm tabular-nums">{agent.budget.daily_micros != null ? formatDollars(agent.budget.daily_micros) : "None"}</dd>
43+ </div>
44+ <div>
45+ <dt className="text-xs text-faint">Per task</dt>
46+ <dd className="mt-0.5 text-sm tabular-nums">{agent.budget.task_micros != null ? formatDollars(agent.budget.task_micros) : "None"}</dd>
47+ </div>
48+ <div>
49+ <dt className="text-xs text-faint">While idle</dt>
50+ <dd className="mt-0.5 text-sm">Costs nothing</dd>
51+ </div>
52+ </dl>
53+ </section>
54+ <section>
55+ <h2 className="text-sm font-medium">By task and by model</h2>
56+ <div className="mt-3 grid gap-3 sm:grid-cols-2">
57+ <div className="rounded-xl border border-dashed border-line px-5 py-8">
58+ <p className="text-sm font-medium">By task</p>
59+ <p className="mt-1 text-[0.8125rem] text-muted">Each task&apos;s spend against its cap shows here once it has worked one.</p>
60+ </div>
61+ <div className="rounded-xl border border-dashed border-line px-5 py-8">
62+ <p className="text-sm font-medium">By model</p>
63+ <p className="mt-1 text-[0.8125rem] text-muted">Every step records the model that ran it; the split shows here once it has worked.</p>
64+ </div>
65+ </div>
66+ </section>
67+ </div>
68+ );
69+}
+113−0
1+import { data } from "react-router";
2+
3+import { type Result, parsePrincipalKey } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/api";
6+import { RESERVED_CHANNEL_NAMES, channelName, channelPath } from "../../../lib/chat";
7+import { principalsFrom } from "../../../lib/chat.server";
8+import { chat } from "../../../lib/services.server";
9+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
10+
11+/**
12+ * What a chat page asks for as it runs, as JSON, without loading a page:
13+ * the sidebar again, a thread's replies, older messages; and everything it
14+ * sends: messages, read marks, stars, new channels and direct messages.
15+ * Every answer is `{ ok, value }` or `{ ok: false, error }`, the services'
16+ * own shape.
17+ */
18+export async function loader({ params, context, request }: Route.LoaderArgs) {
19+ const viewer = requireUser(context, request);
20+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21+ const slug = params.owner.toLowerCase();
22+ const url = new URL(request.url);
23+ const channel = url.searchParams.get("channel");
24+ const answer = await safely<unknown>(() => {
25+ if (!channel) return chat.sidebar(slug, viewer);
26+ return chat.messages(slug, channel, viewer, {
27+ thread_root: url.searchParams.get("thread"),
28+ before: url.searchParams.get("before"),
29+ after: url.searchParams.get("after"),
30+ limit: Number(url.searchParams.get("limit")) || 60,
31+ });
32+ });
33+ return Response.json(answer, { headers: { "cache-control": "no-store" } });
34+}
35+
36+type Sent = {
37+ intent?: string;
38+ channel_id?: string;
39+ body?: string;
40+ thread_root?: string | null;
41+ id?: string;
42+ starred?: boolean;
43+ muted?: boolean;
44+ name?: string;
45+ topic?: string;
46+ private?: boolean;
47+ members?: string[];
48+ member?: string;
49+};
50+
51+export async function action({ params, context, request }: Route.ActionArgs) {
52+ assertSameOrigin(request);
53+ const viewer = requireUser(context, request);
54+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
55+ const slug = params.owner.toLowerCase();
56+ const sent = (await request.json().catch(() => ({}))) as Sent;
57+ const channel = sent.channel_id ?? "";
58+ const answer = await safely(async (): Promise<Result<unknown>> => {
59+ switch (sent.intent) {
60+ case "post": {
61+ const body = (sent.body ?? "").trim();
62+ if (!body) return { ok: false, error: { code: "invalid", message: "Write something to send." } };
63+ return chat.post(slug, channel, viewer, { body, thread_root: sent.thread_root ?? null });
64+ }
65+ case "edit":
66+ return chat.edit(slug, channel, viewer, sent.id ?? "", (sent.body ?? "").trim());
67+ case "remove":
68+ return chat.remove(slug, channel, viewer, sent.id ?? "");
69+ case "read":
70+ return chat.markRead(slug, channel, viewer, sent.id ?? "");
71+ case "preferences":
72+ return chat.setPreferences(slug, channel, viewer, { starred: sent.starred, muted: sent.muted });
73+ case "join":
74+ return chat.join(slug, channel, viewer);
75+ case "leave":
76+ return chat.leave(slug, channel, viewer);
77+ case "invite": {
78+ const [member] = await principalsFrom(slug, viewer, [sent.member ?? ""]);
79+ const principal = member ?? parsePrincipalKey(sent.member ?? "");
80+ if (!principal) return { ok: false, error: { code: "invalid", message: "Choose someone to add." } };
81+ return chat.invite(slug, channel, viewer, principal);
82+ }
83+ case "create_channel": {
84+ const name = channelName(sent.name ?? "");
85+ if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } };
86+ if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) {
87+ return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } };
88+ }
89+ const made = await chat.createChannel(slug, viewer, { name, topic: sent.topic?.trim() || null, private: Boolean(sent.private) });
90+ return made.ok ? { ok: true, value: { channel: made.value, to: channelPath(slug, made.value) } } : made;
91+ }
92+ case "dm": {
93+ const members = await principalsFrom(slug, viewer, sent.members ?? []);
94+ if (members.length === 0) return { ok: false, error: { code: "invalid", message: "Choose who to message." } };
95+ const opened = await chat.openDm(slug, viewer, members);
96+ return opened.ok ? { ok: true, value: { channel: opened.value, to: channelPath(slug, opened.value) } } : opened;
97+ }
98+ default:
99+ return { ok: false, error: { code: "invalid", message: "Unknown request." } };
100+ }
101+ });
102+ return Response.json(answer, { headers: { "cache-control": "no-store" } });
103+}
104+
105+/** A service's answer, or a failure that says chat did not answer. */
106+async function safely<T>(call: () => Promise<Result<T>>): Promise<Result<T>> {
107+ try {
108+ return await call();
109+ } catch (error) {
110+ console.error("chat: the chat service did not answer", error);
111+ return { ok: false, error: { code: "conflict", message: "Chat didn't answer. Try again in a moment." } };
112+ }
113+}
+101−0
1+import { Hash, Search } from "lucide-react";
2+import { useMemo, useState } from "react";
3+import { Link, useNavigate } from "react-router";
4+
5+import type { Channel } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/browse";
8+import { ChatUnavailable } from "../../../components/chat/empty";
9+import { CreateChannelButton, useChatSend } from "../../../components/chat/actions";
10+import { channelPath } from "../../../lib/chat";
11+import { sidebarOrNull } from "../../../lib/chat.server";
12+import { page } from "../../../lib/meta";
13+import { chat } from "../../../lib/services.server";
14+import { requireUser } from "../../../lib/session.server";
15+
16+export function meta({ params, ...args }: Route.MetaArgs) {
17+ return page(args, { title: `Browse channels · ${params.owner} · g1t` });
18+}
19+
20+/** Every public channel in the workspace, joined or not. */
21+export async function loader({ params, context, request }: Route.LoaderArgs) {
22+ const viewer = requireUser(context, request);
23+ 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] };
32+}
33+
34+export default function Browse({ loaderData }: Route.ComponentProps) {
35+ const [query, setQuery] = useState("");
36+ const navigate = useNavigate();
37+ const send = useChatSend(loaderData.slug);
38+ const [joining, setJoining] = useState<string | null>(null);
39+ const shown = useMemo(() => {
40+ if (loaderData.unavailable) return [];
41+ const q = query.trim().toLowerCase().replace(/^#/, "");
42+ return loaderData.channels.filter((c) => !q || (c.name ?? "").includes(q) || (c.topic ?? "").toLowerCase().includes(q));
43+ }, [loaderData, query]);
44+ if (loaderData.unavailable) return <ChatUnavailable title="Browse channels" />;
45+ const joined = new Set(loaderData.joined);
46+ return (
47+ <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-8 lg:py-12">
48+ <div className="flex flex-wrap items-center justify-between gap-3">
49+ <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>
52+ </div>
53+ <CreateChannelButton slug={loaderData.slug} variant="button" />
54+ </div>
55+ <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">
56+ <Search size={15} className="text-faint" />
57+ <input
58+ value={query}
59+ onChange={(event) => setQuery(event.target.value)}
60+ placeholder="Search channels"
61+ aria-label="Search channels"
62+ className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
63+ />
64+ </label>
65+ <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>}
67+ {shown.map((channel) => {
68+ const isIn = joined.has(channel.id);
69+ return (
70+ <li key={channel.id} className="flex items-center gap-3 px-4 py-3">
71+ <Hash size={16} className="shrink-0 text-faint" />
72+ <div className="min-w-0 grow">
73+ <Link to={channelPath(loaderData.slug, channel)} className="font-medium hover:text-accent">
74+ {channel.name}
75+ </Link>
76+ {channel.topic && <p className="truncate text-sm text-muted">{channel.topic}</p>}
77+ </div>
78+ {isIn ? (
79+ <span className="text-xs text-faint">Joined</span>
80+ ) : (
81+ <button
82+ type="button"
83+ disabled={joining === channel.id}
84+ onClick={async () => {
85+ setJoining(channel.id);
86+ const done = await send({ intent: "join", channel_id: channel.id });
87+ setJoining(null);
88+ if (done.ok) navigate(channelPath(loaderData.slug, channel));
89+ }}
90+ className="h-8 rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised disabled:opacity-50"
91+ >
92+ {joining === channel.id ? "Joining…" : "Join"}
93+ </button>
94+ )}
95+ </li>
96+ );
97+ })}
98+ </ul>
99+ </div>
100+ );
101+}
+97−0
1+import { type ShouldRevalidateFunctionArgs, data } from "react-router";
2+
3+import type { Channel, ChannelMember, ChatMessage, User } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/channel";
6+import { ChannelView } from "../../../components/chat/channel";
7+import { ChatUnavailable } from "../../../components/chat/empty";
8+import { channelPath } from "../../../lib/chat";
9+import { rememberChat } from "../../../lib/chat.server";
10+import { page } from "../../../lib/meta";
11+import { chat } from "../../../lib/services.server";
12+import { requireUser, roleIn } from "../../../lib/session.server";
13+
14+export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
15+ const title = loaderData && "title" in loaderData ? loaderData.title : "Chat";
16+ return page(args, { title: `${title} · ${params.owner} · g1t` });
17+}
18+
19+export type ChannelData =
20+ | { unavailable: true; title: string }
21+ | {
22+ unavailable: false;
23+ title: string;
24+ channel: Channel;
25+ members: ChannelMember[];
26+ messages: ChatMessage[];
27+ older: string | null;
28+ /** Whether the viewer is in it: a public channel can be read before joining. */
29+ joined: boolean;
30+ };
31+
32+/**
33+ * One conversation: a channel by its name (`-/chat/general`) or a direct
34+ * message by its id (`-/chat/dm/<id>`), with its latest messages. Live
35+ * updates arrive over the socket once the page is open.
36+ */
37+export async function loader({ params, context, request }: Route.LoaderArgs) {
38+ const viewer = requireUser(context, request);
39+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
40+ const slug = params.owner.toLowerCase();
41+ const name = "channel" in params ? (params.channel as string | undefined) : undefined;
42+ const fallbackTitle = name ? `#${name}` : "Direct message";
43+ const read = await readConversation(slug, viewer, name, "id" in params ? (params.id as string | undefined) : undefined).catch(
44+ (error: unknown) => {
45+ console.error("chat: the conversation could not be read", error);
46+ return "unavailable" as const;
47+ },
48+ );
49+ if (read === "missing") throw data(null, { status: 404 });
50+ if (read === "unavailable") return { unavailable: true, title: fallbackTitle } satisfies ChannelData;
51+ const secure = new URL(request.url).protocol === "https:";
52+ return data(read, { headers: { "Set-Cookie": rememberChat(channelPath(slug, read.channel), secure) } });
53+}
54+
55+/** The conversation and its latest messages; "missing" for a 404, "unavailable" when chat does not answer. */
56+async function readConversation(
57+ slug: string,
58+ viewer: User,
59+ name: string | undefined,
60+ byId: string | undefined,
61+): Promise<Extract<ChannelData, { unavailable: false }> | "missing" | "unavailable"> {
62+ // A channel's address is its name; a direct message's, its id.
63+ const lookup = byId ? chat.channel(slug, byId, viewer) : name ? chat.channelByName(slug, name, viewer) : null;
64+ if (!lookup) return "missing";
65+ const detail = await lookup;
66+ if (!detail.ok) return detail.error.code === "not_found" || detail.error.code === "forbidden" ? "missing" : "unavailable";
67+ const history = await chat.messages(slug, detail.value.channel.id, viewer, { limit: 60 });
68+ const { channel, members } = detail.value;
69+ // A public channel can be read before joining it.
70+ const joined = members.some((m) => m.member.kind === "user" && m.member.id === viewer.id);
71+ const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === viewer.id));
72+ const title =
73+ channel.kind === "channel"
74+ ? `#${channel.name}`
75+ : others.map((m) => m.member.display_name || m.member.name).join(", ") || "Direct message";
76+ return {
77+ unavailable: false,
78+ title,
79+ channel,
80+ members,
81+ messages: history.ok ? history.value.messages : [],
82+ older: history.ok ? history.value.older : null,
83+ joined,
84+ };
85+}
86+
87+/** Opening a thread (`?thread=`) is the same page: nothing to load again. */
88+export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
89+ if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
90+ if (currentUrl.pathname === nextUrl.pathname) return false;
91+ return defaultShouldRevalidate;
92+}
93+
94+export default function ChannelPage({ loaderData }: Route.ComponentProps) {
95+ if (loaderData.unavailable) return <ChatUnavailable title={loaderData.title} />;
96+ return <ChannelView key={loaderData.channel.id} data={loaderData} />;
97+}
+37−0
1+import { redirect } from "react-router";
2+
3+import type { Route } from "./+types/index";
4+import { ChatUnavailable, NoChannels } from "../../../components/chat/empty";
5+import { channelPath } from "../../../lib/chat";
6+import { LAST_CHAT_COOKIE, sidebarOrNull } from "../../../lib/chat.server";
7+import { readCookie } from "../../../lib/mission";
8+import { page } from "../../../lib/meta";
9+import { requireUser } from "../../../lib/session.server";
10+
11+export function meta({ params, ...args }: Route.MetaArgs) {
12+ return page(args, { title: `Chat · ${params.owner} · g1t` });
13+}
14+
15+/**
16+ * Chat opens where it was left: the last conversation open in this
17+ * workspace, else #general, else the first there is.
18+ */
19+export async function loader({ params, context, request }: Route.LoaderArgs) {
20+ const viewer = requireUser(context, request);
21+ const slug = params.owner.toLowerCase();
22+ const last = readCookie(request.headers.get("cookie"), LAST_CHAT_COOKIE);
23+ if (last && last.startsWith(`/${slug}/-/chat/`) && !last.includes("..")) throw redirect(last);
24+ const sidebar = await sidebarOrNull(slug, viewer);
25+ if (!sidebar) return { unavailable: true };
26+ const entries = sidebar.entries;
27+ const first =
28+ entries.find((entry) => entry.channel.kind === "channel" && entry.channel.name === "general") ??
29+ entries.find((entry) => entry.channel.kind === "channel") ??
30+ entries[0];
31+ if (first) throw redirect(channelPath(slug, first.channel));
32+ return { unavailable: false };
33+}
34+
35+export default function ChatIndex({ loaderData }: Route.ComponentProps) {
36+ return loaderData.unavailable ? <ChatUnavailable /> : <NoChannels />;
37+}
+42−0
1+import { Outlet, data, type ShouldRevalidateFunctionArgs } from "react-router";
2+
3+import type { ChatSidebar, WorkspaceAgent } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/layout";
6+import type { Mentionable } from "../../../lib/chat";
7+import { sidebarOrNull, workspacePeople } from "../../../lib/chat.server";
8+import { requireUser, roleIn } from "../../../lib/session.server";
9+
10+/**
11+ * Chat mode: what its sidebar shows (the shell draws it, from this data)
12+ * and who can be mentioned or messaged. Each part is empty rather than an
13+ * error when its service does not answer.
14+ */
15+export type ChatLayoutData = {
16+ slug: string;
17+ me: { id: string; username: string; avatar: string | null };
18+ sidebar: ChatSidebar | null;
19+ people: Mentionable[];
20+ agents: WorkspaceAgent[];
21+};
22+
23+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<ChatLayoutData> {
24+ const viewer = requireUser(context, request);
25+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
26+ const slug = params.owner.toLowerCase();
27+ const [sidebar, { people, agents }] = await Promise.all([sidebarOrNull(slug, viewer), workspacePeople(slug, viewer)]);
28+ return { slug, me: { id: viewer.id, username: viewer.username, avatar: viewer.avatar ?? null }, sidebar, people, agents };
29+}
30+
31+/**
32+ * Moving between conversations keeps the sidebar: it refreshes itself
33+ * (components/chat/sidebar.tsx) and after anything is sent.
34+ */
35+export function shouldRevalidate({ currentParams, nextParams, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
36+ if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
37+ return currentParams.owner !== nextParams.owner;
38+}
39+
40+export default function ChatLayout() {
41+ return <Outlet />;
42+}
+40−0
1+import { env } from "cloudflare:workers";
2+
3+import { CHAT_VIEWER_HEADER } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/live";
6+import { getViewer, roleIn } from "../../../lib/session.server";
7+
8+/**
9+ * A conversation's live socket: `wss://<site>/<workspace>/-/chat/live?channel=<id>`.
10+ * The site checks the session and that the page asking is the site's own,
11+ * then hands the upgrade to the chat service with the viewer, which checks
12+ * they may read the channel and keeps the socket (one Durable Object per
13+ * channel, hibernating while nothing happens).
14+ */
15+export async function loader({ params, context, request }: Route.LoaderArgs) {
16+ const viewer = getViewer(context);
17+ if (!viewer) return new Response("Sign in to use chat.", { status: 401 });
18+ if (!roleIn(viewer, params.owner)) return new Response("Not found", { status: 404 });
19+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
20+ return new Response("This address takes a WebSocket.", { status: 426, headers: { upgrade: "websocket" } });
21+ }
22+ // Only the site's own pages may open it: a page elsewhere carries the
23+ // cookie too, so the origin is what tells them apart.
24+ const origin = request.headers.get("origin");
25+ if (origin && origin !== new URL(request.url).origin) return new Response("Cross-origin socket refused", { status: 403 });
26+ const channel = new URL(request.url).searchParams.get("channel");
27+ if (!channel) return new Response("Which channel?", { status: 400 });
28+ const headers = new Headers(request.headers);
29+ // Neither the session nor anything else of the browser's goes on.
30+ headers.delete("cookie");
31+ headers.set(CHAT_VIEWER_HEADER, JSON.stringify(viewer));
32+ headers.set("x-g1t-workspace", params.owner.toLowerCase());
33+ const target = `https://chat/live?channel=${encodeURIComponent(channel)}&workspace=${encodeURIComponent(params.owner.toLowerCase())}`;
34+ try {
35+ return await env.CHAT.fetch(new Request(target, { method: "GET", headers }));
36+ } catch (error) {
37+ console.error("chat: the live socket could not be handed over", error);
38+ return new Response("Chat didn't answer.", { status: 503 });
39+ }
40+}
+78−0
1+import { Code2, MessagesSquare } from "lucide-react";
2+import { Link, data } from "react-router";
3+
4+import type { Route } from "./+types/code-access";
5+import { page } from "../../lib/meta";
6+import { identity } from "../../lib/services.server";
7+import { requireUser, roleIn } from "../../lib/session.server";
8+
9+export function meta({ params, ...args }: Route.MetaArgs) {
10+ return page(args, { title: `Code access · ${params.owner} · g1t` });
11+}
12+
13+/**
14+ * What a member without Code access sees in place of a repository, an
15+ * issue, a pull request or a project list (docs/WORKSPACE.md, "Members
16+ * without Code"): what it was, and who to ask.
17+ */
18+export async function loader({ params, context, request }: Route.LoaderArgs) {
19+ const viewer = requireUser(context, request);
20+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21+ const members = await identity.listMembers(params.owner, viewer).catch(() => null);
22+ const owners = members?.ok ? members.value.filter((m) => m.role === "owner").slice(0, 4).map((m) => m.username) : [];
23+ const from = new URL(request.url).searchParams.get("from");
24+ // Only a path on this site, shown as text.
25+ return { owners, from: from && /^\/[^/]/.test(from) ? from.split("?")[0] : null };
26+}
27+
28+export default function CodeAccess({ loaderData, params }: Route.ComponentProps) {
29+ const { owners, from } = loaderData;
30+ const what = from ? from.split("/").filter(Boolean).slice(1).join("/") : null;
31+ return (
32+ <div className="mx-auto max-w-lg py-10 text-center">
33+ <span className="mx-auto flex size-12 items-center justify-center rounded-xl border border-line bg-surface text-muted">
34+ <Code2 size={22} />
35+ </span>
36+ <h1 className="mt-5 text-2xl font-semibold tracking-tight">Ask an owner for Code access</h1>
37+ <p className="mt-2 text-[0.9375rem] leading-relaxed text-muted">
38+ {what ? (
39+ <>
40+ <span className="font-mono text-fg-soft">{what}</span> is part of Code in {params.owner}.
41+ </>
42+ ) : (
43+ <>This is part of Code in {params.owner}.</>
44+ )}{" "}
45+ Your membership includes Chat, Docs, Agents and the Inbox. An owner can turn on Code access for you.
46+ </p>
47+ {owners.length > 0 && (
48+ <p className="mt-4 text-sm text-muted">
49+ Owners:{" "}
50+ {owners.map((owner, index) => (
51+ <span key={owner}>
52+ {index > 0 && ", "}
53+ <Link to={`/u/${owner}`} className="font-medium text-fg hover:text-accent">
54+ @{owner}
55+ </Link>
56+ </span>
57+ ))}
58+ </p>
59+ )}
60+ <div className="mt-8 flex justify-center gap-2">
61+ <Link
62+ to={`/${params.owner}/-/chat`}
63+ className="inline-flex h-9 items-center gap-2 rounded-md bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover"
64+ >
65+ <MessagesSquare size={15} />
66+ Back to Chat
67+ </Link>
68+ <Link
69+ to={`/${params.owner}/-/home`}
70+ className="inline-flex h-9 items-center rounded-md border border-line px-3.5 text-sm font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-surface"
71+ >
72+ Home
73+ </Link>
74+ </div>
75+ <p className="mt-6 text-xs text-faint">Agents can still explain how things work and what changed; they never show source code.</p>
76+ </div>
77+ );
78+}
+54−0
1+import { BookOpen, FileText, GitBranch, MessagesSquare, Sparkles } from "lucide-react";
2+import type { ReactNode } from "react";
3+
4+import type { Route } from "./+types/docs";
5+import { page } from "../../lib/meta";
6+
7+export function meta({ params, ...args }: Route.MetaArgs) {
8+ return page(args, { title: `Docs · ${params.owner} · g1t` });
9+}
10+
11+function Point({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
12+ return (
13+ <li className="flex gap-3.5 rounded-xl border border-line bg-surface p-4">
14+ <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-raised text-muted">{icon}</span>
15+ <span>
16+ <span className="block text-sm font-medium">{title}</span>
17+ <span className="mt-0.5 block text-[0.8125rem] leading-relaxed text-muted">{children}</span>
18+ </span>
19+ </li>
20+ );
21+}
22+
23+/** Docs mode, coming: what it will be (docs/WORKSPACE.md, "Docs"). */
24+export default function Docs() {
25+ return (
26+ <div className="mx-auto max-w-3xl">
27+ <div className="flex items-center gap-2">
28+ <span className="flex size-10 items-center justify-center rounded-xl bg-accent/15 text-accent">
29+ <BookOpen size={20} />
30+ </span>
31+ <span className="rounded-full px-2 py-0.5 text-[0.6875rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">Coming soon</span>
32+ </div>
33+ <h1 className="mt-5 text-3xl font-semibold tracking-tight">Docs that keep themselves true</h1>
34+ <p className="mt-3 max-w-2xl text-[0.9375rem] leading-relaxed text-muted">
35+ The workspace&apos;s written knowledge: specs, runbooks, decisions and onboarding, edited together in real time. Agents read it before they
36+ work and keep it current after every merge.
37+ </p>
38+ <ul className="mt-8 grid gap-3 sm:grid-cols-2">
39+ <Point icon={<FileText size={16} />} title="Spaces and pages">
40+ One space per team or project, each a tree of pages with history, comments and backlinks.
41+ </Point>
42+ <Point icon={<Sparkles size={16} />} title="Agents read and write">
43+ Every reply and task draws on it. Without write access, an agent&apos;s edit is a suggestion you accept or reject.
44+ </Point>
45+ <Point icon={<GitBranch size={16} />} title="Pages know what they describe">
46+ A page that cites code is marked stale when a merge changes it, and its owner drafts the update.
47+ </Point>
48+ <Point icon={<MessagesSquare size={16} />} title="Conversations become pages">
49+ &ldquo;Write this up&rdquo; in a thread makes a page from it, linked both ways, so decisions get a home.
50+ </Point>
51+ </ul>
52+ </div>
53+ );
54+}
+143−0
1+import { ArrowRight, BookOpen, Hash, Inbox, Lock, MessagesSquare, Sparkles } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { Link, data } from "react-router";
4+
5+import type { ChatSidebarEntry } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/home";
8+import { AgentFace } from "../../components/agents-mode";
9+import { MemberAvatar, StatusDot, statusLabel } from "../../components/chat/marks";
10+import { channelPath, sections } from "../../lib/chat";
11+import { sidebarOrNull } from "../../lib/chat.server";
12+import { page } from "../../lib/meta";
13+import { inbox, workspaceAgents } from "../../lib/services.server";
14+import { requireUser, roleIn } from "../../lib/session.server";
15+
16+export function meta({ params, ...args }: Route.MetaArgs) {
17+ return page(args, { title: `Home · ${params.owner} · g1t` });
18+}
19+
20+/**
21+ * Home for the workspace without Code (docs/WORKSPACE.md, "Members without
22+ * Code"): the conversations waiting, the agents, Docs and the inbox, in
23+ * place of projects. Anyone in the workspace can open it.
24+ */
25+export async function loader({ params, context, request }: Route.LoaderArgs) {
26+ const viewer = requireUser(context, request);
27+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
28+ const slug = params.owner.toLowerCase();
29+ const [sidebar, agents, counts] = await Promise.all([
30+ sidebarOrNull(slug, viewer),
31+ workspaceAgents.list(slug, viewer).catch(() => null),
32+ inbox.counts(viewer.username).catch(() => null),
33+ ]);
34+ return {
35+ slug,
36+ name: viewer.username,
37+ sidebar,
38+ agents: agents?.ok ? agents.value.filter((a) => !a.archived_at) : null,
39+ unreadInbox: counts?.unread ?? 0,
40+ };
41+}
42+
43+function Card({ title, icon, to, action, children }: { title: string; icon: ReactNode; to: string; action: string; children: ReactNode }) {
44+ return (
45+ <section className="flex flex-col rounded-2xl border border-line bg-surface">
46+ <header className="flex items-center gap-2.5 border-b border-line px-5 py-3.5">
47+ <span className="text-muted">{icon}</span>
48+ <h2 className="text-sm font-semibold">{title}</h2>
49+ <Link to={to} className="ml-auto flex items-center gap-1 text-xs text-muted hover:text-fg">
50+ {action}
51+ <ArrowRight size={12} />
52+ </Link>
53+ </header>
54+ <div className="grow p-2">{children}</div>
55+ </section>
56+ );
57+}
58+
59+function ConversationRow({ entry, slug }: { entry: ChatSidebarEntry; slug: string }) {
60+ const other = entry.others[0];
61+ return (
62+ <Link to={channelPath(slug, entry.channel)} className="flex h-10 items-center gap-2.5 rounded-lg px-3 text-sm transition-colors hover:bg-raised/60">
63+ <span className="flex w-5 justify-center text-faint">
64+ {entry.channel.kind === "dm" && other ? <MemberAvatar member={other} size={20} /> : entry.channel.private ? <Lock size={14} /> : <Hash size={15} />}
65+ </span>
66+ <span className={`min-w-0 grow truncate ${entry.unread > 0 ? "font-semibold text-fg" : "text-muted"}`}>{entry.title}</span>
67+ {entry.unread > 0 && (
68+ <span className={`rounded-full px-1.5 text-[0.6875rem] leading-[1.125rem] font-semibold tabular-nums ${entry.mentions ? "bg-accent text-bg" : "bg-line-strong text-fg"}`}>
69+ {entry.mentions || entry.unread}
70+ </span>
71+ )}
72+ </Link>
73+ );
74+}
75+
76+export default function WorkspaceHome({ loaderData }: Route.ComponentProps) {
77+ const { slug, name, sidebar, agents, unreadInbox } = loaderData;
78+ const all = sidebar?.entries ?? [];
79+ const waiting = all.filter((e) => !e.muted && e.unread > 0);
80+ const { dms } = sections(all);
81+ return (
82+ <div>
83+ <header className="mb-8">
84+ <h1 className="text-2xl font-semibold tracking-tight">Welcome back, {name}</h1>
85+ <p className="mt-1.5 text-sm text-muted">
86+ {waiting.length > 0
87+ ? `${waiting.length} ${waiting.length === 1 ? "conversation has" : "conversations have"} something new.`
88+ : "You're caught up in chat."}
89+ {unreadInbox > 0 ? ` ${unreadInbox} unread in your inbox.` : ""}
90+ </p>
91+ </header>
92+ <div className="grid gap-4 lg:grid-cols-2">
93+ <Card title="Unread" icon={<MessagesSquare size={16} />} to={`/${slug}/-/chat`} action="Open chat">
94+ {sidebar == null ? (
95+ <p className="px-3 py-6 text-sm text-muted">Chat didn&apos;t answer. Your conversations show here once it does.</p>
96+ ) : waiting.length === 0 ? (
97+ <p className="px-3 py-6 text-sm text-muted">Nothing new. Conversations with new messages show here.</p>
98+ ) : (
99+ waiting.slice(0, 6).map((entry) => <ConversationRow key={entry.channel.id} entry={entry} slug={slug} />)
100+ )}
101+ </Card>
102+ <Card title="Direct messages" icon={<MessagesSquare size={16} />} to={`/${slug}/-/chat`} action="All">
103+ {dms.length === 0 ? (
104+ <p className="px-3 py-6 text-sm text-muted">Message a teammate or an agent from Chat.</p>
105+ ) : (
106+ dms.slice(0, 6).map((entry) => <ConversationRow key={entry.channel.id} entry={entry} slug={slug} />)
107+ )}
108+ </Card>
109+ <Card title="Agents" icon={<Sparkles size={16} />} to={`/${slug}/-/agents`} action="All agents">
110+ {agents == null || agents.length === 0 ? (
111+ <p className="px-3 py-6 text-sm text-muted">
112+ {agents == null ? "The agents service didn't answer." : "No agents yet. Ask an owner to add one, or make one from a template."}
113+ </p>
114+ ) : (
115+ agents.slice(0, 5).map((agent) => (
116+ <Link key={agent.id} to={`/${slug}/-/agents/${agent.handle}`} className="flex h-12 items-center gap-3 rounded-lg px-3 transition-colors hover:bg-raised/60">
117+ <AgentFace agent={agent} size={26} />
118+ <span className="min-w-0 grow">
119+ <span className="block truncate text-sm font-medium">{agent.display_name}</span>
120+ <span className="block truncate text-xs text-faint">{agent.role}</span>
121+ </span>
122+ <span className="flex items-center gap-1.5 text-xs text-muted">
123+ <StatusDot status={agent.status} />
124+ {statusLabel(agent.status)}
125+ </span>
126+ </Link>
127+ ))
128+ )}
129+ </Card>
130+ <div className="grid gap-4">
131+ <Card title="Inbox" icon={<Inbox size={16} />} to="/inbox" action="Open">
132+ <p className="px-3 py-4 text-sm text-muted">
133+ {unreadInbox > 0 ? `${unreadInbox} unread: mentions, approvals and what needs you.` : "Nothing waiting on you."}
134+ </p>
135+ </Card>
136+ <Card title="Docs" icon={<BookOpen size={16} />} to={`/${slug}/-/docs`} action="Learn more">
137+ <p className="px-3 py-4 text-sm text-muted">Specs, runbooks and decisions, kept current by agents. Coming soon.</p>
138+ </Card>
139+ </div>
140+ </div>
141+ </div>
142+ );
143+}
+3−0
144144 const next = going && going.pathname !== pathname ? workspacePage(going.pathname, workspace.slug) : null;
145145 const shown = next ?? here;
146146 const parts = (pathname.split("/-/")[1] ?? "").split("/").filter(Boolean);
147+ // Chat fills the page edge to edge: its conversation, thread and details
148+ // are columns of their own (components/chat/channel.tsx).
149+ if (parts[0] === "chat" && !next) return <Outlet />;
147150 if (shown !== "overview") {
148151 const key = shown ?? parts[0] ?? "";
149152 const heading = PAGES[key];
+5−2
11 # g1t
22
3−> g1t (https://g1t.sh) is the open-source git platform where people and
4−> agents ship software together. It is ordinary git over HTTPS, with
3+> g1t (https://g1t.sh) is one open-source workspace where a team and its
4+> agents talk, work and ship. People and a workspace's own agents (each with
5+> a role, a job, a personality, model limits and a budget) talk in Chat:
6+> channels, direct messages and threads (https://docs.g1t.sh/guides/chat/,
7+> https://docs.g1t.sh/guides/agents/). Code is ordinary git over HTTPS, with
58 > issues, pull requests and reviews. Agents are members of the forge: you
69 > assign an issue to g1t or connect your own over MCP, or hand g1t an
710 > outcome and a planner splits it into issues with dependencies that agents
+7−0
2222 CONTEXT: ServiceBinding;
2323 /** Search across all of g1t, and Explore. */
2424 SEARCH: ServiceBinding;
25+ /**
26+ * Chat: channels, messages and read state over RPC, and the live
27+ * socket, forwarded as it is (app/lib/chat-live.server.ts).
28+ */
29+ CHAT: ServiceBinding & { fetch(request: Request): Promise<Response> };
30+ /** The workspace's own agents: definitions, templates and desks. */
31+ AGENTS: ServiceBinding;
2532 /** Production screenshots, from the og service's `Screenshots` entrypoint. */
2633 SCREENSHOTS?: {
2734 image(input: { host: string; commit: string; since?: string }): Promise<{
+4−0
4545 { "binding": "SECURITY", "service": "g1t-security" },
4646 { "binding": "CONTEXT", "service": "g1t-context" },
4747 { "binding": "SEARCH", "service": "g1t-search" },
48+ // Chat: channels, direct messages and the live socket (services/chat).
49+ { "binding": "CHAT", "service": "g1t-chat" },
50+ // The workspace's own agents: definitions, templates, desks (services/agents).
51+ { "binding": "AGENTS", "service": "g1t-agents" },
4852 // Production screenshots for a project's overview (services/og).
4953 { "binding": "SCREENSHOTS", "service": "g1t-og", "entrypoint": "Screenshots" }
5054 ],
+1−0
7070 const OFF_NAMES = {
7171 "g1t-runner": "Agents",
7272 "g1t-context": "Context search and memory",
73+ "g1t-models": "Hosted models",
7374 };
7475 const OFF = Object.fromEntries(
7576 STACK.filter((unit) => unit.self_host === "off").map((unit) => [unit.worker, OFF_NAMES[unit.worker] ?? unit.worker]),
+35−3
1717 //
1818 // migrations: every pending D1 migration, before any code.
1919 // core: the services, reached through service bindings.
20− // edge: public endpoints other than the site: API, MCP, models, g1t.page, status.
20+ // edge: public endpoints other than the site: API, MCP, g1t.page, status
21+ // (models is public too, but in core: agents binds to it).
2122 // front: the site, sudo and the docs.
2223 "stages": ["migrations", "core", "edge", "front"],
2324
114115 "secrets": [],
115116 "self_host": "run"
116117 },
118+ "chat": {
119+ "path": "services/chat",
120+ "kind": "ts-worker",
121+ "worker": "g1t-chat",
122+ "d1": { "database": "g1t-chat", "migrations": "migrations" },
123+ "stage": "core",
124+ "secrets": [],
125+ "setup": [
126+ "The D1 database, before the first deploy: npx wrangler d1 create g1t-chat, then put its id in services/chat/wrangler.jsonc"
127+ ],
128+ "self_host": "run"
129+ },
130+ "agents": {
131+ "path": "services/agents",
132+ "kind": "ts-worker",
133+ "worker": "g1t-agents",
134+ "d1": { "database": "g1t-agents", "migrations": "migrations" },
135+ "stage": "core",
136+ "secrets": [],
137+ "setup": [
138+ "The D1 database, before the first deploy: npx wrangler d1 create g1t-agents, then put its id in services/agents/wrangler.jsonc"
139+ ],
140+ // Replies route and gate model work exactly as runs do, with the
141+ // runner's own modules: its routing policy and who may use hosted models.
142+ "inputs": ["services/runner/src/model-env.ts", "services/runner/src/hosted.ts"],
143+ "self_host": "run"
144+ },
117145 "billing": {
118146 "path": "services/billing",
119147 "kind": "rust-worker",
248276 "path": "services/models",
249277 "kind": "ts-worker",
250278 "worker": "g1t-models",
251− "stage": "edge",
279+ // Core, though it is public at models.g1t.sh: the agents service
280+ // reaches it by service binding for chat replies. It binds only to
281+ // core services itself.
282+ "stage": "core",
252283 "secrets": ["AI_GATEWAY_TOKEN"],
253284 "setup": ["The AI Gateway `g1t`"],
254− "self_host": "none"
285+ // Not run self-hosted, but bound to the off Worker: agents binds to it.
286+ "self_host": "off"
255287 },
256288 "pages": {
257289 "path": "services/pages",
+3−1
111111 | `services/work` | Rust | Queue consumer | Runs unchanged |
112112 | `services/events` | Rust | Queues (producer and fan-out) | Runs unchanged; the off services' queues are not produced to |
113113 | `services/projects` | TS | Queue consumer | Runs unchanged |
114+| `services/chat` | TS | Durable Objects (one room per channel, WebSocket hibernation) | Runs unchanged; workerd runs its Durable Objects |
115+| `services/agents` | TS | Durable Objects (one desk per agent, alarms) | Runs unchanged; replies reach a model through the `MODELS` binding (the model proxy), which is off, so an agent answers with a short apology |
114116 | `services/search` | Rust | Queues (events and its own jobs); FTS5 | Runs unchanged |
115117 | `services/billing` | Rust | Cron, Cloudflare REST API (keeper), Stripe | Runs with `FREE_WHILE_BUILDING=true` and no Stripe key: nothing is charged |
116118 | `services/security` | Rust | Queue, cron | Runs unchanged; its cron through `scheduler.mjs` |
121123 | `services/deployments` | TS | Workers for Platforms, REST API, KV `DOMAINS`, cron | Runs with no API token: nothing deploys |
122124 | `services/runner` | TS | **Containers**, Durable Objects, outbound interception, AI Gateway, cron | Off: bound to the off Worker |
123125 | `services/context` | TS | **Vectorize**, **Workers AI**, Queues | Off: bound to the off Worker |
124−| `services/models` | TS | AI Gateway; public at `models.g1t.sh` | Not run (only sandboxes call it) |
126+| `services/models` | TS | AI Gateway; public at `models.g1t.sh` | Not run; bound to the off Worker (sandboxes and agent replies call it) |
125127 | `services/pages` | TS | Dispatch namespace, wildcard routes, KV | Not run |
126128 | `services/og` | TS | Cache API | Not run (social cards are optional) |
127129 | `crates/runner` | native, in the sandbox | Talks to `https://api.g1t.sh` and `https://g1t.sh` (hard-coded in the runner Worker); `wrangler deploy --dry-run` for builds | Phase 2 |
+826−0
1+# The workspace: chat, agents, docs and code in one place
2+
3+A g1t workspace is where a team and its agents talk, write things down and
4+ship code. Four modes share one identity, one inbox, one search, one audit
5+log and one bill:
6+
7+| Mode | What it is |
8+| --- | --- |
9+| **Chat** | Channels, threads and direct messages. People and agents are members alike. |
10+| **Agents** | The workspace's agents: who they are, what they are doing right now, what they cost. |
11+| **Docs** | The workspace's written knowledge: specs, runbooks, decisions, onboarding. Agents read it and keep it current. |
12+| **Code** | Projects, issues, pull requests, checks, deploys. Exactly as today. |
13+
14+Home and Inbox sit above the modes and span all of them.
15+
16+This plan supersedes `docs/CHANNELS.md`. It keeps that document's data
17+shapes for channels and messages. It changes how agents run, adds
18+coordination between agents, and adds Docs.
19+
20+## The bet
21+
22+Every team already runs three tools that don't know about each other:
23+
24+- a chat app;
25+- a wiki;
26+- a forge.
27+
28+Agents are bolted onto each one separately. A bot in the chat app can talk
29+but can't safely touch code. A coding agent can touch code but forgets the
30+conversation that asked for it. The wiki goes stale the week it is written.
31+
32+g1t puts the three in one system of record, and agents are members of it:
33+
34+- **Agents are teammates, not features.** Each has a name, a face, a job, a
35+ personality, a budget and a desk. You DM it, invite it to a channel, add
36+ it to a doc's owners, assign it an issue.
37+- **Conversation starts work; the forge records it.** A request in chat
38+ becomes a task. Code changes become pull requests. Knowledge becomes doc
39+ edits. Everything links back to the thread that asked for it.
40+- **Agents work like real sessions.** An agent works the way a Claude Code
41+ session does: it reads, edits, runs things, keeps its context across
42+ days, can be steered mid-flight, and can hold several jobs at once.
43+- **Agents coordinate in the open.** Before an agent touches something, it
44+ claims it. When two agents' work would collide, they negotiate in a
45+ thread a person can read, not in a hidden log.
46+- **The rails are the product.** Budgets, scopes, approvals and audit are
47+ decided by policy, not by the agent's judgment, and they are visible on
48+ every card.
49+
50+## Agents
51+
52+### What an agent is
53+
54+An agent is a member of a workspace, of kind `agent`. It appears everywhere
55+a person does: member lists, mentions, assignees, reviewers, doc authors,
56+the audit log.
57+
58+`@g1t` stays what it is today: the platform's own agent, reachable from any
59+workspace with no setup. A workspace's own agents are created by its
60+members and belong only to that workspace. g1t's built-in roles (planner,
61+implementer, reviewer, triage, documenter) ship as templates you can adopt,
62+rename and change. They are not hidden system actors.
63+
64+### The definition
65+
66+An agent is a versioned record in the workspace. It can also be mirrored
67+to a repository as `.g1t/agents/<handle>.md`: front matter for settings,
68+the body for its job. Edits from either side create a new version, and
69+every run records which version it ran.
70+
71+| Field | What it controls |
72+| --- | --- |
73+| Identity | Display name, `@handle`, avatar, a one-line role ("Release manager for g1t"). |
74+| Job | Instructions: what it is responsible for, how it works, what good looks like. |
75+| Personality | Voice only: tone, verbosity, formality, emoji, language, how it asks questions. Presets (Crisp, Friendly, Socratic, Terse operator) plus free text. Personality never changes policy. |
76+| Models | Routed per step by need, never picked when assigning work. See [Model routing](#model-routing). The definition only sets limits on the routing: a floor, a ceiling, and which providers it may use. |
77+| Budget | A monthly cap, a per-task cap, and an optional daily cap. See [Budgets](#budgets). |
78+| Scopes | Which projects, channels and doc spaces it can read and which it can write. Default: read what it is invited to, write nothing. |
79+| Autonomy | What it may do alone and what needs a person. For example: open pull requests (alone); merge (needs approval); deploy to production (needs approval); publish a doc page (alone in spaces it owns, a suggestion elsewhere). Rulesets and branch protection still apply on top. |
80+| Capacity | How many tasks it works at once (default 3). Beyond that, tasks queue on its desk. |
81+| Triggers | What wakes it without a mention: a schedule, any g1t event, a message in a channel it watches, a webhook, a doc page going stale. |
82+| Skills | Saved procedures it can repeat ("cut a release", "write the weekly update"), made by walking it through once. |
83+| Tools | g1t's MCP tools allowed by its scopes, plus MCP servers the workspace connected. |
84+| Memory | What it has learned. Readable and editable on its profile, with sources. |
85+
86+### Like a colleague
87+
88+Agents are treated like employees, not like settings:
89+
90+- **They belong to teams.** An agent can be added to any team, the same as
91+ a person. It then:
92+ - gets that team's channels and mentions;
93+ - can be requested as a reviewer through the team;
94+ - shows up on the team's page.
95+- **They post updates on their own.**
96+ - When a task changes state (started, opened a pull request, blocked,
97+ done), the agent says so in the thread that asked for it.
98+ - A daily or weekly summary, if you turn it on, goes to the channels it
99+ works for: what it shipped, what it is waiting on, and what it spent.
100+ - Everyone always knows what each agent is doing without asking.
101+- **They have a manager.** Every agent has an owner: the person who
102+ approves its budget and gets its escalations.
103+- **Chat comes first, and issues still work.** You can give an agent work
104+ just by talking to it. Creating an issue and assigning it to the agent
105+ still works the same way, for planned work and for anyone who prefers
106+ it.
107+
108+### Creating one
109+
110+Agents can be created in three ways:
111+
112+- **In chat.** Write something like "make a release manager called Ship that
113+ cuts g1t releases on Tuesdays and asks me before tagging". g1t answers with
114+ a draft card holding every field. You edit the card and confirm.
115+- **From a template**, on the Agents page.
116+- **By committing** `.g1t/agents/ship.md`. g1t offers to adopt it into the
117+ workspace.
118+
119+Once saved, the agent:
120+
121+- introduces itself in the channels it was added to;
122+- opens a DM with the person who created it;
123+- shows up as idle on the Agents page.
124+
125+### Agents mode
126+
127+The sidebar lists:
128+
129+- agents you talk to;
130+- agents working right now, each showing its live tasks;
131+- agents waiting on you;
132+- all agents.
133+
134+An agent's page has five tabs:
135+
136+| Tab | What it shows |
137+| --- | --- |
138+| **Desk** | Every task it holds, as live cards (like Claude Code tabs): steps, files touched, cost so far, what it is waiting on. Open one to read the full transcript, steer it, pause it or take it over. |
139+| **Profile** | The definition, with version history. |
140+| **Memory** | What it remembers, with the source of each fact. Each fact can be pinned, edited or forgotten. |
141+| **Spend** | Spend this month against its budget, by task and by model. |
142+| **Activity** | Everything it did, from the audit log. |
143+
144+## How agents run
145+
146+### Two kinds of turn
147+
148+Most messages to an agent don't need a computer. A reply should take a
149+second and cost a fraction of a cent. Spinning up a sandbox for every
150+message would make chat slow and expensive.
151+
152+- **Replies** run in a Worker with no sandbox. The model gets the thread,
153+ the agent's definition and memory, and g1t's MCP tools within the agent's
154+ scopes:
155+ - read code, issues, pull requests, checks, deploys and docs;
156+ - search context;
157+ - post a message;
158+ - open a task;
159+ - claim something;
160+ - ask another agent.
161+
162+ Questions, summaries, triage, planning and doc reads are all replies.
163+- **Sessions** run in a sandbox, exactly as today's runs do. They are used
164+ for anything that edits a repository, runs code or tests, or works for
165+ longer than a reply. A reply escalates to a session by opening a task.
166+
167+### Sessions persist
168+
169+Today each run is a fresh headless `claude --print`. Sessions become
170+resumable:
171+
172+- Each task has a session: the Claude Code session id, its transcript
173+ (stored in R2), the branch it works on, and its claims.
174+- A sandbox lives only while the session is doing something. When it goes
175+ idle the sandbox stops. The transcript is saved, and the work is pushed
176+ to the task's branch. Idle agents cost nothing.
177+- The next message to that task resumes the session in a new sandbox: the
178+ saved transcript is restored and the run uses `claude --resume <id>`. It
179+ could be a reply in its thread, a review comment, a failed check or an
180+ answer from another agent. The agent picks up with full context, as if
181+ it never left.
182+- Steering keeps today's after-tool-call hook (`crates/runner/src/steer.rs`).
183+ It now reads from the task's thread, not only from the pull request.
184+- A person can open any session and see what Claude Code would show:
185+ - the transcript;
186+ - the diff so far;
187+ - the terminal output.
188+
189+ From there they can send a message, pause it, or take it over. Taking
190+ over hands them the branch and the transcript.
191+
192+### The desk
193+
194+Each agent has one desk: a Durable Object keyed by agent id. Everything
195+addressed to the agent arrives at the desk:
196+
197+- mentions;
198+- DMs;
199+- assignments;
200+- triggers;
201+- messages from other agents.
202+
203+The desk decides what each one is:
204+
205+1. **A question or chat** goes to a reply.
206+2. **About a task it already holds** (same thread, same pull request, or
207+ it says so) steers that session.
208+3. **New work** opens a task. If the desk is at capacity, the task queues
209+ with its position shown to whoever asked.
210+
211+The desk also enforces the agent's capacity, schedules its triggers, and
212+holds the agent's presence (idle, working, waiting on you, out of budget).
213+Workspace-wide concurrency stays the plan's entitlement
214+(`maxConcurrentAgents`), checked as today.
215+
216+### Tasks
217+
218+A task is one job an agent took on. It records:
219+
220+- who asked, and in which thread;
221+- the goal, and what done means;
222+- status: `queued`, `working`, `waiting`, `done` or `cancelled`;
223+- its budget and spend;
224+- its session;
225+- its claims;
226+- what it produced: issues, pull requests, deploys, doc pages, answers.
227+
228+Issues and pull requests stay as they are. A task that touches code ends in
229+pull requests. A task that is planned work opens issues through the
230+planner. A task appears in the issue list only if it produced an issue.
231+Each project gets a Tasks tab for the rest.
232+
233+Assigning an issue to an agent creates a task for it, so every existing
234+entry point keeps working:
235+
236+- assignment;
237+- mentions in pull requests;
238+- label rules;
239+- the planner.
240+
241+## Coordination
242+
243+Several agents working at once on the same codebase, docs and deploys will
244+collide unless the system prevents it. Today's protection is a list of
245+in-flight pull requests in the prompt (`describeInFlight`). It becomes a
246+real protocol.
247+
248+### Claims
249+
250+Before acting, a task claims what it will touch. Claims are held by a
251+coordinator: one Durable Object per workspace, a single writer so that
252+grants are atomic. They are shown on the task card.
253+
254+| Claim | Kind | Example |
255+| --- | --- | --- |
256+| An issue | Exclusive | Only one task works #412. |
257+| A branch | Exclusive | A task's own branch, always. |
258+| An environment | Exclusive | Production deploys of `flagon-io/g1t`. |
259+| A doc section | Exclusive while editing | "Runbook › Rollback". |
260+| Paths in a repository | Shared, with overlap detection | `crates/git/**`. Declared from the plan, then widened automatically to the files the diff actually touches. |
261+
262+Rules:
263+
264+- **Exclusive claims** that are already held return the holder. The task
265+ either waits (it subscribes and resumes when the claim is released) or
266+ asks the holder.
267+- **Overlapping path claims** don't block. Both tasks are told who else is
268+ in those paths, and the later one must say how it will avoid the
269+ conflict. It can sequence after the other, split the work differently, or
270+ agree a boundary with the other agent in a thread. The merge queue stays
271+ the final referee.
272+- **Claims are leases.** They expire if the session dies, and are renewed
273+ while the task is alive. A person can break any claim.
274+- **People claim too.** Assigning yourself an issue or opening a pull
275+ request counts, so agents route around humans as well as each other.
276+
277+### Talking to each other
278+
279+Agents message each other through the existing `agent_messages` exchange
280+(message, question, handoff). It is widened from pull request numbers to
281+task addresses, and it always appears in a visible thread:
282+
283+- the requesting task's thread when there is one;
284+- otherwise the project's channel;
285+- otherwise a workspace `#agents` channel.
286+
287+Agents also get two new kinds:
288+
289+- **review**: "look at my change before I ask a human";
290+- **claim request**: "can I have the deploy lock after you?".
291+
292+Safety rails:
293+
294+- **Hop limit.** An agent-to-agent chain started by one human request
295+ stops after a set number of hops (default 6) and asks a person.
296+- **Addressed only.** Agents answer other agents only when addressed or
297+ mentioned, never because a message appeared in a channel they watch.
298+- **Rate limit.** An agent posts at most a set number of messages per
299+ thread per minute without a person in the loop.
300+- **Shared budget.** Work done for another agent's task is charged to the
301+ task that asked for it, so a chain can't escape its budget.
302+
303+### A lead, when you want one
304+
305+Any agent can be made the lead of a project or channel. A lead receives
306+new work there first, splits it into tasks, hands them to the right agents,
307+and reports progress in one place. The built-in planner is a lead template.
308+Without a lead, the agent that was asked owns the work and hands off
309+pieces itself.
310+
311+## Docs
312+
313+### What it is
314+
315+Docs is the workspace's knowledge base:
316+
317+- **spaces** (one per team or project, plus a workspace space);
318+- holding a tree of **pages**;
319+- edited together in real time, with mentions of people, agents, issues,
320+ pull requests, channels and other pages.
321+
322+Every page has history, comments, backlinks and owners.
323+
324+### Agents and docs
325+
326+This is where Docs earns its place:
327+
328+- **Agents read it.** Pages are indexed by `services/context`, so the
329+ knowledge reaches every reply, session and plan. A space can be pinned to
330+ an agent as required reading.
331+- **Agents write it.** An agent with write access to a space edits pages
332+ directly. Without it, the edit becomes a **suggestion**: tracked changes
333+ a person accepts or rejects inline, the same review loop as a pull
334+ request. Every edit is attributed and in the page history.
335+- **Pages know what they describe.** A page can cite code: paths, symbols,
336+ endpoints, environment variables. When a merged pull request changes
337+ something a page cites, the page is marked possibly stale and its owners
338+ are notified. If an agent owns the page, it drafts the update.
339+- **Conversations become pages.** "Write this up" in a thread makes a page
340+ from the thread, linked both ways. Decisions made in chat get a home.
341+- **A documenter agent** (a template) keeps a space current. It updates
342+ pages after merges, writes release notes and the weekly summary, and
343+ turns incident threads into postmortems.
344+
345+### Docs and repository docs
346+
347+Repository docs (README, `docs/`) stay in the repository and change through
348+pull requests. Docs mode can show a project's `docs/` folder as a read-only
349+space next to the workspace's own spaces. One search and one tree cover
350+both. Editing a repository page from Docs opens a pull request.
351+
352+### How it is stored
353+
354+- **Live editing.** Each open page is a Durable Object holding a CRDT
355+ (Yjs), reached over a WebSocket.
356+- **Durable storage.** On idle, the page is saved as Markdown. Each space
357+ is a git repository in g1t's own git storage, so:
358+ - history, blame and export come free;
359+ - agents can work on a space with the same tools they use on code;
360+ - self-hosted installs keep everything in git.
361+- **Metadata** lives in `services/docs`' D1: tree, owners, permissions,
362+ citations, staleness, comments.
363+
364+## Chat
365+
366+Channels, direct messages, threads, reactions, read state and the
367+scaled sidebar follow `docs/CHANNELS.md` (data model in its "Data"
368+section). The additions:
369+
370+- **Cards.** Everything that happens is a card in the right channels, and
371+ its actions work in place:
372+ - tasks, with live status;
373+ - pull requests, checks, deploys and incidents;
374+ - doc changes;
375+ - approvals;
376+ - claims, for example "@ship is waiting on the deploy lock held by
377+ @oncall".
378+- **One timeline.** Replying in a thread about an issue or pull request is
379+ commenting on it, so the conversation and the record stay one thing.
380+- **Channels link to projects and doc spaces.** A linked channel gets those
381+ events, and Code and Docs show it in the side dock (as in the mockup).
382+- **Search** covers messages, pages and tasks the viewer can see. This
383+ joins the site-wide search, separate from context search.
384+
385+## Budgets
386+
387+Spend is limited at four levels. Each level is checked before work starts
388+(reserve) and enforced while it runs (settle). These are the same
389+mechanisms as today (`docs/SPEND-GUARDRAILS.md`), widened:
390+
391+1. **Workspace.** The owner's spend limit and the AI credit wallet. A hard
392+ stop.
393+2. **Agent.** Monthly, and optionally daily, caps on the agent definition.
394+ At 80% the agent tells the channel that pays for it. At 100% it stops
395+ taking new tasks and finishes nothing past its per-task reserve.
396+3. **Task.** A cap per task, defaulting from the agent. The card shows
397+ spend against the cap live. Going over is an approval card, not a
398+ silent overrun.
399+4. **Session.** Today's per-run caps and guardrails: minutes, tokens,
400+ network.
401+
402+Replies are metered as Agent tokens at the same rates as runs. An idle
403+agent costs nothing. Usage and the agent's Spend tab break cost down by
404+agent, task and model.
405+
406+## The whole company
407+
408+Chat is for everyone in the company: support, sales, finance, design and
409+leadership as well as engineers. Most of them never change code, and
410+agents must not become a way around that. They still get the full value:
411+they can ask questions, look things up, hand over customer data, and have
412+their requests reach the right team.
413+
414+### Members without Code
415+
416+Every workspace member gets Chat, Docs, Agents and the Inbox. Code is a
417+per-member switch: **Code access**, on by default, which owners turn off
418+for people who don't work on code.
419+
420+A member without Code access:
421+
422+- **Sees a rail without Code.** Home shows their channels, DMs, docs and
423+ inbox, not projects.
424+- **Has no repository access at all.** No repository, issue, pull request,
425+ check or deploy page is open to them. The workspace's base permission and
426+ team repository grants don't apply. Turning Code access back on restores
427+ what their teams and roles give them.
428+- **Still sees work reach them in chat.** Cards about issues, pull requests
429+ and deploys appear in the channels they're in as summaries: title, state
430+ and who is on it. Opening one asks for Code access instead of showing the
431+ page.
432+- **Can ask agents anything about the product.** Agents explain how things
433+ work and what changed, but never show them source code. Owners can tighten
434+ this so agents only answer from Docs.
435+- **Doesn't count against anything.** There are no seats, so adding the
436+ whole company costs nothing until they use agents.
437+
438+This is stored as `code_access` on the membership (identity service). Every
439+service that authorizes a repository checks it, and the site hides Code
440+when it is off.
441+
442+### The asker's access caps the agent
443+
444+An agent never does more for someone than that person could do themselves.
445+
446+- **What it does.** An agent acts with the *intersection* of its own scopes
447+ and the access of the person asking.
448+ - Someone without write access to a repository can't get an agent to
449+ change it, whatever the agent's own scopes are.
450+ - A task always records who asked. Approvals go to people who hold the
451+ access the task needs.
452+- **What it says.** An agent answers only with what everyone who can read
453+ the reply can see.
454+ - In a DM, that is the asker's access.
455+ - In a channel, it is the access of the channel's audience: everyone in
456+ the channel, or the whole workspace for a public channel.
457+ - A private repository, doc space or channel never leaks into a place
458+ with a wider audience. When it can't answer here, the agent says so
459+ and offers to answer in a DM.
460+- **No new roles to manage.** This falls out of the access people already
461+ have:
462+ - workspace roles;
463+ - repository roles;
464+ - team membership;
465+ - doc space permissions.
466+
467+ A support lead with read access to the product repository can ask "how
468+ does proration work?" and get an answer grounded in the code. They can't
469+ get it changed.
470+
471+### Requests become intake, not changes
472+
473+When someone who can't change the code asks for a change, the agent
474+doesn't refuse and doesn't do it. It turns the request into intake:
475+
476+1. It drafts a request: a bug or a feature request, in the asker's words,
477+ with the agent's understanding and links to the conversation.
478+2. It routes the request to the team that owns the area. The owner comes
479+ from code owners, the project's team, or the workspace's intake
480+ settings.
481+3. It tells the asker where the request went.
482+4. It tells them again when the request is triaged, scheduled and shipped:
483+ "the export fix you asked for is live".
484+
485+People with write access can still say "just do it" and get a task.
486+
487+### Agents that listen
488+
489+A channel can let agents listen. This is off by default and shown in the
490+channel header. A listening agent watches for:
491+
492+- complaints;
493+- bug reports;
494+- feature requests;
495+- questions nobody answered.
496+
497+It doesn't reply to every message. It groups related messages ("three
498+customers hit the CSV export timeout this week") and files or updates one
499+intake item linked to every source message. It answers only when it's
500+asked, or when it can close a loop: "this was fixed yesterday in #418".
501+
502+Typical setups:
503+
504+- a triage agent listening in `#support`, `#sales` and `#feedback`;
505+- an on-call agent in `#incidents`;
506+- a docs agent that notices the same question asked twice and writes the
507+ page.
508+
509+### Files and customer data
510+
511+People upload whatever their work needs: spreadsheets, contracts, exports,
512+screenshots. Agents work with these, under rules the workspace sets:
513+
514+- **Classification.** Every file has a level:
515+ - Public;
516+ - Internal (the default);
517+ - Confidential;
518+ - Customer data.
519+
520+ The uploader sets the level. An agent may suggest raising it when it
521+ sees personal data.
522+- **Which models may see it.** Each level lists the providers allowed to
523+ process it. For example, customer data may go only to the workspace's
524+ own provider with zero retention. An agent that may not send a file to
525+ any allowed model says so; it never quietly skips the file.
526+- **Memory.** Agents never write customer data into their memory, and
527+ never put it into issues, pull requests or channels with a wider
528+ audience than the file's.
529+- **Retention.** Each level has a retention period, and deletions are
530+ final.
531+- **Audit.** Every time an agent reads a Confidential or customer-data
532+ file, the audit log records it with the task and the person who asked.
533+
534+### Not just code
535+
536+Agents work across the company's tools, not only the repository, through
537+MCP connectors the workspace adds (a CRM, a help desk, a data warehouse).
538+The same rules apply:
539+
540+- the asker's access caps the agent;
541+- the channel's audience caps what it says;
542+- classification decides which models see the data.
543+
544+## Working from another chat app
545+
546+Some companies will keep their existing chat app for the whole
547+organization. Often only the engineers use g1t, or nobody does at first.
548+That has to be a good experience, not a punishment. The agents are the same
549+agents wherever you talk to them, and the work lands in the same place.
550+g1t earns the move over time by being better, never by making the
551+integration worse.
552+
553+User-facing text calls this "the chat app integration" and, on its
554+integration page, by the app's own name. Marketing never compares the two.
555+
556+### One agent, many places
557+
558+Where a conversation happens is just a *surface*. Each surface has an
559+adapter in `services/integrations`:
560+
561+- g1t Chat;
562+- a connected chat app;
563+- later, email.
564+
565+Everything else is shared, whichever surface a message arrived on:
566+
567+- the agent;
568+- its memory;
569+- its tasks;
570+- its budget;
571+- its claims;
572+- the audit log.
573+
574+- **Every external conversation has a home in g1t.** When an agent is used
575+ in an external channel or DM, g1t keeps a linked conversation: the
576+ messages the agent was given or posted, with permalinks back. Tasks,
577+ issues and pull requests link to it like any thread. "Why was this
578+ changed?" leads back to the external thread. The agent's memory learns
579+ from it the same way.
580+- **A task started in one place can be followed from either.** A task
581+ started in the external app posts its updates there. Its live card,
582+ session and diff are one click away in g1t. Steering works from both:
583+ - a reply in the external thread;
584+ - a message on the task in g1t.
585+- **Approvals settle everywhere.** An approval is a button in the external
586+ message, a card in g1t and an item in the inbox. Acting in any one
587+ settles all three.
588+
589+### The app in their chat
590+
591+The workspace installs one app into its external chat workspace from
592+Integrations. The app's name is g1t.
593+
594+- **Each agent speaks as itself.** Messages are posted with the agent's
595+ name and avatar.
596+ - Mention the app and name the agent: "@g1t ask @reviewer to look at
597+ #418".
598+ - Or use a shortcut per agent: `/g1t reviewer …`.
599+ - In the app's DM, a picker chooses which agent you are talking to.
600+- **Invite it to a channel** to let agents answer there when mentioned.
601+ Turn on listening to let a triage agent group feedback, the same as in
602+ g1t (see [Agents that listen](#agents-that-listen)).
603+- **Cards render natively** in the external app: tasks, pull requests,
604+ checks, deploys and approvals, with buttons. "Open in g1t" goes to the
605+ full view.
606+- **Bridged channels** (optional). Link an external channel to a g1t
607+ channel and the two mirror each other: messages, threads, edits and
608+ reactions. Engineers stay in g1t while the rest of the company stays
609+ where it is, in one conversation. Each message shows where it came from.
610+
611+### Who is asking
612+
613+The rules from [The whole company](#the-whole-company) apply unchanged.
614+They depend on knowing who the person is.
615+
616+- **Linked people.** The first time someone talks to an agent from the
617+ external app, the agent asks them to link their account: one click to
618+ sign in to g1t. From then on they act with their own g1t access.
619+- **Unlinked people** are treated as members without Code access:
620+ - they can ask questions and get answers from Docs;
621+ - their change requests become intake;
622+ - they never get code changed, or see code an agent wouldn't show
623+ them.
624+
625+ Owners can require linking before an agent answers at all.
626+- **Audience.** In an external channel, the audience is everyone in that
627+ channel. Agents answer there with only what all of its linked members
628+ can see, and treat unlinked members as having no Code access. Private
629+ g1t content stays out of external channels unless an owner allows it
630+ for that channel.
631+- **Data.** Messages from the external app are stored only as part of
632+ linked conversations, under the workspace's retention and classification
633+ rules. Files shared there follow the same model-routing rules as
634+ uploads.
635+
636+### Why people move over anyway
637+
638+The external app gets the agents, the answers and the approvals. g1t
639+keeps what only it can do:
640+
641+- live task cards with sessions and diffs you can steer;
642+- the one timeline where replying is commenting on a pull request;
643+- Docs side by side with the conversation;
644+- presence that shows what every agent is doing;
645+- no limit on history or seats.
646+
647+These are pointed to in context with "Open in g1t", never with nags.
648+
649+### Build
650+
651+The adapter interface comes with the agents service now. Replies read a
652+conversation and post through a surface port, so the external app is one
653+more adapter later, not a rewrite.
654+
655+The app itself ships after Chat and tasks, in this order:
656+
657+1. install;
658+2. agents speaking as themselves;
659+3. account linking;
660+4. linked conversations;
661+5. approvals;
662+6. bridged channels;
663+7. listening.
664+
665+## Model routing
666+
667+Nobody picks a model to get work done. g1t routes each step of an agent's
668+work to the tier it needs, using today's `AGENT_ROUTING`:
669+
670+| Step | Tier |
671+| --- | --- |
672+| Chat replies, triage | small |
673+| Implementation, routine review | large |
674+| Planning, hard reviews, retries after a failure | frontier |
675+
676+As today, routing steps up after failures and steps back down when the
677+cheaper tier worked.
678+
679+The agent definition limits the routing; it does not replace it:
680+
681+- **Floor.** For example, "never below large" for a reviewer that must be
682+ careful.
683+- **Ceiling.** For example, "never frontier" for a cheap triage agent.
684+- **Providers.** Which models it may use: g1t's hosted models, the
685+ workspace's own providers from Integrations (Anthropic, OpenAI,
686+ compatible endpoints), or both. When the workspace has its own providers,
687+ each tier maps to a provider and model in the workspace's routing
688+ settings. An agent restricted to the workspace's own keys never touches
689+ g1t's.
690+- **Pinned model.** An advanced escape hatch for own endpoints. Not shown
691+ by default.
692+
693+Every step records the model that ran. It is shown in the session and on
694+the agent's Spend tab, so routing is visible without anyone having to
695+choose.
696+
697+## Pricing
698+
699+This follows the standing pricing rule: measured cost plus a modest,
700+uniform overhead, and no seats.
701+
702+| What | How it is charged |
703+| --- | --- |
704+| People chatting: messages, threads, reactions, read state, live delivery | Included on every plan, the free plan too. It costs very little (a message is a few row writes and one Durable Object request; idle sockets hibernate). History is never cut off. It is still metered raw, so the daily reconciliation sees the real cost. |
705+| Docs: pages, editing, history | Included on every plan, like chat. |
706+| An agent replying in chat | Agent tokens from the AI credit wallet: provider price plus the g1t agent rate. Charged to that agent's budget and, inside a task, to the task. |
707+| An agent's sessions | Agent tokens as above, plus sandbox time at cost +20%. |
708+| An agent's model calls on the workspace's own provider | The provider bills the workspace directly. g1t charges the agent rate only. |
709+| Files in chat and docs | The storage meter (served from g1tusercontent.com), at cost +20%. |
710+| Calls and huddles (later) | Media relay at cost +20%. |
711+| Idle agents | Nothing. |
712+
713+Free workspaces get chat and docs with protective caps: a file storage
714+cap, and no agents until the workspace buys AI credit. That keeps the free
715+plan free of compute.
716+
717+## Rails, summarized
718+
719+| Concern | Decided by |
720+| --- | --- |
721+| What an agent can see | Its scopes, and the channels and spaces it was invited to. An invite grants read, never write. |
722+| What it can change | Its scopes, then rulesets, branch protection and environment rules. |
723+| When it must ask | Its autonomy settings plus policy. Approvals are cards in the thread and items in the inbox; acting in either settles both. |
724+| What it can spend | Workspace, then agent, then task, then session. |
725+| Who did what | The audit log. Every action records the agent, its definition version, the task, and the person who asked. |
726+| Not colliding | Claims, the coordinator and the merge queue. |
727+| Not looping | Hop limits, addressed-only replies, rate limits, shared budgets. |
728+
729+## Shell
730+
731+A rail on the left, as in the mockups: Home, Code, Chat, Docs, Agents,
732+Inbox, then the account.
733+
734+- Each mode has its own sidebar, and no mode's sidebar lists another mode's
735+ things.
736+- Code keeps today's sidebar.
737+- A side dock shows the current project's or page's linked channels in Code
738+ and Docs, and the linked project and docs in Chat.
739+
740+Concretely:
741+
742+- `shell.tsx` gains a `mode` above today's drill-down stack;
743+- `workspace-nav.ts` gains a `ModeKey`;
744+- each mode keeps its own `SidebarKey`s.
745+
746+## Services
747+
748+Following the architecture principles: separate services, interfaces in
749+`packages/contracts` and `crates/contracts`, side effects through
750+`services/events`.
751+
752+| Service | Owns |
753+| --- | --- |
754+| `services/chat` (new, TS) | Channels, members, messages, threads, reactions, read state; one Durable Object per channel for live delivery with WebSocket hibernation. |
755+| `services/agents` (new, TS) | Agent definitions and versions, the desk Durable Object per agent, the coordinator Durable Object per workspace (claims), replies (the no-sandbox model loop over g1t MCP). |
756+| `services/docs` (new, TS) | Spaces, pages, the page Durable Object (CRDT), suggestions, comments, citations and staleness, git-backed storage. |
757+| `services/work` | Tasks and task links beside `agent_runs` (which gains `task_id`); `agent_messages` widened to task addresses. |
758+| `services/runner` | Resumable sessions: transcript save and restore in R2, `--resume`, the steer hook reading task threads, claims checked at start and widened from diffs. |
759+| `services/context` | Indexes doc pages and channel decisions; serves them to replies and sessions. |
760+| `services/events` | New event types (`chat.message.created`, `task.*`, `claim.*`, `doc.page.*`); inbox reasons for mentions, approvals, suggestions, stale pages. |
761+| `services/billing` | Agent-level budgets in the reserve/settle contract; Agent-token metering for replies. |
762+
763+Every Cloudflare primitive used here stays behind an adapter, so
764+self-hosting keeps working:
765+
766+- Durable Objects;
767+- WebSockets;
768+- R2;
769+- D1.
770+
771+## Build order
772+
773+Each step ships something usable.
774+
775+1. **Contracts.** Agent definition and version, task, claim, channel,
776+ message, card, page; RPC methods; event types.
777+2. **Agents as members.** `services/agents` with definitions, templates
778+ (planner, implementer, reviewer, triage, documenter) and the Agents mode
779+ list and profile. Assigning an issue to a workspace agent works through
780+ today's runner, as a task.
781+3. **Shell.** The rail and modes; Chat, Docs and Agents sidebars (empty
782+ states where needed); the dock.
783+4. **Channels.** `services/chat`, live delivery, threads, reactions, read
784+ state, mentions into the inbox.
785+5. **Talk to an agent.** The desk; replies with no sandbox; DMs and
786+ mentions; personality applied.
787+6. **Tasks and resumable sessions.** Tasks from chat; live task cards;
788+ sessions saved and resumed; steering from the thread; opening a session
789+ and taking it over; capacity and the queue.
790+7. **Budgets and approvals.** Agent and task budgets in billing; approval
791+ cards that settle with the inbox.
792+8. **Coordination.** The coordinator; claims on issues, branches,
793+ environments and paths; overlap negotiation in threads; widened
794+ `agent_messages`; hop and rate limits; leads.
795+9. **Docs.** Spaces, pages, live editing, history, comments, mentions;
796+ indexed by context; agents reading.
797+10. **Agents in Docs.** Suggestions, citations and staleness, "write this
798+ up", the documenter template, repository docs as spaces.
799+11. **Create in chat, skills and triggers.** The draft-card flow; skills
800+ from a walkthrough; schedules, events, watched channels and webhooks as
801+ triggers.
802+12. **Scale and reach.**
803+ - Team sections, browse, muting, and search across messages, pages and
804+ tasks.
805+ - Installable desktop and mobile apps.
806+ - Bridges for teams that keep another chat tool: one app per workspace,
807+ a handle per agent.
808+
809+Steps 2, 5 and 6 are the turning point: from then on, talking to an agent
810+is the everyday way work starts. Step 8 lets a workspace run many agents at
811+once safely. Step 10 is where Docs stops being a wiki and becomes the
812+thing that keeps itself true.
813+
814+## Decisions to confirm
815+
816+- **Model choice on the agent.** Per-agent preferred models, set when the
817+ agent is defined, with Auto as the default. Choosing a model when
818+ assigning work stays out.
819+- **Replies without a sandbox.** Chat answers come from a Worker-side model
820+ loop, not a container. Recommended for speed and cost.
821+- **Docs stored in git.** Each space is a git repository, and live editing
822+ is a CRDT saved to it.
823+- **Agent edits to docs** are suggestions unless the agent has write access
824+ to that space. Recommended default.
825+- **Default capacity** of 3 concurrent tasks per agent, and a hop limit
826+ of 6.
+24−0
16361636 "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
16371637 "license": "MIT"
16381638 },
1639+ "node_modules/@g1t/agents": {
1640+ "resolved": "services/agents",
1641+ "link": true
1642+ },
1643+ "node_modules/@g1t/chat": {
1644+ "resolved": "services/chat",
1645+ "link": true
1646+ },
16391647 "node_modules/@g1t/context": {
16401648 "resolved": "services/context",
16411649 "link": true
1111011118 "version": "0.1.0",
1111111119 "license": "MIT"
1111211120 },
11121+ "services/agents": {
11122+ "name": "@g1t/agents",
11123+ "version": "0.1.0",
11124+ "license": "MIT",
11125+ "dependencies": {
11126+ "@g1t/contracts": "*"
11127+ }
11128+ },
11129+ "services/chat": {
11130+ "name": "@g1t/chat",
11131+ "version": "0.1.0",
11132+ "license": "MIT",
11133+ "dependencies": {
11134+ "@g1t/contracts": "*"
11135+ }
11136+ },
1111311137 "services/context": {
1111411138 "name": "@g1t/context",
1111511139 "version": "0.1.0",
+301−0
1+/**
2+ * Chat: channels, direct messages, threads and messages, kept by the chat
3+ * service (`services/chat`). People and agents are members alike. Plan:
4+ * docs/WORKSPACE.md.
5+ *
6+ * Wire shapes are snake_case end to end, so the site, the public API and
7+ * the live socket all carry the same objects.
8+ */
9+import type { ServiceBinding } from "./clients";
10+import type { User } from "./identity";
11+import type { Result } from "./result";
12+import type { AskerAccess } from "./workspace-agents";
13+
14+/** Who is speaking: a person (by user id) or a workspace agent (by agent id). */
15+export type Principal = { kind: "user" | "agent"; id: string };
16+
17+export function principalKey(principal: Principal): string {
18+ return `${principal.kind}:${principal.id}`;
19+}
20+
21+export function parsePrincipalKey(key: string): Principal | null {
22+ const at = key.indexOf(":");
23+ if (at < 0) return null;
24+ const kind = key.slice(0, at);
25+ const id = key.slice(at + 1);
26+ if ((kind !== "user" && kind !== "agent") || !id) return null;
27+ return { kind, id };
28+}
29+
30+/** How a member shows: resolved by the chat service when it answers. */
31+export type MemberProfile = Principal & {
32+ /** `username` for a person, `handle` for an agent. */
33+ name: string;
34+ display_name: string;
35+ /** Uploaded avatar hash for a person, or null for the letter avatar. */
36+ avatar: string | null;
37+ /** An agent's one-line role ("Release manager for g1t"). */
38+ role: string | null;
39+};
40+
41+export type ChannelKind = "channel" | "dm";
42+
43+export type Channel = {
44+ id: string;
45+ workspace_id: string;
46+ kind: ChannelKind;
47+ /** Lowercase, no `#`. Null for a direct message. */
48+ name: string | null;
49+ topic: string | null;
50+ private: boolean;
51+ created_by: Principal;
52+ created_at: string;
53+ archived_at: string | null;
54+ last_message_at: string | null;
55+};
56+
57+export type ChannelMember = {
58+ channel_id: string;
59+ member: MemberProfile;
60+ role: "owner" | "member";
61+ starred: boolean;
62+ muted: boolean;
63+ last_read_id: string | null;
64+ joined_at: string;
65+};
66+
67+/** A card g1t or an agent posts: an event, a task, an approval. */
68+export type MessageCard = {
69+ /** What it is about, e.g. `pull`, `issue`, `task`, `deploy`, `approval`. */
70+ kind: string;
71+ title: string;
72+ /** A short line under the title: "3/3 checks · +214 −87". */
73+ detail: string | null;
74+ /** A status shown on the right: "Needs approval", "Merged". */
75+ state: string | null;
76+ /** Where clicking the card goes, relative to the site. */
77+ href: string | null;
78+};
79+
80+export type ChatMessage = {
81+ /** Time-sortable (ULID-like), so ordering by id is ordering by time. */
82+ id: string;
83+ channel_id: string;
84+ author: MemberProfile;
85+ kind: "text" | "card";
86+ body: string;
87+ card: MessageCard | null;
88+ /** The message this replies under, or null for a top-level message. */
89+ thread_root: string | null;
90+ reply_count: number;
91+ last_reply_at: string | null;
92+ created_at: string;
93+ edited_at: string | null;
94+ deleted_at: string | null;
95+};
96+
97+/** One row of the Chat sidebar. */
98+export type ChatSidebarEntry = {
99+ channel: Channel;
100+ /** "g1t-core", or the other members' names for a direct message. */
101+ title: string;
102+ /** For a direct message: who else is in it (up to four). */
103+ others: MemberProfile[];
104+ starred: boolean;
105+ muted: boolean;
106+ unread: number;
107+ mentions: number;
108+};
109+
110+export type ChatSidebar = {
111+ entries: ChatSidebarEntry[];
112+ /** Public channels in the workspace the viewer has not joined. */
113+ browsable: number;
114+};
115+
116+export type MessagePage = {
117+ messages: ChatMessage[];
118+ /** Pass as `before` to read further back; null at the beginning. */
119+ older: string | null;
120+ /**
121+ * Set when the page was read with `after`: pass it as `after` again for
122+ * the next messages, or null when this page reached the newest.
123+ */
124+ newer?: string | null;
125+};
126+
127+export type NewChannel = { name: string; topic?: string | null; private?: boolean };
128+
129+export type PostMessage = { body: string; thread_root?: string | null };
130+
131+/** The most agent-to-agent hops one person's request may start (docs/WORKSPACE.md, "Hop limit"). */
132+export const CHAT_MAX_HOPS = 6;
133+
134+/**
135+ * What an agent posts. When it answers a delivery, it passes that
136+ * delivery's `hops`, `asked_by` and `asker` back, so another agent it @mentions is
137+ * handed the message one hop further along the same person's request, and
138+ * the chain stops at `CHAT_MAX_HOPS`. Left out: a new chain (hops 0) asked
139+ * by the person who created the agent.
140+ */
141+export type AgentPostMessage = PostMessage & {
142+ card?: MessageCard | null;
143+ hops?: number;
144+ asked_by?: string | null;
145+ /** The delivery's `asker`, handed on to agents this message wakes. Absent: none (they treat the asker as unable to change code). */
146+ asker?: AskerAccess | null;
147+};
148+
149+/**
150+ * What the live socket sends. The site opens
151+ * `wss://<site>/<workspace>/chat/live?channel=<id>`; the site checks the
152+ * session and forwards the upgrade to the chat service with the viewer.
153+ */
154+export type ChatLiveEvent =
155+ | { type: "message.created"; message: ChatMessage }
156+ | { type: "message.updated"; message: ChatMessage }
157+ | { type: "message.deleted"; channel_id: string; id: string }
158+ | { type: "typing"; channel_id: string; member: MemberProfile; until: string }
159+ | { type: "read"; channel_id: string; principal: Principal; last_read_id: string };
160+
161+/**
162+ * Header the site sets on a forwarded live socket: the viewer, as JSON.
163+ * The site forwards the upgrade to the chat service's
164+ * `GET /live?workspace=<slug>&channel=<id>` (`workspace` may be left out,
165+ * at the cost of looking up each of the viewer's workspaces).
166+ */
167+export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer";
168+
169+export type ChatApi = {
170+ sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>;
171+ channel(
172+ workspace: string,
173+ channelId: string,
174+ viewer: User,
175+ ): Promise<Result<{ channel: Channel; members: ChannelMember[] }>>;
176+ /** The same, found by its name in the workspace (`#general` or `general`), as the site's URLs name channels. */
177+ channelByName(
178+ workspace: string,
179+ name: string,
180+ viewer: User,
181+ ): Promise<Result<{ channel: Channel; members: ChannelMember[] }>>;
182+ /** Public channels, for Browse channels. */
183+ browse(workspace: string, viewer: User): Promise<Result<Channel[]>>;
184+ createChannel(workspace: string, viewer: User, input: NewChannel): Promise<Result<Channel>>;
185+ /**
186+ * The direct message between the viewer and these members, created on
187+ * first use. The same set of members always gets the same channel.
188+ */
189+ openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>;
190+ join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
191+ leave(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
192+ /** Adds a person or an agent. An invite grants read, never write. */
193+ invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>;
194+ /**
195+ * Newest first. Without `thread_root`, the channel's top-level messages;
196+ * with it, that thread's replies, plus the message they reply to as the
197+ * oldest once the page reaches the start of the thread (`older` null).
198+ * With `after` (catching up after a reconnect): the messages after that
199+ * id instead, oldest first, deleted ones included so the client can
200+ * drop them; `newer` says whether there are more.
201+ * Limit 50 by default, 200 at most.
202+ */
203+ messages(
204+ workspace: string,
205+ channelId: string,
206+ viewer: User,
207+ page?: { before?: string | null; after?: string | null; limit?: number; thread_root?: string | null },
208+ ): Promise<Result<MessagePage>>;
209+ post(workspace: string, channelId: string, viewer: User, message: PostMessage): Promise<Result<ChatMessage>>;
210+ edit(workspace: string, channelId: string, viewer: User, id: string, body: string): Promise<Result<ChatMessage>>;
211+ remove(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
212+ markRead(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
213+ setPreferences(
214+ workspace: string,
215+ channelId: string,
216+ viewer: User,
217+ prefs: { starred?: boolean; muted?: boolean },
218+ ): Promise<Result<null>>;
219+ /**
220+ * Posts as an agent. Only the agents service calls this, for replies and
221+ * cards; the agent must be a member of the channel.
222+ */
223+ postAsAgent(
224+ workspace: string,
225+ channelId: string,
226+ agentId: string,
227+ message: AgentPostMessage,
228+ ): Promise<Result<ChatMessage>>;
229+ /** Shows "is typing" for an agent while it works on a reply. */
230+ agentTyping(workspace: string, channelId: string, agentId: string): Promise<Result<null>>;
231+ /**
232+ * What an agent reads before it replies, oldest first: with
233+ * `thread_root`, that thread (its root, then its replies); without, the
234+ * channel's or direct message's latest top-level messages. Only the
235+ * agents service calls this; the agent must be a member of the channel,
236+ * so it reads only what was said where it was invited. `limit` defaults
237+ * to 30, at most 100.
238+ */
239+ historyForAgent(
240+ workspace: string,
241+ channelId: string,
242+ agentId: string,
243+ page?: { thread_root?: string | null; limit?: number },
244+ ): Promise<Result<ChatMessage[]>>;
245+};
246+
247+async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
248+ const response = await service.fetch(`https://service/rpc/${method}`, {
249+ method: "POST",
250+ headers: { "content-type": "application/json" },
251+ body: JSON.stringify(args),
252+ });
253+ if (!response.ok) {
254+ throw new Error(`${method} failed with status ${response.status}`);
255+ }
256+ return (await response.json()) as T;
257+}
258+
259+export function chatClient(service: ServiceBinding): ChatApi {
260+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
261+ return {
262+ sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }),
263+ channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }),
264+ channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }),
265+ browse: (workspace, viewer) => call("browse", { workspace, viewer }),
266+ createChannel: (workspace, viewer, input) => call("create_channel", { workspace, viewer, input }),
267+ openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }),
268+ join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }),
269+ leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }),
270+ invite: (workspace, channelId, viewer, member) =>
271+ call("invite", { workspace, channel_id: channelId, viewer, member }),
272+ messages: (workspace, channelId, viewer, page) =>
273+ call("messages", {
274+ workspace,
275+ channel_id: channelId,
276+ viewer,
277+ before: page?.before ?? null,
278+ after: page?.after ?? null,
279+ limit: page?.limit ?? null,
280+ thread_root: page?.thread_root ?? null,
281+ }),
282+ post: (workspace, channelId, viewer, message) => call("post", { workspace, channel_id: channelId, viewer, message }),
283+ edit: (workspace, channelId, viewer, id, body) => call("edit", { workspace, channel_id: channelId, viewer, id, body }),
284+ remove: (workspace, channelId, viewer, id) => call("remove", { workspace, channel_id: channelId, viewer, id }),
285+ markRead: (workspace, channelId, viewer, id) => call("mark_read", { workspace, channel_id: channelId, viewer, id }),
286+ setPreferences: (workspace, channelId, viewer, prefs) =>
287+ call("set_preferences", { workspace, channel_id: channelId, viewer, prefs }),
288+ postAsAgent: (workspace, channelId, agentId, message) =>
289+ call("post_as_agent", { workspace, channel_id: channelId, agent_id: agentId, message }),
290+ agentTyping: (workspace, channelId, agentId) =>
291+ call("agent_typing", { workspace, channel_id: channelId, agent_id: agentId }),
292+ historyForAgent: (workspace, channelId, agentId, page) =>
293+ call("history_for_agent", {
294+ workspace,
295+ channel_id: channelId,
296+ agent_id: agentId,
297+ thread_root: page?.thread_root ?? null,
298+ limit: page?.limit ?? null,
299+ }),
300+ };
301+}
+13−0
9797 org_roles?: OrgRole[];
9898 /** What the workspace lets its members do. Absent means the defaults. */
9999 privileges?: MemberPrivileges;
100+ /**
101+ * Whether this member uses Code: repositories, issues, pull requests,
102+ * checks, deploys. False for people who only use Chat, Docs and agents
103+ * (support, sales, finance): they see no repository, whatever the base
104+ * permission, and agents treat them as unable to change code. Absent
105+ * means true.
106+ */
107+ code_access?: boolean;
100108 };
101109
110+/** Whether a member uses Code. See `Membership.code_access`. */
111+export function hasCodeAccess(membership: Pick<Membership, "code_access"> | null | undefined): boolean {
112+ return membership?.code_access !== false;
113+}
114+
102115 /** How long an old workspace slug redirects, and stays reserved for it, after a rename. */
103116 export const SLUG_HOLD_DAYS = 90;
104117
+1−1
11 const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
22
3−export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst";
3+export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp";
44
55 let lastMs = 0;
66 let lastCounter = 0;
+2−0
66 export * from "./agents";
77 export * from "./audit";
88 export * from "./billing";
9+export * from "./chat";
910 export * from "./checks";
1011 export * from "./clients";
1112 export * from "./codeowners";
4546 export * from "./teams";
4647 export * from "./webhooks";
4748 export * from "./work";
49+export * from "./workspace-agents";
+220−0
1+/**
2+ * A workspace's own agents: named members with a job, a personality,
3+ * routing limits and a budget, kept by the agents service
4+ * (`services/agents`). `@g1t` is the platform's agent and is not one of
5+ * these. Plan: docs/WORKSPACE.md.
6+ *
7+ * Wire shapes are snake_case end to end.
8+ */
9+import type { ServiceBinding } from "./clients";
10+import type { Role, User } from "./identity";
11+import type { Result } from "./result";
12+
13+// Model tiers (`ModelTier`, `MODEL_TIERS`) are integrations.ts's, the
14+// same ones runs are routed between.
15+import type { ModelTier } from "./integrations";
16+
17+/** Voice presets; free text in `personality` refines them. */
18+export type PersonalityPreset = "crisp" | "friendly" | "socratic" | "terse";
19+
20+export type AgentRouting = {
21+ /** Never route below this tier. Null: no floor. */
22+ floor: ModelTier | null;
23+ /** Never route above this tier. Null: no ceiling. */
24+ ceiling: ModelTier | null;
25+ /**
26+ * Where its model calls may go. Empty: anything the workspace allows.
27+ * `g1t`: g1t's hosted models. `workspace`: the workspace's own providers
28+ * (Integrations), whichever they are. Any other entry: one of the
29+ * workspace's own providers by integration id. Entries combine, so
30+ * `["g1t", "workspace"]` is both.
31+ */
32+ providers: string[];
33+ /** Advanced: a fixed `provider/model`, for own endpoints. Usually null. */
34+ pinned: string | null;
35+};
36+
37+export type AgentBudget = {
38+ /** Monthly cap in micro-dollars. Null: only the workspace limit applies. */
39+ monthly_micros: number | null;
40+ daily_micros: number | null;
41+ /** Default cap for one task. */
42+ task_micros: number | null;
43+};
44+
45+export type AgentAutonomy = {
46+ open_pull_requests: "alone" | "approval";
47+ merge: "alone" | "approval" | "never";
48+ deploy_production: "approval" | "never";
49+ edit_docs: "alone" | "suggest";
50+};
51+
52+export type AgentStatus = "idle" | "working" | "waiting" | "out_of_budget" | "paused";
53+
54+export type WorkspaceAgent = {
55+ id: string;
56+ workspace_id: string;
57+ /** Lowercase, unique in the workspace, never `g1t`. Mentioned as `@handle`. */
58+ handle: string;
59+ display_name: string;
60+ /** Uploaded avatar hash, or null for the generated mark. */
61+ avatar: string | null;
62+ /** One line: "Reviews every pull request in g1t". */
63+ role: string;
64+ /** The job: what it is responsible for and how it works. */
65+ instructions: string;
66+ personality_preset: PersonalityPreset;
67+ /** Free text refining the voice. Never changes what it may do. */
68+ personality: string;
69+ routing: AgentRouting;
70+ budget: AgentBudget;
71+ autonomy: AgentAutonomy;
72+ /** Tasks it works at once; more queue on its desk. */
73+ capacity: number;
74+ /** The template it was made from, if any. */
75+ template: string | null;
76+ version: number;
77+ status: AgentStatus;
78+ /** Spend this calendar month, in micro-dollars. */
79+ spent_month_micros: number;
80+ created_by: string;
81+ created_at: string;
82+ updated_at: string;
83+ archived_at: string | null;
84+};
85+
86+export type NewWorkspaceAgent = {
87+ handle: string;
88+ display_name: string;
89+ role: string;
90+ instructions: string;
91+ personality_preset?: PersonalityPreset;
92+ personality?: string;
93+ routing?: Partial<AgentRouting>;
94+ budget?: Partial<AgentBudget>;
95+ autonomy?: Partial<AgentAutonomy>;
96+ capacity?: number;
97+ template?: string | null;
98+};
99+
100+export type AgentTemplate = {
101+ id: string;
102+ display_name: string;
103+ handle: string;
104+ role: string;
105+ instructions: string;
106+ personality_preset: PersonalityPreset;
107+ routing: AgentRouting;
108+};
109+
110+/** What the chat service hands an agent: a message it should answer. */
111+export type AgentDelivery = {
112+ workspace: string;
113+ workspace_id: string;
114+ channel_id: string;
115+ channel_kind: "channel" | "dm";
116+ channel_name: string | null;
117+ agent_id: string;
118+ /** The message that woke it. */
119+ message_id: string;
120+ thread_root: string | null;
121+ /** Who asked: the person's user id. */
122+ asked_by: string;
123+ /** Agent-to-agent hops so far in this chain. */
124+ hops: number;
125+ /**
126+ * What the person who asked may do, from the viewer the chat service
127+ * already holds when the message is posted (`askerAccess`). An agent
128+ * never does more for someone than they could do themselves
129+ * (docs/WORKSPACE.md, "The whole company"). Absent from an older chat
130+ * service: the agent then treats the asker as unable to change code.
131+ */
132+ asker?: AskerAccess | null;
133+ /**
134+ * Where the conversation is: g1t's own chat, or later another chat app
135+ * the workspace connected (docs/WORKSPACE.md, "Working from another chat
136+ * app"). The agent reads and replies through that surface; its
137+ * definition, budget and replies are the same everywhere. Absent: `g1t`.
138+ */
139+ surface?: AgentSurface;
140+};
141+
142+/** The chat surfaces an agent answers on. Only g1t's own today. */
143+export type AgentSurface = "g1t";
144+
145+/** Who asked an agent, as far as its reply needs to know. */
146+export type AskerAccess = {
147+ username: string;
148+ /** Their role in the workspace; `outside` for someone who is not a member. */
149+ role: Role | "outside";
150+ /**
151+ * Whether they can change code in the workspace: Code is on for them
152+ * and they hold write access (or more) on at least one of its
153+ * repositories, through the base permission or a grant.
154+ */
155+ can_write: boolean;
156+};
157+
158+const WRITING_ROLES = new Set(["write", "maintain", "admin"]);
159+
160+/**
161+ * `user`'s access in `workspace`, for `AgentDelivery.asker`. Pure, with no
162+ * imports, so the chat service computes it from its viewer for free.
163+ */
164+export function askerAccess(user: User, workspace: string): AskerAccess {
165+ const slug = workspace.toLowerCase();
166+ const membership = user.workspaces?.find((m) => m.slug.toLowerCase() === slug);
167+ // Someone who uses only Chat, Docs and agents sees no repository at all.
168+ const code = membership?.code_access !== false;
169+ const base = membership ? membership.role === "owner" || WRITING_ROLES.has(membership.base_permission ?? "write") : false;
170+ const granted = (user.grants ?? []).some((grant) => grant.workspace.toLowerCase() === slug && WRITING_ROLES.has(grant.role));
171+ return {
172+ username: user.username,
173+ role: membership?.role ?? "outside",
174+ can_write: code && (base || granted),
175+ };
176+}
177+
178+export type WorkspaceAgentsApi = {
179+ list(workspace: string, viewer: User): Promise<Result<WorkspaceAgent[]>>;
180+ get(workspace: string, handle: string, viewer: User): Promise<Result<WorkspaceAgent>>;
181+ /** Internal: by id, for the chat service resolving members. */
182+ byIds(ids: string[]): Promise<WorkspaceAgent[]>;
183+ create(workspace: string, viewer: User, input: NewWorkspaceAgent): Promise<Result<WorkspaceAgent>>;
184+ update(
185+ workspace: string,
186+ handle: string,
187+ viewer: User,
188+ changes: Partial<NewWorkspaceAgent>,
189+ ): Promise<Result<WorkspaceAgent>>;
190+ archive(workspace: string, handle: string, viewer: User): Promise<Result<null>>;
191+ templates(): Promise<AgentTemplate[]>;
192+ /** The chat service hands over a message for an agent to answer. Returns at once. */
193+ deliver(delivery: AgentDelivery): Promise<Result<null>>;
194+};
195+
196+async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
197+ const response = await service.fetch(`https://service/rpc/${method}`, {
198+ method: "POST",
199+ headers: { "content-type": "application/json" },
200+ body: JSON.stringify(args),
201+ });
202+ if (!response.ok) {
203+ throw new Error(`${method} failed with status ${response.status}`);
204+ }
205+ return (await response.json()) as T;
206+}
207+
208+export function workspaceAgentsClient(service: ServiceBinding): WorkspaceAgentsApi {
209+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
210+ return {
211+ list: (workspace, viewer) => call("list", { workspace, viewer }),
212+ get: (workspace, handle, viewer) => call("get", { workspace, handle, viewer }),
213+ byIds: (ids) => call("by_ids", { ids }),
214+ create: (workspace, viewer, input) => call("create", { workspace, viewer, input }),
215+ update: (workspace, handle, viewer, changes) => call("update", { workspace, handle, viewer, changes }),
216+ archive: (workspace, handle, viewer) => call("archive", { workspace, handle, viewer }),
217+ templates: () => call("templates", {}),
218+ deliver: (delivery) => call("deliver", delivery),
219+ };
220+}
+34−0
7777 );
7878 assertSearches(steps, "jobs_self_hosted_lower");
7979 });
80+
81+test("chat: a channel's messages and a thread's replies, newest first, page by page", () => {
82+ const db = migrated("chat");
83+ // The first page passes `~` as the cursor, so both read as a range.
84+ const top = plan(
85+ db,
86+ `SELECT * FROM messages WHERE channel_id = ?1 AND thread_root IS NULL AND id < ?2
87+ AND (deleted_at IS NULL OR reply_count > 0) ORDER BY id DESC LIMIT ?3`,
88+ "chn_1",
89+ "~",
90+ 51,
91+ );
92+ assertSearches(top, "messages_by_channel");
93+ assert.ok(!top.some((step) => step.includes("TEMP B-TREE")), top.join("\n"));
94+ const thread = plan(db, "SELECT * FROM messages WHERE thread_root = ?1 AND channel_id = ?2 AND id < ?3 ORDER BY id DESC LIMIT ?4", "msg_1", "chn_1", "~", 51);
95+ assertSearches(thread, "messages_by_thread");
96+});
97+
98+test("chat: the sidebar's unread messages are read from each channel's index, after the last read", () => {
99+ const steps = plan(
100+ migrated("chat"),
101+ `SELECT msg.channel_id, msg.id, msg.author, msg.mentions
102+ FROM channel_members m
103+ JOIN channels c ON c.id = m.channel_id
104+ JOIN messages msg ON msg.channel_id = m.channel_id AND msg.id > COALESCE(m.last_read_id, '')
105+ WHERE m.principal = ?1 AND c.workspace_id = ?2 AND c.archived_at IS NULL
106+ AND msg.deleted_at IS NULL AND msg.author != ?1
107+ LIMIT 5000`,
108+ "user:usr_1",
109+ "wsp_1",
110+ );
111+ assertSearches(steps, "channel_members_by_principal");
112+ assert.ok(steps.some((step) => step.includes("messages_by_channel (channel_id=? AND id>?)")), steps.join("\n"));
113+});
+92−0
1+-- A workspace's own agents (docs/WORKSPACE.md, "Agents"): their
2+-- definitions, every version of them, each reply they gave and what it
3+-- cost, and spend rolled up by month and day for their budgets.
4+
5+-- One row per agent. Routing, budget and autonomy are small JSON objects
6+-- (@g1t/contracts workspace-agents.ts), read and written whole.
7+CREATE TABLE agents (
8+ id TEXT PRIMARY KEY,
9+ -- By id, not slug: a renamed workspace keeps its agents.
10+ workspace_id TEXT NOT NULL,
11+ handle TEXT NOT NULL,
12+ display_name TEXT NOT NULL,
13+ avatar TEXT,
14+ role TEXT NOT NULL,
15+ instructions TEXT NOT NULL,
16+ personality_preset TEXT NOT NULL DEFAULT 'crisp',
17+ personality TEXT NOT NULL DEFAULT '',
18+ routing TEXT NOT NULL,
19+ budget TEXT NOT NULL,
20+ autonomy TEXT NOT NULL,
21+ capacity INTEGER NOT NULL DEFAULT 3,
22+ template TEXT,
23+ version INTEGER NOT NULL DEFAULT 1,
24+ -- The desk's presence: a reply is in flight until this time. A time, not
25+ -- a flag, so a desk that died mid-reply never leaves it "working".
26+ busy_until TEXT,
27+ created_by TEXT NOT NULL,
28+ created_at TEXT NOT NULL,
29+ updated_at TEXT NOT NULL,
30+ archived_at TEXT
31+);
32+
33+-- A handle is unique among a workspace's agents that are not archived:
34+-- archiving one frees its handle, and its old messages still resolve by id.
35+CREATE UNIQUE INDEX agents_handle ON agents (workspace_id, handle) WHERE archived_at IS NULL;
36+CREATE INDEX agents_workspace ON agents (workspace_id, archived_at);
37+
38+-- Every version of every definition, as it was saved, for the profile's
39+-- history and for runs to say which version they ran.
40+CREATE TABLE agent_versions (
41+ agent_id TEXT NOT NULL,
42+ version INTEGER NOT NULL,
43+ definition TEXT NOT NULL,
44+ changed_by TEXT NOT NULL,
45+ created_at TEXT NOT NULL,
46+ PRIMARY KEY (agent_id, version)
47+);
48+
49+-- Each message an agent was handed, and what came of it: spend and audit.
50+-- status: working, replied, failed, blocked (budget or access; a short
51+-- notice was posted, or kept back as a repeat), skipped (hop limit).
52+CREATE TABLE agent_replies (
53+ id TEXT PRIMARY KEY,
54+ agent_id TEXT NOT NULL,
55+ workspace_id TEXT NOT NULL,
56+ channel_id TEXT NOT NULL,
57+ -- The message that woke it; one reply per message, however often it is handed over.
58+ message_id TEXT NOT NULL,
59+ -- The message the agent posted, when it posted one.
60+ reply_id TEXT,
61+ asked_by TEXT,
62+ agent_version INTEGER,
63+ model TEXT,
64+ tier TEXT,
65+ input_tokens INTEGER NOT NULL DEFAULT 0,
66+ output_tokens INTEGER NOT NULL DEFAULT 0,
67+ -- What the model cost at the provider's price, in millionths of a dollar.
68+ cost_micros INTEGER NOT NULL DEFAULT 0,
69+ -- What it counts against the agent's budget: the model at price plus
70+ -- the agent rate. Billing's ledger, with comped terms and discounts, is
71+ -- what the workspace is actually charged.
72+ charged_micros INTEGER NOT NULL DEFAULT 0,
73+ status TEXT NOT NULL,
74+ error TEXT,
75+ created_at TEXT NOT NULL,
76+ finished_at TEXT
77+);
78+
79+CREATE UNIQUE INDEX agent_replies_message ON agent_replies (agent_id, message_id);
80+CREATE INDEX agent_replies_recent ON agent_replies (agent_id, created_at);
81+CREATE INDEX agent_replies_channel ON agent_replies (agent_id, channel_id, status, created_at);
82+
83+-- Spend by agent and period: `YYYY-MM` for the month, `YYYY-MM-DD` for the
84+-- day, both UTC. Budgets and `spent_month_micros` read these, never a sum
85+-- over every reply.
86+CREATE TABLE agent_spend (
87+ agent_id TEXT NOT NULL,
88+ period TEXT NOT NULL,
89+ micros INTEGER NOT NULL DEFAULT 0,
90+ replies INTEGER NOT NULL DEFAULT 0,
91+ PRIMARY KEY (agent_id, period)
92+);
+15−0
1+{
2+ "name": "@g1t/agents",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "license": "MIT",
7+ "scripts": {
8+ "test": "node --test src/*.test.ts",
9+ "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json",
10+ "deploy": "wrangler deploy"
11+ },
12+ "dependencies": {
13+ "@g1t/contracts": "*"
14+ }
15+}
+29−0
1+/**
2+ * Who may see and who may change a workspace's agents. Pure, so it is
3+ * tested on its own.
4+ *
5+ * Any member sees them: agents are members too, and people need to know
6+ * who they can talk to. Only owners create, change or archive them, since
7+ * an agent spends the workspace's money and acts in its name. A
8+ * workspace's own access token, acting as the workspace, counts as an
9+ * owner, as it does for the workspace's other settings.
10+ */
11+import type { User } from "@g1t/contracts";
12+
13+type Viewer = Pick<User, "kind" | "username" | "workspaces" | "verified"> | null | undefined;
14+
15+export function canSee(viewer: Viewer, workspace: string): boolean {
16+ const slug = workspace.toLowerCase();
17+ if (!viewer) return false;
18+ if (viewer.kind === "workspace") return viewer.username.toLowerCase() === slug;
19+ return !!viewer.workspaces?.some((m) => m.slug.toLowerCase() === slug);
20+}
21+
22+export function canManage(viewer: Viewer, workspace: string): boolean {
23+ const slug = workspace.toLowerCase();
24+ if (!viewer) return false;
25+ if (viewer.kind === "workspace") return viewer.username.toLowerCase() === slug;
26+ return !!viewer.workspaces?.some((m) => m.slug.toLowerCase() === slug && m.role === "owner");
27+}
28+
29+export const MANAGE_REFUSAL = "Only the workspace's owners can create, change or archive its agents.";
+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.

+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.

+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.

+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.

+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.

+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.

+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.

+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.

+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.