@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.
- 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-cs3b557t.js} +792 -792
- 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/TopNavbar.tsx +5 -3
- 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/Header.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
- 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/LayoutWrapper.tsx +4 -0
- package/src/client/components/mobile/RightSidebar.tsx +30 -36
- package/src/client/components/mobile/SessionSidebar.tsx +124 -101
- package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
- 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 +47 -14
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
- package/src/client/components/mobile/panel-chips.test.tsx +155 -0
- package/src/client/components/mobile/panel-chips.tsx +74 -0
- 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 +15 -10
- 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 +162 -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/index.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
- 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/queue-rewind.test.ts +6 -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/ui/toasts.ts +12 -1
- 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/ui/titlebar.test.ts +124 -0
- package/src/shared/lib/ui/titlebar.ts +54 -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/mobile-dock.test.ts +65 -0
- package/src/shared/lib/workspace/mobile-dock.ts +55 -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/src/test-support/silent-socket.ts +69 -0
- package/dist/client/static/js/index-pdp5dwc8.css +0 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The panel catalog: one list, two columns.
|
|
8
|
+
*
|
|
9
|
+
* The built-in views and the installed plugins travel the SAME path — one
|
|
10
|
+
* merged list, one enablement set, one entry shape — so the activity bars, the
|
|
11
|
+
* desktop panel stacks and the phone's tab strip cannot disagree about what
|
|
12
|
+
* exists or what it is called.
|
|
13
|
+
*
|
|
14
|
+
* Two axes are decided here and nowhere else:
|
|
15
|
+
*
|
|
16
|
+
* - **SIDE.** A built-in view is `right` by construction; a plugin panel is
|
|
17
|
+
* `left` only while the user placed it there (`left-dock.ts`). A caller asks
|
|
18
|
+
* for the column it draws (`usePanelCatalog('left')`), or for everything
|
|
19
|
+
* (`usePanelCatalog('all')`, which is what the phone's drawer reads — a panel
|
|
20
|
+
* placed on the left must stay reachable on a phone, which has one drawer).
|
|
21
|
+
* - **ORDER.** The right column keeps the user's dragged order
|
|
22
|
+
* (`omp_right_panel_order`); the left column is ordered by the placement list
|
|
23
|
+
* itself, because placing a panel there IS the arrangement.
|
|
24
|
+
*
|
|
25
|
+
* The left column also carries a view of its own — `sessions`, the session list
|
|
26
|
+
* that used to BE the column. It is a catalog entry like any other so the bar
|
|
27
|
+
* draws it with the same button, the same active state and the same visibility
|
|
28
|
+
* menu; it is never removable, because a chat with no way back to the session
|
|
29
|
+
* list is a dead end.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { lazy } from 'preact/compat';
|
|
33
|
+
import type { ReactNode } from 'preact/compat';
|
|
34
|
+
import { MessagesSquare } from 'lucide-preact';
|
|
35
|
+
import { useMemo } from 'preact/hooks';
|
|
36
|
+
import { usePanelRegistry, usePanelSlots } from '@/client/hooks/workspace/panel-registry';
|
|
37
|
+
import { usePanelOrder } from '@/client/hooks/workspace/panel-order';
|
|
38
|
+
import { useLeftDock } from '@/client/hooks/workspace/left-dock';
|
|
39
|
+
import { PluginIdProvider } from '@kabmo-labs/kabmo-ui';
|
|
40
|
+
import { rightPanelOf } from '@/client/lib/plugins/slots';
|
|
41
|
+
import { pluginEnablementKey, pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
|
|
42
|
+
import { SESSIONS_VIEW_ID, panelSideOf, type PanelSide } from '@/shared/lib/workspace/left-dock';
|
|
43
|
+
import { resolvePluginPanelWidths } from '@/shared/lib/workspace/panel-widths';
|
|
44
|
+
import { DEFAULT_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH } from '@/shared/lib/workspace/sidebar-width';
|
|
45
|
+
import {
|
|
46
|
+
enabledBuiltinPanels,
|
|
47
|
+
type BuiltinPanelView,
|
|
48
|
+
type PanelBodyProps,
|
|
49
|
+
} from '@/client/components/workspace/panels/builtin';
|
|
50
|
+
|
|
51
|
+
/** `left`, `right`, or both — `all` is the phone's drawer and the settings pane. */
|
|
52
|
+
export type PanelCatalogSide = PanelSide | 'all';
|
|
53
|
+
|
|
54
|
+
/** One panel in the merged catalog — a built-in view, the dock's view, or a plugin. */
|
|
55
|
+
export interface ResolvedPanel {
|
|
56
|
+
/** The panel id: a bare built-in view id, `sessions`, or `plugin:<pluginId>`. */
|
|
57
|
+
id: string;
|
|
58
|
+
/** The column it is drawn in. */
|
|
59
|
+
side: PanelSide;
|
|
60
|
+
/** The activity bar's tooltip. */
|
|
61
|
+
title: string;
|
|
62
|
+
/** The phone's chip label. */
|
|
63
|
+
label: string;
|
|
64
|
+
/** A built-in view's own icon; a plugin draws its mark instead. */
|
|
65
|
+
icon?: ReactNode;
|
|
66
|
+
/** A plugin's icon URL, when its manifest declares one. */
|
|
67
|
+
iconUrl?: string;
|
|
68
|
+
/** The plugin's display name; empty for a built-in view. */
|
|
69
|
+
name: string;
|
|
70
|
+
builtin: boolean;
|
|
71
|
+
requiresWorkspace: boolean;
|
|
72
|
+
liveWhileHidden: boolean;
|
|
73
|
+
minWidth: number;
|
|
74
|
+
/** The px width the view opens at before the group's area is measured. */
|
|
75
|
+
defaultWidth: number;
|
|
76
|
+
defaultFraction: number;
|
|
77
|
+
/** Render the body. One call, so every layout draws a view the same way. */
|
|
78
|
+
render: (props: PanelBodyProps) => ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const SessionsView = lazy(() =>
|
|
82
|
+
import('@/client/components/layout/session-sidebar/SessionsView').then((m) => ({ default: m.SessionsView })),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/** The dock's own view. Never removable, never placed anywhere else. */
|
|
86
|
+
const SESSIONS_PANEL: ResolvedPanel = {
|
|
87
|
+
id: SESSIONS_VIEW_ID,
|
|
88
|
+
side: 'left',
|
|
89
|
+
title: 'Sessions',
|
|
90
|
+
label: 'Sessions',
|
|
91
|
+
icon: <MessagesSquare size={16} />,
|
|
92
|
+
name: '',
|
|
93
|
+
builtin: true,
|
|
94
|
+
// The list is the workspace's own navigation: it reads folders and sessions,
|
|
95
|
+
// not the working tree of the active folder.
|
|
96
|
+
requiresWorkspace: false,
|
|
97
|
+
liveWhileHidden: false,
|
|
98
|
+
minWidth: MIN_LEFT_PANEL_WIDTH,
|
|
99
|
+
defaultWidth: DEFAULT_LEFT_PANEL_WIDTH,
|
|
100
|
+
// The left column is px-sized (a session list does not get more useful on a
|
|
101
|
+
// wider monitor), so the fraction is only what a phone's drawer would use.
|
|
102
|
+
defaultFraction: 0.25,
|
|
103
|
+
// The column's title row and footer are NOT this view's: `LeftColumn` draws
|
|
104
|
+
// them, so the rail and the list sit under ONE title and above ONE strip.
|
|
105
|
+
render: (props) => <SessionsView className={props.className} appSettings={props.appSettings} />,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** A built-in view's catalog entry, with its renderer closed over its component. */
|
|
109
|
+
function builtinEntry(view: BuiltinPanelView): ResolvedPanel {
|
|
110
|
+
const View = view.Component;
|
|
111
|
+
return {
|
|
112
|
+
id: view.id,
|
|
113
|
+
// A built-in view is a RIGHT-column view by construction: its floor, width
|
|
114
|
+
// slot and workspace gate were all declared for that column.
|
|
115
|
+
side: 'right',
|
|
116
|
+
title: view.title,
|
|
117
|
+
label: view.label,
|
|
118
|
+
icon: view.icon,
|
|
119
|
+
name: '',
|
|
120
|
+
builtin: true,
|
|
121
|
+
requiresWorkspace: view.requiresWorkspace,
|
|
122
|
+
liveWhileHidden: view.liveWhileHidden === true,
|
|
123
|
+
minWidth: view.minWidth,
|
|
124
|
+
defaultWidth: view.defaultWidth,
|
|
125
|
+
defaultFraction: view.defaultFraction,
|
|
126
|
+
render: (props) => <View {...props} />,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The panel catalog the activity bars and the panel stacks render.
|
|
132
|
+
*
|
|
133
|
+
* `side` selects the column. `all` is deliberately a first-class value rather
|
|
134
|
+
* than "no filter": the phone's drawer and the settings pane both need every
|
|
135
|
+
* panel in one list, and a caller that forgot to pass a side would otherwise get
|
|
136
|
+
* a silently halved catalog.
|
|
137
|
+
*/
|
|
138
|
+
export function usePanelCatalog(side: PanelCatalogSide = 'right'): ResolvedPanel[] {
|
|
139
|
+
const { panels, disabledPanels } = usePanelRegistry();
|
|
140
|
+
const { slots } = usePanelSlots();
|
|
141
|
+
const { apply } = usePanelOrder();
|
|
142
|
+
const dock = useLeftDock();
|
|
143
|
+
|
|
144
|
+
return useMemo(() => {
|
|
145
|
+
const out: ResolvedPanel[] = [];
|
|
146
|
+
|
|
147
|
+
if (side !== 'left') {
|
|
148
|
+
for (const view of enabledBuiltinPanels(disabledPanels)) out.push(builtinEntry(view));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const placed = new Map(dock.panels.map((id, index) => [id, index]));
|
|
152
|
+
const left: ResolvedPanel[] = [];
|
|
153
|
+
|
|
154
|
+
for (const panel of panels) {
|
|
155
|
+
const entry = slots.get(panel.pluginId);
|
|
156
|
+
const Component = entry ? rightPanelOf(entry) : undefined;
|
|
157
|
+
if (!Component) continue;
|
|
158
|
+
const key = pluginPanelKey(panel.pluginId, 'rightPanel');
|
|
159
|
+
if (disabledPanels.includes(pluginEnablementKey(panel.pluginId))) continue;
|
|
160
|
+
const size = entry?.sizing.rightPanel;
|
|
161
|
+
const widths = resolvePluginPanelWidths({ ...size });
|
|
162
|
+
const resolved: ResolvedPanel = {
|
|
163
|
+
id: key,
|
|
164
|
+
// Where the USER put it, keyed per PANEL INSTANCE. A plugin that also
|
|
165
|
+
// contributes a takeover has a second, independent key, so placing this
|
|
166
|
+
// one on the left never moves that one.
|
|
167
|
+
side: panelSideOf(key, dock),
|
|
168
|
+
title: `${entry?.titles.rightPanel ?? panel.name} — ${panel.name}`,
|
|
169
|
+
label: entry?.titles.rightPanel ?? panel.name,
|
|
170
|
+
name: panel.name,
|
|
171
|
+
builtin: false,
|
|
172
|
+
// A plugin reads whatever the host published and draws what it can, so
|
|
173
|
+
// gating it on a workspace folder would hide a panel that works.
|
|
174
|
+
requiresWorkspace: false,
|
|
175
|
+
liveWhileHidden: false,
|
|
176
|
+
minWidth: widths.min,
|
|
177
|
+
defaultWidth: widths.px,
|
|
178
|
+
defaultFraction: widths.fraction,
|
|
179
|
+
...(panel.iconUrl ? { iconUrl: panel.iconUrl } : {}),
|
|
180
|
+
// Wrapped so the kit's participant hooks resolve THIS plugin's id while
|
|
181
|
+
// its panel renders — the scope is what `useRpc`/`useRealtime` read.
|
|
182
|
+
render: (props) => (
|
|
183
|
+
<PluginIdProvider pluginId={panel.pluginId}>
|
|
184
|
+
<Component sessionId={props.sessionId ?? null} workspacePath={null} />
|
|
185
|
+
</PluginIdProvider>
|
|
186
|
+
),
|
|
187
|
+
};
|
|
188
|
+
// `editor` is not a column this catalog draws: the takeover has its own
|
|
189
|
+
// list (`usePanelSlotPlugins`) and its own rail in the editor column.
|
|
190
|
+
if (resolved.side === 'editor') continue;
|
|
191
|
+
if (resolved.side === 'left') left.push(resolved);
|
|
192
|
+
else if (side !== 'left') out.push(resolved);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (side !== 'right') {
|
|
196
|
+
// The dock's own view first, then the placed plugins in PLACEMENT order:
|
|
197
|
+
// on the left, putting a panel there IS the arrangement, so the right
|
|
198
|
+
// column's dragged order would be a second answer to the same question.
|
|
199
|
+
left.sort((a, b) => (placed.get(a.id) ?? 0) - (placed.get(b.id) ?? 0));
|
|
200
|
+
out.unshift(SESSIONS_PANEL, ...left);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return side === 'left' ? out : apply(out);
|
|
204
|
+
}, [panels, slots, disabledPanels, apply, dock, side]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Whether a panel id names the dock's own session list. */
|
|
208
|
+
export function isSessionsPanel(id: string): boolean {
|
|
209
|
+
return id === SESSIONS_VIEW_ID;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Which view the dock actually shows, given the stored selection.
|
|
214
|
+
*
|
|
215
|
+
* Three answers, and they are different states a blank column would conflate:
|
|
216
|
+
*
|
|
217
|
+
* - the stored id is a LEFT panel → draw it;
|
|
218
|
+
* - the stored id names a panel that still exists but lives on the RIGHT (the
|
|
219
|
+
* user moved it back, or it was never placed here) → the dock's own view,
|
|
220
|
+
* because that is not an error;
|
|
221
|
+
* - the stored id names nothing at all (uninstalled, switched off) → the id is
|
|
222
|
+
* returned as-is so the dock can say WHY, which is what the panel position's
|
|
223
|
+
* notice exists for.
|
|
224
|
+
*/
|
|
225
|
+
export function useLeftDockView(stored: string, railEnabled: boolean): string {
|
|
226
|
+
const left = usePanelCatalog('left');
|
|
227
|
+
const all = usePanelCatalog('all');
|
|
228
|
+
return useMemo(() => {
|
|
229
|
+
// With no rail there is no control that switches views, so the dock shows
|
|
230
|
+
// its own view: leaving a plugin panel up would strand the user there with
|
|
231
|
+
// no way back to the session list.
|
|
232
|
+
if (!railEnabled) return SESSIONS_VIEW_ID;
|
|
233
|
+
if (left.some((panel) => panel.id === stored)) return stored;
|
|
234
|
+
if (all.some((panel) => panel.id === stored)) return SESSIONS_VIEW_ID;
|
|
235
|
+
return stored;
|
|
236
|
+
}, [left, all, stored, railEnabled]);
|
|
237
|
+
}
|
|
@@ -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);
|