@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,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
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The hold path: what makes a prompt deferred instead of sent.
8
+ *
9
+ * Three behaviours are pinned, and each is a decision rather than an
10
+ * implementation detail:
11
+ *
12
+ * - a stale pick (the session finished while the draft was written) SENDS,
13
+ * and says so — a wait created on it would be released by the next sweep;
14
+ * - a refusal KEEPS the draft, because the user's text is the one thing that
15
+ * cannot be recovered from anywhere else;
16
+ * - a success writes the pending row's cue, which is what keeps the sidebar
17
+ * row on screen until the dispatch adopts it.
18
+ */
19
+
20
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
21
+ import { holdPromptForDependency, type HoldDeps } from '@/client/hooks/chat/waiting/hold';
22
+ import {
23
+ listPendingSessions,
24
+ releasePendingSession,
25
+ startPendingSession,
26
+ } from '@/client/hooks/chat/omp/pending-sessions';
27
+ import { pendingSessionWait } from '@/client/hooks/chat/omp/pending-waiting';
28
+ import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
29
+ import type { Attachment } from '@/shared/types/chat';
30
+
31
+ const DEPENDENCY: WaitingCandidate = { id: 'sess-a', title: 'Refactor parser', folderName: 'alpha' };
32
+
33
+ interface Recorded {
34
+ drafts: string[];
35
+ attachments: Attachment[][];
36
+ errors: string[];
37
+ cleared: number;
38
+ posted: Record<string, unknown>[];
39
+ }
40
+
41
+ const nativeFetch = globalThis.fetch;
42
+ /** The id `startPendingSession` minted for this case; the store owns the clock. */
43
+ let pendingId = '';
44
+
45
+ function harness(overrides: Partial<HoldDeps> = {}): { deps: HoldDeps; recorded: Recorded } {
46
+ const recorded: Recorded = { drafts: [], attachments: [], errors: [], cleared: 0, posted: [] };
47
+ const deps: HoldDeps = {
48
+ sessionId: pendingId,
49
+ folders: [{ id: 1, name: 'alpha', project_path: '/tmp/alpha', isExpanded: true, hasMore: false, totalSessions: 0, sessions: [] }],
50
+ selectedFolderId: 1,
51
+ composerModelRef: { current: { provider: 'anthropic', modelId: 'claude', thinkingLevel: 'high' } },
52
+ accessModeRef: { current: 'always-ask' },
53
+ spawnModesRef: { current: { plan: false, goal: false } },
54
+ streamStatusMap: { 'sess-a': 'stream' },
55
+ setInputValue: (value) => recorded.drafts.push(value),
56
+ setAttachments: (value) => recorded.attachments.push(value),
57
+ reportActionError: (message) => recorded.errors.push(message),
58
+ clearChoice: () => { recorded.cleared += 1; },
59
+ ...overrides,
60
+ };
61
+ return { deps, recorded };
62
+ }
63
+
64
+ beforeEach(() => {
65
+ pendingId = startPendingSession(1);
66
+ (globalThis as Record<string, unknown>).fetch = async (_input: unknown, init?: { body?: string }) => {
67
+ const body = init?.body ? JSON.parse(init.body) : {};
68
+ createdBody = body;
69
+ return new Response(JSON.stringify({ prompt: { id: 'wait-1', ...body, status: 'waiting' } }), {
70
+ status: 200,
71
+ headers: { 'content-type': 'application/json' },
72
+ });
73
+ };
74
+ });
75
+
76
+ let createdBody: Record<string, unknown> = {};
77
+
78
+ afterEach(() => {
79
+ globalThis.fetch = nativeFetch;
80
+ for (const entry of listPendingSessions()) releasePendingSession(entry.id);
81
+ createdBody = {};
82
+ });
83
+
84
+ describe('holdPromptForDependency', () => {
85
+ test('writes the row and marks the pending chat as waiting', async () => {
86
+ const { deps, recorded } = harness();
87
+ const held = await holdPromptForDependency(deps, DEPENDENCY, 'summarise it', []);
88
+
89
+ expect(held).toBe(true);
90
+ expect(recorded.drafts).toEqual(['']);
91
+ expect(recorded.attachments).toEqual([[]]);
92
+ expect(recorded.cleared).toBe(1);
93
+ expect(createdBody).toMatchObject({
94
+ dependsOn: 'sess-a',
95
+ dependsOnTitle: 'Refactor parser',
96
+ prompt: 'summarise it',
97
+ cwd: '/tmp/alpha',
98
+ folderId: 1,
99
+ model: { provider: 'anthropic', modelId: 'claude', thinkingLevel: 'high', accessMode: 'always-ask' },
100
+ });
101
+ expect(pendingSessionWait(pendingId)).toMatchObject({ waitId: 'wait-1', dependsOn: 'sess-a' });
102
+ });
103
+
104
+ test('no pick means the caller sends normally', async () => {
105
+ const { deps, recorded } = harness();
106
+ expect(await holdPromptForDependency(deps, null, 'hello', [])).toBe(false);
107
+ expect(recorded.drafts).toEqual([]);
108
+ });
109
+
110
+ // The dropdown was opened while the dependency was running; by the time the
111
+ // user hit send it had finished. Holding would be a wait of zero length.
112
+ test('a dependency that finished meanwhile sends instead, and says so', async () => {
113
+ const { deps, recorded } = harness({ streamStatusMap: { 'sess-a': 'finish' } });
114
+ expect(await holdPromptForDependency(deps, DEPENDENCY, 'hello', [])).toBe(false);
115
+ expect(recorded.errors).toEqual(['"Refactor parser" has already finished — sending now instead of waiting.']);
116
+ expect(recorded.cleared).toBe(1);
117
+ expect(recorded.drafts).toEqual([]);
118
+ });
119
+
120
+ test('a refused request keeps the draft and reports the server reason', async () => {
121
+ (globalThis as Record<string, unknown>).fetch = async () =>
122
+ new Response(JSON.stringify({ error: 'That session has already finished' }), {
123
+ status: 409,
124
+ headers: { 'content-type': 'application/json' },
125
+ });
126
+ const { deps, recorded } = harness();
127
+ expect(await holdPromptForDependency(deps, DEPENDENCY, 'hello', [])).toBe(false);
128
+ expect(recorded.errors).toEqual(['That session has already finished']);
129
+ expect(recorded.drafts).toEqual([]);
130
+ expect(pendingSessionWait(pendingId)).toBeNull();
131
+ });
132
+
133
+ test('a chat with no workspace is refused before anything is written', async () => {
134
+ const { deps, recorded } = harness({ folders: [], selectedFolderId: null });
135
+ expect(await holdPromptForDependency(deps, DEPENDENCY, 'hello', [])).toBe(false);
136
+ expect(recorded.errors).toEqual(['Pick a workspace before holding this prompt.']);
137
+ expect(createdBody).toEqual({});
138
+ });
139
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Sending a NEW chat's prompt into a wait instead of dispatching it now.
8
+ *
9
+ * The prompt never reaches omp from here: the row is written server-side and a
10
+ * sweep spawns the session when the dependency finishes. That is why this path
11
+ * clears the composer and marks the pending row, and why it does NOT arm the
12
+ * generating state — nothing is running yet, and a spinner would say otherwise.
13
+ *
14
+ * The dependency is re-checked against the live status map at send time. A
15
+ * dropdown opened a minute ago can name a session that has since finished, and
16
+ * a prompt held on it would be released by the very next sweep: the user asked
17
+ * to wait, so the honest outcome is to send it now and say so.
18
+ */
19
+
20
+ import type { Attachment } from '@/shared/types/chat';
21
+ import type { WorkspaceFolderData } from '@/shared/types/workspace';
22
+ import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
23
+ import { createWaitingPromptRequest } from '@/client/hooks/chat/waiting/store';
24
+ import { markPendingSessionWaiting } from '@/client/hooks/chat/omp/pending-waiting';
25
+ import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
26
+
27
+ /**
28
+ * The slice of the composer's deps record a hold needs.
29
+ *
30
+ * Structural, not an import of the actions record: the caller passes that whole
31
+ * record and TypeScript checks it against this, so the two stay decoupled and
32
+ * this module never depends on the send hook's shape.
33
+ */
34
+ export interface ComposerHoldDeps {
35
+ sessionId: string | null;
36
+ folders: WorkspaceFolderData[];
37
+ selectedFolderId: number | null;
38
+ composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
39
+ accessModeRef: { current: string };
40
+ spawnSelectionRef?: { current: { plan: boolean; goal: boolean } | null };
41
+ streamStatusMap?: Record<string, string | undefined>;
42
+ setInputValue: (value: string) => void;
43
+ setInputAttachments: (attachments: Attachment[] | ((prev: Attachment[]) => Attachment[])) => void;
44
+ reportActionError: (message: string) => void;
45
+ clearWaitingDependency?: () => void;
46
+ waitingDependencyRef?: { current: WaitingCandidate | null };
47
+ }
48
+
49
+ /**
50
+ * The composer's own entry point: hold the draft if a dependency is picked and
51
+ * the chat is idle, else answer false and let the normal send proceed.
52
+ *
53
+ * Idle is part of the condition rather than the caller's business: a chat with
54
+ * a run in flight queues its follow-up (or steers), which is a different
55
+ * decision made further down the send path.
56
+ */
57
+ export async function holdFromComposer(
58
+ deps: ComposerHoldDeps,
59
+ text: string,
60
+ attachments: Attachment[],
61
+ busy: boolean,
62
+ ): Promise<boolean> {
63
+ const choice = deps.waitingDependencyRef?.current ?? null;
64
+ if (!choice || busy) return false;
65
+ return holdPromptForDependency({
66
+ sessionId: deps.sessionId,
67
+ folders: deps.folders,
68
+ selectedFolderId: deps.selectedFolderId,
69
+ composerModelRef: deps.composerModelRef,
70
+ accessModeRef: deps.accessModeRef,
71
+ spawnModesRef: deps.spawnSelectionRef ?? { current: null },
72
+ streamStatusMap: deps.streamStatusMap ?? {},
73
+ setInputValue: deps.setInputValue,
74
+ setAttachments: (next) => deps.setInputAttachments(next),
75
+ reportActionError: deps.reportActionError,
76
+ clearChoice: () => deps.clearWaitingDependency?.(),
77
+ }, choice, text, attachments);
78
+ }
79
+
80
+ export interface HoldDeps {
81
+ sessionId: string | null;
82
+ folders: WorkspaceFolderData[];
83
+ selectedFolderId: number | null;
84
+ composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
85
+ accessModeRef: { current: string };
86
+ spawnModesRef: { current: { plan: boolean; goal: boolean } | null };
87
+ streamStatusMap: Record<string, string | undefined>;
88
+ setInputValue: (value: string) => void;
89
+ setAttachments: (attachments: Attachment[]) => void;
90
+ reportActionError: (message: string) => void;
91
+ /** The dependency pick, cleared once the prompt is held. */
92
+ clearChoice: () => void;
93
+ }
94
+
95
+ /**
96
+ * Hold `text` until `dependency` finishes.
97
+ *
98
+ * Returns true when the prompt was held — the caller must then do nothing else.
99
+ * A false return means the caller should fall through to a normal send: either
100
+ * there is no dependency, or it is no longer running.
101
+ */
102
+ export async function holdPromptForDependency(
103
+ deps: HoldDeps,
104
+ dependency: WaitingCandidate | null,
105
+ text: string,
106
+ attachments: Attachment[],
107
+ ): Promise<boolean> {
108
+ if (!dependency) return false;
109
+ if (deps.streamStatusMap[dependency.id] !== 'stream') {
110
+ // Stale pick: the session finished while the draft was being written. Send
111
+ // normally rather than creating a wait that releases on the next sweep.
112
+ deps.reportActionError(`"${dependency.title}" has already finished — sending now instead of waiting.`);
113
+ deps.clearChoice();
114
+ return false;
115
+ }
116
+ const folder = deps.folders.find((entry) => String(entry.id) === String(deps.selectedFolderId));
117
+ const cwd = folder?.project_path || folder?.name;
118
+ if (!cwd) {
119
+ deps.reportActionError('Pick a workspace before holding this prompt.');
120
+ return false;
121
+ }
122
+
123
+ // Attachments become the persisted shape here, while the live `File` handles
124
+ // still exist: the server sends the prompt hours later, from the row.
125
+ const prepared = await prepareQueuedAttachments(attachments);
126
+ const composerPick = deps.composerModelRef.current;
127
+ try {
128
+ const created = await createWaitingPromptRequest({
129
+ pendingId: deps.sessionId,
130
+ dependsOn: dependency.id,
131
+ dependsOnTitle: dependency.title,
132
+ prompt: text,
133
+ attachments: prepared,
134
+ model: composerPick ? { ...composerPick, accessMode: deps.accessModeRef.current } : null,
135
+ modes: deps.spawnModesRef.current,
136
+ cwd,
137
+ folderId: folder ? Number(folder.id) : null,
138
+ });
139
+ if (deps.sessionId) {
140
+ markPendingSessionWaiting(deps.sessionId, {
141
+ waitId: created.id,
142
+ dependsOn: dependency.id,
143
+ dependsOnTitle: dependency.title,
144
+ });
145
+ }
146
+ deps.setInputValue('');
147
+ deps.setAttachments([]);
148
+ deps.clearChoice();
149
+ return true;
150
+ } catch (error) {
151
+ // The draft is KEPT: a refusal that also threw away the prompt would be the
152
+ // worst of both outcomes, and the composer still holds what the user wrote.
153
+ deps.reportActionError(error instanceof Error ? error.message : String(error));
154
+ return false;
155
+ }
156
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Client view of the waiting prompts (`waiting_prompts`, owned by the server).
8
+ *
9
+ * The panel is a VIEW, exactly like the follow-up queue: the server holds the
10
+ * clock, the row is the state, and this hook reads it off the `waiting` topic.
11
+ * The HTTP route is the fallback for a socket that is down.
12
+ */
13
+
14
+ import { useCallback, useEffect, useState } from 'preact/hooks';
15
+ import { useRealtimeTopic } from '@/client/hooks/ui/realtime';
16
+ import { TOPIC_WAITING } from '@/shared/lib/realtime/protocol';
17
+ import type { WaitingPrompt } from '@/shared/types/waiting';
18
+
19
+ export interface WaitingPromptsState {
20
+ prompts: WaitingPrompt[];
21
+ /**
22
+ * Whether the list has been READ from the server.
23
+ *
24
+ * Load-bearing for anything that CLEARS state from an absence: before the
25
+ * first payload, "no row names this wait" is not a fact, it is a page that
26
+ * has not loaded yet. Clearing on it deleted a cue the user had just created —
27
+ * the controller's effect runs on the render the hold triggers, one frame
28
+ * before the topic's first snapshot.
29
+ */
30
+ loaded: boolean;
31
+ /** Ask the server for a fresh snapshot (a manual refresh). */
32
+ refresh: () => void;
33
+ }
34
+
35
+ export function useWaitingPrompts(): WaitingPromptsState {
36
+ const topic = useRealtimeTopic<WaitingPrompt[]>(TOPIC_WAITING);
37
+ const [fallback, setFallback] = useState<WaitingPrompt[]>([]);
38
+ const [loadedFallback, setLoadedFallback] = useState(false);
39
+
40
+ // The topic is the source; the HTTP read only fills in before the first frame
41
+ // lands, or when the socket is down (a phone that just woke).
42
+ useEffect(() => {
43
+ if (Array.isArray(topic.data)) return;
44
+ let active = true;
45
+ fetch('/api/waiting')
46
+ .then((response) => (response.ok ? response.json() : null))
47
+ .then((data) => {
48
+ if (!active || !Array.isArray(data?.prompts)) return;
49
+ setFallback(data.prompts as WaitingPrompt[]);
50
+ setLoadedFallback(true);
51
+ })
52
+ .catch(() => {});
53
+ return () => {
54
+ active = false;
55
+ };
56
+ }, [topic.data]);
57
+
58
+ const fromTopic = Array.isArray(topic.data);
59
+ const prompts = fromTopic ? (topic.data as WaitingPrompt[]) : fallback;
60
+ const refresh = useCallback(() => topic.refresh(), [topic]);
61
+ return {
62
+ prompts,
63
+ // The HTTP read counts too: it is the same list, and a socket that is down
64
+ // must not leave every consumer believing the server holds nothing.
65
+ loaded: fromTopic || loadedFallback,
66
+ refresh,
67
+ };
68
+ }
69
+
70
+ /** Create a held prompt. Throws the server's own reason on a refusal. */
71
+ export async function createWaitingPromptRequest(input: {
72
+ pendingId: string | null;
73
+ dependsOn: string;
74
+ dependsOnTitle: string | null;
75
+ prompt: string;
76
+ attachments: unknown[];
77
+ model: unknown;
78
+ modes: { plan: boolean; goal: boolean } | null;
79
+ cwd: string;
80
+ folderId: number | null;
81
+ }): Promise<WaitingPrompt> {
82
+ const response = await fetch('/api/waiting', {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify(input),
86
+ });
87
+ const data = await response.json().catch(() => null);
88
+ if (!response.ok || !data?.prompt) {
89
+ throw new Error(typeof data?.error === 'string' ? data.error : `HTTP ${response.status}`);
90
+ }
91
+ return data.prompt as WaitingPrompt;
92
+ }
93
+
94
+ /** Cancel a held prompt. Returns the server's refusal rather than swallowing it. */
95
+ export async function cancelWaitingPromptRequest(id: string): Promise<{ ok: boolean; error?: string }> {
96
+ const response = await fetch(`/api/waiting?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
97
+ if (response.ok) return { ok: true };
98
+ const data = await response.json().catch(() => null);
99
+ return { ok: false, error: typeof data?.error === 'string' ? data.error : `HTTP ${response.status}` };
100
+ }
@@ -1,7 +1,18 @@
1
1
  import { useRef, useState } from 'preact/hooks';
2
2
  import type { ToastData } from '@/client/components/common/Toast';
3
3
 
4
- export function useToasts() {
4
+ /** What `useToasts` hands back — named, so a component takes the contract rather than `ReturnType`. */
5
+ export interface ToastsHandle {
6
+ toasts: ToastData[];
7
+ pushToast: (
8
+ message: string,
9
+ type?: 'success' | 'error',
10
+ options?: { action?: ToastData['action']; duration?: number },
11
+ ) => void;
12
+ dismissToast: (id: number) => void;
13
+ }
14
+
15
+ export function useToasts(): ToastsHandle {
5
16
  const [toasts, setToasts] = useState<ToastData[]>([]);
6
17
  const toastIdRef = useRef(0);
7
18