Side panel listing open zyncai (zai-NNNN) tickets, refreshed from ~/.zyncai/tickets.

hooks/register.tsx 230 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ZTicket, ZTicketLog, ZTickets } from '../types'
5
6const DEFAULT_ROOT = '/Users/sabiridwan/.zyncai/tickets'
7const REFRESH_MS = 15000
8const PANE = 'z-tickets'
9const ORDER = ['information_missing', 'on_hold', 'intake', 'auditing', 'fixing', 'verifying', 'pr_open']
10const COLORS: Record<string, string> = {
11 information_missing: 'red',
12 on_hold: 'yellow',
13 intake: 'cyan',
14 auditing: 'magenta',
15 fixing: 'blue',
16 verifying: 'blue',
17 pr_open: 'green'
18}
19const PER_GROUP = 4
20const DONE_SHOWN = 8
21const TITLE = 'z-tickets'
22// Kept short: the whole store is re-read every REFRESH_MS and held in state.
23const NOTE_MAX = 400
24const REQUEST_MAX = 300
25
26const tickets = atom({ plugin: 'z-tickets', key: 'tickets' } as const, {
27 open: [],
28 done: [],
29 counts: {}
30} as ZTickets)
31const isDoneOpen = atom({ plugin: 'z-tickets', key: 'isDoneOpen' } as const, false)
32// Which ticket row is expanded; one at a time, null when every row is collapsed.
33const expandedId = atom({ plugin: 'z-tickets', key: 'expandedId' } as const, null as string | null)
34
35const title = (request: string) => {
36 const flat = request.replace(/\s+/g, ' ').replace(/ FINDINGS.*$/, '').trim()
37 return flat.replace(/^[a-z][\w-]*: /i, '')
38}
39const ago = (iso: string) => {
40 const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000))
41 if (!Number.isFinite(mins)) return ''
42 if (mins < 60) return `${mins}m`
43 if (mins < 1440) return `${Math.round(mins / 60)}h`
44 return `${Math.round(mins / 1440)}d`
45}
46const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
47const flat = (text: string, max: number) => clip(String(text ?? '').replace(/\s+/g, ' ').trim(), max)
48
49async function scan($: any, root: string): Promise<ZTickets> {
50 const entries = await $.fs.list(root).catch(() => [])
51 const rows = await Promise.all(
52 entries
53 .filter((entry: { name: string }) => /^zai-\d+$/.test(entry.name))
54 .map(async (entry: { name: string }): Promise<ZTicket | null> => {
55 try {
56 const state = JSON.parse(await $.fs.read(`${root}/${entry.name}/state.json`))
57 const log = Array.isArray(state.log) ? state.log : []
58 const tail = log.length > 0 ? log[log.length - 1] : null
59 const last: ZTicketLog | null = tail
60 ? {
61 seat: String(tail.seat ?? '?'),
62 note: flat(String(tail.note ?? ''), NOTE_MAX),
63 at: String(tail.at ?? '')
64 }
65 : null
66 return {
67 id: String(state.id ?? entry.name),
68 status: String(state.status ?? '?'),
69 request: String(state.request ?? ''),
70 updatedAt: String(state.updatedAt ?? ''),
71 createdAt: String(state.createdAt ?? ''),
72 origin: String(state.origin ?? ''),
73 repos: Array.isArray(state.repos) ? state.repos.map((r: unknown) => String(r)) : [],
74 logLen: log.length,
75 last
76 }
77 } catch {
78 return null
79 }
80 })
81 )
82 const all = rows
83 .filter((row): row is ZTicket => row !== null)
84 .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
85 const open = all.filter(row => row.status !== 'completed')
86 const done = all.filter(row => row.status === 'completed')
87 const counts: Record<string, number> = {}
88 for (const row of open) counts[row.status] = (counts[row.status] ?? 0) + 1
89 return { open, done, counts }
90}
91
92async function refresh($: any, root: string): Promise<void> {
93 const next = await scan($, root)
94 await update($, tickets, () => next)
95 $.ui.status(next.open.length === 0 ? undefined : `z: ${next.open.length} open`)
96}
97
98export const register: Register = (on, options) => {
99 const root = typeof options?.storeRoot === 'string' && options.storeRoot ? options.storeRoot : DEFAULT_ROOT
100 let timer: { cancel: () => void } | undefined
101
102 on('session.start', async ($, e, next) => {
103 await $.command.register({ name: 'z-tickets', description: 'Open the z-tickets side panel' })
104 void $.ui.open({ id: PANE, title: TITLE })
105 await refresh($, root)
106 timer?.cancel()
107 timer = $.clock.every(REFRESH_MS, () => {
108 refresh($, root).catch(() => {})
109 })
110 return next(e)
111 })
112
113 on('turn.complete', async ($, e, next) => {
114 await refresh($, root).catch(() => {})
115 return next(e)
116 })
117
118 on('command.run', { command: 'z-tickets' }, async $ => {
119 await $.ui.open({ id: PANE, title: TITLE })
120 await refresh($, root).catch(() => {})
121 return { text: 'z-tickets panel opened.' }
122 })
123
124 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
125 const { Box, Button, Text } = $.ui.resolve(e)
126 const data = await read($, tickets)
127 const showDone = await read($, isDoneOpen)
128 const expanded = await read($, expandedId)
129
130 if (data.open.length === 0 && data.done.length === 0) {
131 return <Text dimColor>No tickets.</Text>
132 }
133
134 const toggle = (id: string) => () => {
135 void update($, expandedId, current => (current === id ? null : id))
136 }
137
138 // The expanded body of one ticket: what the list row cannot carry.
139 const detail = (ticket: ZTicket) => (
140 <Box flexDirection="column" marginLeft={2} marginBottom={1}>
141 <Text wrap="wrap">{flat(ticket.request, REQUEST_MAX)}</Text>
142 <Text dimColor>
143 status <Text color={COLORS[ticket.status] ?? 'gray'}>{ticket.status.replace(/_/g, ' ')}</Text>
144 </Text>
145 <Text dimColor>repos {ticket.repos.join(', ') || '—'}</Text>
146 <Text dimColor>origin {ticket.origin || '—'}</Text>
147 <Text dimColor>
148 opened {ticket.createdAt ? `${ago(ticket.createdAt)} ago` : '—'} · {ticket.logLen} log
149 {ticket.logLen === 1 ? ' entry' : ' entries'}
150 </Text>
151 {ticket.last ? (
152 <Box flexDirection="column" marginTop={1}>
153 <Text dimColor>
154 last {ticket.last.seat} · {ago(ticket.last.at)}
155 </Text>
156 <Text wrap="wrap">{ticket.last.note || '—'}</Text>
157 </Box>
158 ) : (
159 <Text dimColor>no log entries yet</Text>
160 )}
161 </Box>
162 )
163
164 // One pressable row. Pressing it expands this ticket; pressing it again
165 // collapses it. Button children must be text, so the meta line and the
166 // detail sit under the Button, not inside it.
167 const row = (ticket: ZTicket, dim: boolean) => (
168 <Box key={ticket.id} flexDirection="column" marginLeft={2}>
169 <Button
170 key={`row-${ticket.id}`}
171 plain
172 dimColor={dim}
173 label={`${expanded === ticket.id ? '▾' : '▸'} ${clip(title(ticket.request), 54)}`}
174 onPress={toggle(ticket.id)}
175 />
176 <Text dimColor>
177 {ticket.id.replace('zai-', '#')} · {ago(ticket.updatedAt)}
178 </Text>
179 {expanded === ticket.id && detail(ticket)}
180 </Box>
181 )
182
183 const known = ORDER.filter(status => data.counts[status])
184 const rest = Object.keys(data.counts).filter(status => !ORDER.includes(status))
185 const groups = [...known, ...rest].map(status => ({
186 status,
187 rows: data.open.filter(ticket => ticket.status === status)
188 }))
189
190 return (
191 <Box flexDirection="column" paddingX={1}>
192 <Box marginBottom={1}>
193 <Text bold>Tickets </Text>
194 <Text dimColor>{data.open.length} open</Text>
195 </Box>
196 {groups.map(group => (
197 <Box key={group.status} flexDirection="column" marginBottom={1}>
198 <Box>
199 <Text color={COLORS[group.status] ?? 'gray'}>● </Text>
200 <Text bold>{group.status.replace(/_/g, ' ')} </Text>
201 <Text dimColor>{group.rows.length}</Text>
202 </Box>
203 {group.rows.slice(0, PER_GROUP).map(ticket => row(ticket, false))}
204 {group.rows.length > PER_GROUP && (
205 <Box marginLeft={2}>
206 <Text dimColor>+{group.rows.length - PER_GROUP} more</Text>
207 </Box>
208 )}
209 </Box>
210 ))}
211 {data.done.length > 0 && (
212 <Box flexDirection="column">
213 <Button
214 key="done"
215 label={`${showDone ? '▾' : '▸'} completed ${data.done.length}`}
216 onPress={() => update($, isDoneOpen, open => !open)}
217 />
218 {showDone && data.done.slice(0, DONE_SHOWN).map(ticket => row(ticket, true))}
219 {showDone && data.done.length > DONE_SHOWN && (
220 <Box marginLeft={2}>
221 <Text dimColor>+{data.done.length - DONE_SHOWN} more</Text>
222 </Box>
223 )}
224 </Box>
225 )}
226 </Box>
227 )
228 })
229}
230types/index.d.ts 20 lines1export type ZTicketLog = { seat: string; note: string; at: string }
2export type ZTicket = {
3 id: string
4 status: string
5 request: string
6 updatedAt: string
7 createdAt: string
8 origin: string
9 repos: string[]
10 logLen: number
11 last: ZTicketLog | null
12}
13export type ZTickets = { open: ZTicket[]; done: ZTicket[]; counts: Record<string, number> }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'z-tickets': { tickets: ZTickets; isDoneOpen: boolean; expandedId: string | null }
18 }
19}
20