opencode-extended-sidebar 0.2.90 → 0.2.91

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 (108) hide show
  1. package/.oesignore +17 -17
  2. package/CHANGELOG.md +96 -95
  3. package/LICENSE +21 -21
  4. package/README.md +150 -235
  5. package/oes.json +13 -11
  6. package/package.json +100 -100
  7. package/scripts/install-git-hooks.mjs +29 -29
  8. package/src/pware.oc.core/constants/index.ts +18 -18
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -29
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +15 -13
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -241
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -55
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -65
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -58
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -63
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -34
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -74
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -69
  19. package/src/pware.oc.core/git/index.ts +7 -7
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -237
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -87
  22. package/src/pware.oc.core/index.ts +19 -18
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -42
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -48
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -97
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -280
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -79
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -135
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -234
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +99 -89
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -240
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -74
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -726
  34. package/src/pware.oc.core/pware.oc.core.runtimeConfig.ts +41 -0
  35. package/src/pware.oc.core/pware.oc.core.sqlite.ts +164 -175
  36. package/src/pware.oc.core/pware.oc.core.sqliteGateway.ts +89 -0
  37. package/src/pware.oc.core/pware.oc.core.status.ts +214 -214
  38. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -63
  39. package/src/pware.oc.core/pware.oc.core.width.ts +215 -215
  40. package/src/pware.oc.omo/constants/index.ts +15 -15
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -18
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -49
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -29
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -11
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -60
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -49
  47. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -25
  48. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -38
  49. package/src/pware.oc.omo/index.ts +7 -7
  50. package/src/pware.oc.omo/resolver/index.ts +19 -19
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -179
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -116
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -34
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -503
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -40
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -269
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -18
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -18
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -155
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +363 -361
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -18
  62. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -11
  63. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -14
  64. package/src/pware.oc.opencode/constants/index.ts +10 -10
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -15
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -18
  67. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -45
  68. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -58
  69. package/src/pware.oc.opencode/index.ts +8 -8
  70. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -182
  71. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -399
  72. package/src/pware.oc.opencode/resolver/index.ts +223 -170
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +150 -162
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +149 -185
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +248 -211
  76. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +22 -26
  77. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +224 -236
  78. package/src/pware.oc.perf/index.ts +14 -14
  79. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -15
  80. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -284
  81. package/src/pware.oc.perf/pware.oc.perf.reader.ts +962 -927
  82. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -88
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -118
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -79
  85. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -71
  86. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -214
  87. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -179
  88. package/src/pware.oc.perf/pware.oc.perf.view.tsx +463 -462
  89. package/src/pware.oc.runtime/index.ts +13 -13
  90. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -142
  91. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +87 -79
  92. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +282 -253
  93. package/src/pware.oc.runtime/pware.oc.runtime.omoRead.ts +37 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +83 -95
  95. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +130 -129
  96. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -105
  97. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +44 -43
  98. package/src/pware.oc.runtime/resolver/index.ts +268 -224
  99. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -146
  100. package/src/pware.oc.ui/index.ts +12 -12
  101. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +381 -339
  102. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -144
  103. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -111
  104. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -82
  105. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -843
  106. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +552 -526
  107. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1718 -1629
  108. package/src/pware.oc.ui.tsx +98 -98
@@ -1,1629 +1,1718 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import { createEffect, createMemo, createSignal, For, on, Show, onCleanup, type JSX } from "solid-js"
3
- import { useTerminalDimensions } from "@opentui/solid"
4
- import type { BoxRenderable } from "@opentui/core"
5
- import type { TuiPluginApi, TuiTheme } from "@opencode-ai/plugin/tui"
6
- import {
7
- DraftFile,
8
- emptyOmo,
9
- listApprovals,
10
- sessionForPlanFile,
11
- } from "../pware.oc.omo/resolver/index.js"
12
- import {
13
- delegatesForSession,
14
- groupDelegates,
15
- type RuntimeSnapshot,
16
- } from "../pware.oc.runtime/resolver/index.js"
17
- import {
18
- MY_WORK_ORDER,
19
- approvalContinueHint,
20
- dropDismissed,
21
- groupMyWork,
22
- myWorkLabel,
23
- toApprovalItems,
24
- toDraftDocItems,
25
- toPlanItems,
26
- toQuestionItems,
27
- toSessionItems,
28
- type MyWorkItem,
29
- type MyWorkKind,
30
- } from "../pware.oc.runtime/pware.oc.runtime.mywork.js"
31
- import {
32
- emptyDb,
33
- emptyProjectFeed,
34
- isRealSession,
35
- mergeTools,
36
- readProjectFeed,
37
- type ProjectFeed,
38
- } from "../pware.oc.opencode/resolver/index.js"
39
- import { type DocView } from "../pware.oc.omo/resolver/pware.oc.omo.resolver.doc.js"
40
- import type { DelegateView } from "../pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.js"
41
- import { enrichApprovalSessionStates } from "../pware.oc.runtime/pware.oc.runtime.mywork-enrich.js"
42
- import {
43
- ROW_LINE_FALLBACK,
44
- ROW_LINE_RESERVE,
45
- ROW_MIN,
46
- ROW_RANK,
47
- clampScrollOffset,
48
- RT_ACTION_COL_WIDTH,
49
- packSections,
50
- panelRows,
51
- rowsForPlan,
52
- scrollByStep,
53
- } from "../pware.oc.core/pware.oc.core.layout.js"
54
- import { openReadonlyDb } from "../pware.oc.core/pware.oc.core.sqlite.js"
55
- import {
56
- MARK_QUEUED,
57
- PULSE_LIVE,
58
- PULSE_STALE,
59
- } from "../pware.oc.core/constants/pware.oc.core.constants.pulse.js"
60
- import {
61
- STATUS_ARCHIVED,
62
- TOOL_STATUS_RUNNING,
63
- } from "../pware.oc.core/constants/pware.oc.core.constants.status.js"
64
- import {
65
- SESSION_STATUS_ARCHIVED,
66
- } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.js"
67
- import {
68
- ROW_KIND_AGENT,
69
- ROW_KIND_DELEGATE,
70
- ROW_KIND_FILE,
71
- ROW_KIND_GROUP,
72
- ROW_KIND_TOOL,
73
- } from "../pware.oc.core/constants/pware.oc.core.constants.rowKind.js"
74
- import {
75
- QUESTION_KIND_ERROR,
76
- QUESTION_KIND_INTERRUPTED,
77
- QUESTION_KIND_QUESTION,
78
- } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.js"
79
- import {
80
- MY_WORK_GROUP_DISMISSED,
81
- MY_WORK_GROUP_DRAFT_DOCS,
82
- MY_WORK_GROUP_DRAFTING,
83
- MY_WORK_GROUP_FINISHED,
84
- MY_WORK_GROUP_PINNED,
85
- MY_WORK_GROUP_PLANS,
86
- MY_WORK_GROUP_READY_REVIEW,
87
- MY_WORK_GROUP_READY_START,
88
- MY_WORK_GROUP_SESSIONS,
89
- } from "../pware.oc.core/constants/pware.oc.core.constants.myWork.js"
90
- import { DOC_KIND_DRAFT, DOC_KIND_PLAN } from "../pware.oc.omo/constants/pware.oc.omo.constants.docKind.js"
91
- import {
92
- GROUP_GLYPH,
93
- TAB_NEUTRAL_GLYPH,
94
- tabAttentionGlyph,
95
- engageDone,
96
- engageFill,
97
- fileLetterGlyph,
98
- markTone,
99
- myWorkGlyph,
100
- reviewStateSuffix,
101
- spinnerFrame,
102
- type GlyphSpec,
103
- type TabAttentionItem,
104
- } from "./pware.oc.ui.glyphs.js"
105
- import { dismissQuestion, kvRead, kvWrite, kvReadOne, kvWriteOne, readDismissedQuestions, ClickText, ContextActions, type ThemeColors } from "./pware.oc.ui.chrome.js"
106
- import {
107
- AgentLine,
108
- FoldSection,
109
- GroupSection,
110
- RowList,
111
- TabColumn,
112
- OesStatusRow,
113
- agentDisplayName,
114
- useFold,
115
- useReveal,
116
- type RevealState,
117
- type RowData,
118
- } from "./pware.oc.ui.sections.js"
119
- import { clipWidth } from "../pware.oc.core/pware.oc.core.width.js"
120
- import { emptyPerf, readPerfSnapshot } from "../pware.oc.perf/pware.oc.perf.reader.js"
121
- import { PerfPanel } from "../pware.oc.perf/pware.oc.perf.view.js"
122
- import { shareBar } from "../pware.oc.perf/pware.oc.perf.charts.js"
123
- import { StatRealtimeTimeline } from "../pware.oc.perf/pware.oc.perf.realtimeTimeline.js"
124
- import { EventDriverSampler } from "../pware.oc.perf/pware.oc.perf.realtimeSampler.js"
125
- import { readCpuRam } from "../pware.oc.perf/pware.oc.perf.realtimeCpuRam.js"
126
- import { formatSelfLine, readRendererFps, readSelfStats, resetSelfStats, selfDiagActive, selfTime, setSelfFps } from "../pware.oc.perf/pware.oc.perf.self.js"
127
- import {
128
- decorateFiles,
129
- fileFilter,
130
- filesFromEvent,
131
- mergeFiles,
132
- sumDiff,
133
- type FileView,
134
- } from "../pware.oc.opencode/pware.oc.opencode.files.js"
135
- import { onGitMarksChange } from "../pware.oc.core/git/pware.oc.core.git.js"
136
- import { getOes } from "../pware.oc.core/pware.oc.core.oes.js"
137
- import {
138
- TAB_STATUS_SESSION_NOT_IN_DB,
139
- tabStatus,
140
- } from "../pware.oc.core/pware.oc.core.status.js"
141
- import { createEventBus } from "../pware.oc.core/pware.oc.core.bus.js"
142
- import { startRuntimeSource } from "../pware.oc.runtime/pware.oc.runtime.source.js"
143
- import { openApprovalDialog, openDocDetail, openFileDetail, openFileListDialog, openQuestionDialog, openRealtimeCharts, openToolDetail } from "./pware.oc.ui.menudialogs.js"
144
- import { startHostEventBridge } from "./pware.oc.ui.live.js"
145
- import {
146
- approvePlan,
147
- openNewSessionPrompt,
148
- openSessionSwitcher,
149
- runStartWork,
150
- selectSession,
151
- } from "./pware.oc.ui.host.js"
152
- import { dbg, debugActive, debugActiveDir, profile, profileActive, profileActiveDir, profileAsync, readScreenLines, subscribeScreenLines, writeProfileSummary } from "../pware.oc.core/pware.oc.core.debug.js"
153
- import { getOpenCodeDbPath, samePath } from "../pware.oc.core/pware.oc.core.paths.js"
154
- import {
155
- FPS_READ_EVERY_TICKS,
156
- GLYPH_TICK_MS,
157
- MYWORK_BADGE_COOLDOWN_MS,
158
- MYWORK_BADGE_MS,
159
- NOW_MS,
160
- SWITCH_TIMEOUT_MS,
161
- TICK_MS,
162
- TOKEN_RATE_WINDOW_MS,
163
- } from "../pware.oc.core/pware.oc.core.timing.js"
164
- import {
165
- activeFlow,
166
- applyFlow,
167
- composeMark,
168
- formatAge,
169
- formatDuration,
170
- hottestMark,
171
- phaseAgeMs,
172
- pulseAgeMs,
173
- pushTokenTick,
174
- tokenRate,
175
- tokenRateBars,
176
- toolFlow,
177
- toolMark,
178
- type AgentMark,
179
- type FlowDir,
180
- type FlowEntry,
181
- type TokenTick,
182
- type ToolHit,
183
- } from "../pware.oc.core/pware.oc.core.pulse.js"
184
- import {
185
- EV_OES_SESSION_SELECT,
186
- EV_OES_SNAPSHOT,
187
- } from "../pware.oc.core/constants/pware.oc.core.constants.eventName.js"
188
- import {
189
- EV_OC_FILES_TOUCHED,
190
- EV_OC_FLOW,
191
- EV_OC_SESSION_ACTIVITY,
192
- EV_OC_TOKENS_DELTA,
193
- EV_OC_TOOL_HIT,
194
- } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.js"
195
-
196
- export type SidebarProps = {
197
- sessionId: string
198
- api: TuiPluginApi
199
- theme: TuiTheme
200
- }
201
-
202
- const KV_FOLD_AGENTS = "oes.fold.agents"
203
- const KV_FOLD_DELEGATES = "oes.fold.delegates"
204
- const KV_FOLD_TOOLS = "oes.fold.tools"
205
- const KV_FOLD_FILES = "oes.fold.files"
206
- const KV_FOLD_DRAFTS = "oes.fold.drafts"
207
- const KV_TAB = "oes.tab"
208
-
209
- const OES_TABS = ["mywork", "current", "sessions", "perf"] as const
210
- type OesTab = (typeof OES_TABS)[number]
211
-
212
- const TAB_LABELS: Record<string, string> = {
213
- mywork: "My work",
214
- sessions: "Project",
215
- current: "Session",
216
- perf: "Stats",
217
- }
218
-
219
- /** Event rows the bottom debug console shows at once (the ring keeps 200). */
220
- const SCREEN_CONSOLE_VISIBLE = 5
221
-
222
- /**
223
- * The cold-start "engage" bar (and its success toast) plays once per plugin
224
- * boot. The module latch survives sidebar remounts across route changes so a
225
- * session switch never replays the animation.
226
- */
227
- let engageSeen = false
228
-
229
- function emptyRuntime(): RuntimeSnapshot {
230
- const dbPath = getOpenCodeDbPath()
231
- return {
232
- generatedAt: 0,
233
- fingerprint: "",
234
- scanStamp: "0",
235
- db: emptyDb(dbPath),
236
- omo: emptyOmo(),
237
- omoConfig: { present: false, path: null, teamMode: null, agents: [] },
238
- delegates: [],
239
- openQuestions: [],
240
- }
241
- }
242
-
243
- export function SidebarPanel(props: SidebarProps): JSX.Element {
244
- const [snap, setSnap] = createSignal<RuntimeSnapshot>(emptyRuntime())
245
- const [now, setNow] = createSignal(Date.now())
246
- const [frame, setFrame] = createSignal(0)
247
- const [glyphFrame, setGlyphFrame] = createSignal(0)
248
- const [seen, setSeen] = createSignal<Record<string, number>>({})
249
- const [busy, setBusy] = createSignal<Record<string, boolean>>({})
250
- const [flow, setFlow] = createSignal<Record<string, FlowEntry>>({})
251
- const [tab, setTab] = createSignal<OesTab>(kvReadOne(props.api, KV_TAB, "current", OES_TABS))
252
- const dimensions = useTerminalDimensions()
253
- /**
254
- * Row width in columns, measured from the root box's computed layout width.
255
- * `ref` fires before the first layout (width 0), so the oes-independent
256
- * fallback holds until `onSizeChange` reports a real width. A scrollbar
257
- * reserve is subtracted; the value is the same budget `lineMax` used to be.
258
- */
259
- const [lineWidth, setLineWidth] = createSignal(ROW_LINE_FALLBACK)
260
- let rootBox: BoxRenderable | undefined
261
- const rootRef = (node: BoxRenderable): void => {
262
- rootBox = node
263
- node.onSizeChange = () => {
264
- const w = node.width
265
- if (Number.isFinite(w) && w > 0) setLineWidth(Math.max(ROW_LINE_FALLBACK, w - ROW_LINE_RESERVE))
266
- }
267
- }
268
- const lineMax = (): number => lineWidth()
269
- const [liveTools, setLiveTools] = createSignal<Record<string, ToolHit>>({})
270
- const [liveFiles, setLiveFiles] = createSignal<Record<string, FileView>>({})
271
- const [tokenTicks, setTokenTicks] = createSignal<readonly TokenTick[]>([])
272
- const [rtVersion, setRtVersion] = createSignal(0)
273
- const [gitTick, setGitTick] = createSignal(0)
274
- const [switching, setSwitching] = createSignal<{ id: string; at: number } | null>(null)
275
- const [coldTab, setColdTab] = createSignal<string | null>(null)
276
- const [consoleLines, setConsoleLines] = createSignal(readScreenLines())
277
- const [consoleTop, setConsoleTop] = createSignal(0)
278
- const consoleWindow = createMemo(() => {
279
- const lines = consoleLines()
280
- const top = clampScrollOffset(lines.length, SCREEN_CONSOLE_VISIBLE, consoleTop())
281
- return lines.slice(top, top + SCREEN_CONSOLE_VISIBLE)
282
- })
283
- /** Console rows the sidebar must leave for the debug block: label + visible lines. */
284
- const consoleRowSpan = () =>
285
- selfDiagActive() ? 1 + Math.min(consoleLines().length, SCREEN_CONSOLE_VISIBLE) : 0
286
-
287
- const engageBootFrame = glyphFrame()
288
- const [engaging, setEngaging] = createSignal(!engageSeen)
289
- const engageTick = (): number => Math.max(0, glyphFrame() - engageBootFrame)
290
-
291
- const colors = (): ThemeColors => props.theme.current as unknown as ThemeColors
292
-
293
- const requestRender = () => {
294
- try {
295
- profile("requestRender", () => props.api.renderer.requestRender())
296
- } catch {
297
- // teardown
298
- }
299
- }
300
-
301
- const offConsole = subscribeScreenLines(() => {
302
- setConsoleLines(readScreenLines())
303
- if (consoleTop() > 0) setConsoleTop((top) => top + 1)
304
- requestRender()
305
- })
306
- onCleanup(offConsole)
307
-
308
- const onConsoleScroll = (e: { scroll?: { direction?: "up" | "down" | "left" | "right"; delta?: number } }) => {
309
- const dir = e.scroll?.direction
310
- if (dir !== "up" && dir !== "down") return
311
- const step = Math.max(1, Math.round(Math.abs(e.scroll?.delta ?? 1)) || 1)
312
- setConsoleTop((top) => scrollByStep(consoleLines().length, SCREEN_CONSOLE_VISIBLE, top, dir, step))
313
- }
314
-
315
- const bumpSeen = (id: string | null | undefined) => {
316
- if (!id) return
317
- setSeen((prev) => ({ ...prev, [id]: Date.now() }))
318
- }
319
-
320
- const apply = (next: RuntimeSnapshot) => {
321
- setSnap(next)
322
- requestRender()
323
- }
324
-
325
- /** Session switch with the tab status row shown until the target snapshot lands. */
326
- const goSession = (id: string | null | undefined) => {
327
- if (!id) return
328
- selectSession(props.api, id)
329
- if (id === props.sessionId) return
330
- setSwitching({ id, at: Date.now() })
331
- }
332
-
333
- const projectDir = () => props.api.state.path.directory ?? null
334
- const projectRoots = () => {
335
- const dir = projectDir()
336
- const tree = props.api.state.path.worktree ?? null
337
- return [dir, tree].filter((p): p is string => Boolean(p))
338
- }
339
-
340
- const bus = createEventBus()
341
- let watchedId = props.sessionId
342
- let source = startRuntimeSource({
343
- bus,
344
- sessionId: watchedId,
345
- projectRoot: projectDir(),
346
- })
347
-
348
- const refresh = () => profile("scan", () => selfTime("scan", () => source.refresh()))
349
-
350
- const ingestFiles = (hits: FileView[]) => {
351
- if (!hits.length) return
352
- setLiveFiles((prev) => {
353
- const next = { ...prev }
354
- for (const f of hits) next[f.id] = f
355
- return next
356
- })
357
- }
358
-
359
- const hydrateDiff = () =>
360
- profile("hydrate", () => {
361
- try {
362
- const fn = props.api.client?.session?.diff
363
- if (typeof fn !== "function") return
364
- const take = (res: unknown) => {
365
- const o = res && typeof res === "object" ? (res as Record<string, unknown>) : null
366
- const rows = o && (Array.isArray(o.data) ? o.data : Array.isArray(o.diff) ? o.diff : null)
367
- const list = rows ?? (Array.isArray(res) ? res : null)
368
- if (!list) return
369
- ingestFiles(
370
- filesFromEvent(
371
- { type: "session.diff", properties: { sessionID: props.sessionId, diff: list } },
372
- props.sessionId,
373
- fileFilter(projectDir()),
374
- ),
375
- )
376
- }
377
- void profileAsync("rpc.diff", () =>
378
- Promise.resolve()
379
- .then(() => fn({ sessionID: props.sessionId }))
380
- .then(take)
381
- .catch(() => {}),
382
- )
383
- } catch {
384
- // host client without session.diff
385
- }
386
- })
387
-
388
- /** Rebind to the session the props point at. A stale select event only nudges a refresh. */
389
- const remount = () =>
390
- profile("remount", () => {
391
- const id = props.sessionId
392
- if (id === watchedId) {
393
- refresh()
394
- return
395
- }
396
- resetSelfStats()
397
- watchedId = id
398
- setLiveTools({})
399
- setLiveFiles({})
400
- setTokenTicks([])
401
- source.setSession(id)
402
- queueMicrotask(hydrateDiff)
403
- })
404
-
405
- createEffect(on(() => props.sessionId, remount, { defer: true }))
406
-
407
- queueMicrotask(hydrateDiff)
408
- // The monitor's initial forced emit (startRuntimeSource startMonitor) runs
409
- // before the EV_OES_SNAPSHOT listener below is registered, so the bus drops
410
- // it. Re-emit once now that listeners exist — the snapshot was already
411
- // computed and cached, so this is a cache hit, not a second full read.
412
- queueMicrotask(refresh)
413
-
414
- const offSnapshot = bus.on(EV_OES_SNAPSHOT, (evt) => {
415
- const data = evt.data
416
- if (!data || typeof data !== "object") return
417
- const snapshot = (data as Record<string, unknown>).snapshot
418
- if (!snapshot || typeof snapshot !== "object") return
419
- apply(snapshot as RuntimeSnapshot)
420
- const s = switching()
421
- if (s) {
422
- const rs = snapshot as RuntimeSnapshot
423
- const landed = rs.db?.current?.id === s.id
424
- const failed = Boolean(rs.db?.error) && rs.db?.error !== TAB_STATUS_SESSION_NOT_IN_DB
425
- if (landed || failed || Date.now() - s.at > SWITCH_TIMEOUT_MS) setSwitching(null)
426
- }
427
- })
428
-
429
- const offSessionActivity = bus.on(EV_OC_SESSION_ACTIVITY, (evt) => {
430
- const data = evt.data
431
- if (!data || typeof data !== "object") return
432
- const payload = data as Record<string, unknown>
433
- const busyFlag = payload.busy
434
- if (typeof busyFlag !== "boolean") return
435
- const id = typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : props.sessionId
436
- if (busyFlag) bumpSeen(id)
437
- setBusy((prev) => ({ ...prev, [id]: busyFlag }))
438
- })
439
-
440
- const offFlow = bus.on(EV_OC_FLOW, (evt) => {
441
- const data = evt.data
442
- if (!data || typeof data !== "object") return
443
- const payload = data as Record<string, unknown>
444
- const id = typeof payload.sessionId === "string" ? payload.sessionId : ""
445
- const dir = payload.dir
446
- if (!id || (dir !== "recv" && dir !== "wait" && dir !== "tool" && dir !== "clear")) return
447
- if (dir !== "clear") bumpSeen(id)
448
- const at = Date.now()
449
- setFlow((prev) => applyFlow(prev, id, dir, at))
450
- })
451
-
452
- const offToolHit = bus.on(EV_OC_TOOL_HIT, (evt) => {
453
- const data = evt.data
454
- if (!data || typeof data !== "object") return
455
- const payload = data as Record<string, unknown>
456
- const hit = payload.hit
457
- if (!hit || typeof hit !== "object") return
458
- const h = hit as { id?: unknown }
459
- if (typeof h.id !== "string" || !h.id) return
460
- const toolHit = hit as ToolHit
461
- bumpSeen(toolHit.sessionId ?? props.sessionId)
462
- setLiveTools((prev) => ({ ...prev, [toolHit.id]: toolHit }))
463
- })
464
-
465
- const offFilesTouched = bus.on(EV_OC_FILES_TOUCHED, (evt) => {
466
- const data = evt.data
467
- if (!data || typeof data !== "object") return
468
- const payload = data as Record<string, unknown>
469
- const files = payload.files
470
- if (!Array.isArray(files)) return
471
- const sessionId = typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : props.sessionId
472
- bumpSeen(sessionId)
473
- ingestFiles(files as FileView[])
474
- })
475
-
476
- const offTokensDelta = bus.on(EV_OC_TOKENS_DELTA, (evt) => {
477
- const data = evt.data
478
- if (!data || typeof data !== "object") return
479
- const payload = data as Record<string, unknown>
480
- const tokens = payload.tokens
481
- if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0) return
482
- const at = Date.now()
483
- setTokenTicks((prev) => pushTokenTick(prev, at, tokens, TOKEN_RATE_WINDOW_MS))
484
- const sessionId = typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : props.sessionId
485
- const kind = payload.kind === "reasoning" ? "reasoning" : "out"
486
- rtTimeline.ingestEstimate(sessionId, kind, tokens, at)
487
- })
488
-
489
- const offSessionSelect = bus.on(EV_OES_SESSION_SELECT, () => remount())
490
-
491
- const bridge = startHostEventBridge({
492
- api: props.api,
493
- bus,
494
- sessionId: () => props.sessionId,
495
- projectRoot: projectDir,
496
- onRender: requestRender,
497
- })
498
-
499
- // One shared realtime timeline (tokens / cache / CPU / RAM). Exact cumulative
500
- // totals arrive on session.updated; estimated stream deltas arrive on the bus;
501
- // the UI tick below folds everything into a grid sample every TICK_MS, so the
502
- // chart's right edge always reaches now instead of waiting for an event.
503
- const rtTimeline = StatRealtimeTimeline.build(null)
504
- const rtSampler = EventDriverSampler.create(rtTimeline, (handler) => {
505
- try {
506
- const on = props.api.event.on as (
507
- name: string,
508
- cb: (evt: unknown, meta?: { directory?: unknown }) => void,
509
- ) => unknown
510
- const off = on("session.updated", (evt, meta) => {
511
- const dir = projectDir()
512
- if (dir && meta?.directory && !samePath(String(meta.directory), dir)) return
513
- handler(evt)
514
- })
515
- return typeof off === "function" ? () => off() : () => {}
516
- } catch {
517
- return () => {}
518
- }
519
- })
520
- rtSampler.start()
521
-
522
- let tickCount = 0
523
- const tick = setInterval(() => {
524
- profile("tick", () => {
525
- selfTime("tick", () => {
526
- // Coarse: ages/marks recompute at most once a second; the spinner phase
527
- // (`frame`) still animates every tick. Returning `prev` skips the cascade.
528
- setNow((prev) => {
529
- const n = Date.now()
530
- return n - prev >= NOW_MS ? n : prev
531
- })
532
- setFrame((n) => n + 1)
533
- // Realtime grid: CPU/RAM are read here and the timeline folds one sample.
534
- const at = Date.now()
535
- rtTimeline.ingestCpuRam(readCpuRam(), at)
536
- rtTimeline.tick(at)
537
- setRtVersion((n) => n + 1)
538
- })
539
- tickCount += 1
540
- if (selfDiagActive() && tickCount % FPS_READ_EVERY_TICKS === 0) {
541
- const r = readRendererFps(props.api.renderer)
542
- setSelfFps(r.fps, r.frameMs)
543
- }
544
- })
545
- // Periodic floor for the My-work tab light — outside `profile("tick")` so
546
- // the scan never pollutes the tick budget; snapshot changes rescan sooner.
547
- maybeScanBadge(MYWORK_BADGE_MS)
548
- }, TICK_MS)
549
-
550
- /** Fast glyph heartbeat — spinners and direction flows step at `GLYPH_TICK_MS`. */
551
- const glyphTick = setInterval(() => setGlyphFrame((n) => n + 1), GLYPH_TICK_MS)
552
-
553
- const offGit = onGitMarksChange(() => {
554
- profile("git", () => {
555
- setGitTick((n) => n + 1)
556
- queueMicrotask(requestRender)
557
- })
558
- })
559
-
560
- createEffect(() => {
561
- profile("render", () => {
562
- frame()
563
- now()
564
- flow()
565
- liveTools()
566
- liveFiles()
567
- queueMicrotask(requestRender)
568
- })
569
- })
570
-
571
- createEffect(() => {
572
- profile("render", () => {
573
- glyphFrame()
574
- queueMicrotask(requestRender)
575
- })
576
- })
577
-
578
- // Cold tabs show their loading row for one short beat, then compute for real.
579
- createEffect(() => {
580
- const t = coldTab()
581
- if (!t) return
582
- const timer = setTimeout(() => setColdTab(null), 100)
583
- onCleanup(() => clearTimeout(timer))
584
- })
585
-
586
- // End the boot bar once the first real snapshot landed after the minimum
587
- // show time (or the hard ceiling) — then toast exactly once per boot.
588
- createEffect(() => {
589
- if (!engaging()) return
590
- if (engageDone(engageTick(), Boolean(snap().fingerprint))) {
591
- setEngaging(false)
592
- engageSeen = true
593
- try {
594
- props.api.ui.toast({
595
- message: "OpenCode Extended Sidebar Loaded. Engage!",
596
- variant: "success",
597
- duration: 7000,
598
- })
599
- } catch {
600
- // host without toast
601
- }
602
- requestRender()
603
- }
604
- })
605
-
606
- onCleanup(() => {
607
- clearInterval(tick)
608
- clearInterval(glyphTick)
609
- rtSampler.stop()
610
- bridge.stop()
611
- offSnapshot()
612
- offSessionActivity()
613
- offFlow()
614
- offToolHit()
615
- offFilesTouched()
616
- offTokensDelta()
617
- offSessionSelect()
618
- source.stop()
619
- offGit()
620
- writeProfileSummary()
621
- })
622
-
623
- const rowMark = (
624
- lifecycle: string | null | undefined,
625
- archived: boolean,
626
- isBusy: boolean,
627
- ...stamps: Array<number | null | undefined>
628
- ): AgentMark =>
629
- composeMark({
630
- lifecycle,
631
- archived,
632
- busy: isBusy,
633
- ageMs: pulseAgeMs(now(), ...stamps),
634
- })
635
-
636
- const mainMark = createMemo(() => {
637
- const m = snap().db.main
638
- if (!m) return MARK_QUEUED
639
- return rowMark(
640
- m.status === SESSION_STATUS_ARCHIVED ? STATUS_ARCHIVED : null,
641
- m.status === SESSION_STATUS_ARCHIVED,
642
- Boolean(busy()[m.id]),
643
- m.timeUpdated,
644
- seen()[m.id],
645
- )
646
- })
647
-
648
- const rowFlow = (id: string | null | undefined, isBusy: boolean): FlowDir | null =>
649
- activeFlow(id ? flow()[id] : undefined, now(), isBusy)
650
-
651
- const mainFlow = createMemo(() => {
652
- const m = snap().db.main
653
- return rowFlow(m?.id, Boolean(m && busy()[m.id]))
654
- })
655
-
656
- const currentRow = createMemo(() => snap().db.current)
657
-
658
- const currentMark = createMemo(() => {
659
- const c = currentRow()
660
- if (!c) return MARK_QUEUED
661
- return rowMark(
662
- c.status === SESSION_STATUS_ARCHIVED ? STATUS_ARCHIVED : null,
663
- c.status === SESSION_STATUS_ARCHIVED,
664
- Boolean(busy()[c.id]),
665
- c.timeUpdated,
666
- seen()[c.id],
667
- )
668
- })
669
-
670
- const currentFlow = createMemo(() => {
671
- const c = currentRow()
672
- return rowFlow(c?.id, Boolean(c && busy()[c.id]))
673
- })
674
-
675
- const sessionDelegates = createMemo(() => delegatesForSession(snap(), props.sessionId))
676
-
677
- const delegateMark = (d: DelegateView): AgentMark =>
678
- rowMark(
679
- d.status,
680
- d.archived,
681
- Boolean(d.sessionId && busy()[d.sessionId]),
682
- d.timeUpdated,
683
- d.sessionId ? seen()[d.sessionId] : null,
684
- )
685
-
686
- const err = createMemo(() => {
687
- const e = snap().db.error
688
- if (!e || e === "session not in db yet") return null
689
- return e
690
- })
691
-
692
- const delegatesLive = createMemo(() => {
693
- let n = 0
694
- for (const d of snap().delegates) {
695
- const m = delegateMark(d)
696
- if (m === PULSE_LIVE || m === PULSE_STALE) n += 1
697
- }
698
- return n
699
- })
700
-
701
- const foldAgents = useFold(props.api, KV_FOLD_AGENTS, { after: requestRender })
702
- const foldDelegates = useFold(props.api, KV_FOLD_DELEGATES, { after: requestRender })
703
- const foldTools = useFold(props.api, KV_FOLD_TOOLS, { after: requestRender })
704
- const foldFiles = useFold(props.api, KV_FOLD_FILES, { after: requestRender })
705
- const foldDrafts = useFold(props.api, KV_FOLD_DRAFTS, { after: requestRender })
706
-
707
- const myWorkFold = {} as Record<MyWorkKind, ReturnType<typeof useFold>>
708
- for (const kind of MY_WORK_ORDER) {
709
- // Errors and Dismissed questions are noise until you want them — start collapsed.
710
- myWorkFold[kind] = useFold(props.api, `oes.fold.mywork.${kind}`, {
711
- after: requestRender,
712
- defaultOpen: kind !== QUESTION_KIND_ERROR && kind !== MY_WORK_GROUP_DISMISSED,
713
- })
714
- }
715
-
716
- // Reveal state is hoisted, not owned by GroupSection: the My work memos
717
- // read the `now()` clock, so <For> reconciles them to fresh objects
718
- // every second a reveal signal created inside the component would be
719
- // thrown away with it and the "… +N more" click would collapse on its own.
720
- const myWorkReveal = {} as Record<MyWorkKind, RevealState>
721
- for (const kind of MY_WORK_ORDER) {
722
- myWorkReveal[kind] = useReveal(2)
723
- }
724
-
725
- const oes = () => getOes(projectDir())
726
- const tools = createMemo(() => mergeTools(snap().db.tools, liveTools(), now(), oes().toolFetch))
727
-
728
- /**
729
- * Project-wide feed tools/files from every main session. The DB queries run
730
- * only while the Sessions tab is open; elsewhere the memo stays empty.
731
- */
732
- const projectFeed = createMemo<ProjectFeed>(() => {
733
- if (tab() !== "sessions" || coldTab() === "sessions") return emptyProjectFeed()
734
- const db = snap().db
735
- return readProjectFeed({
736
- dbPath: db.dbPath,
737
- sessionIds: db.recent.map((s) => s.id),
738
- toolLimit: oes().toolFetch,
739
- filter: fileFilter(projectDir()),
740
- })
741
- })
742
- const projectFilesStat = createMemo(() => sumDiff(projectFeed().files))
743
-
744
- const filesAll = createMemo(() => {
745
- gitTick()
746
- return decorateFiles(mergeFiles(snap().db.files ?? [], liveFiles()), projectDir(), {
747
- git: tab() === "current" && foldFiles.open(),
748
- })
749
- })
750
- const filesStat = createMemo(() => sumDiff(filesAll()))
751
-
752
- const pickTab = (next: string) => {
753
- if (!(OES_TABS as readonly string[]).includes(next)) return
754
- const picked = next as OesTab
755
- if (picked !== tab()) {
756
- setTab(picked)
757
- kvWriteOne(props.api, KV_TAB, picked)
758
- // Cold start: the tab's heavy reads run after the status row shows, so
759
- // the loading line is actually visible instead of a one-frame flash.
760
- if (picked === "mywork" || picked === "sessions" || picked === "perf") {
761
- setColdTab(picked)
762
- }
763
- }
764
- if (picked === "current" || picked === "perf") refresh()
765
- requestRender()
766
- }
767
-
768
- const omoPresent = createMemo(() => snap().omo.present)
769
-
770
- /** Drafts under `.omo/drafts/` that this session wrote — last five inline, the full list behind "view all". */
771
- const draftsAll = createMemo<DocView[]>(() => {
772
- if (tab() !== "current" || !omoPresent() || !props.sessionId) return []
773
- now() // re-scan while open; the docs cache TTL gates the filesystem read
774
- const db = openReadonlyDb(snap().db.dbPath)
775
- if (!db) return [] // no writer attribution without the DB
776
- try {
777
- return DraftFile.list(projectDir(), props.sessionId, { db })
778
- } catch (e) {
779
- dbg("drafts", "error", String(e))
780
- return []
781
- }
782
- })
783
- const drafts = createMemo(() => draftsAll().slice(0, 5))
784
-
785
- /** Open `question` tools anywhere in this project — the "answer me" queue. */
786
- const myWorkQuestions = createMemo<MyWorkItem[]>(() => {
787
- if (tab() !== "mywork" || coldTab() === "mywork") return []
788
- try {
789
- return dropDismissed(
790
- toQuestionItems(snap().openQuestions),
791
- readDismissedQuestions(props.api),
792
- )
793
- } catch (e) {
794
- dbg("mywork.questions", "error", String(e))
795
- return []
796
- }
797
- })
798
-
799
- /** OMO plans/drafts in the action groups, enriched with planner-session state, plus the Draft docs archive. */
800
- const myWorkApprovals = createMemo<MyWorkItem[]>(() => {
801
- if (tab() !== "mywork" || coldTab() === "mywork" || !omoPresent()) return []
802
- now() // re-scan while open; the plans cache TTL gates the filesystem read
803
- const dir = projectDir()
804
- try {
805
- const buckets = listApprovals(dir)
806
- return [
807
- ...toApprovalItems(
808
- enrichApprovalSessionStates(
809
- [
810
- ...buckets.readyReview,
811
- ...buckets.readyStart,
812
- ...buckets.finished,
813
- ...buckets.drafting,
814
- ],
815
- {
816
- dbPath: snap().db.dbPath,
817
- projectRoot: dir,
818
- },
819
- ),
820
- ),
821
- // Draft docs / Plans are not queue items — no session enrichment needed.
822
- ...toDraftDocItems(buckets.draftDocs),
823
- ...toPlanItems(buckets.plans),
824
- ]
825
- } catch (e) {
826
- dbg("mywork.approvals", "error", String(e))
827
- return []
828
- }
829
- })
830
-
831
- /** Recent main sessions (running and idle) — the "Sessions" group. */
832
- const myWorkRunning = createMemo<MyWorkItem[]>(() => {
833
- if (tab() !== "mywork" || coldTab() === "mywork") return []
834
- return toSessionItems(snap().db.recent)
835
- })
836
-
837
- const myWorkItems = createMemo<MyWorkItem[]>(() => [
838
- ...myWorkQuestions(),
839
- ...myWorkRunning(),
840
- ...myWorkApprovals(),
841
- ])
842
-
843
- const myWorkGroups = createMemo(() => groupMyWork(myWorkItems()))
844
-
845
- /** My-work tab light — one scan pipeline for every tab (see `maybeScanBadge`). */
846
- const [myWorkAttn, setMyWorkAttn] = createSignal<readonly TabAttentionItem[]>([])
847
- let lastMyWorkScan = 0
848
-
849
- const scanMyWorkBadge = () => {
850
- const items: TabAttentionItem[] = []
851
- try {
852
- const dismissed = readDismissedQuestions(props.api)
853
- const open = snap().openQuestions
854
- for (const q of open) {
855
- if (dismissed.has(q.partId)) continue
856
- items.push({ kind: q.kind, ended: q.ended })
857
- }
858
- } catch (e) {
859
- dbg("mywork.badge", "error", String(e))
860
- }
861
- if (omoPresent()) {
862
- try {
863
- const buckets = listApprovals(projectDir())
864
- if (buckets.readyReview.length > 0) items.push({ kind: MY_WORK_GROUP_READY_REVIEW })
865
- if (buckets.readyStart.length > 0) items.push({ kind: MY_WORK_GROUP_READY_START })
866
- } catch (e) {
867
- dbg("mywork.badge", "error", String(e))
868
- }
869
- }
870
- setMyWorkAttn(items)
871
- }
872
-
873
- const maybeScanBadge = (minGapMs: number) => {
874
- const at = Date.now()
875
- if (at - lastMyWorkScan < minGapMs) return
876
- lastMyWorkScan = at
877
- scanMyWorkBadge()
878
- }
879
-
880
- const myWorkTabGlyph = createMemo<GlyphSpec>(() => tabAttentionGlyph(myWorkAttn()))
881
-
882
- createEffect(() => {
883
- snap()
884
- maybeScanBadge(MYWORK_BADGE_COOLDOWN_MS)
885
- })
886
-
887
- /**
888
- * Rows are handed out on every render: chrome that always costs a line is
889
- * counted first, then `packSections` splits what is left.
890
- */
891
- const rowPlan = createMemo(() => {
892
- try {
893
- const o = oes()
894
- const t = tab()
895
- const omo = omoPresent()
896
- const sections: { key: string; want: number; min: number; rank: number }[] = []
897
- let fixed = 2 // OES status line + brand/tabs row
898
- if (selfDiagActive()) fixed += 1 // self line — only while debug/profile is on
899
- if (modeLine()) fixed += 1 // debug/profile flag row
900
- if (modeDirLine()) fixed += 1
901
- if (engaging()) fixed += 1 // cold-start engage bar row
902
- fixed += consoleRowSpan() // bottom dbg console — label + visible event rows
903
- let blocks = 0
904
-
905
- const header = () => {
906
- blocks += 1
907
- fixed += 1
908
- }
909
- const section = (folded: boolean, key: string, want: number, min: number, rank: number) => {
910
- header()
911
- if (!folded) sections.push({ key, want, min, rank })
912
- }
913
-
914
- if (t === "sessions") {
915
- header()
916
- fixed += foldAgents.open() ? 1 : 0
917
- const feed = projectFeed()
918
- if (feed.tools.length > 0) section(!foldTools.open(), "tools", o.toolRows, ROW_MIN.tools, ROW_RANK.tools)
919
- if (feed.files.length > 0) section(!foldFiles.open(), "files", o.fileRows, ROW_MIN.files, ROW_RANK.files)
920
- if (omo) section(!foldDelegates.open(), "delegates", 6, ROW_MIN.delegates, ROW_RANK.delegates)
921
- } else if (t === "current") {
922
- header()
923
- fixed += foldAgents.open() ? 1 : 0
924
- section(!foldDelegates.open(), "delegates", 6, ROW_MIN.delegates, ROW_RANK.delegates)
925
- section(!foldTools.open(), "tools", o.toolRows, ROW_MIN.tools, ROW_RANK.tools)
926
- section(!foldFiles.open(), "files", o.fileRows, ROW_MIN.files, ROW_RANK.files)
927
- if (omo && draftsAll().length > 0) {
928
- section(!foldDrafts.open(), "drafts", Math.min(5, draftsAll().length), ROW_MIN.drafts, ROW_RANK.drafts)
929
- }
930
- } else if (t === "mywork") {
931
- header()
932
- for (const g of myWorkGroups()) {
933
- const fold = myWorkFold[g.kind]
934
- if (!fold) continue
935
- section(!fold.open(), `mywork.${g.kind}`, g.items.length, ROW_MIN.mywork, ROW_RANK.mywork)
936
- }
937
- } else {
938
- // Perf lays out its own sections and caps itself with `perfRows` in
939
- // its own view; no row-plan sections are handed out here.
940
- }
941
- fixed += Math.max(0, blocks - 1) // one blank row between OES sections
942
-
943
- const height = dimensions()?.height
944
- return packSections(panelRows(typeof height === "number" ? height : 24), fixed, sections)
945
- } catch (e) {
946
- dbg("rowplan", "error", String(e))
947
- return {}
948
- }
949
- }, {})
950
-
951
- const rowsFor = (key: string, fallback: number): number => {
952
- try {
953
- return rowsForPlan(rowPlan(), key, fallback)
954
- } catch {
955
- return fallback
956
- }
957
- }
958
-
959
- const projectToolsReveal = useReveal(Math.max(1, oes().toolRows))
960
- const toolsReveal = useReveal(Math.max(1, oes().toolRows))
961
- const delegateReveal = useReveal(4)
962
- const currentDelegatesReveal = useReveal(4)
963
-
964
- /**
965
- * A browsable OMO document (Draft docs / Plans archive) opens the same
966
- * picker the action rows get: Navigate to session first (the session that
967
- * wrote the file), then the preview row. No Approve — an archive row is
968
- * never a sign-off queue. Start work is offered only for the Plans
969
- * archive (via the `startWork` flag); Draft docs stays browse-only.
970
- */
971
- const openDocPicker = (item: { name: string; rel: string }, doc: DocView, docsLabel: string, startWork: boolean = false) => {
972
- const db = openReadonlyDb(snap().db.dbPath)
973
- const sessionId = db ? sessionForPlanFile(db, item.rel) : null
974
- openApprovalDialog(props.api, {
975
- title: item.name,
976
- sessionId,
977
- continueHint: approvalContinueHint(sessionId, Boolean(db)),
978
- onContinue: (sid) => selectSession(props.api, sid),
979
- onStartWork: (mode) => runStartWork(props.api, props.sessionId, mode, item.name),
980
- onDocs: () => openDocDetail(props.api, doc, projectRoots(), colors()),
981
- showApprove: false,
982
- showStartWork: startWork,
983
- docsLabel,
984
- })
985
- }
986
-
987
- const myWorkRow = (item: MyWorkItem): RowData => {
988
- if (item.kind === MY_WORK_GROUP_SESSIONS || item.kind === MY_WORK_GROUP_PINNED) {
989
- const isBusy = Boolean(item.sessionId && busy()[item.sessionId])
990
- return {
991
- kind: ROW_KIND_AGENT,
992
- mark: rowMark(
993
- item.status === SESSION_STATUS_ARCHIVED ? STATUS_ARCHIVED : null,
994
- item.status === SESSION_STATUS_ARCHIVED,
995
- isBusy,
996
- item.timeUpdated,
997
- item.sessionId ? seen()[item.sessionId] : null,
998
- ),
999
- dirSlot: true,
1000
- flow: rowFlow(item.sessionId, isBusy),
1001
- name: item.title,
1002
- current: item.sessionId === props.sessionId,
1003
- suffix: item.sessionId === props.sessionId ? "[C]" : undefined,
1004
- onSelect: () => goSession(item.sessionId),
1005
- // Pin affordance — placeholder until the pinned backend is wired.
1006
- link: item.kind === MY_WORK_GROUP_SESSIONS ? { label: "P", onPick: () => {} } : undefined,
1007
- }
1008
- }
1009
- if (item.kind === MY_WORK_GROUP_DRAFT_DOCS) {
1010
- const doc: DocView = {
1011
- kind: DOC_KIND_DRAFT,
1012
- name: item.name,
1013
- rel: item.rel,
1014
- updatedAt: item.updatedAt,
1015
- sizeBytes: 0,
1016
- previewable: true,
1017
- }
1018
- return {
1019
- kind: ROW_KIND_FILE,
1020
- glyph: myWorkGlyph(item.kind),
1021
- name: item.name,
1022
- onSelect: () => openDocPicker(item, doc, "Preview draft file"),
1023
- }
1024
- }
1025
- if (item.kind === MY_WORK_GROUP_PLANS) {
1026
- const doc: DocView = {
1027
- kind: DOC_KIND_PLAN,
1028
- name: item.name,
1029
- rel: item.rel,
1030
- updatedAt: item.updatedAt,
1031
- sizeBytes: 0,
1032
- previewable: true,
1033
- }
1034
- return {
1035
- kind: ROW_KIND_FILE,
1036
- glyph: myWorkGlyph(item.kind),
1037
- name: item.name,
1038
- onSelect: () => openDocPicker(item, doc, "Preview plan file", true),
1039
- }
1040
- }
1041
- if ("sessionId" in item) {
1042
- const dismissible = item.kind === QUESTION_KIND_INTERRUPTED || item.kind === QUESTION_KIND_ERROR
1043
- return {
1044
- kind: ROW_KIND_AGENT,
1045
- glyph: myWorkGlyph(item.kind),
1046
- name: item.title,
1047
- subline: item.reason ?? undefined,
1048
- onSelect: dismissible
1049
- ? () =>
1050
- openQuestionDialog(props.api, {
1051
- title: item.title,
1052
- sessionId: item.sessionId,
1053
- onNavigate: (sid) => goSession(sid),
1054
- onDismiss: () => {
1055
- dismissQuestion(props.api, item.partId)
1056
- requestRender()
1057
- },
1058
- })
1059
- : () => goSession(item.sessionId),
1060
- }
1061
- }
1062
- const reviewLabel = reviewStateSuffix(item.review)
1063
- const drafting = item.kind === MY_WORK_GROUP_DRAFTING
1064
- const showApprove = item.kind === MY_WORK_GROUP_READY_REVIEW
1065
- const showStartWork =
1066
- item.kind === MY_WORK_GROUP_READY_REVIEW || item.kind === MY_WORK_GROUP_READY_START
1067
- const doc: DocView = {
1068
- kind: DOC_KIND_DRAFT,
1069
- name: item.name,
1070
- rel: item.rel,
1071
- updatedAt: item.updatedAt,
1072
- sizeBytes: 0,
1073
- previewable: true,
1074
- }
1075
- return {
1076
- kind: ROW_KIND_FILE,
1077
- glyph: myWorkGlyph(item.kind),
1078
- name: item.name,
1079
- suffix: reviewLabel || undefined,
1080
- onSelect: () => {
1081
- const db = openReadonlyDb(snap().db.dbPath)
1082
- const sessionId = db ? sessionForPlanFile(db, item.rel) : null
1083
- const base: Parameters<typeof openApprovalDialog>[1] = {
1084
- title: item.name,
1085
- sessionId,
1086
- continueHint: approvalContinueHint(sessionId, Boolean(db)),
1087
- onContinue: (sid) => selectSession(props.api, sid),
1088
- onApprove: (sid) => approvePlan(props.api, sid),
1089
- onStartWork: (mode) => runStartWork(props.api, props.sessionId, mode, item.name),
1090
- onDocs: () => openDocDetail(props.api, doc, projectRoots(), colors()),
1091
- }
1092
- if (drafting) {
1093
- openApprovalDialog(props.api, {
1094
- ...base,
1095
- showApprove: false,
1096
- showStartWork: false,
1097
- docsLabel: "Preview plan file",
1098
- })
1099
- return
1100
- }
1101
- openApprovalDialog(props.api, { ...base, showApprove, showStartWork })
1102
- },
1103
- }
1104
- }
1105
-
1106
- /** Perf SQLite scan runs only while this tab is open. */
1107
- const perf = createMemo(() => {
1108
- if (tab() !== "perf" || coldTab() === "perf") return emptyPerf(props.sessionId)
1109
- const o = oes()
1110
- const history = o.perfHistory > 0
1111
- ? snap()
1112
- .db.recent.filter((s) => s.id !== props.sessionId)
1113
- .slice(0, o.perfHistory)
1114
- .map((s) => ({ id: s.id, title: s.title }))
1115
- : []
1116
- return readPerfSnapshot({
1117
- dbPath: snap().db.dbPath,
1118
- sessionId: props.sessionId,
1119
- turns: o.perfTurns,
1120
- history,
1121
- cacheKey: `${props.sessionId}::${snap().scanStamp}::${o.perfTurns}`,
1122
- })
1123
- })
1124
-
1125
- const tabStatusFor = (tab: OesTab) =>
1126
- tabStatus({
1127
- tab,
1128
- currentId: snap().db.current?.id ?? null,
1129
- dbError: snap().db.error,
1130
- dbPresent: snap().db.present,
1131
- switching: switching()?.id ?? null,
1132
- cold: coldTab() === tab,
1133
- perfError: tab === "perf" ? perf().error : null,
1134
- perfTurns: tab === "perf" ? perf().totals.turns : 0,
1135
- })
1136
-
1137
- const selfFlow = createMemo(() =>
1138
- rowFlow(props.sessionId, Boolean(busy()[props.sessionId])),
1139
- )
1140
- const selfPhaseMs = createMemo(() =>
1141
- phaseAgeMs(flow()[props.sessionId], now(), selfFlow()),
1142
- )
1143
-
1144
- /** The one place that binds a row to theme, frame and the measured row width. */
1145
- const Row = (row: RowData): JSX.Element => (
1146
- <AgentLine
1147
- {...row}
1148
- lineMax={lineMax()}
1149
- frame={frame}
1150
- glyphFrame={glyphFrame}
1151
- colors={colors()}
1152
- />
1153
- )
1154
-
1155
- /** Flatten delegates to rows (agent headers + members) for `RowList`. */
1156
- const delegateLines = (list: DelegateView[]): RowData[] => {
1157
- const out: RowData[] = []
1158
- for (const item of groupDelegates(list)) {
1159
- if (item.kind === "header") {
1160
- out.push({
1161
- kind: ROW_KIND_GROUP,
1162
- glyph: {
1163
- char: GROUP_GLYPH,
1164
- tone: markTone(hottestMark(item.members.map(delegateMark))),
1165
- },
1166
- name: `${agentDisplayName(item.agent)} (${item.count})`,
1167
- })
1168
- continue
1169
- }
1170
- const d = item.delegate
1171
- const isBusy = Boolean(d.sessionId && busy()[d.sessionId])
1172
- const mark = delegateMark(d)
1173
- const dir = rowFlow(d.sessionId, isBusy)
1174
- out.push({
1175
- kind: item.grouped ? ROW_KIND_DELEGATE : ROW_KIND_AGENT,
1176
- mark,
1177
- name: item.grouped ? d.title || d.taskKey || "task" : d.agent || "agent",
1178
- tokens: d.tokensTotal,
1179
- title: item.grouped ? undefined : d.title,
1180
- current: Boolean(d.sessionId && d.sessionId === props.sessionId),
1181
- flow: dir,
1182
- dirSlot: true,
1183
- indent: item.grouped,
1184
- onSelect: () => goSession(d.sessionId),
1185
- })
1186
- }
1187
- return out
1188
- }
1189
-
1190
- /** A delegate list with its own "… +N more" revealer (state hoisted by the caller). */
1191
- const DelegateList = (props: {
1192
- list: DelegateView[]
1193
- budget: number
1194
- reveal: RevealState
1195
- }): JSX.Element => (
1196
- <RowList
1197
- items={delegateLines(props.list)}
1198
- budget={props.budget + props.reveal.more()}
1199
- colors={colors()}
1200
- renderItem={(r) => <Row {...r} />}
1201
- more={{ onReveal: props.reveal.reveal }}
1202
- />
1203
- )
1204
-
1205
- /** Live token rate from streaming deltas — recomputes on each delta and on the coarse now tick. */
1206
- const liveTokenRate = createMemo(() => {
1207
- now()
1208
- return tokenRate(tokenTicks(), Date.now(), TOKEN_RATE_WINDOW_MS)
1209
- })
1210
- const liveRateBar = createMemo(() => {
1211
- now()
1212
- return tokenRateBars(tokenTicks(), Date.now(), TOKEN_RATE_WINDOW_MS)
1213
- })
1214
-
1215
- /** Live self-cost line — reads the tick clock so the Solid insert re-evaluates every tick. */
1216
- const selfLine = createMemo(() => {
1217
- if (!selfDiagActive()) return ""
1218
- now()
1219
- return formatSelfLine(readSelfStats())
1220
- })
1221
-
1222
- /** "debug mode" / "profile" flags visible only while the logger is actually active. */
1223
- const modeLine = () => {
1224
- const modes: string[] = []
1225
- if (debugActive()) modes.push("debug mode")
1226
- if (profileActive()) modes.push("profile")
1227
- return modes.join(" ")
1228
- }
1229
-
1230
- const modeDirLine = () => {
1231
- const dirs = [debugActiveDir(), profileActiveDir()].filter((p): p is string => Boolean(p))
1232
- if (dirs.length === 0) return ""
1233
- const uniq = Array.from(new Set(dirs))
1234
- return `logs ${clipWidth(uniq.join(" | "), 78)}`
1235
- }
1236
-
1237
- const openRealtimeModal = (): void =>
1238
- openRealtimeCharts(props.api, colors(), {
1239
- getTimeline: () => rtTimeline,
1240
- initialTabId: "tokens",
1241
- initialRowKey: "avg",
1242
- })
1243
-
1244
- return (
1245
- <box ref={rootRef} flexDirection="column" gap={0}>
1246
- <Show when={engaging()}>
1247
- <box flexDirection="row" gap={1}>
1248
- <text fg={colors().primary}>{`${spinnerFrame(engageTick())} engage`}</text>
1249
- <text fg={colors().textMuted}>{shareBar(engageFill(engageTick()), 10)}</text>
1250
- </box>
1251
- </Show>
1252
- <Show when={modeLine()}>
1253
- <text fg={colors().warning || colors().text}>{modeLine()}</text>
1254
- </Show>
1255
- <Show when={modeDirLine()}>
1256
- <text fg={colors().textMuted}>{modeDirLine()}</text>
1257
- </Show>
1258
- <box flexDirection="row" gap={1} width="100%">
1259
- <box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0}>
1260
- <OesStatusRow
1261
- status={tabStatusFor(tab())}
1262
- colors={colors()}
1263
- glyphFrame={glyphFrame}
1264
- rateBar={liveRateBar()}
1265
- rate={liveTokenRate()}
1266
- />
1267
- </box>
1268
- <ContextActions
1269
- actions={[{ label: "C", onPick: openRealtimeModal }]}
1270
- colors={colors()}
1271
- width={RT_ACTION_COL_WIDTH}
1272
- />
1273
- </box>
1274
- <Show when={selfDiagActive()}>
1275
- <text fg={colors().textMuted}>{selfLine()}</text>
1276
- </Show>
1277
- <TabColumn
1278
- brand=""
1279
- tabs={OES_TABS}
1280
- labels={TAB_LABELS}
1281
- active={tab()}
1282
- colors={colors()}
1283
- onPick={pickTab}
1284
- gap={0}
1285
- glyph={(key) => (key === "mywork" ? myWorkTabGlyph() : TAB_NEUTRAL_GLYPH)}
1286
- panels={{
1287
- mywork: () => {
1288
- try {
1289
- const groups = myWorkGroups()
1290
- dbg("mywork.panel", "render", { groups: groups.map((g) => `${g.kind}:${g.items.length}`) })
1291
- const status = tabStatusFor("mywork")
1292
- if (!status && groups.length === 0) return <text fg={colors().textMuted}>• nothing</text>
1293
- return (
1294
- <box flexDirection="column" gap={1}>
1295
- <For each={groups}>
1296
- {(g) => (
1297
- <GroupSection
1298
- title={myWorkLabel(g.kind)}
1299
- open={myWorkFold[g.kind].open()}
1300
- onToggle={myWorkFold[g.kind].toggle}
1301
- colors={colors()}
1302
- items={g.items}
1303
- budget={rowsFor(`mywork.${g.kind}`, 2)}
1304
- reveal={myWorkReveal[g.kind]}
1305
- actions={
1306
- g.kind === MY_WORK_GROUP_SESSIONS
1307
- ? [
1308
- { label: "switch", onPick: () => openSessionSwitcher(props.api) },
1309
- { label: "new", onPick: () => openNewSessionPrompt(props.api) },
1310
- ]
1311
- : undefined
1312
- }
1313
- renderItem={(item) => <Row {...myWorkRow(item)} />}
1314
- />
1315
- )}
1316
- </For>
1317
- </box>
1318
- )
1319
- } catch (e) {
1320
- dbg("mywork.panel", "error", String(e))
1321
- return <text fg={colors().error || colors().text}>• my work error</text>
1322
- }
1323
- },
1324
- sessions: () => (
1325
- <box flexDirection="column" gap={1}>
1326
- <FoldSection title="Agents" open={foldAgents.open()} colors={colors()} onToggle={foldAgents.toggle}>
1327
- {isRealSession(snap().db.main) ? (
1328
- <Row
1329
- kind={ROW_KIND_AGENT}
1330
- mark={mainMark()}
1331
- name={snap().db.main!.agent}
1332
- tokens={snap().db.main!.tokensTotal}
1333
- cost={snap().db.main!.cost}
1334
- current={snap().db.main!.id === props.sessionId}
1335
- flow={mainFlow()}
1336
- dirSlot
1337
- onSelect={() => goSession(snap().db.main?.id)}
1338
- />
1339
- ) : (
1340
- <text fg={colors().textMuted}>
1341
- {`• session · ${props.sessionId.slice(0, 14)}`}
1342
- {err() ? ` · ${err()}` : ""}
1343
- </text>
1344
- )}
1345
- </FoldSection>
1346
-
1347
- <Show when={projectFeed().tools.length > 0}>
1348
- <FoldSection title="Tool Calls" open={foldTools.open()} colors={colors()} onToggle={foldTools.toggle}>
1349
- <RowList
1350
- items={projectFeed().tools}
1351
- budget={rowsFor("tools", oes().toolRows) + projectToolsReveal.more()}
1352
- colors={colors()}
1353
- renderItem={(t) => {
1354
- const mark = toolMark(t.status)
1355
- const dir = toolFlow(t.status)
1356
- const dur =
1357
- t.status === TOOL_STATUS_RUNNING && t.startedAt != null
1358
- ? formatDuration(Math.max(0, now() - t.startedAt))
1359
- : formatDuration(t.durationMs)
1360
- return (
1361
- <Row
1362
- kind={ROW_KIND_TOOL}
1363
- mark={mark}
1364
- name={t.name}
1365
- suffix={dur}
1366
- flow={dir}
1367
- dirSlot
1368
- onSelect={() => openToolDetail(props.api, t, colors())}
1369
- />
1370
- )
1371
- }}
1372
- more={{ onReveal: projectToolsReveal.reveal }}
1373
- />
1374
- </FoldSection>
1375
- </Show>
1376
-
1377
- <Show when={projectFeed().files.length > 0}>
1378
- <FoldSection
1379
- title="Files"
1380
- open={foldFiles.open()}
1381
- count={projectFeed().files.length}
1382
- diff={projectFilesStat()}
1383
- actions={[
1384
- {
1385
- label: "view all",
1386
- onPick: () =>
1387
- openFileListDialog(
1388
- props.api,
1389
- "Files",
1390
- projectFeed().files.map((f) => ({
1391
- name: f.name,
1392
- onSelect: () => openFileDetail(props.api, f, projectRoots(), colors()),
1393
- })),
1394
- ),
1395
- },
1396
- ]}
1397
- colors={colors()}
1398
- onToggle={foldFiles.toggle}
1399
- >
1400
- <RowList
1401
- items={projectFeed().files}
1402
- budget={rowsFor("files", oes().fileRows)}
1403
- colors={colors()}
1404
- renderItem={(f) => (
1405
- <Row
1406
- kind={ROW_KIND_FILE}
1407
- glyph={fileLetterGlyph(f.letter)}
1408
- name={f.name}
1409
- diff={{ additions: f.additions, deletions: f.deletions }}
1410
- onSelect={() => openFileDetail(props.api, f, projectRoots(), colors())}
1411
- />
1412
- )}
1413
- />
1414
- </FoldSection>
1415
- </Show>
1416
-
1417
- <Show when={snap().omo.present}>
1418
- <FoldSection
1419
- title="Delegates"
1420
- open={foldDelegates.open()}
1421
- count={snap().delegates.length}
1422
- live={delegatesLive()}
1423
- colors={colors()}
1424
- onToggle={foldDelegates.toggle}
1425
- >
1426
- {snap().delegates.length === 0 ? (
1427
- <text fg={colors().textMuted}>• none</text>
1428
- ) : (
1429
- <DelegateList list={snap().delegates} budget={rowsFor("delegates", 6)} reveal={delegateReveal} />
1430
- )}
1431
- </FoldSection>
1432
- </Show>
1433
- </box>
1434
- ),
1435
- current: () => (
1436
- <box flexDirection="column" gap={1}>
1437
- <FoldSection title="Agents" open={foldAgents.open()} colors={colors()} onToggle={foldAgents.toggle}>
1438
- {isRealSession(currentRow()) ? (
1439
- <Row
1440
- kind={ROW_KIND_AGENT}
1441
- mark={currentMark()}
1442
- name={currentRow()!.agent}
1443
- tokens={currentRow()!.tokensTotal}
1444
- cost={currentRow()!.cost}
1445
- current
1446
- flow={currentFlow()}
1447
- dirSlot
1448
- onSelect={() => goSession(currentRow()?.id)}
1449
- />
1450
- ) : (
1451
- <text fg={colors().textMuted}>
1452
- {`• session · ${props.sessionId.slice(0, 14)}`}
1453
- </text>
1454
- )}
1455
- </FoldSection>
1456
-
1457
- <Show when={snap().omo.present || sessionDelegates().length > 0}>
1458
- <FoldSection
1459
- title="Delegates"
1460
- open={foldDelegates.open()}
1461
- count={sessionDelegates().length}
1462
- live={sessionDelegates().filter((d) => {
1463
- const m = delegateMark(d)
1464
- return m === PULSE_LIVE || m === PULSE_STALE
1465
- }).length}
1466
- colors={colors()}
1467
- onToggle={foldDelegates.toggle}
1468
- >
1469
- {sessionDelegates().length === 0 ? (
1470
- <text fg={colors().textMuted}>• none</text>
1471
- ) : (
1472
- <DelegateList list={sessionDelegates()} budget={rowsFor("delegates", 6)} reveal={currentDelegatesReveal} />
1473
- )}
1474
- </FoldSection>
1475
- </Show>
1476
-
1477
- <FoldSection title="Tool Calls" open={foldTools.open()} colors={colors()} onToggle={foldTools.toggle}>
1478
- {tools().length === 0 ? (
1479
- <text fg={colors().textMuted}>• none</text>
1480
- ) : (
1481
- <RowList
1482
- items={tools()}
1483
- budget={rowsFor("tools", oes().toolRows) + toolsReveal.more()}
1484
- colors={colors()}
1485
- renderItem={(t) => {
1486
- const mark = toolMark(t.status)
1487
- const dir = toolFlow(t.status)
1488
- const dur =
1489
- t.status === TOOL_STATUS_RUNNING && t.startedAt != null
1490
- ? formatDuration(Math.max(0, now() - t.startedAt))
1491
- : formatDuration(t.durationMs)
1492
- return (
1493
- <Row
1494
- kind={ROW_KIND_TOOL}
1495
- mark={mark}
1496
- name={t.name}
1497
- suffix={dur}
1498
- flow={dir}
1499
- dirSlot
1500
- onSelect={() => openToolDetail(props.api, t, colors())}
1501
- />
1502
- )
1503
- }}
1504
- more={{ onReveal: toolsReveal.reveal }}
1505
- />
1506
- )}
1507
- </FoldSection>
1508
-
1509
- <FoldSection
1510
- title="Files"
1511
- open={foldFiles.open()}
1512
- count={filesAll().length > 0 ? filesAll().length : undefined}
1513
- diff={filesStat()}
1514
- actions={[
1515
- {
1516
- label: "view all",
1517
- onPick: () =>
1518
- openFileListDialog(
1519
- props.api,
1520
- "Files",
1521
- filesAll().map((f) => ({
1522
- name: f.name,
1523
- onSelect: () => openFileDetail(props.api, f, projectRoots(), colors()),
1524
- })),
1525
- ),
1526
- },
1527
- ]}
1528
- colors={colors()}
1529
- onToggle={foldFiles.toggle}
1530
- >
1531
- {filesAll().length === 0 ? (
1532
- <text fg={colors().textMuted}>• none</text>
1533
- ) : (
1534
- <RowList
1535
- items={filesAll()}
1536
- budget={rowsFor("files", oes().fileRows)}
1537
- colors={colors()}
1538
- renderItem={(f) => (
1539
- <Row
1540
- kind={ROW_KIND_FILE}
1541
- glyph={fileLetterGlyph(f.letter)}
1542
- name={f.name}
1543
- diff={{ additions: f.additions, deletions: f.deletions }}
1544
- onSelect={() => openFileDetail(props.api, f, projectRoots(), colors())}
1545
- />
1546
- )}
1547
- />
1548
- )}
1549
- </FoldSection>
1550
-
1551
- <Show when={drafts().length > 0}>
1552
- <FoldSection
1553
- title="Drafts"
1554
- open={foldDrafts.open()}
1555
- count={draftsAll().length}
1556
- actions={[
1557
- {
1558
- label: "view all",
1559
- onPick: () =>
1560
- openFileListDialog(
1561
- props.api,
1562
- "Drafts",
1563
- draftsAll().map((d) => ({
1564
- name: d.name,
1565
- description: d.rel,
1566
- onSelect: () => openDocDetail(props.api, d, projectRoots(), colors()),
1567
- })),
1568
- ),
1569
- },
1570
- ]}
1571
- colors={colors()}
1572
- onToggle={foldDrafts.toggle}
1573
- >
1574
- <RowList
1575
- items={drafts()}
1576
- budget={rowsFor("drafts", 5)}
1577
- colors={colors()}
1578
- renderItem={(d) => {
1579
- const age = pulseAgeMs(now(), d.updatedAt)
1580
- return (
1581
- <Row
1582
- kind={ROW_KIND_FILE}
1583
- glyph={{ char: "•", tone: markTone(composeMark({ ageMs: age })) }}
1584
- name={d.name}
1585
- suffix={formatAge(age)}
1586
- onSelect={() => openDocDetail(props.api, d, projectRoots(), colors())}
1587
- />
1588
- )
1589
- }}
1590
- />
1591
- </FoldSection>
1592
- </Show>
1593
- </box>
1594
- ),
1595
- perf: () => (
1596
- <box flexDirection="column" gap={1}>
1597
- <PerfPanel
1598
- api={props.api}
1599
- perf={perf()}
1600
- colors={colors()}
1601
- lineMax={lineMax()}
1602
- rows={oes().perfRows}
1603
- glyphFrame={glyphFrame}
1604
- livePhase={selfFlow()}
1605
- livePhaseMs={selfPhaseMs()}
1606
- currentSessionId={props.sessionId}
1607
- dbPath={snap().db.dbPath}
1608
- turns={oes().perfTurns}
1609
- onSelect={(id) => goSession(id)}
1610
- />
1611
- </box>
1612
- ),
1613
- }}
1614
- />
1615
-
1616
- {err() && snap().db.main ? (
1617
- <text fg={colors().textMuted}>{err()}</text>
1618
- ) : null}
1619
- <Show when={selfDiagActive()}>
1620
- <box flexDirection="column" gap={0} onMouseScroll={onConsoleScroll}>
1621
- <text fg={colors().textMuted}>{`dbg · ${consoleLines().length}`}</text>
1622
- <For each={consoleWindow()}>
1623
- {(line) => <text fg={colors().textMuted}>{clipWidth(line.text, lineMax())}</text>}
1624
- </For>
1625
- </box>
1626
- </Show>
1627
- </box>
1628
- )
1629
- }
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { createEffect, createMemo, createSignal, For, on, Show, onCleanup, type JSX } from "solid-js"
3
+ import { useTerminalDimensions } from "@opentui/solid"
4
+ import type { BoxRenderable } from "@opentui/core"
5
+ import type { TuiPluginApi, TuiTheme } from "@opencode-ai/plugin/tui"
6
+ import {
7
+ emptyOmo,
8
+ listApprovals,
9
+ } from "../pware.oc.omo/resolver/index.js"
10
+ import {
11
+ delegatesForSession,
12
+ groupDelegates,
13
+ type RuntimeSnapshot,
14
+ } from "../pware.oc.runtime/resolver/index.js"
15
+ import {
16
+ MY_WORK_ORDER,
17
+ approvalContinueHint,
18
+ dropDismissed,
19
+ groupMyWork,
20
+ myWorkLabel,
21
+ toApprovalItems,
22
+ toDraftDocItems,
23
+ toPlanItems,
24
+ toPinnedItems,
25
+ toQuestionItems,
26
+ toSessionItems,
27
+ type MyWorkItem,
28
+ type MyWorkKind,
29
+ } from "../pware.oc.runtime/pware.oc.runtime.mywork.js"
30
+ import {
31
+ emptyDb,
32
+ emptyProjectFeed,
33
+ isRealSession,
34
+ mergeTools,
35
+ readProjectFeedAsync,
36
+ type ProjectFeed,
37
+ } from "../pware.oc.opencode/resolver/index.js"
38
+ import { type DocView } from "../pware.oc.omo/resolver/pware.oc.omo.resolver.doc.js"
39
+ import type { DelegateView } from "../pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.js"
40
+ import { enrichApprovalSessionStates } from "../pware.oc.runtime/pware.oc.runtime.mywork-enrich.js"
41
+ import { readPlanSession, readSessionDrafts } from "../pware.oc.runtime/pware.oc.runtime.omoRead.js"
42
+ import {
43
+ ROW_LINE_FALLBACK,
44
+ ROW_LINE_RESERVE,
45
+ ROW_MIN,
46
+ ROW_RANK,
47
+ clampScrollOffset,
48
+ RT_ACTION_COL_WIDTH,
49
+ packSections,
50
+ panelRows,
51
+ rowsForPlan,
52
+ scrollByStep,
53
+ } from "../pware.oc.core/pware.oc.core.layout.js"
54
+ import {
55
+ MARK_QUEUED,
56
+ PULSE_LIVE,
57
+ PULSE_STALE,
58
+ } from "../pware.oc.core/constants/pware.oc.core.constants.pulse.js"
59
+ import {
60
+ STATUS_ARCHIVED,
61
+ TOOL_STATUS_RUNNING,
62
+ } from "../pware.oc.core/constants/pware.oc.core.constants.status.js"
63
+ import {
64
+ SESSION_STATUS_ARCHIVED,
65
+ SESSION_STATUS_RUNNING,
66
+ } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.js"
67
+ import {
68
+ HOUR_MS,
69
+ sessionRowOpacity,
70
+ } from "../pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.js"
71
+ import {
72
+ ROW_KIND_AGENT,
73
+ ROW_KIND_DELEGATE,
74
+ ROW_KIND_FILE,
75
+ ROW_KIND_GROUP,
76
+ ROW_KIND_TOOL,
77
+ } from "../pware.oc.core/constants/pware.oc.core.constants.rowKind.js"
78
+ import {
79
+ QUESTION_KIND_ERROR,
80
+ QUESTION_KIND_INTERRUPTED,
81
+ QUESTION_KIND_QUESTION,
82
+ } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.js"
83
+ import {
84
+ MY_WORK_GROUP_DISMISSED,
85
+ MY_WORK_GROUP_DRAFT_DOCS,
86
+ MY_WORK_GROUP_DRAFTING,
87
+ MY_WORK_GROUP_FINISHED,
88
+ MY_WORK_GROUP_PINNED,
89
+ MY_WORK_GROUP_PLANS,
90
+ MY_WORK_GROUP_READY_REVIEW,
91
+ MY_WORK_GROUP_READY_START,
92
+ MY_WORK_GROUP_SESSIONS,
93
+ } from "../pware.oc.core/constants/pware.oc.core.constants.myWork.js"
94
+ import { DOC_KIND_DRAFT, DOC_KIND_PLAN } from "../pware.oc.omo/constants/pware.oc.omo.constants.docKind.js"
95
+ import {
96
+ GROUP_GLYPH,
97
+ TAB_NEUTRAL_GLYPH,
98
+ tabAttentionGlyph,
99
+ engageDone,
100
+ engageFill,
101
+ fileLetterGlyph,
102
+ markTone,
103
+ myWorkGlyph,
104
+ reviewStateSuffix,
105
+ spinnerFrame,
106
+ type GlyphSpec,
107
+ type TabAttentionItem,
108
+ } from "./pware.oc.ui.glyphs.js"
109
+ import { createPinnedSessions, dismissQuestion, kvRead, kvWrite, kvReadOne, kvWriteOne, readDismissedQuestions, ClickText, ContextActions, type ThemeColors } from "./pware.oc.ui.chrome.js"
110
+ import {
111
+ AgentLine,
112
+ FoldSection,
113
+ GroupSection,
114
+ RowList,
115
+ TabColumn,
116
+ OesStatusRow,
117
+ agentDisplayName,
118
+ useFold,
119
+ useReveal,
120
+ type RevealState,
121
+ type RowData,
122
+ } from "./pware.oc.ui.sections.js"
123
+ import { clipWidth } from "../pware.oc.core/pware.oc.core.width.js"
124
+ import { emptyPerf, readPerfSnapshot } from "../pware.oc.perf/pware.oc.perf.reader.js"
125
+ import { PerfPanel } from "../pware.oc.perf/pware.oc.perf.view.js"
126
+ import { shareBar } from "../pware.oc.perf/pware.oc.perf.charts.js"
127
+ import { StatRealtimeTimeline } from "../pware.oc.perf/pware.oc.perf.realtimeTimeline.js"
128
+ import { EventDriverSampler } from "../pware.oc.perf/pware.oc.perf.realtimeSampler.js"
129
+ import { readCpuRam } from "../pware.oc.perf/pware.oc.perf.realtimeCpuRam.js"
130
+ import { formatSelfLine, readRendererFps, readSelfStats, resetSelfStats, selfDiagActive, selfTime, setSelfFps } from "../pware.oc.perf/pware.oc.perf.self.js"
131
+ import {
132
+ decorateFiles,
133
+ fileFilter,
134
+ filesFromEvent,
135
+ mergeFiles,
136
+ sumDiff,
137
+ type FileView,
138
+ } from "../pware.oc.opencode/pware.oc.opencode.files.js"
139
+ import { onGitMarksChange } from "../pware.oc.core/git/pware.oc.core.git.js"
140
+ import { getOes } from "../pware.oc.core/pware.oc.core.oes.js"
141
+ import {
142
+ TAB_STATUS_SESSION_NOT_IN_DB,
143
+ tabStatus,
144
+ } from "../pware.oc.core/pware.oc.core.status.js"
145
+ import { createEventBus } from "../pware.oc.core/pware.oc.core.bus.js"
146
+ import { startRuntimeSource } from "../pware.oc.runtime/pware.oc.runtime.source.js"
147
+ import { openApprovalDialog, openDocDetail, openFileDetail, openFileListDialog, openQuestionDialog, openRealtimeCharts, openToolDetail } from "./pware.oc.ui.menudialogs.js"
148
+ import { startHostEventBridge } from "./pware.oc.ui.live.js"
149
+ import {
150
+ approvePlan,
151
+ openNewSessionPrompt,
152
+ openSessionSwitcher,
153
+ runStartWork,
154
+ selectSession,
155
+ } from "./pware.oc.ui.host.js"
156
+ import { dbg, debugActive, debugActiveDir, profile, profileActive, profileActiveDir, profileAsync, readScreenLines, subscribeScreenLines, writeProfileSummary } from "../pware.oc.core/pware.oc.core.debug.js"
157
+ import { getOpenCodeDbPath, samePath } from "../pware.oc.core/pware.oc.core.paths.js"
158
+ import {
159
+ FPS_READ_EVERY_TICKS,
160
+ GLYPH_TICK_MS,
161
+ MYWORK_BADGE_COOLDOWN_MS,
162
+ MYWORK_BADGE_MS,
163
+ NOW_MS,
164
+ SWITCH_TIMEOUT_MS,
165
+ TICK_MS,
166
+ TOKEN_RATE_WINDOW_MS,
167
+ } from "../pware.oc.core/pware.oc.core.timing.js"
168
+ import {
169
+ activeFlow,
170
+ applyFlow,
171
+ composeMark,
172
+ formatAge,
173
+ formatDuration,
174
+ hottestMark,
175
+ phaseAgeMs,
176
+ pulseAgeMs,
177
+ pushTokenTick,
178
+ tokenRate,
179
+ tokenRateBars,
180
+ toolFlow,
181
+ toolMark,
182
+ type AgentMark,
183
+ type FlowDir,
184
+ type FlowEntry,
185
+ type TokenTick,
186
+ type ToolHit,
187
+ } from "../pware.oc.core/pware.oc.core.pulse.js"
188
+ import {
189
+ EV_OES_REFRESH_MY_WORK,
190
+ EV_OES_SESSION_SELECT,
191
+ EV_OES_SNAPSHOT,
192
+ } from "../pware.oc.core/constants/pware.oc.core.constants.eventName.js"
193
+ import {
194
+ EV_OC_FILES_TOUCHED,
195
+ EV_OC_FLOW,
196
+ EV_OC_SESSION_ACTIVITY,
197
+ EV_OC_TOKENS_DELTA,
198
+ EV_OC_TOOL_HIT,
199
+ } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.js"
200
+
201
+ export type SidebarProps = {
202
+ sessionId: string
203
+ api: TuiPluginApi
204
+ theme: TuiTheme
205
+ }
206
+
207
+ const KV_FOLD_AGENTS = "oes.fold.agents"
208
+ const KV_FOLD_DELEGATES = "oes.fold.delegates"
209
+ const KV_FOLD_TOOLS = "oes.fold.tools"
210
+ const KV_FOLD_FILES = "oes.fold.files"
211
+ const KV_FOLD_DRAFTS = "oes.fold.drafts"
212
+ const KV_TAB = "oes.tab"
213
+
214
+ const OES_TABS = ["mywork", "current", "sessions", "perf"] as const
215
+ type OesTab = (typeof OES_TABS)[number]
216
+
217
+ const TAB_LABELS: Record<string, string> = {
218
+ mywork: "My work",
219
+ sessions: "Project",
220
+ current: "Session",
221
+ perf: "Stats",
222
+ }
223
+
224
+ /** Event rows the bottom debug console shows at once (the ring keeps 200). */
225
+ const SCREEN_CONSOLE_VISIBLE = 5
226
+
227
+ /**
228
+ * The cold-start "engage" bar (and its success toast) plays once per plugin
229
+ * boot. The module latch survives sidebar remounts across route changes so a
230
+ * session switch never replays the animation.
231
+ */
232
+ let engageSeen = false
233
+
234
+ function emptyRuntime(): RuntimeSnapshot {
235
+ const dbPath = getOpenCodeDbPath()
236
+ return {
237
+ generatedAt: 0,
238
+ fingerprint: "",
239
+ scanStamp: "0",
240
+ db: emptyDb(dbPath),
241
+ omo: emptyOmo(),
242
+ omoConfig: { present: false, path: null, teamMode: null, agents: [] },
243
+ delegates: [],
244
+ openQuestions: [],
245
+ }
246
+ }
247
+
248
+ export function SidebarPanel(props: SidebarProps): JSX.Element {
249
+ const [snap, setSnap] = createSignal<RuntimeSnapshot>(emptyRuntime())
250
+ const [now, setNow] = createSignal(Date.now())
251
+ const [frame, setFrame] = createSignal(0)
252
+ const [glyphFrame, setGlyphFrame] = createSignal(0)
253
+ const [seen, setSeen] = createSignal<Record<string, number>>({})
254
+ const [busy, setBusy] = createSignal<Record<string, boolean>>({})
255
+ const [flow, setFlow] = createSignal<Record<string, FlowEntry>>({})
256
+ const [tab, setTab] = createSignal<OesTab>(kvReadOne(props.api, KV_TAB, "current", OES_TABS))
257
+ const dimensions = useTerminalDimensions()
258
+ /**
259
+ * Row width in columns, measured from the root box's computed layout width.
260
+ * `ref` fires before the first layout (width 0), so the oes-independent
261
+ * fallback holds until `onSizeChange` reports a real width. A scrollbar
262
+ * reserve is subtracted; the value is the same budget `lineMax` used to be.
263
+ */
264
+ const [lineWidth, setLineWidth] = createSignal(ROW_LINE_FALLBACK)
265
+ let rootBox: BoxRenderable | undefined
266
+ const rootRef = (node: BoxRenderable): void => {
267
+ rootBox = node
268
+ node.onSizeChange = () => {
269
+ const w = node.width
270
+ if (Number.isFinite(w) && w > 0) setLineWidth(Math.max(ROW_LINE_FALLBACK, w - ROW_LINE_RESERVE))
271
+ }
272
+ }
273
+ const lineMax = (): number => lineWidth()
274
+ const [liveTools, setLiveTools] = createSignal<Record<string, ToolHit>>({})
275
+ const [liveFiles, setLiveFiles] = createSignal<Record<string, FileView>>({})
276
+ const [tokenTicks, setTokenTicks] = createSignal<readonly TokenTick[]>([])
277
+ const [rtVersion, setRtVersion] = createSignal(0)
278
+ const [gitTick, setGitTick] = createSignal(0)
279
+ const [switching, setSwitching] = createSignal<{ id: string; at: number } | null>(null)
280
+ const [coldTab, setColdTab] = createSignal<string | null>(null)
281
+ const [consoleLines, setConsoleLines] = createSignal(readScreenLines())
282
+ const [consoleTop, setConsoleTop] = createSignal(0)
283
+ const consoleWindow = createMemo(() => {
284
+ const lines = consoleLines()
285
+ const top = clampScrollOffset(lines.length, SCREEN_CONSOLE_VISIBLE, consoleTop())
286
+ return lines.slice(top, top + SCREEN_CONSOLE_VISIBLE)
287
+ })
288
+ /** Console rows the sidebar must leave for the debug block: label + visible lines. */
289
+ const consoleRowSpan = () =>
290
+ selfDiagActive() ? 1 + Math.min(consoleLines().length, SCREEN_CONSOLE_VISIBLE) : 0
291
+
292
+ const engageBootFrame = glyphFrame()
293
+ const [engaging, setEngaging] = createSignal(!engageSeen)
294
+ const engageTick = (): number => Math.max(0, glyphFrame() - engageBootFrame)
295
+
296
+ const colors = (): ThemeColors => props.theme.current as unknown as ThemeColors
297
+
298
+ const requestRender = () => {
299
+ try {
300
+ profile("requestRender", () => props.api.renderer.requestRender())
301
+ } catch {
302
+ // teardown
303
+ }
304
+ }
305
+
306
+ const offConsole = subscribeScreenLines(() => {
307
+ setConsoleLines(readScreenLines())
308
+ if (consoleTop() > 0) setConsoleTop((top) => top + 1)
309
+ requestRender()
310
+ })
311
+ onCleanup(offConsole)
312
+
313
+ const onConsoleScroll = (e: { scroll?: { direction?: "up" | "down" | "left" | "right"; delta?: number } }) => {
314
+ const dir = e.scroll?.direction
315
+ if (dir !== "up" && dir !== "down") return
316
+ const step = Math.max(1, Math.round(Math.abs(e.scroll?.delta ?? 1)) || 1)
317
+ setConsoleTop((top) => scrollByStep(consoleLines().length, SCREEN_CONSOLE_VISIBLE, top, dir, step))
318
+ }
319
+
320
+ const bumpSeen = (id: string | null | undefined) => {
321
+ if (!id) return
322
+ setSeen((prev) => ({ ...prev, [id]: Date.now() }))
323
+ }
324
+
325
+ const apply = (next: RuntimeSnapshot) => {
326
+ setSnap(next)
327
+ requestRender()
328
+ }
329
+
330
+ /** Session switch with the tab status row shown until the target snapshot lands. */
331
+ const goSession = (id: string | null | undefined) => {
332
+ if (!id) return
333
+ selectSession(props.api, id)
334
+ if (id === props.sessionId) return
335
+ setSwitching({ id, at: Date.now() })
336
+ }
337
+
338
+ const projectDir = () => props.api.state.path.directory ?? null
339
+ const projectRoots = () => {
340
+ const dir = projectDir()
341
+ const tree = props.api.state.path.worktree ?? null
342
+ return [dir, tree].filter((p): p is string => Boolean(p))
343
+ }
344
+
345
+ const bus = createEventBus()
346
+
347
+ // Pinned-sessions kv manager + the refresh tick that invalidates the My work
348
+ // memos whenever a pin/unpin fires the `refresh_my_work` event.
349
+ const pinnedSessions = createPinnedSessions(props.api)
350
+ const [pinnedTick, setPinnedTick] = createSignal(0)
351
+ const refreshMyWork = (): void => bus.emit({ type: EV_OES_REFRESH_MY_WORK, ts: Date.now() })
352
+ const onPinSession = (sessionId: string): void => {
353
+ pinnedSessions.pin(sessionId)
354
+ refreshMyWork()
355
+ }
356
+ const onUnpinSession = (sessionId: string): void => {
357
+ pinnedSessions.unpin(sessionId)
358
+ refreshMyWork()
359
+ }
360
+
361
+ let watchedId = props.sessionId
362
+ let source = startRuntimeSource({
363
+ bus,
364
+ sessionId: watchedId,
365
+ projectRoot: projectDir(),
366
+ })
367
+
368
+ const refresh = () => profile("scan", () => selfTime("scan", () => source.refresh()))
369
+
370
+ const ingestFiles = (hits: FileView[]) => {
371
+ if (!hits.length) return
372
+ setLiveFiles((prev) => {
373
+ const next = { ...prev }
374
+ for (const f of hits) next[f.id] = f
375
+ return next
376
+ })
377
+ }
378
+
379
+ const hydrateDiff = () =>
380
+ profile("hydrate", () => {
381
+ try {
382
+ const fn = props.api.client?.session?.diff
383
+ if (typeof fn !== "function") return
384
+ const take = (res: unknown) => {
385
+ const o = res && typeof res === "object" ? (res as Record<string, unknown>) : null
386
+ const rows = o && (Array.isArray(o.data) ? o.data : Array.isArray(o.diff) ? o.diff : null)
387
+ const list = rows ?? (Array.isArray(res) ? res : null)
388
+ if (!list) return
389
+ ingestFiles(
390
+ filesFromEvent(
391
+ { type: "session.diff", properties: { sessionID: props.sessionId, diff: list } },
392
+ props.sessionId,
393
+ fileFilter(projectDir()),
394
+ ),
395
+ )
396
+ }
397
+ void profileAsync("rpc.diff", () =>
398
+ Promise.resolve()
399
+ .then(() => fn({ sessionID: props.sessionId }))
400
+ .then(take)
401
+ .catch(() => {}),
402
+ )
403
+ } catch {
404
+ // host client without session.diff
405
+ }
406
+ })
407
+
408
+ /** Rebind to the session the props point at. A stale select event only nudges a refresh. */
409
+ const remount = () =>
410
+ profile("remount", () => {
411
+ const id = props.sessionId
412
+ if (id === watchedId) {
413
+ refresh()
414
+ return
415
+ }
416
+ resetSelfStats()
417
+ watchedId = id
418
+ setLiveTools({})
419
+ setLiveFiles({})
420
+ setTokenTicks([])
421
+ source.setSession(id)
422
+ queueMicrotask(hydrateDiff)
423
+ })
424
+
425
+ createEffect(on(() => props.sessionId, remount, { defer: true }))
426
+
427
+ queueMicrotask(hydrateDiff)
428
+ // The monitor's initial forced emit (startRuntimeSource → startMonitor) runs
429
+ // before the EV_OES_SNAPSHOT listener below is registered, so the bus drops
430
+ // it. Re-emit once now that listeners exist — the snapshot was already
431
+ // computed and cached, so this is a cache hit, not a second full read.
432
+ queueMicrotask(refresh)
433
+
434
+ const offSnapshot = bus.on(EV_OES_SNAPSHOT, (evt) => {
435
+ const data = evt.data
436
+ if (!data || typeof data !== "object") return
437
+ const snapshot = (data as Record<string, unknown>).snapshot
438
+ if (!snapshot || typeof snapshot !== "object") return
439
+ apply(snapshot as RuntimeSnapshot)
440
+ const s = switching()
441
+ if (s) {
442
+ const rs = snapshot as RuntimeSnapshot
443
+ const landed = rs.db?.current?.id === s.id
444
+ const failed = Boolean(rs.db?.error) && rs.db?.error !== TAB_STATUS_SESSION_NOT_IN_DB
445
+ if (landed || failed || Date.now() - s.at > SWITCH_TIMEOUT_MS) setSwitching(null)
446
+ }
447
+ })
448
+
449
+ const offSessionActivity = bus.on(EV_OC_SESSION_ACTIVITY, (evt) => {
450
+ const data = evt.data
451
+ if (!data || typeof data !== "object") return
452
+ const payload = data as Record<string, unknown>
453
+ const busyFlag = payload.busy
454
+ if (typeof busyFlag !== "boolean") return
455
+ const id = typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : props.sessionId
456
+ if (busyFlag) bumpSeen(id)
457
+ setBusy((prev) => ({ ...prev, [id]: busyFlag }))
458
+ })
459
+
460
+ const offFlow = bus.on(EV_OC_FLOW, (evt) => {
461
+ const data = evt.data
462
+ if (!data || typeof data !== "object") return
463
+ const payload = data as Record<string, unknown>
464
+ const id = typeof payload.sessionId === "string" ? payload.sessionId : ""
465
+ const dir = payload.dir
466
+ if (!id || (dir !== "recv" && dir !== "wait" && dir !== "tool" && dir !== "clear")) return
467
+ if (dir !== "clear") bumpSeen(id)
468
+ const at = Date.now()
469
+ setFlow((prev) => applyFlow(prev, id, dir, at))
470
+ })
471
+
472
+ const offToolHit = bus.on(EV_OC_TOOL_HIT, (evt) => {
473
+ const data = evt.data
474
+ if (!data || typeof data !== "object") return
475
+ const payload = data as Record<string, unknown>
476
+ const hit = payload.hit
477
+ if (!hit || typeof hit !== "object") return
478
+ const h = hit as { id?: unknown }
479
+ if (typeof h.id !== "string" || !h.id) return
480
+ const toolHit = hit as ToolHit
481
+ bumpSeen(toolHit.sessionId ?? props.sessionId)
482
+ setLiveTools((prev) => ({ ...prev, [toolHit.id]: toolHit }))
483
+ })
484
+
485
+ const offFilesTouched = bus.on(EV_OC_FILES_TOUCHED, (evt) => {
486
+ const data = evt.data
487
+ if (!data || typeof data !== "object") return
488
+ const payload = data as Record<string, unknown>
489
+ const files = payload.files
490
+ if (!Array.isArray(files)) return
491
+ const sessionId = typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : props.sessionId
492
+ bumpSeen(sessionId)
493
+ ingestFiles(files as FileView[])
494
+ })
495
+
496
+ const offTokensDelta = bus.on(EV_OC_TOKENS_DELTA, (evt) => {
497
+ const data = evt.data
498
+ if (!data || typeof data !== "object") return
499
+ const payload = data as Record<string, unknown>
500
+ const tokens = payload.tokens
501
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0) return
502
+ const at = Date.now()
503
+ setTokenTicks((prev) => pushTokenTick(prev, at, tokens, TOKEN_RATE_WINDOW_MS))
504
+ const sessionId = typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : props.sessionId
505
+ const kind = payload.kind === "reasoning" ? "reasoning" : "out"
506
+ rtTimeline.ingestEstimate(sessionId, kind, tokens, at)
507
+ })
508
+
509
+ const offSessionSelect = bus.on(EV_OES_SESSION_SELECT, () => remount())
510
+
511
+ const offRefreshMyWork = bus.on(EV_OES_REFRESH_MY_WORK, () => {
512
+ setPinnedTick((n) => n + 1)
513
+ requestRender()
514
+ })
515
+
516
+ const bridge = startHostEventBridge({
517
+ api: props.api,
518
+ bus,
519
+ sessionId: () => props.sessionId,
520
+ projectRoot: projectDir,
521
+ onRender: requestRender,
522
+ })
523
+
524
+ // One shared realtime timeline (tokens / cache / CPU / RAM). Exact cumulative
525
+ // totals arrive on session.updated; estimated stream deltas arrive on the bus;
526
+ // the UI tick below folds everything into a grid sample every TICK_MS, so the
527
+ // chart's right edge always reaches now instead of waiting for an event.
528
+ const rtTimeline = StatRealtimeTimeline.build(null)
529
+ const rtSampler = EventDriverSampler.create(rtTimeline, (handler) => {
530
+ try {
531
+ const on = props.api.event.on as (
532
+ name: string,
533
+ cb: (evt: unknown, meta?: { directory?: unknown }) => void,
534
+ ) => unknown
535
+ const off = on("session.updated", (evt, meta) => {
536
+ const dir = projectDir()
537
+ if (dir && meta?.directory && !samePath(String(meta.directory), dir)) return
538
+ handler(evt)
539
+ })
540
+ return typeof off === "function" ? () => off() : () => {}
541
+ } catch {
542
+ return () => {}
543
+ }
544
+ })
545
+ rtSampler.start()
546
+
547
+ let tickCount = 0
548
+ const tick = setInterval(() => {
549
+ profile("tick", () => {
550
+ selfTime("tick", () => {
551
+ // Coarse: ages/marks recompute at most once a second; the spinner phase
552
+ // (`frame`) still animates every tick. Returning `prev` skips the cascade.
553
+ setNow((prev) => {
554
+ const n = Date.now()
555
+ return n - prev >= NOW_MS ? n : prev
556
+ })
557
+ setFrame((n) => n + 1)
558
+ // Realtime grid: CPU/RAM are read here and the timeline folds one sample.
559
+ const at = Date.now()
560
+ rtTimeline.ingestCpuRam(readCpuRam(), at)
561
+ rtTimeline.tick(at)
562
+ setRtVersion((n) => n + 1)
563
+ })
564
+ tickCount += 1
565
+ if (selfDiagActive() && tickCount % FPS_READ_EVERY_TICKS === 0) {
566
+ const r = readRendererFps(props.api.renderer)
567
+ setSelfFps(r.fps, r.frameMs)
568
+ }
569
+ })
570
+ // Periodic floor for the My-work tab light — outside `profile("tick")` so
571
+ // the scan never pollutes the tick budget; snapshot changes rescan sooner.
572
+ maybeScanBadge(MYWORK_BADGE_MS)
573
+ }, TICK_MS)
574
+
575
+ /** Fast glyph heartbeat — spinners and direction flows step at `GLYPH_TICK_MS`. */
576
+ const glyphTick = setInterval(() => setGlyphFrame((n) => n + 1), GLYPH_TICK_MS)
577
+
578
+ const offGit = onGitMarksChange(() => {
579
+ profile("git", () => {
580
+ setGitTick((n) => n + 1)
581
+ queueMicrotask(requestRender)
582
+ })
583
+ })
584
+
585
+ createEffect(() => {
586
+ profile("render", () => {
587
+ frame()
588
+ now()
589
+ flow()
590
+ liveTools()
591
+ liveFiles()
592
+ queueMicrotask(requestRender)
593
+ })
594
+ })
595
+
596
+ createEffect(() => {
597
+ profile("render", () => {
598
+ glyphFrame()
599
+ queueMicrotask(requestRender)
600
+ })
601
+ })
602
+
603
+ // Cold tabs show their loading row for one short beat, then compute for real.
604
+ createEffect(() => {
605
+ const t = coldTab()
606
+ if (!t) return
607
+ const timer = setTimeout(() => setColdTab(null), 100)
608
+ onCleanup(() => clearTimeout(timer))
609
+ })
610
+
611
+ // End the boot bar once the first real snapshot landed after the minimum
612
+ // show time (or the hard ceiling) — then toast exactly once per boot.
613
+ createEffect(() => {
614
+ if (!engaging()) return
615
+ if (engageDone(engageTick(), Boolean(snap().fingerprint))) {
616
+ setEngaging(false)
617
+ engageSeen = true
618
+ try {
619
+ props.api.ui.toast({
620
+ message: "OpenCode Extended Sidebar Loaded. Engage!",
621
+ variant: "success",
622
+ duration: 7000,
623
+ })
624
+ } catch {
625
+ // host without toast
626
+ }
627
+ requestRender()
628
+ }
629
+ })
630
+
631
+ onCleanup(() => {
632
+ clearInterval(tick)
633
+ clearInterval(glyphTick)
634
+ rtSampler.stop()
635
+ bridge.stop()
636
+ offSnapshot()
637
+ offSessionActivity()
638
+ offFlow()
639
+ offToolHit()
640
+ offFilesTouched()
641
+ offTokensDelta()
642
+ offSessionSelect()
643
+ offRefreshMyWork()
644
+ source.stop()
645
+ offGit()
646
+ writeProfileSummary()
647
+ })
648
+
649
+ const rowMark = (
650
+ lifecycle: string | null | undefined,
651
+ archived: boolean,
652
+ isBusy: boolean,
653
+ ...stamps: Array<number | null | undefined>
654
+ ): AgentMark =>
655
+ composeMark({
656
+ lifecycle,
657
+ archived,
658
+ busy: isBusy,
659
+ ageMs: pulseAgeMs(now(), ...stamps),
660
+ })
661
+
662
+ const mainMark = createMemo(() => {
663
+ const m = snap().db.main
664
+ if (!m) return MARK_QUEUED
665
+ return rowMark(
666
+ m.status === SESSION_STATUS_ARCHIVED ? STATUS_ARCHIVED : null,
667
+ m.status === SESSION_STATUS_ARCHIVED,
668
+ Boolean(busy()[m.id]),
669
+ m.timeUpdated,
670
+ seen()[m.id],
671
+ )
672
+ })
673
+
674
+ const rowFlow = (id: string | null | undefined, isBusy: boolean): FlowDir | null =>
675
+ activeFlow(id ? flow()[id] : undefined, now(), isBusy)
676
+
677
+ const mainFlow = createMemo(() => {
678
+ const m = snap().db.main
679
+ return rowFlow(m?.id, Boolean(m && busy()[m.id]))
680
+ })
681
+
682
+ const currentRow = createMemo(() => snap().db.current)
683
+
684
+ const currentMark = createMemo(() => {
685
+ const c = currentRow()
686
+ if (!c) return MARK_QUEUED
687
+ return rowMark(
688
+ c.status === SESSION_STATUS_ARCHIVED ? STATUS_ARCHIVED : null,
689
+ c.status === SESSION_STATUS_ARCHIVED,
690
+ Boolean(busy()[c.id]),
691
+ c.timeUpdated,
692
+ seen()[c.id],
693
+ )
694
+ })
695
+
696
+ const currentFlow = createMemo(() => {
697
+ const c = currentRow()
698
+ return rowFlow(c?.id, Boolean(c && busy()[c.id]))
699
+ })
700
+
701
+ const sessionDelegates = createMemo(() => delegatesForSession(snap(), props.sessionId))
702
+
703
+ const delegateMark = (d: DelegateView): AgentMark =>
704
+ rowMark(
705
+ d.status,
706
+ d.archived,
707
+ Boolean(d.sessionId && busy()[d.sessionId]),
708
+ d.timeUpdated,
709
+ d.sessionId ? seen()[d.sessionId] : null,
710
+ )
711
+
712
+ const err = createMemo(() => {
713
+ const e = snap().db.error
714
+ if (!e || e === "session not in db yet") return null
715
+ return e
716
+ })
717
+
718
+ const delegatesLive = createMemo(() => {
719
+ let n = 0
720
+ for (const d of snap().delegates) {
721
+ const m = delegateMark(d)
722
+ if (m === PULSE_LIVE || m === PULSE_STALE) n += 1
723
+ }
724
+ return n
725
+ })
726
+
727
+ const foldAgents = useFold(props.api, KV_FOLD_AGENTS, { after: requestRender })
728
+ const foldDelegates = useFold(props.api, KV_FOLD_DELEGATES, { after: requestRender })
729
+ const foldTools = useFold(props.api, KV_FOLD_TOOLS, { after: requestRender })
730
+ const foldFiles = useFold(props.api, KV_FOLD_FILES, { after: requestRender })
731
+ const foldDrafts = useFold(props.api, KV_FOLD_DRAFTS, { after: requestRender })
732
+
733
+ const myWorkFold = {} as Record<MyWorkKind, ReturnType<typeof useFold>>
734
+ for (const kind of MY_WORK_ORDER) {
735
+ // Errors and Dismissed questions are noise until you want them — start collapsed.
736
+ myWorkFold[kind] = useFold(props.api, `oes.fold.mywork.${kind}`, {
737
+ after: requestRender,
738
+ defaultOpen: kind !== QUESTION_KIND_ERROR && kind !== MY_WORK_GROUP_DISMISSED,
739
+ })
740
+ }
741
+
742
+ // Reveal state is hoisted, not owned by GroupSection: the My work memos
743
+ // read the `now()` clock, so <For> reconciles them to fresh objects
744
+ // every second a reveal signal created inside the component would be
745
+ // thrown away with it and the "… +N more" click would collapse on its own.
746
+ const myWorkReveal = {} as Record<MyWorkKind, RevealState>
747
+ for (const kind of MY_WORK_ORDER) {
748
+ myWorkReveal[kind] = useReveal(2)
749
+ }
750
+
751
+ const oes = () => getOes(projectDir())
752
+ const tools = createMemo(() => mergeTools(snap().db.tools, liveTools(), now(), oes().toolFetch))
753
+
754
+ /**
755
+ * Project-wide feed tools/files from every main session. The DB queries run
756
+ * only while the Sessions tab is open; elsewhere the memo stays empty.
757
+ */
758
+ const [projectFeed, setProjectFeed] = createSignal<ProjectFeed>(emptyProjectFeed())
759
+ let projectFeedGeneration = 0
760
+ createEffect(() => {
761
+ if (tab() !== "sessions" || coldTab() === "sessions") {
762
+ projectFeedGeneration += 1
763
+ setProjectFeed(emptyProjectFeed())
764
+ return
765
+ }
766
+ const db = snap().db
767
+ const generation = ++projectFeedGeneration
768
+ void readProjectFeedAsync({
769
+ dbPath: db.dbPath,
770
+ sessionIds: db.recent.map((s) => s.id),
771
+ toolLimit: oes().toolFetch,
772
+ filter: fileFilter(projectDir()),
773
+ }).then((feed) => {
774
+ if (generation === projectFeedGeneration) setProjectFeed(feed)
775
+ })
776
+ })
777
+ const projectFilesStat = createMemo(() => sumDiff(projectFeed().files))
778
+
779
+ const filesAll = createMemo(() => {
780
+ gitTick()
781
+ return decorateFiles(mergeFiles(snap().db.files ?? [], liveFiles()), projectDir(), {
782
+ git: tab() === "current" && foldFiles.open(),
783
+ })
784
+ })
785
+ const filesStat = createMemo(() => sumDiff(filesAll()))
786
+
787
+ const pickTab = (next: string) => {
788
+ if (!(OES_TABS as readonly string[]).includes(next)) return
789
+ const picked = next as OesTab
790
+ if (picked !== tab()) {
791
+ setTab(picked)
792
+ kvWriteOne(props.api, KV_TAB, picked)
793
+ // Cold start: the tab's heavy reads run after the status row shows, so
794
+ // the loading line is actually visible instead of a one-frame flash.
795
+ if (picked === "mywork" || picked === "sessions" || picked === "perf") {
796
+ setColdTab(picked)
797
+ }
798
+ }
799
+ if (picked === "current" || picked === "perf") refresh()
800
+ requestRender()
801
+ }
802
+
803
+ const omoPresent = createMemo(() => snap().omo.present)
804
+
805
+ /** Drafts under `.omo/drafts/` that this session wrote — last five inline, the full list behind "view all". */
806
+ const [draftsAll, setDraftsAll] = createSignal<DocView[]>([])
807
+ let draftsGeneration = 0
808
+ createEffect(() => {
809
+ if (tab() !== "current" || !omoPresent() || !props.sessionId) {
810
+ draftsGeneration += 1
811
+ setDraftsAll([])
812
+ return
813
+ }
814
+ now() // re-scan while open; the docs cache TTL gates the filesystem read
815
+ const generation = ++draftsGeneration
816
+ void readSessionDrafts({
817
+ dbPath: snap().db.dbPath,
818
+ projectRoot: projectDir(),
819
+ sessionId: props.sessionId,
820
+ }).then((drafts) => {
821
+ if (generation === draftsGeneration) setDraftsAll(drafts)
822
+ })
823
+ })
824
+ const drafts = createMemo(() => draftsAll().slice(0, 5))
825
+
826
+ /** Open `question` tools anywhere in this project — the "answer me" queue. */
827
+ const myWorkQuestions = createMemo<MyWorkItem[]>(() => {
828
+ if (tab() !== "mywork" || coldTab() === "mywork") return []
829
+ try {
830
+ return dropDismissed(
831
+ toQuestionItems(snap().openQuestions),
832
+ readDismissedQuestions(props.api),
833
+ )
834
+ } catch (e) {
835
+ dbg("mywork.questions", "error", String(e))
836
+ return []
837
+ }
838
+ })
839
+
840
+ /** OMO plans/drafts in the action groups, enriched with planner-session state, plus the Draft docs archive. */
841
+ const [myWorkApprovals, setMyWorkApprovals] = createSignal<MyWorkItem[]>([])
842
+ let approvalGeneration = 0
843
+ createEffect(() => {
844
+ if (tab() !== "mywork" || coldTab() === "mywork" || !omoPresent()) {
845
+ approvalGeneration += 1
846
+ setMyWorkApprovals([])
847
+ return
848
+ }
849
+ now() // re-scan while open; the plans cache TTL gates the filesystem read
850
+ const dir = projectDir()
851
+ try {
852
+ const buckets = listApprovals(dir)
853
+ const generation = ++approvalGeneration
854
+ void enrichApprovalSessionStates(
855
+ [
856
+ ...buckets.readyReview,
857
+ ...buckets.readyStart,
858
+ ...buckets.finished,
859
+ ...buckets.drafting,
860
+ ],
861
+ {
862
+ dbPath: snap().db.dbPath,
863
+ projectRoot: dir,
864
+ },
865
+ ).then((approvals) => {
866
+ if (generation !== approvalGeneration) return
867
+ setMyWorkApprovals([
868
+ ...toApprovalItems(approvals),
869
+ ...toDraftDocItems(buckets.draftDocs),
870
+ ...toPlanItems(buckets.plans),
871
+ ])
872
+ })
873
+ } catch (e) {
874
+ dbg("mywork.approvals", "error", String(e))
875
+ setMyWorkApprovals([])
876
+ }
877
+ })
878
+
879
+ /** Pinned session ids, re-read from kv whenever a pin/unpin fires the refresh event. */
880
+ const pinnedIds = createMemo<string[]>(() => {
881
+ pinnedTick()
882
+ return pinnedSessions.list()
883
+ })
884
+
885
+ /** Pinned sessions (full rows), resolved from the pinned ids against the recent-session list. */
886
+ const myWorkPinned = createMemo<MyWorkItem[]>(() => {
887
+ if (tab() !== "mywork" || coldTab() === "mywork") return []
888
+ return toPinnedItems(pinnedIds(), snap().db.recent)
889
+ })
890
+
891
+ /** Recent main sessions (running and idle), minus the pinned ones — the "Sessions" group. */
892
+ const myWorkRunning = createMemo<MyWorkItem[]>(() => {
893
+ if (tab() !== "mywork" || coldTab() === "mywork") return []
894
+ const pinned = new Set(pinnedIds())
895
+ return toSessionItems(snap().db.recent.filter((s) => !pinned.has(s.id)))
896
+ })
897
+
898
+ const myWorkItems = createMemo<MyWorkItem[]>(() => [
899
+ ...myWorkPinned(),
900
+ ...myWorkQuestions(),
901
+ ...myWorkRunning(),
902
+ ...myWorkApprovals(),
903
+ ])
904
+
905
+ const myWorkGroups = createMemo(() => groupMyWork(myWorkItems()))
906
+
907
+ /** My-work tab light — one scan pipeline for every tab (see `maybeScanBadge`). */
908
+ const [myWorkAttn, setMyWorkAttn] = createSignal<readonly TabAttentionItem[]>([])
909
+ let lastMyWorkScan = 0
910
+
911
+ const scanMyWorkBadge = () => {
912
+ const items: TabAttentionItem[] = []
913
+ try {
914
+ const dismissed = readDismissedQuestions(props.api)
915
+ const open = snap().openQuestions
916
+ for (const q of open) {
917
+ if (dismissed.has(q.partId)) continue
918
+ items.push({ kind: q.kind, ended: q.ended })
919
+ }
920
+ } catch (e) {
921
+ dbg("mywork.badge", "error", String(e))
922
+ }
923
+ if (omoPresent()) {
924
+ try {
925
+ const buckets = listApprovals(projectDir())
926
+ if (buckets.readyReview.length > 0) items.push({ kind: MY_WORK_GROUP_READY_REVIEW })
927
+ if (buckets.readyStart.length > 0) items.push({ kind: MY_WORK_GROUP_READY_START })
928
+ } catch (e) {
929
+ dbg("mywork.badge", "error", String(e))
930
+ }
931
+ }
932
+ setMyWorkAttn(items)
933
+ }
934
+
935
+ const maybeScanBadge = (minGapMs: number) => {
936
+ const at = Date.now()
937
+ if (at - lastMyWorkScan < minGapMs) return
938
+ lastMyWorkScan = at
939
+ scanMyWorkBadge()
940
+ }
941
+
942
+ const myWorkTabGlyph = createMemo<GlyphSpec>(() => tabAttentionGlyph(myWorkAttn()))
943
+
944
+ createEffect(() => {
945
+ snap()
946
+ maybeScanBadge(MYWORK_BADGE_COOLDOWN_MS)
947
+ })
948
+
949
+ /**
950
+ * Rows are handed out on every render: chrome that always costs a line is
951
+ * counted first, then `packSections` splits what is left.
952
+ */
953
+ const rowPlan = createMemo(() => {
954
+ try {
955
+ const o = oes()
956
+ const t = tab()
957
+ const omo = omoPresent()
958
+ const sections: { key: string; want: number; min: number; rank: number }[] = []
959
+ let fixed = 2 // OES status line + brand/tabs row
960
+ if (selfDiagActive()) fixed += 1 // self line — only while debug/profile is on
961
+ if (modeLine()) fixed += 1 // debug/profile flag row
962
+ if (modeDirLine()) fixed += 1
963
+ if (engaging()) fixed += 1 // cold-start engage bar row
964
+ fixed += consoleRowSpan() // bottom dbg console — label + visible event rows
965
+ let blocks = 0
966
+
967
+ const header = () => {
968
+ blocks += 1
969
+ fixed += 1
970
+ }
971
+ const section = (folded: boolean, key: string, want: number, min: number, rank: number) => {
972
+ header()
973
+ if (!folded) sections.push({ key, want, min, rank })
974
+ }
975
+
976
+ if (t === "sessions") {
977
+ header()
978
+ fixed += foldAgents.open() ? 1 : 0
979
+ const feed = projectFeed()
980
+ if (feed.tools.length > 0) section(!foldTools.open(), "tools", o.toolRows, ROW_MIN.tools, ROW_RANK.tools)
981
+ if (feed.files.length > 0) section(!foldFiles.open(), "files", o.fileRows, ROW_MIN.files, ROW_RANK.files)
982
+ if (omo) section(!foldDelegates.open(), "delegates", 6, ROW_MIN.delegates, ROW_RANK.delegates)
983
+ } else if (t === "current") {
984
+ header()
985
+ fixed += foldAgents.open() ? 1 : 0
986
+ section(!foldDelegates.open(), "delegates", 6, ROW_MIN.delegates, ROW_RANK.delegates)
987
+ section(!foldTools.open(), "tools", o.toolRows, ROW_MIN.tools, ROW_RANK.tools)
988
+ section(!foldFiles.open(), "files", o.fileRows, ROW_MIN.files, ROW_RANK.files)
989
+ if (omo && draftsAll().length > 0) {
990
+ section(!foldDrafts.open(), "drafts", Math.min(5, draftsAll().length), ROW_MIN.drafts, ROW_RANK.drafts)
991
+ }
992
+ } else if (t === "mywork") {
993
+ header()
994
+ for (const g of myWorkGroups()) {
995
+ const fold = myWorkFold[g.kind]
996
+ if (!fold) continue
997
+ section(!fold.open(), `mywork.${g.kind}`, g.items.length, g.items.length > 0 ? ROW_MIN.mywork : 0, ROW_RANK.mywork)
998
+ }
999
+ } else {
1000
+ // Perf lays out its own sections and caps itself with `perfRows` in
1001
+ // its own view; no row-plan sections are handed out here.
1002
+ }
1003
+ fixed += Math.max(0, blocks - 1) // one blank row between OES sections
1004
+
1005
+ const height = dimensions()?.height
1006
+ return packSections(panelRows(typeof height === "number" ? height : 24), fixed, sections)
1007
+ } catch (e) {
1008
+ dbg("rowplan", "error", String(e))
1009
+ return {}
1010
+ }
1011
+ }, {})
1012
+
1013
+ const rowsFor = (key: string, fallback: number): number => {
1014
+ try {
1015
+ return rowsForPlan(rowPlan(), key, fallback)
1016
+ } catch {
1017
+ return fallback
1018
+ }
1019
+ }
1020
+
1021
+ const projectToolsReveal = useReveal(Math.max(1, oes().toolRows))
1022
+ const toolsReveal = useReveal(Math.max(1, oes().toolRows))
1023
+ const delegateReveal = useReveal(4)
1024
+ const currentDelegatesReveal = useReveal(4)
1025
+
1026
+ /**
1027
+ * A browsable OMO document (Draft docs / Plans archive) opens the same
1028
+ * picker the action rows get: Navigate to session first (the session that
1029
+ * wrote the file), then the preview row. No Approve — an archive row is
1030
+ * never a sign-off queue. Start work is offered only for the Plans
1031
+ * archive (via the `startWork` flag); Draft docs stays browse-only.
1032
+ */
1033
+ const openDocPicker = async (item: { name: string; rel: string }, doc: DocView, docsLabel: string, startWork: boolean = false) => {
1034
+ const planSession = await readPlanSession({ dbPath: snap().db.dbPath, rel: item.rel })
1035
+ const sessionId = planSession.sessionId
1036
+ openApprovalDialog(props.api, {
1037
+ title: item.name,
1038
+ sessionId,
1039
+ continueHint: approvalContinueHint(sessionId, planSession.dbAvailable),
1040
+ onContinue: (sid) => selectSession(props.api, sid),
1041
+ onStartWork: (mode) => runStartWork(props.api, props.sessionId, mode, item.name),
1042
+ onDocs: () => openDocDetail(props.api, doc, projectRoots(), colors()),
1043
+ showApprove: false,
1044
+ showStartWork: startWork,
1045
+ docsLabel,
1046
+ })
1047
+ }
1048
+
1049
+ const myWorkRow = (item: MyWorkItem): RowData => {
1050
+ if (item.kind === MY_WORK_GROUP_SESSIONS || item.kind === MY_WORK_GROUP_PINNED) {
1051
+ const isBusy = Boolean(item.sessionId && busy()[item.sessionId])
1052
+ const keep =
1053
+ item.sessionId === props.sessionId ||
1054
+ item.sessionId === snap().db.main?.id ||
1055
+ item.status === SESSION_STATUS_RUNNING
1056
+ const ageMs = item.timeUpdated == null ? null : now() - item.timeUpdated
1057
+ const opacity =
1058
+ item.kind === MY_WORK_GROUP_SESSIONS
1059
+ ? sessionRowOpacity(
1060
+ ageMs,
1061
+ oes().sessionDimHours * HOUR_MS,
1062
+ oes().sessionVisibleHours * HOUR_MS,
1063
+ keep,
1064
+ )
1065
+ : undefined
1066
+ return {
1067
+ kind: ROW_KIND_AGENT,
1068
+ mark: rowMark(
1069
+ item.status === SESSION_STATUS_ARCHIVED ? STATUS_ARCHIVED : null,
1070
+ item.status === SESSION_STATUS_ARCHIVED,
1071
+ isBusy,
1072
+ item.timeUpdated,
1073
+ item.sessionId ? seen()[item.sessionId] : null,
1074
+ ),
1075
+ dirSlot: true,
1076
+ flow: rowFlow(item.sessionId, isBusy),
1077
+ name: item.title,
1078
+ current: item.sessionId === props.sessionId,
1079
+ suffix: item.sessionId === props.sessionId ? "[C]" : undefined,
1080
+ opacity,
1081
+ onSelect: () => goSession(item.sessionId),
1082
+ link:
1083
+ item.kind === MY_WORK_GROUP_SESSIONS
1084
+ ? { label: "P", onPick: () => onPinSession(item.sessionId) }
1085
+ : { label: "U", onPick: () => onUnpinSession(item.sessionId) },
1086
+ }
1087
+ }
1088
+ if (item.kind === MY_WORK_GROUP_DRAFT_DOCS) {
1089
+ const doc: DocView = {
1090
+ kind: DOC_KIND_DRAFT,
1091
+ name: item.name,
1092
+ rel: item.rel,
1093
+ updatedAt: item.updatedAt,
1094
+ sizeBytes: 0,
1095
+ previewable: true,
1096
+ }
1097
+ return {
1098
+ kind: ROW_KIND_FILE,
1099
+ glyph: myWorkGlyph(item.kind),
1100
+ name: item.name,
1101
+ onSelect: () => void openDocPicker(item, doc, "Preview draft file"),
1102
+ }
1103
+ }
1104
+ if (item.kind === MY_WORK_GROUP_PLANS) {
1105
+ const doc: DocView = {
1106
+ kind: DOC_KIND_PLAN,
1107
+ name: item.name,
1108
+ rel: item.rel,
1109
+ updatedAt: item.updatedAt,
1110
+ sizeBytes: 0,
1111
+ previewable: true,
1112
+ }
1113
+ return {
1114
+ kind: ROW_KIND_FILE,
1115
+ glyph: myWorkGlyph(item.kind),
1116
+ name: item.name,
1117
+ onSelect: () => void openDocPicker(item, doc, "Preview plan file", true),
1118
+ }
1119
+ }
1120
+ if ("sessionId" in item) {
1121
+ const dismissible = item.kind === QUESTION_KIND_INTERRUPTED || item.kind === QUESTION_KIND_ERROR
1122
+ return {
1123
+ kind: ROW_KIND_AGENT,
1124
+ glyph: myWorkGlyph(item.kind),
1125
+ name: item.title,
1126
+ subline: item.reason ?? undefined,
1127
+ onSelect: dismissible
1128
+ ? () =>
1129
+ openQuestionDialog(props.api, {
1130
+ title: item.title,
1131
+ sessionId: item.sessionId,
1132
+ onNavigate: (sid) => goSession(sid),
1133
+ onDismiss: () => {
1134
+ dismissQuestion(props.api, item.partId)
1135
+ requestRender()
1136
+ },
1137
+ })
1138
+ : () => goSession(item.sessionId),
1139
+ }
1140
+ }
1141
+ const reviewLabel = reviewStateSuffix(item.review)
1142
+ const drafting = item.kind === MY_WORK_GROUP_DRAFTING
1143
+ const showApprove = item.kind === MY_WORK_GROUP_READY_REVIEW
1144
+ const showStartWork =
1145
+ item.kind === MY_WORK_GROUP_READY_REVIEW || item.kind === MY_WORK_GROUP_READY_START
1146
+ const doc: DocView = {
1147
+ kind: DOC_KIND_DRAFT,
1148
+ name: item.name,
1149
+ rel: item.rel,
1150
+ updatedAt: item.updatedAt,
1151
+ sizeBytes: 0,
1152
+ previewable: true,
1153
+ }
1154
+ return {
1155
+ kind: ROW_KIND_FILE,
1156
+ glyph: myWorkGlyph(item.kind),
1157
+ name: item.name,
1158
+ suffix: reviewLabel || undefined,
1159
+ onSelect: () => {
1160
+ void readPlanSession({ dbPath: snap().db.dbPath, rel: item.rel }).then((planSession) => {
1161
+ const sessionId = planSession.sessionId
1162
+ const base: Parameters<typeof openApprovalDialog>[1] = {
1163
+ title: item.name,
1164
+ sessionId,
1165
+ continueHint: approvalContinueHint(sessionId, planSession.dbAvailable),
1166
+ onContinue: (sid) => selectSession(props.api, sid),
1167
+ onApprove: (sid) => approvePlan(props.api, sid),
1168
+ onStartWork: (mode) => runStartWork(props.api, props.sessionId, mode, item.name),
1169
+ onDocs: () => openDocDetail(props.api, doc, projectRoots(), colors()),
1170
+ }
1171
+ if (drafting) {
1172
+ openApprovalDialog(props.api, {
1173
+ ...base,
1174
+ showApprove: false,
1175
+ showStartWork: false,
1176
+ docsLabel: "Preview plan file",
1177
+ })
1178
+ return
1179
+ }
1180
+ openApprovalDialog(props.api, { ...base, showApprove, showStartWork })
1181
+ })
1182
+ },
1183
+ }
1184
+ }
1185
+
1186
+ /** Perf SQLite scan runs only while this tab is open. */
1187
+ const [perf, setPerf] = createSignal(emptyPerf(props.sessionId))
1188
+ let perfGeneration = 0
1189
+ createEffect(() => {
1190
+ if (tab() !== "perf" || coldTab() === "perf") {
1191
+ perfGeneration += 1
1192
+ setPerf(emptyPerf(props.sessionId))
1193
+ return
1194
+ }
1195
+ const o = oes()
1196
+ const history = o.perfHistory > 0
1197
+ ? snap()
1198
+ .db.recent.filter((s) => s.id !== props.sessionId)
1199
+ .slice(0, o.perfHistory)
1200
+ .map((s) => ({ id: s.id, title: s.title }))
1201
+ : []
1202
+ const generation = ++perfGeneration
1203
+ void readPerfSnapshot({
1204
+ dbPath: snap().db.dbPath,
1205
+ sessionId: props.sessionId,
1206
+ turns: o.perfTurns,
1207
+ history,
1208
+ cacheKey: `${props.sessionId}::${snap().scanStamp}::${o.perfTurns}`,
1209
+ }).then((snapshot) => {
1210
+ if (generation === perfGeneration) setPerf(snapshot)
1211
+ })
1212
+ })
1213
+
1214
+ const tabStatusFor = (tab: OesTab) =>
1215
+ tabStatus({
1216
+ tab,
1217
+ currentId: snap().db.current?.id ?? null,
1218
+ dbError: snap().db.error,
1219
+ dbPresent: snap().db.present,
1220
+ switching: switching()?.id ?? null,
1221
+ cold: coldTab() === tab,
1222
+ perfError: tab === "perf" ? perf().error : null,
1223
+ perfTurns: tab === "perf" ? perf().totals.turns : 0,
1224
+ })
1225
+
1226
+ const selfFlow = createMemo(() =>
1227
+ rowFlow(props.sessionId, Boolean(busy()[props.sessionId])),
1228
+ )
1229
+ const selfPhaseMs = createMemo(() =>
1230
+ phaseAgeMs(flow()[props.sessionId], now(), selfFlow()),
1231
+ )
1232
+
1233
+ /** The one place that binds a row to theme, frame and the measured row width. */
1234
+ const Row = (row: RowData): JSX.Element => (
1235
+ <AgentLine
1236
+ {...row}
1237
+ lineMax={lineMax()}
1238
+ frame={frame}
1239
+ glyphFrame={glyphFrame}
1240
+ colors={colors()}
1241
+ />
1242
+ )
1243
+
1244
+ /** Flatten delegates to rows (agent headers + members) for `RowList`. */
1245
+ const delegateLines = (list: DelegateView[]): RowData[] => {
1246
+ const out: RowData[] = []
1247
+ for (const item of groupDelegates(list)) {
1248
+ if (item.kind === "header") {
1249
+ out.push({
1250
+ kind: ROW_KIND_GROUP,
1251
+ glyph: {
1252
+ char: GROUP_GLYPH,
1253
+ tone: markTone(hottestMark(item.members.map(delegateMark))),
1254
+ },
1255
+ name: `${agentDisplayName(item.agent)} (${item.count})`,
1256
+ })
1257
+ continue
1258
+ }
1259
+ const d = item.delegate
1260
+ const isBusy = Boolean(d.sessionId && busy()[d.sessionId])
1261
+ const mark = delegateMark(d)
1262
+ const dir = rowFlow(d.sessionId, isBusy)
1263
+ out.push({
1264
+ kind: item.grouped ? ROW_KIND_DELEGATE : ROW_KIND_AGENT,
1265
+ mark,
1266
+ name: item.grouped ? d.title || d.taskKey || "task" : d.agent || "agent",
1267
+ tokens: d.tokensTotal,
1268
+ title: item.grouped ? undefined : d.title,
1269
+ current: Boolean(d.sessionId && d.sessionId === props.sessionId),
1270
+ flow: dir,
1271
+ dirSlot: true,
1272
+ indent: item.grouped,
1273
+ onSelect: () => goSession(d.sessionId),
1274
+ })
1275
+ }
1276
+ return out
1277
+ }
1278
+
1279
+ /** A delegate list with its own "… +N more" revealer (state hoisted by the caller). */
1280
+ const DelegateList = (props: {
1281
+ list: DelegateView[]
1282
+ budget: number
1283
+ reveal: RevealState
1284
+ }): JSX.Element => (
1285
+ <RowList
1286
+ items={delegateLines(props.list)}
1287
+ budget={props.budget + props.reveal.more()}
1288
+ colors={colors()}
1289
+ renderItem={(r) => <Row {...r} />}
1290
+ more={{ onReveal: props.reveal.reveal }}
1291
+ />
1292
+ )
1293
+
1294
+ /** Live token rate from streaming deltas — recomputes on each delta and on the coarse now tick. */
1295
+ const liveTokenRate = createMemo(() => {
1296
+ now()
1297
+ return tokenRate(tokenTicks(), Date.now(), TOKEN_RATE_WINDOW_MS)
1298
+ })
1299
+ const liveRateBar = createMemo(() => {
1300
+ now()
1301
+ return tokenRateBars(tokenTicks(), Date.now(), TOKEN_RATE_WINDOW_MS)
1302
+ })
1303
+
1304
+ /** Live self-cost line — reads the tick clock so the Solid insert re-evaluates every tick. */
1305
+ const selfLine = createMemo(() => {
1306
+ if (!selfDiagActive()) return ""
1307
+ now()
1308
+ return formatSelfLine(readSelfStats())
1309
+ })
1310
+
1311
+ /** "debug mode" / "profile" flags — visible only while the logger is actually active. */
1312
+ const modeLine = () => {
1313
+ const modes: string[] = []
1314
+ if (debugActive()) modes.push("debug mode")
1315
+ if (profileActive()) modes.push("profile")
1316
+ return modes.join(" ")
1317
+ }
1318
+
1319
+ const modeDirLine = () => {
1320
+ const dirs = [debugActiveDir(), profileActiveDir()].filter((p): p is string => Boolean(p))
1321
+ if (dirs.length === 0) return ""
1322
+ const uniq = Array.from(new Set(dirs))
1323
+ return `logs ${clipWidth(uniq.join(" | "), 78)}`
1324
+ }
1325
+
1326
+ const openRealtimeModal = (): void =>
1327
+ openRealtimeCharts(props.api, colors(), {
1328
+ getTimeline: () => rtTimeline,
1329
+ initialTabId: "tokens",
1330
+ initialRowKey: "avg",
1331
+ })
1332
+
1333
+ return (
1334
+ <box ref={rootRef} flexDirection="column" gap={0}>
1335
+ <Show when={engaging()}>
1336
+ <box flexDirection="row" gap={1}>
1337
+ <text fg={colors().primary}>{`${spinnerFrame(engageTick())} engage`}</text>
1338
+ <text fg={colors().textMuted}>{shareBar(engageFill(engageTick()), 10)}</text>
1339
+ </box>
1340
+ </Show>
1341
+ <Show when={modeLine()}>
1342
+ <text fg={colors().warning || colors().text}>{modeLine()}</text>
1343
+ </Show>
1344
+ <Show when={modeDirLine()}>
1345
+ <text fg={colors().textMuted}>{modeDirLine()}</text>
1346
+ </Show>
1347
+ <box flexDirection="row" gap={1} width="100%">
1348
+ <box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0}>
1349
+ <OesStatusRow
1350
+ status={tabStatusFor(tab())}
1351
+ colors={colors()}
1352
+ glyphFrame={glyphFrame}
1353
+ rateBar={liveRateBar()}
1354
+ rate={liveTokenRate()}
1355
+ />
1356
+ </box>
1357
+ <ContextActions
1358
+ actions={[{ label: "C", onPick: openRealtimeModal }]}
1359
+ colors={colors()}
1360
+ width={RT_ACTION_COL_WIDTH}
1361
+ />
1362
+ </box>
1363
+ <Show when={selfDiagActive()}>
1364
+ <text fg={colors().textMuted}>{selfLine()}</text>
1365
+ </Show>
1366
+ <TabColumn
1367
+ brand=""
1368
+ tabs={OES_TABS}
1369
+ labels={TAB_LABELS}
1370
+ active={tab()}
1371
+ colors={colors()}
1372
+ onPick={pickTab}
1373
+ gap={0}
1374
+ glyph={(key) => (key === "mywork" ? myWorkTabGlyph() : TAB_NEUTRAL_GLYPH)}
1375
+ panels={{
1376
+ mywork: () => {
1377
+ try {
1378
+ const groups = myWorkGroups()
1379
+ dbg("mywork.panel", "render", { groups: groups.map((g) => `${g.kind}:${g.items.length}`) })
1380
+ const status = tabStatusFor("mywork")
1381
+ if (!status && groups.length === 0) return <text fg={colors().textMuted}>• nothing</text>
1382
+ return (
1383
+ <box flexDirection="column" gap={1}>
1384
+ <For each={groups}>
1385
+ {(g) => (
1386
+ <GroupSection
1387
+ title={myWorkLabel(g.kind)}
1388
+ open={myWorkFold[g.kind].open()}
1389
+ onToggle={myWorkFold[g.kind].toggle}
1390
+ colors={colors()}
1391
+ items={g.items}
1392
+ budget={rowsFor(`mywork.${g.kind}`, 2)}
1393
+ reveal={myWorkReveal[g.kind]}
1394
+ actions={
1395
+ g.kind === MY_WORK_GROUP_SESSIONS
1396
+ ? [
1397
+ { label: "switch", onPick: () => openSessionSwitcher(props.api) },
1398
+ { label: "new", onPick: () => openNewSessionPrompt(props.api) },
1399
+ ]
1400
+ : undefined
1401
+ }
1402
+ renderItem={(item) => <Row {...myWorkRow(item)} />}
1403
+ />
1404
+ )}
1405
+ </For>
1406
+ </box>
1407
+ )
1408
+ } catch (e) {
1409
+ dbg("mywork.panel", "error", String(e))
1410
+ return <text fg={colors().error || colors().text}>• my work error</text>
1411
+ }
1412
+ },
1413
+ sessions: () => (
1414
+ <box flexDirection="column" gap={1}>
1415
+ <FoldSection title="Agents" open={foldAgents.open()} colors={colors()} onToggle={foldAgents.toggle}>
1416
+ {isRealSession(snap().db.main) ? (
1417
+ <Row
1418
+ kind={ROW_KIND_AGENT}
1419
+ mark={mainMark()}
1420
+ name={snap().db.main!.agent}
1421
+ tokens={snap().db.main!.tokensTotal}
1422
+ cost={snap().db.main!.cost}
1423
+ current={snap().db.main!.id === props.sessionId}
1424
+ flow={mainFlow()}
1425
+ dirSlot
1426
+ onSelect={() => goSession(snap().db.main?.id)}
1427
+ />
1428
+ ) : (
1429
+ <text fg={colors().textMuted}>
1430
+ {`• session · ${props.sessionId.slice(0, 14)}`}
1431
+ {err() ? ` · ${err()}` : ""}
1432
+ </text>
1433
+ )}
1434
+ </FoldSection>
1435
+
1436
+ <Show when={projectFeed().tools.length > 0}>
1437
+ <FoldSection title="Tool Calls" open={foldTools.open()} colors={colors()} onToggle={foldTools.toggle}>
1438
+ <RowList
1439
+ items={projectFeed().tools}
1440
+ budget={rowsFor("tools", oes().toolRows) + projectToolsReveal.more()}
1441
+ colors={colors()}
1442
+ renderItem={(t) => {
1443
+ const mark = toolMark(t.status)
1444
+ const dir = toolFlow(t.status)
1445
+ const dur =
1446
+ t.status === TOOL_STATUS_RUNNING && t.startedAt != null
1447
+ ? formatDuration(Math.max(0, now() - t.startedAt))
1448
+ : formatDuration(t.durationMs)
1449
+ return (
1450
+ <Row
1451
+ kind={ROW_KIND_TOOL}
1452
+ mark={mark}
1453
+ name={t.name}
1454
+ suffix={dur}
1455
+ flow={dir}
1456
+ dirSlot
1457
+ onSelect={() => openToolDetail(props.api, t, colors())}
1458
+ />
1459
+ )
1460
+ }}
1461
+ more={{ onReveal: projectToolsReveal.reveal }}
1462
+ />
1463
+ </FoldSection>
1464
+ </Show>
1465
+
1466
+ <Show when={projectFeed().files.length > 0}>
1467
+ <FoldSection
1468
+ title="Files"
1469
+ open={foldFiles.open()}
1470
+ count={projectFeed().files.length}
1471
+ diff={projectFilesStat()}
1472
+ actions={[
1473
+ {
1474
+ label: "view all",
1475
+ onPick: () =>
1476
+ openFileListDialog(
1477
+ props.api,
1478
+ "Files",
1479
+ projectFeed().files.map((f) => ({
1480
+ name: f.name,
1481
+ onSelect: () => openFileDetail(props.api, f, projectRoots(), colors()),
1482
+ })),
1483
+ ),
1484
+ },
1485
+ ]}
1486
+ colors={colors()}
1487
+ onToggle={foldFiles.toggle}
1488
+ >
1489
+ <RowList
1490
+ items={projectFeed().files}
1491
+ budget={rowsFor("files", oes().fileRows)}
1492
+ colors={colors()}
1493
+ renderItem={(f) => (
1494
+ <Row
1495
+ kind={ROW_KIND_FILE}
1496
+ glyph={fileLetterGlyph(f.letter)}
1497
+ name={f.name}
1498
+ diff={{ additions: f.additions, deletions: f.deletions }}
1499
+ onSelect={() => openFileDetail(props.api, f, projectRoots(), colors())}
1500
+ />
1501
+ )}
1502
+ />
1503
+ </FoldSection>
1504
+ </Show>
1505
+
1506
+ <Show when={snap().omo.present}>
1507
+ <FoldSection
1508
+ title="Delegates"
1509
+ open={foldDelegates.open()}
1510
+ count={snap().delegates.length}
1511
+ live={delegatesLive()}
1512
+ colors={colors()}
1513
+ onToggle={foldDelegates.toggle}
1514
+ >
1515
+ {snap().delegates.length === 0 ? (
1516
+ <text fg={colors().textMuted}>• none</text>
1517
+ ) : (
1518
+ <DelegateList list={snap().delegates} budget={rowsFor("delegates", 6)} reveal={delegateReveal} />
1519
+ )}
1520
+ </FoldSection>
1521
+ </Show>
1522
+ </box>
1523
+ ),
1524
+ current: () => (
1525
+ <box flexDirection="column" gap={1}>
1526
+ <FoldSection title="Agents" open={foldAgents.open()} colors={colors()} onToggle={foldAgents.toggle}>
1527
+ {isRealSession(currentRow()) ? (
1528
+ <Row
1529
+ kind={ROW_KIND_AGENT}
1530
+ mark={currentMark()}
1531
+ name={currentRow()!.agent}
1532
+ tokens={currentRow()!.tokensTotal}
1533
+ cost={currentRow()!.cost}
1534
+ current
1535
+ flow={currentFlow()}
1536
+ dirSlot
1537
+ onSelect={() => goSession(currentRow()?.id)}
1538
+ />
1539
+ ) : (
1540
+ <text fg={colors().textMuted}>
1541
+ {`• session · ${props.sessionId.slice(0, 14)}`}
1542
+ </text>
1543
+ )}
1544
+ </FoldSection>
1545
+
1546
+ <Show when={snap().omo.present || sessionDelegates().length > 0}>
1547
+ <FoldSection
1548
+ title="Delegates"
1549
+ open={foldDelegates.open()}
1550
+ count={sessionDelegates().length}
1551
+ live={sessionDelegates().filter((d) => {
1552
+ const m = delegateMark(d)
1553
+ return m === PULSE_LIVE || m === PULSE_STALE
1554
+ }).length}
1555
+ colors={colors()}
1556
+ onToggle={foldDelegates.toggle}
1557
+ >
1558
+ {sessionDelegates().length === 0 ? (
1559
+ <text fg={colors().textMuted}>• none</text>
1560
+ ) : (
1561
+ <DelegateList list={sessionDelegates()} budget={rowsFor("delegates", 6)} reveal={currentDelegatesReveal} />
1562
+ )}
1563
+ </FoldSection>
1564
+ </Show>
1565
+
1566
+ <FoldSection title="Tool Calls" open={foldTools.open()} colors={colors()} onToggle={foldTools.toggle}>
1567
+ {tools().length === 0 ? (
1568
+ <text fg={colors().textMuted}>• none</text>
1569
+ ) : (
1570
+ <RowList
1571
+ items={tools()}
1572
+ budget={rowsFor("tools", oes().toolRows) + toolsReveal.more()}
1573
+ colors={colors()}
1574
+ renderItem={(t) => {
1575
+ const mark = toolMark(t.status)
1576
+ const dir = toolFlow(t.status)
1577
+ const dur =
1578
+ t.status === TOOL_STATUS_RUNNING && t.startedAt != null
1579
+ ? formatDuration(Math.max(0, now() - t.startedAt))
1580
+ : formatDuration(t.durationMs)
1581
+ return (
1582
+ <Row
1583
+ kind={ROW_KIND_TOOL}
1584
+ mark={mark}
1585
+ name={t.name}
1586
+ suffix={dur}
1587
+ flow={dir}
1588
+ dirSlot
1589
+ onSelect={() => openToolDetail(props.api, t, colors())}
1590
+ />
1591
+ )
1592
+ }}
1593
+ more={{ onReveal: toolsReveal.reveal }}
1594
+ />
1595
+ )}
1596
+ </FoldSection>
1597
+
1598
+ <FoldSection
1599
+ title="Files"
1600
+ open={foldFiles.open()}
1601
+ count={filesAll().length > 0 ? filesAll().length : undefined}
1602
+ diff={filesStat()}
1603
+ actions={[
1604
+ {
1605
+ label: "view all",
1606
+ onPick: () =>
1607
+ openFileListDialog(
1608
+ props.api,
1609
+ "Files",
1610
+ filesAll().map((f) => ({
1611
+ name: f.name,
1612
+ onSelect: () => openFileDetail(props.api, f, projectRoots(), colors()),
1613
+ })),
1614
+ ),
1615
+ },
1616
+ ]}
1617
+ colors={colors()}
1618
+ onToggle={foldFiles.toggle}
1619
+ >
1620
+ {filesAll().length === 0 ? (
1621
+ <text fg={colors().textMuted}>• none</text>
1622
+ ) : (
1623
+ <RowList
1624
+ items={filesAll()}
1625
+ budget={rowsFor("files", oes().fileRows)}
1626
+ colors={colors()}
1627
+ renderItem={(f) => (
1628
+ <Row
1629
+ kind={ROW_KIND_FILE}
1630
+ glyph={fileLetterGlyph(f.letter)}
1631
+ name={f.name}
1632
+ diff={{ additions: f.additions, deletions: f.deletions }}
1633
+ onSelect={() => openFileDetail(props.api, f, projectRoots(), colors())}
1634
+ />
1635
+ )}
1636
+ />
1637
+ )}
1638
+ </FoldSection>
1639
+
1640
+ <Show when={drafts().length > 0}>
1641
+ <FoldSection
1642
+ title="Drafts"
1643
+ open={foldDrafts.open()}
1644
+ count={draftsAll().length}
1645
+ actions={[
1646
+ {
1647
+ label: "view all",
1648
+ onPick: () =>
1649
+ openFileListDialog(
1650
+ props.api,
1651
+ "Drafts",
1652
+ draftsAll().map((d) => ({
1653
+ name: d.name,
1654
+ description: d.rel,
1655
+ onSelect: () => openDocDetail(props.api, d, projectRoots(), colors()),
1656
+ })),
1657
+ ),
1658
+ },
1659
+ ]}
1660
+ colors={colors()}
1661
+ onToggle={foldDrafts.toggle}
1662
+ >
1663
+ <RowList
1664
+ items={drafts()}
1665
+ budget={rowsFor("drafts", 5)}
1666
+ colors={colors()}
1667
+ renderItem={(d) => {
1668
+ const age = pulseAgeMs(now(), d.updatedAt)
1669
+ return (
1670
+ <Row
1671
+ kind={ROW_KIND_FILE}
1672
+ glyph={{ char: "•", tone: markTone(composeMark({ ageMs: age })) }}
1673
+ name={d.name}
1674
+ suffix={formatAge(age)}
1675
+ onSelect={() => openDocDetail(props.api, d, projectRoots(), colors())}
1676
+ />
1677
+ )
1678
+ }}
1679
+ />
1680
+ </FoldSection>
1681
+ </Show>
1682
+ </box>
1683
+ ),
1684
+ perf: () => (
1685
+ <box flexDirection="column" gap={1}>
1686
+ <PerfPanel
1687
+ api={props.api}
1688
+ perf={perf()}
1689
+ colors={colors()}
1690
+ lineMax={lineMax()}
1691
+ rows={oes().perfRows}
1692
+ glyphFrame={glyphFrame}
1693
+ livePhase={selfFlow()}
1694
+ livePhaseMs={selfPhaseMs()}
1695
+ currentSessionId={props.sessionId}
1696
+ dbPath={snap().db.dbPath}
1697
+ turns={oes().perfTurns}
1698
+ onSelect={(id) => goSession(id)}
1699
+ />
1700
+ </box>
1701
+ ),
1702
+ }}
1703
+ />
1704
+
1705
+ {err() && snap().db.main ? (
1706
+ <text fg={colors().textMuted}>{err()}</text>
1707
+ ) : null}
1708
+ <Show when={selfDiagActive()}>
1709
+ <box flexDirection="column" gap={0} onMouseScroll={onConsoleScroll}>
1710
+ <text fg={colors().textMuted}>{`dbg · ${consoleLines().length}`}</text>
1711
+ <For each={consoleWindow()}>
1712
+ {(line) => <text fg={colors().textMuted}>{clipWidth(line.text, lineMax())}</text>}
1713
+ </For>
1714
+ </box>
1715
+ </Show>
1716
+ </box>
1717
+ )
1718
+ }