@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
@@ -1,18 +1,13 @@
1
1
  import { useMemo } from 'preact/hooks';
2
- import type { ComponentType, ReactNode } from 'preact/compat';
2
+ import type { ComponentType } from 'preact/compat';
3
3
  import type { RightPanelProps } from '@kabmo-labs/kabmo-sdk/app';
4
4
  import { usePanelRegistry, usePanelSlots } from '@/client/hooks/workspace/panel-registry';
5
- import { usePanelOrder } from '@/client/hooks/workspace/panel-order';
6
- import { pluginIdOf, pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
7
- import { resolvePluginPanelWidths } from '@/shared/lib/workspace/panel-widths';
8
- import { PluginIdProvider } from '@kabmo-labs/kabmo-ui';
5
+ import { pluginEnablementKey, pluginIdOf, pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
6
+ import { panelSideOf } from '@/shared/lib/workspace/left-dock';
7
+ import { useLeftDock } from '@/client/hooks/workspace/left-dock';
9
8
  import { panelOf, rightPanelOf } from '@/client/lib/plugins/slots';
10
9
  import { PluginError } from '@/client/components/workspace/plugin-panel/Error';
11
- import {
12
- enabledBuiltinPanels,
13
- type BuiltinPanelView,
14
- type PanelBodyProps,
15
- } from '@/client/components/workspace/panels/builtin';
10
+ import type { PanelBodyProps } from '@/client/components/workspace/panels/builtin';
16
11
 
17
12
  export type { PanelBodyProps };
18
13
 
@@ -60,7 +55,7 @@ export function usePluginPanels(activePanel: string): PluginPanels {
60
55
  if (!Component) continue;
61
56
  const size = entry?.sizing.rightPanel;
62
57
  out.push({
63
- panelKey: pluginPanelKey(panel.pluginId),
58
+ panelKey: pluginPanelKey(panel.pluginId, 'rightPanel'),
64
59
  pluginId: panel.pluginId,
65
60
  name: panel.name,
66
61
  title: entry?.titles.rightPanel ?? panel.name,
@@ -114,8 +109,9 @@ export function PluginPanelBody({ panelKey }: { panelKey: string }) {
114
109
  * it renders, so nothing here assumes what it contains.
115
110
  */
116
111
  export function usePanelSlotPlugins(): ResolvedPluginPanel[] {
117
- const { panels } = usePanelRegistry();
112
+ const { panels, disabledPanels } = usePanelRegistry();
118
113
  const { slots } = usePanelSlots();
114
+ const dock = useLeftDock();
119
115
 
120
116
  return useMemo(() => {
121
117
  const out: ResolvedPluginPanel[] = [];
@@ -123,9 +119,17 @@ export function usePanelSlotPlugins(): ResolvedPluginPanel[] {
123
119
  const entry = slots.get(panel.pluginId);
124
120
  const Component = entry ? panelOf(entry) : undefined;
125
121
  if (!Component) continue;
122
+ // Enablement is per PLUGIN, so a switched-off plugin contributes nothing
123
+ // to any column — the same rule the right-panel catalog uses.
124
+ if (disabledPanels.includes(pluginEnablementKey(panel.pluginId))) continue;
125
+ const key = pluginPanelKey(panel.pluginId, 'panel');
126
+ // The column is one destination, and a `panel` slot only rides it. A row
127
+ // in the dock's placement list naming this instance is therefore ignored
128
+ // by `panelSideOf`'s slot rule rather than honoured.
129
+ if (panelSideOf(key, dock) !== 'editor') continue;
126
130
  const size = entry?.sizing.panel;
127
131
  out.push({
128
- panelKey: pluginPanelKey(panel.pluginId),
132
+ panelKey: key,
129
133
  pluginId: panel.pluginId,
130
134
  name: panel.name,
131
135
  title: entry?.titles.panel ?? panel.name,
@@ -136,7 +140,7 @@ export function usePanelSlotPlugins(): ResolvedPluginPanel[] {
136
140
  });
137
141
  }
138
142
  return out;
139
- }, [panels, slots]);
143
+ }, [panels, slots, disabledPanels, dock]);
140
144
  }
141
145
 
142
146
  /**
@@ -153,111 +157,4 @@ export function useActiveEditorPanel(activeKey: string | null): ResolvedPluginPa
153
157
  return activeKey ? plugins.find((panel) => panel.panelKey === activeKey) ?? null : null;
154
158
  }
155
159
 
156
- /** A built-in view's catalog entry, with its renderer closed over its component. */
157
- function builtinEntry(view: BuiltinPanelView): ResolvedPanel {
158
- const View = view.Component;
159
- return {
160
- id: view.id,
161
- title: view.title,
162
- label: view.label,
163
- icon: view.icon,
164
- name: '',
165
- builtin: true,
166
- requiresWorkspace: view.requiresWorkspace,
167
- liveWhileHidden: view.liveWhileHidden === true,
168
- minWidth: view.minWidth,
169
- defaultWidth: view.defaultWidth,
170
- defaultFraction: view.defaultFraction,
171
- render: (props) => <View {...props} />,
172
- };
173
- }
174
-
175
- /** One panel in the merged catalog — a built-in view or an installed plugin. */
176
- export interface ResolvedPanel {
177
- /** The panel id: a bare built-in view id, or `plugin:<pluginId>`. */
178
- id: string;
179
- /** The activity bar's tooltip. */
180
- title: string;
181
- /** The phone's chip label. */
182
- label: string;
183
- /** A built-in view's own icon; a plugin draws its mark instead. */
184
- icon?: ReactNode;
185
- /** A plugin's icon URL, when its manifest declares one. */
186
- iconUrl?: string;
187
- /** The plugin's display name; empty for a built-in view. */
188
- name: string;
189
- builtin: boolean;
190
- requiresWorkspace: boolean;
191
- liveWhileHidden: boolean;
192
- minWidth: number;
193
- /** The px width the view opens at before the group's area is measured. */
194
- defaultWidth: number;
195
- defaultFraction: number;
196
- /** Render the body. One call, so both layouts draw a view the same way. */
197
- render: (props: PanelBodyProps) => ReactNode;
198
- }
199
-
200
- /**
201
- * The panel catalog the activity bar and the panel stack render.
202
- *
203
- * ONE list, two sources: the built-in views (in `RIGHT_PANEL_TYPES` order) and
204
- * the installed plugins (in the registry's order). Enablement is ONE set of ids
205
- * — a built-in view is off by its bare id and a plugin by `plugin:<id>` — so a
206
- * view the user switched off disappears from the bar, the phone's strip and the
207
- * right-click menu alike, whichever kind it is.
208
- *
209
- * The BASE order is built-ins then plugins, which is what makes the bar stable
210
- * across reloads rather than following whichever import finished first; the
211
- * user's own order (`usePanelOrder`) is laid over it, so a dragged button stays
212
- * where it was put and a panel the order does not name keeps its base position.
213
- * Applied HERE rather than in the bar, because the desktop stack and the phone's
214
- * tab strip render from this same list — ordering at one call site would leave
215
- * the other two drawing the base order.
216
- */
217
- export function usePanelCatalog(): ResolvedPanel[] {
218
- const { panels, disabledPanels } = usePanelRegistry();
219
- const { slots } = usePanelSlots();
220
- const { apply } = usePanelOrder();
221
-
222
- return useMemo(() => {
223
- const out: ResolvedPanel[] = [];
224
-
225
- for (const view of enabledBuiltinPanels(disabledPanels)) {
226
- out.push(builtinEntry(view));
227
- }
228
-
229
- for (const panel of panels) {
230
- const entry = slots.get(panel.pluginId);
231
- const Component = entry ? rightPanelOf(entry) : undefined;
232
- if (!Component) continue;
233
- const key = pluginPanelKey(panel.pluginId);
234
- if (disabledPanels.includes(key)) continue;
235
- const size = entry?.sizing.rightPanel;
236
- const widths = resolvePluginPanelWidths({ ...size });
237
- out.push({
238
- id: key,
239
- title: `${entry?.titles.rightPanel ?? panel.name} — ${panel.name}`,
240
- label: entry?.titles.rightPanel ?? panel.name,
241
- name: panel.name,
242
- builtin: false,
243
- // A plugin reads whatever the host published and draws what it can, so
244
- // gating it on a workspace folder would hide a panel that works.
245
- requiresWorkspace: false,
246
- liveWhileHidden: false,
247
- minWidth: widths.min,
248
- defaultWidth: widths.px,
249
- defaultFraction: widths.fraction,
250
- ...(panel.iconUrl ? { iconUrl: panel.iconUrl } : {}),
251
- // Wrapped so the kit's participant hooks resolve THIS plugin's id while
252
- // its panel renders — the scope is what `useRpc`/`useRealtime` read.
253
- render: (props) => (
254
- <PluginIdProvider pluginId={panel.pluginId}>
255
- <Component sessionId={props.sessionId ?? null} workspacePath={null} />
256
- </PluginIdProvider>
257
- ),
258
- });
259
- }
260
-
261
- return apply(out);
262
- }, [panels, slots, disabledPanels, apply]);
263
- }
160
+ export { usePanelCatalog, isSessionsPanel, type PanelCatalogSide, type ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone composer's "give the height back" state, and the tap that ends it.
8
+ *
9
+ * A reader scrolled back through the transcript with an empty draft gets the
10
+ * conversation's height back — the textarea is REMOVED rather than shrunk (a
11
+ * shrunk one keeps a focusable element with no usable pixels and a
12
+ * sentence-long placeholder clipped mid-word). `AttachmentToolbar` then carries
13
+ * the "Type here" target that brings the box back.
14
+ *
15
+ * Two properties are load-bearing and each was a bug first:
16
+ *
17
+ * - **The reopen flag is not derived from focus.** Re-opening removes the
18
+ * button that was tapped, so the browser emits a `focusout` for it in the
19
+ * same frame the textarea is being inserted — measured order:
20
+ * `focusin:BUTTON, focusout:BUTTON, ta-added, ta-removed`. Any flag derived
21
+ * from focus collapses the composer it just opened.
22
+ * - **The decision is made on POINTERDOWN, not click.** Pressing the target
23
+ * fires `focusin`, which expands the composer, which removes the button the
24
+ * finger is still on — so the pointerup lands on a different target and the
25
+ * browser emits no click at all (measured: `pd:BUTTON` with no `click:`
26
+ * line after it).
27
+ *
28
+ * Only reaching for something OUTSIDE the card takes the composer back to
29
+ * compact: the reader asked for the box, and nothing the focus system does on
30
+ * the way to it may take that back.
31
+ */
32
+
33
+ import { useEffect, useState } from 'preact/hooks';
34
+ import type { RefObject } from 'preact/compat';
35
+ import type { Attachment } from '@/shared/types';
36
+
37
+ export interface ComposerCompactInput {
38
+ /** The composer's card — the element the tap handler is bound to. */
39
+ cardRef: RefObject<HTMLDivElement>;
40
+ /** Only a phone gives its height back; a desktop sits beside the chat. */
41
+ isMobile: boolean;
42
+ /** The timeline says the reader is back through the transcript. */
43
+ shrinkWhileReading: boolean;
44
+ /** Anything inside the card has focus. */
45
+ composerFocused: boolean;
46
+ setComposerFocused: (focused: boolean) => void;
47
+ value: string;
48
+ attachments: Attachment[];
49
+ }
50
+
51
+ export interface ComposerCompact {
52
+ /** True when the textarea must not be rendered. */
53
+ compact: boolean;
54
+ /** The reader tapped the card to get the box back. */
55
+ reopen: () => void;
56
+ /** The card's `onPointerDown`, for every state (focuses the draft when open). */
57
+ onCardPointerDown: (event: PointerEvent) => void;
58
+ }
59
+
60
+ /** Controls that own their own press — a tap on one must not re-open the card. */
61
+ const OWN_PRESS = 'button, input, textarea, a, label, [role="button"]';
62
+
63
+ export function useComposerCompact({
64
+ cardRef,
65
+ isMobile,
66
+ shrinkWhileReading,
67
+ composerFocused,
68
+ setComposerFocused,
69
+ value,
70
+ attachments,
71
+ }: ComposerCompactInput): ComposerCompact {
72
+ const [reopenRequested, setReopenRequested] = useState(false);
73
+
74
+ // A draft means the reader is writing, and a focused composer is the same
75
+ // rule from the other side: the tap that re-opens it must not be undone by
76
+ // the scroll position it was tapped from.
77
+ const compact = isMobile
78
+ && shrinkWhileReading
79
+ && !composerFocused
80
+ && !reopenRequested
81
+ && !value.trim()
82
+ && attachments.length === 0;
83
+
84
+ // The tap ends with the caret where the reader aimed it. Runs on the commit
85
+ // that put the textarea back in the DOM, which is why it cannot live in the
86
+ // press handler. The flag is NOT cleared here — see the module note.
87
+ useEffect(() => {
88
+ if (!reopenRequested) return;
89
+ cardRef.current?.querySelector('textarea')?.focus();
90
+ }, [reopenRequested, cardRef]);
91
+
92
+ useEffect(() => {
93
+ if (!reopenRequested) return;
94
+ const onOutside = (event: Event) => {
95
+ if (cardRef.current?.contains(event.target as Node)) return;
96
+ setReopenRequested(false);
97
+ setComposerFocused(false);
98
+ };
99
+ document.addEventListener('pointerdown', onOutside);
100
+ return () => document.removeEventListener('pointerdown', onOutside);
101
+ }, [reopenRequested, cardRef, setComposerFocused]);
102
+
103
+ const onCardPointerDown = (event: PointerEvent) => {
104
+ const target = event.target as HTMLElement;
105
+ if (target.closest(OWN_PRESS)) {
106
+ // The "Type here" target is the ONE control that must open the box; it is
107
+ // marked rather than special-cased by its text.
108
+ if (target.closest('[data-composer-reopen]')) setReopenRequested(true);
109
+ return;
110
+ }
111
+ if (compact) {
112
+ setReopenRequested(true);
113
+ return;
114
+ }
115
+ cardRef.current?.querySelector('textarea')?.focus();
116
+ };
117
+
118
+ return { compact, reopen: () => setReopenRequested(true), onCardPointerDown };
119
+ }
@@ -64,6 +64,10 @@ export function withPendingRows(
64
64
  // newest and reshuffled LATEST_SESSION ordering mid-run.
65
65
  created_at: stamp,
66
66
  updated_at: stamp,
67
+ // The row's own reason for existing past the click: its prompt is held.
68
+ ...(entry.waiting
69
+ ? { waitingFor: { waitId: entry.waiting.waitId, sessionId: entry.waiting.dependsOn, title: entry.waiting.dependsOnTitle } }
70
+ : {}),
67
71
  };
68
72
  result = result.map((folder) => (
69
73
  folder.id === target.id
@@ -57,6 +57,19 @@ export interface PendingSessionEntry {
57
57
  /** Epoch ms a prompt was dispatched for this chat, or null while it is a
58
58
  * draft the user has not sent in. */
59
59
  sentAt: number | null;
60
+ /**
61
+ * Set while this chat's prompt is HELD for another session to finish.
62
+ *
63
+ * The row is drawn exactly as it was before the send — same id, same folder —
64
+ * because the session does not exist yet: what the user asked for is that the
65
+ * row stays put and says what it is waiting on. `waitId` is the server row
66
+ * that owns the wait, so cancelling from the row reaches it.
67
+ */
68
+ waiting?: {
69
+ waitId: string;
70
+ dependsOn: string;
71
+ dependsOnTitle: string | null;
72
+ };
60
73
  }
61
74
 
62
75
  /** How long a SENT entry may outlive its switch with no real row behind it.
@@ -90,11 +103,15 @@ function createdAtFor(id: string): number {
90
103
  /** Write an entry, emitting only when it actually moved. */
91
104
  function upsert(entry: PendingSessionEntry): void {
92
105
  const current = entries.get(entry.id);
106
+ const sameWait = current?.waiting?.waitId === entry.waiting?.waitId
107
+ && current?.waiting?.dependsOn === entry.waiting?.dependsOn
108
+ && current?.waiting?.dependsOnTitle === entry.waiting?.dependsOnTitle;
93
109
  if (
94
110
  current
95
111
  && current.folderId === entry.folderId
96
112
  && current.createdAt === entry.createdAt
97
113
  && current.sentAt === entry.sentAt
114
+ && sameWait
98
115
  ) {
99
116
  return;
100
117
  }
@@ -160,6 +177,8 @@ export function adoptPendingSession(
160
177
  folderId: from?.folderId ?? folderId ?? null,
161
178
  createdAt: from?.createdAt ?? createdAtFor(toId),
162
179
  sentAt: from?.sentAt ?? Date.now(),
180
+ // The wait ended when the spawn answered: the new row is a real session
181
+ // from this moment, and a cue left on it would say it is still held.
163
182
  });
164
183
  }
165
184
 
@@ -216,6 +235,28 @@ export function hydratePendingSession(
216
235
  upsert({ id, folderId, createdAt: createdAtFor(id), sentAt: running ? Date.now() : null });
217
236
  }
218
237
 
238
+ /**
239
+ * Rewrite one entry through the store's own write path.
240
+ *
241
+ * Exported for the WAITING half (`pending-waiting.ts`): the field is a normal
242
+ * part of the entry, so it must go through the same no-op check and the same
243
+ * emit rather than mutating the map behind the store's back.
244
+ */
245
+ export function patchPendingEntry(
246
+ id: string,
247
+ update: (entry: PendingSessionEntry) => PendingSessionEntry | null,
248
+ ): void {
249
+ const entry = entries.get(id);
250
+ if (!entry) return;
251
+ const next = update(entry);
252
+ if (next === null) {
253
+ entries.delete(id);
254
+ emit();
255
+ return;
256
+ }
257
+ upsert(next);
258
+ }
259
+
219
260
  /** Drop an entry (a released draft, a session that went away). */
220
261
  export function releasePendingSession(id: string): void {
221
262
  if (entries.delete(id)) emit();
@@ -252,6 +293,12 @@ export function reconcilePendingSessions(input: {
252
293
  // A live run holds its row wherever the user navigates: the spinner and
253
294
  // the row it belongs to are the same fact.
254
295
  if (input.running.has(id)) continue;
296
+ // A HELD prompt holds its row for as long as the wait lasts. The settle
297
+ // window below exists because a sent prompt's transcript is seconds away;
298
+ // a held prompt has no transcript at all yet, so expiring it after 30s
299
+ // would delete the very row the user is waiting on. The server's own list
300
+ // is what ends this — the dispatch watcher clears the cue.
301
+ if (entry.waiting) continue;
255
302
  if (entry.sentAt !== null) {
256
303
  if (now - entry.sentAt > PENDING_SETTLE_MS) {
257
304
  entries.delete(id);
@@ -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