@adecore/agents-react 0.20.0 → 0.20.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/chat/chat-client.d.ts +1 -0
- package/dist/chat/logic/timeline-target.d.ts +6 -0
- package/dist/chat/logic/timeline-target.js +23 -3
- package/dist/chat/logic/timeline.js +9 -7
- package/dist/chat/ui/CodeBlock.d.ts +3 -1
- package/dist/chat/ui/CodeBlock.js +38 -7
- package/dist/chat/ui/Composer.js +13 -3
- package/dist/chat/ui/Markdown.d.ts +7 -1
- package/dist/chat/ui/Markdown.js +80 -23
- package/dist/chat/ui/MessageActions.js +6 -7
- package/dist/chat/ui/SubagentTimeline.js +1 -1
- package/dist/chat/ui/Timeline.js +1 -1
- package/dist/chat/ui/TimelineMenu.js +7 -3
- package/dist/chat/ui/file-links.d.ts +2 -1
- package/dist/chat/ui/file-links.js +8 -3
- package/dist/chat/ui/rows/MessageRows.js +6 -3
- package/dist/host.d.ts +14 -2
- package/dist/host.js +1 -0
- package/dist/locales/en/agent-chat.json +7 -1
- package/dist/locales/nl/agent-chat.json +7 -1
- package/dist/theme.css +19 -2
- package/package.json +3 -3
- package/src/chat/chat-client.ts +1 -0
- package/src/chat/logic/timeline-target.ts +28 -3
- package/src/chat/logic/timeline.ts +9 -7
- package/src/chat/ui/CodeBlock.tsx +70 -21
- package/src/chat/ui/Composer.tsx +22 -5
- package/src/chat/ui/Markdown.tsx +123 -26
- package/src/chat/ui/MessageActions.tsx +15 -8
- package/src/chat/ui/SubagentTimeline.tsx +2 -0
- package/src/chat/ui/Timeline.tsx +1 -1
- package/src/chat/ui/TimelineMenu.tsx +6 -3
- package/src/chat/ui/file-links.ts +9 -3
- package/src/chat/ui/rows/MessageRows.tsx +9 -3
- package/src/host.ts +19 -2
- package/src/locales/en/agent-chat.json +7 -1
- package/src/locales/nl/agent-chat.json +7 -1
- package/src/theme.css +19 -2
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { ChatItem } from '@adecore/agent-contracts';
|
|
2
2
|
import type { TimelineRow } from './timeline.js';
|
|
3
|
+
import type { FileRef } from '../../host.js';
|
|
3
4
|
export interface TimelineTarget {
|
|
4
5
|
selection: string;
|
|
5
6
|
row: TimelineRow | null;
|
|
6
7
|
code: string | null;
|
|
7
8
|
path: string | null;
|
|
8
9
|
line: number | null;
|
|
10
|
+
file?: {
|
|
11
|
+
ref: FileRef;
|
|
12
|
+
cwd: string | null;
|
|
13
|
+
scopeId: string | null;
|
|
14
|
+
} | null;
|
|
9
15
|
}
|
|
10
16
|
export declare const EMPTY_TARGET: TimelineTarget;
|
|
11
17
|
export declare function withCurrentText(rows: TimelineRow[], items: Record<string, ChatItem> | undefined): TimelineRow[];
|
|
@@ -23,12 +23,32 @@ export function withCurrentText(rows, items) {
|
|
|
23
23
|
export function readTimelineTarget(element, scroller, rows) {
|
|
24
24
|
const id = element.closest('[data-item-id]')?.dataset.itemId;
|
|
25
25
|
const file = element.closest('[data-file-path]');
|
|
26
|
-
const line =
|
|
26
|
+
const line = positiveInteger(file?.dataset.fileLine);
|
|
27
|
+
const column = positiveInteger(file?.dataset.fileColumn);
|
|
28
|
+
const endLine = positiveInteger(file?.dataset.fileEndLine);
|
|
29
|
+
const path = file?.dataset.filePath ?? null;
|
|
27
30
|
return {
|
|
28
31
|
selection: selectionWithin(scroller),
|
|
29
32
|
row: rows.find((row) => row.id === id) ?? null,
|
|
30
33
|
code: element.closest('pre')?.textContent ?? null,
|
|
31
|
-
path
|
|
32
|
-
line
|
|
34
|
+
path,
|
|
35
|
+
line,
|
|
36
|
+
file: path === null
|
|
37
|
+
? null
|
|
38
|
+
: {
|
|
39
|
+
ref: {
|
|
40
|
+
path,
|
|
41
|
+
...(line === null ? {} : { line }),
|
|
42
|
+
...(column === null ? {} : { column }),
|
|
43
|
+
...(line === null || endLine === null || endLine < line ? {} : { endLine }),
|
|
44
|
+
directory: file?.dataset.fileDirectory === 'true'
|
|
45
|
+
},
|
|
46
|
+
cwd: file?.closest('[data-file-cwd]')?.dataset.fileCwd || null,
|
|
47
|
+
scopeId: file?.closest('[data-file-scope-id]')?.dataset.fileScopeId || null
|
|
48
|
+
}
|
|
33
49
|
};
|
|
34
50
|
}
|
|
51
|
+
function positiveInteger(value) {
|
|
52
|
+
const number = Number(value);
|
|
53
|
+
return Number.isSafeInteger(number) && number > 0 ? number : null;
|
|
54
|
+
}
|
|
@@ -289,23 +289,25 @@ export function deriveTimelineRows(items, options) {
|
|
|
289
289
|
}
|
|
290
290
|
const turnId = chunk.turnId;
|
|
291
291
|
const turn = chunk.items.find((item) => item.kind === 'turn');
|
|
292
|
-
const
|
|
293
|
-
const rest = chunk.items.filter((item) => item.kind !== 'user' && item.kind !== 'turn');
|
|
292
|
+
const conversation = chunk.items.filter((item) => item.kind !== 'turn');
|
|
294
293
|
if (turn?.origin === 'agent') {
|
|
295
294
|
// The agent started this one itself, so there is no message of the person to show above it.
|
|
296
295
|
rows.push({ kind: 'turn-start', id: `start-${turnId}`, turn, label: agentTurnLabel(turn) });
|
|
297
296
|
}
|
|
298
|
-
rows.push(...rowsForItems(user, options, children));
|
|
299
297
|
const active = turnId === options.activeTurnId || turn?.state === 'running';
|
|
300
|
-
const work = rowsForItems(rest, options, children);
|
|
301
298
|
if (!turn || active) {
|
|
302
|
-
rows.push(...
|
|
299
|
+
rows.push(...rowsForItems(conversation, options, children));
|
|
303
300
|
if (active) {
|
|
304
301
|
rows.push({ kind: 'working', id: `working-${turnId}`, startedAt: turn?.createdAt ?? Date.now() });
|
|
305
302
|
}
|
|
306
303
|
continue;
|
|
307
304
|
}
|
|
308
|
-
//
|
|
305
|
+
// Steering splits the conversation inside a turn. Earlier responses keep their place above it.
|
|
306
|
+
const lastUser = conversation.findLastIndex((item) => item.kind === 'user');
|
|
307
|
+
rows.push(...rowsForItems(conversation.slice(0, lastUser + 1), options, children));
|
|
308
|
+
const rest = conversation.slice(lastUser + 1);
|
|
309
|
+
const work = rowsForItems(rest, options, children);
|
|
310
|
+
// The closing answer stays visible; the work after the last input folds behind the label.
|
|
309
311
|
const finalAssistant = lastAssistantRow(work);
|
|
310
312
|
const before = finalAssistant ? work.slice(0, work.indexOf(finalAssistant)) : work;
|
|
311
313
|
// A workflow runs on past the turn that launched it, so its row stays in sight until it ends.
|
|
@@ -328,7 +330,7 @@ export function deriveTimelineRows(items, options) {
|
|
|
328
330
|
}
|
|
329
331
|
}
|
|
330
332
|
rows.push(...standing);
|
|
331
|
-
const changed = changedFilesRow(turn,
|
|
333
|
+
const changed = changedFilesRow(turn, conversation);
|
|
332
334
|
if (changed) {
|
|
333
335
|
rows.push(changed);
|
|
334
336
|
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Fragment, memo, useContext, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Fragment, memo, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Check, Copy } from 'lucide-react';
|
|
6
|
+
import { ButtonGroup, IconButton } from '@adecore/ui';
|
|
4
7
|
import { IncrementalLines } from './code-lines.js';
|
|
5
8
|
import { CodeStreamingContext } from './code-streaming.js';
|
|
6
9
|
import { WHOLE_FADE_CLASS } from './rehype-fade.js';
|
|
7
10
|
import { shikiThemeOf, useCodeTheme } from './code-theme.js';
|
|
8
11
|
// A plain language needs no grammar; shiki draws it in the theme's own foreground.
|
|
9
12
|
const PLAIN = 'text';
|
|
13
|
+
const COPIED_MS = 1500;
|
|
10
14
|
let highlighter = null;
|
|
11
15
|
let highlighterLoad = null;
|
|
12
16
|
const loadedLanguages = new Set([PLAIN]);
|
|
@@ -102,9 +106,12 @@ const CodeLine = memo(function CodeLine({ tokens }) {
|
|
|
102
106
|
* fence that is still open is tokenized as it grows, and the same component carries on once it
|
|
103
107
|
* closes, so closing it does not draw it again.
|
|
104
108
|
*/
|
|
105
|
-
export function CodeBlock({ code, lang }) {
|
|
109
|
+
export function CodeBlock({ code, lang, actions }) {
|
|
110
|
+
const { t } = useTranslation('agent-chat');
|
|
106
111
|
const theme = useCodeTheme();
|
|
107
112
|
const streaming = useContext(CodeStreamingContext);
|
|
113
|
+
const [copyState, setCopyState] = useState('idle');
|
|
114
|
+
const copying = useRef(false);
|
|
108
115
|
// What loaded last, so a block whose theme changes draws again once the new one is in.
|
|
109
116
|
const [loaded, setLoaded] = useState(null);
|
|
110
117
|
const tokenizer = tokenizerFor(lang, theme);
|
|
@@ -112,6 +119,31 @@ export function CodeBlock({ code, lang }) {
|
|
|
112
119
|
const [waited] = useState(tokenizer === null);
|
|
113
120
|
const lines = useMemo(() => (tokenizer ? new IncrementalLines(tokenizer.tokenize) : null), [tokenizer]);
|
|
114
121
|
const tokens = useMemo(() => lines?.update(code, !streaming) ?? null, [lines, code, streaming]);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (copyState !== 'copied') {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const timer = setTimeout(() => setCopyState('idle'), COPIED_MS);
|
|
127
|
+
return () => clearTimeout(timer);
|
|
128
|
+
}, [copyState]);
|
|
129
|
+
async function copy() {
|
|
130
|
+
if (copying.current) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
copying.current = true;
|
|
134
|
+
setCopyState('copying');
|
|
135
|
+
try {
|
|
136
|
+
// The best-effort clipboard helper cannot distinguish a refusal from success.
|
|
137
|
+
await navigator.clipboard.writeText(code);
|
|
138
|
+
setCopyState('copied');
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
setCopyState('failed');
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
copying.current = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
115
147
|
useEffect(() => {
|
|
116
148
|
if (tokenizer !== null) {
|
|
117
149
|
return;
|
|
@@ -124,9 +156,8 @@ export function CodeBlock({ code, lang }) {
|
|
|
124
156
|
cancelled = true;
|
|
125
157
|
};
|
|
126
158
|
}, [lang, theme, tokenizer]);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return (_jsx("div", { className: clsx('chat-code', waited && WHOLE_FADE_CLASS), children: _jsx("pre", { style: { color: tokenizer.fg }, children: _jsx("code", { children: tokens.map((line, index) => (_jsxs(Fragment, { children: [_jsx(CodeLine, { tokens: line }), index < tokens.length - 1 && '\n'] }, index))) }) }) }));
|
|
159
|
+
const pending = (tokenizer === null || tokens === null) && loaded !== 'failed';
|
|
160
|
+
return (_jsxs("div", { className: "chat-code", "data-copy-state": copyState, children: [_jsx("pre", { className: clsx(pending && 'invisible', !pending && tokens !== null && waited && WHOLE_FADE_CLASS), "aria-hidden": pending || undefined, style: { color: tokenizer?.fg }, children: _jsx("code", { children: tokens === null
|
|
161
|
+
? code
|
|
162
|
+
: tokens.map((line, index) => (_jsxs(Fragment, { children: [_jsx(CodeLine, { tokens: line }), index < tokens.length - 1 && '\n'] }, index))) }) }), _jsxs(ButtonGroup, { className: "chat-code-actions", role: "group", "aria-label": t('code.actions'), children: [_jsx(IconButton, { icon: copyState === 'copied' ? Check : Copy, size: "sm", label: copyState === 'copied' ? t('timeline.actions.copied') : t('timeline.menu.copyCode'), busy: copyState === 'copying', "aria-busy": copyState === 'copying', "aria-disabled": copyState === 'copying', onClick: () => void copy() }), actions] }), _jsx("span", { className: "sr-only", role: "status", children: copyState === 'copied' ? t('timeline.actions.copied') : '' }), copyState === 'failed' && (_jsx("p", { className: "chat-code-error", role: "alert", children: t('code.copyFailed') }))] }));
|
|
132
163
|
}
|
package/dist/chat/ui/Composer.js
CHANGED
|
@@ -95,6 +95,8 @@ function splitPath(path) {
|
|
|
95
95
|
// Keep the editor mounted while a pending request takes over, so its selection and draft survive.
|
|
96
96
|
export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabled, readOnly = false, providerFixed, onSend, onRetarget }) {
|
|
97
97
|
const { t } = useTranslation('agent-chat');
|
|
98
|
+
const sendDelivery = chatHost().useSendDelivery();
|
|
99
|
+
const canSteer = info.provider === 'claude' || info.provider === 'codex';
|
|
98
100
|
const [draft, setDraft] = useState(() => readDraft(chatId));
|
|
99
101
|
const [historyIndex, setHistoryIndex] = useState(null);
|
|
100
102
|
const [menuIndex, setMenuIndex] = useState(0);
|
|
@@ -474,7 +476,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
474
476
|
const removeAttachment = (index) => {
|
|
475
477
|
setDraft((current) => ({ ...current, attachments: current.attachments.filter((_, i) => i !== index) }));
|
|
476
478
|
};
|
|
477
|
-
const submit = () => {
|
|
479
|
+
const submit = (alternate = false) => {
|
|
480
|
+
const delivery = canSteer && (alternate ? sendDelivery === 'queue' : sendDelivery === 'steer') ? 'steer' : 'queue';
|
|
478
481
|
const trimmed = text.trim();
|
|
479
482
|
if (isEmptyDraft(draft) || !writable || guard.tooLong) {
|
|
480
483
|
return;
|
|
@@ -502,6 +505,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
502
505
|
return;
|
|
503
506
|
}
|
|
504
507
|
onSend(withQuote(quote, trimmed), {
|
|
508
|
+
delivery,
|
|
505
509
|
mentions: presentMentions(trimmed, draft.mentions),
|
|
506
510
|
skills: presentSkills(trimmed, draft.skills),
|
|
507
511
|
chats: draft.chats,
|
|
@@ -739,7 +743,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
739
743
|
view.dispatch({ changes: { from: line.from, to: line.to }, selection: { anchor: line.from }, userEvent: 'delete' });
|
|
740
744
|
return true;
|
|
741
745
|
}
|
|
742
|
-
submit();
|
|
746
|
+
submit(e.altKey);
|
|
743
747
|
return true;
|
|
744
748
|
}
|
|
745
749
|
const { from, to } = view.state.selection.main;
|
|
@@ -852,5 +856,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
852
856
|
addFiles(Array.from(e.target.files ?? []));
|
|
853
857
|
e.target.value = '';
|
|
854
858
|
inputRef.current?.focus();
|
|
855
|
-
} }), _jsx(RunSettings, { disabled: configurationPending || !writable, providers: pickable, provider: info.provider, selection: info.selection, model: model, runtimeMode: info.runtimeMode, effectiveRuntimeMode: info.effectiveRuntimeMode, permissionMode: info.permissionMode, open: modelPickerOpen, onOpenChange: setModelPickerOpen, onModel: chooseModel, onOption: (id, value) => configure({ selection: { ...info.selection, options: { ...info.selection.options, [id]: value } } }), onMode: (runtimeMode) => configure({ runtimeMode }), usage: info.usage, compactDisabled: busy || !writable, onCompact: compact, account: accountChoice === null ? null : { choice: accountChoice, started, onPick: chooseAccount } }), _jsx(StashPicker, { onRestore: restoreStashed }), Slot !== null && (_jsx(ErrorBoundary, { label: t('composer.slotFailed'), resetKeys: [chatId], compact: true, className: "relative rounded-lg", children: _jsx(Slot, { scopeId: scope.id, chatId: chatId, disabled: !writable, insert: insertIntoDraft }) })), _jsx("span", { className: "grow" }), _jsxs("div", { className: "flex h-9 shrink-0 items-center gap-0.5 rounded-full bg-surface-hover", children: [_jsx("div", { ref: setDictationToolbar, className: "flex items-center pl-1 empty:hidden [&_.icon-btn]:rounded-full" }), busy && (_jsx(Tooltip, { label: composerStopLabel(), name: true, children: _jsx("button", { className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-status-error text-accent-text", onClick: (event) => stop(event.shiftKey), children: _jsx(Icon, { icon: Square, size: 16 }) }) })), (!busy || !isEmptyDraft(draft)) && (_jsx(Tooltip, { label: busy
|
|
859
|
+
} }), _jsx(RunSettings, { disabled: configurationPending || !writable, providers: pickable, provider: info.provider, selection: info.selection, model: model, runtimeMode: info.runtimeMode, effectiveRuntimeMode: info.effectiveRuntimeMode, permissionMode: info.permissionMode, open: modelPickerOpen, onOpenChange: setModelPickerOpen, onModel: chooseModel, onOption: (id, value) => configure({ selection: { ...info.selection, options: { ...info.selection.options, [id]: value } } }), onMode: (runtimeMode) => configure({ runtimeMode }), usage: info.usage, compactDisabled: busy || !writable, onCompact: compact, account: accountChoice === null ? null : { choice: accountChoice, started, onPick: chooseAccount } }), _jsx(StashPicker, { onRestore: restoreStashed }), Slot !== null && (_jsx(ErrorBoundary, { label: t('composer.slotFailed'), resetKeys: [chatId], compact: true, className: "relative rounded-lg", children: _jsx(Slot, { scopeId: scope.id, chatId: chatId, disabled: !writable, insert: insertIntoDraft }) })), _jsx("span", { className: "grow" }), _jsxs("div", { className: "flex h-9 shrink-0 items-center gap-0.5 rounded-full bg-surface-hover", children: [_jsx("div", { ref: setDictationToolbar, className: "flex items-center pl-1 empty:hidden [&_.icon-btn]:rounded-full" }), busy && (_jsx(Tooltip, { label: composerStopLabel(), name: true, children: _jsx("button", { className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-status-error text-accent-text", onClick: (event) => stop(event.shiftKey), children: _jsx(Icon, { icon: Square, size: 16 }) }) })), (!busy || !isEmptyDraft(draft)) && (_jsx(Tooltip, { label: busy
|
|
860
|
+
? t(canSteer
|
|
861
|
+
? sendDelivery === 'steer'
|
|
862
|
+
? 'composer.steerButton'
|
|
863
|
+
: 'composer.queueWithSteerButton'
|
|
864
|
+
: 'composer.queueButton')
|
|
865
|
+
: t('composer.send'), kbd: KEY_SHORTCUTS.modEnter, name: true, children: _jsx("button", { className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text hover:brightness-90 disabled:opacity-50 disabled:hover:brightness-100", disabled: isEmptyDraft(draft) || !writable || guard.tooLong, onClick: (event) => submit(event.altKey), children: _jsx(Icon, { icon: ArrowUp, size: 16 }) }) }))] })] })] }) }), _jsx(PromptDialog, { open: confirmClear, title: t('composer.clear.title'), description: t('composer.clear.description'), confirmLabel: t('composer.clear.confirm'), danger: true, fallbackMessage: t('composer.notice.clearFailed'), onConfirm: forceClearThread, onOpenChange: () => setConfirmClear(false) })] }));
|
|
856
866
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { type Components, type Options } from 'react-markdown';
|
|
2
|
-
|
|
2
|
+
import { type ShellCodeBlockContext } from '../../host.js';
|
|
3
|
+
type ShellCodeBlockReply = Pick<ShellCodeBlockContext, 'scopeId' | 'chatId' | 'itemId'> & {
|
|
4
|
+
streaming: boolean;
|
|
5
|
+
};
|
|
6
|
+
export declare const ReplyMarkdown: import("react").MemoExoticComponent<({ text, streaming, arriving, reply }: {
|
|
3
7
|
text: string;
|
|
4
8
|
streaming: boolean;
|
|
5
9
|
arriving?: boolean;
|
|
10
|
+
reply?: ShellCodeBlockReply;
|
|
6
11
|
}) => import("react").JSX.Element>;
|
|
7
12
|
export declare const MessageMarkdown: import("react").MemoExoticComponent<({ text, mentions, skills }: {
|
|
8
13
|
text: string;
|
|
@@ -16,3 +21,4 @@ export declare const Markdown: import("react").MemoExoticComponent<({ text, brea
|
|
|
16
21
|
rehypePlugins?: Options['rehypePlugins'];
|
|
17
22
|
componentOverrides?: Components;
|
|
18
23
|
}) => import("react").JSX.Element>;
|
|
24
|
+
export {};
|
package/dist/chat/ui/Markdown.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { memo, useMemo } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, memo, useContext, useMemo } from 'react';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
4
|
import { Zap } from 'lucide-react';
|
|
5
5
|
import ReactMarkdown, {} from 'react-markdown';
|
|
@@ -12,8 +12,43 @@ import { splitMarkdownBlocks } from './markdown-blocks.js';
|
|
|
12
12
|
import { rehypeChips } from './rehype-chips.js';
|
|
13
13
|
import { rehypeFadeWords } from './rehype-fade.js';
|
|
14
14
|
import { remarkHtmlAsText } from './remark-html-as-text.js';
|
|
15
|
-
import {
|
|
15
|
+
import { chatHost } from '../../host.js';
|
|
16
|
+
import { openFileLink, useFileLinkCwd, useFileLinkScopeId, useFileLinkTarget } from './file-links.js';
|
|
16
17
|
import { FileIcon, Icon } from '@adecore/ui';
|
|
18
|
+
const ShellCodeBlockReplyContext = createContext(null);
|
|
19
|
+
const BlockCodeContext = createContext(false);
|
|
20
|
+
/* Only root children qualify; Markdown's parser decides whether a fence belongs to a list or quote. */
|
|
21
|
+
function remarkShellCodeBlocks() {
|
|
22
|
+
return (tree, file) => {
|
|
23
|
+
for (const node of tree.children ?? []) {
|
|
24
|
+
if (node.type !== 'code') {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const start = node.position?.start.offset;
|
|
28
|
+
const end = node.position?.end.offset;
|
|
29
|
+
if (start === undefined || end === undefined) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const raw = String(file.value).slice(start, end);
|
|
33
|
+
const opening = /^ {0,3}(`{3,}|~{3,})[ \t]*(sh|bash|zsh)[ \t]*\r?\n/.exec(raw);
|
|
34
|
+
if (opening === null) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const body = raw.slice(opening[0].length);
|
|
38
|
+
const closing = /(?:^|\r?\n) {0,3}(`{3,}|~{3,})[ \t]*$/.exec(body);
|
|
39
|
+
const closed = closing !== null && closing[1][0] === opening[1][0] && closing[1].length >= opening[1].length;
|
|
40
|
+
node.data = {
|
|
41
|
+
...node.data,
|
|
42
|
+
hProperties: {
|
|
43
|
+
...node.data?.hProperties,
|
|
44
|
+
'data-shell-language': opening[2],
|
|
45
|
+
'data-shell-code': closed ? body.slice(0, closing.index) : body,
|
|
46
|
+
'data-shell-closed': closed
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
17
52
|
function languageOf(className) {
|
|
18
53
|
return /language-([\w-]+)/.exec(className ?? '')?.[1] ?? 'text';
|
|
19
54
|
}
|
|
@@ -26,7 +61,8 @@ const INLINE_CODE = 'rounded-sm bg-surface-sunken px-1 py-px font-mono text-code
|
|
|
26
61
|
*/
|
|
27
62
|
function FileLink({ target, className, children }) {
|
|
28
63
|
const cwd = useFileLinkCwd();
|
|
29
|
-
|
|
64
|
+
const scopeId = useFileLinkScopeId();
|
|
65
|
+
return (_jsx("button", { type: "button", className: clsx('cursor-pointer select-text', className), "data-file-path": target.path, "data-file-line": target.line, "data-file-column": target.column, "data-file-end-line": target.endLine, "data-file-directory": target.directory, "data-file-cwd": cwd ?? '', "data-file-scope-id": scopeId ?? '', onClick: () => openFileLink(cwd, target, scopeId), children: children }));
|
|
30
66
|
}
|
|
31
67
|
/* Inline code, which is a file reference often enough that it is worth asking every time. */
|
|
32
68
|
function InlineCode({ text }) {
|
|
@@ -55,18 +91,19 @@ function MarkdownLink({ href, children }) {
|
|
|
55
91
|
}
|
|
56
92
|
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", children: children }));
|
|
57
93
|
}
|
|
94
|
+
function MarkdownCode({ className, children, plain = false }) {
|
|
95
|
+
const block = useContext(BlockCodeContext);
|
|
96
|
+
const text = String(children ?? '');
|
|
97
|
+
if (block) {
|
|
98
|
+
return _jsx(CodeBlock, { code: text.replace(/\n$/, ''), lang: languageOf(className) });
|
|
99
|
+
}
|
|
100
|
+
return plain ? _jsx("code", { className: INLINE_CODE, children: text }) : _jsx(InlineCode, { text: text });
|
|
101
|
+
}
|
|
58
102
|
const components = {
|
|
59
103
|
pre({ children }) {
|
|
60
|
-
return _jsx(
|
|
61
|
-
},
|
|
62
|
-
code({ className, children }) {
|
|
63
|
-
const text = String(children ?? '');
|
|
64
|
-
// Fenced blocks end with a newline and carry a language; anything else is inline.
|
|
65
|
-
if (className?.startsWith('language-') || text.includes('\n')) {
|
|
66
|
-
return _jsx(CodeBlock, { code: text.replace(/\n$/, ''), lang: languageOf(className) });
|
|
67
|
-
}
|
|
68
|
-
return _jsx(InlineCode, { text: text });
|
|
104
|
+
return _jsx(BlockCodeContext.Provider, { value: true, children: children });
|
|
69
105
|
},
|
|
106
|
+
code: MarkdownCode,
|
|
70
107
|
a({ href, children }) {
|
|
71
108
|
return _jsx(MarkdownLink, { href: href, children: children });
|
|
72
109
|
},
|
|
@@ -79,11 +116,7 @@ const components = {
|
|
|
79
116
|
const plainComponents = {
|
|
80
117
|
...components,
|
|
81
118
|
code({ className, children }) {
|
|
82
|
-
|
|
83
|
-
if (className?.startsWith('language-') || text.includes('\n')) {
|
|
84
|
-
return _jsx(CodeBlock, { code: text.replace(/\n$/, ''), lang: languageOf(className) });
|
|
85
|
-
}
|
|
86
|
-
return _jsx("code", { className: INLINE_CODE, children: text });
|
|
119
|
+
return (_jsx(MarkdownCode, { className: className, plain: true, children: children }));
|
|
87
120
|
},
|
|
88
121
|
a({ href, children }) {
|
|
89
122
|
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", children: children }));
|
|
@@ -100,6 +133,7 @@ function replyHeading(level) {
|
|
|
100
133
|
}
|
|
101
134
|
const replyComponents = {
|
|
102
135
|
...components,
|
|
136
|
+
code: ReplyCode,
|
|
103
137
|
h1: replyHeading(1),
|
|
104
138
|
h2: replyHeading(2),
|
|
105
139
|
h3: replyHeading(3),
|
|
@@ -107,12 +141,34 @@ const replyComponents = {
|
|
|
107
141
|
h5: replyHeading(5),
|
|
108
142
|
h6: replyHeading(6)
|
|
109
143
|
};
|
|
144
|
+
function ReplyCode({ className, children, node }) {
|
|
145
|
+
const reply = useContext(ShellCodeBlockReplyContext);
|
|
146
|
+
const block = useContext(BlockCodeContext);
|
|
147
|
+
const properties = node?.properties;
|
|
148
|
+
const language = properties?.['data-shell-language'];
|
|
149
|
+
const code = properties?.['data-shell-code'];
|
|
150
|
+
const context = reply !== null && (language === 'sh' || language === 'bash' || language === 'zsh') && typeof code === 'string'
|
|
151
|
+
? {
|
|
152
|
+
scopeId: reply.scopeId,
|
|
153
|
+
chatId: reply.chatId,
|
|
154
|
+
itemId: reply.itemId,
|
|
155
|
+
language,
|
|
156
|
+
code,
|
|
157
|
+
complete: !reply.streaming && properties?.['data-shell-closed'] === true
|
|
158
|
+
}
|
|
159
|
+
: null;
|
|
160
|
+
const text = String(children ?? '');
|
|
161
|
+
if (block) {
|
|
162
|
+
return (_jsx(CodeBlock, { code: text.replace(/\n$/, ''), lang: languageOf(className), actions: context !== null ? chatHost().renderShellCodeBlock?.(context) : undefined }));
|
|
163
|
+
}
|
|
164
|
+
return _jsx(InlineCode, { text: text });
|
|
165
|
+
}
|
|
110
166
|
/* A picked file or skill in a sent message. The glyph stands in for the sigil the text still carries. */
|
|
111
167
|
function Chip({ kind, value }) {
|
|
112
168
|
if (kind === 'skill') {
|
|
113
169
|
return (_jsxs("span", { className: clsx(CHIP_IN_MESSAGE, SKILL_TONE), children: [_jsx(Icon, { icon: Zap, size: 14, className: "shrink-0 opacity-85" }), _jsx("span", { className: "truncate", children: value })] }));
|
|
114
170
|
}
|
|
115
|
-
return (_jsxs("span", { className: clsx(CHIP_IN_MESSAGE, MENTION_TONE), "data-file-path": value, children: [_jsx(FileIcon, { path: value, size: 14 }), _jsx("span", { className: "truncate", children: value })] }));
|
|
171
|
+
return (_jsxs("span", { className: clsx(CHIP_IN_MESSAGE, MENTION_TONE), "data-file-path": value, "data-file-directory": /[\\/]$/.test(value), children: [_jsx(FileIcon, { path: value, size: 14 }), _jsx("span", { className: "truncate", children: value })] }));
|
|
116
172
|
}
|
|
117
173
|
const messageComponents = {
|
|
118
174
|
...replyComponents,
|
|
@@ -125,14 +181,15 @@ const messageComponents = {
|
|
|
125
181
|
};
|
|
126
182
|
// Module constants, so a render never hands react-markdown a fresh array and makes it parse again.
|
|
127
183
|
const PLUGINS = [remarkGfm];
|
|
184
|
+
const REPLY_PLUGINS = [remarkGfm, remarkShellCodeBlocks];
|
|
128
185
|
const PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
|
|
129
186
|
const MESSAGE_PLUGINS = [remarkGfm, remarkHtmlAsText, remarkBreaks];
|
|
130
187
|
const FADE_PLUGINS = [rehypeFadeWords];
|
|
131
188
|
const NO_PLUGINS = [];
|
|
132
189
|
/* One block of a reply. It renders no element of its own, so prose still sees the paragraphs as
|
|
133
190
|
direct children and keeps its first and last margins. */
|
|
134
|
-
const ReplyBlock = memo(function ReplyBlock({ text, fade, open }) {
|
|
135
|
-
return (_jsx(CodeStreamingContext.Provider, { value: open, children: _jsx(ReactMarkdown, { remarkPlugins:
|
|
191
|
+
const ReplyBlock = memo(function ReplyBlock({ text, fade, open, reply }) {
|
|
192
|
+
return (_jsx(CodeStreamingContext.Provider, { value: open, children: _jsx(ShellCodeBlockReplyContext.Provider, { value: reply ?? null, children: _jsx(ReactMarkdown, { remarkPlugins: REPLY_PLUGINS, rehypePlugins: fade ? FADE_PLUGINS : NO_PLUGINS, components: replyComponents, children: text }) }) }));
|
|
136
193
|
});
|
|
137
194
|
/*
|
|
138
195
|
* A reply in a chat thread, parsed a block at a time so a delta only parses the block that grows.
|
|
@@ -140,9 +197,9 @@ const ReplyBlock = memo(function ReplyBlock({ text, fade, open }) {
|
|
|
140
197
|
* line at a time as it grows. `arriving` fades in each block as it is added instead, for a reply
|
|
141
198
|
* that is shown a block at a time.
|
|
142
199
|
*/
|
|
143
|
-
export const ReplyMarkdown = memo(function ReplyMarkdown({ text, streaming, arriving = false }) {
|
|
200
|
+
export const ReplyMarkdown = memo(function ReplyMarkdown({ text, streaming, arriving = false, reply }) {
|
|
144
201
|
const blocks = useMemo(() => splitMarkdownBlocks(text), [text]);
|
|
145
|
-
return (_jsx("div", { className: "chat-markdown prose prose-sm max-w-none text-sm", "data-arriving": arriving || undefined, children: blocks.map((block, index) => (_jsx(ReplyBlock, { text: block.text, fade: streaming, open: streaming && block.openFence }, index))) }));
|
|
202
|
+
return (_jsx("div", { className: "chat-markdown prose prose-sm max-w-none text-sm", "data-arriving": arriving || undefined, children: blocks.map((block, index) => (_jsx(ReplyBlock, { text: block.text, fade: streaming, open: streaming && block.openFence, reply: reply }, index))) }));
|
|
146
203
|
});
|
|
147
204
|
/*
|
|
148
205
|
* A message a person sent. Enter meant a line break, a tag typed without backticks stays the text
|
|
@@ -9,15 +9,14 @@ import { markdownOf, messageTextOf } from '../logic/timeline-copy.js';
|
|
|
9
9
|
import { chatHost } from '../../host.js';
|
|
10
10
|
import { useChatScope } from '../../scope.js';
|
|
11
11
|
import { useChatRow, useChats } from '../../state/chats.js';
|
|
12
|
-
import { ButtonGroup, copyText, IconButton } from '@adecore/ui';
|
|
12
|
+
import { ButtonGroup, copyText, IconButton, Tooltip } from '@adecore/ui';
|
|
13
|
+
import { formatClock, formatDateTime, useFormatLocale } from '@adecore/ui/format';
|
|
13
14
|
const COPIED_MS = 1500;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* `group/message`. The row always takes its height, so the virtualizer never measures it again on a
|
|
17
|
-
* hover. Like the strip's card, it leaves out an action the message cannot take right now.
|
|
18
|
-
*/
|
|
15
|
+
const TIMESTAMP_FORMAT = { dateStyle: 'full', timeStyle: 'medium' };
|
|
16
|
+
/* Hidden actions keep their height, so hovering never forces the virtualizer to remeasure. */
|
|
19
17
|
export function MessageActions({ chatId, row }) {
|
|
20
18
|
const { t } = useTranslation('agent-chat');
|
|
19
|
+
useFormatLocale();
|
|
21
20
|
const scope = useChatScope();
|
|
22
21
|
const { fork } = chatHost();
|
|
23
22
|
const turnId = row.item.turnId;
|
|
@@ -49,5 +48,5 @@ export function MessageActions({ chatId, row }) {
|
|
|
49
48
|
setCopied(true);
|
|
50
49
|
}
|
|
51
50
|
};
|
|
52
|
-
return (
|
|
51
|
+
return (_jsxs("div", { role: "toolbar", "aria-label": t('timeline.actions.label'), className: clsx('flex h-7 items-center gap-2 opacity-0 transition-opacity group-has-focus-visible/message:opacity-100 group-hover/message:opacity-100', row.kind === 'user' && 'mt-1.5 justify-end'), children: [!streaming && (_jsxs(ButtonGroup, { render: _jsx("span", {}), children: [bookmark === null && (_jsx(IconButton, { icon: Bookmark, size: "sm", label: t('bookmarks.add'), onClick: () => void placeBookmark(scope, chatId, row.id) })), canFork && turnId !== null && fork !== null && (_jsx(IconButton, { icon: GitFork, size: "sm", label: t('timeline.menu.forkFromHere'), onClick: () => fork(chatId, turnId) })), hasText && (_jsx(IconButton, { icon: copied ? Check : Copy, size: "sm", label: copied ? t('timeline.actions.copied') : t('timeline.menu.copyMessage'), onClick: (e) => copy(e.shiftKey) }))] })), _jsx(Tooltip, { label: formatDateTime(row.item.createdAt, TIMESTAMP_FORMAT), children: _jsx("time", { dateTime: new Date(row.item.createdAt).toISOString(), className: clsx('shrink-0 text-xs text-text-faint tabular-nums select-none', row.kind === 'assistant' && 'order-first'), children: formatClock(row.item.createdAt) }) })] }));
|
|
53
52
|
}
|
|
@@ -94,7 +94,7 @@ export function SubagentTimeline({ chatId, toolUseId }) {
|
|
|
94
94
|
const openChild = (step) => {
|
|
95
95
|
show(openBelow(trail, step));
|
|
96
96
|
};
|
|
97
|
-
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [_jsx(SubagentInfo, { chatId: chatId, toolUseId: toolUseId }), state.status === 'loading' && _jsx("div", { className: "chat-column-content px-4 pt-4 text-xs text-text-faint", children: t('subagents.loading') }), state.status === 'failed' && (_jsx(EmptyState, { icon: Bot, children: state.unsupported ? t('subagents.unsupported') : (state.error ?? t('subagents.unreadable')) })), state.status === 'ready' && (_jsx(FileLinkContext.Provider, { value: state.context?.cwd ?? cwd, children: _jsx(ReplyContext.Provider, { value: state.context ?? { provider: state.source === 'codex-thread' ? 'codex' : 'claude' }, children: _jsx("div", { ref: scrollRef, className: "chat-thread relative min-h-0 grow overflow-auto px-4 pt-4 pb-3",
|
|
97
|
+
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [_jsx(SubagentInfo, { chatId: chatId, toolUseId: toolUseId }), state.status === 'loading' && _jsx("div", { className: "chat-column-content px-4 pt-4 text-xs text-text-faint", children: t('subagents.loading') }), state.status === 'failed' && (_jsx(EmptyState, { icon: Bot, children: state.unsupported ? t('subagents.unsupported') : (state.error ?? t('subagents.unreadable')) })), state.status === 'ready' && (_jsx(FileLinkContext.Provider, { value: state.context?.cwd ?? cwd, children: _jsx(ReplyContext.Provider, { value: state.context ?? { provider: state.source === 'codex-thread' ? 'codex' : 'claude' }, children: _jsx("div", { ref: scrollRef, "data-file-cwd": state.context?.cwd ?? cwd ?? '', "data-file-scope-id": scope.id, className: "chat-thread relative min-h-0 grow overflow-auto px-4 pt-4 pb-3",
|
|
98
98
|
// The thread keeps its reader in place itself when a page goes in above.
|
|
99
99
|
style: { overflowAnchor: 'none' }, onScroll: (event) => {
|
|
100
100
|
const element = event.currentTarget;
|
package/dist/chat/ui/Timeline.js
CHANGED
|
@@ -395,7 +395,7 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
395
395
|
followRef.current = false;
|
|
396
396
|
virtualizer.scrollToIndex(found.index, { align: 'start' });
|
|
397
397
|
};
|
|
398
|
-
return (_jsx(FileLinkContext.Provider, { value: info?.cwd ?? null, children: _jsxs("div", { ref: frameRef, className: "relative flex min-h-0 grow flex-col", children: [searching && chatFind.bar, _jsxs(ContextMenu.Root, { children: [_jsx("div", { ref: scrollRef,
|
|
398
|
+
return (_jsx(FileLinkContext.Provider, { value: info?.cwd ?? null, children: _jsxs("div", { ref: frameRef, className: "relative flex min-h-0 grow flex-col", "data-file-cwd": info?.cwd ?? '', "data-file-scope-id": scope.id, children: [searching && chatFind.bar, _jsxs(ContextMenu.Root, { children: [_jsx("div", { ref: scrollRef,
|
|
399
399
|
// No CSS scroll-padding here: Chromium counts the sticky composer's caret as hidden behind
|
|
400
400
|
// it and scrolls the thread toward the end on every keystroke.
|
|
401
401
|
className: "chat-scroll min-h-0 grow overflow-auto",
|
|
@@ -7,7 +7,7 @@ import { markdownOf, messageTextOf } from '../logic/timeline-copy.js';
|
|
|
7
7
|
import { chatHost } from '../../host.js';
|
|
8
8
|
import { useChatScope } from '../../scope.js';
|
|
9
9
|
import { useChatRow } from '../../state/chats.js';
|
|
10
|
-
import { openFileLink, useFileLinkCwd } from './file-links.js';
|
|
10
|
+
import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links.js';
|
|
11
11
|
import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
|
|
12
12
|
/*
|
|
13
13
|
* The menu behind a right-click in a thread. Copy is the reason it exists. Everything in a thread
|
|
@@ -23,10 +23,14 @@ export function TimelineMenuPopup({ target, thread, chatId = null }) {
|
|
|
23
23
|
const markable = chatId !== null && (target.row?.kind === 'user' || target.row?.kind === 'assistant') ? target.row.id : null;
|
|
24
24
|
// A thread outside a chat (a sub-agent's transcript) has no cwd of its own; the app answers for it then.
|
|
25
25
|
const cwd = useFileLinkCwd();
|
|
26
|
+
const scopeId = useFileLinkScopeId();
|
|
26
27
|
const { fork, fileLinks } = chatHost();
|
|
27
28
|
const openInPreview = () => {
|
|
28
|
-
if (target.
|
|
29
|
-
openFileLink(cwd,
|
|
29
|
+
if (target.file != null) {
|
|
30
|
+
openFileLink(target.file.cwd, target.file.ref, target.file.scopeId);
|
|
31
|
+
}
|
|
32
|
+
else if (target.path !== null) {
|
|
33
|
+
openFileLink(cwd, { path: target.path, ...(target.line === null ? {} : { line: target.line }), directory: false }, scopeId);
|
|
30
34
|
}
|
|
31
35
|
};
|
|
32
36
|
return (_jsxs(ContextMenu.Popup, { children: [_jsxs(ContextMenu.Item, { disabled: target.selection === '', onClick: () => copyText(target.selection), children: [_jsx(Icon, { icon: Copy, size: 14 }), " ", t('common.action.copy'), " ", _jsx(Kbd, { shortcut: EDIT_SHORTCUTS.copy })] }), message !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(message), children: [_jsx(Icon, { icon: MessageSquare, size: 14 }), " ", t('timeline.menu.copyMessage')] })), target.code !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(target.code ?? ''), children: [_jsx(Icon, { icon: Braces, size: 14 }), " ", t('timeline.menu.copyCode')] })), markdown !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(markdown), children: [_jsx(Icon, { icon: FileText, size: 14 }), " ", t('timeline.menu.copyMarkdown')] })), chatId !== null && turnId !== null && fork !== null && (_jsx(DisabledReason, { reason: forkBlocked, children: _jsxs(ContextMenu.Item, { disabled: forkBlocked !== null, onClick: () => fork(chatId, turnId), children: [_jsx(Icon, { icon: GitFork, size: 14 }), " ", t('timeline.menu.forkFromHere')] }) })), chatId !== null && markable !== null && _jsx(BookmarkMenuItems, { chatId: chatId, itemId: markable }), _jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { onClick: () => selectAllWithin(thread.current), children: [_jsx(Icon, { icon: Scan, size: 14 }), " ", t('common.action.selectAll')] }), target.path !== null && fileLinks !== null && (_jsxs(_Fragment, { children: [_jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { onClick: openInPreview, children: [_jsx(Icon, { icon: Eye, size: 14 }), " ", t('timeline.menu.openInPreview')] })] }))] }));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FileRef } from '../../host.js';
|
|
2
2
|
export declare const FileLinkContext: import("react").Context<string | null>;
|
|
3
3
|
export declare function useFileLinkCwd(): string | null;
|
|
4
|
+
export declare function useFileLinkScopeId(): string | null;
|
|
4
5
|
export declare function useFileLinkTarget(text: string): FileRef | null;
|
|
5
|
-
export declare function openFileLink(cwd: string | null, ref: FileRef): void;
|
|
6
|
+
export declare function openFileLink(cwd: string | null, ref: FileRef, scopeId?: string | null): void;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo } from 'react';
|
|
2
2
|
import { chatHost } from '../../host.js';
|
|
3
|
+
import { ChatScopeContext } from '../../scope.js';
|
|
3
4
|
/*
|
|
4
5
|
* The folder a relative reference in rendered text counts from: a chat's own cwd, which is a
|
|
5
6
|
* worktree as often as it is the project, or the folder of the file an app is drawing. Null where
|
|
@@ -9,12 +10,16 @@ export const FileLinkContext = createContext(null);
|
|
|
9
10
|
export function useFileLinkCwd() {
|
|
10
11
|
return useContext(FileLinkContext);
|
|
11
12
|
}
|
|
13
|
+
export function useFileLinkScopeId() {
|
|
14
|
+
return useContext(ChatScopeContext)?.id ?? null;
|
|
15
|
+
}
|
|
12
16
|
/* The reference a piece of text names, only where this spot on screen could open it. */
|
|
13
17
|
export function useFileLinkTarget(text) {
|
|
14
18
|
const cwd = useFileLinkCwd();
|
|
15
|
-
|
|
19
|
+
const scopeId = useFileLinkScopeId();
|
|
20
|
+
return useMemo(() => chatHost().fileLinks?.target(text, cwd, scopeId) ?? null, [text, cwd, scopeId]);
|
|
16
21
|
}
|
|
17
22
|
/* A reference followed, the way the app opens a file or a folder. */
|
|
18
|
-
export function openFileLink(cwd, ref) {
|
|
19
|
-
chatHost().fileLinks?.open(cwd, ref);
|
|
23
|
+
export function openFileLink(cwd, ref, scopeId = null) {
|
|
24
|
+
chatHost().fileLinks?.open(cwd, ref, scopeId);
|
|
20
25
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useContext, useState } from 'react';
|
|
2
|
+
import { useContext, useMemo, useState } from 'react';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { Bot, Brain, Check, ChevronDown, CircleAlert, File, Info, MessageCircleQuestionMark, TriangleAlert, X } from 'lucide-react';
|
|
@@ -107,18 +107,21 @@ function Writing() {
|
|
|
107
107
|
*/
|
|
108
108
|
export function AssistantRow({ chatId, item: derived }) {
|
|
109
109
|
const item = useCurrentItem(chatId, derived);
|
|
110
|
+
const scope = useChatScope();
|
|
111
|
+
const context = useContext(ReplyContext);
|
|
112
|
+
const reply = useMemo(() => (context === null && !item.parentToolUseId ? { scopeId: scope.id, chatId, itemId: item.id, streaming: item.streaming } : undefined), [context, item.parentToolUseId, scope.id, chatId, item.id, item.streaming]);
|
|
110
113
|
const mode = chatHost().useStreaming();
|
|
111
114
|
const [sawWriting] = useState(item.streaming);
|
|
112
115
|
const live = mode === 'words' && item.streaming;
|
|
113
116
|
const reveal = useRevealedText(item.text, live);
|
|
114
117
|
if (mode === 'blocks') {
|
|
115
118
|
const settled = item.streaming ? settledBlocksText(item.text) : item.text;
|
|
116
|
-
return (_jsxs("div", { "data-find-item": item.id, className: "-mx-1 px-1 pb-2", children: [_jsx(ReplyHeading, { chatId: chatId }), settled !== '' && (_jsx("div", { "data-find-field": "text", "data-quote-answer": true, className: "contents", children: _jsx(ReplyMarkdown, { text: settled, streaming: false, arriving: sawWriting }) })), item.streaming && _jsx(Writing, {})] }));
|
|
119
|
+
return (_jsxs("div", { "data-find-item": item.id, className: "-mx-1 px-1 pb-2", children: [_jsx(ReplyHeading, { chatId: chatId }), settled !== '' && (_jsx("div", { "data-find-field": "text", "data-quote-answer": true, className: "contents", children: _jsx(ReplyMarkdown, { text: settled, streaming: false, arriving: sawWriting, reply: reply }) })), item.streaming && _jsx(Writing, {})] }));
|
|
117
120
|
}
|
|
118
121
|
if (mode === 'whole' && item.streaming) {
|
|
119
122
|
return (_jsxs("div", { className: "-mx-1 px-1 pb-2", children: [_jsx(ReplyHeading, { chatId: chatId }), _jsx(Writing, {})] }));
|
|
120
123
|
}
|
|
121
|
-
return (_jsxs("div", { "data-find-item": item.id, className: clsx('-mx-1 px-1 pb-2', mode === 'whole' && sawWriting && WHOLE_FADE_CLASS), children: [_jsx(ReplyHeading, { chatId: chatId }), _jsx("div", { "data-find-field": "text", "data-quote-answer": true, className: "contents", children: _jsx(ReplyMarkdown, { text: reveal.text, streaming: reveal.active }) }), live && item.text === '' && _jsx("span", { className: "inline-block h-3.5 w-1.5 animate-pulse rounded-sm bg-text-faint align-middle" })] }));
|
|
124
|
+
return (_jsxs("div", { "data-find-item": item.id, className: clsx('-mx-1 px-1 pb-2', mode === 'whole' && sawWriting && WHOLE_FADE_CLASS), children: [_jsx(ReplyHeading, { chatId: chatId }), _jsx("div", { "data-find-field": "text", "data-quote-answer": true, className: "contents", children: _jsx(ReplyMarkdown, { text: reveal.text, streaming: reveal.active, reply: reply }) }), live && item.text === '' && _jsx("span", { className: "inline-block h-3.5 w-1.5 animate-pulse rounded-sm bg-text-faint align-middle" })] }));
|
|
122
125
|
}
|
|
123
126
|
/*
|
|
124
127
|
* What the model thought before it answered. It shimmers while it streams and folds itself away
|