@kabmo-labs/kabmo 0.3.1 → 0.4.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 (135) 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-twsk4wfa.js} +791 -791
  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/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/registry.server.ts +2 -2
  98. package/src/server/lib/queue/delivery.server.ts +31 -39
  99. package/src/server/lib/queue/payload.ts +73 -0
  100. package/src/server/lib/realtime/signals.server.ts +10 -1
  101. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  102. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  103. package/src/server/lib/realtime/topics.server.ts +2 -0
  104. package/src/server/lib/util/with-timeout.ts +29 -0
  105. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  106. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  107. package/src/server/lib/waiting/runtime.server.ts +313 -0
  108. package/src/server/lib/waiting/schema.server.ts +46 -0
  109. package/src/server/lib/waiting/settle.test.ts +84 -0
  110. package/src/server/lib/waiting/settle.ts +77 -0
  111. package/src/server/lib/waiting/store.server.test.ts +167 -0
  112. package/src/server/lib/waiting/store.server.ts +268 -0
  113. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  114. package/src/server/plugins/compress.ts +18 -6
  115. package/src/server/plugins/ssr.ts +16 -8
  116. package/src/server/plugins/static.test.ts +59 -0
  117. package/src/server/plugins/static.ts +56 -14
  118. package/src/server/routes/index.ts +2 -0
  119. package/src/server/routes/waiting/bindings.ts +14 -0
  120. package/src/server/routes/waiting/index.ts +123 -0
  121. package/src/server/routes/waiting/routes.test.ts +121 -0
  122. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  123. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  124. package/src/shared/lib/realtime/protocol.ts +10 -0
  125. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  126. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  127. package/src/shared/lib/workspace/left-dock.ts +197 -0
  128. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  129. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  130. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  131. package/src/shared/types/index.ts +1 -0
  132. package/src/shared/types/waiting.ts +54 -0
  133. package/src/shared/types/workspace.ts +8 -0
  134. package/src/test-support/isolated-db.ts +3 -4
  135. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer's "wait for session" pick, per chat.
8
+ *
9
+ * Held in the session's own UI state (`chat.waitingFor`) rather than in
10
+ * component state: the composer is remounted by a stretch, a tab switch and a
11
+ * reload, and a pick that evaporated with it would silently send the prompt
12
+ * immediately — the one outcome the user explicitly deferred.
13
+ *
14
+ * The ref is the other half, and it is not redundant: the send handler is
15
+ * created once per deps record, so reading the pick from render state there
16
+ * would dispatch with whatever it was when that closure was built.
17
+ */
18
+
19
+ import { useCallback, useEffect, useRef } from 'preact/hooks';
20
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
21
+ import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
22
+
23
+ export interface WaitingDependencyChoice {
24
+ value: WaitingCandidate | null;
25
+ ref: { current: WaitingCandidate | null };
26
+ set: (candidate: WaitingCandidate | null) => void;
27
+ clear: () => void;
28
+ }
29
+
30
+ export function useWaitingDependencyChoice(sessionId: string | null): WaitingDependencyChoice {
31
+ const [stored, setStored] = useSessionState<WaitingCandidate | null>('chat.waitingFor', null);
32
+ const ref = useRef<WaitingCandidate | null>(stored);
33
+ ref.current = stored;
34
+
35
+ // The pick belongs to ONE chat's next prompt. A pending chat is replaced by
36
+ // the id a spawn adopts, which is a different slot — so nothing has to be
37
+ // cleared for the normal path. This covers the other direction: a session that
38
+ // is not pending at all has no use for a pick, and a stale value there would
39
+ // be applied by the next New Session the user opens in that slot.
40
+ const pending = Boolean(sessionId?.startsWith('new-'));
41
+ useEffect(() => {
42
+ if (!pending && stored !== null) setStored(null);
43
+ }, [pending, stored, setStored]);
44
+
45
+ const set = useCallback((candidate: WaitingCandidate | null) => {
46
+ ref.current = candidate;
47
+ setStored(candidate);
48
+ }, [setStored]);
49
+
50
+ const clear = useCallback(() => set(null), [set]);
51
+
52
+ return { value: stored, ref, set, clear };
53
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Following a held prompt to its outcome.
8
+ *
9
+ * The dispatch happens on the SERVER, in a process that may be a different
10
+ * instance, a different tab's, or one that started after this page was closed.
11
+ * Nothing on this side sends it, so the only thing that can report it is the
12
+ * `waiting` topic — and what a dispatched row carries is exactly what the local
13
+ * send path produces for itself: the omp session id the spawn answered with.
14
+ *
15
+ * That is why the adoption is here rather than in the composer: the pending
16
+ * row's `new-…` id has to move onto the real one whether or not the composer is
17
+ * still mounted, and whether or not this tab is the one that created it.
18
+ */
19
+
20
+ import { useEffect, useRef } from 'preact/hooks';
21
+ import { useWaitingPrompts } from '@/client/hooks/chat/waiting/store';
22
+ import {
23
+ adoptPendingSession,
24
+ releasePendingSession,
25
+ } from '@/client/hooks/chat/omp/pending-sessions';
26
+ import { clearPendingSessionWaiting, pendingSessionWait } from '@/client/hooks/chat/omp/pending-waiting';
27
+ import { useSearchParams } from '@/client/lib/router/search-params';
28
+
29
+ export interface WaitingDispatchOptions {
30
+ /** Surfaces an outcome the user must know about (a failed or cancelled wait). */
31
+ onNotice?: (message: string) => void;
32
+ }
33
+
34
+ /**
35
+ * Adopt the session a held prompt became, and report the outcomes that are not
36
+ * a dispatch.
37
+ *
38
+ * Mounted once, beside the sidebar's own data provider: every layout needs it,
39
+ * and the pending row it adopts lives in the same client store both sidebars
40
+ * read.
41
+ */
42
+ export function useWaitingDispatch({ onNotice }: WaitingDispatchOptions = {}): void {
43
+ const { prompts } = useWaitingPrompts();
44
+ const [, setSearchParams] = useSearchParams();
45
+ // The toast stack's `pushToast` is a fresh closure per render, so it must not
46
+ // be an effect dependency: every unrelated render would re-run this walk.
47
+ const noticeRef = useRef(onNotice);
48
+ noticeRef.current = onNotice;
49
+
50
+ useEffect(() => {
51
+ for (const prompt of prompts) {
52
+ const pendingId = prompt.pendingId;
53
+ if (!pendingId) continue;
54
+ const held = pendingSessionWait(pendingId);
55
+
56
+ if (prompt.status === 'dispatched' && prompt.sessionId) {
57
+ // Adopt when this tab OWNS the chat: it still holds the cue, or its URL
58
+ // names the pending id (a reload, a second tab on the same chat). A row
59
+ // from someone else's chat is left alone — adopting it would mint a
60
+ // sidebar entry for a session this tab never opened.
61
+ const openId = new URLSearchParams(window.location.search).get('sessionId');
62
+ if (!held && openId !== pendingId) continue;
63
+ adoptPendingSession(pendingId, prompt.sessionId, prompt.folderId);
64
+ // The URL may still name the pending id — this tab was left on the chat
65
+ // while the dispatch happened elsewhere.
66
+ setSearchParams((prev) => {
67
+ if (prev.get('sessionId') !== pendingId) return prev;
68
+ const next = new URLSearchParams(prev);
69
+ next.set('sessionId', prompt.sessionId as string);
70
+ return next;
71
+ }, { replace: true });
72
+ noticeRef.current?.(`Waiting finished — your prompt is now running in a new session.`);
73
+ continue;
74
+ }
75
+
76
+ if (prompt.status === 'error') {
77
+ const openId = new URLSearchParams(window.location.search).get('sessionId');
78
+ if (!held && openId !== pendingId) continue;
79
+ clearPendingSessionWaiting(pendingId);
80
+ releasePendingSession(pendingId);
81
+ noticeRef.current?.(`The prompt you held was not sent: ${prompt.error ?? 'the dispatch failed.'}`);
82
+ continue;
83
+ }
84
+
85
+ if (prompt.status === 'cancelled' && held) {
86
+ clearPendingSessionWaiting(pendingId);
87
+ noticeRef.current?.('The held prompt was cancelled.');
88
+ }
89
+ }
90
+ }, [prompts, setSearchParams]);
91
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The held prompt of the chat on screen, and how to drop it.
8
+ *
9
+ * A chat whose prompt is deferred has no session and no transcript, so the
10
+ * composer is the only place the wait is visible — and it must be DISABLED
11
+ * while the wait lasts: a second prompt would have nowhere to go.
12
+ */
13
+
14
+ import { useCallback, useMemo } from 'preact/hooks';
15
+ import { useWaitingPrompts } from '@/client/hooks/chat/waiting/store';
16
+ import { useCancelWait } from '@/client/hooks/chat/waiting/cancel';
17
+ import type { WaitingPrompt } from '@/shared/types/waiting';
18
+
19
+ export interface HeldWait {
20
+ /** The waiting row for the open chat, or null. */
21
+ heldWait: WaitingPrompt | null;
22
+ cancelHeldWait: () => void;
23
+ }
24
+
25
+ export function useHeldWait(
26
+ sessionId: string | null,
27
+ isPendingSession: boolean,
28
+ onNotice?: (message: string, type?: 'success' | 'error') => void,
29
+ ): HeldWait {
30
+ const { prompts } = useWaitingPrompts();
31
+ const cancelWait = useCancelWait(onNotice);
32
+ const heldWait = useMemo(
33
+ () => (isPendingSession
34
+ ? prompts.find((prompt) => prompt.pendingId === sessionId && prompt.status === 'waiting') ?? null
35
+ : null),
36
+ [isPendingSession, sessionId, prompts],
37
+ );
38
+ const cancelHeldWait = useCallback(() => {
39
+ if (heldWait) cancelWait(heldWait.id);
40
+ }, [heldWait, cancelWait]);
41
+ return { heldWait, cancelHeldWait };
42
+ }