SLOPSHOPPER

z-tickets

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

newpanecommandstatustimer
v0.1.0no licenseupdated 2026-10-10sabiridwan/my-claude-config/mods/z-tickets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · z-tickets
│ ┃ z-tickets ✕ › fix the failing auth test and add an audit log call │ ┃ No tickets. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /z-tickets │ ⎿ z-tickets: z-tickets panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · z-tickets
No tickets.
Source 2 files
hooks/register.tsx 230 lines
1import { 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}
230
types/index.d.ts 20 lines
1export 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