@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +412 -383
- package/dist/client/static/js/index-3qamd33d.css +1 -0
- package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
- package/package.json +1 -1
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
- package/src/client/components/common/session-actions-menu/index.tsx +23 -2
- package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
- package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
- package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
- package/src/client/components/layout/LeftDock.tsx +119 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
- package/src/client/components/layout/desktop-layout/index.tsx +88 -61
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
- package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
- package/src/client/components/mobile/RightSidebar.tsx +8 -5
- package/src/client/components/mobile/SessionSidebar.tsx +6 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
- package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
- package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
- package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
- package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
- package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
- package/src/client/components/workspace/editor/index.tsx +1 -0
- package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
- package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
- package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
- package/src/client/components/workspace/panels/builtin.tsx +8 -0
- package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
- package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
- package/src/client/hooks/chat/composer/compact.ts +119 -0
- package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
- package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
- package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
- package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
- package/src/client/hooks/chat/timeline/actions.ts +34 -35
- package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
- package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
- package/src/client/hooks/chat/timeline/index.ts +15 -19
- package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
- package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
- package/src/client/hooks/chat/waiting/cancel.ts +34 -0
- package/src/client/hooks/chat/waiting/choice.ts +53 -0
- package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
- package/src/client/hooks/chat/waiting/held.ts +42 -0
- package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
- package/src/client/hooks/chat/waiting/hold.ts +156 -0
- package/src/client/hooks/chat/waiting/store.ts +100 -0
- package/src/client/hooks/workspace/left-dock.ts +132 -0
- package/src/client/hooks/workspace/panel-registry.ts +13 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.test.ts +52 -1
- package/src/server/db.server.ts +49 -1
- package/src/server/index.ts +9 -0
- package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
- package/src/server/lib/assets/compressed-cache.server.ts +88 -0
- package/src/server/lib/db/schema.test.ts +1 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/fs/fs-path.test.ts +1 -1
- package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
- package/src/server/lib/omp/session/delete.server.ts +8 -0
- package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
- package/src/server/lib/panels/registry.server.ts +2 -2
- package/src/server/lib/queue/delivery.server.ts +31 -39
- package/src/server/lib/queue/payload.ts +73 -0
- package/src/server/lib/realtime/signals.server.ts +10 -1
- package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
- package/src/server/lib/realtime/topics/global.server.ts +5 -0
- package/src/server/lib/realtime/topics.server.ts +2 -0
- package/src/server/lib/util/with-timeout.ts +29 -0
- package/src/server/lib/waiting/row-clock.server.ts +37 -0
- package/src/server/lib/waiting/runtime.server.test.ts +278 -0
- package/src/server/lib/waiting/runtime.server.ts +313 -0
- package/src/server/lib/waiting/schema.server.ts +46 -0
- package/src/server/lib/waiting/settle.test.ts +84 -0
- package/src/server/lib/waiting/settle.ts +77 -0
- package/src/server/lib/waiting/store.server.test.ts +167 -0
- package/src/server/lib/waiting/store.server.ts +268 -0
- package/src/server/lib/waiting/sweep-trigger.ts +35 -0
- package/src/server/plugins/compress.ts +18 -6
- package/src/server/plugins/ssr.ts +16 -8
- package/src/server/plugins/static.test.ts +59 -0
- package/src/server/plugins/static.ts +56 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/waiting/bindings.ts +14 -0
- package/src/server/routes/waiting/index.ts +123 -0
- package/src/server/routes/waiting/routes.test.ts +121 -0
- package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
- package/src/shared/lib/chat/timeline/gutter.ts +37 -0
- package/src/shared/lib/realtime/protocol.ts +10 -0
- package/src/shared/lib/workspace/left-dock-width.ts +78 -0
- package/src/shared/lib/workspace/left-dock.test.ts +203 -0
- package/src/shared/lib/workspace/left-dock.ts +197 -0
- package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
- package/src/shared/lib/workspace/panel-ids.ts +114 -26
- package/src/shared/lib/workspace/sidebar-width.ts +4 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/waiting.ts +54 -0
- package/src/shared/types/workspace.ts +8 -0
- package/src/test-support/isolated-db.ts +3 -4
- 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
|
|
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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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:
|
|
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
|
-
|
|
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
|