Chat pane to talk with other people using the mod, with GitHub as the identity

A Claude Code mod for joint sessions: when several people are working with Claude at the same time, a chat pane becomes the channel between each person's Claude.
Status: early prototype. Sign-in with GitHub, groups, direct chats and text messages work over GitHub; files and the joint-session features are not built yet. See the MVP spec (in Portuguese) for the plan.
There is no server. GitHub is the backend, used through your own GitHub CLI:
The mod polls for new messages: every 20 s for the open chat, every 5 min for the others.
gh installed and signed in (gh auth status) with the repo scope.In Claude Code (terminal or desktop app):
/plugin install team-chat --marketplace AlecsFarias/claude-team-chat
On versions older than 2.1.275, add the marketplace first:
/plugin marketplace add AlecsFarias/claude-team-chat
/plugin install team-chat@claude-team-chat
Then run /chat, check that the GitHub account shown is yours and press "Começar". Invitations to chats are accepted on their own within 5 minutes.
The mod lives in mod/. It is a Claude Code plugin of function hooks:
hooks/register.tsx: state, actions and the /chat command.hooks/views.tsx: the screens.hooks/github.ts: finding gh, the sign-in check and gh api calls.hooks/chats.ts: chats, messages, members and invitations on GitHub.hooks/sync.ts: polling and the actions that write.types/index.d.ts: the state contract.Load it in a session and open the pane:
claude --plugin-dir mod
Then run /chat.
Check it:
claude plugin validate mod
claude plugin test mod
npx -y -p typescript@5.6.3 tsc -p mod
tsc needs the API types that Claude Code writes to mod/.claude-plugin/types/ the first time it loads the mod.
hooks/register.tsx 338 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Avatar, ChatMessage, ChatUser, Conversation, GhState, Profile, SyncState, View } from '../types'
5import { checkGh, fetchAvatar, getUser, homeDir, pngBase64 } from './github'
6import type { Run } from './github'
7import { createGroup, markRead, openDm, sendMessage, startSync } from './sync'
8import { dmPeer } from './chats'
9import type { Host, Who } from './sync'
10import { renderApp } from './views'
11import type { Actions } from './views'
12
13const PANE = 'team-chat'
14const TITLE = 'Chat'
15// $.store key: the GitHub login that pressed "Começar" on this machine.
16const SIGNED_IN = 'signedInAs'
17// Stands for the signed-in login in stored chats; swapped for it before drawing.
18const ME = 'me'
19
20const view = atom({ plugin: 'team-chat', key: 'view' } as const, 'setup' as View)
21const gh = atom({ plugin: 'team-chat', key: 'gh' } as const, { status: 'checking' } as GhState)
22const conversationId = atom({ plugin: 'team-chat', key: 'conversationId' } as const, '', { shape: 'v2' })
23const profile = atom({ plugin: 'team-chat', key: 'profile' } as const, null as Profile | null, { shape: 'v2' })
24const contacts = atom({ plugin: 'team-chat', key: 'contacts' } as const, [] as ChatUser[], { shape: 'v3' })
25// v3/v4: chats are GitHub repos now (id "owner/tc-..."); local-only chats are dropped.
26const conversations = atom({ plugin: 'team-chat', key: 'conversations' } as const, [] as Conversation[], { shape: 'v3' })
27const messages = atom({ plugin: 'team-chat', key: 'messages' } as const, [] as ChatMessage[], { shape: 'v4' })
28const unread = atom({ plugin: 'team-chat', key: 'unread' } as const, {} as Record<string, number>, { shape: 'v2' })
29const avatars = atom({ plugin: 'team-chat', key: 'avatars' } as const, {} as Record<string, Avatar>, { shape: 'v2' })
30const draft = atom({ plugin: 'team-chat', key: 'draft' } as const, '')
31const groupDraft = atom({ plugin: 'team-chat', key: 'groupDraft' } as const, '')
32const contactDraft = atom({ plugin: 'team-chat', key: 'contactDraft' } as const, '')
33const groupSearch = atom({ plugin: 'team-chat', key: 'groupSearch' } as const, '')
34const contactSearch = atom({ plugin: 'team-chat', key: 'contactSearch' } as const, '')
35const contactError = atom({ plugin: 'team-chat', key: 'contactError' } as const, '')
36const sync = atom({ plugin: 'team-chat', key: 'sync' } as const, { status: 'idle' } as SyncState)
37const busy = atom({ plugin: 'team-chat', key: 'busy' } as const, {} as Record<string, string>)
38const frame = atom({ plugin: 'team-chat', key: 'frame' } as const, 0)
39
40// The sample chats name the signed-in person `ME`; draw them as that login.
41const asMe = (login: string) => (id: string) => (id === ME ? login : id)
42
43// The terminal spinner: one timer while anything loads (an action, the sync,
44// the gh check), stopped when all is quiet. The desktop's spinner animates itself.
45let spinner: Timer | undefined
46
47async function spin($: EngineInterface) {
48 const loading =
49 Object.keys(await read($, busy)).length > 0 ||
50 (await read($, sync)).status === 'syncing' ||
51 (await read($, gh)).status === 'checking'
52 if (!loading) {
53 spinner?.cancel()
54 spinner = undefined
55 return
56 }
57 await update($, frame, n => (n + 1) % 1000)
58}
59
60function startSpinner($: EngineInterface) {
61 spinner ??= $.clock.every(120, () => void spin($))
62}
63
64// Runs one action with a loading line on screen under `key`.
65async function withBusy<T>($: EngineInterface, key: string, label: string, work: () => Promise<T>): Promise<T> {
66 await update($, busy, b => ({ ...b, [key]: label }))
67 startSpinner($)
68 try {
69 return await work()
70 } finally {
71 await update($, busy, b => {
72 const { [key]: _, ...rest } = b
73 return rest
74 })
75 }
76}
77
78// Module state: a reload checks gh again.
79let checking: Promise<void> | undefined
80let started = false
81
82async function loadAvatars($: EngineInterface, home: string, people: { id: string; avatarUrl?: string }[]) {
83 if (!home) return
84 const run: Run = (argv, init) => $.process.run(argv, init)
85 const have = await read($, avatars)
86 for (const p of people) {
87 if (!p.avatarUrl || have[p.id]) continue
88 const file = await fetchAvatar(run, p.avatarUrl, p.id, `${home}/.cache/claude-team-chat/avatars`)
89 if (!file) continue
90 const png = await pngBase64(run, file)
91 await update($, avatars, a => ({ ...a, [p.id]: png ? { file, png } : { file } }))
92 }
93}
94
95// Reads gh and the GitHub profile; never leaves the screen on "checking".
96function check($: EngineInterface) {
97 checking ??= (async () => {
98 const run: Run = (argv, init) => $.process.run(argv, init)
99 try {
100 await update($, gh, () => ({ status: 'checking' }) as GhState)
101 const home = await homeDir(run)
102 const state = await checkGh(run, home)
103 await update($, gh, () => state)
104 if (state.status !== 'ok') {
105 await update($, view, () => 'setup' as View)
106 return
107 }
108 const signedIn = await $.store.get(SIGNED_IN)
109 if (signedIn === state.profile.id) {
110 await update($, profile, () => state.profile)
111 if ((await read($, view)) === 'setup') await update($, view, () => 'chat' as View)
112 beginSync($, state)
113 } else {
114 await update($, view, () => 'setup' as View)
115 }
116 await loadAvatars($, home, [state.profile, ...(await read($, contacts))])
117 } catch (err) {
118 const message = err instanceof Error ? err.message : String(err)
119 await update($, gh, () => ({ status: 'error', message: `Falha ao verificar o gh: ${message}` }) as GhState).catch(() => {})
120 }
121 })().finally(() => {
122 checking = undefined
123 })
124 return checking
125}
126
127// The engine as hooks/sync.ts uses it; the atoms stay here, where the scan reads them.
128function hostOf($: EngineInterface): Host {
129 return {
130 run: (argv, init) => $.process.run(argv, init),
131 store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
132 every: (ms, fn) => $.clock.every(ms, fn),
133 now: () => Date.now(),
134 conversations: { get: () => read($, conversations), set: async f => void (await update($, conversations, f)) },
135 messages: { get: () => read($, messages), set: async f => void (await update($, messages, f)) },
136 unread: { get: () => read($, unread), set: async f => void (await update($, unread, f)) },
137 conversationId: { get: () => read($, conversationId) },
138 sync: { get: () => read($, sync), set: async f => void (await update($, sync, f)) },
139 }
140}
141
142// Starts polling GitHub once the person is signed in (hooks/sync.ts).
143function beginSync($: EngineInterface, state: GhState) {
144 if (state.status === 'ok') startSync(hostOf($), { gh: state.gh, me: state.profile.id })
145}
146
147// The signed-in person as the sync calls GitHub, or null before "Começar".
148async function who($: EngineInterface): Promise<Who | null> {
149 const state = await read($, gh)
150 const me = await read($, profile)
151 return state.status === 'ok' && me ? { gh: state.gh, me: me.id } : null
152}
153
154// Work that outlives a dispatch runs on a timer; state can't be written while drawing.
155function startCheck($: EngineInterface) {
156 if (started) return
157 started = true
158 $.clock.after(0, () => void check($))
159}
160
161export const register: Register = on => {
162 on('session.start', async ($, e, next) => {
163 await $.command.register({ name: 'chat', description: 'Abrir a aba de chat' })
164 startCheck($)
165 // Open the chat beside the transcript in every session, so nobody has to
166 // type /chat; it waits undrawn until a surface that docks it attaches.
167 void $.ui.open({ id: PANE, title: TITLE })
168
169 return next(e)
170 })
171
172 // A surface that joins later (the desktop app) gets the chat too; when it
173 // doesn't place an unasked pane, say why instead of failing quietly.
174 on('session.attach', async ($, e, next) => {
175 const opened = await $.ui.open({ id: PANE, title: TITLE })
176 if (!opened.isPlaced) {
177 // The engine places an unasked pane only on a wide enough surface (144
178 // columns, 110 once the person opened it); say how to open it instead.
179 $.ui.log(`Chat: não abriu sozinho (${opened.reason}).`)
180 $.ui.toast('Chat pronto. Digite /chat para abrir.', { timeoutMs: 8000 })
181 }
182
183 return next(e)
184 })
185
186 on('command.run', { command: 'chat' }, async $ => {
187 await $.ui.open({ id: PANE, title: TITLE })
188
189 return { text: 'Chat aberto.' }
190 })
191
192 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
193 const run: Run = (argv, init) => $.process.run(argv, init)
194
195 const els = $.ui.resolve(e)
196 const { Box, Text, Button } = els
197 const Input = 'Input' in els ? els.Input : undefined
198 const Link = 'Link' in els ? els.Link : undefined
199 // Pictures: the terminal reads a PNG file; desktop and mobile draw an Svg.
200 const Image = e.surface === 'terminal' && 'Image' in els ? els.Image : undefined
201 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
202
203 const ghState = await read($, gh)
204 startCheck($)
205 // The terminal's spinner turns only while something loads.
206 if (e.surface === 'terminal' && (ghState.status === 'checking' || (await read($, sync)).status === 'syncing')) {
207 startSpinner($)
208 }
209
210 const me = await read($, profile)
211 const login = me?.id ?? ME
212 const mine = asMe(login)
213 const currentView = me ? await read($, view) : 'setup'
214 const currentId = await read($, conversationId)
215 // A DM is named after the other person, as each side knows them.
216 const people = await read($, contacts)
217 const dmName = (c: Conversation) => {
218 const peer = dmPeer(c, login)
219 return people.find(p => p.id.toLowerCase() === peer?.toLowerCase())?.name ?? peer ?? c.name
220 }
221 const allConversations = (await read($, conversations)).map(c => ({
222 ...c,
223 name: c.kind === 'dm' ? dmName(c) : c.name,
224 memberIds: c.memberIds.map(mine),
225 }))
226 const allMessages = (await read($, messages)).map(m => ({ ...m, authorId: mine(m.authorId) }))
227 const current = allConversations.find(c => c.id === currentId) ?? allConversations[0]
228
229 const open = async (id: string) => {
230 await update($, conversationId, () => id)
231 await update($, unread, u => ({ ...u, [id]: 0 }))
232 await update($, view, () => 'chat' as View)
233 await markRead(hostOf($), id)
234 }
235
236 const act: Actions = {
237 go: next => void update($, view, () => next),
238 open: id => void open(id),
239 openDm: async userId => {
240 const w = await who($)
241 if (!w) return
242 const dm = await withBusy($, 'dm', 'Abrindo a conversa no GitHub…', () => openDm(hostOf($), w, userId))
243 if ('message' in dm) return void $.ui.toast(dm.message)
244 await open(dm.id)
245 },
246 send: async value => {
247 const text = value.trim()
248 const w = await who($)
249 if (!text || !current || !w) return
250 await update($, draft, () => '')
251 const sent = await sendMessage(hostOf($), w, current.id, text)
252 if ('message' in sent && !('conversationId' in sent)) {
253 // Give the text back so nothing typed is lost.
254 await update($, draft, d => d || text)
255 $.ui.toast(`Não enviou: ${sent.message}`)
256 }
257 },
258 setDraft: value => void update($, draft, () => value),
259 createGroup: async value => {
260 const name = value.trim().replace(/^#\s*/, '')
261 const w = await who($)
262 if (!name || !w) return
263 const group = await withBusy($, 'group', `Criando # ${name} no GitHub…`, () => createGroup(hostOf($), w, name))
264 if ('message' in group) return void $.ui.toast(`Não criou o grupo: ${group.message}`)
265 await update($, groupDraft, () => '')
266 await open(group.id)
267 },
268 setGroupDraft: value => void update($, groupDraft, () => value),
269 setGroupSearch: value => void update($, groupSearch, () => value),
270 setContactSearch: value => void update($, contactSearch, () => value),
271 addContact: async value => {
272 const handle = value.trim().replace(/^@/, '')
273 if (!handle) return
274 const state = await read($, gh)
275 if (state.status !== 'ok') return
276 const known = await read($, contacts)
277 if (handle.toLowerCase() === state.profile.id.toLowerCase()) {
278 await update($, contactError, () => 'Esse é você.')
279 return
280 }
281 if (known.some(p => p.id.toLowerCase() === handle.toLowerCase())) {
282 await update($, contactError, () => `@${handle} já está nos contatos.`)
283 return
284 }
285 const found = await withBusy($, 'contact', `Procurando @${handle} no GitHub…`, () => getUser(run, state.gh, handle))
286 if ('kind' in found) {
287 await update($, contactError, () => found.message)
288 return
289 }
290 await update($, contacts, l => [...l, found as ChatUser])
291 await update($, contactDraft, () => '')
292 await update($, contactError, () => '')
293 await loadAvatars($, await homeDir(run), [found])
294 },
295 setContactDraft: value => {
296 void update($, contactDraft, () => value)
297 void update($, contactError, () => '')
298 },
299 retryGh: () => void check($),
300 start: async () => {
301 const state = await read($, gh)
302 if (state.status !== 'ok') return
303 await $.store.set(SIGNED_IN, state.profile.id)
304 await update($, profile, () => state.profile)
305 await update($, view, () => 'chat' as View)
306 beginSync($, state)
307 },
308 }
309
310 return renderApp({
311 ui: { Box, Text, Button, Input, Link, Image, Svg },
312 cols: e.props.bodyColumns,
313 rows: e.props.scroll.bodyRows,
314 now: Date.now(),
315 view: currentView,
316 gh: ghState,
317 profile: me,
318 contacts: await read($, contacts),
319 conversations: allConversations,
320 current,
321 messages: current ? allMessages.filter(m => m.conversationId === current.id) : [],
322 lastMessages: Object.fromEntries(allMessages.map(m => [m.conversationId, m])),
323 unread: await read($, unread),
324 avatars: await read($, avatars),
325 draft: await read($, draft),
326 groupDraft: await read($, groupDraft),
327 contactDraft: await read($, contactDraft),
328 groupSearch: await read($, groupSearch),
329 contactSearch: await read($, contactSearch),
330 contactError: await read($, contactError),
331 busy: await read($, busy),
332 frame: await read($, frame),
333 sync: await read($, sync),
334 act,
335 })
336 })
337}
338hooks/github.ts 303 lines1// GitHub through the person's own `gh`: find it, check the sign-in, call the API.
2// Pure over an injected runner so tests never start a process; in the mod pass
3// `(argv, init) => $.process.run(argv, init)`. The token is never read: no
4// `gh auth token`, no `--show-token`, and `gh auth status` text never leaves here.
5
6import type { ChatUser, GhState, Profile } from '../types'
7
8export type Run = (
9 argv: readonly string[],
10 init?: { timeoutMs?: number; stdin?: string; cwd?: string },
11) => Promise<{ exitCode: number; stdout: string; stderr: string }>
12
13export type GhError = { kind: 'not-found' | 'rate-limited' | 'network' | 'auth' | 'unknown'; message: string }
14
15export type ApiResult<T> =
16 | { ok: true; data: T; status: number; etag?: string }
17 | { ok: false; error: GhError; status?: number }
18
19const PROBE_MS = 5_000
20const AUTH_MS = 15_000
21const API_MS = 20_000
22const FILE_MS = 10_000
23const DOWNLOAD_S = 15
24
25// GitHub logins: 1 to 39 letters, digits or single hyphens, not at either end.
26const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/
27
28export const isLogin = (login: string) => LOGIN.test(login)
29
30export const isGhError = (value: unknown): value is GhError =>
31 typeof value === 'object' && value !== null && 'kind' in value && 'message' in value
32
33// Where `gh` usually is: the session PATH first, then the common install folders.
34export const ghCandidates = (home: string): string[] => [
35 'gh',
36 ...(home ? [`${home}/.local/bin/gh`] : []),
37 '/opt/homebrew/bin/gh',
38 '/usr/local/bin/gh',
39]
40
41export async function homeDir(run: Run): Promise<string> {
42 try {
43 const { exitCode, stdout } = await run(['/bin/sh', '-c', 'printf %s "$HOME"'], { timeoutMs: PROBE_MS })
44 const home = stdout.trim()
45 return exitCode === 0 && home.startsWith('/') ? home : ''
46 } catch {
47 return ''
48 }
49}
50
51export async function findGh(run: Run, home: string): Promise<string | null> {
52 for (const gh of ghCandidates(home)) {
53 try {
54 const { exitCode, stdout } = await run([gh, '--version'], { timeoutMs: PROBE_MS })
55 if (exitCode === 0 && /gh version/i.test(stdout)) return gh
56 } catch {
57 // Could not start: not installed there.
58 }
59 }
60 return null
61}
62
63export type AuthStatus =
64 | { state: 'logged-in'; login: string; scopes?: string[] }
65 | { state: 'logged-out' }
66 | { state: 'timeout' }
67 | { state: 'unknown' }
68
69// Reads `gh auth status -h github.com` (both the 2.4x+ "account" format and the
70// older "as" one). With several accounts, the active one counts. `scopes` is
71// absent when gh lists none (fine-grained tokens, some versions).
72export function parseAuthStatus(text: string): AuthStatus {
73 const head = /(Logged in to|Failed to log in to|Timeout trying to log in to) \S+ (?:account|as) ([A-Za-z0-9-]+)/
74 const blocks: string[][] = []
75 for (const line of text.split(/\r?\n/)) {
76 if (head.test(line)) blocks.push([line])
77 else blocks[blocks.length - 1]?.push(line)
78 }
79 if (blocks.length === 0) {
80 return /not logged in|gh auth login/i.test(text) ? { state: 'logged-out' } : { state: 'unknown' }
81 }
82 const block = blocks.find(lines => lines.some(l => /Active account:\s*true/.test(l))) ?? blocks[0]!
83 const [, verb, login = ''] = head.exec(block[0] ?? '') ?? []
84 if (verb === 'Failed to log in to') return { state: 'logged-out' }
85 if (verb === 'Timeout trying to log in to') return { state: 'timeout' }
86 const scopesLine = block.map(l => /Token scopes:\s*(.*)$/.exec(l)?.[1]).find(s => s !== undefined)
87 if (scopesLine === undefined) return { state: 'logged-in', login }
88 const scopes = scopesLine
89 .split(',')
90 .map(s => s.trim().replace(/^['"]|['"]$/g, ''))
91 .filter(s => s && s !== 'none')
92 return { state: 'logged-in', login, scopes }
93}
94
95export async function checkGh(run: Run, home: string): Promise<GhState> {
96 const gh = await findGh(run, home)
97 if (!gh) return { status: 'missing' }
98
99 let auth: AuthStatus
100 try {
101 const out = await run([gh, 'auth', 'status', '-h', 'github.com'], { timeoutMs: AUTH_MS })
102 auth = parseAuthStatus(`${out.stdout}\n${out.stderr}`)
103 } catch {
104 return { status: 'error', gh, message: 'O gh demorou demais para responder. Tente de novo.' }
105 }
106 if (auth.state === 'logged-out') return { status: 'logged-out', gh }
107 if (auth.state === 'timeout') return { status: 'error', gh, message: NETWORK }
108 if (auth.state === 'unknown') return { status: 'error', gh, message: 'Não deu para ler o login do gh.' }
109 if (auth.scopes && !auth.scopes.includes('repo')) return { status: 'no-repo-scope', gh, login: auth.login }
110
111 const me = await ghApi<GitHubUser>(run, gh, 'user')
112 if (!me.ok) {
113 // A rejected token means signing in again, which the logged-out screen offers.
114 if (me.status === 401) return { status: 'logged-out', gh }
115 return { status: 'error', gh, message: me.error.message }
116 }
117 if (!me.data || !isLogin(me.data.login)) return { status: 'error', gh, message: 'Resposta inesperada do GitHub.' }
118 const profile: Profile = {
119 id: me.data.login,
120 handle: me.data.login,
121 name: me.data.name || me.data.login,
122 avatarUrl: me.data.avatar_url,
123 htmlUrl: me.data.html_url || `https://github.com/${me.data.login}`,
124 }
125 return { status: 'ok', gh, profile }
126}
127
128type GitHubUser = {
129 login: string
130 name?: string | null
131 avatar_url?: string
132 html_url?: string
133 type?: string
134}
135
136const NETWORK = 'Sem conexão com o GitHub. Confira a internet e tente de novo.'
137
138// `gh api --include` prints the status line and headers, a blank line, then the body.
139function parseIncluded(stdout: string) {
140 const status = /^HTTP\/[\d.]+ (\d{3})/.exec(stdout)
141 if (!status) return null
142 const cut = /\r?\n\r?\n/.exec(stdout)
143 const head = cut ? stdout.slice(0, cut.index) : stdout
144 const body = cut ? stdout.slice(cut.index + cut[0].length) : ''
145 const headers: Record<string, string> = {}
146 for (const line of head.split(/\r?\n/).slice(1)) {
147 const at = line.indexOf(':')
148 if (at > 0) headers[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 1).trim()
149 }
150 return { status: Number(status[1]), headers, body }
151}
152
153function httpError(status: number, headers: Record<string, string>, body: string): GhError {
154 let message = ''
155 try {
156 message = String((JSON.parse(body) as { message?: unknown }).message ?? '')
157 } catch {
158 // Not JSON.
159 }
160 if (status === 429 || (status === 403 && (headers['x-ratelimit-remaining'] === '0' || /rate limit/i.test(message)))) {
161 const reset = Number(headers['x-ratelimit-reset'])
162 const minutes = reset > 0 ? Math.max(1, Math.ceil((reset * 1000 - Date.now()) / 60_000)) : 0
163 return {
164 kind: 'rate-limited',
165 message: minutes
166 ? `Limite de uso da API do GitHub atingido. Tente de novo em ${minutes} min.`
167 : 'Limite de uso da API do GitHub atingido. Tente de novo daqui a pouco.',
168 }
169 }
170 if (status === 404) return { kind: 'not-found', message: 'Não encontrado no GitHub.' }
171 if (status === 401) return { kind: 'auth', message: 'O login do gh expirou. Entre com GitHub de novo.' }
172 if (status === 403) return { kind: 'auth', message: 'O GitHub negou o acesso a isso.' }
173 if (status >= 500) return { kind: 'unknown', message: `O GitHub está com problemas (HTTP ${status}). Tente de novo.` }
174 return { kind: 'unknown', message: `O GitHub recusou o pedido (HTTP ${status}).` }
175}
176
177// No HTTP answer at all: gh could not reach GitHub or failed before sending.
178function runError(stderr: string): GhError {
179 if (/dial tcp|no such host|connection refused|network is unreachable|i\/o timeout|timeout|TLS handshake|could not resolve|connection reset/i.test(stderr)) {
180 return { kind: 'network', message: NETWORK }
181 }
182 if (/gh auth login|authentication|not logged in/i.test(stderr)) {
183 return { kind: 'auth', message: 'O gh não está logado. Entre com GitHub de novo.' }
184 }
185 return { kind: 'unknown', message: 'O gh falhou ao falar com o GitHub.' }
186}
187
188// One REST call. A 304 (after If-None-Match) is ok with no data, so polling can
189// skip unchanged resources; gh exits 1 on it and on every 4xx/5xx, so the
190// status comes from the included headers, not the exit code.
191export async function ghApi<T = unknown>(
192 run: Run,
193 gh: string,
194 path: string,
195 opts: { method?: string; body?: unknown; headers?: Record<string, string> } = {},
196): Promise<ApiResult<T>> {
197 const argv = [gh, 'api', '--include']
198 if (opts.method) argv.push('--method', opts.method.toUpperCase())
199 for (const [name, value] of Object.entries(opts.headers ?? {})) argv.push('-H', `${name}: ${value}`)
200 const hasBody = opts.body !== undefined
201 if (hasBody) argv.push('--input', '-')
202 argv.push(path)
203
204 let out: Awaited<ReturnType<Run>>
205 try {
206 out = await run(argv, { timeoutMs: API_MS, ...(hasBody ? { stdin: JSON.stringify(opts.body) } : {}) })
207 } catch {
208 return { ok: false, error: { kind: 'network', message: NETWORK } }
209 }
210
211 const res = parseIncluded(out.stdout)
212 if (!res) return { ok: false, error: runError(out.stderr) }
213 const etag = res.headers['etag']
214 if (res.status === 304) return { ok: true, data: undefined as T, status: 304, ...(etag ? { etag } : {}) }
215 if (res.status < 200 || res.status > 299) {
216 return { ok: false, status: res.status, error: httpError(res.status, res.headers, res.body) }
217 }
218 let data: T
219 try {
220 data = (res.body.trim() ? JSON.parse(res.body) : undefined) as T
221 } catch {
222 return { ok: false, status: res.status, error: { kind: 'unknown', message: 'Resposta inesperada do GitHub.' } }
223 }
224 return { ok: true, data, status: res.status, ...(etag ? { etag } : {}) }
225}
226
227// A person by @login, as "Adicionar pelo @" needs it. Accepts a leading @.
228export async function getUser(run: Run, gh: string, login: string): Promise<ChatUser | GhError> {
229 const wanted = login.trim().replace(/^@/, '')
230 if (!isLogin(wanted)) return { kind: 'not-found', message: `"${wanted}" não é um @ válido do GitHub.` }
231 const res = await ghApi<GitHubUser>(run, gh, `users/${wanted}`)
232 if (!res.ok) {
233 return res.status === 404 ? { kind: 'not-found', message: `@${wanted} não existe no GitHub.` } : res.error
234 }
235 const user = res.data
236 if (!user || !isLogin(user.login)) return { kind: 'unknown', message: 'Resposta inesperada do GitHub.' }
237 if (user.type && user.type !== 'User') {
238 return { kind: 'not-found', message: `@${user.login} é uma organização, não uma pessoa.` }
239 }
240 return { id: user.login, handle: user.login, name: user.name || user.login, avatarUrl: user.avatar_url }
241}
242
243const PNG_MAGIC = '89504e470d0a1a0a'
244
245async function isPng(run: Run, file: string): Promise<boolean> {
246 try {
247 const { exitCode, stdout } = await run(['/usr/bin/od', '-An', '-tx1', '-N8', file], { timeoutMs: FILE_MS })
248 return exitCode === 0 && stdout.replace(/\s+/g, '') === PNG_MAGIC
249 } catch {
250 return false
251 }
252}
253
254// Asks the avatar server for a 64 px picture.
255export function avatarAt64(url: string): string {
256 if (/[?&]s=\d+/.test(url)) return url.replace(/([?&])s=\d+/, '$1s=64')
257 return `${url}${url.includes('?') ? '&' : '?'}s=64`
258}
259
260// Downloads the avatar to `<cacheDir>/<login>.png` for the `Image` element, which
261// reads PNG only; GitHub may serve JPEG, converted with macOS `sips` when present.
262export async function fetchAvatar(run: Run, url: string, login: string, cacheDir: string): Promise<string | null> {
263 if (!isLogin(login) || !/^https:\/\/[^\s]+$/.test(url) || !cacheDir.startsWith('/') || /[\0\n]/.test(cacheDir)) {
264 return null
265 }
266 const name = login.toLowerCase()
267 const tmp = `${cacheDir}/${name}.download`
268 const out = `${cacheDir}/${name}.png`
269 const ok = async (argv: string[], timeoutMs = FILE_MS) => {
270 try {
271 return (await run(argv, { timeoutMs })).exitCode === 0
272 } catch {
273 return false
274 }
275 }
276
277 if (!(await ok(['/bin/mkdir', '-p', cacheDir]))) return null
278 const curl = ['/usr/bin/curl', '-fsSL', '--max-time', String(DOWNLOAD_S), '-o', tmp, avatarAt64(url)]
279 if (!(await ok(curl, (DOWNLOAD_S + 5) * 1000))) {
280 await ok(['/bin/rm', '-f', tmp])
281 return null
282 }
283
284 let made = false
285 if (await isPng(run, tmp)) made = await ok(['/bin/mv', '-f', tmp, out])
286 else made = (await ok(['/usr/bin/sips', '-s', 'format', 'png', tmp, '--out', out], 20_000)) && (await isPng(run, out))
287 await ok(['/bin/rm', '-f', tmp])
288 return made ? out : null
289}
290
291// The PNG as base64, for surfaces that draw it inside an `Svg` (desktop, mobile)
292// instead of reading the file. Null when unreadable or too big for an `Svg`.
293export async function pngBase64(run: Run, file: string): Promise<string | null> {
294 if (!file.startsWith('/') || !file.endsWith('.png')) return null
295 try {
296 const { exitCode, stdout } = await run(['/usr/bin/base64', '-i', file], { timeoutMs: FILE_MS })
297 const b64 = stdout.replace(/\s+/g, '')
298 return exitCode === 0 && /^[A-Za-z0-9+/]+=*$/.test(b64) && b64.length < 100_000 ? b64 : null
299 } catch {
300 return null
301 }
302}
303hooks/sync.ts 252 lines1// Keeps `$.state` in step with the chats on GitHub: discovers chats, accepts
2// invitations, polls messages (the open chat every 20 s, the others every 5 min,
3// with ETags), counts unread, and runs the actions that write (send, create a
4// group, open a DM). The screens only read the atoms and call these functions.
5
6import type { ChatMessage, Conversation, SyncState } from '../types'
7import {
8 acceptInvites,
9 createChat,
10 dmPeer,
11 dmRepoName,
12 fetchMessages,
13 listChats,
14 loadChat,
15 mergeMessages,
16 postMessage,
17 unreadCount,
18} from './chats'
19import type { ChatRef, Cursor } from './chats'
20import { isGhError } from './github'
21import type { GhError, Run } from './github'
22
23export const OPEN_MS = 20_000
24export const OTHERS_MS = 5 * 60_000
25// $.store key: the last comment id read, per chat id.
26export const LAST_READ = 'lastRead'
27const KEEP = 1000
28
29export type Who = { gh: string; me: string }
30
31// Module state: a reload starts over and reads every chat again (merged by id).
32let timer: { cancel: () => void } | undefined
33let busy = false
34let chatsEtag: string | undefined
35let lastList = 0
36const cursors = new Map<string, Cursor>()
37const fetchedAt = new Map<string, number>()
38
39// One `$.state` value. The engine's scan only accepts `read`/`update` on an atom
40// declared in the same file, so register.tsx builds these over its own atoms.
41export type Slot<T> = { get: () => Promise<T>; set: (f: (value: T) => T) => Promise<void> }
42
43// What the sync needs from the engine, so tests can hand in a plain object.
44export type Host = {
45 run: Run
46 store: { get: (key: string) => Promise<unknown>; set: (key: string, value: unknown) => Promise<void> }
47 every: (ms: number, fn: () => void) => { cancel: () => void }
48 now: () => number
49 conversations: Slot<Conversation[]>
50 messages: Slot<ChatMessage[]>
51 unread: Slot<Record<string, number>>
52 // The chat on screen; read only.
53 conversationId: Pick<Slot<string>, 'get'>
54 sync: Slot<SyncState>
55}
56
57// For tests and sign-out: forget timers and cursors.
58export function stopSync() {
59 timer?.cancel()
60 timer = undefined
61 busy = false
62 chatsEtag = undefined
63 lastList = 0
64 cursors.clear()
65 fetchedAt.clear()
66}
67
68export function startSync(h: Host, who: Who) {
69 if (timer) return
70 timer = h.every(OPEN_MS, () => void tick(h, who))
71 void tick(h, who)
72}
73
74async function fail(h: Host, error: GhError) {
75 await h.sync.set(() => ({ status: 'error', message: error.message, at: h.now() }) as SyncState)
76}
77
78// One pass. Overlapping ticks are skipped, so a slow GitHub never piles up calls.
79export async function tick(h: Host, who: Who, force = false) {
80 if (busy) return
81 busy = true
82 try {
83 const now = h.now()
84 await h.sync.set(s => ({ ...s, status: 'syncing' }) as SyncState)
85 if (force || now - lastList >= OTHERS_MS) {
86 const listed = await refreshChats(h, who)
87 if (isGhError(listed)) return void (await fail(h, listed))
88 lastList = now
89 }
90 const openId = await h.conversationId.get()
91 for (const chat of await h.conversations.get()) {
92 const due = chat.id === openId ? OPEN_MS : OTHERS_MS
93 if (!force && now - (fetchedAt.get(chat.id) ?? 0) < due - 1000) continue
94 const got = await pullMessages(h, who, chat.id)
95 if (isGhError(got)) return void (await fail(h, got))
96 }
97 await recount(h, who.me)
98 await h.sync.set(() => ({ status: 'idle', at: h.now() }) as SyncState)
99 } catch (err) {
100 await fail(h, { kind: 'unknown', message: err instanceof Error ? err.message : String(err) })
101 } finally {
102 busy = false
103 }
104}
105
106// Accepts invitations, then lists the chat repos and loads the ones not seen yet.
107// Chats that disappeared (repo deleted, I left) are dropped with their messages.
108export async function refreshChats(h: Host, who: Who): Promise<true | GhError> {
109 const run = h.run
110 const joined = await acceptInvites(run, who.gh)
111 // An invitation that cannot be read is not worth stopping the chat for.
112 const listed = await listChats(run, who.gh, isGhError(joined) || joined.length === 0 ? chatsEtag : undefined)
113 if (isGhError(listed)) return listed
114 chatsEtag = listed.etag
115 if (listed.notModified) return true
116
117 const have = await h.conversations.get()
118 const known = new Map(have.map(c => [c.id, c]))
119 const next: Conversation[] = []
120 for (const ref of listed.chats) {
121 const chat = known.get(ref.id) ?? (await loadOne(run, who.gh, ref))
122 if (chat) next.push(chat)
123 }
124 const ids = new Set(next.map(c => c.id))
125 await h.conversations.set(() => next)
126 await h.messages.set(list => list.filter(m => ids.has(m.conversationId) || m.pending))
127 for (const id of [...cursors.keys()]) if (!ids.has(id)) cursors.delete(id)
128 return true
129}
130
131// A repo with the topic but a broken meta.json is skipped, not fatal.
132async function loadOne(run: Run, gh: string, ref: ChatRef): Promise<Conversation | null> {
133 const chat = await loadChat(run, gh, ref)
134 return 'message' in chat ? null : chat
135}
136
137export async function pullMessages(h: Host, who: Who, id: string): Promise<true | GhError> {
138 const page = await fetchMessages(h.run, who.gh, id, cursors.get(id))
139 if (isGhError(page)) return page
140 cursors.set(id, page.cursor)
141 fetchedAt.set(id, h.now())
142 if (page.messages.length) {
143 await h.messages.set(list => {
144 const mine = list.filter(m => m.conversationId === id)
145 const rest = list.filter(m => m.conversationId !== id)
146 return [...rest, ...mergeMessages(mine, page.messages).slice(-KEEP)]
147 })
148 }
149 if (id === (await h.conversationId.get())) await markRead(h, id)
150 return true
151}
152
153async function lastReadMap(h: Host): Promise<Record<string, string>> {
154 const v = await h.store.get(LAST_READ)
155 return v && typeof v === 'object' ? (v as Record<string, string>) : {}
156}
157
158// The open chat counts as read up to its newest message.
159export async function markRead(h: Host, id: string) {
160 const last = (await h.messages.get()).filter(m => m.conversationId === id && !m.pending).pop()
161 if (last) {
162 const map = await lastReadMap(h)
163 if (map[id] !== last.id) await h.store.set(LAST_READ, { ...map, [id]: last.id })
164 }
165 await h.unread.set(u => (u[id] ? { ...u, [id]: 0 } : u))
166}
167
168export async function recount(h: Host, me: string) {
169 const map = await lastReadMap(h)
170 const all = await h.messages.get()
171 const openId = await h.conversationId.get()
172 const counts: Record<string, number> = {}
173 for (const chat of await h.conversations.get()) {
174 counts[chat.id] =
175 chat.id === openId ? 0 : unreadCount(all.filter(m => m.conversationId === chat.id), map[chat.id], me)
176 }
177 await h.unread.set(() => counts)
178}
179
180// Shows the message at once (pending), then swaps in the posted comment. On a
181// failure the pending one is removed and the error returned, so the screen can
182// give the text back to the composer.
183export async function sendMessage(
184 h: Host,
185 who: Who,
186 id: string,
187 text: string,
188): Promise<ChatMessage | GhError> {
189 const body = text.trim()
190 if (!body) return { kind: 'unknown', message: 'Mensagem vazia.' }
191 const temp: ChatMessage = {
192 id: `pending-${h.now()}`,
193 conversationId: id,
194 authorId: who.me,
195 text: body,
196 at: h.now(),
197 pending: true,
198 }
199 await h.messages.set(list => [...list, temp])
200 const posted = await postMessage(h.run, who.gh, id, body)
201 await h.messages.set(list => {
202 const rest = list.filter(m => m.id !== temp.id)
203 return isGhError(posted) ? rest : mergeMessages(rest, [posted])
204 })
205 if (isGhError(posted)) await fail(h, posted)
206 else await markRead(h, id)
207 return posted
208}
209
210// Creates the group's repo on GitHub and lists it.
211export async function createGroup(h: Host, who: Who, name: string) {
212 const made = await createChat(h.run, who.gh, { kind: 'group', name, me: who.me })
213 // `exists` only comes back for a DM.
214 if (made.kind === 'exists') return { kind: 'unknown', message: made.message } as GhError
215 if (isGhError(made)) return made
216 await h.conversations.set(l => [...l.filter(c => c.id !== made.id), made])
217 return made
218}
219
220// The DM with `login`: the one already listed (either of us may own it), else one
221// joined from a pending invitation, else a new repo with them invited.
222export async function openDm(
223 h: Host,
224 who: Who,
225 login: string,
226): Promise<Conversation | GhError> {
227 const find = async () =>
228 (await h.conversations.get()).find(
229 c => c.kind === 'dm' && dmPeer(c, who.me)?.toLowerCase() === login.toLowerCase(),
230 ) ??
231 (await h.conversations.get()).find(c => c.id.split('/')[1] === dmRepoName(who.me, login))
232 const listed = await find()
233 if (listed) return listed
234
235 const refreshed = await refreshChats(h, who)
236 if (!isGhError(refreshed)) {
237 const joined = await find()
238 if (joined) return joined
239 }
240
241 const run = h.run
242 const made = await createChat(run, who.gh, { kind: 'dm', me: who.me, with: login })
243 // Checked first: `exists` also has `kind` and `message`, like a GhError.
244 const chat =
245 made.kind === 'exists'
246 ? await loadChat(run, who.gh, { id: made.id, owner: who.me, name: made.id.split('/')[1]! })
247 : made
248 if (isGhError(chat)) return chat
249 await h.conversations.set(l => [...l.filter(c => c.id !== chat.id), chat])
250 return chat
251}
252hooks/chats.ts 389 lines1// Chats on GitHub: each chat is a private repo `tc-...` with the topic
2// `claude-team-chat`, its messages are the comments of issue #1 and its
3// members are the repo's collaborators (see docs/MVP.md, section 4).
4// Pure over the injected runner, like github.ts; every call goes through `ghApi`.
5
6import type { ChatMessage, ChatMeta, Conversation, MessageData } from '../types'
7import { ghApi, isLogin } from './github'
8import type { GhError, Run } from './github'
9
10export const TOPIC = 'claude-team-chat'
11export const PREFIX = 'tc-'
12export const LABEL = 'team-chat'
13// The repo description, also how an invitation is recognised before accepting it
14// (an invitee cannot read the private repo's topics yet).
15export const DESCRIPTION = 'Claude Team Chat'
16export const ISSUE = 1
17const META_PATH = '.team-chat/meta.json'
18const PER_PAGE = 100
19const MAX_PAGES = 10
20
21const unexpected: GhError = { kind: 'unknown', message: 'Resposta inesperada do GitHub.' }
22
23// --- Names -------------------------------------------------------------------
24
25export function groupSlug(name: string): string {
26 return (
27 name
28 .toLowerCase()
29 .normalize('NFD')
30 .replace(/[\u0300-\u036f]/g, '')
31 .replace(/[^a-z0-9]+/g, '-')
32 .replace(/^-+|-+$/g, '')
33 .slice(0, 60)
34 .replace(/-+$/, '') || 'grupo'
35 )
36}
37
38export const groupRepoName = (name: string) => `${PREFIX}${groupSlug(name)}`
39
40// One repo per pair, whoever creates it: logins lowercased, in alphabetical order.
41export function dmRepoName(a: string, b: string): string {
42 const [x, y] = [a.toLowerCase(), b.toLowerCase()].sort()
43 return `${PREFIX}dm-${x}-${y}`
44}
45
46// "owner/name" with both parts valid GitHub names; anything else never reaches a URL.
47export function isRepoId(id: string): boolean {
48 const [owner, name, extra] = id.split('/')
49 return (
50 extra === undefined && !!owner && !!name && isLogin(owner) && /^[A-Za-z0-9._-]{1,100}$/.test(name) && !/^\.+$/.test(name)
51 )
52}
53
54// The other person in a DM, as the chat header and contacts list show it.
55export const dmPeer = (chat: Conversation, me: string) => chat.memberIds.find(id => id.toLowerCase() !== me.toLowerCase())
56
57// --- Message bodies ----------------------------------------------------------
58
59const MARKER = /\n*<!-- team-chat:v1 (\{.*\}) -->\s*$/s
60
61// Readable text on github.com, then the structured data in an HTML comment. `<`
62// and `>` are escaped inside the JSON so no value can close the comment early.
63export function formatBody(text: string, data: MessageData = { kind: 'text' }): string {
64 const json = JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
65 return `${text.trim()}\n\n<!-- team-chat:v1 ${json} -->`
66}
67
68// A comment without a valid marker (one written on github.com) is plain text.
69export function parseBody(body: string): { text: string; data: MessageData } {
70 const found = MARKER.exec(body)
71 if (found) {
72 try {
73 const data = JSON.parse(found[1]!) as MessageData
74 if (data && typeof data === 'object' && typeof data.kind === 'string') {
75 return { text: body.slice(0, found.index).trim(), data }
76 }
77 } catch {
78 // Broken marker: show the whole body.
79 }
80 }
81 return { text: body.trim(), data: { kind: 'text' } }
82}
83
84type GitHubComment = {
85 id: number
86 body?: string | null
87 user?: { login?: string } | null
88 created_at: string
89 updated_at?: string
90}
91
92export function toMessage(chatId: string, c: GitHubComment): ChatMessage {
93 const { text, data } = parseBody(c.body ?? '')
94 return {
95 id: String(c.id),
96 conversationId: chatId,
97 authorId: c.user?.login ?? 'ghost',
98 text,
99 at: Date.parse(c.created_at),
100 data,
101 }
102}
103
104// --- Base64 (no Buffer or btoa in the mod runtime) ----------------------------
105
106const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
107
108export function base64Utf8(text: string): string {
109 const bytes: number[] = []
110 for (const ch of text) {
111 const cp = ch.codePointAt(0)!
112 if (cp < 0x80) bytes.push(cp)
113 else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 63))
114 else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63))
115 else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 63), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63))
116 }
117 let out = ''
118 for (let i = 0; i < bytes.length; i += 3) {
119 const a = bytes[i]!
120 const b = bytes[i + 1]
121 const c = bytes[i + 2]
122 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
123 out += B64[n >> 18]! + B64[(n >> 12) & 63]!
124 out += b === undefined ? '=' : B64[(n >> 6) & 63]!
125 out += c === undefined ? '=' : B64[n & 63]!
126 }
127 return out
128}
129
130// --- Chats -------------------------------------------------------------------
131
132type GitHubRepo = {
133 name: string
134 full_name: string
135 owner: { login: string }
136 private?: boolean
137 description?: string | null
138 topics?: string[]
139}
140
141export type ChatRef = { id: string; owner: string; name: string }
142
143const isChatRepo = (r: GitHubRepo) =>
144 r.name.toLowerCase().startsWith(PREFIX) && (r.topics ?? []).includes(TOPIC) && isRepoId(r.full_name)
145
146export type ChatList = { notModified: true; etag?: string } | { notModified: false; chats: ChatRef[]; etag?: string }
147
148// The chats I own or was added to. With the first page's `etag` from last time, an
149// unchanged first page answers 304 (free against the rate limit) and nothing else is read.
150export async function listChats(run: Run, gh: string, etag?: string): Promise<ChatList | GhError> {
151 const chats: ChatRef[] = []
152 let first: string | undefined
153 for (let page = 1; page <= MAX_PAGES; page++) {
154 const res = await ghApi<GitHubRepo[]>(
155 run,
156 gh,
157 `user/repos?affiliation=owner,collaborator&sort=full_name&per_page=${PER_PAGE}&page=${page}`,
158 page === 1 && etag ? { headers: { 'If-None-Match': etag } } : {},
159 )
160 if (!res.ok) return res.error
161 if (page === 1) {
162 first = res.etag
163 if (res.status === 304) return { notModified: true, ...(first ? { etag: first } : {}) }
164 }
165 if (!Array.isArray(res.data)) return unexpected
166 for (const r of res.data) if (isChatRepo(r)) chats.push({ id: r.full_name, owner: r.owner.login, name: r.name })
167 if (res.data.length < PER_PAGE) break
168 }
169 return { notModified: false, chats, ...(first ? { etag: first } : {}) }
170}
171
172export async function readMeta(run: Run, gh: string, id: string): Promise<ChatMeta | GhError> {
173 if (!isRepoId(id)) return unexpected
174 const res = await ghApi<ChatMeta>(run, gh, `repos/${id}/contents/${META_PATH}`, {
175 headers: { Accept: 'application/vnd.github.raw+json' },
176 })
177 if (!res.ok) return res.error
178 const m = res.data
179 if (!m || (m.kind !== 'group' && m.kind !== 'dm') || typeof m.name !== 'string') return unexpected
180 return { v: 1, kind: m.kind, name: m.name, createdBy: String(m.createdBy ?? '') }
181}
182
183export async function listMembers(run: Run, gh: string, id: string): Promise<string[] | GhError> {
184 if (!isRepoId(id)) return unexpected
185 const res = await ghApi<{ login: string }[]>(run, gh, `repos/${id}/collaborators?affiliation=all&per_page=${PER_PAGE}`)
186 if (!res.ok) return res.error
187 if (!Array.isArray(res.data)) return unexpected
188 return res.data.map(u => u.login).filter(isLogin)
189}
190
191// A chat as the screens draw it: meta.json for its name and kind, collaborators for members.
192export async function loadChat(run: Run, gh: string, ref: ChatRef): Promise<Conversation | GhError> {
193 const meta = await readMeta(run, gh, ref.id)
194 if ('message' in meta) return meta
195 const members = await listMembers(run, gh, ref.id)
196 if (!Array.isArray(members)) return members
197 return { id: ref.id, name: meta.name, kind: meta.kind, memberIds: members, owner: ref.owner }
198}
199
200// Stop GitHub emailing every member on every message.
201export async function muteChat(run: Run, gh: string, id: string): Promise<true | GhError> {
202 if (!isRepoId(id)) return unexpected
203 const res = await ghApi(run, gh, `repos/${id}/subscription`, {
204 method: 'PUT',
205 body: { subscribed: false, ignored: true },
206 })
207 return res.ok ? true : res.error
208}
209
210export type NewChat = { kind: 'group'; name: string; me: string } | { kind: 'dm'; me: string; with: string; name?: string }
211
212export type Exists = { kind: 'exists'; id: string; message: string }
213
214// Creates the repo, its topic, meta.json, the `chat` issue, mutes it and, for a DM,
215// invites the other person. Every step is a write on the person's GitHub account.
216// A group whose name is taken gets "-2", "-3"...; a DM repo that already exists on
217// my account comes back as `exists`, so the caller opens it instead.
218export async function createChat(run: Run, gh: string, spec: NewChat): Promise<Conversation | GhError | Exists> {
219 if (!isLogin(spec.me) || (spec.kind === 'dm' && !isLogin(spec.with))) return unexpected
220 const base = spec.kind === 'group' ? groupRepoName(spec.name) : dmRepoName(spec.me, spec.with)
221 const name = spec.kind === 'group' ? spec.name.trim() : (spec.name ?? spec.with)
222 if (!name) return { kind: 'unknown', message: 'Dê um nome ao grupo.' }
223
224 let repo: GitHubRepo | undefined
225 for (let attempt = 1; attempt <= 5 && !repo; attempt++) {
226 const repoName = attempt === 1 ? base : `${base}-${attempt}`
227 const res = await ghApi<GitHubRepo>(run, gh, 'user/repos', {
228 method: 'POST',
229 body: {
230 name: repoName,
231 description: DESCRIPTION,
232 private: true,
233 has_issues: true,
234 has_wiki: false,
235 has_projects: false,
236 auto_init: false,
237 },
238 })
239 if (res.ok) {
240 if (!res.data?.full_name || !isRepoId(res.data.full_name)) return unexpected
241 repo = res.data
242 } else if (res.status === 422) {
243 // The name is taken on this account.
244 if (spec.kind === 'dm') return { kind: 'exists', id: `${spec.me}/${repoName}`, message: 'Essa conversa já existe.' }
245 } else {
246 return res.error
247 }
248 }
249 if (!repo) return { kind: 'unknown', message: 'Já existem grupos demais com esse nome.' }
250 const id = repo.full_name
251
252 const meta: ChatMeta = { v: 1, kind: spec.kind, name, createdBy: spec.me }
253 const steps: [string, { method: string; body: unknown }][] = [
254 [`repos/${id}/topics`, { method: 'PUT', body: { names: [TOPIC] } }],
255 [
256 `repos/${id}/contents/${META_PATH}`,
257 { method: 'PUT', body: { message: 'Start team chat', content: base64Utf8(`${JSON.stringify(meta, null, 2)}\n`) } },
258 ],
259 [`repos/${id}/labels`, { method: 'POST', body: { name: LABEL, color: 'D97757' } }],
260 [`repos/${id}/issues`, { method: 'POST', body: { title: 'chat', labels: [LABEL] } }],
261 ]
262 for (const [path, opts] of steps) {
263 const res = await ghApi(run, gh, path, opts)
264 // A label that already exists is fine.
265 if (!res.ok && !(path.endsWith('/labels') && res.status === 422)) return res.error
266 }
267 await muteChat(run, gh, id)
268
269 const members = [spec.me]
270 if (spec.kind === 'dm') {
271 const invited = await addMember(run, gh, id, spec.with)
272 if (invited !== true) return invited
273 members.push(spec.with)
274 }
275 return { id, name, kind: spec.kind, memberIds: members, owner: repo.owner.login }
276}
277
278// --- Members -----------------------------------------------------------------
279
280// Invites a person; their mod accepts the invitation on its own (acceptInvites).
281export async function addMember(run: Run, gh: string, id: string, login: string): Promise<true | GhError> {
282 if (!isRepoId(id) || !isLogin(login)) return unexpected
283 const res = await ghApi(run, gh, `repos/${id}/collaborators/${login}`, { method: 'PUT', body: { permission: 'push' } })
284 return res.ok ? true : res.error
285}
286
287// Leaving removes me as a collaborator. The owner cannot leave (MVP): the repo is theirs.
288export async function leaveChat(run: Run, gh: string, chat: Conversation, me: string): Promise<true | GhError> {
289 if (!isRepoId(chat.id) || !isLogin(me)) return unexpected
290 if (chat.id.split('/')[0]!.toLowerCase() === me.toLowerCase()) {
291 return { kind: 'unknown', message: 'Você criou este grupo e não pode sair dele.' }
292 }
293 const res = await ghApi(run, gh, `repos/${chat.id}/collaborators/${me}`, { method: 'DELETE' })
294 return res.ok ? true : res.error
295}
296
297type GitHubInvitation = { id: number; repository?: GitHubRepo }
298
299// Accepts invitations to chat repos (prefix `tc-` and our description) and mutes
300// each one. Returns the chats joined; other invitations are left alone.
301export async function acceptInvites(run: Run, gh: string): Promise<ChatRef[] | GhError> {
302 const res = await ghApi<GitHubInvitation[]>(run, gh, `user/repository_invitations?per_page=${PER_PAGE}`)
303 if (!res.ok) return res.error
304 if (!Array.isArray(res.data)) return unexpected
305 const joined: ChatRef[] = []
306 for (const inv of res.data) {
307 const r = inv.repository
308 if (!r || !Number.isInteger(inv.id) || !isRepoId(r.full_name)) continue
309 if (!r.name.toLowerCase().startsWith(PREFIX) || r.description !== DESCRIPTION) continue
310 const ok = await ghApi(run, gh, `user/repository_invitations/${inv.id}`, { method: 'PATCH' })
311 if (!ok.ok) continue
312 await muteChat(run, gh, r.full_name)
313 joined.push({ id: r.full_name, owner: r.owner.login, name: r.name })
314 }
315 return joined
316}
317
318// --- Messages ----------------------------------------------------------------
319
320// Where the last read of a chat stopped. `since` is the newest `updated_at` seen
321// (GitHub's filter is inclusive, so repeats are merged by id); `etag` belongs to
322// the URL with that `since`, so an unchanged chat answers 304.
323export type Cursor = { since?: string; etag?: string }
324
325export type MessagePage = { messages: ChatMessage[]; cursor: Cursor }
326
327export async function fetchMessages(run: Run, gh: string, id: string, cursor: Cursor = {}): Promise<MessagePage | GhError> {
328 if (!isRepoId(id)) return unexpected
329 const all: GitHubComment[] = []
330 let etag: string | undefined
331 for (let page = 1; page <= MAX_PAGES; page++) {
332 const since = cursor.since ? `&since=${encodeURIComponent(cursor.since)}` : ''
333 const res = await ghApi<GitHubComment[]>(
334 run,
335 gh,
336 `repos/${id}/issues/${ISSUE}/comments?per_page=${PER_PAGE}&page=${page}${since}`,
337 page === 1 && cursor.etag ? { headers: { 'If-None-Match': cursor.etag } } : {},
338 )
339 if (!res.ok) return res.error
340 if (page === 1) {
341 etag = res.etag
342 if (res.status === 304) return { messages: [], cursor }
343 }
344 if (!Array.isArray(res.data)) return unexpected
345 all.push(...res.data)
346 if (res.data.length < PER_PAGE) break
347 }
348 const since = all.reduce<string | undefined>((max, c) => {
349 const t = c.updated_at ?? c.created_at
350 return !max || t > max ? t : max
351 }, cursor.since)
352 // The etag is kept only while `since` (and so the URL) stays the same.
353 return {
354 messages: all.filter(c => Number.isInteger(c.id)).map(c => toMessage(id, c)),
355 cursor: { ...(since ? { since } : {}), ...(etag && since === cursor.since ? { etag } : {}) },
356 }
357}
358
359export async function postMessage(
360 run: Run,
361 gh: string,
362 id: string,
363 text: string,
364 data: MessageData = { kind: 'text' },
365): Promise<ChatMessage | GhError> {
366 if (!isRepoId(id)) return unexpected
367 if (!text.trim()) return { kind: 'unknown', message: 'Mensagem vazia.' }
368 const res = await ghApi<GitHubComment>(run, gh, `repos/${id}/issues/${ISSUE}/comments`, {
369 method: 'POST',
370 body: { body: formatBody(text, data) },
371 })
372 if (!res.ok) return res.error
373 if (!res.data || !Number.isInteger(res.data.id)) return unexpected
374 return toMessage(id, res.data)
375}
376
377// Adds new messages to a chat's list: by id (an edit replaces), sorted by time.
378export function mergeMessages(have: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
379 const byId = new Map(have.map(m => [m.id, m]))
380 for (const m of incoming) byId.set(m.id, m)
381 return [...byId.values()].sort((a, b) => a.at - b.at || Number(a.id) - Number(b.id))
382}
383
384// Messages from others newer than the last one I read (comment ids only grow).
385export function unreadCount(messages: ChatMessage[], lastReadId: string | undefined, me: string): number {
386 const last = Number(lastReadId ?? 0)
387 return messages.filter(m => !m.pending && Number(m.id) > last && m.authorId.toLowerCase() !== me.toLowerCase()).length
388}
389hooks/views.tsx 1280 lines1import type {
2 BoxProps,
3 SvgProps,
4 ButtonProps,
5 Color,
6 ElementConstructor,
7 ImageProps,
8 InputProps,
9 LinkProps,
10 RenderChildren,
11 TextProps,
12} from 'claude-code'
13
14import type { Avatar as AvatarFile, ChatMessage, ChatUser, Conversation, GhState, Profile, SyncState, View } from '../types'
15
16export type Ui = {
17 Box: ElementConstructor<BoxProps>
18 Text: ElementConstructor<TextProps>
19 Button: ElementConstructor<ButtonProps>
20 // The mobile app draws no text field yet.
21 Input?: ElementConstructor<InputProps>
22 // Every surface has Link today; kept optional so a missing one falls back to text.
23 Link?: ElementConstructor<LinkProps>
24 // The terminal draws a PNG file; desktop and mobile draw an Svg instead.
25 Image?: ElementConstructor<ImageProps>
26 Svg?: ElementConstructor<SvgProps>
27}
28
29export type Actions = {
30 go: (view: View) => void
31 open: (conversationId: string) => void
32 openDm: (userId: string) => void
33 send: (text: string) => void
34 setDraft: (text: string) => void
35 createGroup: (name: string) => void
36 setGroupDraft: (text: string) => void
37 setGroupSearch: (text: string) => void
38 setContactSearch: (text: string) => void
39 addContact: (login: string) => void
40 setContactDraft: (text: string) => void
41 // Runs the `gh` check again from the setup screen.
42 retryGh: () => void
43 // Confirms the GitHub identity and leaves the setup screen.
44 start: () => void
45}
46
47export type Ctx = {
48 ui: Ui
49 cols: number
50 rows: number
51 // Epoch ms of this draw; "ativo há X min" is measured against it.
52 now: number
53 view: View
54 gh: GhState
55 // Null until the person confirms their GitHub identity.
56 profile: Profile | null
57 contacts: ChatUser[]
58 conversations: Conversation[]
59 // Undefined until the person has a group or a direct chat.
60 current?: Conversation
61 messages: ChatMessage[]
62 // The newest message of each conversation, for the list previews.
63 lastMessages: Record<string, ChatMessage>
64 unread: Record<string, number>
65 // Downloaded GitHub pictures by login.
66 avatars: Record<string, AvatarFile>
67 draft: string
68 groupDraft: string
69 contactDraft: string
70 // What the search fields of the group and contact lists hold.
71 groupSearch: string
72 contactSearch: string
73 contactError: string
74 // Actions under way, by name ('group', 'contact', 'dm'), with what to say.
75 busy: Record<string, string>
76 // The terminal spinner's frame; advances while something is loading.
77 frame: number
78 // The background sync with GitHub.
79 sync: SyncState
80 act: Actions
81}
82
83// Every screen past setup has a profile.
84type SignedIn = Ctx & { profile: Profile }
85
86
87const AUTHOR_COLORS: Color[] = ['suggestion', 'merged', 'autoAccept', 'ide', 'remember', 'planMode']
88
89// The canvas palette (dark pane, clay accent). Raw colors are fine on every
90// surface (`Color` admits hex); the terminal keeps theme keys where it reads better.
91const P = {
92 accent: '#D97757',
93 onAccent: '#1F1E1C',
94 bubble: '#302F2C',
95 row: '#2E2D2A',
96 tile: '#33312D',
97 tileOn: '#3A2C25',
98 accentSoft: '#E8916F',
99 muted: '#A8A49C',
100 line: '#3A3936',
101}
102// Avatar fills and the matching name colors, as in the canvas.
103const FILLS = ['#7AA7E8', '#B49AE6', '#8CC48C', '#E0B25C', '#E88FA8', '#6FC2C2']
104const NAMES = ['#9DBEF0', '#C7B5EE', '#A6D4A6', '#EBC57F', '#F0AEC0', '#93D3D3']
105const ME_FILL = '#E8916F'
106
107const MINUTE = 60_000
108const HOUR = 60 * MINUTE
109const DAY = 24 * HOUR
110
111export const activeAgo = (at: number | undefined, now: number): string => {
112 if (at === undefined) return ''
113 const ago = now - at
114 if (ago < MINUTE) return 'ativo agora'
115 if (ago < HOUR) return `ativo há ${Math.floor(ago / MINUTE)} min`
116 if (ago < DAY) return `ativo há ${Math.floor(ago / HOUR)} h`
117 return `ativo há ${Math.floor(ago / DAY)} d`
118}
119
120// Desktop and mobile draw pictures (Svg) and colors as the canvas does; the
121// terminal is a grid of cells.
122const gfx = (ctx: Ctx) => ctx.ui.Svg !== undefined
123
124const meId = (ctx: Ctx) => ctx.profile?.id ?? ''
125
126const colorIndex = (id: string) => {
127 let sum = 0
128 for (const ch of id) sum += ch.charCodeAt(0)
129 return sum
130}
131
132const authorColor = (ctx: Ctx, id: string): Color => {
133 if (gfx(ctx)) return id === meId(ctx) ? P.accentSoft : NAMES[colorIndex(id) % NAMES.length]!
134 if (id === meId(ctx)) return 'claude'
135 return AUTHOR_COLORS[colorIndex(id) % AUTHOR_COLORS.length] ?? 'suggestion'
136}
137
138const avatarFill = (ctx: Ctx, id: string) => (id === meId(ctx) ? ME_FILL : FILLS[colorIndex(id) % FILLS.length]!)
139
140const clock = (at: number) => {
141 const d = new Date(at)
142 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
143}
144
145// "10:11" today, "ontem", else day/month: the time column of the lists.
146const when = (at: number, now: number) => {
147 const day = (t: number) => new Date(t).toDateString()
148 if (day(at) === day(now)) return clock(at)
149 if (day(at) === day(now - DAY)) return 'ontem'
150 const d = new Date(at)
151 return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`
152}
153
154const initials = (user: { name: string; handle?: string }) => {
155 const words = user.name.trim().split(/\s+/).filter(Boolean)
156 const letters = words.length > 1 ? `${words[0]![0]}${words[words.length - 1]![0]}` : (words[0] ?? user.handle ?? '?').slice(0, 2)
157 return letters.toUpperCase()
158}
159
160const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
161
162const matches = (query: string, ...texts: string[]) => {
163 const q = query.trim().toLowerCase()
164 return !q || texts.some(t => t.toLowerCase().includes(q))
165}
166
167const people = (ctx: SignedIn): ChatUser[] => [
168 { ...ctx.profile, lastActiveAt: ctx.now },
169 ...ctx.contacts,
170]
171
172const personById = (ctx: SignedIn, id: string) => people(ctx).find(p => p.id === id)
173
174const titleOf = (c: Conversation) => (c.kind === 'group' ? `# ${c.name}` : `@ ${c.name}`)
175
176const unreadOf = (ctx: Ctx, kind: Conversation['kind']) =>
177 ctx.conversations
178 .filter(c => c.kind === kind && c.id !== ctx.current?.id)
179 .reduce((n, c) => n + (ctx.unread[c.id] ?? 0), 0)
180
181// ── Pictures ─────────────────────────────────────────────────────────────────
182
183const escapeXml = (text: string) =>
184 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
185
186// Stroke icons from the canvas, 24×24 paths.
187const ICONS = {
188 hash: '<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18"/>',
189 search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4-4"/>',
190 users:
191 '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.6 3.3-5.5 6.5-5.5s5.7 1.9 6.5 5.5"/><path d="M16 4.6a3.5 3.5 0 0 1 0 6.8M18.5 14.8c1.6.8 2.6 2.6 3 5.2"/>',
192 chat: '<path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/>',
193 file: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>',
194 clip: '<path d="M20 11.5l-8.1 8.1a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/>',
195 spark: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/>',
196 task: '<path d="M9 11l3 3 8-8"/><path d="M20 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
197 plus: '<path d="M12 5v14M5 12h14"/>',
198 user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c1-4 4-6 8-6s7 2 8 6"/>',
199}
200
201const iconSvg = (paths: string, px: number, stroke: string) =>
202 `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
203
204// An icon on a rounded tile, like the group icon of the canvas list.
205const tileSvg = (paths: string, px: number, fill: string, stroke: string) =>
206 `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 ${px} ${px}">` +
207 `<rect width="${px}" height="${px}" rx="${Math.round(px * 0.28)}" fill="${fill}"/>` +
208 `<g transform="translate(${px / 2 - 9} ${px / 2 - 9}) scale(0.75)" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</g></svg>`
209
210// Graphical surfaces only; the terminal gets nothing (callers add a glyph).
211// Each Svg needs real alt text: a blank one is not drawn on the desktop.
212const ICON_ALT: Record<keyof typeof ICONS, string> = {
213 hash: 'grupo',
214 search: 'buscar',
215 users: 'membros',
216 chat: 'chat',
217 file: 'arquivos',
218 clip: 'anexar',
219 spark: 'contexto',
220 task: 'tarefa',
221 plus: 'adicionar',
222 user: 'perfil',
223}
224
225const Icon = (ctx: Ctx, name: keyof typeof ICONS, px: number, stroke = P.muted) => {
226 const { Box, Svg } = ctx.ui
227 if (!Svg) return ''
228 return (
229 <Box flexShrink={0}>
230 <Svg source={iconSvg(ICONS[name], px, stroke)} alt={ICON_ALT[name]} width={px} height={px} />
231 </Box>
232 )
233}
234
235const Tile = (ctx: Ctx, name: keyof typeof ICONS, on: boolean) => {
236 const { Box, Svg } = ctx.ui
237 if (!Svg) return ''
238 return (
239 <Box flexShrink={0}>
240 <Svg
241 source={tileSvg(ICONS[name], 36, on ? P.tileOn : P.tile, on ? P.accentSoft : '#C9C5BC')}
242 alt={ICON_ALT[name]}
243 width={36}
244 height={36}
245 />
246 </Box>
247 )
248}
249
250// A control of a later MVP phase, in dark gray and not pressable (a Button has
251// no disabled state), so the screen shows where the feature will live.
252const OFF = '#5A5853'
253
254const Soon = (ctx: Ctx, key: string, label: string, icon?: keyof typeof ICONS, pill = false) => {
255 const { Box, Text } = ctx.ui
256 const g = gfx(ctx)
257 return (
258 <Box
259 key={key}
260 flexDirection="row"
261 gap={1}
262 alignItems="center"
263 flexShrink={0}
264 borderStyle={g && pill ? 'round' : undefined}
265 borderColor={g && pill ? OFF : undefined}
266 paddingX={g && pill ? 1 : 0}
267 >
268 {icon ? Icon(ctx, icon, 14, OFF) : ''}
269 <Text color={OFF} size="caption">
270 {g ? label : `[${label}]`}
271 </Text>
272 </Box>
273 )
274}
275
276type AvatarSize = 'sm' | 'md' | 'xl' | 'lg'
277
278// Cells for the terminal's Image, CSS pixels for an Svg. A Box's width and
279// height are cells on every surface, so the Svg's Box gets no size of its own.
280const AVATAR_CELLS: Record<AvatarSize, { columns: number; rows: number; px: number }> = {
281 sm: { columns: 2, rows: 1, px: 28 },
282 md: { columns: 2, rows: 1, px: 32 },
283 xl: { columns: 6, rows: 3, px: 56 },
284 lg: { columns: 8, rows: 4, px: 84 },
285}
286
287// A round avatar as SVG: the GitHub photo when we have it, else initials on color.
288const avatarSvg = (ctx: Ctx, user: { id: string; name: string; handle?: string }, px: number) => {
289 const png = ctx.avatars[user.id]?.png
290 const r = px / 2
291 const inner = png
292 ? `<clipPath id="c"><circle cx="${r}" cy="${r}" r="${r}"/></clipPath>` +
293 `<image href="data:image/png;base64,${png}" width="${px}" height="${px}" clip-path="url(#c)" preserveAspectRatio="xMidYMid slice"/>`
294 : `<circle cx="${r}" cy="${r}" r="${r}" fill="${avatarFill(ctx, user.id)}"/>` +
295 `<text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="600" font-size="${Math.round(px * 0.38)}" fill="${P.onAccent}">${escapeXml(initials(user))}</text>`
296 return `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 ${px} ${px}">${inner}</svg>`
297}
298
299// The GitHub photo where the surface can draw it; else initials.
300const Avatar = (ctx: Ctx, user: { id: string; name: string; handle?: string }, size: AvatarSize) => {
301 const { Box, Text, Image, Svg } = ctx.ui
302 const { columns, rows, px } = AVATAR_CELLS[size]
303 const file = ctx.avatars[user.id]?.file
304 const alt = initials(user)
305 const color = authorColor(ctx, user.id)
306
307 if (Svg) {
308 return (
309 <Box flexShrink={0}>
310 <Svg source={avatarSvg(ctx, user, px)} alt={alt} width={px} height={px} />
311 </Box>
312 )
313 }
314 if (Image && file && file.endsWith('.png')) {
315 return (
316 <Box width={columns} height={rows} flexShrink={0}>
317 <Image source={{ file, format: 'png' }} columns={columns} rows={rows} alt={alt} />
318 </Box>
319 )
320 }
321 if (size === 'sm' || size === 'md') {
322 return (
323 <Box width={columns} flexShrink={0}>
324 <Text bold color={color}>
325 {alt.padEnd(2)}
326 </Text>
327 </Box>
328 )
329 }
330 return (
331 <Box
332 width={columns}
333 height={rows}
334 flexShrink={0}
335 borderStyle="round"
336 borderColor={color}
337 justifyContent="center"
338 alignItems="center"
339 >
340 <Text bold color={color}>
341 {alt}
342 </Text>
343 </Box>
344 )
345}
346
347// ── Shared pieces ────────────────────────────────────────────────────────────
348
349// A Box has one border for all four sides, so a rule is a line of text cut at
350// the pane's edge.
351const Divider = (ctx: Ctx) => {
352 const { Box, Text } = ctx.ui
353 // One row, clipped: a truncated Text would end in "…" where the pane ends.
354 return (
355 <Box height={1} overflow="hidden" flexShrink={0}>
356 <Text color={gfx(ctx) ? P.line : undefined} dimColor={!gfx(ctx)} wrap="wrap">
357 {'─'.repeat(Math.max(400, ctx.cols))}
358 </Text>
359 </Box>
360 )
361}
362
363// Where "←" leads: back to the chat that is open, named, or just "Chat".
364const backLabel = (ctx: Ctx) => (ctx.current ? titleOf(ctx.current) : 'Chat')
365
366// Small all-caps label over a list or field, like the canvas's section titles.
367const Section = (ctx: Ctx, text: string) => {
368 const { Text } = ctx.ui
369 return (
370 <Text dimColor mono size="caption" wrap="truncate">
371 {text.toUpperCase()}
372 </Text>
373 )
374}
375
376// The canvas's orange count: a pill on graphical surfaces, "●2" on the terminal.
377const Badge = (ctx: Ctx, n: number, key: string) => {
378 const { Box, Text } = ctx.ui
379 if (n <= 0) return ''
380 if (!gfx(ctx)) {
381 return (
382 <Text key={key} bold color="claude">
383 ●{n}
384 </Text>
385 )
386 }
387 return (
388 <Box key={key} backgroundColor={P.accent} borderStyle="round" borderColor={P.accent} paddingX={1} flexShrink={0}>
389 <Text bold size="caption" color={P.onAccent}>
390 {n}
391 </Text>
392 </Box>
393 )
394}
395
396// ── Loading ──────────────────────────────────────────────────────────────────
397
398const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
399
400// An arc that turns by itself (SMIL in an interactive Svg), so the desktop
401// needs no redraws to animate it.
402const spinnerSvg = (px: number, color: string) =>
403 `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 24 24">` +
404 `<circle cx="12" cy="12" r="9" fill="none" stroke="${color}" stroke-opacity="0.25" stroke-width="3"/>` +
405 `<path d="M12 3a9 9 0 0 1 9 9" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round">` +
406 `<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="0.8s" repeatCount="indefinite"/>` +
407 `</path></svg>`
408
409// A turning spinner and what is happening: an animated Svg on the desktop, a
410// braille frame that the mod advances on the terminal.
411const Spinner = (ctx: Ctx, key: string, label: string, size: 'sm' | 'md' = 'sm') => {
412 const { Box, Text, Svg } = ctx.ui
413 const px = size === 'sm' ? 14 : 20
414 return (
415 <Box key={key} flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
416 {Svg ? (
417 <Svg source={spinnerSvg(px, P.accent)} alt="carregando" width={px} height={px} isInteractive />
418 ) : (
419 <Text color="claude">{FRAMES[ctx.frame % FRAMES.length]}</Text>
420 )}
421 {label ? (
422 <Text dimColor size={size === 'sm' ? 'caption' : undefined}>
423 {label}
424 </Text>
425 ) : (
426 ''
427 )}
428 </Box>
429 )
430}
431
432// A big centered loader for a screen whose content is on its way.
433const Loading = (ctx: Ctx, key: string, title: string, line: string) => {
434 const { Box, Text } = ctx.ui
435 return (
436 <Box key={key} flexDirection="column" flexGrow={1} justifyContent="center" alignItems="center" gap={1}>
437 {Spinner(ctx, `${key}-spin`, '', 'md')}
438 <Text bold>{title}</Text>
439 <Text dimColor>{line}</Text>
440 </Box>
441 )
442}
443
444// Nothing synced yet: the first pass over GitHub is still running.
445const firstSync = (ctx: Ctx) => ctx.sync.status === 'syncing' && ctx.sync.at === undefined
446
447// A rounded chat bubble: background and a same-colored round border on
448// graphical surfaces (the border gives the corners), plain color on the terminal.
449// A message still being posted is drawn faded.
450const Bubble = (ctx: Ctx, key: string, text: string, mine: boolean, pending = false) => {
451 const { Box, Text } = ctx.ui
452 if (!gfx(ctx)) {
453 return (
454 <Box key={key} backgroundColor={mine && !pending ? 'claude' : undefined} paddingX={mine ? 1 : 0}>
455 <Text color={mine && !pending ? 'inverseText' : undefined} dimColor={pending}>
456 {text}
457 </Text>
458 </Box>
459 )
460 }
461 const bg = mine ? (pending ? '#7A4A36' : P.accent) : P.bubble
462 return (
463 <Box key={key} backgroundColor={bg} borderStyle="round" borderColor={bg} paddingX={1} flexShrink={1}>
464 <Text color={mine ? P.onAccent : undefined}>{text}</Text>
465 </Box>
466 )
467}
468
469const AppHeader = (ctx: SignedIn) => {
470 const { Box, Text, Button } = ctx.ui
471 const groups = unreadOf(ctx, 'group')
472 const dms = unreadOf(ctx, 'dm')
473
474 return (
475 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={1} paddingY={gfx(ctx) ? 1 : 0}>
476 <Box flexDirection="row" gap={1} alignItems="center" flexShrink={1}>
477 {Avatar(ctx, ctx.profile, 'md')}
478 <Box flexDirection="column" flexShrink={1}>
479 <Button key="nav-profile" plain onPress={() => ctx.act.go('profile')}>
480 <Text bold>{ctx.profile.name}</Text>
481 </Button>
482 <Text dimColor size="caption" wrap="truncate">
483 @{ctx.profile.handle} · perfil
484 </Text>
485 </Box>
486 </Box>
487 <Box flexDirection="row" gap={1} flexShrink={0} alignItems="center">
488 {ctx.sync.status === 'syncing' ? Spinner(ctx, 'sync-spin', '') : ''}
489 <Button
490 key="nav-groups"
491 variant={ctx.view === 'groups' ? 'primary' : 'secondary'}
492 onPress={() => ctx.act.go(ctx.view === 'groups' ? 'chat' : 'groups')}
493 >
494 Grupos
495 </Button>
496 {Badge(ctx, groups, 'badge-groups')}
497 <Button
498 key="nav-contacts"
499 variant={ctx.view === 'contacts' ? 'primary' : 'secondary'}
500 onPress={() => ctx.act.go(ctx.view === 'contacts' ? 'chat' : 'contacts')}
501 >
502 Contatos
503 </Button>
504 {Badge(ctx, dms, 'badge-contacts')}
505 </Box>
506 </Box>
507 )
508}
509
510// "← Chat Grupos · 3": back on the left, as in the canvas.
511const SubHeader = (ctx: Ctx, back: string, title: string, detail?: string, end?: RenderChildren) => {
512 const { Box, Text, Button } = ctx.ui
513 return (
514 <Box flexDirection="row" gap={2} alignItems="center" paddingY={gfx(ctx) ? 1 : 0}>
515 <Button key="back" plain dimColor onPress={() => ctx.act.go('chat')}>
516 ← {back}
517 </Button>
518 <Box flexGrow={1} flexShrink={1}>
519 <Text wrap="truncate">
520 <Text bold>{title}</Text>
521 {detail ? <Text dimColor> · {detail}</Text> : ''}
522 </Text>
523 </Box>
524 {end ?? ''}
525 </Box>
526 )
527}
528
529// A search field over a list (the canvas's "Buscar grupos").
530const Search = (ctx: Ctx, key: string, placeholder: string, value: string, onInput: (v: string) => void) => {
531 const { Box, Input } = ctx.ui
532 if (!Input) return ''
533 return (
534 <Box flexDirection="row" gap={1} alignItems="center" width="100%" marginTop={1}>
535 {Icon(ctx, 'search', 16)}
536 <Box flexGrow={1}>
537 <Input key={key} placeholder={placeholder} value={value} submitLabel="buscar" onInput={onInput} onSubmit={onInput} />
538 </Box>
539 </Box>
540 )
541}
542
543// The field at the bottom of a screen, full width: an optional caps label,
544// the field (the surface draws it and its Enter button), then an error or a
545// hint split left and right like the canvas.
546const Composer = (
547 ctx: Ctx,
548 field: Omit<InputProps, 'autoFocus'>,
549 hint: [string, string?],
550 opts: { label?: string; error?: string; tools?: RenderChildren; busy?: string } = {},
551) => {
552 const { Box, Text, Input } = ctx.ui
553 const input = Input ? <Input autoFocus {...field} /> : <Text dimColor>Envio indisponível aqui.</Text>
554 return (
555 <Box flexDirection="column" width="100%" gap={gfx(ctx) ? 1 : 0}>
556 {Divider(ctx)}
557 {opts.tools ? (
558 <Box flexDirection="row" gap={1} flexWrap="wrap">
559 {opts.tools}
560 </Box>
561 ) : (
562 ''
563 )}
564 {opts.label ? Section(ctx, opts.label) : ''}
565 {gfx(ctx) ? (
566 // A column stretches its children across: the app's field sizes itself,
567 // so this is the most room the API lets us give it.
568 <Box flexDirection="column" alignItems="stretch" width="100%" flexGrow={1}>
569 {input}
570 </Box>
571 ) : (
572 <Box borderStyle="round" borderColor={opts.error ? 'error' : 'promptBorder'} paddingX={1}>
573 {input}
574 </Box>
575 )}
576 {opts.busy ? (
577 Spinner(ctx, `${field.key}-busy`, opts.busy)
578 ) : opts.error ? (
579 <Text color="error" wrap="truncate">
580 {opts.error}
581 </Text>
582 ) : (
583 <Box flexDirection="row" justifyContent="space-between" gap={2}>
584 <Text dimColor size="caption" wrap="truncate">
585 {hint[0]}
586 </Text>
587 {hint[1] ? (
588 <Text dimColor size="caption" wrap="truncate">
589 {hint[1]}
590 </Text>
591 ) : (
592 ''
593 )}
594 </Box>
595 )}
596 </Box>
597 )
598}
599
600// The sync failed: say why until the next pass works.
601const SyncError = (ctx: Ctx) => {
602 const { Box, Text } = ctx.ui
603 if (ctx.sync.status !== 'error') return ''
604 return (
605 <Box key="sync-error" flexDirection="row" gap={1} paddingY={gfx(ctx) ? 1 : 0}>
606 <Text color="warning" size="caption" wrap="truncate">
607 ⚠ Sem sincronizar com o GitHub: {ctx.sync.message} Tentando de novo…
608 </Text>
609 </Box>
610 )
611}
612
613const Frame = (ctx: SignedIn, ...children: RenderChildren[]) => {
614 const { Box } = ctx.ui
615 return (
616 <Box flexDirection="column" height={ctx.rows} width="100%">
617 {AppHeader(ctx)}
618 {Divider(ctx)}
619 {SyncError(ctx)}
620 {children}
621 </Box>
622 )
623}
624
625// A centered "nothing here yet" block: an icon, a title, a line, buttons.
626const Empty = (
627 ctx: Ctx,
628 key: string,
629 icon: keyof typeof ICONS,
630 title: string,
631 line: string,
632 ...actions: RenderChildren[]
633) => {
634 const { Box, Text } = ctx.ui
635 return (
636 <Box key={key} flexDirection="column" flexGrow={1} justifyContent="center" alignItems="center" gap={1}>
637 {Tile(ctx, icon, true)}
638 <Text bold>{title}</Text>
639 <Text dimColor>{line}</Text>
640 {actions.length > 0 ? (
641 <Box flexDirection="row" gap={1}>
642 {actions}
643 </Box>
644 ) : (
645 ''
646 )}
647 </Box>
648 )
649}
650
651// "@login · ativo há 5 min", without the dot when there is no activity yet.
652const handleLine = (ctx: Ctx, p: ChatUser) => {
653 const ago = activeAgo(p.lastActiveAt, ctx.now)
654 return ago ? `@${p.handle} · ${ago}` : `@${p.handle}`
655}
656
657// ── Setup ────────────────────────────────────────────────────────────────────
658
659const Command = (ctx: Ctx, cmd: string) => {
660 const { Text } = ctx.ui
661 return (
662 <Text bold mono color="claude">
663 {cmd}
664 </Text>
665 )
666}
667
668const RetryButton = (ctx: Ctx) => {
669 const { Button } = ctx.ui
670 return (
671 <Button key="setup-retry" variant="secondary" onPress={() => ctx.act.retryGh()}>
672 Tentar de novo
673 </Button>
674 )
675}
676
677// One "something to fix" block: a title, how to fix it, and Retry.
678const Problem = (ctx: Ctx, title: string, ...lines: RenderChildren[]) => {
679 const { Box, Text } = ctx.ui
680 return (
681 <Box flexDirection="column" gap={1} borderStyle="round" borderColor="warning" paddingX={2} paddingY={1}>
682 <Text bold color="warning">
683 {title}
684 </Text>
685 {lines}
686 <Box flexDirection="row">{RetryButton(ctx)}</Box>
687 </Box>
688 )
689}
690
691const GhStep = (ctx: Ctx) => {
692 const { Box, Text, Button } = ctx.ui
693 const gh = ctx.gh
694
695 switch (gh.status) {
696 case 'checking':
697 return Spinner(ctx, 'gh-checking', 'Verificando o gh e a sua conta do GitHub…', 'md')
698 case 'missing':
699 return Problem(
700 ctx,
701 'gh não encontrado',
702 <Text>
703 O chat usa o GitHub CLI. Instale em{' '}
704 <Text color="claude">https://cli.github.com</Text> (no Mac: {Command(ctx, 'brew install gh')}).
705 </Text>,
706 <Text>Depois rode {Command(ctx, 'gh auth login')} no terminal.</Text>,
707 )
708 case 'logged-out':
709 return Problem(
710 ctx,
711 'gh sem login',
712 <Text>Rode {Command(ctx, 'gh auth login')} no terminal e entre com a sua conta do GitHub.</Text>,
713 )
714 case 'no-repo-scope':
715 return Problem(
716 ctx,
717 'Falta o escopo repo',
718 <Text dimColor>Conectado como @{gh.login}.</Text>,
719 <Text>
720 O chat guarda as conversas em repositórios privados. Rode {Command(ctx, 'gh auth refresh -s repo')} no
721 terminal.
722 </Text>,
723 )
724 case 'error':
725 return Problem(ctx, 'Não deu para falar com o gh', <Text>{gh.message}</Text>)
726 case 'ok':
727 return (
728 <Box flexDirection="column" gap={2}>
729 <Box
730 flexDirection="row"
731 gap={2}
732 alignItems="center"
733 borderStyle="round"
734 borderColor={gfx(ctx) ? P.line : undefined}
735 borderDimColor={!gfx(ctx)}
736 backgroundColor={gfx(ctx) ? '#2B2A27' : undefined}
737 paddingX={2}
738 paddingY={1}
739 >
740 {Avatar(ctx, gh.profile, gfx(ctx) ? 'md' : 'lg')}
741 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
742 <Text bold>{gh.profile.name}</Text>
743 <Text dimColor mono size="caption">
744 @{gh.profile.handle}
745 </Text>
746 </Box>
747 <Text color="success" size="caption">
748 ✓ Conectado pelo gh
749 </Text>
750 </Box>
751 <Button key="setup-start" variant="primary" onPress={() => ctx.act.start()}>
752 Começar
753 </Button>
754 <Text dimColor size="caption">
755 Sua identidade é a do GitHub. Nada da sua sessão é compartilhado sem você confirmar.
756 </Text>
757 </Box>
758 )
759 }
760}
761
762const SetupView = (ctx: Ctx) => {
763 const { Box, Text } = ctx.ui
764 return (
765 <Box flexDirection="column" height={ctx.rows} width="100%" justifyContent="center" paddingX={2} gap={2}>
766 {gfx(ctx) ? (
767 Tile(ctx, 'chat', true)
768 ) : (
769 <Text bold color="claude">
770 ✻ Team Chat
771 </Text>
772 )}
773 <Box flexDirection="column" gap={1}>
774 <Text bold size="title">
775 Trabalhe junto, cada um com seu Claude
776 </Text>
777 <Text dimColor>
778 Converse com o time, compartilhe o contexto da sua sessão e passe tarefas sem copiar e colar.
779 </Text>
780 </Box>
781 {GhStep(ctx)}
782 </Box>
783 )
784}
785
786// ── Chat ─────────────────────────────────────────────────────────────────────
787
788type Run = { authorId: string; at: number; items: ChatMessage[] }
789
790const toRuns = (list: ChatMessage[]): Run[] => {
791 const runs: Run[] = []
792 for (const m of list) {
793 const last = runs[runs.length - 1]
794 if (last && last.authorId === m.authorId && m.at - last.at < 5 * 60_000) {
795 last.items.push(m)
796 last.at = m.at
797 } else {
798 runs.push({ authorId: m.authorId, at: m.at, items: [m] })
799 }
800 }
801 return runs
802}
803
804// Keep the newest runs that fit, so the list sits on the composer. A bubble
805// takes its text's lines, plus its border's two rows where one is drawn.
806const fitRuns = (ctx: SignedIn, runs: Run[], rows: number): Run[] => {
807 const width = Math.max(10, ctx.cols - 8)
808 const border = gfx(ctx) ? 2 : 0
809 const kept: Run[] = []
810 let used = 0
811 for (let i = runs.length - 1; i >= 0; i--) {
812 const run = runs[i]!
813 const lines = run.items.reduce((n, m) => n + Math.ceil((m.text.length + 4) / width) + border, 0)
814 const cost = 2 + lines
815 if (used + cost > rows && kept.length > 0) break
816 used += cost
817 kept.unshift(run)
818 }
819 return kept
820}
821
822// A group run as the canvas draws it: avatar, colored name and time, bubbles.
823// Your own: right-aligned, no avatar, orange bubbles. In a direct chat, no
824// names or avatars: bubbles and the time under them.
825const MessageRun = (ctx: SignedIn, run: Run, direct: boolean) => {
826 const { Box, Text } = ctx.ui
827 const mine = run.authorId === ctx.profile.id
828 const person = personById(ctx, run.authorId) ?? { id: run.authorId, name: run.authorId, handle: run.authorId }
829 const first = run.items[0]!
830 const time = (
831 <Text dimColor mono size="caption">
832 {clock(direct ? run.at : first.at)}
833 </Text>
834 )
835 const bubbles = run.items.map(m => Bubble(ctx, m.id, m.text, mine, m.pending))
836 const sending = run.items.some(m => m.pending) ? Spinner(ctx, `sending-${first.id}`, 'enviando…') : ''
837
838 if (direct || mine) {
839 return (
840 <Box
841 key={`run-${first.id}`}
842 flexDirection="column"
843 alignItems={mine ? 'flex-end' : 'flex-start'}
844 marginTop={1}
845 rowGap="xs"
846 >
847 {direct ? (
848 ''
849 ) : (
850 <Text>
851 <Text bold>você</Text> {time}
852 </Text>
853 )}
854 {bubbles}
855 {sending}
856 {direct ? time : ''}
857 </Box>
858 )
859 }
860 return (
861 <Box key={`run-${first.id}`} flexDirection="row" gap={1} marginTop={1}>
862 {Avatar(ctx, person, 'sm')}
863 <Box flexDirection="column" flexShrink={1} alignItems="flex-start" rowGap="xs">
864 <Text>
865 <Text bold color={authorColor(ctx, run.authorId)}>
866 {person.name}
867 </Text>{' '}
868 {time}
869 </Text>
870 {bubbles}
871 </Box>
872 </Box>
873 )
874}
875
876// No group or chat yet: say so and point at the two ways to start one.
877const EmptyChat = (ctx: SignedIn) => {
878 const { Button } = ctx.ui
879 if (firstSync(ctx)) {
880 return Frame(ctx, Loading(ctx, 'loading-chats', 'Buscando suas conversas', 'Lendo seus grupos e conversas no GitHub…'))
881 }
882 return Frame(
883 ctx,
884 Empty(
885 ctx,
886 'empty',
887 'chat',
888 'Nenhuma conversa ainda',
889 'Crie um grupo ou adicione alguém pelo @ do GitHub.',
890 <Button key="empty-groups" variant="primary" onPress={() => ctx.act.go('groups')}>
891 Criar grupo
892 </Button>,
893 <Button key="empty-contacts" variant="secondary" onPress={() => ctx.act.go('contacts')}>
894 Adicionar contato
895 </Button>,
896 ),
897 )
898}
899
900// The chat's own header: "# geral" over "5 membros", or for a direct chat
901// the person's avatar, name and "ativo há … · conversa direta".
902const ChatHead = (ctx: SignedIn, c: Conversation, other: ChatUser | undefined) => {
903 const { Box, Text, Button } = ctx.ui
904 const ago = other ? activeAgo(other.lastActiveAt, ctx.now) : ''
905 // Say plainly which chat is open: its kind, then its name in heading size.
906 const sub = other
907 ? ['Conversa direta', ago].filter(Boolean).join(' · ')
908 : `Grupo · ${plural(c.memberIds.length, 'membro', 'membros')}`
909 return (
910 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={1} paddingY={gfx(ctx) ? 1 : 0}>
911 <Box flexDirection="row" gap={1} alignItems="center" flexShrink={1}>
912 {other ? Avatar(ctx, other, 'md') : Tile(ctx, 'hash', true)}
913 <Box flexDirection="column" flexShrink={1}>
914 <Text dimColor mono size="caption" wrap="truncate">
915 {sub.toUpperCase()}
916 </Text>
917 <Text bold size="heading" wrap="truncate">
918 {other ? other.name : titleOf(c)}
919 </Text>
920 </Box>
921 </Box>
922 <Box flexDirection="row" gap={2} alignItems="center" flexShrink={0}>
923 {other ? (
924 Soon(ctx, 'soon-profile', 'Ver perfil', 'user')
925 ) : (
926 <Box flexDirection="row" gap={2} alignItems="center">
927 {Soon(ctx, 'soon-files', 'Arquivos', 'file')}
928 <Box flexDirection="row" gap={1} alignItems="center">
929 {Icon(ctx, 'users', 16, '#C9C5BC')}
930 <Button key="show-members" plain dimColor onPress={() => ctx.act.go('members')}>
931 Membros
932 </Button>
933 </Box>
934 </Box>
935 )}
936 </Box>
937 </Box>
938 )
939}
940
941const ChatView = (ctx: SignedIn) => {
942 const { Box, Text } = ctx.ui
943 const c = ctx.current
944 if (!c) return EmptyChat(ctx)
945 const direct = c.kind === 'dm'
946 const other = direct ? personById(ctx, c.memberIds.find(id => id !== ctx.profile.id) ?? '') : undefined
947 // App header, dividers, chat header, the composer and its hint.
948 const area = Math.max(3, ctx.rows - (gfx(ctx) ? 12 : 9))
949 const runs = fitRuns(ctx, toRuns(ctx.messages), area)
950
951 return Frame(
952 ctx,
953 ChatHead(ctx, c, other),
954 Divider(ctx),
955 <Box
956 key="messages"
957 flexDirection="column"
958 flexGrow={1}
959 height={area}
960 justifyContent="flex-end"
961 overflow="hidden"
962 >
963 {direct ? (
964 <Box key="day" justifyContent="center">
965 <Text dimColor mono size="caption">
966 só vocês dois veem esta conversa
967 </Text>
968 </Box>
969 ) : (
970 ''
971 )}
972 {runs.length === 0 && ctx.sync.status === 'syncing'
973 ? Loading(ctx, 'loading-messages', 'Carregando mensagens', 'Buscando a conversa no GitHub…')
974 : runs.length === 0
975 ? Empty(
976 ctx,
977 'no-messages',
978 'chat',
979 'Nenhuma mensagem ainda',
980 direct ? `Comece a conversa com ${other?.name ?? c.name}.` : `Diga oi para ${titleOf(c)}.`,
981 )
982 : runs.map(run => MessageRun(ctx, run, direct))}
983 </Box>,
984 Composer(
985 ctx,
986 {
987 key: 'composer',
988 placeholder: `Mensagem para ${direct ? other?.name ?? c.name : titleOf(c)}`,
989 submitLabel: 'enviar',
990 value: ctx.draft,
991 onInput: value => ctx.act.setDraft(value),
992 onSubmit: value => ctx.act.send(value),
993 },
994 ['enter envia', 'só nesta sessão por enquanto'],
995 {
996 tools: [
997 Soon(ctx, 'soon-attach', 'Anexar', 'clip', true),
998 Soon(ctx, 'soon-share', 'Compartilhar contexto', 'spark', true),
999 Soon(ctx, 'soon-task', 'Passar tarefa', 'task', true),
1000 ],
1001 },
1002 ),
1003 )
1004}
1005
1006// ── Lists ────────────────────────────────────────────────────────────────────
1007
1008// One person: name over "@login · ativo há …".
1009const PersonInfo = (ctx: SignedIn, p: ChatUser) => {
1010 const { Box, Text } = ctx.ui
1011 const me = p.id === ctx.profile.id
1012 return (
1013 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
1014 <Text bold wrap="truncate">
1015 {me ? `${p.name} (você)` : p.name}
1016 </Text>
1017 <Text dimColor size="caption" wrap="truncate">
1018 {me ? `@${p.handle}` : handleLine(ctx, p)}
1019 </Text>
1020 </Box>
1021 )
1022}
1023
1024// A list row: a keyed Box that lights on hover, as the canvas rows do.
1025const Row = (ctx: Ctx, key: string, current: boolean, ...children: RenderChildren[]) => {
1026 const { Box } = ctx.ui
1027 const g = gfx(ctx)
1028 return (
1029 <Box
1030 key={key}
1031 flexDirection="row"
1032 gap={1}
1033 alignItems="center"
1034 paddingX={g ? 1 : 0}
1035 paddingY={g ? 1 : 0}
1036 backgroundColor={g && current ? P.row : undefined}
1037 hover={g ? { backgroundColor: P.row } : undefined}
1038 >
1039 {children}
1040 </Box>
1041 )
1042}
1043
1044// ── Members ──────────────────────────────────────────────────────────────────
1045
1046const MembersView = (ctx: SignedIn) => {
1047 const { Box } = ctx.ui
1048 const c = ctx.current
1049 if (!c) return EmptyChat(ctx)
1050 const members = c.memberIds
1051 .map(id => personById(ctx, id))
1052 .filter((p): p is ChatUser => p !== undefined)
1053 .sort((a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0))
1054
1055 return Frame(
1056 ctx,
1057 SubHeader(ctx, titleOf(c), 'Membros', `${members.length}`, Soon(ctx, 'soon-add-member', 'Adicionar', 'plus', true)),
1058 Divider(ctx),
1059 <Box flexDirection="column" flexGrow={1} overflow="hidden" marginTop={1}>
1060 {Section(ctx, 'Mais recentes primeiro')}
1061 {members.map(p => Row(ctx, `person-${p.id}`, false, Avatar(ctx, p, 'md'), PersonInfo(ctx, p)))}
1062 </Box>,
1063 c.kind === 'group' ? Divider(ctx) : '',
1064 c.kind === 'group' ? (
1065 <Box flexDirection="row" justifyContent="flex-end" paddingY={gfx(ctx) ? 1 : 0}>
1066 {Soon(ctx, 'soon-leave', 'Sair do grupo')}
1067 </Box>
1068 ) : (
1069 ''
1070 ),
1071 )
1072}
1073
1074// ── Groups ───────────────────────────────────────────────────────────────────
1075
1076// "Carla: to testando agora" under the group name.
1077const preview = (ctx: SignedIn, c: Conversation) => {
1078 const m = ctx.lastMessages[c.id]
1079 if (!m) return { text: 'Nenhuma mensagem ainda', at: undefined }
1080 const who = m.authorId === ctx.profile.id ? 'você' : (personById(ctx, m.authorId)?.name ?? m.authorId).split(' ')[0]
1081 return { text: `${who}: ${m.text}`, at: m.at }
1082}
1083
1084const GroupsView = (ctx: SignedIn) => {
1085 const { Box, Text, Button } = ctx.ui
1086 const all = ctx.conversations.filter(c => c.kind === 'group')
1087 const groups = all
1088 .filter(g => matches(ctx.groupSearch, g.name))
1089 .sort((a, b) => (ctx.lastMessages[b.id]?.at ?? 0) - (ctx.lastMessages[a.id]?.at ?? 0))
1090
1091 return Frame(
1092 ctx,
1093 SubHeader(ctx, backLabel(ctx), 'Grupos', `${all.length}`),
1094 Divider(ctx),
1095 all.length > 0 ? Search(ctx, 'group-search', 'Buscar grupos', ctx.groupSearch, v => ctx.act.setGroupSearch(v)) : '',
1096 <Box flexDirection="column" flexGrow={1} overflow="hidden" marginTop={1}>
1097 {all.length === 0 && firstSync(ctx)
1098 ? Loading(ctx, 'loading-groups', 'Buscando seus grupos', 'Lendo os repositórios do chat no GitHub…')
1099 : all.length === 0
1100 ? Empty(ctx, 'no-groups', 'hash', 'Nenhum grupo ainda', 'Crie o primeiro aqui embaixo e chame o time.')
1101 : groups.length === 0
1102 ? <Text dimColor>Nenhum grupo com “{ctx.groupSearch.trim()}”.</Text>
1103 : groups.map(g => {
1104 const cur = g.id === ctx.current?.id
1105 const n = cur ? 0 : ctx.unread[g.id] ?? 0
1106 const last = preview(ctx, g)
1107 return Row(
1108 ctx,
1109 `group-row-${g.id}`,
1110 cur,
1111 Tile(ctx, 'hash', cur),
1112 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
1113 <Button key={`group-${g.id}`} plain onPress={() => ctx.act.open(g.id)}>
1114 <Text bold={cur}>{gfx(ctx) ? g.name : `# ${g.name}`}</Text>
1115 </Button>
1116 <Text dimColor size="caption" wrap="truncate">
1117 {last.text}
1118 </Text>
1119 </Box>,
1120 <Box flexDirection="column" alignItems="flex-end" flexShrink={0}>
1121 <Text dimColor mono size="caption">
1122 {last.at ? when(last.at, ctx.now) : ' '}
1123 </Text>
1124 {cur ? (
1125 <Text bold color={gfx(ctx) ? P.accentSoft : 'claude'} size="caption">
1126 aberto no chat
1127 </Text>
1128 ) : n > 0 ? (
1129 Badge(ctx, n, `group-badge-${g.id}`)
1130 ) : (
1131 <Text dimColor size="caption">
1132 {plural(g.memberIds.length, 'membro', 'membros')}
1133 </Text>
1134 )}
1135 </Box>,
1136 <Button key={`group-open-${g.id}`} variant="secondary" onPress={() => ctx.act.open(g.id)}>
1137 {cur ? 'Voltar' : 'Abrir'}
1138 </Button>,
1139 )
1140 })}
1141 </Box>,
1142 Composer(
1143 ctx,
1144 {
1145 key: 'new-group',
1146 placeholder: 'Nome do grupo',
1147 submitLabel: 'criar',
1148 value: ctx.groupDraft,
1149 onInput: value => ctx.act.setGroupDraft(value),
1150 onSubmit: value => ctx.act.createGroup(value),
1151 },
1152 ['enter cria o grupo e abre o chat'],
1153 { label: 'Novo grupo', busy: ctx.busy.group },
1154 ),
1155 )
1156}
1157
1158// ── Contacts ─────────────────────────────────────────────────────────────────
1159
1160const ContactsView = (ctx: SignedIn) => {
1161 const { Box, Text, Button } = ctx.ui
1162 const dmUnread = (userId: string) => {
1163 const dm = ctx.conversations.find(c => c.kind === 'dm' && c.memberIds.includes(userId))
1164 return dm && dm.id !== ctx.current?.id ? ctx.unread[dm.id] ?? 0 : 0
1165 }
1166 const list = ctx.contacts
1167 .filter(p => matches(ctx.contactSearch, p.name, p.handle))
1168 .sort((a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0))
1169
1170 return Frame(
1171 ctx,
1172 SubHeader(ctx, backLabel(ctx), 'Contatos', `${ctx.contacts.length}`),
1173 Divider(ctx),
1174 ctx.contacts.length > 0
1175 ? Search(ctx, 'contact-search', 'Buscar contatos', ctx.contactSearch, v => ctx.act.setContactSearch(v))
1176 : '',
1177 <Box flexDirection="column" flexGrow={1} overflow="hidden" marginTop={1}>
1178 {ctx.contacts.length === 0 ? (
1179 Empty(ctx, 'no-contacts', 'users', 'Nenhum contato ainda', 'Adicione alguém pelo @ do GitHub aqui embaixo.')
1180 ) : list.length === 0 ? (
1181 <Text dimColor>Nenhum contato com “{ctx.contactSearch.trim()}”.</Text>
1182 ) : (
1183 <Box flexDirection="column">
1184 {Section(ctx, 'Mais recentes primeiro')}
1185 {list.map(p =>
1186 Row(
1187 ctx,
1188 `contact-row-${p.id}`,
1189 false,
1190 Avatar(ctx, p, 'md'),
1191 PersonInfo(ctx, p),
1192 Badge(ctx, dmUnread(p.id), `contact-badge-${p.id}`),
1193 <Button key={`contact-${p.id}`} variant="secondary" onPress={() => ctx.act.openDm(p.id)}>
1194 Conversar
1195 </Button>,
1196 ),
1197 )}
1198 </Box>
1199 )}
1200 </Box>,types/index.d.ts 115 lines1// A person is a GitHub user; `id` and `handle` are both the GitHub login.
2export type ChatUser = {
3 id: string
4 name: string
5 handle: string
6 // https://avatars.githubusercontent.com/... from the GitHub API.
7 avatarUrl?: string
8 // Epoch ms of their last message seen in any chat; drives "ativo há X min".
9 lastActiveAt?: number
10}
11
12export type Profile = {
13 id: string
14 name: string
15 handle: string
16 avatarUrl?: string
17 // https://github.com/<login>
18 htmlUrl: string
19}
20
21// What `gh` looks like on this machine, as the setup screen shows it.
22export type GhState =
23 | { status: 'checking' }
24 | { status: 'missing' }
25 | { status: 'logged-out'; gh: string }
26 | { status: 'no-repo-scope'; gh: string; login: string }
27 | { status: 'error'; gh?: string; message: string }
28 | { status: 'ok'; gh: string; profile: Profile }
29
30// A downloaded avatar: the PNG file and, when it could be read, its base64.
31export type Avatar = { file: string; png?: string }
32
33// A chat is a private GitHub repo: `id` is its full name, "owner/tc-...".
34export type Conversation = {
35 id: string
36 name: string
37 kind: 'group' | 'dm'
38 memberIds: string[]
39 // GitHub login that owns the repo (who created the chat).
40 owner?: string
41}
42
43// What `.team-chat/meta.json` holds in each chat repo.
44export type ChatMeta = { v: 1; kind: 'group' | 'dm'; name: string; createdBy: string }
45
46// The structured part of a message, from its `<!-- team-chat:v1 {...} -->` marker.
47export type MessageData =
48 | { kind: 'text'; viaClaude?: boolean }
49 | { kind: 'file'; path: string; name: string; size: number; mime: string; viaClaude?: boolean }
50 | {
51 kind: 'context'
52 title: string
53 summary: string
54 source: 'summary' | 'last-answer' | 'changes'
55 files: { path: string; added: number; removed: number }[]
56 viaClaude?: boolean
57 }
58 | { kind: 'task'; to: string; title: string; details: string; context?: unknown; viaClaude?: boolean }
59
60// `id` is the GitHub comment id; `pending` while it is still being posted.
61export type ChatMessage = {
62 id: string
63 conversationId: string
64 authorId: string
65 text: string
66 at: number
67 data?: MessageData
68 pending?: boolean
69}
70
71// The background sync with GitHub, for a status line or an error banner.
72export type SyncState =
73 | { status: 'idle'; at?: number }
74 | { status: 'syncing'; at?: number }
75 | { status: 'error'; message: string; at: number }
76
77export type View = 'setup' | 'chat' | 'members' | 'groups' | 'contacts' | 'profile'
78
79declare module 'claude-code' {
80 interface PluginState {
81 'team-chat': {
82 view: View
83 gh: GhState
84 // Shaped: v1 defaulted to the sample group.
85 conversationId: Shaped<string>
86 // Shaped: v1 had a mock profile with no avatar or GitHub link; null until signed in.
87 profile: Shaped<Profile | null>
88 // Shaped: v1 carried a `presence` field; v2 started with sample people.
89 contacts: Shaped<ChatUser[]>
90 // Shaped: v1 started with sample groups.
91 conversations: Shaped<Conversation[]>
92 // Shaped: the first version stored messages under `roomId`.
93 messages: Shaped<ChatMessage[]>
94 // Shaped: v1 started with sample counts.
95 unread: Shaped<Record<string, number>>
96 // Shaped: v1 held only the file path. Avatar pictures by login: the local
97 // PNG for the terminal's `Image`, and its base64 for an `Svg` elsewhere.
98 avatars: Shaped<Record<string, Avatar>>
99 draft: string
100 groupDraft: string
101 contactDraft: string
102 groupSearch: string
103 contactSearch: string
104 // Error from the last "Adicionar pelo @" attempt, shown under the field.
105 contactError: string
106 // Background sync with GitHub (hooks/sync.ts).
107 sync: SyncState
108 // Actions under way ('group', 'contact', 'dm') and the line the UI shows.
109 busy: Record<string, string>
110 // The terminal spinner's frame; ticks only while something loads.
111 frame: number
112 }
113 }
114}
115