@phenx-inc/ctlsurf 0.17.2 → 0.17.4

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 (54) hide show
  1. package/README.md +25 -2
  2. package/bin/ctlsurf-worker.js +243 -44
  3. package/out/headless/index.mjs +1033 -128
  4. package/out/headless/index.mjs.map +4 -4
  5. package/out/main/index.js +933 -17
  6. package/out/preload/index.js +15 -0
  7. package/out/renderer/assets/{cssMode-BtQxI2Eb.js → cssMode-BJfoToLX.js} +3 -3
  8. package/out/renderer/assets/{freemarker2-DRW5dmY5.js → freemarker2-T0eMtP1Z.js} +1 -1
  9. package/out/renderer/assets/{handlebars-CFLF3cP7.js → handlebars-Dbbib8fi.js} +1 -1
  10. package/out/renderer/assets/{html-DtnBOkwp.js → html-BRHT_oxc.js} +1 -1
  11. package/out/renderer/assets/{htmlMode-DMVysDCg.js → htmlMode-BRlZTxME.js} +3 -3
  12. package/out/renderer/assets/{index-CEjwKjtd.css → index-CBWAeUYW.css} +91 -0
  13. package/out/renderer/assets/{index-Dm70hk5s.js → index-RCAeuJTr.js} +476 -411
  14. package/out/renderer/assets/{javascript-ChlyA1sf.js → javascript-BbOZOxq9.js} +2 -2
  15. package/out/renderer/assets/{jsonMode-D1_oHzeN.js → jsonMode-CUvAeYcT.js} +3 -3
  16. package/out/renderer/assets/{liquid-D4ftdy1G.js → liquid-DRIACo_s.js} +1 -1
  17. package/out/renderer/assets/{lspLanguageFeatures-Ckqu5ubG.js → lspLanguageFeatures-Ds-3nH9p.js} +1 -1
  18. package/out/renderer/assets/{mdx-B9wsVYSC.js → mdx-D76jlpLO.js} +1 -1
  19. package/out/renderer/assets/{python-e23tD6IS.js → python-CMuRPKJi.js} +1 -1
  20. package/out/renderer/assets/{razor-CrDyXj3d.js → razor-DRoH5mal.js} +1 -1
  21. package/out/renderer/assets/{tsMode-BwGC5o4O.js → tsMode-dPJzgFD7.js} +1 -1
  22. package/out/renderer/assets/{typescript-Do-98d_O.js → typescript-D5USKnLj.js} +1 -1
  23. package/out/renderer/assets/{xml-Dpdu5R3m.js → xml-qYg1wt2P.js} +1 -1
  24. package/out/renderer/assets/{yaml-B0Zt7LCJ.js → yaml-Uc-CvnvL.js} +1 -1
  25. package/out/renderer/index.html +2 -2
  26. package/package.json +4 -2
  27. package/scripts/check-work-map-gpu.cjs +62 -0
  28. package/scripts/check-work-map-gpu.d.cts +13 -0
  29. package/scripts/postinstall.cjs +44 -0
  30. package/scripts/prepare-work-map.py +49 -0
  31. package/scripts/setup-ui.cjs +161 -0
  32. package/scripts/test-work-map-capability.cjs +45 -0
  33. package/scripts/test-work-map-ui.cjs +130 -0
  34. package/scripts/test-work-map.cjs +352 -0
  35. package/scripts/work-map-clef.py +81 -0
  36. package/scripts/work-map-setup.cjs +127 -0
  37. package/src/main/index.ts +43 -1
  38. package/src/main/observer/observer.ts +2 -0
  39. package/src/main/orchestrator.ts +39 -4
  40. package/src/main/transcripts.ts +35 -16
  41. package/src/main/workMap/clef.ts +156 -0
  42. package/src/main/workMap/normalize.ts +172 -0
  43. package/src/main/workMap/source.ts +28 -0
  44. package/src/main/workMap/tree.ts +199 -0
  45. package/src/main/workMap/workMap.ts +244 -0
  46. package/src/preload/index.ts +17 -0
  47. package/src/renderer/App.tsx +15 -16
  48. package/src/renderer/components/ObserverPanel.tsx +3 -0
  49. package/src/renderer/components/SettingsDialog.tsx +0 -104
  50. package/src/renderer/components/WorkMapPanel.tsx +235 -0
  51. package/src/renderer/components/WorkMapToggle.tsx +50 -0
  52. package/src/renderer/lib/workMapHighlights.ts +41 -0
  53. package/src/renderer/styles.css +91 -0
  54. package/src/shared/workMap.d.ts +70 -0
@@ -69,60 +69,21 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
69
69
  const [saved, setSaved] = useState(false)
70
70
  const [version, setVersion] = useState('')
71
71
 
72
- // Observer (global) — the key is write-only over IPC; we only learn
73
- // whether one is configured (hasApiKey) and from where (keySource).
74
- const [obsEnabled, setObsEnabled] = useState(false)
75
- const [obsApiKey, setObsApiKey] = useState('')
76
- const [obsHasKey, setObsHasKey] = useState(false)
77
- const [obsKeySource, setObsKeySource] = useState<'settings' | 'env' | null>(null)
78
- const [obsCapUsd, setObsCapUsd] = useState(1)
79
- const [obsSaved, setObsSaved] = useState(false)
80
-
81
72
  const loadProfiles = async () => {
82
73
  const data = await window.worker.listProfiles() as ProfileListResponse
83
74
  setProfileList(data)
84
75
  setActiveProfileId(data.activeProfile)
85
76
  }
86
77
 
87
- const loadObserver = async () => {
88
- try {
89
- const o = await window.worker.getObserverSettings()
90
- setObsEnabled(o.enabled)
91
- setObsHasKey(o.hasApiKey)
92
- setObsKeySource(o.keySource)
93
- setObsCapUsd(o.dailyCapUsd)
94
- setObsApiKey('')
95
- } catch { /* older main process without observer IPC */ }
96
- }
97
-
98
78
  useEffect(() => {
99
79
  if (open) {
100
80
  loadProfiles()
101
- loadObserver()
102
81
  setEditingId(null)
103
82
  setSaved(false)
104
- setObsSaved(false)
105
83
  window.worker.getVersion().then(setVersion).catch(() => { /* ignore */ })
106
84
  }
107
85
  }, [open])
108
86
 
109
- const handleObserverSave = async () => {
110
- const data: { enabled: boolean; anthropicApiKey?: string; dailyCapUsd?: number } = {
111
- enabled: obsEnabled,
112
- dailyCapUsd: Math.max(0.05, obsCapUsd) || 1,
113
- }
114
- // Only send the key if the user typed a new one (blank = keep stored).
115
- if (obsApiKey.trim()) {
116
- data.anthropicApiKey = obsApiKey.trim()
117
- }
118
- const o = await window.worker.setObserverSettings(data)
119
- setObsHasKey(o.hasApiKey)
120
- setObsKeySource(o.keySource)
121
- setObsApiKey('')
122
- setObsSaved(true)
123
- setTimeout(() => setObsSaved(false), 900)
124
- }
125
-
126
87
  const startEditing = (profile: ProfileSummary) => {
127
88
  setEditingId(profile.id)
128
89
  setName(profile.name)
@@ -359,71 +320,6 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
359
320
  </div>
360
321
  )}
361
322
 
362
- {!editingId && (
363
- // ─── Observer (global) ─────────────────
364
- <div className="settings-section">
365
- <h4>Observer</h4>
366
- <p className="settings-hint">
367
- Reviews agent sessions for assumptions, unverified claims, and
368
- dependency/license risk, tracks time per work item, and posts a
369
- session digest to the project's Agent Datastore page.
370
- </p>
371
-
372
- <label className="settings-checkbox">
373
- <input
374
- type="checkbox"
375
- checked={obsEnabled}
376
- onChange={e => setObsEnabled(e.target.checked)}
377
- />
378
- <span>Enable Observer</span>
379
- <span className="settings-hint">
380
- Applies to newly started agent tabs. Advisory only — never
381
- blocks or slows the agent.
382
- </span>
383
- </label>
384
-
385
- <label>
386
- Anthropic API Key
387
- <input
388
- type="password"
389
- value={obsApiKey}
390
- onChange={e => setObsApiKey(e.target.value)}
391
- placeholder={
392
- obsKeySource === 'settings' ? '***configured*** (leave blank to keep)'
393
- : obsKeySource === 'env' ? 'using ANTHROPIC_API_KEY from environment'
394
- : 'sk-ant-…'
395
- }
396
- />
397
- <span className="settings-hint">
398
- Your own Anthropic key — separate from the ctlsurf profile key.
399
- Without one, the Observer still records the session ledger,
400
- Tier-0 tags, and spans, but skips model review and digests.
401
- </span>
402
- </label>
403
-
404
- <label>
405
- Daily budget cap (USD)
406
- <input
407
- type="number"
408
- min={0.05}
409
- step={0.25}
410
- value={obsCapUsd}
411
- onChange={e => setObsCapUsd(parseFloat(e.target.value) || 1)}
412
- />
413
- <span className="settings-hint">
414
- Model review pauses for the day once spend reaches the cap;
415
- Tier-0 tagging and spans continue.
416
- </span>
417
- </label>
418
-
419
- <div className="settings-actions">
420
- <button className="btn-primary" onClick={handleObserverSave}>
421
- {obsSaved ? 'Saved!' : 'Save Observer Settings'}
422
- </button>
423
- </div>
424
- </div>
425
- )}
426
-
427
323
  <div className="settings-section settings-about">
428
324
  <h4>About</h4>
429
325
  <div className="about-name">ctlsurf{version ? ` · v${version}` : ''}</div>
@@ -0,0 +1,235 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
+ import type { MapActivity, MapIntent, MapNode, MapSettings, MapSnapshot } from '../../shared/workMap'
3
+ import { thinkingHighlights } from '../lib/workMapHighlights'
4
+
5
+ const ICONS: Record<MapActivity, string> = { thinking: '✦', investigating: '⌕', editing: '✎', testing: '✓', planning: '◇', reporting: '≡' }
6
+ const STATUS = { running: 'Running', succeeded: 'Succeeded', failed: 'Failed', unknown: 'Recorded', interrupted: 'Stopped' }
7
+ const INTENTS: Record<MapIntent, string> = { decision: 'Decision', hypothesis: 'Hypothesis', alternative: 'Alternative', next_step: 'Next step', context: 'Thinking' }
8
+
9
+ export function WorkMapPanel({ tabId }: { tabId: string }) {
10
+ const [snapshot, setSnapshot] = useState<MapSnapshot | null>(null)
11
+ const [error, setError] = useState<string | null>(null)
12
+ const [saving, setSaving] = useState(false)
13
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
14
+ const [selected, setSelected] = useState<string | null>(null)
15
+ const [following, setFollowing] = useState(true)
16
+ const [paths, setPaths] = useState({ pythonPath: '', modelPath: '' })
17
+ const [fullSource, setFullSource] = useState<{ eventId: string; text?: string; error?: string; loading?: boolean } | null>(null)
18
+ const scroll = useRef<HTMLDivElement>(null)
19
+ const alive = useRef(true)
20
+ const revision = useRef(-1)
21
+
22
+ const accept = (next: MapSnapshot) => {
23
+ if (!alive.current || next.tabId !== tabId || next.revision < revision.current) return
24
+ revision.current = next.revision
25
+ setSnapshot(next)
26
+ }
27
+
28
+ useEffect(() => {
29
+ alive.current = true
30
+ const unsubscribe = window.worker.onWorkMapUpdate(accept)
31
+ window.worker.getWorkMap(tabId).then(next => {
32
+ accept(next)
33
+ if (alive.current) setPaths({ pythonPath: next.settings.pythonPath, modelPath: next.settings.modelPath })
34
+ }).catch(err => { if (alive.current) setError(String(err)) })
35
+ return () => { alive.current = false; unsubscribe() }
36
+ }, [tabId])
37
+
38
+ useEffect(() => {
39
+ const event = snapshot?.nodes.find(n => n.id === selected)?.evidence.find(e => e.kind === 'thinking' || e.kind === 'message')
40
+ if (!event) { setFullSource(null); return }
41
+ let cancelled = false
42
+ setFullSource({ eventId: event.id, loading: true })
43
+ if (typeof window.worker.getWorkMapSource !== 'function') {
44
+ setFullSource({ eventId: event.id, error: 'Restart the app to load full source text. Showing the recorded preview.' })
45
+ return
46
+ }
47
+ window.worker.getWorkMapSource(tabId, event.id).then(source => {
48
+ if (!cancelled) setFullSource(source ? { eventId: event.id, text: source.text } : { eventId: event.id, error: 'The original source record is no longer available.' })
49
+ }).catch(err => { if (!cancelled) setFullSource({ eventId: event.id, error: String(err) }) })
50
+ return () => { cancelled = true }
51
+ }, [selected, tabId])
52
+
53
+ useLayoutEffect(() => {
54
+ if (following && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight
55
+ }, [snapshot?.revision, following])
56
+
57
+ async function configure(settings: Partial<MapSettings>) {
58
+ setSaving(true)
59
+ setError(null)
60
+ try { accept(await window.worker.setWorkMap(tabId, settings)) }
61
+ catch (err) { if (alive.current) setError(String(err)) }
62
+ finally { if (alive.current) setSaving(false) }
63
+ }
64
+
65
+ const toggleBranch = (id: string) => setCollapsed(prev => {
66
+ const next = new Set(prev)
67
+ if (next.has(id)) next.delete(id)
68
+ else next.add(id)
69
+ return next
70
+ })
71
+
72
+ const nodes = snapshot?.nodes || []
73
+ const steps = nodes.filter(n => n.kind === 'step')
74
+ const decisions = steps.filter(n => n.intent && n.intent !== 'context')
75
+ const nodeById = new Map(nodes.map(n => [n.id, n]))
76
+ const goals = nodes.filter(n => n.kind === 'goal')
77
+
78
+ // Render consecutive runs in event order. Reusing a topic must not move new
79
+ // actions back up the page or separate them from the preceding decision.
80
+ function threadRuns(goalId: string) {
81
+ const runs: Array<{ id: string; key: string | null; label: string; nodes: MapNode[] }> = []
82
+ for (const node of steps) {
83
+ if (nodeById.get(node.parentId || '')?.parentId !== goalId) continue
84
+ const textSource = node.evidence[0]?.kind === 'thinking' || node.evidence[0]?.kind === 'message'
85
+ const anchor = textSource ? node : nodeById.get(node.contextId || '')
86
+ const key = anchor?.id || node.parentId
87
+ const previous = runs.at(-1)
88
+ if (previous?.key === key) previous.nodes.push(node)
89
+ else runs.push({ id: `thread-${node.id}`, key, label: nodeById.get(anchor?.parentId || node.parentId || '')?.title || 'Activity', nodes: [node] })
90
+ }
91
+ return runs
92
+ }
93
+ const enabled = snapshot?.settings.enabled || false
94
+ const available = snapshot?.capability?.state === 'available'
95
+ const runtime = snapshot?.runtime
96
+ const activity = snapshot?.ended ? 'Session ended'
97
+ : !enabled ? 'Map is off'
98
+ : !snapshot?.supported ? 'Waiting for a local agent'
99
+ : snapshot.state === 'awaiting_decision' ? 'Waiting for approval'
100
+ : snapshot.state === 'awaiting_input' ? 'Waiting for you'
101
+ : runtime === 'loading' ? 'Loading Clef-Flash…'
102
+ : runtime === 'thinking' ? 'Connecting the dots…'
103
+ : runtime === 'error' ? 'Model paused'
104
+ : steps.length ? 'Following this session' : 'Waiting for activity'
105
+
106
+ function renderStep(node: MapNode) {
107
+ const open = selected === node.id
108
+ const thinking = node.evidence[0]?.kind === 'thinking'
109
+ const actions = nodes.filter(n => n.contextId === node.id)
110
+ const linked = !!node.contextId && nodeById.has(node.contextId)
111
+ return <li key={node.id} className={`work-map-step ${node.status}${linked ? ' follows-intent' : ''}`}>
112
+ <button className="work-map-step-button" onClick={() => setSelected(open ? null : node.id)} aria-expanded={open}>
113
+ <span className={`work-map-activity ${node.activity}`} title={node.activity} aria-label={node.activity}>{ICONS[node.activity]}</span>
114
+ <span className="work-map-step-copy"><span className="work-map-step-title">{node.focus?.text || node.title}</span>
115
+ {node.focus?.reason && <span className="work-map-reason"><b>Why</b> {node.focus.reason}</span>}
116
+ <span className="work-map-step-meta">
117
+ {node.intent && node.intent !== 'context' ? <span className={`work-map-intent ${node.intent}`}>{INTENTS[node.intent]}</span> : <span className={`work-map-status ${node.status}`}>{STATUS[node.status]}</span>}
118
+ {(thinking || node.evidence[0]?.kind === 'message') && <span>{thinking ? 'Thinking summary' : 'Agent update'}</span>}
119
+ {linked && <span className="work-map-link" title={`Follows: ${nodeById.get(node.contextId!)?.title}`}>↳ {nodeById.get(node.contextId!)?.evidence[0]?.kind === 'thinking' ? 'Follows thinking' : 'Follows update'}</span>}
120
+ <time dateTime={node.ts}>{new Date(node.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>
121
+ {actions.length > 0 && <span>{actions.length} following {actions.length === 1 ? 'action' : 'actions'}</span>}
122
+ {node.placement === 'clef-flash' && <span title="Topic placement suggested by Clef-Flash">Clef ↗</span>}
123
+ </span>
124
+ </span>
125
+ <span className="work-map-receipt-caret" aria-hidden="true">{open ? '−' : '+'}</span>
126
+ </button>
127
+ {open && <div className="work-map-evidence">
128
+ <div className="work-map-evidence-heading">Source events</div>
129
+ {node.evidence.map(e => <div key={e.id} className="work-map-receipt">
130
+ <span>{e.kind === 'thinking' ? fullSource?.eventId === e.id && fullSource.text !== undefined ? 'Full thinking' : 'Thinking summary' : e.kind === 'tool' ? 'Tool call' : e.kind === 'result' ? 'Tool result' : fullSource?.eventId === e.id && fullSource.text !== undefined ? 'Full agent update' : 'Agent message'}</span>
131
+ {fullSource?.eventId === e.id && fullSource.loading && <small>Loading full source…</small>}
132
+ {fullSource?.eventId === e.id && fullSource.error && <small>{fullSource.error}</small>}
133
+ <pre className="work-map-source-text">{highlightSource(fullSource?.eventId === e.id && fullSource.text !== undefined ? fullSource.text : e.text, node.focus)}</pre>
134
+ {node.focus && <small className="work-map-highlight-key"><span>Highlighted: important choice</span>{node.focus.reason && <span>supporting reason</span>}</small>}
135
+ <code title={`${e.source} · ${e.toolId || e.id}`}>{e.source}<br />{e.toolId || e.id}</code>
136
+ </div>)}
137
+ {node.placement === 'clef-flash' && <small>Clef suggested the topic{thinking ? ' and interpretation' : ''}. {thinking ? 'A stated plan or hypothesis is not a verified result.' : 'Execution status comes from the tool result.'}</small>}
138
+ </div>}
139
+ </li>
140
+ }
141
+
142
+ return <section className="work-map-panel" aria-label="Live work map">
143
+ <div className="work-map-controls">
144
+ <div className="work-map-heading"><span className="work-map-kicker">THE THREAD OF WORK</span>
145
+ <div className="work-map-heading-row"><h2>Follow the thread.</h2>
146
+ <button className={`work-map-switch ${enabled ? 'on' : ''}`} role="switch" aria-label="Enable work map" aria-checked={enabled}
147
+ disabled={!snapshot || saving || !available} onClick={() => void configure({ enabled: !enabled })}>
148
+ <span /><b>{enabled ? 'On' : 'Off'}</b>
149
+ </button>
150
+ </div>
151
+ </div>
152
+ <div className="work-map-engine-row"><label htmlFor={`map-engine-${tabId}`}>Engine</label>
153
+ <select id={`map-engine-${tabId}`} value={snapshot?.settings.engine || 'clef-flash'} disabled={!snapshot || saving || !available}
154
+ onChange={e => void configure({ engine: e.target.value as MapSettings['engine'] })}>
155
+ <option value="clef-flash">Clef-Flash · local</option><option value="rules">Rules · lightweight</option>
156
+ </select>
157
+ </div>
158
+ {!available && snapshot && <p className="work-map-thinking-status" role="status">{snapshot.capability?.reason || 'Restart the app to check Apple GPU availability.'}</p>}
159
+ {enabled && snapshot?.supported && <p className="work-map-thinking-status" role="status">
160
+ {snapshot.thinkingStatus === 'available' ? 'Readable thinking included'
161
+ : snapshot.thinkingStatus === 'unavailable' ? 'This transcript omits readable thinking summaries.'
162
+ : 'Waiting for a readable thinking summary.'}
163
+ </p>}
164
+ <div className="work-map-live" role="status"><span className={`work-map-live-dot ${enabled && snapshot?.supported && !snapshot?.ended ? 'on' : ''}`} />
165
+ {activity}<span className="work-map-agent">{snapshot?.agentName}</span>
166
+ </div>
167
+ {(error || snapshot?.error) && <div className="work-map-error" role="alert">{error || snapshot?.error}
168
+ <button disabled={saving || !available} onClick={() => void configure({ enabled: true })}>Retry</button>
169
+ </div>}
170
+ {snapshot?.settings.engine === 'clef-flash' && <details className="work-map-setup">
171
+ <summary>Local model setup</summary>
172
+ <p>Use your downloaded Clef-Flash 8-bit model and its Python environment. Inference stays on this Mac.</p>
173
+ <label>Python executable<input value={paths.pythonPath} onChange={e => setPaths(p => ({ ...p, pythonPath: e.target.value }))} spellCheck={false} /></label>
174
+ <label>Model folder<input value={paths.modelPath} onChange={e => setPaths(p => ({ ...p, modelPath: e.target.value }))} spellCheck={false} /></label>
175
+ <button disabled={saving || !available || !paths.pythonPath.trim() || !paths.modelPath.trim()} onClick={() => void configure(paths)}>Save paths</button>
176
+ </details>}
177
+ </div>
178
+
179
+ <div className="work-map-scroll" ref={scroll} onScroll={() => {
180
+ const el = scroll.current
181
+ if (el) setFollowing(el.scrollHeight - el.scrollTop - el.clientHeight < 55)
182
+ }}>
183
+ {goals.length === 0 ? <div className="work-map-empty">
184
+ <div className="work-map-empty-tree" aria-hidden="true">◇<span>├─ ⌕</span><span>└─ ✓</span></div>
185
+ <h3>{enabled ? 'The choices and the work, together.' : 'A map of what happens next.'}</h3>
186
+ <p>{!enabled ? 'Turn on the map to follow goals, investigations, edits, and checks as your agent works.'
187
+ : !snapshot?.supported ? 'Start a local Claude Code or Codex session to see its work here. Remote sessions are not supported yet.'
188
+ : 'Stated decisions, readable thinking, actions, and results appear in one continuous thread. Open any card to inspect its source.'}</p>
189
+ </div> : <div className="work-map-forest">
190
+ {goals.map((goal, i) => <article className="work-map-goal" key={goal.id}>
191
+ <button className="work-map-goal-heading" onClick={() => toggleBranch(goal.id)} aria-expanded={!collapsed.has(goal.id)} title={goal.evidence[0]?.text}>
192
+ <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>
193
+ <span className="work-map-caret">{collapsed.has(goal.id) ? '▸' : '▾'}</span>
194
+ </button>
195
+ {!collapsed.has(goal.id) && <div className="work-map-branches">
196
+ {threadRuns(goal.id).map(run => {
197
+ const failed = run.nodes.some(n => n.status === 'failed')
198
+ return <div className="work-map-branch" key={run.id}>
199
+ <button className="work-map-branch-heading" onClick={() => toggleBranch(run.id)} aria-expanded={!collapsed.has(run.id)}>
200
+ <span className={`work-map-branch-point ${failed ? 'failed' : ''}`} />
201
+ <span className="work-map-branch-label">{run.label}</span><span className="work-map-count">{run.nodes.length}</span>
202
+ <span className="work-map-caret">{collapsed.has(run.id) ? '▸' : '▾'}</span>
203
+ </button>
204
+ {!collapsed.has(run.id) && <ul className="work-map-steps" aria-label="Activity thread">{run.nodes.map(renderStep)}</ul>}
205
+ </div>
206
+ })}
207
+ </div>}
208
+ </article>)}
209
+ {snapshot?.dropped ? <p className="work-map-retained">Earlier activity trimmed · keeping the latest 240 nodes</p> : null}
210
+ </div>}
211
+ </div>
212
+ {!following && steps.length > 0 && <button className="work-map-jump" onClick={() => setFollowing(true)}>↓ Follow latest</button>}
213
+ <footer className="work-map-footer"><span>{decisions.length} {decisions.length === 1 ? 'choice' : 'choices'} · {steps.length} events · local</span>
214
+ <button disabled={!steps.length || saving} onClick={async () => {
215
+ setError(null)
216
+ try { accept(await window.worker.clearWorkMap(tabId)); setCollapsed(new Set()); setSelected(null); setFollowing(true) }
217
+ catch (err) { if (alive.current) setError(String(err)) }
218
+ }}>Clear map</button>
219
+ </footer>
220
+ </section>
221
+ }
222
+
223
+ function highlightSource(text: string, focus?: MapNode['focus']): ReactNode {
224
+ const ranges = thinkingHighlights(text, focus)
225
+ if (!ranges.length) return text
226
+ const parts: ReactNode[] = []
227
+ let offset = 0
228
+ for (const range of ranges) {
229
+ parts.push(text.slice(offset, range.start))
230
+ parts.push(<mark key={range.start} className={`work-map-highlight ${range.kind}`}>{text.slice(range.start, range.end)}</mark>)
231
+ offset = range.end
232
+ }
233
+ parts.push(text.slice(offset))
234
+ return parts
235
+ }
@@ -0,0 +1,50 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { MapCapability } from '../../shared/workMap'
3
+
4
+ let capability: MapCapability = { state: 'checking', reason: 'Checking Apple GPU availability…' }
5
+
6
+ export function canOpenWorkMap(): boolean {
7
+ return capability.state === 'available'
8
+ }
9
+
10
+ export function WorkMapToggle({ visible, onToggle, onUnavailable }: {
11
+ visible: boolean
12
+ onToggle: () => void
13
+ onUnavailable: () => void
14
+ }) {
15
+ const [current, setCurrent] = useState(capability)
16
+ useEffect(() => {
17
+ let cancelled = false
18
+ let pushed = false
19
+ const accept = (next: MapCapability) => {
20
+ if (cancelled) return
21
+ capability = next
22
+ setCurrent(next)
23
+ if (next.state === 'unavailable') onUnavailable()
24
+ }
25
+ if (!window.worker.getWorkMapCapability) {
26
+ accept({ state: 'unavailable', reason: 'Restart the app to check Apple GPU availability.' })
27
+ return
28
+ }
29
+ const unsubscribe = window.worker.onWorkMapCapability(next => { pushed = true; accept(next) })
30
+ window.worker.getWorkMapCapability().then(next => { if (!pushed) accept(next) })
31
+ .catch(() => accept({ state: 'unavailable', reason: 'GPU availability could not be verified.' }))
32
+ return () => { cancelled = true; unsubscribe() }
33
+ }, [])
34
+
35
+ const available = current.state === 'available'
36
+ return <button
37
+ className={`titlebar-btn titlebar-icon-btn ${visible ? 'active' : ''}`}
38
+ onClick={onToggle}
39
+ disabled={!available}
40
+ title={available ? `Show or hide the live work map · ${current.gpuName || 'Apple GPU'} (Ctrl+Shift+M)` : current.reason || 'Work map is unavailable'}
41
+ aria-label="Work map"
42
+ aria-pressed={visible}
43
+ >
44
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
45
+ <rect x="2" y="9" width="6" height="6" rx="2" /><rect x="16" y="2" width="6" height="6" rx="2" /><rect x="16" y="16" width="6" height="6" rx="2" />
46
+ <path d="M8 12h4V5h4M12 12v7h4" />
47
+ </svg>
48
+ <span>Work map</span>
49
+ </button>
50
+ }
@@ -0,0 +1,41 @@
1
+ export interface ThinkingHighlight { start: number; end: number; kind: 'choice' | 'reason' }
2
+
3
+ function canonical(text: string) {
4
+ let value = ''
5
+ const offsets: number[] = []
6
+ for (let i = 0; i < text.length; i++) {
7
+ if (text[i] === '*' || text[i] === '`') continue
8
+ const ch = /\s/.test(text[i]) ? ' ' : text[i]
9
+ if (ch === ' ' && value.endsWith(' ')) continue
10
+ value += ch
11
+ offsets.push(i)
12
+ }
13
+ return { value, offsets }
14
+ }
15
+
16
+ /** Locate selected excerpts in the original text, preserving whitespace and Markdown. */
17
+ export function thinkingHighlights(text: string, focus?: { text: string; reason?: string }): ThinkingHighlight[] {
18
+ if (!focus) return []
19
+ const source = canonical(text)
20
+ const ranges: ThinkingHighlight[] = []
21
+ for (const kind of ['choice', 'reason'] as const) {
22
+ const excerpt = kind === 'choice' ? focus.text : focus.reason
23
+ if (!excerpt) continue
24
+ const needle = canonical(excerpt).value.trim()
25
+ if (!needle) continue
26
+ const index = source.value.indexOf(needle)
27
+ if (index < 0) continue
28
+ let start = source.offsets[index]
29
+ let end = source.offsets[index + needle.length - 1] + 1
30
+ while (start > 0 && /[*`]/.test(text[start - 1])) start--
31
+ while (end < text.length && /[*`]/.test(text[end])) end++
32
+ ranges.push({ start, end, kind })
33
+ }
34
+ const merged: ThinkingHighlight[] = []
35
+ for (const range of ranges.sort((a, b) => a.start - b.start)) {
36
+ const last = merged.at(-1)
37
+ if (last && range.start < last.end) { last.end = Math.max(last.end, range.end); if (range.kind === 'choice') last.kind = 'choice' }
38
+ else merged.push({ ...range })
39
+ }
40
+ return merged
41
+ }
@@ -17,6 +17,97 @@
17
17
  flipped mechanically.
18
18
  ───────────────────────────────────────────────────────────── */
19
19
 
20
+ /* Live work map */
21
+ .work-map-panel { position: relative; display: flex; flex-direction: column; height: 100%; min-width: 0; overflow: hidden; background: var(--bg-app); color: var(--text); font-size: 12px; }
22
+ .work-map-controls { padding: 20px 18px 12px; flex-shrink: 0; border-bottom: 1px solid var(--border-subtle); background: var(--bg-chrome); max-height: 55%; overflow-y: auto; }
23
+ .work-map-kicker { display: block; color: var(--text-muted); font-size: 9px; font-weight: 700; letter-spacing: 1.6px; line-height: 1.8; }
24
+ .work-map-heading-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 4px 0 10px; }
25
+ .work-map-heading h2 { margin: 0; color: var(--text-bright); font-size: 21px; font-weight: 550; letter-spacing: -0.7px; }
26
+ .work-map-panel button, .work-map-panel select, .work-map-panel input { font: inherit; }
27
+ .work-map-panel button { cursor: pointer; }
28
+ .work-map-panel button:disabled { opacity: .45; cursor: default; }
29
+ .work-map-panel button:focus-visible, .work-map-panel summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
30
+ .work-map-switch { display: flex; align-items: center; gap: 6px; flex-shrink: 0; padding: 4px 7px; border: 1px solid var(--border); border-radius: 20px; background: var(--bg-surface); color: var(--text-muted); }
31
+ .work-map-switch span { width: 11px; height: 11px; border-radius: 50%; background: var(--text-muted); }
32
+ .work-map-switch b { font-size: 10px; font-weight: 600; }
33
+ .work-map-switch.on { color: var(--success); border-color: var(--success); background: var(--bg-chip-soft); }
34
+ .work-map-switch.on span { background: var(--success); }
35
+ .work-map-engine-row { display: flex; gap: 12px; align-items: center; }
36
+ .work-map-engine-row label { color: var(--text-soft); font-size: 11px; }
37
+ .work-map-engine-row select { flex: 1; min-width: 0; padding: 6px 8px; color: var(--text-bright); background: var(--bg-well); border: 1px solid var(--border-subtle); border-radius: 6px; }
38
+ .work-map-live { display: flex; align-items: center; gap: 6px; padding-top: 12px; font-size: 10px; color: var(--text-soft); }
39
+ .work-map-live-dot { width: 5px; height: 5px; flex-shrink: 0; background: var(--text-muted); border-radius: 50%; }
40
+ .work-map-live-dot.on { background: var(--success); box-shadow: 0 0 7px color-mix(in srgb, var(--success) 30%, transparent); }
41
+ .work-map-agent { margin-left: auto; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
42
+ .work-map-error { padding: 10px; margin-top: 12px; border: 1px solid var(--danger); border-radius: 6px; color: var(--danger); font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; }
43
+ .work-map-error button, .work-map-setup button { display: block; background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-bright); border-radius: 4px; padding: 4px 9px; margin-top: 8px; }
44
+ .work-map-setup { margin-top: 10px; font-size: 10px; color: var(--text-muted); }
45
+ .work-map-setup summary { cursor: pointer; width: fit-content; }
46
+ .work-map-setup p { color: var(--text-soft); line-height: 1.6; }
47
+ .work-map-setup label { display: block; margin-top: 8px; color: var(--text-soft); }
48
+ .work-map-setup input { display: block; width: 100%; box-sizing: border-box; margin-top: 4px; padding: 6px; background: var(--bg-well); color: var(--text); border: 1px solid var(--border); border-radius: 4px; font-size: 10px; }
49
+ .work-map-scroll { flex: 1; min-height: 0; overflow: auto; padding: 20px 14px 40px; scrollbar-gutter: stable; overflow-anchor: none; }
50
+ .work-map-empty { padding: 24px 14px; max-width: 280px; margin: auto; }
51
+ .work-map-empty-tree { display: flex; flex-direction: column; color: var(--accent); font: 22px/1.7 ui-monospace, monospace; margin: 0 0 20px 10px; }
52
+ .work-map-empty-tree span { color: var(--text-muted); padding-left: 5px; }
53
+ .work-map-empty h3 { font-size: 16px; font-weight: 500; line-height: 1.5; color: var(--text-bright); margin: 0 0 10px; }
54
+ .work-map-empty p { color: var(--text-soft); font-size: 12px; line-height: 1.8; margin: 0; }
55
+ .work-map-goal + .work-map-goal { margin-top: 28px; }
56
+ .work-map-goal-heading { display: flex; align-items: flex-start; gap: 9px; width: 100%; border: 0; padding: 0; background: transparent; text-align: left; color: var(--text-bright); }
57
+ .work-map-goal-icon { font-size: 22px; line-height: 1.5; color: var(--accent); flex-shrink: 0; }
58
+ .work-map-goal-heading strong { display: block; line-height: 1.5; font-size: 13px; font-weight: 550; overflow-wrap: anywhere; }
59
+ .work-map-caret { margin-left: auto; color: var(--text-muted); flex-shrink: 0; font-size: 12px; }
60
+ .work-map-branches { margin: 12px 0 0 10px; padding-left: 17px; border-left: 1px solid var(--border-dim); }
61
+ .work-map-branch { position: relative; padding: 0 0 17px; }
62
+ .work-map-branch:last-child { padding-bottom: 0; }
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); }
66
+ .work-map-branch-point.failed { background: var(--danger); }
67
+ .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; }
69
+ .work-map-steps { list-style: none; margin: 5px 0 0 2px; padding: 0 0 0 8px; border-left: 1px solid var(--border-subtle); }
70
+ .work-map-step { margin: 5px 0; border: 1px solid var(--border-well); background: var(--bg-well); border-radius: 7px; overflow: hidden; }
71
+ .work-map-step.failed { border-color: color-mix(in srgb, var(--danger) 45%, var(--border-well)); }
72
+ .work-map-step-button { display: flex; align-items: flex-start; gap: 8px; padding: 10px 9px; width: 100%; border: 0; background: transparent; color: var(--text); text-align: left; }
73
+ .work-map-step-button:hover { background: var(--bg-inset-hi); }
74
+ .work-map-activity { display: grid; place-items: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: 5px; background: var(--bg-chip); color: var(--accent); font-size: 17px; }
75
+ .work-map-activity.editing { color: var(--warning); }
76
+ .work-map-activity.testing { color: var(--success); }
77
+ .work-map-activity.planning { color: var(--sev-digest); }
78
+ .work-map-activity.thinking { color: var(--sev-digest); }
79
+ .work-map-step.follows-intent { margin-left: 12px; border-left: 2px solid var(--border-dim); }
80
+ .work-map-link { color: var(--text-soft); }
81
+ .work-map-reason { display: block; border-left: 2px solid var(--border-dim); padding-left: 7px; margin-top: 7px; color: var(--text-soft); font-size: 10px; line-height: 1.7; overflow-wrap: anywhere; }
82
+ .work-map-reason b { color: var(--text-bright); font-size: 9px; margin-right: 3px; }
83
+ .work-map-receipt .work-map-source-text { max-height: 310px; line-height: 1.8; }
84
+ .work-map-highlight { color: var(--text-bright); background: color-mix(in srgb, var(--warning) 25%, transparent); border-radius: 2px; padding: 1px 0; }
85
+ .work-map-highlight.reason { background: color-mix(in srgb, var(--accent) 25%, transparent); }
86
+ .work-map-highlight-key { display: flex !important; flex-wrap: wrap; gap: 10px; }
87
+ .work-map-highlight-key span:first-child { color: var(--warning); }
88
+ .work-map-highlight-key span + span { color: var(--accent); }
89
+ .work-map-thinking-status { color: var(--text-muted); font-size: 10px; line-height: 1.5; margin: 8px 0 0; }
90
+ .work-map-intent { color: var(--sev-digest); }
91
+ .work-map-intent.hypothesis { color: var(--warning); }
92
+ .work-map-intent.decision { color: var(--accent); }
93
+ .work-map-step-copy { min-width: 0; flex: 1; }
94
+ .work-map-step-title { display: block; overflow-wrap: anywhere; line-height: 1.6; font-size: 11px; }
95
+ .work-map-step-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 5px; font-size: 9px; color: var(--text-muted); }
96
+ .work-map-status.running { color: var(--accent); }
97
+ .work-map-status.succeeded { color: var(--success); }
98
+ .work-map-status.failed { color: var(--danger); }
99
+ .work-map-receipt-caret { color: var(--text-muted); line-height: 1.6; font-size: 12px; }
100
+ .work-map-evidence { padding: 0 10px 10px; font-size: 10px; line-height: 1.6; }
101
+ .work-map-evidence-heading { border-top: 1px solid var(--border-subtle); padding-top: 10px; color: var(--text-bright); font-weight: 600; }
102
+ .work-map-receipt { margin-top: 10px; color: var(--text-soft); }
103
+ .work-map-receipt pre { font: 10px/1.6 ui-monospace, monospace; color: var(--text); background: var(--bg-app); white-space: pre-wrap; overflow-wrap: anywhere; padding: 8px; border-radius: 4px; max-height: 170px; overflow: auto; }
104
+ .work-map-receipt code { display: block; color: var(--text-muted); font-size: 9px; overflow-wrap: anywhere; }
105
+ .work-map-evidence small { display: block; margin-top: 10px; color: var(--text-muted); font-size: 9px; }
106
+ .work-map-retained { color: var(--text-muted); font-size: 10px; padding: 12px 0; text-align: center; }
107
+ .work-map-jump { position: absolute; bottom: 45px; left: 50%; transform: translateX(-50%); white-space: nowrap; border: 1px solid var(--border-strong); border-radius: 20px; padding: 7px 13px; background: var(--bg-raised); color: var(--accent); box-shadow: 0 3px 12px var(--shadow-color); }
108
+ .work-map-footer { display: flex; align-items: center; justify-content: space-between; padding: 9px 14px; gap: 8px; border-top: 1px solid var(--border-subtle); background: var(--bg-chrome); color: var(--text-muted); font-size: 9px; }
109
+ .work-map-footer button { border: 0; background: transparent; color: var(--text-soft); padding: 2px; font-size: 10px; }
110
+
20
111
  :root,
21
112
  :root[data-theme='tokyo-night'] {
22
113
  color-scheme: dark;
@@ -0,0 +1,70 @@
1
+ export type MapEngine = 'rules' | 'clef-flash'
2
+ export type MapActivity = 'thinking' | 'investigating' | 'editing' | 'testing' | 'planning' | 'reporting'
3
+ export type MapIntent = 'decision' | 'hypothesis' | 'alternative' | 'next_step' | 'context'
4
+ export type MapStatus = 'running' | 'succeeded' | 'failed' | 'unknown' | 'interrupted'
5
+
6
+ export interface MapCapability {
7
+ state: 'checking' | 'available' | 'unavailable'
8
+ reason: string | null
9
+ gpuName?: string
10
+ }
11
+
12
+ export interface MapSettings {
13
+ enabled: boolean
14
+ engine: MapEngine
15
+ pythonPath: string
16
+ modelPath: string
17
+ }
18
+
19
+ export interface MapEvidence {
20
+ id: string
21
+ ts: string
22
+ kind: 'prompt' | 'thinking' | 'message' | 'tool' | 'result'
23
+ text: string
24
+ source: string
25
+ tool?: string
26
+ toolId?: string
27
+ status?: MapStatus
28
+ /** Salient candidates from the complete readable block, before display truncation. */
29
+ segments?: string[]
30
+ /** Populated only by an on-demand source read, never retained in map snapshots. */
31
+ fullText?: string
32
+ }
33
+
34
+ export interface MapNode {
35
+ id: string
36
+ parentId: string | null
37
+ kind: 'goal' | 'branch' | 'step'
38
+ title: string
39
+ activity: MapActivity
40
+ status: MapStatus
41
+ ts: string
42
+ evidence: MapEvidence[]
43
+ placement?: MapEngine
44
+ confidence?: number
45
+ intent?: MapIntent
46
+ /** Most recent stated reasoning/intent before this action, within the same goal. */
47
+ contextId?: string
48
+ focus?: { text: string; reason?: string }
49
+ }
50
+
51
+ export interface MapSnapshot {
52
+ revision: number
53
+ tabId: string
54
+ agentName: string | null
55
+ supported: boolean
56
+ ended: boolean
57
+ state: string
58
+ settings: MapSettings
59
+ runtime: 'off' | 'idle' | 'loading' | 'ready' | 'thinking' | 'error'
60
+ error: string | null
61
+ nodes: MapNode[]
62
+ dropped: number
63
+ thinkingStatus: 'waiting' | 'available' | 'unavailable'
64
+ capability: MapCapability
65
+ }
66
+
67
+ export interface MapSource {
68
+ text: string
69
+ source: string
70
+ }