agentgui 1.0.1126 → 1.0.1127

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.

Potentially problematic release.


This version of agentgui might be problematic. Click here for more details.

Files changed (170) hide show
  1. package/.gm/browser-config.json +1 -0
  2. package/.gm/disciplines/agentgui/mem-1779277005143-0-1352.json +1 -0
  3. package/.gm/disciplines/agentgui/memories/.flat-export-done +0 -0
  4. package/.gm/disciplines/agentgui/memories/mem-c07fa3b0836edbe2-1352.md +8 -0
  5. package/.gm/gm.db +0 -0
  6. package/.gm/memories/.flat-export-done +1 -0
  7. package/.gm/memories/mem-0131ff4869d86085-370.md +10 -0
  8. package/.gm/memories/mem-04049d11cb8c1661-2135.md +8 -0
  9. package/.gm/memories/mem-069ff10372ed1770-1375.md +8 -0
  10. package/.gm/memories/mem-08a4a9e050a4deff-3048.md +8 -0
  11. package/.gm/memories/mem-09597ee0df9c9224-965.md +8 -0
  12. package/.gm/memories/mem-0993d156f571b619-1059.md +8 -0
  13. package/.gm/memories/mem-0b681bf2340c8df1-3170.md +8 -0
  14. package/.gm/memories/mem-0c444abde86202f6-1681.md +8 -0
  15. package/.gm/memories/mem-0d1408fa048b9923-1159.md +8 -0
  16. package/.gm/memories/mem-0dad5d82fec829ba-887.md +8 -0
  17. package/.gm/memories/mem-10a776effb7ce3a6-935.md +8 -0
  18. package/.gm/memories/mem-10d264b4f8e66fb4-1492.md +8 -0
  19. package/.gm/memories/mem-118c35ac67541d98-2909.md +8 -0
  20. package/.gm/memories/mem-119cf70dd19aa0a5-2225.md +8 -0
  21. package/.gm/memories/mem-15a07cd761b337c4-4320.md +8 -0
  22. package/.gm/memories/mem-15c73aca528535cb-2183.md +8 -0
  23. package/.gm/memories/mem-166f52796185e0e7-1487.md +8 -0
  24. package/.gm/memories/mem-1a148cf9c3126df5-3963.md +8 -0
  25. package/.gm/memories/mem-1a459001058f618e-567.md +8 -0
  26. package/.gm/memories/mem-1af3e13f7618e668-2146.md +8 -0
  27. package/.gm/memories/mem-1fd31d6873e9c164-794.md +8 -0
  28. package/.gm/memories/mem-21f551be092fd77d-2340.md +8 -0
  29. package/.gm/memories/mem-231ff5de003f20f0-586.md +8 -0
  30. package/.gm/memories/mem-2567393d3adb03ab-347.md +8 -0
  31. package/.gm/memories/mem-27faf41cdd050c80-1261.md +8 -0
  32. package/.gm/memories/mem-28889e43e8ce64cc-3663.md +8 -0
  33. package/.gm/memories/mem-2f4fd06515abf439-2562.md +8 -0
  34. package/.gm/memories/mem-34a749430303c5f7-544.md +8 -0
  35. package/.gm/memories/mem-3602d11d7bce9446-1057.md +8 -0
  36. package/.gm/memories/mem-38b7e24dbcba2f3e-639.md +8 -0
  37. package/.gm/memories/mem-39a7cc2f887a90b1-1079.md +8 -0
  38. package/.gm/memories/mem-40d6169c8759061d-806.md +8 -0
  39. package/.gm/memories/mem-4420e6d00e5c8632-2318.md +8 -0
  40. package/.gm/memories/mem-447e5a54ddc9357a-654.md +8 -0
  41. package/.gm/memories/mem-455b3beb4afabe14-591.md +8 -0
  42. package/.gm/memories/mem-4845ee594759151c-560.md +8 -0
  43. package/.gm/memories/mem-4a1254c60b868b88-374.md +8 -0
  44. package/.gm/memories/mem-4bafafa47a0e4b6c-3237.md +8 -0
  45. package/.gm/memories/mem-4c06c45b4d31bc60-1202.md +8 -0
  46. package/.gm/memories/mem-4dfac801d0378448-3676.md +8 -0
  47. package/.gm/memories/mem-4ecd3b713e384170-122.md +10 -0
  48. package/.gm/memories/mem-51d8992a3c6f6c9c-1073.md +8 -0
  49. package/.gm/memories/mem-539eee7e8c6c10ee-2557.md +8 -0
  50. package/.gm/memories/mem-559b8b2abc4b1091-1665.md +8 -0
  51. package/.gm/memories/mem-59dd73a463e54948-871.md +8 -0
  52. package/.gm/memories/mem-5b99b882ffe1a3c2-2262.md +8 -0
  53. package/.gm/memories/mem-5feb252a5244149c-1791.md +8 -0
  54. package/.gm/memories/mem-6224f8d185797f2b-1047.md +8 -0
  55. package/.gm/memories/mem-6434ee8261a6a6a2-3126.md +8 -0
  56. package/.gm/memories/mem-66b0947b946f3437-1155.md +8 -0
  57. package/.gm/memories/mem-6bdf783a1f6be7c2-1062.md +8 -0
  58. package/.gm/memories/mem-6d49f08bebac54c9-744.md +8 -0
  59. package/.gm/memories/mem-70d96acb736af2d7-685.md +8 -0
  60. package/.gm/memories/mem-70de7d5e71fcd439-2680.md +8 -0
  61. package/.gm/memories/mem-713614f12d7029da-1577.md +8 -0
  62. package/.gm/memories/mem-720d76c88e64f6ee-1967.md +8 -0
  63. package/.gm/memories/mem-7535f34f430fc196-1340.md +8 -0
  64. package/.gm/memories/mem-861a588d2773681a-1311.md +8 -0
  65. package/.gm/memories/mem-8af7980a92e09959-891.md +8 -0
  66. package/.gm/memories/mem-8b438ef4c609b7af-401.md +8 -0
  67. package/.gm/memories/mem-8f59884039787ef3-743.md +8 -0
  68. package/.gm/memories/mem-92b199c2024ee1aa-2513.md +8 -0
  69. package/.gm/memories/mem-9329105b05b9c0f6-324.md +10 -0
  70. package/.gm/memories/mem-94f30cec3c283f97-782.md +10 -0
  71. package/.gm/memories/mem-956d4813319f65a3-1710.md +8 -0
  72. package/.gm/memories/mem-96fffbb6615e031b-1263.md +8 -0
  73. package/.gm/memories/mem-993ab31f7a10aefa-718.md +8 -0
  74. package/.gm/memories/mem-a3e440db285f655a-995.md +8 -0
  75. package/.gm/memories/mem-a52933cd6495e6f5-2624.md +8 -0
  76. package/.gm/memories/mem-a86426ac44138c96-1606.md +8 -0
  77. package/.gm/memories/mem-a95b1d49d71133ce-1007.md +8 -0
  78. package/.gm/memories/mem-a9968a186368b022-1150.md +8 -0
  79. package/.gm/memories/mem-aaeae38d8eb01809-1076.md +8 -0
  80. package/.gm/memories/mem-ade01d9c02677c55-1557.md +8 -0
  81. package/.gm/memories/mem-b06b8c85b81663e6-1250.md +8 -0
  82. package/.gm/memories/mem-b451e5e17e555202-1490.md +8 -0
  83. package/.gm/memories/mem-b64ad00dcb6f909e-937.md +8 -0
  84. package/.gm/memories/mem-b7d6018ceca55b6f-2220.md +8 -0
  85. package/.gm/memories/mem-bdf466854032cac9-1509.md +8 -0
  86. package/.gm/memories/mem-c0482039e6ab4c50-2047.md +8 -0
  87. package/.gm/memories/mem-c4aaec5c4e6f80c2-920.md +8 -0
  88. package/.gm/memories/mem-c66976ddacb4fbe8-1454.md +8 -0
  89. package/.gm/memories/mem-c7c5bc263850c332-1844.md +8 -0
  90. package/.gm/memories/mem-cda639547932c796-713.md +8 -0
  91. package/.gm/memories/mem-cdf04ff6a6a0aa6f-671.md +8 -0
  92. package/.gm/memories/mem-cf8472c84d9e04c4-1883.md +8 -0
  93. package/.gm/memories/mem-cfd4d894370c63c3-1288.md +8 -0
  94. package/.gm/memories/mem-d060480435f92eec-1146.md +8 -0
  95. package/.gm/memories/mem-d347c921b79e6d44-1099.md +8 -0
  96. package/.gm/memories/mem-d3c8ea6c861bfbb8-1457.md +8 -0
  97. package/.gm/memories/mem-d5e928c552cd87f9-214.md +10 -0
  98. package/.gm/memories/mem-d63bc3b8a0269844-406.md +10 -0
  99. package/.gm/memories/mem-d9c19257abc2fa5e-1061.md +8 -0
  100. package/.gm/memories/mem-d9f26358a7cd3bb8-866.md +8 -0
  101. package/.gm/memories/mem-da1c91acf63d81d3-863.md +8 -0
  102. package/.gm/memories/mem-dc1ac3b04d22317e-664.md +8 -0
  103. package/.gm/memories/mem-dceeae20bc56d407-711.md +8 -0
  104. package/.gm/memories/mem-dd98b21c0896dc04-1557.md +8 -0
  105. package/.gm/memories/mem-df74674eec7745ac-178.md +10 -0
  106. package/.gm/memories/mem-dfe3b466fd5d1199-802.md +8 -0
  107. package/.gm/memories/mem-e0f9286aed2d5a06-1116.md +8 -0
  108. package/.gm/memories/mem-e1950b064099b7be-1895.md +8 -0
  109. package/.gm/memories/mem-e1d5431ae2542872-961.md +8 -0
  110. package/.gm/memories/mem-e490a181de6469fc-487.md +8 -0
  111. package/.gm/memories/mem-e72c6efbb27326fb-392.md +8 -0
  112. package/.gm/memories/mem-e943b58115a02b4c-593.md +8 -0
  113. package/.gm/memories/mem-e9bc5f9ed6edbcf7-1580.md +8 -0
  114. package/.gm/memories/mem-eae1e383927854fb-2649.md +8 -0
  115. package/.gm/memories/mem-ebbe6c0694c526f0-1392.md +8 -0
  116. package/.gm/memories/mem-ebf85becc23410b0-1633.md +8 -0
  117. package/.gm/memories/mem-ee0596fa17c7102e-362.md +10 -0
  118. package/.gm/memories/mem-ee4450fbc311b583-1361.md +8 -0
  119. package/.gm/memories/mem-ee8858162518f144-1252.md +8 -0
  120. package/.gm/memories/mem-f3297e82df622649-1166.md +8 -0
  121. package/.gm/memories/mem-f3f317c083d979ee-1210.md +8 -0
  122. package/.gm/memories/mem-f714a86465fba158-2041.md +8 -0
  123. package/.gm/memories/mem-f786bc1626d92845-204.md +10 -0
  124. package/.gm/memories/mem-f9f0dc8895b21e18-1217.md +8 -0
  125. package/.gm/memories/mem-fb3b2a7395800041-525.md +8 -0
  126. package/.gm/memories/mem-fc5753f92ee7d654-966.md +8 -0
  127. package/.gm/memories/mem-fe3faa1652f07239-480.md +8 -0
  128. package/.gm/memories/mem-ff8669e527e5c427-2788.md +8 -0
  129. package/.gm/mutables.yml +53 -0
  130. package/.gm/prd.yml +3342 -1
  131. package/AGENTS.md +122 -122
  132. package/UX_OPTIMIZATION_SUMMARY.md +238 -0
  133. package/agentgui-after.png +0 -0
  134. package/agentgui-current.png +0 -0
  135. package/agentgui-final.png +0 -0
  136. package/agentgui-nobrand.png +0 -0
  137. package/agentgui-now.png +0 -0
  138. package/agentgui-v2.png +0 -0
  139. package/agentgui-v4.png +0 -0
  140. package/bash.exe.stackdump +28 -0
  141. package/database.js +110 -109
  142. package/design-reference.png +0 -0
  143. package/lib/acp-sdk-manager.js +9 -42
  144. package/lib/claude-runner-acp.js +155 -213
  145. package/lib/claude-runner-agents.js +157 -170
  146. package/lib/http-handler.js +931 -400
  147. package/lib/ws-handlers-util.js +466 -11
  148. package/package.json +1 -1
  149. package/site/app/js/app.js +4741 -4221
  150. package/site/app/js/backend.js +602 -621
  151. package/site/app/vendor/anentrypoint-design/247420.css +13673 -14799
  152. package/site/app/vendor/anentrypoint-design/247420.js +449 -735
  153. package/.gm/.embed-generation.code_chunks +0 -1
  154. package/.gm/.embed-generation.git_commit_vectors +0 -1
  155. package/.gm/.embed-generation.memories +0 -1
  156. package/.gm/.embed-generation.rssearch_vectors +0 -1
  157. package/lib/acp-http-protocol.js +0 -95
  158. package/lib/http-routes/mutations.js +0 -199
  159. package/lib/http-routes/reads.js +0 -212
  160. package/lib/http-routes/shared.js +0 -225
  161. package/lib/ws-handlers/agents.js +0 -137
  162. package/lib/ws-handlers/chat.js +0 -157
  163. package/lib/ws-handlers/git.js +0 -159
  164. package/lib/ws-handlers/misc.js +0 -36
  165. package/lib/ws-handlers/shared.js +0 -47
  166. package/lib/ws-handlers/terminal-state.js +0 -16
  167. package/site/app/js/chat-persistence.js +0 -123
  168. package/site/app/js/hash-routing.js +0 -62
  169. package/site/app/js/history.js +0 -435
  170. package/site/app/js/shortcuts.js +0 -128
@@ -1,435 +0,0 @@
1
- // History tab: session list, event viewer, search - extracted from app.js
2
- // (vertical slice per AGENTS.md SOLID/Clean-Architecture preferences). The
3
- // factory takes every app.js dependency explicitly (state, render, backend
4
- // module B, kit components, and helper functions) rather than closing over
5
- // app.js's module scope implicitly - the same pattern used successfully for
6
- // chat-persistence.js and shortcuts.js earlier in this split.
7
- export function createHistory(state, render, B, deps) {
8
- const {
9
- h, PageHeader, ShortcutList, Btn, Panel, EventList, FilterPills, SessionMeta,
10
- SHORTCUTS, UNTITLED_CONVERSATION,
11
- reconnectAlert, agentById, projectLabel, pathBasename, plural, fmtRelTime, truncate,
12
- eventMatchesFilter, sessionDuration, resumeInChat, downloadBlob, toolLabel, copyText,
13
- pushRecentCwd, announce, lsSet, errText, writeHash,
14
- getCopyToast, copySid,
15
- } = deps;
16
-
17
- function jumpToEvent(idx) {
18
- if (idx < 0) return;
19
- state.eventFilter = 'all';
20
- state._errorNavIdx = idx;
21
- const fromEnd = state.events.length - idx;
22
- if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
23
- render();
24
- const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
25
- const rowPos = idx - sliceStart;
26
- requestAnimationFrame(() => {
27
- const rows = document.querySelectorAll('.ds-event-list .row');
28
- const row = rows[rowPos];
29
- if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
30
- });
31
- }
32
- function jumpToFirstError() {
33
- const idx = state.events.findIndex(e => e.isError);
34
- jumpToEvent(idx);
35
- }
36
- // Persistent next/prev navigation between error events - jumpToFirstError
37
- // alone only reaches the FIRST error once; a session with multiple errors had
38
- // no way to step through them without manually scanning the event list.
39
- function jumpToNextError(dir) {
40
- const errIdxs = state.events.reduce((acc, e, i) => { if (e.isError) acc.push(i); return acc; }, []);
41
- if (!errIdxs.length) return;
42
- const cur = state._errorNavIdx;
43
- let pos = errIdxs.indexOf(cur);
44
- pos = pos < 0 ? (dir > 0 ? 0 : errIdxs.length - 1) : (pos + dir + errIdxs.length) % errIdxs.length;
45
- jumpToEvent(errIdxs[pos]);
46
- }
47
-
48
- function historyMain() {
49
- if (!state.selectedSid) {
50
- const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
51
- return [
52
- reconnectAlert(),
53
- PageHeader({
54
- compact: true,
55
- dense: true,
56
- title: 'History',
57
- lede: 'Pick a conversation to inspect its events as they happen.',
58
- }),
59
- h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
60
- h('p', { key: 'gt', class: 'history-empty-title' },
61
- count ? 'Select a conversation to view its events' : 'No conversations yet'),
62
- h('p', { key: 'gs', class: 'history-empty-sub' },
63
- count
64
- ? count + ' conversation' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
65
- : 'Start a chat or run a local coding agent - its conversation will appear here live.'),
66
- count ? h('div', { key: 'gh', class: 'history-empty-hints' },
67
- ShortcutList({ shortcuts: SHORTCUTS.slice(0, 4) })) : null),
68
- ].filter(Boolean);
69
- }
70
-
71
- const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
72
- // sess.model is the raw ccsniff-sourced field (41st-run fix); ccsniff only
73
- // reads Claude Code's own JSONL so the agent is always constant. The
74
- // History detail header previously never surfaced either, reading
75
- // identity-thin next to the same session's Running-panel/Live-dashboard
76
- // rows which both show an agent+model badge.
77
- const agentModelBit = sess?.model ? ((agentById('claude-code')?.name || 'Claude Code') + ' · ' + sess.model) : null;
78
- const lede = sess
79
- ? (projectLabel(sess.project) || pathBasename(sess.cwd) || 'unknown location') + (agentModelBit ? ' · ' + agentModelBit : '') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
80
- : UNTITLED_CONVERSATION;
81
-
82
- const head = PageHeader({
83
- compact: true,
84
- dense: true,
85
- title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || pathBasename(sess?.cwd) || state.selectedSid || UNTITLED_CONVERSATION, 40, 80),
86
- lede,
87
- });
88
-
89
- const hasErrors = state.events.some(e => e.isError);
90
- const actions = h('div', { key: 'acts', class: 'history-actions' }, [
91
- Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
92
- Btn({ key: 'copy', onClick: copySid, children: getCopyToast() || 'copy conversation id' }),
93
- Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
94
- onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
95
- children: 'export' }),
96
- hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
97
- // Persistent next/prev stepping between errors - jump-to-first alone only
98
- // ever reaches the FIRST one; a session with several errors had no way to
99
- // step through the rest without manually scrolling/scanning.
100
- hasErrors ? Btn({ key: 'errprev', title: 'previous error', 'aria-label': 'previous error', onClick: () => jumpToNextError(-1), children: 'prev error' }) : null,
101
- hasErrors ? Btn({ key: 'errnext', title: 'next error', 'aria-label': 'next error', onClick: () => jumpToNextError(1), children: 'next error' }) : null,
102
- ].filter(Boolean));
103
-
104
- if (state.events.length === 0) {
105
- // Distinguish "still loading" from "genuinely empty" so a 0-event session
106
- // doesn't spin forever. After 5s of an unresolved first fetch, swap to the
107
- // indexing copy (ccsniff's first JSONL walk can take a minute).
108
- const body = state.eventsLoaded
109
- ? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
110
- h('span', { key: 'noevtxt' }, 'no events in this conversation'),
111
- Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
112
- // Shape-matched skeleton rows (kit EventList loading state) instead of a
113
- // lone spinner collapsing the slowest pane in the product.
114
- : h('div', { key: 'loading' }, EventList({ items: [], loading: true,
115
- loadingText: state.eventsSlow ? 'Indexing your Claude history — the first load can take a minute…' : 'loading events…' }));
116
- return [reconnectAlert(), head, actions, Panel({ title: 'events', kind: 'wide', children: body })].filter(Boolean);
117
- }
118
-
119
- if (!state.expandedEvents) state.expandedEvents = new Set();
120
- // Event-type filter applies BEFORE the render-window slice so "errors" shows
121
- // every error in the session, not only errors among the most-recent 300.
122
- const ef = state.eventFilter || 'all';
123
- const filteredEvents = ef === 'all' ? state.events : state.events.filter(e => eventMatchesFilter(e, ef));
124
- const filterPills = FilterPills({
125
- options: [
126
- { id: 'all', label: 'all' },
127
- { id: 'text', label: 'text' },
128
- { id: 'tool', label: 'tools' },
129
- { id: 'errors', label: 'errors' },
130
- { id: 'thinking', label: 'thinking' },
131
- ],
132
- selected: ef,
133
- onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
134
- label: 'Filter events by type',
135
- });
136
- // Single pass over state.events for all three counters (replaces three separate .filter() calls).
137
- const evCounters = state.events.reduce((c, e) => {
138
- if (e.role === 'user') c.turns++;
139
- if (e.type === 'tool_use') c.tools++;
140
- if (e.isError) c.errors++;
141
- return c;
142
- }, { turns: 0, tools: 0, errors: 0 });
143
- const meta = SessionMeta({
144
- items: [
145
- sess && sess.cwd ? { label: 'directory', value: sess.cwd, title: sess.cwd,
146
- actionLabel: 'use as chat cwd',
147
- onAction: () => { state.chatCwd = sess.cwd; lsSet('agentgui.cwd', sess.cwd); pushRecentCwd(sess.cwd); announce('working directory set to ' + sess.cwd); render(); } } : null,
148
- (() => { const dur = sessionDuration(); return dur ? { label: 'duration', value: dur } : null; })(),
149
- { label: 'session id', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'session id copied') },
150
- // Spelled counter vocabulary in the detail strip (events/turns/tools/
151
- // errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
152
- { label: 'events', value: String(state.events.length) },
153
- { label: 'turns', value: String(sess?.userTurns ?? evCounters.turns) },
154
- { label: 'tools', value: String(evCounters.tools) },
155
- { label: 'errors', value: String(evCounters.errors) },
156
- sess && sess.cost != null ? { label: 'cost', value: '$' + Number(sess.cost).toFixed(4) } : null,
157
- ].filter(Boolean),
158
- });
159
- if (filteredEvents.length === 0) {
160
- return [reconnectAlert(), head, actions, Panel({ title: 'events', kind: 'wide', children: [
161
- h('div', { key: 'evmeta' }, meta),
162
- h('div', { key: 'evfp' }, filterPills),
163
- h('div', { key: 'nofilt', class: 'lede empty-state', role: 'status' },
164
- h('span', { key: 'noftxt' }, 'no events match this filter'),
165
- Btn({ key: 'clearf', onClick: () => { state.eventFilter = 'all'; render(); }, children: 'clear filter' })),
166
- ] })].filter(Boolean);
167
- }
168
- const total = filteredEvents.length;
169
- const limit = state.eventsLimit;
170
- const shown = filteredEvents.slice(-limit);
171
- const hiddenCount = total - shown.length;
172
- // Keys of the currently-shown rows, so expand-all toggles only what's rendered.
173
- const shownKeys = shown.map((e, i) => e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i)));
174
- const allExpanded = shownKeys.length > 0 && shownKeys.every(k => state.expandedEvents.has(k));
175
- const eventControls = h('div', { key: 'evctrl', class: 'history-actions', role: 'group', 'aria-label': 'event controls' },
176
- Btn({ key: 'expall', onClick: () => {
177
- if (allExpanded) { shownKeys.forEach(k => state.expandedEvents.delete(k)); }
178
- else { shownKeys.forEach(k => state.expandedEvents.add(k)); }
179
- render();
180
- }, children: allExpanded ? 'collapse shown' : 'expand shown' }),
181
- hiddenCount > 0
182
- ? Btn({ key: 'older', onClick: () => { const added = Math.min(300, hiddenCount); state.eventsLimit += 300; announce('loaded ' + added + ' more events'); render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
183
- : null,
184
- // A per-click 300-event step is fine for casual scanning, but a huge
185
- // session (thousands hidden) makes that a lot of repeat clicks - a
186
- // secondary "load all" jumps straight to the full transcript.
187
- hiddenCount > 1000
188
- ? Btn({ key: 'loadall', onClick: () => { state.eventsLimit = total; announce('loaded all ' + total + ' events'); render(); }, children: 'load all (' + hiddenCount + ' hidden)' })
189
- : null,
190
- );
191
- return [
192
- reconnectAlert(),
193
- head,
194
- actions,
195
- Panel({
196
- title: plural(total, 'event') + (ef !== 'all' ? ' (' + ef + ' filter)' : '') + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
197
- kind: 'wide',
198
- children: [h('div', { key: 'evmeta' }, meta), h('div', { key: 'evfp' }, filterPills), eventControls, EventList({
199
- items: shown.map((e, i) => {
200
- // Stable key: prefer the server-assigned event index, else the
201
- // event timestamp + position, never a bare array index (which
202
- // collides between loaded and live-pushed events).
203
- // Stable key: server event index when present, else ts + the event's
204
- // ABSOLUTE position in state.events (not the sliced-view index, which
205
- // shifts when live events append and would collide loaded vs live rows).
206
- const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i));
207
- const role = e.role || '?';
208
- const type = e.type || '?';
209
- const tool = e.tool ? ' · tool: ' + e.tool : '';
210
- const errMark = e.isError ? ' · error' : '';
211
- const raw = e.text || '';
212
- const text = raw.replace(/\s+/g, ' ').trim() || (e.type === 'tool_use' && e.toolInput ? toolLabel(e.toolInput) : '');
213
- const toolNamePrefix = (e.type === 'tool_use' && e.tool) ? e.tool + ': ' : '';
214
- const typePrefix = e.type === 'tool_result' ? '(result) ' : (e.type === 'tool_use' ? ('(tool call) ' + toolNamePrefix) : '');
215
- const expanded = state.expandedEvents.has(key);
216
- // Only build the expanded body (JSON.stringify tool input) when the row is
217
- // expanded - doing it for all ~300 rows every frame wastes work mid-stream.
218
- const full = expanded ? (e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw) : '';
219
- // Rail tone matches the session/agents rail semantics so an event's
220
- // kind is visible at a glance, consistent across the GUI:
221
- // flame = error, purple = tool activity, green = normal turn.
222
- const rail = e.isError ? 'flame' : (e.type === 'tool_use' || e.type === 'tool_result' ? 'purple' : 'green');
223
- // When the session was opened from a search hit, window the collapsed
224
- // title AROUND the first query match (a match at char 5000 would
225
- // otherwise be invisible behind the 0-220 slice).
226
- let collapsedTitle = typePrefix + text.slice(0, 220);
227
- const q = state.sessionSearchQ;
228
- if (q && !expanded) {
229
- const qi = text.toLowerCase().indexOf(q.toLowerCase());
230
- if (qi > 60) collapsedTitle = '…' + text.slice(qi - 60, qi - 60 + 220);
231
- }
232
- return {
233
- key,
234
- code: String(total - shown.length + i + 1).padStart(4, '0'),
235
- rail,
236
- expanded, // disclosure state -> kit Row sets aria-expanded
237
- highlight: q || undefined,
238
- // Copy is available whether or not the row is expanded - a user
239
- // scanning collapsed rows for a specific payload shouldn't have
240
- // to expand every row first just to copy one.
241
- actions: [{
242
- label: 'copy', title: 'copy event',
243
- onClick: () => copyText(full || raw || ('(' + type + ')'), 'event copied'),
244
- }],
245
- title: expanded ? (typePrefix + (text || '(' + type + ')')) : (collapsedTitle || typePrefix + '(' + type + ')'),
246
- detail: expanded && e.toolInput ? JSON.stringify(e.toolInput, null, 2) : undefined,
247
- // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
248
- // Every row is click-to-expand, so always show the affordance word
249
- // (not only when text overflows 220 chars).
250
- // Relative time matches every other surface; the absolute stamp
251
- // appears when the row is expanded (forensic precision preserved).
252
- sub: (e.ts ? (expanded ? new Date(e.ts).toLocaleString() : fmtRelTime(e.ts)) : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
253
- onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
254
- };
255
- }),
256
- })],
257
- }),
258
- ].filter(Boolean);
259
- }
260
-
261
- async function refreshHistory() {
262
- // Guard against concurrent calls: a slow first fetch followed by a polling
263
- // trigger would otherwise stack two in-flight requests; the second would
264
- // overwrite state mid-render with a stale response.
265
- if (state._historyFetching) return;
266
- state._historyFetching = true;
267
- // Warmup copy: the FIRST sessions fetch can sit behind ccsniff's 30-90s
268
- // JSONL walk; after 5s swap the loading copy to indexing language.
269
- const firstLoad = !state._historyLoadedOnce;
270
- const slowTimer = firstLoad
271
- ? setTimeout(() => { if (!state._historyLoadedOnce) { state.historySlow = true; render(); } }, 5000)
272
- : null;
273
- try {
274
- state.sessions = await B.listSessions(state.backend);
275
- state._historyLoadedOnce = true;
276
- state._historyLoadedAt = Date.now();
277
- state.historySlow = false;
278
- // Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
279
- // linear scan per event during a burst load.
280
- state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
281
- state._sessionGroupsCache = null;
282
- // Bound the live tally: drop entries with no activity in 24h and cap the
283
- // Map at ~200 most-recent sids (a long-lived tab otherwise accumulates
284
- // every sid ever seen, and dead entries could resurrect wrong externals).
285
- if (state.live.tally) {
286
- const cutoff = Date.now() - 24 * 3600 * 1000;
287
- for (const [sid, t] of [...state.live.tally]) {
288
- if (!t.last || t.last < cutoff) state.live.tally.delete(sid);
289
- }
290
- if (state.live.tally.size > 200) {
291
- state.live.tally = new Map([...state.live.tally.entries()]
292
- .sort((a, b) => (b[1].last || 0) - (a[1].last || 0))
293
- .slice(0, 200));
294
- }
295
- }
296
- // If the selected session vanished from the list (deleted/aged out server-side),
297
- // drop the selection so the main pane doesn't sit on stale events that can no
298
- // longer be reloaded; fall back to the no-selection empty state.
299
- if (state.selectedSid && !state.sessionsBySid.has(state.selectedSid)) {
300
- state.selectedSid = null;
301
- state.events = [];
302
- state.eventsLoaded = false;
303
- writeHash();
304
- }
305
- state.historyError = null;
306
- } catch (e) {
307
- // Only a genuine fetch/list failure is a history error. A render exception
308
- // must not masquerade as one (it would poison the sessions panel with a
309
- // render-stack string and never clear), so render() lives outside this try.
310
- state.historyError = errText(e);
311
- console.warn('history fetch failed:', e.message);
312
- } finally {
313
- state._historyFetching = false;
314
- if (slowTimer) clearTimeout(slowTimer);
315
- render();
316
- }
317
- }
318
-
319
- async function runSearch() {
320
- const q = state.searchQ.trim();
321
- if (!q) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
322
- if (q.length < 2) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
323
- // The project-filter pills are hidden while searching; clear the filter so it
324
- // doesn't silently re-apply (and surprise the user) when they later clear the
325
- // search and the now-visible session list is unexpectedly narrowed.
326
- state.projectFilter = '';
327
- // The debounced search keeps the URL's q=/project= in sync via replaceState
328
- // so a reload (or share) restores the search, without flooding history.
329
- writeHash();
330
- state.searchBusy = true;
331
- render();
332
- try {
333
- state.searchHits = await B.searchHistory(state.backend, q, 60);
334
- // Announce the settled count for AT - the sessions-column count is only
335
- // rendered visually (the history actions row is the only aria-live region).
336
- const n = (state.searchHits.results || []).length;
337
- announce((n || 'no') + ' matches for ' + q);
338
- } catch (e) {
339
- state.searchHits = { query: q, results: [], error: errText(e) };
340
- } finally {
341
- state.searchBusy = false;
342
- render();
343
- }
344
- }
345
-
346
- async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromHash = false } = {}) {
347
- // Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
348
- if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
349
- if (sid === state.selectedSid && state.eventsLoaded && !fromHash && focusEventI == null && focusEventTs == null) {
350
- render();
351
- requestAnimationFrame(() => { document.querySelector('.app-side .row.active')?.scrollIntoView({ block: 'nearest' }); });
352
- return;
353
- }
354
- state.selectedSid = sid;
355
- // A plain (non-search-hit) session open must not carry a stale event
356
- // anchor forward into the URL - only reset it when this call ISN'T itself
357
- // the one supplying a fresh focusEventTs.
358
- if (focusEventTs == null) state._focusEventTs = null;
359
- state.events = [];
360
- state.events._seen = new Set(); // O(1) dedupe by event index
361
- state.eventsLoaded = false;
362
- state.eventsSlow = false;
363
- state.eventsLimit = 300; // reset the render window per session
364
- state.eventFilter = 'all'; // don't carry the type filter across sessions
365
- state.expandedEvents = new Set(); // don't carry expansion to the new session
366
- // Remember the query this session was opened FROM (search hit) so the event
367
- // rows can highlight + window around the match; a plain selection clears it.
368
- state.sessionSearchQ = (focusEventI != null || focusEventTs != null) && state.searchQ.trim().length >= 2
369
- ? state.searchQ.trim() : null;
370
- // The live "live · N" crumb counter reads as the selected session's activity,
371
- // so reset it per selection rather than letting it accrue across all sessions.
372
- state.live.eventCount = 0;
373
- writeHash({ push: !fromHash });
374
- // Warmup copy: a first events fetch can sit behind ccsniff's JSONL walk.
375
- const slowTimer = setTimeout(() => { if (!state.eventsLoaded && state.selectedSid === sid) { state.eventsSlow = true; render(); } }, 5000);
376
- // Close the mobile sidebar drawer on selection. The DS only auto-closes when
377
- // the clicked element is an <a>; agentgui's session rows are onClick divs, so
378
- // we close it explicitly here.
379
- // Close the WorkspaceShell mobile sessions drawer on session selection.
380
- if (state.wsSessions) { state.wsSessions = false; }
381
- document.querySelector('[data-ws-sessions-open]')?.removeAttribute('data-ws-sessions-open');
382
- render();
383
- // Bring the now-active sidebar row into view (deep-link / back-forward may
384
- // select a row that's scrolled out of the session list).
385
- requestAnimationFrame(() => {
386
- document.querySelector('.app-side .row.active')?.scrollIntoView({ block: 'nearest' });
387
- });
388
- try {
389
- state.events = await B.getSessionEvents(state.backend, sid);
390
- // ccsniff's events route has no ?limit= (checked: router.js returns the
391
- // whole session) - cap in-memory state at the most-recent 5000 so a
392
- // monster session can't pin the tab; the render window stays 300+load-older.
393
- if (state.events.length > 5000) state.events = state.events.slice(-5000);
394
- // Stamp stable _idx so EventList keys are stable regardless of slice/cap.
395
- state.events.forEach((e, i) => { if (e._idx == null) e._idx = i; });
396
- clearTimeout(slowTimer);
397
- state.eventsSlow = false;
398
- state.eventsLoaded = true;
399
- // If we arrived from a search hit, make sure the matched event is within the
400
- // render window, then scroll to + flash it so the match isn't lost.
401
- if (focusEventI != null || focusEventTs != null) {
402
- const idx = state.events.findIndex(e => (focusEventI != null && e.i === focusEventI) || (focusEventTs != null && e.ts === focusEventTs));
403
- if (idx >= 0) {
404
- const fromEnd = state.events.length - idx;
405
- if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
406
- render();
407
- // The rendered EventList shows the last eventsLimit events in order, so
408
- // the matched event's row is at (idx - sliceStart) among .ds-event-list rows.
409
- const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
410
- const rowPos = idx - sliceStart;
411
- requestAnimationFrame(() => {
412
- const rows = document.querySelectorAll('.ds-event-list .row');
413
- const row = rows[rowPos];
414
- if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
415
- });
416
- return;
417
- }
418
- }
419
- render();
420
- } catch (e) {
421
- state.events = [{
422
- ts: Date.now(),
423
- role: 'error',
424
- type: 'fetch',
425
- text: 'Failed to load session: ' + errText(e) + ' - retry via the rail',
426
- }];
427
- clearTimeout(slowTimer);
428
- state.eventsSlow = false;
429
- state.eventsLoaded = true;
430
- render();
431
- }
432
- }
433
-
434
- return { jumpToEvent, jumpToFirstError, jumpToNextError, historyMain, refreshHistory, runSearch, loadSession };
435
- }
@@ -1,128 +0,0 @@
1
- // Global keyboard shortcut handling, extracted from app.js (vertical slice
2
- // per AGENTS.md SOLID/Clean-Architecture preferences). This is the highest
3
- // cross-cutting-coupling code in app.js (AGENTS.md flags it as historically
4
- // crash-prone under webjsx keying mistakes) - the factory takes every
5
- // dependency explicitly rather than closing over app.js's module scope, so
6
- // the coupling is visible at the call site instead of implicit.
7
- export function installShortcuts(state, render, helpers) {
8
- const {
9
- navTo, announce, closeFileDialog, filesMarked, clearFileSelection,
10
- cancelChat, newChat, previewNeighbours, openPreview,
11
- clearNewChatArmTimer, clearStopAllArmTimer, clearStopSelArmTimer,
12
- } = helpers;
13
-
14
- let gPending = false;
15
-
16
- function focusComposer() {
17
- const el = document.querySelector('#agentgui-main textarea, #agentgui-main [contenteditable="true"], #agentgui-main input[type="text"]');
18
- el?.focus();
19
- }
20
- function focusSearch() {
21
- const el = document.querySelector('#app input[type="search"]');
22
- el?.focus();
23
- }
24
- // Focus the active surface's filter input (Files grid filter / Live dashboard
25
- // filter) - both are search-type inputs inside the main region.
26
- function focusFilter() {
27
- const el = document.querySelector('#agentgui-main input[type="search"]')
28
- || document.querySelector('#agentgui-main .ds-file-filter-input')
29
- || document.querySelector('#agentgui-main input[type="text"]');
30
- el?.focus();
31
- return !!el;
32
- }
33
-
34
- window.addEventListener('keydown', (e) => {
35
- const t = e.target;
36
- const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
37
- // One explicit chord BEFORE the modifier early-return: Mod+Shift+L focuses
38
- // the composer from anywhere, even while typing in another field.
39
- if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'L' || e.key === 'l')) {
40
- e.preventDefault();
41
- navTo('chat');
42
- requestAnimationFrame(() => focusComposer());
43
- announce('composer focused');
44
- return;
45
- }
46
- if (e.metaKey || e.ctrlKey || e.altKey) return;
47
- if (typing) {
48
- // The cwd editor's own text input is the one exception: Escape there must
49
- // close the editor in a single press (matching every other Escape-
50
- // closeable surface in the app), not blur-then-require-a-second-press.
51
- if (e.key === 'Escape' && t.classList && t.classList.contains('agentchat-cwd-input')) {
52
- t.blur();
53
- // Fall through to the ladder below instead of returning early.
54
- } else {
55
- if (e.key === 'Escape') t.blur();
56
- return;
57
- }
58
- }
59
- if (e.key === 'Escape') {
60
- // Priority ladder for transient state (modals/drawers are kit-handled):
61
- // shortcuts overlay > armed confirms > stop a streaming generation.
62
- if (state.showShortcuts) { state.showShortcuts = false; render(); announce('shortcuts closed'); return; }
63
- // File-mutation dialog: close on Escape wherever focus sits (the kit's
64
- // backdrop listener covers in-dialog focus; this covers everything else).
65
- if (state.files.dialog) { if (!state.files.dialog.busy) closeFileDialog(); return; }
66
- if (state.chat.confirmingEdit) { state.chat.confirmingEdit = null; render(); announce('edit cancelled'); return; }
67
- // cwd editor: the browse popover is a nested layer within it - Escape
68
- // closes the popover first (one level of the nesting) before falling
69
- // through to closing the whole editor on a second press, matching the
70
- // dialog-then-page Escape convention used elsewhere in the app.
71
- if (state.cwdEditing) {
72
- if (state.cwdBrowse) { state.cwdBrowse = null; render(); announce('folder browser closed'); return; }
73
- state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false;
74
- render(); announce('cwd edit cancelled');
75
- requestAnimationFrame(() => { const btn = document.querySelector('.agentchat-cwd-btn'); if (btn) btn.focus(); });
76
- return;
77
- }
78
- if (state.confirmingClearData) { state.confirmingClearData = false; render(); announce('clear cancelled'); return; }
79
- if (state.confirmingNewChat) { clearNewChatArmTimer(); state.confirmingNewChat = false; render(); announce('new chat cancelled'); return; }
80
- if (state.live.confirmingStopAll || state.live.confirmingStopSelected) {
81
- state.live.confirmingStopAll = false; state.live.confirmingStopSelected = false;
82
- clearStopAllArmTimer(); clearStopSelArmTimer();
83
- render(); announce('stop cancelled'); return;
84
- }
85
- // A live file multi-select is transient state too: Escape drops it before
86
- // falling through to stop-generation.
87
- if (state.tab === 'files' && filesMarked().size) { clearFileSelection(); return; }
88
- if (state.chat.busy && state.tab === 'chat') { cancelChat(); announce('generation stopped'); return; }
89
- return;
90
- }
91
- if (gPending) {
92
- gPending = false;
93
- if (e.key === 'c') { navTo('chat'); return; }
94
- if (e.key === 'h') { navTo('history'); return; }
95
- if (e.key === 'f') { navTo('files'); return; }
96
- if (e.key === 'l') { navTo('live'); return; }
97
- if (e.key === 's') { navTo('settings'); return; }
98
- return;
99
- }
100
- if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
101
- if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
102
- if (e.key === '/') {
103
- // / targets the active surface's find affordance: search on history,
104
- // composer on chat, the filter inputs on files/live. Settings has no
105
- // field - the only documented no-op.
106
- if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
107
- else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
108
- else if (state.tab === 'files' || state.tab === 'live') { e.preventDefault(); if (focusFilter()) announce('filter focused'); }
109
- return;
110
- }
111
- if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
112
- // Left/Right: step through file previews (documented in SHORTCUTS).
113
- if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && state.tab === 'files' && state.files.preview) {
114
- const { prev, next } = previewNeighbours();
115
- const target = e.key === 'ArrowLeft' ? prev : next;
116
- if (target) { e.preventDefault(); openPreview(target); }
117
- return;
118
- }
119
- });
120
-
121
- // A file dropped anywhere outside a DropZone must never navigate the browser
122
- // away (destroying the live session view). DropZones handle their own events.
123
- window.addEventListener('dragover', (e) => {
124
- if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
125
- });
126
-
127
- return { focusComposer, focusSearch, focusFilter };
128
- }