SLOPSHOPPER

team-chat

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

panecommandtoastprocesstimer
v0.2.0MITupdated 2026-10-17AlecsFarias/claude-team-chat/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · team-chat
│ ┃ Chat ✕ › fix the failing auth test and add an audit log call │ ┃ ✻ Team Chat │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Trabalhe junto, cada um com seu Claude ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Converse com o time, compartilhe o ⏺ Bash(bun test) │ ┃ contexto da sua sessão e passe tarefas ⎿ 3 pass, 1 fail │ ┃ sem copiar e colar. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ⠋ Verificando o gh e a sua conta do ✻ Worked for 42s · done 4:20 PM │ ┃ GitHub… │ ┃ › /chat │ ┃ ⎿ team-chat: Chat aberto. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Chat
✻ Team Chat Trabalhe junto, cada um com seu Claude Converse com o time, compartilhe o contexto da sua sessão e passe tarefas sem copiar e colar. ⠋ Verificando o gh e a sua conta do GitHub…
README

Claude Team Chat

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.

  • Share context. Send a summary of your session, Claude's last answer or your changed files to a group. A teammate brings it into their own Claude with one click, so nobody has to copy and paste.
  • Claude in the chat. Claude can read a group and draft posts for you. Nothing is posted until you press Post.
  • Hand off tasks. Send a task with its context to someone, who starts it in their Claude.
  • Plus the basics: groups, direct chats, files (up to 4 MB) and links, and a list of a chat's files, links and contexts.

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.

How it works

There is no server. GitHub is the backend, used through your own GitHub CLI:

  • Your identity is your GitHub profile.
  • Each chat is a private repository, and its members are collaborators.
  • Messages are comments on an issue in that repository.
  • Files are committed to the repository.

The mod polls for new messages: every 20 s for the open chat, every 5 min for the others.

Requirements

  • Claude Code, in the terminal or the desktop app. The mobile app is not supported.
  • gh installed and signed in (gh auth status) with the repo scope.

Install

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.

Development

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.

License

MIT

Source 6 files
hooks/register.tsx 338 lines
1import { 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}
338
hooks/github.ts 303 lines
1// 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}
303
hooks/sync.ts 252 lines
1// 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}
252
hooks/chats.ts 389 lines
1// 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}
389
hooks/views.tsx 1280 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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 lines
1// 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