@kabmo-labs/kabmo 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-28z316qk.js → index-cs3b557t.js} +792 -792
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
  14. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  15. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  16. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  17. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  18. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  19. package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
  20. package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
  21. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  22. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  23. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  24. package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
  25. package/src/client/components/mobile/RightSidebar.tsx +30 -36
  26. package/src/client/components/mobile/SessionSidebar.tsx +124 -101
  27. package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
  28. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
  29. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
  30. package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
  31. package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
  32. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  34. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  35. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +47 -14
  37. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  38. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
  39. package/src/client/components/mobile/panel-chips.test.tsx +155 -0
  40. package/src/client/components/mobile/panel-chips.tsx +74 -0
  41. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  42. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  43. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  44. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  45. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  46. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  47. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  49. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  50. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  51. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  52. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  53. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  54. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  55. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +15 -10
  58. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  60. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +162 -0
  61. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  62. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  63. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  64. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  65. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  66. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  67. package/src/client/components/workspace/editor/index.tsx +1 -0
  68. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  69. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  70. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  71. package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
  72. package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
  73. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  74. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  75. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  76. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  77. package/src/client/hooks/chat/composer/compact.ts +119 -0
  78. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  79. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  80. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  81. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  82. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  83. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  84. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  85. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  86. package/src/client/hooks/chat/timeline/index.ts +15 -19
  87. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  88. package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
  89. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  90. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  91. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  92. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  93. package/src/client/hooks/chat/waiting/held.ts +42 -0
  94. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  95. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  96. package/src/client/hooks/chat/waiting/store.ts +100 -0
  97. package/src/client/hooks/ui/toasts.ts +12 -1
  98. package/src/client/hooks/workspace/left-dock.ts +132 -0
  99. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  100. package/src/client/tailwind.css +22 -0
  101. package/src/server/db.server.test.ts +52 -1
  102. package/src/server/db.server.ts +49 -1
  103. package/src/server/index.ts +9 -0
  104. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  105. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  106. package/src/server/lib/db/schema.test.ts +1 -0
  107. package/src/server/lib/db/schema.ts +3 -0
  108. package/src/server/lib/fs/fs-path.test.ts +1 -1
  109. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  110. package/src/server/lib/omp/session/delete.server.ts +8 -0
  111. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  112. package/src/server/lib/panels/registry.server.ts +2 -2
  113. package/src/server/lib/queue/delivery.server.ts +31 -39
  114. package/src/server/lib/queue/payload.ts +73 -0
  115. package/src/server/lib/realtime/signals.server.ts +10 -1
  116. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  117. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  118. package/src/server/lib/realtime/topics.server.ts +2 -0
  119. package/src/server/lib/util/with-timeout.ts +29 -0
  120. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  121. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  122. package/src/server/lib/waiting/runtime.server.ts +313 -0
  123. package/src/server/lib/waiting/schema.server.ts +46 -0
  124. package/src/server/lib/waiting/settle.test.ts +84 -0
  125. package/src/server/lib/waiting/settle.ts +77 -0
  126. package/src/server/lib/waiting/store.server.test.ts +167 -0
  127. package/src/server/lib/waiting/store.server.ts +268 -0
  128. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  129. package/src/server/plugins/compress.ts +18 -6
  130. package/src/server/plugins/ssr.ts +16 -8
  131. package/src/server/plugins/static.test.ts +59 -0
  132. package/src/server/plugins/static.ts +56 -14
  133. package/src/server/routes/index.ts +2 -0
  134. package/src/server/routes/waiting/bindings.ts +14 -0
  135. package/src/server/routes/waiting/index.ts +123 -0
  136. package/src/server/routes/waiting/routes.test.ts +121 -0
  137. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  138. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  139. package/src/shared/lib/realtime/protocol.ts +10 -0
  140. package/src/shared/lib/ui/titlebar.test.ts +124 -0
  141. package/src/shared/lib/ui/titlebar.ts +54 -0
  142. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  143. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  144. package/src/shared/lib/workspace/left-dock.ts +197 -0
  145. package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
  146. package/src/shared/lib/workspace/mobile-dock.ts +55 -0
  147. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  148. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  149. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  150. package/src/shared/types/index.ts +1 -0
  151. package/src/shared/types/waiting.ts +54 -0
  152. package/src/shared/types/workspace.ts +8 -0
  153. package/src/test-support/isolated-db.ts +3 -4
  154. package/src/test-support/silent-socket.ts +69 -0
  155. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,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, 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);