@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,185 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * "Wait for session" — the composer's dependency pick for a NEW chat.
8
+ *
9
+ * Only sessions with a run in flight are offered, and that is a rule rather than
10
+ * a filter for convenience: a prompt held on an idle session would be released
11
+ * by the very next sweep, so the dropdown would be promising a wait that cannot
12
+ * happen. The list is derived from the sidebar's server-tracked status map —
13
+ * the same `stream` rows the row spinner is drawn from — so a session that
14
+ * finished while the popup was open leaves it on the next render.
15
+ *
16
+ * The choice lives in the session's own UI state (`chat.waitingFor`), not in
17
+ * this component: the composer is remounted by an expansion, a tab switch and a
18
+ * reload, and a pick that evaporated with it would silently send the prompt
19
+ * immediately.
20
+ */
21
+
22
+ import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
23
+ import { Hourglass, ChevronDown, Check, X } from 'lucide-preact';
24
+ import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
25
+ import type { WorkspaceFolderData } from '@/shared/types';
26
+
27
+ export interface WaitingCandidate {
28
+ id: string;
29
+ title: string;
30
+ folderName: string;
31
+ }
32
+
33
+ export interface WaitingDependencyPickerProps {
34
+ folders: WorkspaceFolderData[];
35
+ /** Server-tracked `stream` ids — the only sessions that may be waited on. */
36
+ streamStatusMap: Record<string, string | undefined>;
37
+ /** The session this composer writes to; never offered as its own dependency. */
38
+ sessionId: string | null;
39
+ /** A name for a streaming id the sidebar structure has not scanned yet. */
40
+ titleFor?: (id: string) => string | undefined;
41
+ value: WaitingCandidate | null;
42
+ onChange: (candidate: WaitingCandidate | null) => void;
43
+ }
44
+
45
+ /**
46
+ * Sessions with a run in flight, in sidebar order.
47
+ *
48
+ * The STATUS map is the list, and the folders are only where a name comes from.
49
+ * That order matters: a freshly spawned session has a `stream` row the moment
50
+ * its prompt is dispatched, while omp's transcript — which is what the sidebar
51
+ * structure is scanned from — lands seconds later (and, in a test fixture, may
52
+ * never land at all). Driving the list off `folder.sessions` therefore offered
53
+ * NOTHING in the one situation the feature exists for: a run that just started.
54
+ * Measured in `waiting-prompt.e2e.ts`, where the sidebar drew the running
55
+ * session as a PENDING row while the folder listed no sessions at all.
56
+ */
57
+ export function waitingCandidates(
58
+ folders: WorkspaceFolderData[],
59
+ streamStatusMap: Record<string, string | undefined>,
60
+ sessionId: string | null,
61
+ titleFor?: (id: string) => string | undefined,
62
+ ): WaitingCandidate[] {
63
+ const candidates: WaitingCandidate[] = [];
64
+ const seen = new Set<string>();
65
+ for (const folder of folders) {
66
+ for (const session of folder.sessions ?? []) {
67
+ const id = String(session.id);
68
+ if (streamStatusMap[id] !== 'stream') continue;
69
+ seen.add(id);
70
+ if (sessionId && id === String(sessionId)) continue;
71
+ candidates.push({ id, title: session.title || id, folderName: folder.name });
72
+ }
73
+ }
74
+ // Streaming ids the structure has not scanned: still real runs, still
75
+ // waitable. Named from the title hint the send path published, else the id.
76
+ for (const [id, status] of Object.entries(streamStatusMap)) {
77
+ if (status !== 'stream' || seen.has(id)) continue;
78
+ if (sessionId && id === String(sessionId)) continue;
79
+ candidates.push({ id, title: titleFor?.(id) ?? id, folderName: '' });
80
+ }
81
+ return candidates;
82
+ }
83
+
84
+ export function WaitingDependencyPicker({
85
+ folders,
86
+ streamStatusMap,
87
+ sessionId,
88
+ titleFor,
89
+ value,
90
+ onChange,
91
+ }: WaitingDependencyPickerProps) {
92
+ const [open, setOpen] = useState(false);
93
+ const ref = useRef<HTMLDivElement>(null);
94
+ // A stable handler: the hook re-registers its document listeners whenever the
95
+ // callback identity changes, and an inline arrow would do that on every
96
+ // render — including every stream-status frame.
97
+ const close = useCallback(() => setOpen(false), []);
98
+ useOnClickOutside(ref, close);
99
+ const candidates = useMemo(
100
+ () => waitingCandidates(folders, streamStatusMap, sessionId, titleFor),
101
+ [folders, streamStatusMap, sessionId, titleFor],
102
+ );
103
+ const unavailable = candidates.length === 0;
104
+
105
+ return (
106
+ <div className="relative shrink-0" ref={ref}>
107
+ <button
108
+ type="button"
109
+ data-testid="wait-picker"
110
+ onClick={() => setOpen((prev) => !prev)}
111
+ disabled={unavailable && !value}
112
+ title={unavailable && !value ? 'No session is running right now' : 'Wait for another session to finish'}
113
+ aria-label={value ? `Waiting for ${value.title}` : 'Wait for session'}
114
+ aria-expanded={open}
115
+ className={`group flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium transition-all outline-none focus-visible:bg-ink/5 cursor-pointer select-none disabled:cursor-not-allowed disabled:opacity-50 ${
116
+ value ? 'bg-ink/10 text-ink' : 'text-ink/75 hover:text-ink hover:bg-ink/5'
117
+ }`}
118
+ >
119
+ <Hourglass size={13} className={`flex-shrink-0 ${value ? 'text-ink/80' : 'text-ink/50 group-hover:text-ink/80'}`} />
120
+ <span className="max-w-[200px] truncate tracking-tight">
121
+ {value ? `Wait for ${value.title}` : 'Wait for session'}
122
+ </span>
123
+ <ChevronDown
124
+ size={13}
125
+ className={`flex-shrink-0 text-ink/40 transition-transform duration-150 ${open ? 'rotate-180 text-ink/70' : ''}`}
126
+ />
127
+ </button>
128
+
129
+ {open && (
130
+ <div className="absolute bottom-full left-0 mb-1.5 w-72 bg-paper border border-ink/10 rounded-lg shadow-lg z-50 flex flex-col overflow-hidden text-xs py-1">
131
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-ink/5">
132
+ <span className="text-[10px] uppercase tracking-wider text-ink/40 font-semibold">Wait for session</span>
133
+ {value && (
134
+ <button
135
+ type="button"
136
+ onClick={() => {
137
+ onChange(null);
138
+ setOpen(false);
139
+ }}
140
+ title="Send without waiting"
141
+ aria-label="Send without waiting"
142
+ className="flex items-center gap-1 text-[10px] text-ink/50 hover:text-ink cursor-pointer"
143
+ >
144
+ <X size={10} /> clear
145
+ </button>
146
+ )}
147
+ </div>
148
+ <div className="max-h-56 scrollbar-overlay-container py-1">
149
+ {unavailable ? (
150
+ <div className="px-3 py-2 text-ink/40 italic">No session is running right now</div>
151
+ ) : (
152
+ candidates.map((candidate) => {
153
+ const selected = value?.id === candidate.id;
154
+ return (
155
+ <button
156
+ key={candidate.id}
157
+ type="button"
158
+ data-testid="wait-candidate"
159
+ data-session-id={candidate.id}
160
+ onClick={() => {
161
+ onChange(selected ? null : candidate);
162
+ setOpen(false);
163
+ }}
164
+ className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
165
+ selected ? 'bg-ink/10 text-ink font-semibold' : 'text-ink/80 hover:bg-ink/5 hover:text-ink'
166
+ }`}
167
+ >
168
+ <span className="flex items-center gap-2 truncate pr-2">
169
+ <Hourglass size={12} className={selected ? 'text-ink flex-shrink-0' : 'text-ink/50 flex-shrink-0'} />
170
+ <span className="truncate font-medium">{candidate.title}</span>
171
+ {candidate.folderName && (
172
+ <span className="truncate text-[10px] text-ink/40">{candidate.folderName}</span>
173
+ )}
174
+ </span>
175
+ {selected && <Check size={12} className="text-ink flex-shrink-0" />}
176
+ </button>
177
+ );
178
+ })
179
+ )}
180
+ </div>
181
+ </div>
182
+ )}
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone's compact composer.
8
+ *
9
+ * A reader scrolled back through the transcript with an empty draft gets the
10
+ * height back — but not by shrinking the box: the textarea is REMOVED, and the
11
+ * attachment row carries a "Type here" target that brings it back. Shrinking it
12
+ * instead left a focusable element with no usable pixels and a sentence-long
13
+ * placeholder clipped mid-word, and it kept the composer at a height it was
14
+ * supposed to give away.
15
+ *
16
+ * These pin the three states that make that work: compact has no textarea and
17
+ * one target, a tap on the target restores the box and focuses it, and a draft
18
+ * (or a focused composer) never enters compact at all.
19
+ */
20
+
21
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
22
+ import { Window } from 'happy-dom';
23
+ import { h, render } from 'preact';
24
+ import { act } from 'preact/test-utils';
25
+ import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input';
26
+ import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
27
+
28
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'MouseEvent', 'KeyboardEvent', 'FocusEvent'] as const;
29
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
30
+
31
+ beforeAll(() => {
32
+ const win = new Window({ url: 'http://localhost' });
33
+ const target = globalThis as unknown as Record<string, unknown>;
34
+ for (const key of DOM_GLOBALS) {
35
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
36
+ target[key] = (win as unknown as Record<string, unknown>)[key];
37
+ }
38
+ });
39
+
40
+ afterAll(() => {
41
+ const target = globalThis as unknown as Record<string, unknown>;
42
+ for (const key of DOM_GLOBALS) {
43
+ if (nativeGlobals[key] === undefined) delete target[key];
44
+ else target[key] = nativeGlobals[key];
45
+ }
46
+ });
47
+
48
+ let container: HTMLElement | null = null;
49
+
50
+ beforeEach(() => {
51
+ // Per case, because `restoreRealtime` clears the constructor: the composer's
52
+ // model picker subscribes on every mount, and this origin has no listener to
53
+ // reach, so a real dial would fail after the globals are restored.
54
+ silenceRealtime();
55
+ });
56
+
57
+ afterEach(() => {
58
+ container?.remove();
59
+ container = null;
60
+ // The composer's model picker subscribes to the realtime topic, and its
61
+ // reconnect timer outlives this file's DOM — it then fires into a torn-down
62
+ // `window` and surfaces as an unhandled error in whichever test runs next.
63
+ // `restoreRealtime` resets the client AND puts the real constructor back, so
64
+ // the silence does not follow this file into the next one.
65
+ restoreRealtime();
66
+ });
67
+
68
+ interface MountOptions {
69
+ shrinkWhileReading?: boolean;
70
+ value?: string;
71
+ variant?: 'desktop' | 'mobile';
72
+ }
73
+
74
+ async function mount({
75
+ shrinkWhileReading = false,
76
+ value = '',
77
+ variant = 'mobile',
78
+ }: MountOptions = {}): Promise<HTMLElement> {
79
+ const host = document.createElement('div');
80
+ document.body.appendChild(host);
81
+ await act(async () => {
82
+ // No `SessionStateProvider`: the composer reads no session UI state, and the
83
+ // provider's own load kicks a realtime reconnect that outlives this file and
84
+ // throws `window is not defined` after teardown.
85
+ render(
86
+ h(ChatInput, {
87
+ value,
88
+ onChange: () => {},
89
+ onSend: () => {},
90
+ isGenerating: false,
91
+ composerModelRef: { current: null },
92
+ variant,
93
+ shrinkWhileReading,
94
+ sessionId: 'composer-compact-test',
95
+ }),
96
+ host,
97
+ );
98
+ });
99
+ container = host;
100
+ return host;
101
+ }
102
+
103
+ const textarea = (el: HTMLElement) => el.querySelector('textarea');
104
+ const typeHere = (el: HTMLElement) =>
105
+ Array.from(el.querySelectorAll('button')).find((b) => (b.textContent ?? '').trim() === 'Type here') ?? null;
106
+
107
+ describe('compact composer', () => {
108
+ test('a scrolled-back phone with an empty draft renders no textarea at all', async () => {
109
+ const el = await mount({ shrinkWhileReading: true });
110
+ expect(textarea(el)).toBeNull();
111
+ });
112
+
113
+ test('…and offers exactly one target that says what tapping it does', async () => {
114
+ const el = await mount({ shrinkWhileReading: true });
115
+ const target = typeHere(el);
116
+ expect(target).not.toBeNull();
117
+ expect(target!.tagName).toBe('BUTTON');
118
+ });
119
+
120
+ test('a draft keeps the box: the reader is writing, not reading', async () => {
121
+ const el = await mount({ shrinkWhileReading: true, value: 'half a prompt' });
122
+ expect(textarea(el)).not.toBeNull();
123
+ expect(typeHere(el)).toBeNull();
124
+ });
125
+
126
+ test('a desktop is never compact — its composer sits beside the chat', async () => {
127
+ const el = await mount({ shrinkWhileReading: true, variant: 'desktop' });
128
+ expect(textarea(el)).not.toBeNull();
129
+ });
130
+
131
+ test('tapping the target brings the textarea back, focused', async () => {
132
+ const el = await mount({ shrinkWhileReading: true });
133
+ const target = typeHere(el)!;
134
+ // POINTERDOWN, not click: pressing the target expands the composer, which
135
+ // removes the button the finger is still on — so the browser never emits a
136
+ // click at all (measured in Chromium: `pd:BUTTON` with no `click` after it).
137
+ await act(async () => { target.dispatchEvent(new window.PointerEvent('pointerdown', { bubbles: true })); });
138
+ const box = textarea(el);
139
+ expect(box).not.toBeNull();
140
+ // The tap ends where the reader aimed it: the caret is in the box, which is
141
+ // also what stops the scroll position from collapsing it again.
142
+ expect(document.activeElement).toBe(box);
143
+ });
144
+
145
+ test('tapping a control inside the compact card does not also re-open it', async () => {
146
+ const el = await mount({ shrinkWhileReading: true });
147
+ const paperclip = el.querySelector('button[title="Attach file or image"]') as HTMLButtonElement;
148
+ await act(async () => { paperclip.dispatchEvent(new window.PointerEvent('pointerdown', { bubbles: true })); });
149
+ // The paperclip owns its own click; the card must not turn it into a reopen.
150
+ expect(textarea(el)).toBeNull();
151
+ });
152
+
153
+ test('in the normal state, tapping the card focuses the draft instead of doing nothing', async () => {
154
+ const el = await mount({ shrinkWhileReading: false });
155
+ const box = textarea(el)!;
156
+ // The header row is the line the paperclip lives on; a click on its padding
157
+ // is a click on the composer, and the caret should land in the draft.
158
+ const header = el.querySelector('button[title="Attach file or image"]')!.parentElement!;
159
+ await act(async () => { header.dispatchEvent(new window.PointerEvent('pointerdown', { bubbles: true })); });
160
+ expect(document.activeElement).toBe(box);
161
+ });
162
+ });
@@ -23,14 +23,16 @@ import { describe, expect, test } from 'bun:test';
23
23
 
24
24
  import { composerExpansion } from '@/client/components/workspace/chat-timeline/chat-input/expansion';
25
25
 
26
- const base = { expanded: false, isDragging: false, keyboardInset: 0, compact: false } as const;
26
+ const base = { expanded: false, isDragging: false, keyboardInset: 0 } as const;
27
27
 
28
28
  describe('composerExpansion docked', () => {
29
29
  test('a phone keeps its touch-sized box and opens the popup upward', () => {
30
30
  const layout = composerExpansion({ ...base, variant: 'mobile' });
31
31
  expect(layout.cardPositionClass).toBe('relative');
32
32
  expect(layout.textareaClass).toContain('min-h-[68px]');
33
- expect(layout.textareaClass).toContain('text-base');
33
+ // The transcript's own size, not a larger one: a draft read at 16px beside
34
+ // 13px messages looked like a different document pasted underneath.
35
+ expect(layout.textareaClass).toContain('text-[13px]');
34
36
  expect(layout.pickerPlacement).toBe('up');
35
37
  expect(layout.style).toBeUndefined();
36
38
  });
@@ -38,7 +40,7 @@ describe('composerExpansion docked', () => {
38
40
  test('a desktop keeps its 80px box', () => {
39
41
  const layout = composerExpansion({ ...base, variant: 'desktop' });
40
42
  expect(layout.textareaClass).toContain('min-h-[80px]');
41
- expect(layout.textareaClass).toContain('text-sm');
43
+ expect(layout.textareaClass).toContain('text-[13px]');
42
44
  expect(layout.pickerPlacement).toBe('up');
43
45
  });
44
46
 
@@ -88,32 +90,31 @@ describe('composerExpansion frame matches a tool card', () => {
88
90
  });
89
91
 
90
92
  /**
91
- * The phone's compact composer: a reader scrolled back through the transcript
92
- * gets the height back. The rules that matter are which layout may shrink (only
93
- * a docked phone — a desktop sits beside the chat, a stretched one IS the
94
- * screen) and that it stays a card and a touch target rather than becoming a
95
- * second, differently shaped control.
93
+ * The compact composer is a DECISION ONE LEVEL UP, not a size in this table.
94
+ *
95
+ * A phone reader scrolled back through the transcript with an empty draft gets
96
+ * the height back by not rendering the textarea at all (`ChatInput`), and the
97
+ * attachment row then carries a "Type here" target that brings it back. So this
98
+ * module has nothing left to say about it — and the cases below pin that a
99
+ * docked or stretched layout is unchanged by the feature.
96
100
  */
97
- describe('composerExpansion compact', () => {
98
- test('a docked phone shrinks to a 44px box, still framed as a card', () => {
99
- const layout = composerExpansion({ ...base, variant: 'mobile', compact: true });
100
- // A fixed height, not a min-height: a textarea's own `rows` sets its
101
- // height, so a minimum alone never shrinks it.
102
- expect(layout.textareaClass).toContain('h-11');
103
- expect(layout.textareaClass).not.toContain('min-h-[68px]');
101
+ describe('composerExpansion has no compact size', () => {
102
+ test('a docked phone keeps its full box: compact removes the element instead', () => {
103
+ const layout = composerExpansion({ ...base, variant: 'mobile' });
104
+ expect(layout.textareaClass).toContain('min-h-[68px]');
105
+ expect(layout.textareaClass).not.toContain('h-9');
106
+ // Still the same card frame as a tool card.
104
107
  expect(layout.cardClass).toContain('rounded-xl');
105
108
  expect(layout.cardClass).toContain('shadow-sm');
106
- // The frame's own state does not change: compact is a SIZE, not a style.
107
- expect(layout.cardClass).toContain('border-ink/10');
108
109
  });
109
110
 
110
- test('a desktop ignores it: its composer sits beside the chat', () => {
111
- const layout = composerExpansion({ ...base, variant: 'desktop', compact: true });
111
+ test('a desktop is untouched', () => {
112
+ const layout = composerExpansion({ ...base, variant: 'desktop' });
112
113
  expect(layout.textareaClass).toContain('min-h-[80px]');
113
114
  });
114
115
 
115
- test('a stretched phone is the screen, so there is nothing to give back', () => {
116
- const layout = composerExpansion({ ...base, expanded: true, variant: 'mobile', compact: true });
116
+ test('a stretched phone is the screen', () => {
117
+ const layout = composerExpansion({ ...base, expanded: true, variant: 'mobile' });
117
118
  expect(layout.cardClass).toContain('rounded-none');
118
119
  expect(layout.textareaClass).toContain('h-full');
119
120
  });
@@ -59,13 +59,6 @@ export interface ComposerExpansionInput {
59
59
  * `useKeyboardInset`. 0 when there is no keyboard (or no `visualViewport`).
60
60
  */
61
61
  keyboardInset: number;
62
- /**
63
- * A phone reader is scrolled back through the transcript and the draft is
64
- * empty, so the composer gives the conversation its height back. Only the
65
- * docked phone layout has anything to give: a desktop composer sits beside
66
- * the chat rather than over it, and a stretched one is the screen.
67
- */
68
- compact: boolean;
69
62
  }
70
63
 
71
64
  export interface ComposerExpansion {
@@ -95,7 +88,6 @@ export function composerExpansion({
95
88
  expanded,
96
89
  isDragging,
97
90
  keyboardInset,
98
- compact,
99
91
  }: ComposerExpansionInput): ComposerExpansion {
100
92
  const mobile = variant === 'mobile';
101
93
  // Same frame as a tool card (`ToolCardShell`): `rounded-xl`, `border-ink/10`
@@ -112,18 +104,18 @@ export function composerExpansion({
112
104
  cardClass: `border rounded-xl bg-paper shadow-sm ${border}`,
113
105
  cardPositionClass: 'relative',
114
106
  cardLayoutClass: '',
115
- // A compact phone composer gives the transcript its height back. `h-11`
116
- // rather than a `min-h`: a textarea's height comes from its `rows`
117
- // (two lines by default), so a min-height alone never shrinks it —
118
- // measured, `min-h-[44px]` still laid out at 64px. The box is fixed at
119
- // 44px, the smallest reliable touch target, and it never needs to grow:
120
- // a draft restores the full composer on the same commit the first
121
- // character lands in it.
107
+ // The phone's compact state does not render a textarea at all (see
108
+ // `ChatInput`), so there is no size to return here. The class below is
109
+ // what a DOCKED composer uses; compact is decided one level up, by
110
+ // whether the element exists.
111
+ // Same type as the transcript it sits under: `text-[13px] leading-relaxed`
112
+ // is what every message row uses, so a draft reads at the size of the
113
+ // conversation it is replying to. `composer-type` carries the iOS guard —
114
+ // see the rule in tailwind.css, where a sub-16px control would make
115
+ // Safari zoom the page on focus.
122
116
  textareaClass: mobile
123
- ? compact
124
- ? 'px-3 py-2 text-base h-11 max-h-40'
125
- : 'px-3 py-3 text-base min-h-[68px] max-h-40'
126
- : 'px-3 py-3 text-sm min-h-[80px]',
117
+ ? 'px-3 py-3 text-[13px] leading-relaxed composer-type min-h-[68px] max-h-40'
118
+ : 'px-3 py-3 text-[13px] leading-relaxed composer-type min-h-[80px]',
127
119
  wrapperClass: '',
128
120
  pickerPlacement: 'up',
129
121
  style: undefined,
@@ -139,7 +131,7 @@ export function composerExpansion({
139
131
  cardClass: 'border-0 rounded-none bg-paper shadow-none',
140
132
  cardPositionClass: 'fixed inset-0 z-40',
141
133
  cardLayoutClass: '',
142
- textareaClass: 'h-full px-3 py-3 text-base',
134
+ textareaClass: 'h-full px-3 py-3 text-[13px] leading-relaxed composer-type',
143
135
  wrapperClass: 'flex-1 min-h-0',
144
136
  pickerPlacement: 'over',
145
137
  style: {
@@ -19,6 +19,7 @@ import { normalizeGoalBudget } from '@/shared/lib/omp/mode/budget';
19
19
  import { readKabmoSetting } from '@/shared/lib/settings/client';
20
20
  import { useKeyboardInset } from '@/client/hooks/ui/keyboard-inset';
21
21
  import { composerExpansion } from '@/client/components/workspace/chat-timeline/chat-input/expansion';
22
+ import { useComposerCompact } from '@/client/hooks/chat/composer/compact';
22
23
  import type { ChatInputProps } from '@/client/components/workspace/chat-timeline/chat-input/props';
23
24
 
24
25
  export function ChatInput({
@@ -142,13 +143,16 @@ export function ChatInput({
142
143
  // Enter, so the covered toolbar would be the only way to send). Docked, the
143
144
  // card is inside the chat's own column and the measurement is unused.
144
145
  const keyboardInset = useKeyboardInset(cardRef, expandable && expanded);
145
- // A phone gives its height back to the transcript while the reader is
146
- // scrolling back — but only when there is nothing to lose: a draft means the
147
- // user is writing, and the box must stay the size it was. A focused composer
148
- // is the same rule from the other side (the tap that re-opens it must not be
149
- // undone by the scroll position it was tapped from).
150
- const compact = shrinkWhileReading && !composerFocused && !value.trim() && attachments.length === 0;
151
- const layout = composerExpansion({ variant, expanded, isDragging, keyboardInset, compact });
146
+ const { compact, reopen, onCardPointerDown } = useComposerCompact({
147
+ cardRef,
148
+ isMobile,
149
+ shrinkWhileReading,
150
+ composerFocused,
151
+ setComposerFocused,
152
+ value,
153
+ attachments,
154
+ });
155
+ const layout = composerExpansion({ variant, expanded, isDragging, keyboardInset });
152
156
 
153
157
  // Escape returns to the docked composer. Bound to the document rather than to
154
158
  // the textarea so it works while the picker has focus — the picker consumes
@@ -199,7 +203,22 @@ export function ChatInput({
199
203
  // focus, which never happens). Preact maps `onFocusIn`/`onFocusOut` to
200
204
  // the native bubble-phase events.
201
205
  onFocusIn={() => setComposerFocused(true)}
202
- onFocusOut={() => setComposerFocused(false)}
206
+ // A focusout is NOT proof the reader left the card. Re-opening from the
207
+ // compact state REMOVES the "Type here" button that had focus, and the
208
+ // browser emits a focusout for it after the new textarea has already been
209
+ // focused — so the flag went false one tick later and the composer
210
+ // collapsed again (measured: `focusin:BUTTON, focusout:BUTTON,
211
+ // added:DIV, removed:DIV, added:BUTTON` — open and shut within a frame).
212
+ // The question is where focus ENDED UP, which is only answerable after
213
+ // the focus change has settled.
214
+ onFocusOut={() => {
215
+ requestAnimationFrame(() => {
216
+ if (!cardRef.current?.contains(document.activeElement)) setComposerFocused(false);
217
+ });
218
+ }}
219
+ // Press-time, not click-time — see `useComposerCompact` for why a click
220
+ // never arrives in the compact state.
221
+ onPointerDown={onCardPointerDown}
203
222
  {...dropProps}
204
223
  >
205
224
  <DropOverlay visible={isDragging} />
@@ -226,6 +245,8 @@ export function ChatInput({
226
245
  // (the New Chat modal) gets no control and cannot be stretched.
227
246
  expanded={expandable ? expanded : undefined}
228
247
  onToggleExpand={expandable ? () => setExpanded((prev) => !prev) : undefined}
248
+ compact={compact}
249
+ onExpandComposer={reopen}
229
250
  />
230
251
 
231
252
  {/* Refused plan/goal command notice. */}
@@ -244,25 +265,31 @@ export function ChatInput({
244
265
  </div>
245
266
  )}
246
267
 
247
- {/* Textarea */}
248
- <ComposerTextarea
249
- value={value}
250
- onChange={onChange}
251
- onSend={handleSubmit}
252
- disabled={disabled}
253
- appSettings={appSettings}
254
- rootPath={rootPath}
255
- enablePicker={enablePicker}
256
- placement={layout.pickerPlacement}
257
- wrapperClassName={layout.wrapperClass}
258
- placeholder={placeholder ?? (disabled ? "Please select a workspace above to start prompting..." : "@ for files/agents; / for commands and skills (Paste images/files here)")}
259
- className={`w-full bg-transparent border-none focus:outline-none resize-none text-ink placeholder-ink/40 disabled:opacity-50 disabled:cursor-not-allowed ${layout.textareaClass}`}
260
- onPaste={handlePaste}
261
- variant={variant}
262
- textareaRef={autoGrow ? growRef : undefined}
263
- onReady={onTextareaReady}
264
- />
265
-
268
+ {/* Textarea. Not rendered at all while compact: the phone gives the
269
+ transcript its height back by removing the box, not by shrinking it,
270
+ and `AttachmentToolbar` then carries the "Type here" target that
271
+ brings it back. Rendering it at `h-0` would keep a focusable element
272
+ with no pixels, which is exactly the trap the `inert` rules elsewhere
273
+ exist to avoid. */}
274
+ {!compact && (
275
+ <ComposerTextarea
276
+ value={value}
277
+ onChange={onChange}
278
+ onSend={handleSubmit}
279
+ disabled={disabled}
280
+ appSettings={appSettings}
281
+ rootPath={rootPath}
282
+ enablePicker={enablePicker}
283
+ placement={layout.pickerPlacement}
284
+ wrapperClassName={layout.wrapperClass}
285
+ placeholder={placeholder ?? (disabled ? "Please select a workspace above to start prompting..." : "@ for files/agents; / for commands and skills (Paste images/files here)")}
286
+ className={`w-full bg-transparent border-none focus:outline-none resize-none text-ink placeholder-ink/40 disabled:opacity-50 disabled:cursor-not-allowed ${layout.textareaClass}`}
287
+ onPaste={handlePaste}
288
+ variant={variant}
289
+ textareaRef={autoGrow ? growRef : undefined}
290
+ onReady={onTextareaReady}
291
+ />
292
+ )}
266
293
  {/* Dictation progress / failure, above the toolbar that owns the mic. */}
267
294
  <VoiceNotice status={voiceStatus} error={displayedVoiceError} />
268
295
 
@@ -13,6 +13,7 @@ import type { SetStateAction } from 'preact/compat';
13
13
  import type { Attachment } from '@/shared/types';
14
14
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
15
15
  import type { ComposerModes } from '@/client/components/workspace/chat-timeline/chat-input/modes-props';
16
+ import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
16
17
 
17
18
  export interface ChatInputProps {
18
19
  value: string;
@@ -104,4 +105,19 @@ export interface ChatInputProps {
104
105
  * reader reaches the tail, types, or focuses the box.
105
106
  */
106
107
  shrinkWhileReading?: boolean;
108
+ /**
109
+ * "Wait for session": the pick, the sessions that may be waited on, and the
110
+ * setter.
111
+ *
112
+ * Omitted by every composer that is not a NEW chat's — the pick defers a
113
+ * prompt that has no session yet, so an existing chat has nothing to defer.
114
+ * The chat composer never passes it either: its own timeline renders
115
+ * `EmptyWorkspacePrompt` while the session is pending, and that is where the
116
+ * picker lives.
117
+ */
118
+ waiting?: {
119
+ value: WaitingCandidate | null;
120
+ candidates: WaitingCandidate[];
121
+ onChange: (candidate: WaitingCandidate | null) => void;
122
+ };
107
123
  }