@kabmo-labs/kabmo 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +412 -383
- package/dist/client/static/js/index-3qamd33d.css +1 -0
- package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
- package/package.json +1 -1
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
- package/src/client/components/common/session-actions-menu/index.tsx +23 -2
- package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
- package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
- package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
- package/src/client/components/layout/LeftDock.tsx +119 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
- package/src/client/components/layout/desktop-layout/index.tsx +88 -61
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
- package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
- package/src/client/components/mobile/RightSidebar.tsx +8 -5
- package/src/client/components/mobile/SessionSidebar.tsx +6 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
- package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
- package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
- package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
- package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
- package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
- package/src/client/components/workspace/editor/index.tsx +1 -0
- package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
- package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
- package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
- package/src/client/components/workspace/panels/builtin.tsx +8 -0
- package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
- package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
- package/src/client/hooks/chat/composer/compact.ts +119 -0
- package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
- package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
- package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
- package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
- package/src/client/hooks/chat/timeline/actions.ts +34 -35
- package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
- package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
- package/src/client/hooks/chat/timeline/index.ts +15 -19
- package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
- package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
- package/src/client/hooks/chat/waiting/cancel.ts +34 -0
- package/src/client/hooks/chat/waiting/choice.ts +53 -0
- package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
- package/src/client/hooks/chat/waiting/held.ts +42 -0
- package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
- package/src/client/hooks/chat/waiting/hold.ts +156 -0
- package/src/client/hooks/chat/waiting/store.ts +100 -0
- package/src/client/hooks/workspace/left-dock.ts +132 -0
- package/src/client/hooks/workspace/panel-registry.ts +13 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.test.ts +52 -1
- package/src/server/db.server.ts +49 -1
- package/src/server/index.ts +9 -0
- package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
- package/src/server/lib/assets/compressed-cache.server.ts +88 -0
- package/src/server/lib/db/schema.test.ts +1 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/fs/fs-path.test.ts +1 -1
- package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
- package/src/server/lib/omp/session/delete.server.ts +8 -0
- package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
- package/src/server/lib/panels/registry.server.ts +2 -2
- package/src/server/lib/queue/delivery.server.ts +31 -39
- package/src/server/lib/queue/payload.ts +73 -0
- package/src/server/lib/realtime/signals.server.ts +10 -1
- package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
- package/src/server/lib/realtime/topics/global.server.ts +5 -0
- package/src/server/lib/realtime/topics.server.ts +2 -0
- package/src/server/lib/util/with-timeout.ts +29 -0
- package/src/server/lib/waiting/row-clock.server.ts +37 -0
- package/src/server/lib/waiting/runtime.server.test.ts +278 -0
- package/src/server/lib/waiting/runtime.server.ts +313 -0
- package/src/server/lib/waiting/schema.server.ts +46 -0
- package/src/server/lib/waiting/settle.test.ts +84 -0
- package/src/server/lib/waiting/settle.ts +77 -0
- package/src/server/lib/waiting/store.server.test.ts +167 -0
- package/src/server/lib/waiting/store.server.ts +268 -0
- package/src/server/lib/waiting/sweep-trigger.ts +35 -0
- package/src/server/plugins/compress.ts +18 -6
- package/src/server/plugins/ssr.ts +16 -8
- package/src/server/plugins/static.test.ts +59 -0
- package/src/server/plugins/static.ts +56 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/waiting/bindings.ts +14 -0
- package/src/server/routes/waiting/index.ts +123 -0
- package/src/server/routes/waiting/routes.test.ts +121 -0
- package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
- package/src/shared/lib/chat/timeline/gutter.ts +37 -0
- package/src/shared/lib/realtime/protocol.ts +10 -0
- package/src/shared/lib/workspace/left-dock-width.ts +78 -0
- package/src/shared/lib/workspace/left-dock.test.ts +203 -0
- package/src/shared/lib/workspace/left-dock.ts +197 -0
- package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
- package/src/shared/lib/workspace/panel-ids.ts +114 -26
- package/src/shared/lib/workspace/sidebar-width.ts +4 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/waiting.ts +54 -0
- package/src/shared/types/workspace.ts +8 -0
- package/src/test-support/isolated-db.ts +3 -4
- package/dist/client/static/js/index-pdp5dwc8.css +0 -1
|
@@ -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,154 @@
|
|
|
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, 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 { resetRealtimeClient } from '@/shared/lib/realtime/client';
|
|
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
|
+
afterEach(() => {
|
|
51
|
+
container?.remove();
|
|
52
|
+
container = null;
|
|
53
|
+
// The composer's model picker subscribes to the realtime topic, and its
|
|
54
|
+
// reconnect timer outlives this file's DOM — it then fires into a torn-down
|
|
55
|
+
// `window` and surfaces as an unhandled error in whichever test runs next.
|
|
56
|
+
// Resetting the client is what the app's own suites do for the same reason.
|
|
57
|
+
resetRealtimeClient();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
interface MountOptions {
|
|
61
|
+
shrinkWhileReading?: boolean;
|
|
62
|
+
value?: string;
|
|
63
|
+
variant?: 'desktop' | 'mobile';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function mount({
|
|
67
|
+
shrinkWhileReading = false,
|
|
68
|
+
value = '',
|
|
69
|
+
variant = 'mobile',
|
|
70
|
+
}: MountOptions = {}): Promise<HTMLElement> {
|
|
71
|
+
const host = document.createElement('div');
|
|
72
|
+
document.body.appendChild(host);
|
|
73
|
+
await act(async () => {
|
|
74
|
+
// No `SessionStateProvider`: the composer reads no session UI state, and the
|
|
75
|
+
// provider's own load kicks a realtime reconnect that outlives this file and
|
|
76
|
+
// throws `window is not defined` after teardown.
|
|
77
|
+
render(
|
|
78
|
+
h(ChatInput, {
|
|
79
|
+
value,
|
|
80
|
+
onChange: () => {},
|
|
81
|
+
onSend: () => {},
|
|
82
|
+
isGenerating: false,
|
|
83
|
+
composerModelRef: { current: null },
|
|
84
|
+
variant,
|
|
85
|
+
shrinkWhileReading,
|
|
86
|
+
sessionId: 'composer-compact-test',
|
|
87
|
+
}),
|
|
88
|
+
host,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
container = host;
|
|
92
|
+
return host;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const textarea = (el: HTMLElement) => el.querySelector('textarea');
|
|
96
|
+
const typeHere = (el: HTMLElement) =>
|
|
97
|
+
Array.from(el.querySelectorAll('button')).find((b) => (b.textContent ?? '').trim() === 'Type here') ?? null;
|
|
98
|
+
|
|
99
|
+
describe('compact composer', () => {
|
|
100
|
+
test('a scrolled-back phone with an empty draft renders no textarea at all', async () => {
|
|
101
|
+
const el = await mount({ shrinkWhileReading: true });
|
|
102
|
+
expect(textarea(el)).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('…and offers exactly one target that says what tapping it does', async () => {
|
|
106
|
+
const el = await mount({ shrinkWhileReading: true });
|
|
107
|
+
const target = typeHere(el);
|
|
108
|
+
expect(target).not.toBeNull();
|
|
109
|
+
expect(target!.tagName).toBe('BUTTON');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('a draft keeps the box: the reader is writing, not reading', async () => {
|
|
113
|
+
const el = await mount({ shrinkWhileReading: true, value: 'half a prompt' });
|
|
114
|
+
expect(textarea(el)).not.toBeNull();
|
|
115
|
+
expect(typeHere(el)).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a desktop is never compact — its composer sits beside the chat', async () => {
|
|
119
|
+
const el = await mount({ shrinkWhileReading: true, variant: 'desktop' });
|
|
120
|
+
expect(textarea(el)).not.toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('tapping the target brings the textarea back, focused', async () => {
|
|
124
|
+
const el = await mount({ shrinkWhileReading: true });
|
|
125
|
+
const target = typeHere(el)!;
|
|
126
|
+
// POINTERDOWN, not click: pressing the target expands the composer, which
|
|
127
|
+
// removes the button the finger is still on — so the browser never emits a
|
|
128
|
+
// click at all (measured in Chromium: `pd:BUTTON` with no `click` after it).
|
|
129
|
+
await act(async () => { target.dispatchEvent(new window.PointerEvent('pointerdown', { bubbles: true })); });
|
|
130
|
+
const box = textarea(el);
|
|
131
|
+
expect(box).not.toBeNull();
|
|
132
|
+
// The tap ends where the reader aimed it: the caret is in the box, which is
|
|
133
|
+
// also what stops the scroll position from collapsing it again.
|
|
134
|
+
expect(document.activeElement).toBe(box);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('tapping a control inside the compact card does not also re-open it', async () => {
|
|
138
|
+
const el = await mount({ shrinkWhileReading: true });
|
|
139
|
+
const paperclip = el.querySelector('button[title="Attach file or image"]') as HTMLButtonElement;
|
|
140
|
+
await act(async () => { paperclip.dispatchEvent(new window.PointerEvent('pointerdown', { bubbles: true })); });
|
|
141
|
+
// The paperclip owns its own click; the card must not turn it into a reopen.
|
|
142
|
+
expect(textarea(el)).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('in the normal state, tapping the card focuses the draft instead of doing nothing', async () => {
|
|
146
|
+
const el = await mount({ shrinkWhileReading: false });
|
|
147
|
+
const box = textarea(el)!;
|
|
148
|
+
// The header row is the line the paperclip lives on; a click on its padding
|
|
149
|
+
// is a click on the composer, and the caret should land in the draft.
|
|
150
|
+
const header = el.querySelector('button[title="Attach file or image"]')!.parentElement!;
|
|
151
|
+
await act(async () => { header.dispatchEvent(new window.PointerEvent('pointerdown', { bubbles: true })); });
|
|
152
|
+
expect(document.activeElement).toBe(box);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -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
|
|
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
|
-
|
|
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-
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
99
|
-
const layout = composerExpansion({ ...base, variant: 'mobile'
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
111
|
-
const layout = composerExpansion({ ...base, variant: 'desktop'
|
|
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
|
|
116
|
-
const layout = composerExpansion({ ...base, expanded: true, variant: 'mobile'
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
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
|
-
?
|
|
124
|
-
|
|
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-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
}
|