@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.
- package/README.md +3 -1
- package/out/headless/index.mjs +1 -1
- package/out/headless/index.mjs.map +1 -1
- package/out/renderer/assets/{cssMode-CRJW4vYa.js → cssMode-DOrZXO36.js} +3 -3
- package/out/renderer/assets/{freemarker2-IgsORiC6.js → freemarker2-5u4104Xl.js} +1 -1
- package/out/renderer/assets/{handlebars-pDu2AR4b.js → handlebars-C_laShLt.js} +1 -1
- package/out/renderer/assets/{html-DNF7NUE5.js → html-DO3EoflS.js} +1 -1
- package/out/renderer/assets/{htmlMode-gbfJICtW.js → htmlMode-Cwu8rsx-.js} +3 -3
- package/out/renderer/assets/{index-CbuISbRh.js → index-CHdMn4av.js} +139 -53
- package/out/renderer/assets/{index-CBWAeUYW.css → index-CmvYnexl.css} +15 -3
- package/out/renderer/assets/{javascript-CzPWOwBB.js → javascript-BPewzTWL.js} +2 -2
- package/out/renderer/assets/{jsonMode-CWOEvILb.js → jsonMode-BjgG963U.js} +3 -3
- package/out/renderer/assets/{liquid-CP-HUzpK.js → liquid-K0EVwJ4n.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-DsiaqQq_.js → lspLanguageFeatures-m4j5zxQl.js} +1 -1
- package/out/renderer/assets/{mdx-DerBk5VK.js → mdx-DG0llsx7.js} +1 -1
- package/out/renderer/assets/{python-DkUMRAVL.js → python-CwfkGHje.js} +1 -1
- package/out/renderer/assets/{razor-C6ynuc0X.js → razor-DxTakA2q.js} +1 -1
- package/out/renderer/assets/{tsMode-DXrOkNVu.js → tsMode-BANGjk9B.js} +1 -1
- package/out/renderer/assets/{typescript-BmhhpI43.js → typescript-DiF0ejYV.js} +1 -1
- package/out/renderer/assets/{xml-CTMRAgAB.js → xml-BLsIDJrq.js} +1 -1
- package/out/renderer/assets/{yaml-DYNNafNI.js → yaml-BWqzZSkF.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/scripts/test-work-map-ui.cjs +85 -9
- package/src/renderer/App.tsx +10 -1
- package/src/renderer/components/RollingCount.tsx +60 -0
- package/src/renderer/components/TicketPanel.tsx +24 -7
- package/src/renderer/components/WorkMapPanel.tsx +43 -22
- package/src/renderer/styles.css +15 -3
package/out/renderer/index.html
CHANGED
|
@@ -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-
|
|
110
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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.
|
|
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')
|
|
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`),
|
|
63
|
-
await run(`document.querySelector('.work-map-
|
|
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.
|
|
95
|
-
await run(`document.
|
|
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
|
|
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()
|
package/src/renderer/App.tsx
CHANGED
|
@@ -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
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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));
|
|
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
|
package/src/renderer/styles.css
CHANGED
|
@@ -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:
|
|
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:
|
|
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)); }
|