@kabmo-labs/kabmo 0.3.1 → 0.5.0

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 (155) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-28z316qk.js → index-cs3b557t.js} +792 -792
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
  14. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  15. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  16. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  17. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  18. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  19. package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
  20. package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
  21. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  22. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  23. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  24. package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
  25. package/src/client/components/mobile/RightSidebar.tsx +30 -36
  26. package/src/client/components/mobile/SessionSidebar.tsx +124 -101
  27. package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
  28. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
  29. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
  30. package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
  31. package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
  32. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  34. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  35. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +47 -14
  37. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  38. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
  39. package/src/client/components/mobile/panel-chips.test.tsx +155 -0
  40. package/src/client/components/mobile/panel-chips.tsx +74 -0
  41. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  42. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  43. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  44. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  45. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  46. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  47. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  49. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  50. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  51. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  52. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  53. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  54. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  55. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +15 -10
  58. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  60. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +162 -0
  61. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  62. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  63. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  64. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  65. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  66. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  67. package/src/client/components/workspace/editor/index.tsx +1 -0
  68. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  69. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  70. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  71. package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
  72. package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
  73. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  74. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  75. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  76. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  77. package/src/client/hooks/chat/composer/compact.ts +119 -0
  78. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  79. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  80. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  81. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  82. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  83. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  84. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  85. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  86. package/src/client/hooks/chat/timeline/index.ts +15 -19
  87. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  88. package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
  89. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  90. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  91. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  92. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  93. package/src/client/hooks/chat/waiting/held.ts +42 -0
  94. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  95. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  96. package/src/client/hooks/chat/waiting/store.ts +100 -0
  97. package/src/client/hooks/ui/toasts.ts +12 -1
  98. package/src/client/hooks/workspace/left-dock.ts +132 -0
  99. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  100. package/src/client/tailwind.css +22 -0
  101. package/src/server/db.server.test.ts +52 -1
  102. package/src/server/db.server.ts +49 -1
  103. package/src/server/index.ts +9 -0
  104. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  105. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  106. package/src/server/lib/db/schema.test.ts +1 -0
  107. package/src/server/lib/db/schema.ts +3 -0
  108. package/src/server/lib/fs/fs-path.test.ts +1 -1
  109. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  110. package/src/server/lib/omp/session/delete.server.ts +8 -0
  111. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  112. package/src/server/lib/panels/registry.server.ts +2 -2
  113. package/src/server/lib/queue/delivery.server.ts +31 -39
  114. package/src/server/lib/queue/payload.ts +73 -0
  115. package/src/server/lib/realtime/signals.server.ts +10 -1
  116. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  117. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  118. package/src/server/lib/realtime/topics.server.ts +2 -0
  119. package/src/server/lib/util/with-timeout.ts +29 -0
  120. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  121. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  122. package/src/server/lib/waiting/runtime.server.ts +313 -0
  123. package/src/server/lib/waiting/schema.server.ts +46 -0
  124. package/src/server/lib/waiting/settle.test.ts +84 -0
  125. package/src/server/lib/waiting/settle.ts +77 -0
  126. package/src/server/lib/waiting/store.server.test.ts +167 -0
  127. package/src/server/lib/waiting/store.server.ts +268 -0
  128. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  129. package/src/server/plugins/compress.ts +18 -6
  130. package/src/server/plugins/ssr.ts +16 -8
  131. package/src/server/plugins/static.test.ts +59 -0
  132. package/src/server/plugins/static.ts +56 -14
  133. package/src/server/routes/index.ts +2 -0
  134. package/src/server/routes/waiting/bindings.ts +14 -0
  135. package/src/server/routes/waiting/index.ts +123 -0
  136. package/src/server/routes/waiting/routes.test.ts +121 -0
  137. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  138. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  139. package/src/shared/lib/realtime/protocol.ts +10 -0
  140. package/src/shared/lib/ui/titlebar.test.ts +124 -0
  141. package/src/shared/lib/ui/titlebar.ts +54 -0
  142. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  143. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  144. package/src/shared/lib/workspace/left-dock.ts +197 -0
  145. package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
  146. package/src/shared/lib/workspace/mobile-dock.ts +55 -0
  147. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  148. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  149. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  150. package/src/shared/types/index.ts +1 -0
  151. package/src/shared/types/waiting.ts +54 -0
  152. package/src/shared/types/workspace.ts +8 -0
  153. package/src/test-support/isolated-db.ts +3 -4
  154. package/src/test-support/silent-socket.ts +69 -0
  155. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The pending store's WAITING rules.
8
+ *
9
+ * A held prompt's row is the only thing on screen for a chat that has no
10
+ * transcript and no omp id, so the store has to keep it for as long as the wait
11
+ * lasts — and drop the cue the moment the wait ends, or the row keeps claiming
12
+ * something the server no longer holds.
13
+ */
14
+
15
+ import { afterEach, describe, expect, test } from 'bun:test';
16
+ import {
17
+ adoptPendingSession,
18
+ listPendingSessions,
19
+ markPendingSessionSent,
20
+ reconcilePendingSessions,
21
+ releasePendingSession,
22
+ startPendingSession,
23
+ subscribePendingSessions,
24
+ } from '@/client/hooks/chat/omp/pending-sessions';
25
+ import {
26
+ clearPendingSessionWaiting,
27
+ markPendingSessionWaiting,
28
+ pendingSessionWait,
29
+ releasePendingSessionByWait,
30
+ } from '@/client/hooks/chat/omp/pending-waiting';
31
+
32
+ afterEach(() => {
33
+ for (const entry of listPendingSessions()) releasePendingSession(entry.id);
34
+ });
35
+
36
+ describe('pending session waiting state', () => {
37
+ test('a held prompt keeps its row past the settle window', () => {
38
+ const id = startPendingSession(1);
39
+ markPendingSessionWaiting(id, { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'Session A' });
40
+
41
+ // Far past PENDING_SETTLE_MS, which is the bound a SENT prompt gets.
42
+ reconcilePendingSessions({
43
+ listed: new Set(),
44
+ running: new Set(),
45
+ activeId: null,
46
+ now: Date.now() + 10 * 60_000,
47
+ });
48
+ expect(listPendingSessions().map((entry) => entry.id)).toContain(id);
49
+ });
50
+
51
+ // The contrast case: a SENT prompt with no wait behind it is released on the
52
+ // settle window, which is what makes the waiting rule a deliberate exception
53
+ // rather than a disabled timer.
54
+ test('an ordinary sent prompt still expires on the settle window', () => {
55
+ const id = startPendingSession(1);
56
+ markPendingSessionSent(id);
57
+ reconcilePendingSessions({ listed: new Set(), running: new Set(), activeId: null, now: Date.now() + 10 * 60_000 });
58
+ expect(listPendingSessions().map((entry) => entry.id)).not.toContain(id);
59
+ });
60
+
61
+ test('clearing the wait keeps the row but drops the cue', () => {
62
+ const id = startPendingSession(1);
63
+ markPendingSessionWaiting(id, { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'Session A' });
64
+ clearPendingSessionWaiting(id);
65
+ expect(pendingSessionWait(id)).toBeNull();
66
+ expect(listPendingSessions().map((entry) => entry.id)).toContain(id);
67
+ });
68
+
69
+ test('a cancelled wait drops its row by the wait id', () => {
70
+ const first = startPendingSession(1);
71
+ const second = startPendingSession(1);
72
+ markPendingSessionWaiting(first, { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'A' });
73
+ markPendingSessionWaiting(second, { waitId: 'wait-2', dependsOn: 'sess-b', dependsOnTitle: 'B' });
74
+
75
+ releasePendingSessionByWait('wait-1');
76
+ const ids = listPendingSessions().map((entry) => entry.id);
77
+ expect(ids).not.toContain(first);
78
+ expect(ids).toContain(second);
79
+ });
80
+
81
+ // The wait ends where the spawn answers: the new row is a real session from
82
+ // that moment, so a cue carried across would say it is still held.
83
+ test('adoption drops the waiting cue', () => {
84
+ const id = startPendingSession(1);
85
+ markPendingSessionWaiting(id, { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'A' });
86
+ adoptPendingSession(id, 'omp-session-9', 1);
87
+ expect(pendingSessionWait('omp-session-9')).toBeNull();
88
+ });
89
+
90
+ // The cue is re-stamped from the server list on every publish, so an identical
91
+ // write must be a no-op — otherwise every sidebar re-renders on every frame.
92
+ test('re-stamping the same wait does not notify', () => {
93
+ const id = startPendingSession(1);
94
+ const wait = { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'A' };
95
+ markPendingSessionWaiting(id, wait);
96
+ let notifications = 0;
97
+ const stop = subscribePendingSessions(() => { notifications += 1; });
98
+ markPendingSessionWaiting(id, { ...wait });
99
+ stop();
100
+ expect(notifications).toBe(0);
101
+ });
102
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The WAITING half of the pending store.
8
+ *
9
+ * Split from `pending-sessions.ts` for the repo's per-file ceiling, and it is a
10
+ * clean seam: that file owns an entry's LIFETIME (mint, adopt, retain, drop),
11
+ * this one owns the single field that says the chat's prompt is deferred. The
12
+ * store exposes one write hook (`patchPendingEntry`) so the field is still
13
+ * written through the same map, the same emit and the same no-op check.
14
+ */
15
+
16
+ import type { PendingSessionEntry } from '@/client/hooks/chat/omp/pending-sessions';
17
+ import { listPendingSessions, patchPendingEntry } from '@/client/hooks/chat/omp/pending-sessions';
18
+
19
+ export type PendingWait = NonNullable<PendingSessionEntry['waiting']>;
20
+
21
+ /**
22
+ * The chat's prompt is held until another session finishes.
23
+ *
24
+ * `sentAt` is set as well, deliberately: the row now stands for a real prompt
25
+ * the user has committed, so the "an unsent draft with nothing in it may go"
26
+ * rule must not reclaim it. The waiting half is what survives the switch —
27
+ * `reconcilePendingSessions` keeps it while the server still reports the row.
28
+ */
29
+ export function markPendingSessionWaiting(id: string, waiting: PendingWait): void {
30
+ patchPendingEntry(id, (entry) => ({ ...entry, sentAt: entry.sentAt ?? Date.now(), waiting }));
31
+ }
32
+
33
+ /** The wait ended (dispatched, cancelled, failed): keep the row, drop the cue. */
34
+ export function clearPendingSessionWaiting(id: string): void {
35
+ patchPendingEntry(id, (entry) => (entry.waiting ? { ...entry, waiting: undefined } : entry));
36
+ }
37
+
38
+ /** The waiting half of one entry, or null when the row is not waiting. */
39
+ export function pendingSessionWait(id: string): PendingWait | null {
40
+ return listPendingSessions().find((entry) => entry.id === id)?.waiting ?? null;
41
+ }
42
+
43
+ /** Drop the entry whose prompt was held by `waitId` (the wait was cancelled). */
44
+ export function releasePendingSessionByWait(waitId: string): void {
45
+ for (const entry of listPendingSessions()) {
46
+ if (entry.waiting?.waitId === waitId) patchPendingEntry(entry.id, () => null);
47
+ }
48
+ }
@@ -30,6 +30,8 @@ import {
30
30
  reconcilePendingSessions,
31
31
  usePendingSessions,
32
32
  } from '@/client/hooks/chat/omp/pending-sessions';
33
+ import { markPendingSessionWaiting } from '@/client/hooks/chat/omp/pending-waiting';
34
+ import { useWaitingPrompts } from '@/client/hooks/chat/waiting/store';
33
35
  import { withPendingRows } from '@/client/hooks/chat/omp/pending-rows';
34
36
  import { writeSetting } from '@/shared/lib/settings/client';
35
37
  import { subscribeSessionKey } from '@/shared/lib/workspace/session-state/listeners';
@@ -150,6 +152,11 @@ export function useSessionSidebarController(
150
152
  // A chat omp has not written a transcript for yet. The store owns the row's
151
153
  // whole life — see `pending-sessions.ts` — so the sidebar only reads it here.
152
154
  const pendingEntries = usePendingSessions();
155
+ // A held prompt is a property of the SERVER row, not of this tab: a reload (or
156
+ // a second tab) re-enters a pending chat whose wait it never created, and the
157
+ // row has to keep saying what it waits for. The cue is therefore re-stamped
158
+ // from the list on every publish, and cleared when the row is gone.
159
+ const { prompts: waitingPrompts, loaded: waitingLoaded } = useWaitingPrompts();
153
160
  const runningIds = useMemo(
154
161
  () => new Set(Object.entries(sessionStatus).filter(([, status]) => status === 'stream').map(([id]) => id)),
155
162
  [sessionStatus],
@@ -165,6 +172,28 @@ export function useSessionSidebarController(
165
172
  hydratePendingSession(sessionParam, folderIdParam, sessionStatus[sessionParam] === 'stream');
166
173
  }, [sessionParam, folders, initializing, folderIdParam, sessionStatus]);
167
174
 
175
+ // The cue is STAMPED from the server list, never cleared from an absence.
176
+ //
177
+ // An absence is not evidence: the effect runs on the render a hold triggers,
178
+ // one frame before the topic's first snapshot, and a snapshot resolved before
179
+ // the create does not carry the row either. Clearing on that deleted the cue
180
+ // the user had just made — and, with it, the id the dispatch watcher needs to
181
+ // adopt. A wait ENDS through a terminal row, which the watcher
182
+ // (`waiting/dispatch.ts`) reads: dispatched, cancelled or failed.
183
+ useEffect(() => {
184
+ if (initializing || !waitingLoaded) return;
185
+ for (const entry of pendingEntries) {
186
+ const id = String(entry.id);
187
+ const held = waitingPrompts.find((prompt) => prompt.pendingId === id && prompt.status === 'waiting');
188
+ if (!held || entry.waiting?.waitId === held.id) continue;
189
+ markPendingSessionWaiting(id, {
190
+ waitId: held.id,
191
+ dependsOn: held.dependsOn,
192
+ dependsOnTitle: held.dependsOnTitle,
193
+ });
194
+ }
195
+ }, [pendingEntries, waitingPrompts, waitingLoaded, initializing]);
196
+
168
197
  // A pending chat's row is kept only while its draft still holds something,
169
198
  // so the reconcile below has to re-run when a draft SLOT moves — the blob
170
199
  // arriving is the common case (a chat opened and left empty has its entry
@@ -19,14 +19,15 @@ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
19
  import type { SetSearchParams } from '@/client/lib/router/search-params';
20
20
  import { applyComposerPick, consumeComposerPick, stashComposerPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
21
21
  import { useNewChatSubmit } from '@/client/hooks/chat/timeline/new-chat';
22
- import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
23
- import { dispatchPluginCommand } from '@/client/hooks/chat/timeline/plugin-commands';
24
22
  import { blockTuiOnlySend } from '@/client/hooks/chat/timeline/tui-only-guard';
23
+ import { dispatchLocalCommand } from '@/client/hooks/chat/timeline/local-commands';
25
24
  import { createQueueActions } from '@/client/hooks/chat/timeline/queue-actions';
26
25
  import { reportSteerOutcome } from '@/shared/lib/chat/timeline/steer-report';
27
26
  import { createRewindActions } from '@/client/hooks/chat/timeline/rewind-actions';
28
- import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
27
+ import { enqueuePreparedFollowUp } from '@/client/hooks/chat/timeline/enqueue-follow-up';
29
28
  import { readKabmoSetting } from '@/shared/lib/settings/client';
29
+ import { holdFromComposer } from '@/client/hooks/chat/waiting/hold';
30
+ import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
30
31
 
31
32
  export interface ChatTimelineActionsDeps {
32
33
  inputValue: string;
@@ -80,6 +81,20 @@ export interface ChatTimelineActionsDeps {
80
81
  deferredComposerPickRef: DeferredModelStore;
81
82
  /** Global access-control mode, snapshotted onto queued items. */
82
83
  accessModeRef: { current: ApprovalMode };
84
+ /**
85
+ * The composer's "wait for session" pick, mirrored in a ref: the send handler
86
+ * is created once per deps record, and reading the pick from render state
87
+ * would send with whatever it was when that closure was built.
88
+ */
89
+ waitingDependencyRef?: { current: WaitingCandidate | null };
90
+ /** Clear the pick once the prompt is held (the wait now owns it). */
91
+ clearWaitingDependency?: () => void;
92
+ /** Server-tracked `stream` rows: re-checked at send, so a dependency that
93
+ * finished while the draft was being written sends normally. */
94
+ streamStatusMap?: Record<string, 'stream' | 'finish' | 'abort'>;
95
+ /** Plan/goal flags the held spawn must carry (read when the wait is created,
96
+ * because the dispatch happens in another process later). */
97
+ spawnSelectionRef?: { current: { plan: boolean; goal: boolean } | null };
83
98
  setSearchParams: SetSearchParams;
84
99
  /**
85
100
  * Surface a footer action that failed (rewind refused, send rejected). Undo
@@ -137,17 +152,14 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
137
152
  // they are built once per render from it rather than re-listing every field.
138
153
  const queueActions = useMemo(() => createQueueActions(deps), [deps]);
139
154
 
140
- /** Snapshot the composer's model/thinking plus the live access mode onto a
141
- * queued item, so server-side auto-delivery replays exactly these settings.
142
- * The queue row is JSON — a `File` does not survive it — so text contents
143
- * and display fields are captured while the live handle still exists. */
144
- const enqueueFollowUp = useCallback((text: string, attachments: Attachment[]) => {
145
- const composerPick = composerModelRef.current;
146
- const model = composerPick ? { ...composerPick, accessMode: accessModeRef.current } : null;
147
- void prepareQueuedAttachments(attachments).then((prepared) => {
148
- enqueueMessage({ text, attachments: prepared, model });
149
- });
150
- }, [composerModelRef, accessModeRef, enqueueMessage]);
155
+ const enqueueFollowUp = useCallback(
156
+ (text: string, attachments: Attachment[]) => enqueuePreparedFollowUp(
157
+ { composerModelRef, accessModeRef, enqueueMessage },
158
+ text,
159
+ attachments,
160
+ ),
161
+ [composerModelRef, accessModeRef, enqueueMessage],
162
+ );
151
163
 
152
164
  const handleSend = useCallback(async (attachments: Attachment[], options?: { steering?: boolean }) => {
153
165
  const textToSend = inputValue.trim();
@@ -157,26 +169,8 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
157
169
  // resume delivering after this run ends.
158
170
  stopHoldRef.current = false;
159
171
 
160
- // `/btw [question]` is the side-question entry point, not chat text: the
161
- // panel owns it (omp's `/btw` is TUI-only, so nothing downstream would
162
- // understand the token). A bare `/btw` only opens the panel's history.
163
- //
164
- // MUST run before the TUI-only guard below: `/btw` is in that table (omp
165
- // implements it in the TUI only), so the guard would refuse the very
166
- // command Kabmo answers with a panel.
167
- if (dispatchBtwCommand(textToSend, attachments)) {
168
- setInputValue('');
169
- return;
170
- }
171
-
172
- // A command a PLUGIN contributed through the `commandPaletteAction` slot is
173
- // answered by that plugin, exactly like a Kabmo command: it is local work
174
- // the popup advertised, so it must never reach the model as literal text
175
- // (RPC has no expansion for it, and omp would improvise a whole turn around
176
- // the string). MUST run before the TUI-only guard below: a plugin may
177
- // legitimately claim a name omp implements only in its TUI, and refusing
178
- // the row the popup just offered would be the composer contradicting itself.
179
- if (dispatchPluginCommand(textToSend)) {
172
+ // Commands Kabmo answers ITSELF, before anything can reach the model.
173
+ if (dispatchLocalCommand(textToSend, attachments)) {
180
174
  setInputValue('');
181
175
  return;
182
176
  }
@@ -187,6 +181,11 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
187
181
  // keep the draft so the user can edit or retype it.
188
182
  if (blockTuiOnlySend(textToSend, setLocalMessages)) return;
189
183
 
184
+ // A NEW chat whose prompt is held for another session: nothing is sent now,
185
+ // and the pending row stays on screen saying what it waits for. Checked
186
+ // before the queue-vs-send split — a pending chat has no run to queue behind.
187
+ if (await holdFromComposer(deps, textToSend, attachments, isGenerating || chatRunning)) return;
188
+
190
189
  // "A run is in flight" is the CHAT-level flag, not this client's own:
191
190
  // `isGenerating` only knows the run THIS page started, so a second tab, a
192
191
  // scheduled task, the goal driver's continuation or a page that never
@@ -234,7 +233,7 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
234
233
  // branch would rather than losing the message. Every other failure may
235
234
  // already have been accepted, and is reported by the send path instead.
236
235
  if (result.busy) enqueueFollowUp(textToSend, attachments);
237
- }, [inputValue, isGenerating, chatRunning, executeSend, enqueueMessage, enqueueFollowUp, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef, setLocalMessages]);
236
+ }, [deps, inputValue, isGenerating, chatRunning, executeSend, enqueueMessage, enqueueFollowUp, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef, setLocalMessages]);
238
237
 
239
238
  const handleEditQueueItem = useCallback((item: QueuedMessage) => {
240
239
  queueActions.handleEditQueueItem(item);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer's model/thinking refs, and the one rule that ties them to a
8
+ * session.
9
+ *
10
+ * Three of the four are mirrors of picks the user made, and they exist because
11
+ * the send path cannot read render state: it is created once per deps record,
12
+ * so a pick read from a prop there would dispatch with whatever it was when
13
+ * that closure was built. The fourth (`deferredComposerPickRef`) holds a pick
14
+ * made WHILE a turn streams, so it cannot re-target the answer in flight.
15
+ *
16
+ * Split out of `timeline/index.ts` for the per-file ceiling; the semantics are
17
+ * the hook's own and are documented at each field.
18
+ */
19
+
20
+ import { useEffect, useRef } from 'preact/hooks';
21
+ import type { ComposerModelPick } from '@/client/hooks/chat/timeline/deferred-model';
22
+
23
+ export interface ComposerRefs {
24
+ /** Model picked on a pending `new-…` chat; applied to the spawn. */
25
+ pendingComposerModelRef: { current: { provider: string; modelId: string } | null };
26
+ /** Same, for the thinking level. */
27
+ pendingThinkingLevelRef: { current: string | null };
28
+ /** Live mirror of the composer's pick; snapshotted onto queued items. */
29
+ composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
30
+ /** A pick made mid-run, pushed right before the next prompt this composer starts. */
31
+ deferredComposerPickRef: { current: ComposerModelPick | null };
32
+ }
33
+
34
+ export function useComposerRefs(sessionId: string | null): ComposerRefs {
35
+ const pendingComposerModelRef = useRef<{ provider: string; modelId: string } | null>(null);
36
+ const pendingThinkingLevelRef = useRef<string | null>(null);
37
+ const composerModelRef = useRef<{ provider: string; modelId: string; thinkingLevel: string } | null>(null);
38
+ const deferredComposerPickRef = useRef<ComposerModelPick | null>(null);
39
+
40
+ // A stashed pick belongs to ONE session's next prompt: carrying it across a
41
+ // switch would silently re-target the session the user just opened.
42
+ useEffect(() => {
43
+ deferredComposerPickRef.current = null;
44
+ }, [sessionId]);
45
+
46
+ return { pendingComposerModelRef, pendingThinkingLevelRef, composerModelRef, deferredComposerPickRef };
47
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Snapshot a follow-up onto the server-backed queue.
8
+ *
9
+ * The composer's model/thinking pick plus the live access mode ride along, so
10
+ * auto-delivery replays exactly these settings instead of whatever the session
11
+ * happens to run with at delivery time. The queue row is JSON — a live `File`
12
+ * does not survive it — so text contents and display fields are captured HERE,
13
+ * while the handle still exists.
14
+ */
15
+
16
+ import type { Attachment, QueuedMessage, QueuedMessageModel } from '@/shared/types/chat';
17
+ import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
18
+
19
+ export interface FollowUpSnapshotDeps {
20
+ composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
21
+ accessModeRef: { current: string };
22
+ enqueueMessage: (item: Omit<QueuedMessage, 'id'>) => void;
23
+ }
24
+
25
+ export function enqueuePreparedFollowUp(
26
+ deps: FollowUpSnapshotDeps,
27
+ text: string,
28
+ attachments: Attachment[],
29
+ ): void {
30
+ const composerPick = deps.composerModelRef.current;
31
+ const model: QueuedMessageModel | null = composerPick
32
+ ? { ...composerPick, accessMode: deps.accessModeRef.current as QueuedMessageModel['accessMode'] }
33
+ : null;
34
+ void prepareQueuedAttachments(attachments).then((prepared) => {
35
+ deps.enqueueMessage({ text, attachments: prepared, model });
36
+ });
37
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
1
+ import { useCallback, useRef, useState } from 'preact/hooks';
2
2
  import { useTimelineScope } from '@/client/hooks/chat/timeline/scope';
3
3
  import type { ChatMessageData } from '@/shared/types';
4
4
  import { useOmpAgent } from '@/client/hooks/chat/omp';
@@ -9,7 +9,6 @@ import { useGeneratingState } from '@/client/hooks/chat/timeline/generating';
9
9
  import { useTimelineAutoScroll } from '@/client/hooks/chat/timeline/auto-scroll';
10
10
  import { useChatTimelineActions } from '@/client/hooks/chat/timeline/actions';
11
11
  import { useChatTimelineSend } from '@/client/hooks/chat/timeline/send';
12
- import type { ComposerModelPick } from '@/client/hooks/chat/timeline/deferred-model';
13
12
  import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
14
13
  import { useUserTurns } from '@/client/hooks/chat/timeline/user-turns';
15
14
  import { useRailJump } from '@/client/hooks/chat/timeline/rail-jump';
@@ -19,6 +18,8 @@ import { useChatTimelineAccessMode } from '@/client/hooks/chat/timeline/access-m
19
18
  import { useComposerModes } from '@/client/hooks/chat/timeline/composer-modes';
20
19
  import { useComposerDraft } from '@/client/hooks/chat/composer/use-draft';
21
20
  import { isChatRunning } from '@/client/hooks/chat/omp/session-statuses';
21
+ import { useWaitingDependencyChoice } from '@/client/hooks/chat/waiting/choice';
22
+ import { useComposerRefs } from '@/client/hooks/chat/timeline/composer-refs';
22
23
 
23
24
  interface UseChatTimelineOptions {
24
25
  folders?: any[];
@@ -157,28 +158,18 @@ export function useChatTimeline({
157
158
  });
158
159
  const jumpToUserTurn = useRailJump(pagedJumpToTurn, { followRef, scrollRef });
159
160
 
160
- // Composer picks made BEFORE the omp session exists (pending "new-…" view):
161
- // there is no live session to receive the RPC yet, so the selection is held
162
- // here and applied to the spawn command on first send.
163
- const pendingComposerModelRef = useRef<{ provider: string; modelId: string } | null>(null);
164
- const pendingThinkingLevelRef = useRef<string | null>(null);
165
- // Live mirror of the composer's model/thinking pick: the enqueue path
166
- // snapshots it onto queued items so auto-delivery replays those settings.
167
- const composerModelRef = useRef<{ provider: string; modelId: string; thinkingLevel: string } | null>(null);
168
- // Picks made WHILE a turn streams: held here so they cannot re-target the
169
- // answer in flight (omp applies set_model to the running turn), and pushed
170
- // onto the session right before the next prompt.
171
- const deferredComposerPickRef = useRef<ComposerModelPick | null>(null);
172
- // A stashed pick belongs to ONE session's next prompt: carrying it across a
173
- // switch would silently re-target the session the user just opened.
174
- useEffect(() => {
175
- deferredComposerPickRef.current = null;
176
- }, [sessionId]);
161
+ // The composer's model/thinking mirrors and the mid-run stash (see the hook).
162
+ const { pendingComposerModelRef, pendingThinkingLevelRef, composerModelRef, deferredComposerPickRef } =
163
+ useComposerRefs(sessionId);
177
164
 
178
165
  // Access control is a GLOBAL, persisted user preference (unlike the
179
166
  // per-session model/thinking picks).
180
167
  const { accessMode, accessModeRef, setAccessMode: handleAccessModeChange } = useChatTimelineAccessMode(appSettings);
181
168
 
169
+ // "Wait for session" on a NEW chat: the pick, and the ref the send handler
170
+ // reads (see `choice.ts` for why both).
171
+ const waitingDependency = useWaitingDependencyChoice(sessionId);
172
+
182
173
  const persistMessages = useCallback((messagesToSave: any[]) => {
183
174
  if (!sessionId) return;
184
175
  fetch(`/api/chat/${sessionId}`, {
@@ -291,6 +282,10 @@ export function useChatTimeline({
291
282
  composerModelRef,
292
283
  deferredComposerPickRef,
293
284
  accessModeRef,
285
+ waitingDependencyRef: waitingDependency.ref,
286
+ clearWaitingDependency: waitingDependency.clear,
287
+ streamStatusMap,
288
+ spawnSelectionRef: modes.spawnSelectionRef,
294
289
  setSearchParams,
295
290
  reportActionError,
296
291
  });
@@ -343,6 +338,7 @@ export function useChatTimeline({
343
338
  planReview,
344
339
  composerModelRef,
345
340
  deferredComposerPickRef,
341
+ waitingDependency,
346
342
  extensionDialogs,
347
343
  resolveExtensionDialog,
348
344
  respondToExtensionUi: ompAgent.respondToExtensionUi,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Commands Kabmo answers itself, checked before anything can reach the model.
8
+ *
9
+ * Two of them, and both MUST run ahead of the TUI-only guard:
10
+ *
11
+ * - `/btw [question]` is the side-question panel's entry point. omp implements
12
+ * `/btw` in its TUI only, so the guard would refuse the very command Kabmo
13
+ * answers — a bare `/btw` just opens the panel's history.
14
+ * - a command a PLUGIN contributed through `commandPaletteAction` is local
15
+ * work the popup advertised, so it must never arrive as literal text (RPC
16
+ * has no expansion for it, and omp would improvise a turn around the
17
+ * string). A plugin may legitimately claim a name omp implements only in its
18
+ * TUI, and refusing the row the popup just offered would be the composer
19
+ * contradicting itself.
20
+ */
21
+
22
+ import type { Attachment } from '@/shared/types/chat';
23
+ import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
24
+ import { dispatchPluginCommand } from '@/client/hooks/chat/timeline/plugin-commands';
25
+
26
+ /** True when the text was answered locally — the caller clears the composer and returns. */
27
+ export function dispatchLocalCommand(text: string, attachments: Attachment[]): boolean {
28
+ return dispatchBtwCommand(text, attachments) || dispatchPluginCommand(text);
29
+ }
@@ -28,6 +28,7 @@ import type { QueuedMessage } from '@/shared/types';
28
28
  import { SessionStateContext } from '@/client/hooks/workspace/session-state/context';
29
29
  import { useChatTimelineQueue, type ChatTimelineQueueResult } from '@/client/hooks/chat/timeline/queue';
30
30
  import { requestRewind } from '@/client/hooks/chat/timeline/rewind';
31
+ import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
31
32
 
32
33
  const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'CustomEvent'] as const;
33
34
  /** The runner's own globals, restored on teardown so later files still have them. */
@@ -80,12 +81,17 @@ afterAll(() => {
80
81
  }
81
82
  });
82
83
  beforeEach(() => {
84
+ // The queue hook subscribes to its session topic on mount, and this origin has
85
+ // no listener: a real dial would fail asynchronously, after the globals are
86
+ // restored. Per case, because `restoreRealtime` clears the constructor.
87
+ silenceRealtime();
83
88
  calls.length = 0;
84
89
  canonical = [];
85
90
  gate = null;
86
91
  stubFetch();
87
92
  });
88
93
  afterEach(() => {
94
+ restoreRealtime();
89
95
  if (container) render(null, container);
90
96
  container?.remove();
91
97
  latest = null;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The provider/model the generating indicator names a run by.
8
+ *
9
+ * The run's model is persisted BESIDE the stream status and rides the sidebar
10
+ * payload, so the indicator names the real provider/model even for a run this
11
+ * page never started. The transcript is the fallback, not the source: a freshly
12
+ * spawned session has no readable JSONL yet, and one loaded from history may
13
+ * carry a model the run has since switched away from.
14
+ */
15
+
16
+ import { useMemo } from 'preact/hooks';
17
+ import type { WorkspaceFolderData } from '@/shared/types';
18
+ import type { SessionDataShape } from '@/client/hooks/chat/timeline/session-load';
19
+
20
+ export interface RunIdentity {
21
+ sessionProvider?: string;
22
+ sessionModelName?: string;
23
+ }
24
+
25
+ export function useRunIdentity(input: {
26
+ folders: WorkspaceFolderData[];
27
+ sessionId: string | null;
28
+ sessionData: SessionDataShape | null | undefined;
29
+ modelNames: Record<string, string>;
30
+ }): RunIdentity {
31
+ const { folders, sessionId, sessionData, modelNames } = input;
32
+ const runModel = useMemo(
33
+ () => folders.flatMap((f) => f.sessions ?? []).find((s) => String(s.id) === String(sessionId))?.runModel ?? null,
34
+ [folders, sessionId],
35
+ );
36
+ const fallback = sessionData?.model;
37
+ const fallbackModel = typeof fallback === 'object' ? fallback : null;
38
+ const sessionModelId = runModel?.modelId ?? fallbackModel?.modelId;
39
+ return {
40
+ sessionProvider: runModel?.provider ?? fallbackModel?.provider,
41
+ sessionModelName: sessionModelId
42
+ ? (modelNames[sessionModelId] ?? sessionModelId)
43
+ : (typeof fallback === 'string' ? fallback : undefined),
44
+ };
45
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Dropping a held prompt from the row that shows it.
8
+ *
9
+ * A refusal is REPORTED: the only two refusals are "already sent" (the prompt
10
+ * is running now, so Stop the session instead) and "no longer there", and both
11
+ * leave the row on screen. Swallowing them would leave a row that looks stuck
12
+ * with no way to learn why.
13
+ */
14
+
15
+ import { useCallback } from 'preact/hooks';
16
+ import { cancelWaitingPromptRequest } from '@/client/hooks/chat/waiting/store';
17
+ import { releasePendingSessionByWait } from '@/client/hooks/chat/omp/pending-waiting';
18
+
19
+ export type CancelWait = (waitId: string) => void;
20
+
21
+ export function useCancelWait(onNotice?: (message: string, type?: 'success' | 'error') => void): CancelWait {
22
+ return useCallback((waitId: string) => {
23
+ void cancelWaitingPromptRequest(waitId).then((result) => {
24
+ if (!result.ok) {
25
+ onNotice?.(result.error ?? 'Could not cancel the waiting prompt.', 'error');
26
+ return;
27
+ }
28
+ // The row existed only for this wait, so it goes with it. The topic
29
+ // republish also removes the cue; doing it here keeps the click instant.
30
+ releasePendingSessionByWait(waitId);
31
+ onNotice?.('Waiting prompt cancelled.', 'success');
32
+ });
33
+ }, [onNotice]);
34
+ }