@phenx-inc/ctlsurf 0.17.5 → 0.17.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +3 -1
  2. package/out/headless/index.mjs +1 -1
  3. package/out/headless/index.mjs.map +1 -1
  4. package/out/renderer/assets/{cssMode-CRJW4vYa.js → cssMode-DOrZXO36.js} +3 -3
  5. package/out/renderer/assets/{freemarker2-IgsORiC6.js → freemarker2-5u4104Xl.js} +1 -1
  6. package/out/renderer/assets/{handlebars-pDu2AR4b.js → handlebars-C_laShLt.js} +1 -1
  7. package/out/renderer/assets/{html-DNF7NUE5.js → html-DO3EoflS.js} +1 -1
  8. package/out/renderer/assets/{htmlMode-gbfJICtW.js → htmlMode-Cwu8rsx-.js} +3 -3
  9. package/out/renderer/assets/{index-CbuISbRh.js → index-CHdMn4av.js} +139 -53
  10. package/out/renderer/assets/{index-CBWAeUYW.css → index-CmvYnexl.css} +15 -3
  11. package/out/renderer/assets/{javascript-CzPWOwBB.js → javascript-BPewzTWL.js} +2 -2
  12. package/out/renderer/assets/{jsonMode-CWOEvILb.js → jsonMode-BjgG963U.js} +3 -3
  13. package/out/renderer/assets/{liquid-CP-HUzpK.js → liquid-K0EVwJ4n.js} +1 -1
  14. package/out/renderer/assets/{lspLanguageFeatures-DsiaqQq_.js → lspLanguageFeatures-m4j5zxQl.js} +1 -1
  15. package/out/renderer/assets/{mdx-DerBk5VK.js → mdx-DG0llsx7.js} +1 -1
  16. package/out/renderer/assets/{python-DkUMRAVL.js → python-CwfkGHje.js} +1 -1
  17. package/out/renderer/assets/{razor-C6ynuc0X.js → razor-DxTakA2q.js} +1 -1
  18. package/out/renderer/assets/{tsMode-DXrOkNVu.js → tsMode-BANGjk9B.js} +1 -1
  19. package/out/renderer/assets/{typescript-BmhhpI43.js → typescript-DiF0ejYV.js} +1 -1
  20. package/out/renderer/assets/{xml-CTMRAgAB.js → xml-BLsIDJrq.js} +1 -1
  21. package/out/renderer/assets/{yaml-DYNNafNI.js → yaml-BWqzZSkF.js} +1 -1
  22. package/out/renderer/index.html +2 -2
  23. package/package.json +1 -1
  24. package/scripts/test-work-map-ui.cjs +85 -9
  25. package/src/renderer/App.tsx +10 -1
  26. package/src/renderer/components/RollingCount.tsx +60 -0
  27. package/src/renderer/components/TicketPanel.tsx +24 -7
  28. package/src/renderer/components/WorkMapPanel.tsx +43 -22
  29. package/src/renderer/styles.css +15 -3
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-CbuISbRh.js";
1
+ import { l as languages } from "./index-CHdMn4av.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-CbuISbRh.js";
1
+ import { l as languages } from "./index-CHdMn4av.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-CbuISbRh.js";
1
+ import { l as languages } from "./index-CHdMn4av.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -106,8 +106,8 @@
106
106
  :root[data-theme='catppuccin-latte'] .boot-splash-title { color: #2f3145; }
107
107
  :root[data-theme='catppuccin-latte'] .boot-splash-label { color: #7c7f93; }
108
108
  </style>
109
- <script type="module" crossorigin src="./assets/index-CbuISbRh.js"></script>
110
- <link rel="stylesheet" crossorigin href="./assets/index-CBWAeUYW.css">
109
+ <script type="module" crossorigin src="./assets/index-CHdMn4av.js"></script>
110
+ <link rel="stylesheet" crossorigin href="./assets/index-CmvYnexl.css">
111
111
  </head>
112
112
  <body>
113
113
  <div id="boot-splash">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.17.5",
3
+ "version": "0.17.7",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
@@ -55,12 +55,25 @@ async function main() {
55
55
  }
56
56
  win = new BrowserWindow({ show: false, width: 430, height: 860, webPreferences: { backgroundThrottling: false } })
57
57
  await win.loadFile(path.join(temp, 'index.html'))
58
+ win.webContents.debugger.attach('1.3')
59
+ await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] })
58
60
  const run = code => win.webContents.executeJavaScript(code)
61
+ const finishRolls = () => run(`Promise.all(document.querySelector('.work-map-forest').getAnimations({subtree:true}).map(a=>a.finished.catch(()=>{})))`)
62
+ const investigationCount = `document.querySelector('.work-map-activity-count.investigating .work-map-count')`
63
+ const visibleInvestigationCount = `Array.from(${investigationCount}.querySelectorAll('.work-map-counter-reel'),el=>String(Math.round(-new DOMMatrixReadOnly(getComputedStyle(el).transform).m42/parseFloat(getComputedStyle(el).fontSize))%10)).join('')`
59
64
  await run(`new Promise(resolve=>setTimeout(resolve,150))`)
60
- assert.equal(await run(`document.querySelector('.work-map-goal-heading').getAttribute('aria-expanded')`), 'true')
61
- assert.equal(await run(`document.querySelector('.work-map-branch-heading').getAttribute('aria-expanded')`), 'false')
62
- assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 0, 'event groups start collapsed')
63
- await run(`document.querySelector('.work-map-branch-heading').click()`)
65
+ assert.equal(await run(`document.querySelector('button.work-map-goal-heading')`), null, 'goals cannot hide reasoning and updates')
66
+ assert.equal(await run(`document.querySelector('button.work-map-branch-heading').getAttribute('aria-expanded')`), 'false')
67
+ assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 1, 'thinking and decisions stay visible while technical activity starts collapsed')
68
+ assert.match(await run(`document.querySelector('.work-map-step').textContent`), /Preserve the reader/)
69
+ assert.deepEqual(await run(`[...document.querySelectorAll('.work-map-activity-count')].map(el=>el.getAttribute('aria-label'))`), ['Thinking: 1', 'Investigations: 10, 1 running', 'Edits: 9', 'Tests: 9, 1 failed'])
70
+ assert.equal(await run(`${visibleInvestigationCount}`), '10', 'initial counts are settled immediately')
71
+ await run(`window.emitMap({nodes:window.mapSnapshot().nodes.map(n=>n.id==='step-27'?{...n,status:'succeeded'}:n)})`)
72
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
73
+ assert.equal(await run(`${investigationCount}.dataset.value`), '10', 'a tool result does not add another event')
74
+ assert.equal(await run(`document.querySelector('.work-map-activity-count.investigating').getAttribute('aria-label')`), 'Investigations: 10')
75
+ assert.equal(await run(`${investigationCount}.getAnimations({subtree:true}).length`), 0, 'status changes do not roll unchanged counts')
76
+ await run(`document.querySelector('button.work-map-branch-heading').click()`)
64
77
  await run(`new Promise(resolve=>setTimeout(resolve,30))`)
65
78
  assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 29, 'choices and actions must share one thread')
66
79
  assert.equal(await run(`document.querySelector('.work-map-view')`), null, 'there are no separate decision/activity tabs')
@@ -89,10 +102,17 @@ async function main() {
89
102
  await run(`new Promise(resolve=>setTimeout(resolve,80))`)
90
103
  assert.equal(await run(`document.querySelector('.work-map-scroll').scrollTop`), 0, 'updates preserve scroll position while reading history')
91
104
  assert(await run(`!!document.querySelector('.work-map-jump')`))
92
- await run(`document.querySelector('.work-map-branch-heading').click()`)
105
+ await run(`document.querySelector('button.work-map-branch-heading').click()`)
106
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
107
+ assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 1, 'collapsing technical activity keeps its preceding choice visible')
108
+ await run(`window.emitMap({nodes:[...window.mapSnapshot().nodes,{...window.mapSnapshot().nodes.at(-1),id:'collapsed-step',status:'running'}]})`)
93
109
  await run(`new Promise(resolve=>setTimeout(resolve,30))`)
94
- assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 0)
95
- await run(`document.querySelector('.work-map-branch-heading').click()`)
110
+ assert.equal(await run(`document.querySelector('button.work-map-branch-heading').getAttribute('aria-expanded')`), 'false', 'new activity never reopens a collapsed investigation')
111
+ assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 1)
112
+ assert.equal(await run(`${investigationCount}.dataset.value`), '12', 'collapsed summaries still receive live events')
113
+ assert(await run(`${investigationCount}.getAnimations({subtree:true}).length>0`), 'live counts roll upward')
114
+ await finishRolls()
115
+ await run(`document.querySelector('button.work-map-branch-heading').click()`)
96
116
  await run(`new Promise(resolve=>setTimeout(resolve,30))`)
97
117
  await run(`document.querySelector('.work-map-step-button').click()`)
98
118
  await run(`new Promise(resolve=>setTimeout(resolve,30))`)
@@ -106,9 +126,65 @@ async function main() {
106
126
  await run(`document.querySelector('.work-map-scroll').scrollTop=0`)
107
127
  await run(`new Promise(resolve=>setTimeout(resolve,30))`)
108
128
  assert(await run(`document.documentElement.scrollWidth<=window.innerWidth`), 'no horizontal overflow at panel width')
129
+ await run(`document.querySelector('button.work-map-branch-heading').click()`)
130
+ await run(`document.querySelector('.work-map-step-button[aria-expanded="true"]')?.click(); document.querySelector('.work-map-scroll').scrollTop=0`)
131
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
109
132
  const screenshot = process.env.WORK_MAP_SCREENSHOT || path.join(repo, 'experiments/live-map/.cache/work-map-panel.png')
110
133
  await fs.mkdir(path.dirname(screenshot), { recursive: true })
111
134
  await fs.writeFile(screenshot, (await win.webContents.capturePage()).toPNG())
135
+ await run(`
136
+ window.beforeCountNodes=window.mapSnapshot().nodes;
137
+ window.withInvestigationCount=count=>window.emitMap({nodes:[
138
+ ...window.beforeCountNodes.filter(n=>n.kind!=='step'||n.activity!=='investigating'),
139
+ ...Array.from({length:count},(_,i)=>({...window.beforeCountNodes.find(n=>n.kind==='step'&&n.activity==='investigating'),id:'counter-step-'+i}))
140
+ ]});
141
+ window.withInvestigationCount(99);
142
+ `)
143
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
144
+ await finishRolls()
145
+ assert.equal(await run(visibleInvestigationCount), '99')
146
+ await run(`window.withInvestigationCount(100)`)
147
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
148
+ assert(await run(`${investigationCount}.getAnimations({subtree:true}).length>0`), 'digit carry animates from 99 to 100')
149
+ await finishRolls()
150
+ assert.equal(await run(visibleInvestigationCount), '100')
151
+ for (const count of [101, 105]) {
152
+ await run(`window.withInvestigationCount(${count})`)
153
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
154
+ }
155
+ await finishRolls()
156
+ assert.equal(await run(visibleInvestigationCount), '105', 'bursts settle on the latest count')
157
+ await run(`window.withInvestigationCount(9)`)
158
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
159
+ assert.equal(await run(visibleInvestigationCount), '9', 'trimming or reclassification can decrease a count')
160
+ await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] })
161
+ await run(`window.withInvestigationCount(10)`)
162
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
163
+ assert.equal(await run(visibleInvestigationCount), '10')
164
+ assert.equal(await run(`${investigationCount}.getAnimations({subtree:true}).length`), 0, 'reduced motion updates counts immediately')
165
+ win.setSize(320, 860)
166
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
167
+ assert(await run(`document.documentElement.scrollWidth<=window.innerWidth`), 'activity icons wrap at narrow panel widths')
168
+ await run(`window.emitMap({nodes:[...window.exampleNodes,
169
+ ...['planning','reporting','thinking','investigating','editing','testing'].map((activity,i)=>({
170
+ ...window.exampleNodes[2],id:'update-'+i,activity,intent:i===0?'next_step':i===1?'context':undefined,
171
+ title:'Visible agent update '+i,focus:undefined,
172
+ evidence:[{...window.exampleNodes[2].evidence[0],id:'update-event-'+i,kind:i===2?'thinking':'message'}]
173
+ }))
174
+ ]})`)
175
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
176
+ assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 7, 'plans, reporting and readable text stay visible regardless of model activity labels')
177
+ assert.equal(await run(`document.querySelectorAll('button.work-map-branch-heading').length`), 1, 'narrative-only groups have no collapse control')
178
+ await run(`document.querySelector('button.work-map-branch-heading').click()`)
179
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
180
+ assert.deepEqual(await run(`[...document.querySelectorAll('.work-map-step-title')].map(e=>e.textContent)`), [
181
+ 'Preserve the reader’s position instead of forcing the viewport down',
182
+ ...Array.from({length:28},(_,i)=>['Inspect the viewport and scroll listeners','Preserve the reader’s position during new output','Check terminal scroll regression'][i%3]),
183
+ ...Array.from({length:6},(_,i)=>'Visible agent update '+i)
184
+ ], 'expanding technical details preserves the chronology of surrounding updates')
185
+ await run(`document.querySelector('button.work-map-branch-heading').click()`)
186
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
187
+ assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 7, 'collapsing technical activity cannot hide later reporting')
112
188
  await run(`document.querySelector('.work-map-footer button').click()`)
113
189
  await run(`new Promise(resolve=>setTimeout(resolve,30))`)
114
190
  assert.equal(await run(`document.querySelectorAll('.work-map-goal').length`), 0)
@@ -117,7 +193,7 @@ async function main() {
117
193
  await run(`new Promise(resolve=>setTimeout(resolve,30))`)
118
194
  assert.match(await run(`document.querySelector('.work-map-thinking-status').textContent`), /omits readable thinking/)
119
195
  assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 0, 'events collected after clearing also start collapsed')
120
- await run(`document.querySelector('.work-map-branch-heading').click()`)
196
+ await run(`document.querySelector('button.work-map-branch-heading').click()`)
121
197
  await run(`new Promise(resolve=>setTimeout(resolve,30))`)
122
198
  assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 28, 'activity remains visible when thinking is omitted')
123
199
  assert.equal(await run(`document.querySelectorAll('.work-map-intent').length`), 0, 'tool activity must not become invented decisions when thinking is omitted')
@@ -128,7 +204,7 @@ async function main() {
128
204
  assert(await run(`document.querySelector('[role=switch]').disabled`))
129
205
  assert(await run(`document.querySelector('select').disabled`))
130
206
  }
131
- console.log('PASS collapsed event groups, unified chronological thread, salient excerpts and reasons, source receipts, action links, omitted-thinking notice, controls and scroll')
207
+ console.log('PASS visible thinking, decisions, plans and reporting, collapsed technical activity, live summaries, rolling counts, chronology, source receipts, controls and scroll')
132
208
  console.log('Screenshot:', screenshot)
133
209
  } finally {
134
210
  win?.destroy()
@@ -201,6 +201,14 @@ export default function App() {
201
201
  const [sessionStart, setSessionStart] = useState<Date | null>(null)
202
202
  const [layout, setLayout] = useState<LayoutNode>(DEFAULT_LAYOUT)
203
203
  const [hiddenPanes, setHiddenPanes] = useState<Set<string>>(new Set())
204
+ const ticketStartupPending = useRef(true)
205
+ const handleInitialTicketsLoaded = useCallback((empty: boolean) => {
206
+ if (!ticketStartupPending.current) return
207
+ ticketStartupPending.current = false
208
+ if (!empty) return
209
+ setLayout(previous => removePane(previous, 'tickets') || previous)
210
+ setHiddenPanes(previous => new Set([...previous, 'tickets']))
211
+ }, [])
204
212
  const [showSettings, setShowSettings] = useState(false)
205
213
  const [wsStatus, setWsStatus] = useState('disconnected')
206
214
  const [cwd, setCwd] = useState<string | null>(null)
@@ -529,6 +537,7 @@ export default function App() {
529
537
 
530
538
  // Toggle pane: hide removes from layout tree, show re-inserts
531
539
  const togglePane = useCallback((id: string) => {
540
+ if (id === 'tickets') ticketStartupPending.current = false
532
541
  if (id === 'workmap' && !visiblePaneIds.includes(id) && !canOpenWorkMap()) return
533
542
  if (visiblePaneIds.includes(id)) {
534
543
  // Hide: remove from tree
@@ -680,7 +689,7 @@ export default function App() {
680
689
  ),
681
690
  },
682
691
  { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
683
- { id: 'tickets', label: 'Tickets', content: <TicketPane onSendToTerminal={handleSendToTerminal} /> },
692
+ { id: 'tickets', label: 'Tickets', content: <TicketPane onSendToTerminal={handleSendToTerminal} onInitialLoad={handleInitialTicketsLoaded} /> },
684
693
  { id: 'workmap', label: 'Work map', content: <WorkMapPanel key={activeTabId} tabId={activeTabId} /> },
685
694
  ]
686
695
 
@@ -0,0 +1,60 @@
1
+ import { useLayoutEffect, useRef } from 'react'
2
+
3
+ /** Each place rolls independently, including the carry from 9 to 10. */
4
+ export function RollingCount({ value, label }: { value: number; label?: string }) {
5
+ const previous = useRef(value)
6
+ const increasing = value > previous.current
7
+ useLayoutEffect(() => { previous.current = value }, [value])
8
+
9
+ return <span className="work-map-count" role="img" aria-label={label || String(value)} data-value={value}>
10
+ <span className="work-map-counter" aria-hidden="true">
11
+ {String(value).split('').map((_, index, digits) => {
12
+ const place = digits.length - index - 1
13
+ const turns = Math.floor(value / 10 ** place)
14
+ return <RollingDigit key={place} turns={turns} initial={increasing ? 0 : turns} />
15
+ })}
16
+ </span>
17
+ </span>
18
+ }
19
+
20
+ function RollingDigit({ turns, initial }: { turns: number; initial: number }) {
21
+ const reel = useRef<HTMLSpanElement>(null)
22
+ const previous = useRef(initial)
23
+ const animation = useRef<Animation | null>(null)
24
+ const digit = turns % 10
25
+
26
+ useLayoutEffect(() => {
27
+ const el = reel.current!
28
+ const before = previous.current
29
+ previous.current = turns
30
+ let start = before % 10
31
+ // Continue from the visible position when another event arrives mid-roll.
32
+ if (animation.current) {
33
+ const style = getComputedStyle(el)
34
+ start = (-new DOMMatrixReadOnly(style.transform).m42 / parseFloat(style.fontSize)) % 10
35
+ animation.current.cancel()
36
+ animation.current = null
37
+ }
38
+ if (turns <= before || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
39
+
40
+ const distance = Math.min(turns - before, 10 + (turns - before) % 10)
41
+ let end = before % 10 + distance
42
+ while (end <= start) end += 10
43
+ const roll = el.animate([
44
+ { transform: `translateY(${-start}em)` },
45
+ { transform: `translateY(${-end}em)` },
46
+ ], { duration: 550, easing: 'cubic-bezier(.2, .65, .3, 1)' })
47
+ animation.current = roll
48
+ void roll.finished.then(() => {
49
+ if (animation.current === roll) animation.current = null
50
+ }, () => {})
51
+ }, [turns])
52
+
53
+ useLayoutEffect(() => () => { animation.current?.cancel() }, [])
54
+
55
+ return <span className="work-map-counter-digit">
56
+ <span className="work-map-counter-reel" ref={reel} style={{ transform: `translateY(${-digit}em)` }}>
57
+ {Array.from({ length: 30 }, (_, i) => <span key={i}>{i % 10}</span>)}
58
+ </span>
59
+ </span>
60
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from 'react'
1
+ import { useState, useEffect, useCallback, useRef } from 'react'
2
2
 
3
3
  interface Option { value: string; color: string }
4
4
 
@@ -167,7 +167,9 @@ function TicketCard({ ticket, onClick, onSend }: { ticket: Ticket; onClick: () =
167
167
  // Docked side-pane variant of the ticket UI: header + list/form body + footer.
168
168
  // It only mounts when its pane is visible, so it loads on mount and refreshes
169
169
  // when the connected project (cwd) changes. Closed via the pane's own header.
170
- export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: string) => void }) {
170
+ export function TicketPane({ onSendToTerminal, onInitialLoad }: { onSendToTerminal: (text: string) => void; onInitialLoad?: (empty: boolean) => void }) {
171
+ const initialLoadHandled = useRef(false)
172
+ const listRevision = useRef(0)
171
173
  const [project, setProject] = useState<string | null>(null)
172
174
  const [view, setView] = useState<View>('list')
173
175
 
@@ -193,20 +195,25 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
193
195
  const [save, setSave] = useState<SaveState>({ kind: 'idle' })
194
196
 
195
197
  const refreshList = useCallback(async () => {
198
+ const revision = ++listRevision.current
196
199
  setListLoading(true)
197
200
  setListError(null)
198
201
  try {
199
202
  const r = await window.worker.listTickets()
203
+ if (revision !== listRevision.current) return null
200
204
  if (r?.ok) {
201
205
  setTickets(r.tickets || [])
202
206
  setRefs(r.blockId && r.pageId ? { blockId: r.blockId, pageId: r.pageId } : null)
203
207
  } else { setTickets([]); setRefs(null); setListError(r?.error || 'Failed to load tickets') }
208
+ return r?.ok ? (r.tickets || []).length === 0 : null
204
209
  } catch (err: any) {
210
+ if (revision !== listRevision.current) return null
205
211
  setTickets([])
206
212
  setRefs(null)
207
213
  setListError(err?.message || 'Failed to load tickets')
214
+ return null
208
215
  } finally {
209
- setListLoading(false)
216
+ if (revision === listRevision.current) setListLoading(false)
210
217
  }
211
218
  }, [])
212
219
 
@@ -218,12 +225,22 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
218
225
  .then(r => { if (!cancelled) setProject(r?.name ?? null) })
219
226
  .catch(() => { if (!cancelled) setProject(null) })
220
227
  loadProject()
221
- void refreshList()
222
- const unsub = window.worker.onCwdChanged(() => { loadProject(); void refreshList() })
223
- return () => { cancelled = true; unsub() }
224
- }, [refreshList])
228
+ // The first successful load may follow choosing the startup session.
229
+ const loadTickets = () => void refreshList().then(empty => {
230
+ if (cancelled || empty === null || initialLoadHandled.current) return
231
+ initialLoadHandled.current = true
232
+ onInitialLoad?.(empty)
233
+ })
234
+ loadTickets()
235
+ const unsub = window.worker.onCwdChanged(() => {
236
+ loadProject()
237
+ loadTickets()
238
+ })
239
+ return () => { cancelled = true; listRevision.current++; unsub() }
240
+ }, [refreshList, onInitialLoad])
225
241
 
226
242
  const openNewForm = useCallback(() => {
243
+ initialLoadHandled.current = true
227
244
  setEditingId(null)
228
245
  setEditingBlockId(null)
229
246
  setTitle('')
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import type { MapActivity, MapIntent, MapNode, MapSettings, MapSnapshot } from '../../shared/workMap'
3
3
  import { thinkingHighlights } from '../lib/workMapHighlights'
4
+ import { RollingCount } from './RollingCount'
4
5
 
5
6
  const ICONS: Record<MapActivity, string> = { thinking: '✦', investigating: '⌕', editing: '✎', testing: '✓', planning: '◇', reporting: '≡' }
7
+ const ACTIVITIES: Record<MapActivity, string> = { thinking: 'Thinking', investigating: 'Investigations', editing: 'Edits', testing: 'Tests', planning: 'Plans', reporting: 'Updates' }
6
8
  const STATUS = { running: 'Running', succeeded: 'Succeeded', failed: 'Failed', unknown: 'Recorded', interrupted: 'Stopped' }
7
9
  const INTENTS: Record<MapIntent, string> = { decision: 'Decision', hypothesis: 'Hypothesis', alternative: 'Alternative', next_step: 'Next step', context: 'Thinking' }
8
10
 
@@ -10,7 +12,6 @@ export function WorkMapPanel({ tabId }: { tabId: string }) {
10
12
  const [snapshot, setSnapshot] = useState<MapSnapshot | null>(null)
11
13
  const [error, setError] = useState<string | null>(null)
12
14
  const [saving, setSaving] = useState(false)
13
- const [collapsedGoals, setCollapsedGoals] = useState<Set<string>>(new Set())
14
15
  const [expandedThreads, setExpandedThreads] = useState<Set<string>>(new Set())
15
16
  const [selected, setSelected] = useState<string | null>(null)
16
17
  const [following, setFollowing] = useState(true)
@@ -63,13 +64,6 @@ export function WorkMapPanel({ tabId }: { tabId: string }) {
63
64
  finally { if (alive.current) setSaving(false) }
64
65
  }
65
66
 
66
- const toggleGoal = (id: string) => setCollapsedGoals(prev => {
67
- const next = new Set(prev)
68
- if (next.has(id)) next.delete(id)
69
- else next.add(id)
70
- return next
71
- })
72
-
73
67
  const toggleThread = (id: string) => setExpandedThreads(prev => {
74
68
  const next = new Set(prev)
75
69
  if (next.has(id)) next.delete(id)
@@ -86,15 +80,19 @@ export function WorkMapPanel({ tabId }: { tabId: string }) {
86
80
  // Render consecutive runs in event order. Reusing a topic must not move new
87
81
  // actions back up the page or separate them from the preceding decision.
88
82
  function threadRuns(goalId: string) {
89
- const runs: Array<{ id: string; key: string | null; label: string; nodes: MapNode[] }> = []
83
+ const runs: Array<{ id: string; key: string | null; label: string; technical: boolean; nodes: MapNode[] }> = []
90
84
  for (const node of steps) {
91
85
  if (nodeById.get(node.parentId || '')?.parentId !== goalId) continue
92
86
  const textSource = node.evidence[0]?.kind === 'thinking' || node.evidence[0]?.kind === 'message'
93
87
  const anchor = textSource ? node : nodeById.get(node.contextId || '')
94
88
  const key = anchor?.id || node.parentId
89
+ // Only tool activity can be tucked away. Keep stated reasoning and
90
+ // updates visible even when their topic or activity is reclassified.
91
+ const technical = !node.intent && ['investigating', 'editing', 'testing'].includes(node.activity)
92
+ && node.evidence.length > 0 && node.evidence.every(e => e.kind === 'tool' || e.kind === 'result')
95
93
  const previous = runs.at(-1)
96
- if (previous?.key === key) previous.nodes.push(node)
97
- else runs.push({ id: `thread-${node.id}`, key, label: nodeById.get(anchor?.parentId || node.parentId || '')?.title || 'Activity', nodes: [node] })
94
+ if (previous?.key === key && previous.technical === technical) previous.nodes.push(node)
95
+ else runs.push({ id: `thread-${node.id}`, key, technical, label: nodeById.get(anchor?.parentId || node.parentId || '')?.title || 'Activity', nodes: [node] })
98
96
  }
99
97
  return runs
100
98
  }
@@ -196,23 +194,28 @@ export function WorkMapPanel({ tabId }: { tabId: string }) {
196
194
  : 'Stated decisions, readable thinking, actions, and results appear in one continuous thread. Open any card to inspect its source.'}</p>
197
195
  </div> : <div className="work-map-forest">
198
196
  {goals.map((goal, i) => <article className="work-map-goal" key={goal.id}>
199
- <button className="work-map-goal-heading" onClick={() => toggleGoal(goal.id)} aria-expanded={!collapsedGoals.has(goal.id)} title={goal.evidence[0]?.text}>
197
+ <div className="work-map-goal-heading" title={goal.evidence[0]?.text}>
200
198
  <span className="work-map-goal-icon" aria-hidden="true">◎</span><span><span className="work-map-kicker">GOAL {String(i + 1).padStart(2, '0')}</span><strong>{goal.title}</strong></span>
201
- <span className="work-map-caret">{collapsedGoals.has(goal.id) ? '▸' : '▾'}</span>
202
- </button>
203
- {!collapsedGoals.has(goal.id) && <div className="work-map-branches">
199
+ </div>
200
+ <div className="work-map-branches">
204
201
  {threadRuns(goal.id).map(run => {
205
202
  const failed = run.nodes.some(n => n.status === 'failed')
203
+ const heading = <>
204
+ <span className={`work-map-branch-point ${failed ? 'failed' : ''}`} />
205
+ <span className="work-map-branch-copy">
206
+ <span className="work-map-branch-title"><span className="work-map-branch-label">{run.technical ? `Technical activity · ${run.label}` : run.label}</span><RollingCount value={run.nodes.length} label={`${run.nodes.length} events`} /></span>
207
+ <ActivityCounts nodes={run.nodes} />
208
+ </span>
209
+ </>
206
210
  return <div className="work-map-branch" key={run.id}>
207
- <button className="work-map-branch-heading" onClick={() => toggleThread(run.id)} aria-expanded={expandedThreads.has(run.id)}>
208
- <span className={`work-map-branch-point ${failed ? 'failed' : ''}`} />
209
- <span className="work-map-branch-label">{run.label}</span><span className="work-map-count">{run.nodes.length}</span>
211
+ {run.technical ? <button className="work-map-branch-heading" onClick={() => toggleThread(run.id)} aria-expanded={expandedThreads.has(run.id)}>
212
+ {heading}
210
213
  <span className="work-map-caret">{expandedThreads.has(run.id) ? '▾' : '▸'}</span>
211
- </button>
212
- {expandedThreads.has(run.id) && <ul className="work-map-steps" aria-label="Activity thread">{run.nodes.map(renderStep)}</ul>}
214
+ </button> : <div className="work-map-branch-heading">{heading}</div>}
215
+ {(!run.technical || expandedThreads.has(run.id)) && <ul className="work-map-steps" aria-label="Activity thread">{run.nodes.map(renderStep)}</ul>}
213
216
  </div>
214
217
  })}
215
- </div>}
218
+ </div>
216
219
  </article>)}
217
220
  {snapshot?.dropped ? <p className="work-map-retained">Earlier activity trimmed · keeping the latest 240 nodes</p> : null}
218
221
  </div>}
@@ -221,13 +224,31 @@ export function WorkMapPanel({ tabId }: { tabId: string }) {
221
224
  <footer className="work-map-footer"><span>{decisions.length} {decisions.length === 1 ? 'choice' : 'choices'} · {steps.length} events · local</span>
222
225
  <button disabled={!steps.length || saving} onClick={async () => {
223
226
  setError(null)
224
- try { accept(await window.worker.clearWorkMap(tabId)); setCollapsedGoals(new Set()); setExpandedThreads(new Set()); setSelected(null); setFollowing(true) }
227
+ try { accept(await window.worker.clearWorkMap(tabId)); setExpandedThreads(new Set()); setSelected(null); setFollowing(true) }
225
228
  catch (err) { if (alive.current) setError(String(err)) }
226
229
  }}>Clear map</button>
227
230
  </footer>
228
231
  </section>
229
232
  }
230
233
 
234
+ function ActivityCounts({ nodes }: { nodes: MapNode[] }) {
235
+ return <span className="work-map-activity-counts">
236
+ {(Object.keys(ACTIVITIES) as MapActivity[]).map(activity => {
237
+ const events = nodes.filter(node => node.activity === activity)
238
+ if (!events.length) return null
239
+ const failed = events.filter(node => node.status === 'failed').length
240
+ const running = events.filter(node => node.status === 'running').length
241
+ const label = `${ACTIVITIES[activity]}: ${events.length}${running ? `, ${running} running` : ''}${failed ? `, ${failed} failed` : ''}`
242
+ return <span key={activity} className={`work-map-activity-count ${activity}`} role="img" aria-label={label} title={label}>
243
+ <span className={`work-map-activity ${activity}`} aria-hidden="true">{ICONS[activity]}</span>
244
+ <RollingCount value={events.length} />
245
+ {running > 0 && <span className="work-map-activity-running" aria-hidden="true" />}
246
+ {failed > 0 && <span className="work-map-activity-alert" aria-hidden="true">!</span>}
247
+ </span>
248
+ })}
249
+ </span>
250
+ }
251
+
231
252
  function highlightSource(text: string, focus?: MapNode['focus']): ReactNode {
232
253
  const ranges = thinkingHighlights(text, focus)
233
254
  if (!ranges.length) return text
@@ -61,11 +61,23 @@
61
61
  .work-map-branch { position: relative; padding: 0 0 17px; }
62
62
  .work-map-branch:last-child { padding-bottom: 0; }
63
63
  .work-map-branch::before { content: ''; position: absolute; left: -18px; top: 12px; width: 15px; height: 1px; background: var(--border-dim); }
64
- .work-map-branch-heading { display: flex; align-items: center; gap: 7px; width: 100%; padding: 5px 0; border: 0; background: transparent; text-align: left; color: var(--text-bright); }
65
- .work-map-branch-point { width: 6px; height: 6px; flex-shrink: 0; border-radius: 2px; background: var(--accent); }
64
+ .work-map-branch-heading { display: flex; align-items: flex-start; gap: 7px; width: 100%; padding: 5px 0; border: 0; background: transparent; text-align: left; color: var(--text-bright); }
65
+ .work-map-branch-point { width: 6px; height: 6px; margin-top: 5px; flex-shrink: 0; border-radius: 2px; background: var(--accent); }
66
66
  .work-map-branch-point.failed { background: var(--danger); }
67
+ .work-map-branch-copy { flex: 1; min-width: 0; }
68
+ .work-map-branch-title { display: flex; align-items: center; gap: 7px; line-height: 1.5; }
67
69
  .work-map-branch-label { min-width: 0; overflow-wrap: anywhere; font-size: 11px; font-weight: 600; }
68
- .work-map-count { padding: 1px 5px; border-radius: 4px; background: var(--bg-chip); color: var(--text-soft); font-size: 9px; }
70
+ .work-map-count { display: inline-flex; flex-shrink: 0; padding: 2px 5px; border-radius: 4px; background: var(--bg-chip); color: var(--text-soft); font-size: 9px; }
71
+ .work-map-counter { display: inline-flex; height: 1em; line-height: 1; font-variant-numeric: tabular-nums; }
72
+ .work-map-counter-digit { display: inline-block; width: 1ch; height: 1em; overflow: hidden; }
73
+ .work-map-counter-reel { display: flex; flex-direction: column; }
74
+ .work-map-counter-reel > span { flex: 0 0 1em; height: 1em; text-align: center; }
75
+ .work-map-activity-counts { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-top: 7px; }
76
+ .work-map-activity-count { display: inline-flex; align-items: center; gap: 4px; padding: 3px 5px; border: 1px solid var(--border-subtle); border-radius: 5px; background: var(--bg-well); }
77
+ .work-map-activity-count .work-map-activity { width: 14px; height: 14px; font-size: 13px; background: transparent; }
78
+ .work-map-activity-count .work-map-count { padding: 0; background: transparent; font-size: 10px; }
79
+ .work-map-activity-running { width: 4px; height: 4px; border-radius: 50%; background: var(--accent); }
80
+ .work-map-activity-alert { color: var(--danger); font-size: 10px; font-weight: 700; line-height: 1; }
69
81
  .work-map-steps { list-style: none; margin: 5px 0 0 2px; padding: 0 0 0 8px; border-left: 1px solid var(--border-subtle); }
70
82
  .work-map-step { margin: 5px 0; border: 1px solid var(--border-well); background: var(--bg-well); border-radius: 7px; overflow: hidden; }
71
83
  .work-map-step.failed { border-color: color-mix(in srgb, var(--danger) 45%, var(--border-well)); }