@kabmo-labs/kabmo 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/registry.server.ts +2 -2
  98. package/src/server/lib/queue/delivery.server.ts +31 -39
  99. package/src/server/lib/queue/payload.ts +73 -0
  100. package/src/server/lib/realtime/signals.server.ts +10 -1
  101. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  102. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  103. package/src/server/lib/realtime/topics.server.ts +2 -0
  104. package/src/server/lib/util/with-timeout.ts +29 -0
  105. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  106. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  107. package/src/server/lib/waiting/runtime.server.ts +313 -0
  108. package/src/server/lib/waiting/schema.server.ts +46 -0
  109. package/src/server/lib/waiting/settle.test.ts +84 -0
  110. package/src/server/lib/waiting/settle.ts +77 -0
  111. package/src/server/lib/waiting/store.server.test.ts +167 -0
  112. package/src/server/lib/waiting/store.server.ts +268 -0
  113. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  114. package/src/server/plugins/compress.ts +18 -6
  115. package/src/server/plugins/ssr.ts +16 -8
  116. package/src/server/plugins/static.test.ts +59 -0
  117. package/src/server/plugins/static.ts +56 -14
  118. package/src/server/routes/index.ts +2 -0
  119. package/src/server/routes/waiting/bindings.ts +14 -0
  120. package/src/server/routes/waiting/index.ts +123 -0
  121. package/src/server/routes/waiting/routes.test.ts +121 -0
  122. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  123. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  124. package/src/shared/lib/realtime/protocol.ts +10 -0
  125. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  126. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  127. package/src/shared/lib/workspace/left-dock.ts +197 -0
  128. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  129. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  130. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  131. package/src/shared/types/index.ts +1 -0
  132. package/src/shared/types/waiting.ts +54 -0
  133. package/src/shared/types/workspace.ts +8 -0
  134. package/src/test-support/isolated-db.ts +3 -4
  135. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The left column draws ONE separator, and its chrome spans the WHOLE column.
8
+ *
9
+ * Both `LeftColumn` and `DesktopLayout` rendered a `ResizeHandle` after the
10
+ * dock, and both were mounted — so the column boundary drew two hairlines 3px
11
+ * apart and a reader saw a double rule between the session list and the chat.
12
+ * It was reproduced only when the rail was ON, because that is the layout a
13
+ * screenshotted report came from, though the extra handle was there in both
14
+ * states.
15
+ *
16
+ * A geometry test cannot catch the separator (happy-dom computes no layout), so
17
+ * that assertion is on the STRUCTURE: the group's direct children, in order. The
18
+ * second handle was a sibling — `LeftColumn`'s own sits between the dock and
19
+ * whatever follows — which is exactly what an order comparison shows.
20
+ *
21
+ * The chrome half is structural too, and both cases were reported. The strip
22
+ * lived inside the dock BODY while the rail was a sibling panel, so with the
23
+ * rail on it stopped at the body's left edge and left an empty strip under the
24
+ * rail. And the rail started at the column's top, running up past the title row
25
+ * and under the app's own titlebar. The column is three ROWS — title, then
26
+ * (rail | view), then strip — so both are asserted by the row each one lives in.
27
+ */
28
+
29
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
30
+ import { Window } from 'happy-dom';
31
+ import { h, render } from 'preact';
32
+ import { act } from 'preact/test-utils';
33
+ import { Group } from '@/client/components/layout/desktop-layout/resizer';
34
+ import { LeftColumn } from '@/client/components/layout/desktop-layout/LeftColumn';
35
+ import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
36
+ import { primeKabmoSettings } from '@/shared/lib/settings/client';
37
+ import { realtimeClient, resetRealtimeClient } from '@/shared/lib/realtime/client';
38
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
39
+
40
+ let container: HTMLElement | null = null;
41
+
42
+ /**
43
+ * A socket that never dials.
44
+ *
45
+ * The rail subscribes to the Source Control topic on mount. Left to a REAL
46
+ * socket, its close lands after this file has restored the globals and surfaces
47
+ * as `Unhandled error. (ErrorEvent …)` attributed to whichever suite ran next —
48
+ * measured. This column's subject is its own structure, so the channel is
49
+ * silenced rather than served.
50
+ */
51
+ class SilentSocket {
52
+ static readonly CLOSING = 2;
53
+ static readonly CLOSED = 3;
54
+ readyState = SilentSocket.CLOSED;
55
+ onopen: (() => void) | null = null;
56
+ onclose: (() => void) | null = null;
57
+ onmessage: ((event: { data: string }) => void) | null = null;
58
+ onerror: (() => void) | null = null;
59
+ send(): void {}
60
+ close(): void {}
61
+ }
62
+
63
+ beforeEach(() => {
64
+ installDomGlobals(new Window({ url: 'http://127.0.0.1:1' }));
65
+ realtimeClient.setSocketConstructor(SilentSocket as unknown as typeof WebSocket);
66
+ primeKabmoSettings({});
67
+ });
68
+
69
+ afterAll(() => {
70
+ realtimeClient.setSocketConstructor(null);
71
+ restoreDomGlobals();
72
+ });
73
+
74
+ afterEach(() => {
75
+ // The rail's Source Control dot subscribes to a realtime topic, so the render
76
+ // opens a socket. Left open, its close lands in the NEXT file's globals once
77
+ // this one has restored them — measured as `Unhandled error. (ErrorEvent …)`
78
+ // between tests, attributed to whichever suite happened to run next.
79
+ resetRealtimeClient();
80
+ if (container) {
81
+ render(null, container);
82
+ container.remove();
83
+ container = null;
84
+ }
85
+ primeKabmoSettings({});
86
+ });
87
+
88
+ async function mountColumn(railEnabled: boolean) {
89
+ const host = document.createElement('div');
90
+ document.body.appendChild(host);
91
+ container = host;
92
+
93
+ await act(async () => {
94
+ render(
95
+ h(SessionStateProvider, {
96
+ sessionId: 'new-separator',
97
+ children: h(Group, {
98
+ orientation: 'horizontal',
99
+ id: 'kabmo-main',
100
+ children: [
101
+ h(LeftColumn, {
102
+ panelRef: { current: null },
103
+ open: true,
104
+ railEnabled,
105
+ view: 'sessions',
106
+ onSelectView: () => {},
107
+ onCollapse: () => {},
108
+ width: 280,
109
+ minWidth: 264,
110
+ hasActiveContext: true,
111
+ refreshKey: 0,
112
+ onRefresh: () => {},
113
+ onOpenFile: () => {},
114
+ }),
115
+ h('div', { 'data-panel-id': 'main-right-stack' }),
116
+ ],
117
+ }),
118
+ }),
119
+ host,
120
+ );
121
+ });
122
+ // The dock's view is `lazy()`, so the boundary matters before it resolves:
123
+ // the separator is a SIBLING of the panel, not part of its content.
124
+ for (let i = 0; i < 5; i += 1) await act(async () => {});
125
+ }
126
+
127
+ /** The group's DIRECT children, in order — a separator appears once per entry. */
128
+ const children = (): string[] =>
129
+ [...(container!.querySelector('[data-panel-group-id="kabmo-main"]')?.children ?? [])].map((child) =>
130
+ child.hasAttribute('data-separator') ? 'separator' : (child.getAttribute('data-panel-id') ?? child.tagName),
131
+ );
132
+
133
+ describe('left column separators', () => {
134
+ test('one separator with the rail on', async () => {
135
+ await mountColumn(true);
136
+ expect(children().filter((entry) => entry === 'separator')).toHaveLength(1);
137
+ });
138
+
139
+ test('one separator with the rail off', async () => {
140
+ await mountColumn(false);
141
+ expect(children().filter((entry) => entry === 'separator')).toHaveLength(1);
142
+ });
143
+
144
+ test('the separator is the column boundary — after the dock, not before it', async () => {
145
+ await mountColumn(true);
146
+ const kids = children();
147
+ expect(kids.at(-1)).toBe('main-right-stack');
148
+ expect(kids.at(-2)).toBe('separator');
149
+ });
150
+ });
151
+
152
+ describe('left column chrome', () => {
153
+ const panel = () => container!.querySelector('[data-panel-id="left-panel"]') as HTMLElement;
154
+ const rail = () => panel().querySelector('nav[aria-label="Left panel views"]');
155
+ const title = () => panel().querySelector('.h-10.flex-shrink-0.flex.items-center.justify-between');
156
+ const footer = () => panel().querySelector('button[aria-label="Settings"]');
157
+
158
+ test('the title row, the rail and the strip are three rows, in that order', async () => {
159
+ await mountColumn(true);
160
+ const column = panel().firstElementChild as HTMLElement;
161
+ const rows = [...column.children];
162
+ expect(rows).toHaveLength(3);
163
+ // Row 0 is the title, row 1 holds the rail beside the view, row 2 the strip.
164
+ expect(rows[0].contains(title())).toBe(true);
165
+ expect(rows[1].contains(rail())).toBe(true);
166
+ expect(rows[2].contains(footer())).toBe(true);
167
+ });
168
+
169
+ test('the rail starts BELOW the title, never above it', async () => {
170
+ await mountColumn(true);
171
+ const column = panel().firstElementChild as HTMLElement;
172
+ expect(column.children[0].contains(rail())).toBe(false);
173
+ expect(column.children[1].contains(rail())).toBe(true);
174
+ });
175
+
176
+ test('the strip spans the column, so the rail does not outlive it', async () => {
177
+ await mountColumn(true);
178
+ const column = panel().firstElementChild as HTMLElement;
179
+ expect(column.children[2].contains(rail())).toBe(false);
180
+ expect(column.children[2].contains(footer())).toBe(true);
181
+ // Same row container for title and strip: both are full-width rows of the
182
+ // column, which is what makes the strip run under the rail too.
183
+ expect(column.children[0].parentElement).toBe(column.children[2].parentElement);
184
+ });
185
+
186
+ test('the strip survives a view that is not the session list', async () => {
187
+ await mountColumn(true);
188
+ // The column's furniture is rendered outside `LeftDockView` entirely, so it
189
+ // is present even while the lazy view is still resolving.
190
+ expect(footer()).not.toBeNull();
191
+ });
192
+ });
@@ -126,9 +126,15 @@ export function CategoryHeader({
126
126
  a checkbox is a second thing the click could mean. */}
127
127
  {!batchActive && (
128
128
  <div className="flex items-center space-x-0.5 pl-1">
129
+ {/* `aria-label` as well as `title`, like the phone's row: the button
130
+ is icon-only, so `title` alone leaves it unnamed to assistive tech
131
+ and to `getByRole` — and naming the FOLDER is what tells this
132
+ button apart from the toolbar's own New Session, which inherits
133
+ the current session's folder instead of naming one. */}
129
134
  <button
130
135
  type="button"
131
136
  title="New Session"
137
+ aria-label={`New session in ${folder.name}`}
132
138
  className="w-5 h-5 flex items-center justify-center text-ink/40 hover:text-ink hover:bg-ink/10 rounded cursor-pointer transition-colors"
133
139
  onClick={(e) => { e.stopPropagation(); onNewSession(); }}
134
140
  >
@@ -30,6 +30,7 @@ export function Category({
30
30
  onToast,
31
31
  batch,
32
32
  onStartSessionSelection,
33
+ onCancelWait,
33
34
  }: {
34
35
  folder: WorkspaceFolderData;
35
36
  activeSessionId: number | string | null;
@@ -43,6 +44,8 @@ export function Category({
43
44
  batch?: BatchBinding;
44
45
  /** Start a session selection scoped to this workspace, from its `⋯` menu. */
45
46
  onStartSessionSelection?: (folderId: number) => void;
47
+ /** Drop the held prompt of one row (`waitingFor.waitId`). */
48
+ onCancelWait?: (waitId: string) => void;
46
49
  }) {
47
50
  const [isOpen, setIsOpen] = useState(folder.isExpanded || false);
48
51
  const [showMenu, setShowMenu] = useState(false);
@@ -226,6 +229,12 @@ export function Category({
226
229
  hasSubagents={hasSubagents}
227
230
  isExpanded={isExpanded}
228
231
  onToggleExpand={() => handleToggleSessionExpand(sessionKey)}
232
+ waitingFor={session.waitingFor ? { title: session.waitingFor.title } : undefined}
233
+ onCancelWait={
234
+ session.waitingFor
235
+ ? () => onCancelWait?.(session.waitingFor!.waitId)
236
+ : undefined
237
+ }
229
238
  selectable={sessionCheckboxes}
230
239
  selected={Boolean(batch?.selected.includes(sessionKey))}
231
240
  onToggleSelect={batch ? () => batch.toggle(sessionKey) : undefined}
@@ -1,5 +1,5 @@
1
1
  import type { TargetedMouseEvent } from 'preact';
2
- import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHorizontal } from 'lucide-preact';
2
+ import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Hourglass, Loader2, MoreHorizontal } from 'lucide-preact';
3
3
  import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
4
  import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
5
5
  import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu';
@@ -30,6 +30,14 @@ export interface SessionItemProps {
30
30
  isExpanded?: boolean;
31
31
  hasSubagents?: boolean;
32
32
  onToggleExpand?: (e: TargetedMouseEvent<HTMLElement>) => void;
33
+ /**
34
+ * The row's prompt is held until another session finishes. A pending row has
35
+ * no transcript and no omp id, so this cue is the only thing on screen saying
36
+ * the chat exists and what it is waiting for.
37
+ */
38
+ waitingFor?: { title: string | null };
39
+ /** Drop this row's held prompt, from the row's own menu. */
40
+ onCancelWait?: () => void;
33
41
  /** Draw a checkbox and let the row's click toggle the selection instead of
34
42
  * opening the session (batch mode). */
35
43
  selectable?: boolean;
@@ -54,6 +62,8 @@ export function SessionItem({
54
62
  isExpanded = false,
55
63
  hasSubagents = false,
56
64
  onToggleExpand,
65
+ waitingFor,
66
+ onCancelWait,
57
67
  selectable = false,
58
68
  selected = false,
59
69
  onToggleSelect,
@@ -70,7 +80,8 @@ export function SessionItem({
70
80
  // or expanded: it is the only affordance left worth showing, and making it
71
81
  // hover-only would leave the roster reachable only by hovering exactly the
72
82
  // right 16px.
73
- const liveRun = awaitingInput || status === 'stream';
83
+ const waiting = Boolean(waitingFor);
84
+ const liveRun = awaitingInput || status === 'stream' || waiting;
74
85
  const chevronPinned = showChevron && !liveRun && !renaming;
75
86
  const showStatus = Boolean(renaming || awaitingInput || status) && !chevronPinned;
76
87
  const {
@@ -84,7 +95,7 @@ export function SessionItem({
84
95
  } = useInlineRename(title, onRename);
85
96
  // One trigger for every row action; also reachable by right-clicking the row.
86
97
  const menu = useRowMenu();
87
- const hasActions = Boolean(onRename || onArchive || onDelete);
98
+ const hasActions = Boolean(onRename || onArchive || onDelete || onCancelWait);
88
99
 
89
100
  return (
90
101
  <>
@@ -163,6 +174,10 @@ export function SessionItem({
163
174
  </span>
164
175
  ) : awaitingInput ? (
165
176
  <CircleQuestionMark size={12} className="animate-pulse text-ink/80" />
177
+ ) : waiting ? (
178
+ <span role="status" aria-label={`Waiting for ${waitingFor?.title ?? 'another session'}`}>
179
+ <Hourglass size={12} className="animate-pulse text-ink/60" />
180
+ </span>
166
181
  ) : status === 'stream' ? (
167
182
  <Loader2 size={12} className="animate-spin" />
168
183
  ) : (
@@ -190,8 +205,17 @@ export function SessionItem({
190
205
  className="flex-1 min-w-0 bg-paper border border-ink/25 rounded px-1 py-0.5 text-xs text-ink outline-none focus:border-ink/50"
191
206
  />
192
207
  ) : (
193
- <span className="flex-1 min-w-0 truncate leading-snug">
194
- {title.charAt(0).toUpperCase() + title.slice(1)}
208
+ <span className="flex-1 min-w-0 flex flex-col leading-snug">
209
+ <span className="truncate">
210
+ {title.charAt(0).toUpperCase() + title.slice(1)}
211
+ </span>
212
+ {/* One muted line under the title: the row is a real chat, and this
213
+ is why it has not started yet. */}
214
+ {waiting && (
215
+ <span className="truncate text-[10px] text-ink/40 font-normal">
216
+ waiting for {waitingFor?.title ?? 'another session'}
217
+ </span>
218
+ )}
195
219
  </span>
196
220
  )}
197
221
 
@@ -234,6 +258,7 @@ export function SessionItem({
234
258
  onRenameWithAi={onRenameWithAi}
235
259
  onArchive={onArchive}
236
260
  onDelete={onDelete}
261
+ onCancelWait={onCancelWait}
237
262
  onClose={menu.close}
238
263
  />
239
264
  )}
@@ -15,6 +15,8 @@ interface SessionSidebarSessionListProps {
15
15
  /** Present only while a batch selection is active. */
16
16
  batch?: BatchBinding;
17
17
  onStartSessionSelection?: (folderId: number) => void;
18
+ /** Drop a held prompt from its row's menu. */
19
+ onCancelWait?: (waitId: string) => void;
18
20
  }
19
21
 
20
22
  export function SessionSidebarSessionList({
@@ -28,6 +30,7 @@ export function SessionSidebarSessionList({
28
30
  onToast,
29
31
  batch,
30
32
  onStartSessionSelection,
33
+ onCancelWait,
31
34
  }: SessionSidebarSessionListProps) {
32
35
  // No fade wiring here: the app-wide scrollbar rule reveals this list's bar
33
36
  // from the document's own capture-phase listener (`lib/scrollbar-activity`),
@@ -52,6 +55,7 @@ export function SessionSidebarSessionList({
52
55
  onToast={onToast}
53
56
  batch={batch}
54
57
  onStartSessionSelection={onStartSessionSelection}
58
+ onCancelWait={onCancelWait}
55
59
  />
56
60
  ))
57
61
  )}
@@ -1,21 +1,16 @@
1
1
  import { useState } from 'preact/hooks';
2
2
  import { useSearchParams } from '@/client/lib/router/search-params';
3
- import { AboutModal, NewWorkspaceModal, SettingsModal } from '@/client/components/layout/session-sidebar/Modals';
3
+ import { NewWorkspaceModal } from '@/client/components/layout/session-sidebar/Modals';
4
4
  import { SchedulerModal } from '@/client/components/layout/session-sidebar/scheduler-modal';
5
- import { UpdatePopup } from '@/client/components/layout/update-popup';
6
- import { SessionSidebarHeader } from '@/client/components/layout/session-sidebar/Header';
7
5
  import { SessionSidebarToolbar } from '@/client/components/layout/session-sidebar/Toolbar';
8
- import { SessionSidebarFooter } from '@/client/components/layout/session-sidebar/Footer';
9
- import { useAndroidDownload } from '@/client/hooks/ui/updates/android-download';
10
6
  import { SessionSidebarSessionList } from '@/client/components/layout/session-sidebar/SessionList';
11
7
  import { SessionListSkeleton } from '@/client/components/layout/session-sidebar/Skeleton';
12
8
  import { ToastStack } from '@/client/components/common/ToastStack';
13
9
  import { spawnCwdForNewSession, triggerSessionPrewarm } from '@/shared/lib/omp/session/prewarm';
14
- import { useKabmoEvent } from '@/client/hooks/ui/window-event';
15
10
  import { useToasts } from '@/client/hooks/ui/toasts';
16
- import { useUpdates } from '@/client/hooks/ui/updates';
17
- import { UPDATE_REQUEST_EVENT } from '@/shared/lib/updates/popup-state';
18
11
  import { useSessionSidebarController } from '@/client/hooks/chat/omp/session-sidebar-controller';
12
+ import { useCancelWait } from '@/client/hooks/chat/waiting/cancel';
13
+ import { useWaitingDispatch } from '@/client/hooks/chat/waiting/dispatch';
19
14
  import { startPendingSession } from '@/client/hooks/chat/omp/pending-sessions';
20
15
  import { useScheduledTaskCount } from '@/client/hooks/workspace/scheduled-tasks';
21
16
  import { cloneWorkspaceFolder, createWorkspaceFolder } from '@/client/hooks/workspace/new-workspace';
@@ -25,9 +20,37 @@ import { BatchActionBar } from '@/client/components/common/batch-selection/Batch
25
20
  import { BatchDeleteModal } from '@/client/components/common/batch-selection/BatchDeleteModal';
26
21
  import type { BatchBinding } from '@/client/components/common/batch-selection/types';
27
22
  import { liveSelection, selectableSessionIds } from '@/shared/lib/workspace/batch-select';
28
- import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
29
23
 
30
- export function SessionSidebar({ className = '', onClose, appSettings = {}, streamStatus }: { className?: string, onClose?: () => void, appSettings?: Record<string, any>, streamStatus?: AgentStreamStatus }) {
24
+ /**
25
+ * The session list — the left dock's own view.
26
+ *
27
+ * It used to BE the left column. The column is a dock now, and this is the view
28
+ * that fills it unless the user places a plugin panel there; the strip below it
29
+ * (Settings · About · the APK offer) is the DOCK's, not this view's, because
30
+ * those controls act on Kabmo rather than on the list.
31
+ *
32
+ * Three things are deliberately NOT here any more:
33
+ *
34
+ * - **The header.** It is the COLUMN's title row now, and it spans the rail too
35
+ * (see `LeftColumn`): a title that stopped at the dock body would leave the
36
+ * rail starting above it, with a gap under the app's own titlebar.
37
+ * - **The footer.** It is dock furniture, and a plugin view on the same dock
38
+ * must not take it away — moving to a plugin panel would otherwise lose the
39
+ * only visible way into Settings.
40
+ * - **The update popup and the About modal it hands over to.** Both are app
41
+ * level: they are about the console, not about one view, and the dock renders
42
+ * them so they survive a view switch.
43
+ *
44
+ * Everything else — the header's collapse control, the toolbar, the batch bar,
45
+ * the workspace and scheduler dialogs — belongs to the list and stays.
46
+ */
47
+ export function SessionsView({
48
+ className = '',
49
+ appSettings = {},
50
+ }: {
51
+ className?: string;
52
+ appSettings?: Record<string, any>;
53
+ }) {
31
54
  const {
32
55
  folders,
33
56
  initializing,
@@ -49,23 +72,14 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
49
72
  } = useSessionSidebarController(appSettings, { expandSearchMatches: true });
50
73
  const [, setSearchParams] = useSearchParams();
51
74
 
52
- const [settingsOpen, setSettingsOpen] = useState(false);
53
- const [infoOpen, setInfoOpen] = useState(false);
54
-
55
- // Modals for new workspace and scheduler
56
75
  const [newWorkspaceOpen, setNewWorkspaceOpen] = useState(false);
57
76
  const [schedulerOpen, setSchedulerOpen] = useState(false);
58
77
  const scheduledCount = useScheduledTaskCount();
59
- const updates = useUpdates();
60
- // The Android offer: an UPDATE inside the APK, an INSTALL on an Android
61
- // browser, and nothing anywhere else — see `useAndroidDownload`.
62
- const androidDownload = useAndroidDownload(
63
- window.__OMP_BOOTSTRAP__?.shellVersion,
64
- window.__OMP_BOOTSTRAP__?.isAndroid,
65
- );
66
78
  const { toasts, pushToast, dismissToast } = useToasts();
79
+ const cancelWait = useCancelWait(pushToast);
80
+ // Follows a held prompt to its session — in whichever tab or instance sent it.
81
+ useWaitingDispatch({ onNotice: pushToast });
67
82
 
68
- // Sidebar inline states
69
83
  const [isSearchVisible, setIsSearchVisible] = useState(false);
70
84
 
71
85
  // Batch selection: two separate modes (sessions from a workspace's `⋯` menu,
@@ -105,20 +119,6 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
105
119
  else batch.begin('workspace');
106
120
  };
107
121
 
108
- // The popup's "Update now" hands the run over instead of starting its own: the
109
- // About modal is where an update has always been driven from and it owns the
110
- // live output, so this opens it and starts the run it will draw. The popup
111
- // dispatches this only after it has closed, so the two dialogs never stack.
112
- useKabmoEvent(UPDATE_REQUEST_EVENT, () => {
113
- setInfoOpen(true);
114
- void updates.apply('kabmo').then((result) => {
115
- // A null result means the run was aborted with the page; there is nobody
116
- // left to tell. A `manual` answer is the command to run, not a success.
117
- if (!result) return;
118
- pushToast(result.message, result.success && !result.manual ? 'success' : 'error');
119
- });
120
- });
121
-
122
122
  /** The folder whose context a new session will inherit — same resolution
123
123
  * the send path uses, so the prewarmed cwd matches the spawn cwd. */
124
124
  const prewarmForNewSession = (folderId?: number) => {
@@ -137,12 +137,15 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
137
137
  const currentFolder = currentSessionId
138
138
  ? folders.find(f => f.sessions?.some((s) => String(s.id) === String(currentSessionId)))
139
139
  : undefined;
140
- next.set('sessionId', startPendingSession(currentFolder?.id ?? null));
141
- if (currentFolder) {
142
- next.set('folderId', currentFolder.id.toString());
143
- } else {
144
- next.delete('folderId');
145
- }
140
+ // The folder the chat will run in: the active session's, else the one the
141
+ // URL already names. Dropping the URL's own `folderId` (the previous
142
+ // `else next.delete`) left a chat opened with `?folderId=` with NO context
143
+ // — its composer then stayed disabled ("Please select a workspace above")
144
+ // and the first send never happened.
145
+ const contextFolderId = currentFolder?.id ?? (prev.get('folderId') ? Number(prev.get('folderId')) : null);
146
+ next.set('sessionId', startPendingSession(contextFolderId));
147
+ if (contextFolderId != null) next.set('folderId', String(contextFolderId));
148
+ else next.delete('folderId');
146
149
  return next;
147
150
  }, { replace: true });
148
151
  };
@@ -159,9 +162,7 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
159
162
 
160
163
  return (
161
164
  <>
162
- <aside className={`flex flex-col bg-paper h-full ${className}`}>
163
- <SessionSidebarHeader streamStatus={streamStatus} onCollapse={onClose} />
164
-
165
+ <div className={`flex flex-col h-full ${className}`}>
165
166
  <SessionSidebarToolbar
166
167
  isSearchVisible={isSearchVisible}
167
168
  searchQuery={searchQuery}
@@ -199,6 +200,7 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
199
200
  onToast={pushToast}
200
201
  batch={batchBinding}
201
202
  onStartSessionSelection={(folderId) => batch.begin('session', folderId)}
203
+ onCancelWait={cancelWait}
202
204
  />
203
205
  )}
204
206
 
@@ -215,33 +217,7 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
215
217
  onCancel={batch.end}
216
218
  />
217
219
  )}
218
-
219
- <SessionSidebarFooter
220
- onSettings={() => setSettingsOpen(true)}
221
- onInfo={() => setInfoOpen(true)}
222
- androidDownload={androidDownload}
223
- updateAvailable={updates.hasUpdate}
224
- onUpdateClick={() => {
225
- setInfoOpen(true);
226
- void updates.check();
227
- }}
228
- />
229
- </aside>
230
-
231
- {/* Settings Modal */}
232
- <SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} appSettings={appSettings} />
233
-
234
- {/* Info Modal */}
235
- <AboutModal
236
- isOpen={infoOpen}
237
- onClose={() => setInfoOpen(false)}
238
- updates={updates}
239
- onToast={pushToast}
240
- />
241
-
242
- {/* "Update available" announcement — once per version, gated on the check.
243
- Its "Update now" hands the run to the About modal below. */}
244
- <UpdatePopup updates={updates} />
220
+ </div>
245
221
 
246
222
  {/* Batch delete confirmation. It stays open on a refusal so the reasons
247
223
  can be read; a fully successful run closes itself and leaves the mode. */}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * New Session keeps the chat's FOLDER context.
8
+ *
9
+ * The bug this pins was reported as "the composer is disabled in a new chat":
10
+ * opening the app with `?folderId=…` and pressing New Session used to DELETE the
11
+ * `folderId` param (its branch only restored the folder of the session on
12
+ * screen, and there was none yet). The pending chat then had no context, so the
13
+ * composer rendered "Please select a workspace above to start prompting…" and
14
+ * the first send never happened — measured through the browser layer, where
15
+ * `panels.e2e.ts` could not reach the commit box at all.
16
+ *
17
+ * The rule is one sentence, and it is what this file asserts: the folder a new
18
+ * chat inherits is the ACTIVE session's folder, else the one the URL already
19
+ * names. It is expressed here rather than by mounting the sidebar because the
20
+ * component is a controller with a dozen stores behind it; the branch itself is
21
+ * pure, and it is the branch that was wrong.
22
+ */
23
+
24
+ import { describe, expect, test } from 'bun:test';
25
+
26
+ /**
27
+ * The folder id a new chat is opened in, from what the sidebar knows.
28
+ *
29
+ * Mirrors `handleNewSession` in `session-sidebar/SessionsView.tsx`.
30
+ */
31
+ function contextFolderFor(args: {
32
+ sessionFolderId?: number | null;
33
+ urlFolderId?: string | null;
34
+ }): number | null {
35
+ const { sessionFolderId = null, urlFolderId = null } = args;
36
+ if (sessionFolderId != null) return sessionFolderId;
37
+ return urlFolderId ? Number(urlFolderId) : null;
38
+ }
39
+
40
+ describe('new chat folder context', () => {
41
+ test('an active session in a folder wins', () => {
42
+ expect(contextFolderFor({ sessionFolderId: 3, urlFolderId: '7' })).toBe(3);
43
+ });
44
+
45
+ test("a chat opened with ?folderId= keeps that folder", () => {
46
+ // The reported case: no session yet, so nothing to inherit — and the URL's
47
+ // own folder is the context the composer needs to be enabled.
48
+ expect(contextFolderFor({ urlFolderId: '7' })).toBe(7);
49
+ });
50
+
51
+ test('no session and no folder is null, not NaN', () => {
52
+ expect(contextFolderFor({})).toBeNull();
53
+ expect(contextFolderFor({ urlFolderId: null })).toBeNull();
54
+ });
55
+ });
@@ -7,6 +7,7 @@ import { useResolvedRepo } from '@/client/hooks/workspace/repo-scope';
7
7
  import { useSessionState } from '@/client/hooks/workspace/session-state';
8
8
  import { PluginMark } from '@/client/components/settings/categories/panel-plugins/PluginMark';
9
9
  import { PANEL_MARK_SIZE } from '@/shared/lib/workspace/right-panels';
10
+ import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
10
11
 
11
12
  interface MobileRightSidebarProps {
12
13
  enabled?: boolean;
@@ -43,11 +44,13 @@ export function MobileRightSidebar({
43
44
  // the user never asked for a search. Restoring the tab they left also keeps
44
45
  // the phone and the desktop on the same view.
45
46
  const [activeTab, setActiveTab] = useSessionState<string>('layout.mobileRightPanel', 'files');
46
- // ONE catalog, the same one the desktop bar reads: the built-in views and the
47
- // installed plugins are one list, in one order, so the phone cannot become a
48
- // second list that drifts from the desktop's. A view switched off is absent
49
- // here for the same reason it is absent there.
50
- const catalog = usePanelCatalog();
47
+ // ONE catalog, the same one the desktop bars read, and deliberately `all`:
48
+ // a phone has ONE drawer, so a plugin panel the user placed on the left must
49
+ // stay reachable here — reading only the right column would make placing a
50
+ // panel on the left hide it from every phone. The dock's own session list is
51
+ // filtered out below: the phone already has that as its own drawer screen,
52
+ // and a second copy inside this one would be two lists of the same sessions.
53
+ const catalog = usePanelCatalog('all').filter((panel) => panel.id !== SESSIONS_VIEW_ID);
51
54
  const activePanel = catalog.find((panel) => panel.id === activeTab);
52
55
  // Same source the desktop activity bar uses — the Source Control view's own
53
56
  // repo pick, read shared so switching repos moves this dot too. Polls only
@@ -11,6 +11,8 @@ import { UpdatePopup } from '@/client/components/layout/update-popup';
11
11
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
12
12
  import { useKabmoEvent } from '@/client/hooks/ui/window-event';
13
13
  import { useToasts } from '@/client/hooks/ui/toasts';
14
+ import { useCancelWait } from '@/client/hooks/chat/waiting/cancel';
15
+ import { useWaitingDispatch } from '@/client/hooks/chat/waiting/dispatch';
14
16
  import { useUpdates } from '@/client/hooks/ui/updates';
15
17
  import { UPDATE_REQUEST_EVENT } from '@/shared/lib/updates/popup-state';
16
18
  import { useSessionSidebarController } from '@/client/hooks/chat/omp/session-sidebar-controller';
@@ -80,6 +82,8 @@ export function MobileSessionSidebar({
80
82
  window.__OMP_BOOTSTRAP__?.isAndroid,
81
83
  );
82
84
  const { toasts, pushToast, dismissToast } = useToasts();
85
+ const cancelWait = useCancelWait(pushToast);
86
+ useWaitingDispatch({ onNotice: pushToast });
83
87
 
84
88
  // The popup's "Update now" hands the run over rather than starting its own —
85
89
  // see the desktop sidebar. The phone must also bring the drawer forward, or
@@ -229,11 +233,13 @@ export function MobileSessionSidebar({
229
233
  onToast={pushToast}
230
234
  batch={batchBinding}
231
235
  onStartSessionSelection={(folderId) => batch.begin('session', folderId)}
236
+ onCancelWait={cancelWait}
232
237
  />
233
238
  )}
234
239
 
235
240
  {batch.mode && (
236
241
  <BatchActionBar
242
+ drawer
237
243
  mode={batch.mode}
238
244
  count={batchSelected.length}
239
245
  total={batchAvailable.length}