@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,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The PENDING chat's own view: the workspace/wait pickers, its optimistic
|
|
8
|
+
* rows, and the composer it hands them to.
|
|
9
|
+
*
|
|
10
|
+
* Split out of `chat-timeline/index.tsx` for the per-file ceiling, and it is
|
|
11
|
+
* the branch that file used to inline: a `new-…` chat has no omp session, so it
|
|
12
|
+
* renders from the client's own state and its composer is the one that can
|
|
13
|
+
* defer a prompt. Keeping the two branches in one component is what let the
|
|
14
|
+
* pending view drift behind the real one (same composer, different props).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Attachment, ChatMessageData, WorkspaceFolderData } from '@/shared/types';
|
|
18
|
+
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
19
|
+
import type { ComposerModes } from '@/client/components/workspace/chat-timeline/chat-input/modes-props';
|
|
20
|
+
import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
|
|
21
|
+
import type { DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
|
|
22
|
+
import { EmptyWorkspacePrompt } from '@/client/components/workspace/chat-timeline/EmptyWorkspacePrompt';
|
|
23
|
+
|
|
24
|
+
export interface PendingChatViewProps {
|
|
25
|
+
className: string;
|
|
26
|
+
folders: WorkspaceFolderData[];
|
|
27
|
+
selectedFolderId: number | null;
|
|
28
|
+
setSelectedFolderId: (id: number | null) => void;
|
|
29
|
+
rootPath: string | null;
|
|
30
|
+
inputValue: string;
|
|
31
|
+
setInputValue: (value: string) => void;
|
|
32
|
+
inputAttachments: Attachment[];
|
|
33
|
+
setInputAttachments: (attachments: Attachment[] | ((prev: Attachment[]) => Attachment[])) => void;
|
|
34
|
+
onSend: (attachments: Attachment[], options?: { steering?: boolean }) => void;
|
|
35
|
+
isGenerating: boolean;
|
|
36
|
+
appSettings: Record<string, unknown>;
|
|
37
|
+
messages: ChatMessageData[];
|
|
38
|
+
provider?: string;
|
|
39
|
+
providerNames?: Record<string, string>;
|
|
40
|
+
modelName?: string;
|
|
41
|
+
modelNames?: Record<string, string>;
|
|
42
|
+
onThinkingLevelChange: (level: string) => void;
|
|
43
|
+
onModelChange: (provider: string, modelId: string) => void;
|
|
44
|
+
sessionModel: { provider: string; modelId: string } | null;
|
|
45
|
+
sessionThinkingLevel?: string | null;
|
|
46
|
+
accessMode: ApprovalMode;
|
|
47
|
+
onAccessModeChange: (mode: ApprovalMode) => void;
|
|
48
|
+
modes?: ComposerModes;
|
|
49
|
+
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
50
|
+
deferredComposerPickRef: DeferredModelStore;
|
|
51
|
+
generatingVerb: string;
|
|
52
|
+
variant: 'desktop' | 'mobile';
|
|
53
|
+
onUndo?: (id: string, content?: string) => void;
|
|
54
|
+
onRetry?: (id: string) => void;
|
|
55
|
+
onNewChat?: (content: string, attachments?: Attachment[]) => void;
|
|
56
|
+
streamStatusMap: Record<string, string | undefined>;
|
|
57
|
+
titleForSession?: (id: string) => string | undefined;
|
|
58
|
+
waitingFor: WaitingCandidate | null;
|
|
59
|
+
onWaitingForChange: (candidate: WaitingCandidate | null) => void;
|
|
60
|
+
heldWait: { title: string | null } | null;
|
|
61
|
+
onCancelWait: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function PendingChatView(props: PendingChatViewProps) {
|
|
65
|
+
return (
|
|
66
|
+
<EmptyWorkspacePrompt
|
|
67
|
+
className={props.className}
|
|
68
|
+
folders={props.folders}
|
|
69
|
+
selectedFolderId={props.selectedFolderId}
|
|
70
|
+
setSelectedFolderId={props.setSelectedFolderId}
|
|
71
|
+
rootPath={props.rootPath}
|
|
72
|
+
inputValue={props.inputValue}
|
|
73
|
+
setInputValue={props.setInputValue}
|
|
74
|
+
inputAttachments={props.inputAttachments}
|
|
75
|
+
setInputAttachments={props.setInputAttachments}
|
|
76
|
+
onSend={props.onSend}
|
|
77
|
+
isGenerating={props.isGenerating}
|
|
78
|
+
appSettings={props.appSettings}
|
|
79
|
+
messages={props.messages}
|
|
80
|
+
provider={props.provider}
|
|
81
|
+
providerNames={props.providerNames}
|
|
82
|
+
modelName={props.modelName}
|
|
83
|
+
modelNames={props.modelNames}
|
|
84
|
+
onThinkingLevelChange={props.onThinkingLevelChange}
|
|
85
|
+
onModelChange={props.onModelChange}
|
|
86
|
+
accessMode={props.accessMode}
|
|
87
|
+
onAccessModeChange={props.onAccessModeChange}
|
|
88
|
+
modes={props.modes}
|
|
89
|
+
composerModelRef={props.composerModelRef}
|
|
90
|
+
deferredComposerPickRef={props.deferredComposerPickRef}
|
|
91
|
+
sessionModel={props.sessionModel}
|
|
92
|
+
sessionThinkingLevel={props.sessionThinkingLevel}
|
|
93
|
+
generatingVerb={props.generatingVerb}
|
|
94
|
+
variant={props.variant}
|
|
95
|
+
onUndo={props.onUndo}
|
|
96
|
+
onRetry={props.onRetry}
|
|
97
|
+
onNewChat={props.onNewChat}
|
|
98
|
+
streamStatusMap={props.streamStatusMap}
|
|
99
|
+
titleForSession={props.titleForSession}
|
|
100
|
+
waitingFor={props.waitingFor}
|
|
101
|
+
onWaitingForChange={props.onWaitingForChange}
|
|
102
|
+
heldWait={props.heldWait}
|
|
103
|
+
onCancelWait={props.onCancelWait}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -50,7 +50,7 @@ export function SessionSkeleton() {
|
|
|
50
50
|
return (
|
|
51
51
|
<div aria-hidden className="flex flex-col h-full min-h-0 overflow-hidden">
|
|
52
52
|
{/* Timeline body — mirrors the real container's padding in both variants. */}
|
|
53
|
-
<div className="flex-1 min-h-0 overflow-hidden
|
|
53
|
+
<div className="flex-1 min-h-0 overflow-hidden pl-4 pr-3 py-3 pb-8 sm:p-4 sm:pb-10">
|
|
54
54
|
<div className="mx-auto w-full max-w-[970px]">
|
|
55
55
|
{rows.map((row, i) => (
|
|
56
56
|
<SkeletonBubble key={i} row={row} index={i} />
|
|
@@ -110,7 +110,9 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
|
|
|
110
110
|
{/* Transcript */}
|
|
111
111
|
<div
|
|
112
112
|
ref={scrollRef}
|
|
113
|
-
className=
|
|
113
|
+
className={`flex-1 min-h-0 overflow-y-auto overscroll-contain scrollbar-overlay-container ${
|
|
114
|
+
isMobile ? 'pl-4 pr-3 py-3 pb-8' : 'p-4 pb-10'
|
|
115
|
+
}`}
|
|
114
116
|
>
|
|
115
117
|
<div className="mx-auto w-full max-w-[970px]">
|
|
116
118
|
{isLoading && messages.length === 0 ? (
|
|
@@ -137,6 +139,7 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
|
|
|
137
139
|
isStreaming={isLoading}
|
|
138
140
|
isPrevAssistant={isPrevAssistant}
|
|
139
141
|
userActions={false}
|
|
142
|
+
isMobile={isMobile}
|
|
140
143
|
className={isNoticeRow(msg) ? 'mt-3 mb-1' : isPrevAssistant ? 'mt-1' : 'mt-3'}
|
|
141
144
|
/>
|
|
142
145
|
{footer && (
|
|
@@ -10,6 +10,7 @@ import { parseGoalNotice, type GoalNoticeData } from '@/shared/lib/omp/mode/noti
|
|
|
10
10
|
import { GoalNotice } from '@/client/components/workspace/chat-timeline/GoalNotice';
|
|
11
11
|
import { TaskResultContent } from '@/client/components/workspace/chat-timeline/TaskResultContent';
|
|
12
12
|
import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
|
|
13
|
+
import { transcriptGutter } from '@/shared/lib/chat/timeline/gutter';
|
|
13
14
|
|
|
14
15
|
const KIND_LABEL: Record<GoalNoticeData['kind'], string> = {
|
|
15
16
|
start: 'Goal started',
|
|
@@ -43,12 +44,14 @@ interface SystemNoticeProps {
|
|
|
43
44
|
/** The `customType` this notice came from, when it has one — the only way to
|
|
44
45
|
* recognize a goal's opening turn, whose text is the bare objective. */
|
|
45
46
|
source?: string;
|
|
47
|
+
/** Phone layout: the card's frame is the timeline gutter itself. */
|
|
48
|
+
isMobile?: boolean;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/** System notice card — handles generic notices, structured <task-result> agent
|
|
49
52
|
* jobs, and <system-reminder> blocks, all collapsed by default; a goal-mode
|
|
50
53
|
* injection renders open and has no collapse control (see `forcedOpen`). */
|
|
51
|
-
export function SystemNotice({ notice, source }: SystemNoticeProps) {
|
|
54
|
+
export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeProps) {
|
|
52
55
|
const [userOpen, setUserOpen] = useState(false);
|
|
53
56
|
useSyntaxReady();
|
|
54
57
|
|
|
@@ -195,7 +198,7 @@ export function SystemNotice({ notice, source }: SystemNoticeProps) {
|
|
|
195
198
|
|
|
196
199
|
return (
|
|
197
200
|
<div
|
|
198
|
-
className={
|
|
201
|
+
className={`${transcriptGutter(isMobile)} overflow-hidden rounded-xl border border-ink/10 bg-paper text-[12px] text-ink transition-colors ${
|
|
199
202
|
canToggle ? 'hover:border-ink/20' : ''
|
|
200
203
|
} select-text`}
|
|
201
204
|
>
|
|
@@ -3,17 +3,21 @@ import { BrainCircuit, ChevronDown, Sparkles } from 'lucide-preact';
|
|
|
3
3
|
import type { ThinkingData } from '@/shared/types';
|
|
4
4
|
import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
|
|
5
5
|
import { CopyButton } from '@/client/components/common/CopyButton';
|
|
6
|
+
import { transcriptGutter } from '@/shared/lib/chat/timeline/gutter';
|
|
6
7
|
|
|
7
8
|
interface ThinkingSectionProps {
|
|
8
9
|
thinking: ThinkingData | string;
|
|
9
10
|
defaultExpanded?: boolean;
|
|
10
11
|
showSeparator?: boolean;
|
|
12
|
+
/** Phone layout: the section's frame is the timeline gutter itself. */
|
|
13
|
+
isMobile?: boolean;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export function ThinkingSection({
|
|
14
17
|
thinking,
|
|
15
18
|
defaultExpanded = false,
|
|
16
|
-
showSeparator = false
|
|
19
|
+
showSeparator = false,
|
|
20
|
+
isMobile = false,
|
|
17
21
|
}: ThinkingSectionProps) {
|
|
18
22
|
const isGenerating = typeof thinking === 'object' ? Boolean(thinking.isGenerating) : false;
|
|
19
23
|
const [isOpen, setIsOpen] = useState(defaultExpanded || isGenerating);
|
|
@@ -43,7 +47,7 @@ export function ThinkingSection({
|
|
|
43
47
|
if (!cleanThought) return null;
|
|
44
48
|
|
|
45
49
|
return (
|
|
46
|
-
<div className={
|
|
50
|
+
<div className={`${transcriptGutter(isMobile)} ${showSeparator ? 'space-y-1.5' : ''}`}>
|
|
47
51
|
{showSeparator && (
|
|
48
52
|
<div className="flex items-center gap-2 px-1 pt-0.5">
|
|
49
53
|
<span className="text-[9.5px] font-semibold uppercase tracking-[0.14em] text-ink/35">
|
|
@@ -82,7 +82,7 @@ export function TimelineBody({
|
|
|
82
82
|
ref={scrollRef}
|
|
83
83
|
onScroll={handleScroll}
|
|
84
84
|
className={`flex-1 scrollbar-overlay-container overscroll-contain scroll-smooth overflow-x-hidden ${
|
|
85
|
-
isMobile ? '
|
|
85
|
+
isMobile ? 'pl-4 pr-3 py-3 pb-8' : 'p-4 pb-10'
|
|
86
86
|
}`}
|
|
87
87
|
>
|
|
88
88
|
<div ref={contentRef} className="mx-auto w-full max-w-[970px]">
|
|
@@ -75,7 +75,7 @@ async function mount(tools: ToolCallData[]) {
|
|
|
75
75
|
* cards. A section renders nothing else.
|
|
76
76
|
*/
|
|
77
77
|
function sequence(el: HTMLElement): Array<{ kind: 'heading' | 'card'; text: string }> {
|
|
78
|
-
const section = el.querySelector('
|
|
78
|
+
const section = el.querySelector('[data-tool-section]')!;
|
|
79
79
|
const items = Array.from(section.children).map((child) => {
|
|
80
80
|
const cls = (child.className || '').toString();
|
|
81
81
|
if (cls.includes('overflow-hidden rounded-xl')) {
|
|
@@ -109,7 +109,7 @@ describe('ToolCallingSection per-card intent heading', () => {
|
|
|
109
109
|
{ kind: 'card', text: 'Bash' },
|
|
110
110
|
]);
|
|
111
111
|
// No header row: every direct child is a card.
|
|
112
|
-
const children = Array.from(el.querySelector('
|
|
112
|
+
const children = Array.from(el.querySelector('[data-tool-section]')!.children) as HTMLElement[];
|
|
113
113
|
expect(children).toHaveLength(2);
|
|
114
114
|
expect(children.every((child) => child.className.includes('overflow-hidden rounded-xl'))).toBe(true);
|
|
115
115
|
});
|
|
@@ -126,7 +126,7 @@ describe('ToolCallingSection per-card intent heading', () => {
|
|
|
126
126
|
test('a long intent heading is clamped to one line with the full text in title', async () => {
|
|
127
127
|
const long = 'x'.repeat(200);
|
|
128
128
|
const el = await mount([tool('a', long)]);
|
|
129
|
-
const heading = el.querySelector('
|
|
129
|
+
const heading = el.querySelector('[data-tool-section] > div > span') as HTMLElement;
|
|
130
130
|
expect(heading.textContent).toBe(long);
|
|
131
131
|
expect(heading.className).toContain('truncate');
|
|
132
132
|
expect(heading.className).toContain('min-w-0');
|
|
@@ -137,7 +137,7 @@ describe('ToolCallingSection per-card intent heading', () => {
|
|
|
137
137
|
describe('ToolCallingSection heading rule', () => {
|
|
138
138
|
test('the intent heading rule fills the row, the way a card heading draws it', async () => {
|
|
139
139
|
const el = await mount([tool('a', 'Finding PWA manifest')]);
|
|
140
|
-
const row = el.querySelector('
|
|
140
|
+
const row = el.querySelector('[data-tool-section] > div') as HTMLElement;
|
|
141
141
|
const [label, rule] = Array.from(row.children) as HTMLElement[];
|
|
142
142
|
// The label sizes to its text and the rule takes the remainder, so the line
|
|
143
143
|
// runs to the panel edge. Two `flex-1` items would split the row 50/50 and
|
|
@@ -154,7 +154,7 @@ describe('ToolCallingSection heading rule', () => {
|
|
|
154
154
|
|
|
155
155
|
test('the rule keeps a minimum width so a long intent cannot collapse it', async () => {
|
|
156
156
|
const el = await mount([tool('a', 'x'.repeat(400))]);
|
|
157
|
-
const row = el.querySelector('
|
|
157
|
+
const row = el.querySelector('[data-tool-section] > div') as HTMLElement;
|
|
158
158
|
const rule = row.children[1] as HTMLElement;
|
|
159
159
|
expect(rule.className).toContain('min-w-6');
|
|
160
160
|
});
|
|
@@ -8,10 +8,13 @@ import { useMemo } from 'preact/hooks';
|
|
|
8
8
|
import type { AgentActionData, ToolCallData, ToolType } from '@/shared/types';
|
|
9
9
|
import { ToolCallCard } from '@/client/components/workspace/chat-timeline/ToolCallCard';
|
|
10
10
|
import { useToolOpenState } from '@/client/hooks/chat/timeline/tool-open';
|
|
11
|
+
import { transcriptGutter } from '@/shared/lib/chat/timeline/gutter';
|
|
11
12
|
|
|
12
13
|
interface ToolCallingSectionProps {
|
|
13
14
|
tools: (ToolCallData | AgentActionData)[];
|
|
14
15
|
defaultExpanded?: boolean;
|
|
16
|
+
/** Phone layout: the section's frame is the timeline gutter itself. */
|
|
17
|
+
isMobile?: boolean;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
function normalizeToolData(action: ToolCallData | AgentActionData, index: number): ToolCallData {
|
|
@@ -53,7 +56,7 @@ function normalizeToolData(action: ToolCallData | AgentActionData, index: number
|
|
|
53
56
|
};
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
export function ToolCallingSection({ tools, defaultExpanded = false }: ToolCallingSectionProps) {
|
|
59
|
+
export function ToolCallingSection({ tools, defaultExpanded = false, isMobile = false }: ToolCallingSectionProps) {
|
|
57
60
|
const normalizedTools = useMemo(() => {
|
|
58
61
|
return (tools || []).map((t, i) => normalizeToolData(t, i));
|
|
59
62
|
}, [tools]);
|
|
@@ -65,7 +68,7 @@ export function ToolCallingSection({ tools, defaultExpanded = false }: ToolCalli
|
|
|
65
68
|
if (!normalizedTools || normalizedTools.length === 0) return null;
|
|
66
69
|
|
|
67
70
|
return (
|
|
68
|
-
<div className=
|
|
71
|
+
<div className={`${transcriptGutter(isMobile)} space-y-1.5`} data-tool-section>
|
|
69
72
|
{normalizedTools.map((tool) => {
|
|
70
73
|
const intent = tool.intent?.trim();
|
|
71
74
|
return (
|
|
@@ -24,9 +24,17 @@ interface AttachmentToolbarProps {
|
|
|
24
24
|
*/
|
|
25
25
|
expanded?: boolean;
|
|
26
26
|
onToggleExpand?: () => void;
|
|
27
|
+
/**
|
|
28
|
+
* The phone's compact composer: the textarea is gone, so this row carries the
|
|
29
|
+
* affordance that brings it back — a "Type here" target beside the paperclip.
|
|
30
|
+
* Tapping it (or the card) is what expands the composer, which is why it is a
|
|
31
|
+
* button rather than a label.
|
|
32
|
+
*/
|
|
33
|
+
compact?: boolean;
|
|
34
|
+
onExpandComposer?: () => void;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
export function AttachmentToolbar({ attachments, onFilesSelected, onRemove, modes, onOpenGoal, expanded, onToggleExpand }: AttachmentToolbarProps) {
|
|
37
|
+
export function AttachmentToolbar({ attachments, onFilesSelected, onRemove, modes, onOpenGoal, expanded, onToggleExpand, compact = false, onExpandComposer }: AttachmentToolbarProps) {
|
|
30
38
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
31
39
|
|
|
32
40
|
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
|
|
@@ -50,6 +58,22 @@ export function AttachmentToolbar({ attachments, onFilesSelected, onRemove, mode
|
|
|
50
58
|
<Paperclip size={14} />
|
|
51
59
|
</button>
|
|
52
60
|
|
|
61
|
+
{/* The compact composer's own placeholder. The textarea is not rendered
|
|
62
|
+
at all in that state, so this is the only thing that says the card
|
|
63
|
+
is still a composer — and the only thing a tap can hit to bring the
|
|
64
|
+
box back. It sits right beside the paperclip, where the text used to
|
|
65
|
+
start, so the row keeps its reading order. */}
|
|
66
|
+
{compact && (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
data-composer-reopen
|
|
70
|
+
onClick={onExpandComposer}
|
|
71
|
+
className="min-w-0 flex-1 text-left text-[13px] text-ink/40 truncate"
|
|
72
|
+
>
|
|
73
|
+
Type here
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
|
|
53
77
|
{attachments.map(att => (
|
|
54
78
|
<div key={att.id} className="relative flex items-center bg-canvas border border-ink/10 rounded-md p-0.5 pr-6 text-xs shadow-sm group">
|
|
55
79
|
{att.preview ? (
|
|
@@ -102,7 +102,12 @@ export function ComposerToolbar({
|
|
|
102
102
|
// No separator and no tinted strip: the composer is one card, so its
|
|
103
103
|
// toolbar shares the card's surface. `rounded-b-[inherit]` still follows
|
|
104
104
|
// the frame (the phone's stretched composer is `rounded-none`).
|
|
105
|
-
|
|
105
|
+
// `py-1.5`, and the phone's controls are `w-8 h-8`: measured on a Pixel 7,
|
|
106
|
+
// the row was 56px tall for a 40px button, which made the composer's
|
|
107
|
+
// footer read as a second, heavier bar under the draft. A 32px target
|
|
108
|
+
// still clears the 28px minimum for a touch control while bringing the
|
|
109
|
+
// row to 44px — the same height as the compact textarea above it.
|
|
110
|
+
className="@container flex items-center justify-between rounded-b-[inherit] px-3 py-1.5"
|
|
106
111
|
>
|
|
107
112
|
<div className={`flex items-center min-w-0 ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
|
|
108
113
|
{showModel && (
|
|
@@ -166,7 +171,7 @@ export function ComposerToolbar({
|
|
|
166
171
|
? 'bg-error text-canvas animate-pulse shadow-xs cursor-pointer'
|
|
167
172
|
: 'text-ink/50 hover:text-ink hover:bg-ink/10 cursor-pointer'
|
|
168
173
|
} ${
|
|
169
|
-
isMobile ? 'w-
|
|
174
|
+
isMobile ? 'w-8 h-8 rounded-md' : 'w-7 h-7'
|
|
170
175
|
}`}
|
|
171
176
|
title={
|
|
172
177
|
voiceBusy
|
|
@@ -177,11 +182,11 @@ export function ComposerToolbar({
|
|
|
177
182
|
}
|
|
178
183
|
>
|
|
179
184
|
{voiceBusy ? (
|
|
180
|
-
<Loader2 size={isMobile ?
|
|
185
|
+
<Loader2 size={isMobile ? 14 : 12} className="animate-spin" />
|
|
181
186
|
) : voiceListening ? (
|
|
182
|
-
<MicOff size={isMobile ?
|
|
187
|
+
<MicOff size={isMobile ? 14 : 12} />
|
|
183
188
|
) : (
|
|
184
|
-
<Mic size={isMobile ?
|
|
189
|
+
<Mic size={isMobile ? 14 : 12} />
|
|
185
190
|
)}
|
|
186
191
|
</button>
|
|
187
192
|
)}
|
|
@@ -192,12 +197,12 @@ export function ComposerToolbar({
|
|
|
192
197
|
onClick={onStop}
|
|
193
198
|
className={`flex items-center justify-center rounded transition-colors cursor-pointer shadow-xs animate-in zoom-in-90 duration-150 flex-shrink-0 ${
|
|
194
199
|
isMobile
|
|
195
|
-
? 'w-
|
|
200
|
+
? 'w-8 h-8 rounded-md border border-ink/25 text-ink hover:bg-error hover:text-canvas hover:border-error'
|
|
196
201
|
: 'w-7 h-7 bg-ink text-canvas hover:bg-error hover:text-canvas'
|
|
197
202
|
}`}
|
|
198
203
|
title="Stop generation"
|
|
199
204
|
>
|
|
200
|
-
<Square size={isMobile ?
|
|
205
|
+
<Square size={isMobile ? 12 : 10} className="fill-current" />
|
|
201
206
|
</button>
|
|
202
207
|
)}
|
|
203
208
|
|
|
@@ -206,12 +211,12 @@ export function ComposerToolbar({
|
|
|
206
211
|
onClick={onSend}
|
|
207
212
|
disabled={sendDisabled}
|
|
208
213
|
className={`flex items-center justify-center rounded bg-ink text-canvas hover:bg-ink/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer flex-shrink-0 ${
|
|
209
|
-
isMobile ? 'w-
|
|
214
|
+
isMobile ? 'w-8 h-8 rounded-md' : 'w-7 h-7'
|
|
210
215
|
}`}
|
|
211
216
|
title={chatInFlight ? 'Queue follow-up message' : 'Send message'}
|
|
212
217
|
aria-label={chatInFlight ? 'Queue follow-up message' : 'Send message'}
|
|
213
218
|
>
|
|
214
|
-
<Send size={isMobile ?
|
|
219
|
+
<Send size={isMobile ? 14 : 12} className="ml-px" />
|
|
215
220
|
</button>
|
|
216
221
|
</div>
|
|
217
222
|
</div>
|
package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The two client rules that decide whether a prompt is HELD or SENT.
|
|
8
|
+
*
|
|
9
|
+
* Both are about a state the composer cannot see: a dropdown that lists only
|
|
10
|
+
* running sessions can still go stale while the draft is being written, and the
|
|
11
|
+
* pick itself has to survive a remount or the prompt the user deferred is sent
|
|
12
|
+
* immediately — the one outcome the feature exists to prevent.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { waitingCandidates } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
|
|
17
|
+
import type { WorkspaceFolderData } from '@/shared/types/workspace';
|
|
18
|
+
|
|
19
|
+
function folder(id: number, name: string, sessions: { id: string; title: string }[]): WorkspaceFolderData {
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
name,
|
|
23
|
+
isExpanded: true,
|
|
24
|
+
hasMore: false,
|
|
25
|
+
totalSessions: sessions.length,
|
|
26
|
+
sessions: sessions.map((session) => ({ ...session, folder_id: id })),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const folders = [
|
|
31
|
+
folder(1, 'alpha', [{ id: 'sess-a', title: 'Refactor parser' }, { id: 'sess-idle', title: 'Idle chat' }]),
|
|
32
|
+
folder(2, 'beta', [{ id: 'sess-b', title: 'Build pipeline' }]),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
describe('waitingCandidates', () => {
|
|
36
|
+
test('offers only sessions with a run in flight', () => {
|
|
37
|
+
const candidates = waitingCandidates(folders, { 'sess-a': 'stream', 'sess-b': 'stream', 'sess-idle': 'finish' }, null);
|
|
38
|
+
expect(candidates.map((candidate) => candidate.id)).toEqual(['sess-a', 'sess-b']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// A finished run's badge is a terminal one-shot; a session whose row says
|
|
42
|
+
// `finish` has nothing to wait for, and offering it would create a prompt the
|
|
43
|
+
// next sweep releases.
|
|
44
|
+
test('a finished or aborted session is not offered', () => {
|
|
45
|
+
expect(waitingCandidates(folders, { 'sess-a': 'finish', 'sess-b': 'abort' }, null)).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('never offers the session the composer writes to', () => {
|
|
49
|
+
const candidates = waitingCandidates(folders, { 'sess-a': 'stream', 'sess-b': 'stream' }, 'sess-a');
|
|
50
|
+
expect(candidates.map((candidate) => candidate.id)).toEqual(['sess-b']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('carries the folder name so two similarly titled sessions are told apart', () => {
|
|
54
|
+
const candidates = waitingCandidates(folders, { 'sess-b': 'stream' }, null);
|
|
55
|
+
expect(candidates[0]).toEqual({ id: 'sess-b', title: 'Build pipeline', folderName: 'beta' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The case the feature exists for, and the one the first version missed: a
|
|
59
|
+
// freshly spawned session has a `stream` row immediately, while omp's
|
|
60
|
+
// transcript — which the sidebar structure is scanned from — lands seconds
|
|
61
|
+
// later. Driving the list off the folders offered nothing at all.
|
|
62
|
+
test('offers a streaming id the structure has not scanned yet', () => {
|
|
63
|
+
const candidates = waitingCandidates(
|
|
64
|
+
[folder(1, 'alpha', [])],
|
|
65
|
+
{ 'brand-new-id': 'stream' },
|
|
66
|
+
null,
|
|
67
|
+
(id) => (id === 'brand-new-id' ? 'Just started' : undefined),
|
|
68
|
+
);
|
|
69
|
+
expect(candidates).toEqual([{ id: 'brand-new-id', title: 'Just started', folderName: '' }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a streaming id with no hint is named by its id rather than dropped', () => {
|
|
73
|
+
expect(waitingCandidates([], { 'raw-id': 'stream' }, null)[0]).toEqual({
|
|
74
|
+
id: 'raw-id',
|
|
75
|
+
title: 'raw-id',
|
|
76
|
+
folderName: '',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a scanned streaming session is not listed twice', () => {
|
|
81
|
+
const candidates = waitingCandidates(folders, { 'sess-a': 'stream' }, null);
|
|
82
|
+
expect(candidates.map((candidate) => candidate.id)).toEqual(['sess-a']);
|
|
83
|
+
});
|
|
84
|
+
});
|