ompchamber 1.1.0 → 1.2.1
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/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.a5ccbc19dc.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.5fe7a2cb64.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +183 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +88 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
- package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +7 -1
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +16 -1
- package/src/client/hooks/chat/timeline/index.ts +16 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +29 -65
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +57 -0
- package/src/server/lib/queue/delivery.server.ts +25 -3
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +6 -1
- package/src/shared/lib/chat/composer/composer.test.ts +39 -1
- package/src/shared/lib/chat/composer/trigger.ts +45 -1
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +39 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.b859cc1cc5.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.6e065017eb.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.5fe7a2cb64.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The side-question form — what the chat composer becomes while BTW mode is on.
|
|
8
|
+
*
|
|
9
|
+
* Stacked cards, top to bottom: the topic's turns (only once there is something
|
|
10
|
+
* to show, or an error to explain), the ask card (a question of its own), and
|
|
11
|
+
* the side session's composer (follow-ups, attachments, model chip). Nothing it
|
|
12
|
+
* produces enters the chat transcript — that is the point of a side question —
|
|
13
|
+
* and promotion is the one explicit action that moves an answer into the chat,
|
|
14
|
+
* as a session branched from it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useState } from 'preact/hooks';
|
|
18
|
+
import type { BtwMode } from '@/client/hooks/chat/btw/mode';
|
|
19
|
+
import { BtwAskCard } from '@/client/components/workspace/btw-panel/AskCard';
|
|
20
|
+
import { BtwMessages } from '@/client/components/workspace/btw-panel/Messages';
|
|
21
|
+
import { BtwComposer } from '@/client/components/workspace/btw-panel/Composer';
|
|
22
|
+
import { btwImagesFrom, readBtwAttachments, type BtwAttachment } from '@/client/components/workspace/btw-panel/attachments';
|
|
23
|
+
|
|
24
|
+
export interface BtwFormProps {
|
|
25
|
+
mode: BtwMode;
|
|
26
|
+
/** The chat's own model, shown until the topic reports the one it ran on. */
|
|
27
|
+
modelName?: string;
|
|
28
|
+
provider?: string;
|
|
29
|
+
providerNames?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function BtwForm({ mode, modelName, provider, providerNames }: BtwFormProps) {
|
|
33
|
+
const [attachments, setAttachments] = useState<BtwAttachment[]>([]);
|
|
34
|
+
const lastTurn = mode.activeTopic?.turns[mode.activeTopic.turns.length - 1];
|
|
35
|
+
// `busy` is the command in flight (a promote POST, say): promoting again from
|
|
36
|
+
// the same click burst would be a second request for work already running.
|
|
37
|
+
// An already-promoted topic needs no second button either — its branch
|
|
38
|
+
// session exists, and the server hands that id back on a repeat promote.
|
|
39
|
+
const canPromote =
|
|
40
|
+
Boolean(mode.activeTopic) && !mode.runningTopicId && !mode.busy && !mode.activeTopic?.promotedSessionId && lastTurn?.status === 'complete';
|
|
41
|
+
const showTurns = (mode.activeTopic?.turns.length ?? 0) > 0 || Boolean(mode.error);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex flex-col gap-2">
|
|
45
|
+
{showTurns && (
|
|
46
|
+
<div className="bg-paper border border-ink/15 rounded-2xl shadow-xl flex flex-col max-h-[45vh] overflow-hidden">
|
|
47
|
+
<BtwMessages topic={mode.activeTopic} liveAnswer={mode.liveAnswer} error={mode.error} />
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
<BtwAskCard
|
|
52
|
+
value={mode.askDraft}
|
|
53
|
+
onChange={mode.setAskDraft}
|
|
54
|
+
onSubmit={mode.submitAsk}
|
|
55
|
+
onNewQuestion={mode.resetQuestion}
|
|
56
|
+
onClose={mode.exit}
|
|
57
|
+
running={Boolean(mode.runningTopicId)}
|
|
58
|
+
topics={mode.topics}
|
|
59
|
+
activeTopicId={mode.activeTopic?.id ?? null}
|
|
60
|
+
onSelectTopic={mode.selectTopic}
|
|
61
|
+
onDeleteTopic={mode.removeTopic}
|
|
62
|
+
canPromote={canPromote}
|
|
63
|
+
onPromote={() => void mode.promote()}
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
<BtwComposer
|
|
67
|
+
value={mode.followUpDraft}
|
|
68
|
+
onChange={mode.setFollowUpDraft}
|
|
69
|
+
onSubmit={() => {
|
|
70
|
+
// Hand the images over and clear them: leaving them staged would
|
|
71
|
+
// re-send the same picture with every later follow-up.
|
|
72
|
+
mode.submitFollowUp(mode.followUpDraft, btwImagesFrom(attachments));
|
|
73
|
+
setAttachments([]);
|
|
74
|
+
}}
|
|
75
|
+
onStop={mode.abort}
|
|
76
|
+
running={Boolean(mode.runningTopicId)}
|
|
77
|
+
modelLabel={mode.activeTopic?.model?.name ?? modelName}
|
|
78
|
+
provider={mode.activeTopic?.model?.provider ?? provider}
|
|
79
|
+
providerNames={providerNames}
|
|
80
|
+
attachments={attachments}
|
|
81
|
+
onFilesSelected={(files) => {
|
|
82
|
+
void readBtwAttachments(files).then((next) => setAttachments((prev) => [...prev, ...next]));
|
|
83
|
+
}}
|
|
84
|
+
onRemoveAttachment={(id) => setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
2
2
|
import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input/index';
|
|
3
|
+
import { BtwForm } from '@/client/components/workspace/btw-panel/index';
|
|
4
|
+
import { useBtwMode } from '@/client/hooks/chat/btw/mode';
|
|
3
5
|
import { GeneratingIndicator } from '@/client/components/workspace/chat-timeline/GeneratingIndicator';
|
|
4
6
|
import { QueueList } from '@/client/components/workspace/chat-timeline/QueueList';
|
|
5
7
|
import type { Attachment, QueuedMessage } from '@/shared/types';
|
|
@@ -7,6 +9,8 @@ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
|
7
9
|
|
|
8
10
|
interface ComposerDockProps {
|
|
9
11
|
isMobile: boolean;
|
|
12
|
+
/** Chamber session the side-question panel scopes to. */
|
|
13
|
+
sessionId: string | null;
|
|
10
14
|
isGenerating: boolean;
|
|
11
15
|
modelName?: string;
|
|
12
16
|
generatingVerb: string;
|
|
@@ -45,6 +49,7 @@ interface ComposerDockProps {
|
|
|
45
49
|
*/
|
|
46
50
|
export function ComposerDock({
|
|
47
51
|
isMobile,
|
|
52
|
+
sessionId,
|
|
48
53
|
isGenerating,
|
|
49
54
|
modelName,
|
|
50
55
|
generatingVerb,
|
|
@@ -75,12 +80,19 @@ export function ComposerDock({
|
|
|
75
80
|
sessionThinkingLevel,
|
|
76
81
|
variant,
|
|
77
82
|
}: ComposerDockProps) {
|
|
83
|
+
// Side questions take over the composer entirely: the chat's own input is
|
|
84
|
+
// hidden while the form is open, so a stray Enter cannot send to the wrong
|
|
85
|
+
// conversation.
|
|
86
|
+
const btw = useBtwMode(sessionId, appSettings);
|
|
87
|
+
|
|
78
88
|
return (
|
|
79
89
|
<div
|
|
80
90
|
className={`bg-transparent border-t-0 flex-shrink-0 space-y-2 ${isMobile ? 'px-3 pt-1' : 'p-4 pt-1'}`}
|
|
81
91
|
style={isMobile ? { paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom, 0px))' } : undefined}
|
|
82
92
|
>
|
|
83
|
-
|
|
93
|
+
{/* `relative` keeps the queue/indicator stack anchored above the input
|
|
94
|
+
while the side-question form takes the composer's place. */}
|
|
95
|
+
<div className="relative mx-auto w-full max-w-[970px]">
|
|
84
96
|
{isGenerating && (
|
|
85
97
|
<GeneratingIndicator
|
|
86
98
|
modelName={modelName}
|
|
@@ -105,26 +117,30 @@ export function ComposerDock({
|
|
|
105
117
|
})}
|
|
106
118
|
isSteering
|
|
107
119
|
/>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
120
|
+
{btw.open ? (
|
|
121
|
+
<BtwForm mode={btw} modelName={modelName} provider={provider} providerNames={providerNames} />
|
|
122
|
+
) : (
|
|
123
|
+
<ChatInput
|
|
124
|
+
value={inputValue}
|
|
125
|
+
onChange={setInputValue}
|
|
126
|
+
rootPath={rootPath}
|
|
127
|
+
attachments={attachments}
|
|
128
|
+
onAttachmentsChange={setAttachments}
|
|
129
|
+
onSend={onSend}
|
|
130
|
+
isGenerating={isGenerating}
|
|
131
|
+
onStop={onStop}
|
|
132
|
+
appSettings={appSettings}
|
|
133
|
+
onThinkingLevelChange={onThinkingLevelChange}
|
|
134
|
+
onModelChange={onModelChange}
|
|
135
|
+
accessMode={accessMode}
|
|
136
|
+
onAccessModeChange={onAccessModeChange}
|
|
137
|
+
composerModelRef={composerModelRef}
|
|
138
|
+
deferredComposerPickRef={deferredComposerPickRef}
|
|
139
|
+
sessionModel={sessionModel}
|
|
140
|
+
sessionThinkingLevel={sessionThinkingLevel}
|
|
141
|
+
variant={variant}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
128
144
|
</div>
|
|
129
145
|
</div>
|
|
130
146
|
);
|
|
@@ -1,13 +1,40 @@
|
|
|
1
1
|
import { useState } from 'preact/hooks';
|
|
2
|
+
import type { UserTurnRef } from '@/shared/types';
|
|
3
|
+
import { formatMessageStamp } from '@/shared/lib/format/time';
|
|
2
4
|
|
|
3
5
|
interface MinimapShortcutsProps {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
/** Every user turn of the session, oldest first — not just the mounted
|
|
7
|
+
* window, so the rail lists the whole conversation while the timeline holds
|
|
8
|
+
* one page of it. */
|
|
9
|
+
turns: UserTurnRef[];
|
|
10
|
+
/** A jump is paging history to reach the clicked turn. */
|
|
11
|
+
jumping: boolean;
|
|
12
|
+
onJumpTurn: (turn: UserTurnRef) => void;
|
|
6
13
|
}
|
|
7
14
|
|
|
8
|
-
|
|
15
|
+
/** Row pitch the rail keeps while it fits: a 1px line with 3px of breathing
|
|
16
|
+
* room on each side — the spacing the hover wave was drawn around. */
|
|
17
|
+
const ROW_PITCH_MAX = '7px';
|
|
18
|
+
/** Rows never collapse below this, so a tick stays clickable. */
|
|
19
|
+
const ROW_PITCH_MIN = '2px';
|
|
20
|
+
/** Target ceiling for the rail as a share of the viewport: a session long
|
|
21
|
+
* enough to overflow it tightens its rows rather than growing past the
|
|
22
|
+
* timeline. Past the minimum pitch the class-level `max-h` takes over, so a
|
|
23
|
+
* session with hundreds of turns still keeps every tick on screen. */
|
|
24
|
+
const RAIL_TARGET_HEIGHT = '60vh';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Jump rail: one tick per user turn of the FULL history. A turn outside the
|
|
28
|
+
* mounted window is still clickable — the click pages older windows in until
|
|
29
|
+
* that row exists, then centers it.
|
|
30
|
+
*/
|
|
31
|
+
export function MinimapShortcuts({ turns, jumping, onJumpTurn }: MinimapShortcutsProps) {
|
|
9
32
|
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
|
10
|
-
if (
|
|
33
|
+
if (turns.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
// Rows are laid out in normal flow (never absolutely positioned), so their
|
|
36
|
+
// hit boxes cannot overlap and each tick keeps its own hover.
|
|
37
|
+
const rowPitch = `clamp(${ROW_PITCH_MIN}, calc(${RAIL_TARGET_HEIGHT} / ${turns.length}), ${ROW_PITCH_MAX})`;
|
|
11
38
|
|
|
12
39
|
const lineWidth = (idx: number): string => {
|
|
13
40
|
if (hoveredIdx === null) return 'w-3';
|
|
@@ -19,25 +46,35 @@ export function MinimapShortcuts({ userMessages, onScrollTo }: MinimapShortcutsP
|
|
|
19
46
|
|
|
20
47
|
return (
|
|
21
48
|
<div
|
|
22
|
-
className=
|
|
49
|
+
className={`absolute right-0.5 top-1/2 -translate-y-1/2 z-20 flex flex-col items-center py-4 w-6 max-h-[min(60vh,100%)] ${jumping ? 'animate-pulse' : ''}`}
|
|
50
|
+
role="navigation"
|
|
51
|
+
aria-label={`Jump to your message (${turns.length} turns)`}
|
|
23
52
|
onMouseLeave={() => setHoveredIdx(null)}
|
|
24
53
|
>
|
|
25
|
-
{
|
|
54
|
+
{turns.map((turn, idx) => {
|
|
26
55
|
const isHovered = hoveredIdx === idx;
|
|
27
56
|
return (
|
|
28
57
|
<div
|
|
29
|
-
key={`minimap-${
|
|
30
|
-
className="relative flex items-center justify-center w-full
|
|
58
|
+
key={`minimap-${turn.id}`}
|
|
59
|
+
className="relative flex min-h-0 items-center justify-center w-full cursor-pointer"
|
|
60
|
+
style={{ height: rowPitch }}
|
|
31
61
|
onMouseEnter={() => setHoveredIdx(idx)}
|
|
32
|
-
onClick={() =>
|
|
62
|
+
onClick={() => onJumpTurn(turn)}
|
|
33
63
|
>
|
|
34
64
|
<button
|
|
65
|
+
type="button"
|
|
35
66
|
className={`block h-px rounded-full transition-all duration-200 ease-out ${lineWidth(idx)} ${isHovered ? 'bg-ink h-[2px]' : 'bg-ink/25'}`}
|
|
36
67
|
aria-label={`Jump to your message #${idx + 1}`}
|
|
37
68
|
/>
|
|
38
|
-
<div
|
|
39
|
-
|
|
40
|
-
|
|
69
|
+
<div
|
|
70
|
+
className={`absolute right-full mr-3 top-1/2 -translate-y-1/2 w-64 p-3 bg-ink text-canvas text-xs rounded-md shadow-lg pointer-events-none transition-opacity duration-200 z-30 ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
|
71
|
+
>
|
|
72
|
+
<div className="flex items-center justify-between gap-2 pb-1.5 mb-1.5 border-b border-canvas/20 font-mono text-[10px] uppercase tracking-wider text-canvas/60">
|
|
73
|
+
<span>#{idx + 1} of {turns.length}</span>
|
|
74
|
+
{formatMessageStamp(turn) && <span>{formatMessageStamp(turn)}</span>}
|
|
75
|
+
</div>
|
|
76
|
+
<div className="line-clamp-3 whitespace-pre-wrap leading-relaxed">{turn.preview.trim() || '(no text)'}</div>
|
|
77
|
+
<div className="absolute top-1/2 -right-1 -translate-y-1/2 w-2 h-2 bg-ink rotate-45" />
|
|
41
78
|
</div>
|
|
42
79
|
</div>
|
|
43
80
|
);
|
|
@@ -3,12 +3,15 @@ import { ArrowDown } from 'lucide-preact';
|
|
|
3
3
|
import { MessageList } from '@/client/components/workspace/chat-timeline/MessageList';
|
|
4
4
|
import { MinimapShortcuts } from '@/client/components/workspace/chat-timeline/MinimapShortcuts';
|
|
5
5
|
import { LoadingOlderIndicator } from '@/client/components/workspace/chat-timeline/SessionSkeleton';
|
|
6
|
-
import type { ChatMessageData } from '@/shared/types';
|
|
6
|
+
import type { ChatMessageData, UserTurnRef } from '@/shared/types';
|
|
7
7
|
|
|
8
8
|
interface TimelineBodyProps {
|
|
9
9
|
isMobile: boolean;
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
/** Every user turn of the session (full history), for the jump rail. */
|
|
11
|
+
userTurns: UserTurnRef[];
|
|
12
|
+
/** A jump is paging history to reach the clicked turn. */
|
|
13
|
+
jumpingTurn: boolean;
|
|
14
|
+
onJumpTurn: (turn: UserTurnRef) => void;
|
|
12
15
|
scrollRef: RefObject<HTMLDivElement>;
|
|
13
16
|
contentRef: (node: HTMLDivElement | null) => void;
|
|
14
17
|
handleScroll: () => void;
|
|
@@ -38,8 +41,9 @@ interface TimelineBodyProps {
|
|
|
38
41
|
*/
|
|
39
42
|
export function TimelineBody({
|
|
40
43
|
isMobile,
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
userTurns,
|
|
45
|
+
jumpingTurn,
|
|
46
|
+
onJumpTurn,
|
|
43
47
|
scrollRef,
|
|
44
48
|
contentRef,
|
|
45
49
|
handleScroll,
|
|
@@ -66,7 +70,7 @@ export function TimelineBody({
|
|
|
66
70
|
{/* Minimap Shortcuts — desktop only: the rail needs side room a
|
|
67
71
|
phone does not have. */}
|
|
68
72
|
{!isMobile && (
|
|
69
|
-
<MinimapShortcuts
|
|
73
|
+
<MinimapShortcuts turns={userTurns} jumping={jumpingTurn} onJumpTurn={onJumpTurn} />
|
|
70
74
|
)}
|
|
71
75
|
|
|
72
76
|
{/* Timeline Body */}
|
|
@@ -2,6 +2,7 @@ import { useRef } from 'preact/hooks';
|
|
|
2
2
|
import type { ChangeEvent } from 'preact/compat';
|
|
3
3
|
import { File as FileIcon, Paperclip, X } from 'lucide-preact';
|
|
4
4
|
import type { Attachment } from '@/shared/types';
|
|
5
|
+
import { attachmentName } from '@/shared/lib/chat/attachments';
|
|
5
6
|
|
|
6
7
|
interface AttachmentToolbarProps {
|
|
7
8
|
attachments: Attachment[];
|
|
@@ -39,7 +40,7 @@ export function AttachmentToolbar({ attachments, onFilesSelected, onRemove }: At
|
|
|
39
40
|
<FileIcon size={12} />
|
|
40
41
|
</div>
|
|
41
42
|
)}
|
|
42
|
-
<span className="truncate max-w-[120px] font-mono text-[10px] text-ink/80">{att
|
|
43
|
+
<span className="truncate max-w-[120px] font-mono text-[10px] text-ink/80">{attachmentName(att)}</span>
|
|
43
44
|
<button
|
|
44
45
|
onClick={() => onRemove(att.id)}
|
|
45
46
|
className="absolute right-0.5 top-1/2 -translate-y-1/2 p-0.5 text-ink/40 hover:text-error hover:bg-error/10 rounded transition-colors"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { UploadCloud } from 'lucide-preact';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Covers the composer while a file drag hovers it. `pointer-events-none` is
|
|
5
|
+
* load-bearing: the overlay sits above the card, so without it the drop would
|
|
6
|
+
* land on the overlay and never reach the card's own handler.
|
|
7
|
+
*/
|
|
8
|
+
export function DropOverlay({ visible }: { visible: boolean }) {
|
|
9
|
+
if (!visible) return null;
|
|
10
|
+
return (
|
|
11
|
+
<div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed border-ink/40 bg-canvas/90 text-ink">
|
|
12
|
+
<UploadCloud size={18} className="text-ink/60" />
|
|
13
|
+
<span className="text-[11px] font-semibold">Drop files to attach</span>
|
|
14
|
+
<span className="text-[10px] text-ink/50">Folders are expanded into their files</span>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import type { ClipboardEvent, SetStateAction } from 'preact/compat';
|
|
3
|
+
import { AlertTriangle, X } from 'lucide-preact';
|
|
3
4
|
import type { AIModelOption, Attachment, ModelEntry } from '@/shared/types';
|
|
4
5
|
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
5
6
|
import { ComposerToolbar } from '@/client/components/workspace/chat-timeline/chat-input/Toolbar';
|
|
@@ -9,6 +10,9 @@ import { selectableThinkingLevels } from '@/shared/lib/models/thinking-levels';
|
|
|
9
10
|
import { fetchModelsData, subscribeModelsUpdated } from '@/shared/lib/models/client';
|
|
10
11
|
import { resolveThinkingLevel, selectionFor } from '@/client/components/workspace/chat-timeline/chat-input/selection';
|
|
11
12
|
import { NO_PENDING_PICK } from '@/client/hooks/chat/timeline/deferred-model';
|
|
13
|
+
import { primeFileReads } from '@/client/hooks/chat/composer/file-reads';
|
|
14
|
+
import { useComposerPipeline } from '@/client/hooks/chat/composer/pipeline';
|
|
15
|
+
import { DropOverlay } from '@/client/components/workspace/chat-timeline/chat-input/DropOverlay';
|
|
12
16
|
|
|
13
17
|
export function ChatInput({
|
|
14
18
|
value,
|
|
@@ -230,61 +234,61 @@ export function ChatInput({
|
|
|
230
234
|
if (clipboard.files.length > 0) {
|
|
231
235
|
e.preventDefault();
|
|
232
236
|
const files = Array.from(clipboard.files);
|
|
233
|
-
|
|
237
|
+
// Same rule as a drop: the clipboard's file permission is released when
|
|
238
|
+
// this handler returns, so the reads are started here, in its tick.
|
|
239
|
+
acceptFiles(files, false, [], primeFileReads(files));
|
|
234
240
|
}
|
|
235
241
|
};
|
|
236
242
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
reader.onload = () => {
|
|
249
|
-
const result = reader.result as string;
|
|
250
|
-
const base64 = result.split(',')[1];
|
|
251
|
-
if (!base64) return;
|
|
252
|
-
setAttachments(prev => prev.map(a => (a.id === att.id ? { ...a, dataBase64: base64 } : a)));
|
|
253
|
-
};
|
|
254
|
-
reader.readAsDataURL(att.file);
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
const removeAttachment = (id: string) => {
|
|
259
|
-
setAttachments(prev => {
|
|
260
|
-
const att = prev.find(p => p.id === id);
|
|
261
|
-
if (att && att.preview) URL.revokeObjectURL(att.preview);
|
|
262
|
-
return prev.filter(p => p.id !== id);
|
|
263
|
-
});
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
const handleSendClick = (options?: { steering?: boolean }) => {
|
|
267
|
-
if (!value.trim() && attachments.length === 0) return;
|
|
268
|
-
onSend(attachments, options);
|
|
269
|
-
setAttachments([]);
|
|
270
|
-
};
|
|
243
|
+
// Attach-and-send pipeline: drop handling, the inline notice, and the send
|
|
244
|
+
// guard all live in the hook, because a drop registers files before this
|
|
245
|
+
// component re-renders and the send decision cannot read a stale prop.
|
|
246
|
+
const { notice, dismissNotice, isDragging, dropProps, acceptFiles, removeAttachment, submit } = useComposerPipeline({
|
|
247
|
+
attachments,
|
|
248
|
+
setAttachments,
|
|
249
|
+
value,
|
|
250
|
+
disabled,
|
|
251
|
+
rootPath,
|
|
252
|
+
onSend,
|
|
253
|
+
});
|
|
271
254
|
|
|
272
255
|
const isMobile = variant === 'mobile';
|
|
273
256
|
|
|
274
257
|
return (
|
|
275
|
-
<div
|
|
276
|
-
|
|
258
|
+
<div
|
|
259
|
+
className={`relative border rounded-md bg-paper transition-colors flex flex-col shadow-sm ${
|
|
260
|
+
isDragging ? 'border-ink/40' : 'border-ink/20 focus-within:border-ink'
|
|
261
|
+
} ${className}`}
|
|
262
|
+
{...dropProps}
|
|
263
|
+
>
|
|
264
|
+
<DropOverlay visible={isDragging} />
|
|
265
|
+
|
|
277
266
|
<AttachmentToolbar
|
|
278
267
|
attachments={attachments}
|
|
279
|
-
onFilesSelected={
|
|
268
|
+
onFilesSelected={(files) => acceptFiles(files)}
|
|
280
269
|
onRemove={removeAttachment}
|
|
281
270
|
/>
|
|
282
271
|
|
|
272
|
+
{notice && (
|
|
273
|
+
<div className="flex items-start gap-1.5 border-b border-error/20 bg-error/5 px-3 py-1.5 text-[11px] text-error">
|
|
274
|
+
<AlertTriangle size={11} className="mt-0.5 shrink-0" />
|
|
275
|
+
<span className="min-w-0 flex-1 break-words">{notice}</span>
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
onClick={() => dismissNotice()}
|
|
279
|
+
className="shrink-0 text-error/60 hover:text-error transition-colors"
|
|
280
|
+
title="Dismiss"
|
|
281
|
+
>
|
|
282
|
+
<X size={11} />
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
|
|
283
287
|
{/* Textarea */}
|
|
284
288
|
<ComposerTextarea
|
|
285
289
|
value={value}
|
|
286
290
|
onChange={onChange}
|
|
287
|
-
onSend={
|
|
291
|
+
onSend={submit}
|
|
288
292
|
disabled={disabled}
|
|
289
293
|
appSettings={appSettings}
|
|
290
294
|
rootPath={rootPath}
|
|
@@ -310,7 +314,7 @@ export function ChatInput({
|
|
|
310
314
|
onThinkingLevelChange={onThinkingLevelChange}
|
|
311
315
|
isGenerating={isGenerating}
|
|
312
316
|
onStop={onStop}
|
|
313
|
-
onSend={() =>
|
|
317
|
+
onSend={() => submit()}
|
|
314
318
|
sendDisabled={disabled || (!value.trim() && attachments.length === 0)}
|
|
315
319
|
accessMode={accessMode}
|
|
316
320
|
onSelectAccess={onAccessModeChange}
|
|
@@ -3,6 +3,7 @@ import { useSearchParams } from '@/client/lib/router/search-params';
|
|
|
3
3
|
import { ExtensionDialog } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
|
|
4
4
|
import { AskFramesContext, splitAskFrames, type AskFramesHandle } from '@/client/hooks/chat/timeline/ask-frames';
|
|
5
5
|
import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
|
|
6
|
+
import type { UserTurnRef } from '@/shared/types/chat';
|
|
6
7
|
import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
|
|
7
8
|
import { EmptyWorkspacePrompt } from '@/client/components/workspace/chat-timeline/EmptyWorkspacePrompt';
|
|
8
9
|
import { SessionSkeleton } from '@/client/components/workspace/chat-timeline/SessionSkeleton';
|
|
@@ -47,6 +48,9 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
47
48
|
hasMore,
|
|
48
49
|
loadingOlder, loadOlderError, sessionLoading,
|
|
49
50
|
loadOlder,
|
|
51
|
+
userTurns,
|
|
52
|
+
jumpingTurn,
|
|
53
|
+
jumpToUserTurn,
|
|
50
54
|
generatingVerb,
|
|
51
55
|
messageQueue,
|
|
52
56
|
removeMessage,
|
|
@@ -127,18 +131,14 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
127
131
|
|
|
128
132
|
useSessionTitle(sessionId, sessionData?.title, onSessionTitle);
|
|
129
133
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
134
|
-
}
|
|
135
|
-
}, []);
|
|
134
|
+
const handleJumpTurn = useCallback((turn: UserTurnRef) => {
|
|
135
|
+
void jumpToUserTurn(turn.id, turn.index);
|
|
136
|
+
}, [jumpToUserTurn]);
|
|
136
137
|
|
|
137
138
|
const handleNewChat = useCallback((content: string) => setNewChatInitialContent(content), []);
|
|
138
139
|
|
|
139
140
|
const modelNames = useModelNames();
|
|
140
141
|
const providerNames = useProviderNames();
|
|
141
|
-
const userMessages = useMemo(() => localMessages.filter(m => m.role === 'user'), [localMessages]);
|
|
142
142
|
const sessionProvider = typeof sessionData?.model === 'object' ? sessionData.model.provider : undefined;
|
|
143
143
|
const sessionModelName = typeof sessionData?.model === 'object'
|
|
144
144
|
? (modelNames[sessionData.model.modelId] ?? sessionData.model.modelId)
|
|
@@ -210,8 +210,9 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
210
210
|
) : (
|
|
211
211
|
<TimelineBody
|
|
212
212
|
isMobile={isMobile}
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
userTurns={userTurns}
|
|
214
|
+
jumpingTurn={jumpingTurn}
|
|
215
|
+
onJumpTurn={handleJumpTurn}
|
|
215
216
|
scrollRef={scrollRef}
|
|
216
217
|
contentRef={contentRef}
|
|
217
218
|
handleScroll={handleScroll}
|
|
@@ -240,6 +241,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
240
241
|
{!activeSubagent && (
|
|
241
242
|
<ComposerDock
|
|
242
243
|
isMobile={isMobile}
|
|
244
|
+
sessionId={sessionId}
|
|
243
245
|
isGenerating={isGenerating}
|
|
244
246
|
modelName={sessionModelName}
|
|
245
247
|
generatingVerb={generatingVerb}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
2
|
-
import { FileCode, FileText, Folder, FolderOpen, Info, Loader2 } from 'lucide-preact';
|
|
2
|
+
import { FileCode, FileImage, FileText, Folder, FolderOpen, Info, Loader2 } from 'lucide-preact';
|
|
3
3
|
import { CopyButton } from '@/client/components/common/CopyButton';
|
|
4
4
|
import { getLanguageFromPath, highlightCode } from '@/shared/lib/code/syntax-highlight';
|
|
5
5
|
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
6
6
|
import { parseDirListing, parseNumberedCode } from '@/shared/lib/code/parser';
|
|
7
|
+
import { getImageMimeType } from '@/shared/lib/fs/file-kind';
|
|
8
|
+
import { buildFsRawUrl } from '@/shared/lib/fs/paths';
|
|
7
9
|
import type { ToolCallData } from '@/shared/types/chat';
|
|
8
10
|
import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
|
|
9
11
|
import { extractLineMeta } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/read-line-meta';
|
|
@@ -23,9 +25,13 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
|
|
|
23
25
|
const inputPath = getToolInputPath(tool?.input);
|
|
24
26
|
const filePath = targetFilePath || inputPath || tool?.target || '';
|
|
25
27
|
const cleanFetchPath = filePath.split('?')[0].split('#')[0].replace(/:\d+(?:-\d+)?$/, '');
|
|
28
|
+
// An image read has no text to show: the panel paints the picture instead of
|
|
29
|
+
// the code surface, and `/api/fs/raw` is what carries its bytes.
|
|
30
|
+
const isImage = getImageMimeType(cleanFetchPath) !== null;
|
|
31
|
+
const imageUrl = isImage ? buildFsRawUrl({ path: cleanFetchPath }) : null;
|
|
26
32
|
|
|
27
33
|
useEffect(() => {
|
|
28
|
-
if (cleanFetchPath && !output && lazyContent === null && !loadingFile) {
|
|
34
|
+
if (cleanFetchPath && !isImage && !output && lazyContent === null && !loadingFile) {
|
|
29
35
|
setLoadingFile(true);
|
|
30
36
|
fetch(`/api/fs/read?path=${encodeURIComponent(cleanFetchPath)}`)
|
|
31
37
|
.then((res) => res.json())
|
|
@@ -39,7 +45,7 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
|
|
|
39
45
|
setLoadingFile(false);
|
|
40
46
|
});
|
|
41
47
|
}
|
|
42
|
-
}, [cleanFetchPath, output, lazyContent, loadingFile]);
|
|
48
|
+
}, [cleanFetchPath, output, lazyContent, loadingFile, isImage]);
|
|
43
49
|
|
|
44
50
|
const details = (tool?.details ?? {}) as Record<string, any>;
|
|
45
51
|
const rawContent =
|
|
@@ -107,6 +113,7 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
|
|
|
107
113
|
|
|
108
114
|
const isDir = dirInfo.isDirectory;
|
|
109
115
|
const lang = getLanguageFromPath(filePath);
|
|
116
|
+
const kindLabel = isDir ? 'Directory' : isImage ? 'Image' : lang;
|
|
110
117
|
|
|
111
118
|
const visibleEntries = dirInfo.entries.slice(-MAX_OUTPUT_LINES);
|
|
112
119
|
const skippedEntries = dirInfo.entries.length - visibleEntries.length;
|
|
@@ -126,17 +133,17 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
|
|
|
126
133
|
<div className="flex items-center justify-between rounded-lg border border-ink/8 bg-paper px-3 py-2">
|
|
127
134
|
<div className="flex min-w-0 items-center gap-2">
|
|
128
135
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-ink/5 text-ink/70">
|
|
129
|
-
{isDir ? <FolderOpen size={12} /> : <FileCode size={12} />}
|
|
136
|
+
{isDir ? <FolderOpen size={12} /> : isImage ? <FileImage size={12} /> : <FileCode size={12} />}
|
|
130
137
|
</span>
|
|
131
138
|
<span className="truncate font-mono text-[11px] font-medium text-ink">
|
|
132
139
|
{filePath || (isDir ? 'Directory Listing' : 'File Content')}
|
|
133
140
|
</span>
|
|
134
141
|
<span className="rounded bg-ink/5 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-ink/50">
|
|
135
|
-
{
|
|
142
|
+
{kindLabel}
|
|
136
143
|
</span>
|
|
137
144
|
</div>
|
|
138
145
|
|
|
139
|
-
{rawContent && (
|
|
146
|
+
{rawContent && !isImage && (
|
|
140
147
|
<CopyButton
|
|
141
148
|
text={parsedCode.hasLineNumbers ? parsedCode.cleanCode : (rawContent || '')}
|
|
142
149
|
className="flex shrink-0 cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-[10.5px] text-ink/50 transition-colors hover:bg-ink/5 hover:text-ink"
|
|
@@ -146,7 +153,18 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
|
|
|
146
153
|
)}
|
|
147
154
|
</div>
|
|
148
155
|
|
|
149
|
-
{
|
|
156
|
+
{isImage && imageUrl ? (
|
|
157
|
+
/* An image read has no code to show: paint the bytes. */
|
|
158
|
+
<div className="overflow-hidden rounded-lg border border-ink/8 bg-paper">
|
|
159
|
+
<div className="flex max-h-80 items-center justify-center overflow-auto bg-canvas/60 p-3">
|
|
160
|
+
<img
|
|
161
|
+
src={imageUrl}
|
|
162
|
+
alt={cleanFetchPath}
|
|
163
|
+
className="max-h-72 max-w-full object-contain"
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
) : loadingFile ? (
|
|
150
168
|
<div className="flex items-center gap-2 rounded-lg border border-ink/8 bg-paper px-3 py-3 font-mono text-[11px] text-ink/50">
|
|
151
169
|
<Loader2 size={12} className="animate-spin" />
|
|
152
170
|
<span>Loading file content...</span>
|