@steerable/agent-shell-web 0.6.19 → 0.6.21
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/package.json +15 -8
- package/src/components/chat/ApprovalModal.tsx +21 -41
- package/src/components/chat/ApprovalPromptProvider.tsx +98 -0
- package/src/components/chat/AskUserPromptProvider.tsx +99 -0
- package/src/components/chat/AssistantMessage.tsx +25 -2
- package/src/components/chat/ChatInput.tsx +116 -13
- package/src/components/chat/FilePathCode.tsx +65 -0
- package/src/components/chat/LocalChatPanel.tsx +19 -0
- package/src/components/chat/Markdown.tsx +54 -25
- package/src/components/chat/MessageList.tsx +66 -18
- package/src/components/chat/ModelPicker.tsx +5 -3
- package/src/components/chat/SuggestedReplies.tsx +34 -0
- package/src/components/chat/TurnFilesCard.tsx +113 -0
- package/src/components/chat/TurnProcessGroup.tsx +7 -2
- package/src/components/chat/path-mentions.ts +148 -0
- package/src/components/chat/suggested-replies-model.ts +40 -0
- package/src/components/chat/turn-files.ts +50 -0
- package/src/layouts/AgentLayout.tsx +15 -26
- package/src/lib/browser-dev-electron-mock.ts +15 -0
- package/src/lib/electron-bridge.ts +13 -7
- package/src/lib/http-bridge.ts +8 -2
- package/src/lib/local-api.ts +31 -0
- package/src/pages/AgentPage.tsx +126 -1
- package/src/pages/ChatPanelPreviewPage.tsx +4 -0
- package/vite.base.d.ts +7 -0
- package/vite.base.js +83 -0
- package/src/brand.test.ts +0 -22
- package/src/components/AgentSidebar.test.tsx +0 -791
- package/src/components/ChatHeader.test.tsx +0 -230
- package/src/components/LocalLlmSettingsModal.test.tsx +0 -372
- package/src/components/chat/ApprovalModal.test.tsx +0 -83
- package/src/components/chat/AskUserModalHost.test.tsx +0 -176
- package/src/components/chat/AskUserModalHost.tsx +0 -61
- package/src/components/chat/AskUserQuestionMenu.test.tsx +0 -190
- package/src/components/chat/AssistantMessage.test.tsx +0 -68
- package/src/components/chat/ChatInput.test.tsx +0 -609
- package/src/components/chat/ExecPolicyPicker.test.tsx +0 -26
- package/src/components/chat/Markdown.test.tsx +0 -170
- package/src/components/chat/ModelPicker.test.tsx +0 -149
- package/src/components/chat/SessionTreeModal.test.tsx +0 -161
- package/src/components/chat/TaskOutcomeCards.test.tsx +0 -97
- package/src/components/chat/TaskPanelModal.test.tsx +0 -198
- package/src/components/chat/TaskProcessPanel.test.tsx +0 -111
- package/src/components/chat/TurnProcessGroup.test.tsx +0 -161
- package/src/components/chat/elapsed.test.ts +0 -42
- package/src/components/chat/executed-actions-model.test.ts +0 -134
- package/src/components/chat/orchestration-children-model.test.ts +0 -115
- package/src/components/chat/turn-timeline.test.ts +0 -97
- package/src/components/chat/useChatTasks.test.ts +0 -136
- package/src/components/settings/AgentsSettingsPanel.test.tsx +0 -229
- package/src/components/settings/InsightsSettingsPanel.test.tsx +0 -132
- package/src/components/settings/LlmSettingsPanel.test.tsx +0 -461
- package/src/components/settings/ModelIdCombobox.test.tsx +0 -88
- package/src/components/settings/SecuritySettingsPanel.test.tsx +0 -122
- package/src/components/settings/SkillsSettingsPanel.test.tsx +0 -62
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +0 -90
- package/src/components/settings/compat-flags-model.test.ts +0 -71
- package/src/components/settings/llm-vendors.test.ts +0 -171
- package/src/components/settings/model-capabilities.test.ts +0 -88
- package/src/components/settings/preset-choice-model.test.ts +0 -96
- package/src/hooks/useChatsAndAgents.test.ts +0 -165
- package/src/lib/attachments.test.ts +0 -127
- package/src/lib/chat-title.test.ts +0 -53
- package/src/lib/chat-transport.test.ts +0 -282
- package/src/lib/date-groups.test.ts +0 -62
- package/src/lib/demo-flag.test.ts +0 -17
- package/src/lib/electron-bridge.test.ts +0 -54
- package/src/lib/exec-policy.test.ts +0 -71
- package/src/lib/http-bridge.test.ts +0 -283
- package/src/lib/insights.test.ts +0 -56
- package/src/lib/local-api.test.ts +0 -353
- package/src/lib/pending-first-message.test.ts +0 -17
- package/src/lib/slash-sources.test.ts +0 -161
- package/src/pages/AgentPage.test.tsx +0 -627
- package/src/pages/SettingsPage.test.tsx +0 -102
- package/vite.base.ts +0 -94
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
type McpToolItem,
|
|
43
43
|
type SkillItem,
|
|
44
44
|
} from '@/lib/slash-sources';
|
|
45
|
+
import { useAskUserPrompt } from './AskUserPromptProvider';
|
|
46
|
+
import { AskUserQuestionMenu } from './AskUserQuestionMenu';
|
|
47
|
+
import { useApprovalPrompt } from './ApprovalPromptProvider';
|
|
48
|
+
import { ApprovalPromptMenu } from './ApprovalModal';
|
|
45
49
|
import { ExecPolicyPicker } from './ExecPolicyPicker';
|
|
46
50
|
|
|
47
51
|
export type ChatMode = 'agent' | 'plan';
|
|
@@ -704,6 +708,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
704
708
|
},
|
|
705
709
|
ref,
|
|
706
710
|
) {
|
|
711
|
+
const askUserPrompt = useAskUserPrompt();
|
|
712
|
+
const approvalPrompt = useApprovalPrompt();
|
|
707
713
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
708
714
|
const agentMenuRef = useRef<HTMLDivElement>(null);
|
|
709
715
|
const skillOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
@@ -731,6 +737,12 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
731
737
|
const compositionEndAtRef = useRef<number>(0);
|
|
732
738
|
|
|
733
739
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
740
|
+
// 工具栏"空间不足时优先隐藏快捷键提示"的测量 refs,见下方 useLayoutEffect。
|
|
741
|
+
const toolbarRowRef = useRef<HTMLDivElement>(null);
|
|
742
|
+
const toolbarLeftRef = useRef<HTMLDivElement>(null);
|
|
743
|
+
const hintRef = useRef<HTMLDivElement>(null);
|
|
744
|
+
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
|
745
|
+
const [hintsSuppressed, setHintsSuppressed] = useState(false);
|
|
734
746
|
const [localFiles, setLocalFiles] = useState<AttachmentFile[]>([]);
|
|
735
747
|
const actualFiles = files ?? localFiles;
|
|
736
748
|
const actualOnFilesChange = onFilesChange ?? setLocalFiles;
|
|
@@ -1050,6 +1062,50 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1050
1062
|
return () => window.removeEventListener('keydown', onKey);
|
|
1051
1063
|
}, [isStreaming, onCancel]);
|
|
1052
1064
|
|
|
1065
|
+
// 快捷键提示是工具栏里优先级最低的元素:空间不够时第一个隐藏(先于
|
|
1066
|
+
// 左侧选择器截断)。容器查询只看输入盒宽度、看不到左侧内容(模型 id
|
|
1067
|
+
// 长度可变)的实际占用,所以直接测量:左组自然宽度 + 提示 + 发送键
|
|
1068
|
+
// 放不下就收起提示。测量时临时取消左组 flex 收缩并强制显示提示,
|
|
1069
|
+
// 拿到的都是不依赖当前收起状态的自然宽度——结果稳定,不会来回闪烁。
|
|
1070
|
+
useLayoutEffect(() => {
|
|
1071
|
+
const row = toolbarRowRef.current;
|
|
1072
|
+
const left = toolbarLeftRef.current;
|
|
1073
|
+
if (!row || !left) return;
|
|
1074
|
+
|
|
1075
|
+
const check = () => {
|
|
1076
|
+
if (row.clientWidth === 0) return; // 无布局环境(happy-dom)不干预
|
|
1077
|
+
const hint = hintRef.current;
|
|
1078
|
+
const send = sendButtonRef.current;
|
|
1079
|
+
const prevShrink = left.style.flexShrink;
|
|
1080
|
+
left.style.flexShrink = '0';
|
|
1081
|
+
const prevHintDisplay = hint?.style.display ?? '';
|
|
1082
|
+
if (hint) hint.style.display = 'block';
|
|
1083
|
+
const needed =
|
|
1084
|
+
left.getBoundingClientRect().width +
|
|
1085
|
+
(hint?.getBoundingClientRect().width ?? 0) +
|
|
1086
|
+
(send?.getBoundingClientRect().width ?? 0) +
|
|
1087
|
+
16; // gap-2 ×2:左右组之间 + 提示与发送键之间
|
|
1088
|
+
left.style.flexShrink = prevShrink;
|
|
1089
|
+
if (hint) hint.style.display = prevHintDisplay;
|
|
1090
|
+
const available = row.clientWidth - 16; // px-2 两侧
|
|
1091
|
+
const suppress = needed > available + 1;
|
|
1092
|
+
setHintsSuppressed((prev) => (prev === suppress ? prev : suppress));
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
check();
|
|
1096
|
+
const resizeObs = new ResizeObserver(check);
|
|
1097
|
+
resizeObs.observe(row);
|
|
1098
|
+
// 左组内容变化(切换模型/沙箱档、设置齿轮挂载)不改变行宽,
|
|
1099
|
+
// ResizeObserver 观察不到,用 MutationObserver 兜底重新测量。
|
|
1100
|
+
const mutationObs = new MutationObserver(check);
|
|
1101
|
+
mutationObs.observe(left, { childList: true, subtree: true, characterData: true });
|
|
1102
|
+
return () => {
|
|
1103
|
+
resizeObs.disconnect();
|
|
1104
|
+
mutationObs.disconnect();
|
|
1105
|
+
};
|
|
1106
|
+
// isStreaming 切换提示文案(kbd 标签不同、宽度不同),需要重测。
|
|
1107
|
+
}, [isStreaming]);
|
|
1108
|
+
|
|
1053
1109
|
const trimmed = value.trim();
|
|
1054
1110
|
const canSend = (trimmed.length > 0 || actualFiles.length > 0) && !disabled && !isStreaming;
|
|
1055
1111
|
|
|
@@ -1454,6 +1510,42 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1454
1510
|
editorRef.current?.focus();
|
|
1455
1511
|
};
|
|
1456
1512
|
|
|
1513
|
+
if (approvalPrompt?.current) {
|
|
1514
|
+
return (
|
|
1515
|
+
<div className="chat-input-container @container relative px-3 pb-3 pt-1">
|
|
1516
|
+
<ApprovalPromptMenu
|
|
1517
|
+
key={approvalPrompt.current.requestId}
|
|
1518
|
+
request={approvalPrompt.current}
|
|
1519
|
+
pendingCount={approvalPrompt.pendingCount}
|
|
1520
|
+
onDecide={approvalPrompt.decide}
|
|
1521
|
+
/>
|
|
1522
|
+
</div>
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
if (askUserPrompt?.current) {
|
|
1527
|
+
const request = askUserPrompt.current;
|
|
1528
|
+
return (
|
|
1529
|
+
<div
|
|
1530
|
+
className="chat-input-container @container relative px-3 pb-3 pt-1"
|
|
1531
|
+
data-testid="ask-user-composer"
|
|
1532
|
+
>
|
|
1533
|
+
<AskUserQuestionMenu
|
|
1534
|
+
key={request.requestId}
|
|
1535
|
+
intro={request.intro}
|
|
1536
|
+
questions={request.questions}
|
|
1537
|
+
onSubmit={askUserPrompt.answer}
|
|
1538
|
+
onAutoContinue={() => askUserPrompt.answer({})}
|
|
1539
|
+
bottomHint={
|
|
1540
|
+
askUserPrompt.pendingCount > 0
|
|
1541
|
+
? `还有 ${askUserPrompt.pendingCount} 组问题待回答`
|
|
1542
|
+
: undefined
|
|
1543
|
+
}
|
|
1544
|
+
/>
|
|
1545
|
+
</div>
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1457
1549
|
return (
|
|
1458
1550
|
// @container:底栏元素的显隐/宽度用容器查询(@sm = 384px)而不是
|
|
1459
1551
|
// 视口断点——侧边栏占宽后,视口够宽但输入框本身可能已经很窄。
|
|
@@ -1708,11 +1800,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1708
1800
|
))}
|
|
1709
1801
|
</div>
|
|
1710
1802
|
)}
|
|
1711
|
-
<div
|
|
1803
|
+
<div
|
|
1804
|
+
ref={toolbarRowRef}
|
|
1805
|
+
className="flex items-center justify-between gap-2 px-2 pb-2 pt-1"
|
|
1806
|
+
>
|
|
1712
1807
|
{/* Left toolbar: mode + exec sandbox + host extras + settings.
|
|
1713
1808
|
Agent picker lives in the meta row above the box, next to
|
|
1714
1809
|
the project badge. min-w-0 允许窄屏时胶囊截断收缩。 */}
|
|
1715
|
-
<div className="flex min-w-0 items-center gap-1">
|
|
1810
|
+
<div ref={toolbarLeftRef} className="flex min-w-0 items-center gap-1">
|
|
1716
1811
|
{mode && onModeChange && (
|
|
1717
1812
|
<ModeToggle
|
|
1718
1813
|
mode={mode}
|
|
@@ -1732,7 +1827,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1732
1827
|
type="button"
|
|
1733
1828
|
onClick={handlePickFiles}
|
|
1734
1829
|
disabled={disabled || isStreaming}
|
|
1735
|
-
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
1830
|
+
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
1736
1831
|
title="上传文件(可多选)"
|
|
1737
1832
|
aria-label="上传文件"
|
|
1738
1833
|
>
|
|
@@ -1752,7 +1847,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1752
1847
|
<button
|
|
1753
1848
|
type="button"
|
|
1754
1849
|
onClick={onOpenSettings}
|
|
1755
|
-
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
1850
|
+
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
1756
1851
|
title="LLM 设置"
|
|
1757
1852
|
aria-label="LLM 设置"
|
|
1758
1853
|
data-testid="chat-llm-settings"
|
|
@@ -1761,9 +1856,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1761
1856
|
</button>
|
|
1762
1857
|
)}
|
|
1763
1858
|
</div>
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1859
|
+
{/* shrink-0:发送键是固定尺寸圆钮,窄容器下不允许被压扁;
|
|
1860
|
+
收缩量由左侧可截断的选择器吸收。 */}
|
|
1861
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
1862
|
+
{/* 快捷键提示:空间不足时优先隐藏(测量逻辑见上方 effect) */}
|
|
1863
|
+
<div
|
|
1864
|
+
ref={hintRef}
|
|
1865
|
+
className={`${hintsSuppressed ? 'hidden' : 'block'} text-[11px] text-agent-muted-foreground/80`}
|
|
1866
|
+
>
|
|
1767
1867
|
{isStreaming ? (
|
|
1768
1868
|
<>
|
|
1769
1869
|
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
@@ -1815,15 +1915,16 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1815
1915
|
)}
|
|
1816
1916
|
</div>
|
|
1817
1917
|
<button
|
|
1918
|
+
ref={sendButtonRef}
|
|
1818
1919
|
type="button"
|
|
1819
1920
|
onClick={handleSendClick}
|
|
1820
1921
|
disabled={!isStreaming && !canSend}
|
|
1821
1922
|
className={
|
|
1822
1923
|
isStreaming
|
|
1823
|
-
? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
|
|
1924
|
+
? 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
|
|
1824
1925
|
: canSend
|
|
1825
|
-
? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-foreground text-agent-canvas transition hover:opacity-90'
|
|
1826
|
-
: 'flex h-8 w-8 cursor-not-allowed items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground'
|
|
1926
|
+
? 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-agent-foreground text-agent-canvas transition hover:opacity-90'
|
|
1927
|
+
: 'flex h-8 w-8 shrink-0 cursor-not-allowed items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground'
|
|
1827
1928
|
}
|
|
1828
1929
|
title={
|
|
1829
1930
|
isStreaming
|
|
@@ -1867,8 +1968,10 @@ function ModeToggle({
|
|
|
1867
1968
|
onChange: (mode: ChatMode) => void;
|
|
1868
1969
|
}) {
|
|
1869
1970
|
return (
|
|
1971
|
+
// shrink-0:分段控件被 flex 压缩会压扁文字;极窄容器(<@sm)退化为
|
|
1972
|
+
// 纯图标(title 兜底语义),把收缩量让给可截断的选择器。
|
|
1870
1973
|
<div
|
|
1871
|
-
className="inline-flex h-7 items-center rounded-full border border-agent-border bg-agent-canvas p-0.5"
|
|
1974
|
+
className="inline-flex h-7 shrink-0 items-center rounded-full border border-agent-border bg-agent-canvas p-0.5"
|
|
1872
1975
|
role="radiogroup"
|
|
1873
1976
|
aria-label="对话模式"
|
|
1874
1977
|
data-testid="mode-toggle"
|
|
@@ -1889,7 +1992,7 @@ function ModeToggle({
|
|
|
1889
1992
|
].join(' ')}
|
|
1890
1993
|
>
|
|
1891
1994
|
<LuInfinity className="h-3.5 w-3.5" />
|
|
1892
|
-
<span>Agent</span>
|
|
1995
|
+
<span className="@max-sm:hidden">Agent</span>
|
|
1893
1996
|
</button>
|
|
1894
1997
|
<button
|
|
1895
1998
|
type="button"
|
|
@@ -1907,7 +2010,7 @@ function ModeToggle({
|
|
|
1907
2010
|
].join(' ')}
|
|
1908
2011
|
>
|
|
1909
2012
|
<LuListChecks className="h-3.5 w-3.5" />
|
|
1910
|
-
<span>Plan</span>
|
|
2013
|
+
<span className="@max-sm:hidden">Plan</span>
|
|
1911
2014
|
</button>
|
|
1912
2015
|
</div>
|
|
1913
2016
|
);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
|
2
|
+
import { openLocalPath, type ResolvedLocalPath } from '@/lib/local-api';
|
|
3
|
+
import { peekResolvedPath, subscribeResolvedPath } from './path-mentions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 行内代码里的文件路径:后端确认存在后变成可点击,点击用系统默认应用打开。
|
|
7
|
+
*
|
|
8
|
+
* 未确认存在(或后端不可达)时渲染成与普通行内代码完全一致的外观——路径
|
|
9
|
+
* 靠形状判断挑出,误报难免,不能给点不开的假 affordance。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const INLINE_CODE_CLASS =
|
|
13
|
+
'rounded bg-agent-muted px-1 py-0.5 font-mono text-[0.85em] text-agent-foreground';
|
|
14
|
+
|
|
15
|
+
interface FilePathCodeProps extends ComponentPropsWithoutRef<'code'> {
|
|
16
|
+
/** 行内代码的纯文本,即待解析的路径字面量。 */
|
|
17
|
+
candidate: string;
|
|
18
|
+
/** 当前对话(相对路径按其绑定项目根解析),落地页可缺省。 */
|
|
19
|
+
chatId?: string | null;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function FilePathCode({ candidate, chatId, children, ...rest }: FilePathCodeProps) {
|
|
24
|
+
const [resolved, setResolved] = useState<ResolvedLocalPath | null>(
|
|
25
|
+
() => peekResolvedPath(candidate, chatId) ?? null,
|
|
26
|
+
);
|
|
27
|
+
const [openError, setOpenError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setOpenError(null);
|
|
31
|
+
return subscribeResolvedPath(candidate, chatId, (entry) => setResolved(entry));
|
|
32
|
+
}, [candidate, chatId]);
|
|
33
|
+
|
|
34
|
+
if (!resolved) {
|
|
35
|
+
return (
|
|
36
|
+
<code {...rest} className={INLINE_CODE_CLASS}>
|
|
37
|
+
{children}
|
|
38
|
+
</code>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const handleClick = async () => {
|
|
43
|
+
setOpenError(null);
|
|
44
|
+
try {
|
|
45
|
+
const result = await openLocalPath(resolved.path);
|
|
46
|
+
if (!result.success) setOpenError(result.error || '打开失败');
|
|
47
|
+
} catch (err) {
|
|
48
|
+
setOpenError(err instanceof Error ? err.message : '打开失败');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
data-file-path-chip=""
|
|
56
|
+
onClick={handleClick}
|
|
57
|
+
title={openError ? `${resolved.path}(${openError})` : `点击打开 ${resolved.path}`}
|
|
58
|
+
className={`${INLINE_CODE_CLASS} cursor-pointer underline decoration-dotted underline-offset-2 transition-colors hover:bg-agent-accent/15 hover:text-agent-accent ${
|
|
59
|
+
openError ? 'text-red-600 dark:text-red-400' : ''
|
|
60
|
+
}`}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</button>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -15,6 +15,7 @@ import { MessageList } from './MessageList';
|
|
|
15
15
|
import type { ExecutedAction } from './ExecutedActionsCard';
|
|
16
16
|
import type { ChildInfo } from './OrchestrationChildrenCard';
|
|
17
17
|
import type { TurnBlock } from './turn-timeline';
|
|
18
|
+
import type { TurnFile } from './turn-files';
|
|
18
19
|
import {
|
|
19
20
|
isImageFile,
|
|
20
21
|
saveChatAttachments,
|
|
@@ -97,6 +98,10 @@ export interface LocalChatPanelProps {
|
|
|
97
98
|
currentTurnTimeline?: TurnBlock[];
|
|
98
99
|
currentTurnStartedAtMs?: number;
|
|
99
100
|
durationByMessageId?: Record<string, number>;
|
|
101
|
+
/** 回合产物文件列表(按落库消息 id 键控),转发给 MessageList。 */
|
|
102
|
+
turnFilesByMessageId?: Record<string, TurnFile[]>;
|
|
103
|
+
/** 当轮产物文件(流尾声到达、尚未归档到落库 id 的尾部消息用)。 */
|
|
104
|
+
currentTurnFiles?: TurnFile[];
|
|
100
105
|
/** P3.1: in-flight child agents + per-message reconciliation map. */
|
|
101
106
|
currentTurnChildren?: ChildInfo[];
|
|
102
107
|
orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
|
|
@@ -141,6 +146,11 @@ export interface LocalChatPanelProps {
|
|
|
141
146
|
onDismissFinishedTask?: (taskId: string) => void;
|
|
142
147
|
/** 分享当前对话(截图)。落到最近一条助手消息的时间戳行。 */
|
|
143
148
|
onShare?: () => Promise<boolean>;
|
|
149
|
+
/**
|
|
150
|
+
* 最近一条助手回复下的下一轮输入建议。点击即作为新用户消息发出。
|
|
151
|
+
*/
|
|
152
|
+
suggestedReplies?: string[];
|
|
153
|
+
onSelectSuggestion?: (text: string) => void;
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
export function LocalChatPanel({
|
|
@@ -166,6 +176,8 @@ export function LocalChatPanel({
|
|
|
166
176
|
currentTurnTimeline,
|
|
167
177
|
currentTurnStartedAtMs,
|
|
168
178
|
durationByMessageId,
|
|
179
|
+
turnFilesByMessageId,
|
|
180
|
+
currentTurnFiles,
|
|
169
181
|
currentTurnChildren,
|
|
170
182
|
orchestrationChildrenByMessageId,
|
|
171
183
|
currentRound,
|
|
@@ -189,6 +201,8 @@ export function LocalChatPanel({
|
|
|
189
201
|
onInspectTask,
|
|
190
202
|
onDismissFinishedTask,
|
|
191
203
|
onShare,
|
|
204
|
+
suggestedReplies,
|
|
205
|
+
onSelectSuggestion,
|
|
192
206
|
}: LocalChatPanelProps) {
|
|
193
207
|
const [inputValue, setInputValue] = useState('');
|
|
194
208
|
const [files, setFiles] = useState<AttachmentFile[]>([]);
|
|
@@ -374,6 +388,7 @@ export function LocalChatPanel({
|
|
|
374
388
|
isStreaming={isStreaming}
|
|
375
389
|
agents={agents}
|
|
376
390
|
chats={chats}
|
|
391
|
+
chatId={chatId}
|
|
377
392
|
currentAgent={currentAgent}
|
|
378
393
|
executedActionsByMessageId={executedActionsByMessageId}
|
|
379
394
|
currentTurnActions={currentTurnActions}
|
|
@@ -381,6 +396,8 @@ export function LocalChatPanel({
|
|
|
381
396
|
currentTurnTimeline={currentTurnTimeline}
|
|
382
397
|
currentTurnStartedAtMs={currentTurnStartedAtMs}
|
|
383
398
|
durationByMessageId={durationByMessageId}
|
|
399
|
+
turnFilesByMessageId={turnFilesByMessageId}
|
|
400
|
+
currentTurnFiles={currentTurnFiles}
|
|
384
401
|
currentTurnChildren={currentTurnChildren}
|
|
385
402
|
orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
|
|
386
403
|
currentRound={currentRound}
|
|
@@ -394,6 +411,8 @@ export function LocalChatPanel({
|
|
|
394
411
|
onInspectTask={onInspectTask}
|
|
395
412
|
onDismissFinishedTask={onDismissFinishedTask}
|
|
396
413
|
onShare={onShare}
|
|
414
|
+
suggestedReplies={suggestedReplies}
|
|
415
|
+
onSelectSuggestion={onSelectSuggestion}
|
|
397
416
|
/>
|
|
398
417
|
{inputBanner}
|
|
399
418
|
{chatInputNode}
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
type SkillItem,
|
|
19
19
|
} from '@/lib/slash-sources';
|
|
20
20
|
import { PlanTodoList } from './PlanTodoList';
|
|
21
|
+
import { FilePathCode } from './FilePathCode';
|
|
22
|
+
import { looksLikeFilePath } from './path-mentions';
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Markdown renderer shared by `UserMessage` and `AssistantMessage`.
|
|
@@ -146,7 +148,31 @@ const components: Components = {
|
|
|
146
148
|
),
|
|
147
149
|
|
|
148
150
|
// ── Code (inline + block) ─────────────────────────────────────────────
|
|
149
|
-
code: (
|
|
151
|
+
code: renderCode(null),
|
|
152
|
+
pre: (props: ComponentPropsWithoutRef<'pre'>) => {
|
|
153
|
+
// A ```plan fence arrives as <pre><code class="language-plan">…</code></pre>.
|
|
154
|
+
// The code renderer above already swaps the inner node for PlanTodoList;
|
|
155
|
+
// here we drop the <pre> shell so the card isn't boxed in a code block.
|
|
156
|
+
if (containsPlanCode(props.children)) {
|
|
157
|
+
return <>{props.children}</>;
|
|
158
|
+
}
|
|
159
|
+
return (
|
|
160
|
+
<pre
|
|
161
|
+
{...props}
|
|
162
|
+
className="my-2 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2.5 text-[12px] leading-relaxed text-agent-foreground"
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 行内代码 / 代码块渲染器。`chatId` 只影响行内代码:形状上像本地路径的
|
|
170
|
+
* 字面量交给 FilePathCode,由它经后端确认存在后变成可点击(相对路径按
|
|
171
|
+
* 该对话绑定的项目根解析)。代码块内的路径不参与——整块代码是给人读/
|
|
172
|
+
* 复制的,逐 token 挑路径会把块结构打散。
|
|
173
|
+
*/
|
|
174
|
+
function renderCode(chatId: string | null): Components['code'] {
|
|
175
|
+
return (props) => {
|
|
150
176
|
const { children, className, ...rest } = props as ComponentPropsWithoutRef<'code'> & {
|
|
151
177
|
// react-markdown v9 dropped the `inline` typed prop. Distinguish by
|
|
152
178
|
// language fence (the only time class is set is for fenced blocks
|
|
@@ -167,6 +193,14 @@ const components: Components = {
|
|
|
167
193
|
</code>
|
|
168
194
|
);
|
|
169
195
|
}
|
|
196
|
+
const text = childrenToText(children);
|
|
197
|
+
if (looksLikeFilePath(text)) {
|
|
198
|
+
return (
|
|
199
|
+
<FilePathCode {...rest} candidate={text.trim()} chatId={chatId}>
|
|
200
|
+
{children}
|
|
201
|
+
</FilePathCode>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
170
204
|
return (
|
|
171
205
|
<code
|
|
172
206
|
{...rest}
|
|
@@ -175,22 +209,8 @@ const components: Components = {
|
|
|
175
209
|
{children}
|
|
176
210
|
</code>
|
|
177
211
|
);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// A ```plan fence arrives as <pre><code class="language-plan">…</code></pre>.
|
|
181
|
-
// The code renderer above already swaps the inner node for PlanTodoList;
|
|
182
|
-
// here we drop the <pre> shell so the card isn't boxed in a code block.
|
|
183
|
-
if (containsPlanCode(props.children)) {
|
|
184
|
-
return <>{props.children}</>;
|
|
185
|
-
}
|
|
186
|
-
return (
|
|
187
|
-
<pre
|
|
188
|
-
{...props}
|
|
189
|
-
className="my-2 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2.5 text-[12px] leading-relaxed text-agent-foreground"
|
|
190
|
-
/>
|
|
191
|
-
);
|
|
192
|
-
},
|
|
193
|
-
};
|
|
212
|
+
};
|
|
213
|
+
}
|
|
194
214
|
|
|
195
215
|
function isPlanCodeClass(className: unknown): boolean {
|
|
196
216
|
return typeof className === 'string' && className.split(/\s+/).includes('language-plan');
|
|
@@ -359,6 +379,11 @@ interface MarkdownProps {
|
|
|
359
379
|
skills?: SkillItem[];
|
|
360
380
|
/** MCP tools behind `/mcp__srv__tool` chips. */
|
|
361
381
|
mcpTools?: McpToolItem[];
|
|
382
|
+
/**
|
|
383
|
+
* 当前对话。行内代码里的路径要按它绑定的项目根解析相对路径;缺省时
|
|
384
|
+
* 相对路径按 home 解析(同无项目对话里 exec 的缺省 cwd)。
|
|
385
|
+
*/
|
|
386
|
+
chatId?: string | null;
|
|
362
387
|
}
|
|
363
388
|
|
|
364
389
|
/**
|
|
@@ -401,15 +426,19 @@ export function Markdown({
|
|
|
401
426
|
chats = [],
|
|
402
427
|
skills = [],
|
|
403
428
|
mcpTools = [],
|
|
429
|
+
chatId = null,
|
|
404
430
|
}: MarkdownProps) {
|
|
405
|
-
const baseComponents: Components =
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
431
|
+
const baseComponents: Components = {
|
|
432
|
+
...components,
|
|
433
|
+
code: renderCode(chatId),
|
|
434
|
+
...(inlineParagraph
|
|
435
|
+
? {
|
|
436
|
+
p: (props: ComponentPropsWithoutRef<'p'>) => (
|
|
437
|
+
<span style={{ whiteSpace: 'pre-line' }} {...props} />
|
|
438
|
+
),
|
|
439
|
+
}
|
|
440
|
+
: {}),
|
|
441
|
+
};
|
|
413
442
|
|
|
414
443
|
const finalComponents = withTokens(baseComponents, (node) =>
|
|
415
444
|
renderTextWithMentions(node, agents, chats, skills, mcpTools),
|
|
@@ -13,10 +13,12 @@ import { UserMessage } from './UserMessage';
|
|
|
13
13
|
import { AssistantMessage } from './AssistantMessage';
|
|
14
14
|
import { InterruptedTurnCard } from './InterruptedTurnCard';
|
|
15
15
|
import { TaskOutcomeCards } from './TaskOutcomeCards';
|
|
16
|
+
import { SuggestedReplies } from './SuggestedReplies';
|
|
16
17
|
import type { ExecutedAction } from './ExecutedActionsCard';
|
|
17
18
|
import type { ChildInfo } from './OrchestrationChildrenCard';
|
|
18
19
|
import type { ChatMode } from './ChatInput';
|
|
19
20
|
import type { TurnBlock } from './turn-timeline';
|
|
21
|
+
import type { TurnFile } from './turn-files';
|
|
20
22
|
import { inferDurationMs, readPersistedDurationMs } from './elapsed';
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -55,6 +57,11 @@ interface MessageListProps {
|
|
|
55
57
|
emptyState?: ReactNode;
|
|
56
58
|
agents: LocalChatAgent[];
|
|
57
59
|
chats?: LocalChat[];
|
|
60
|
+
/**
|
|
61
|
+
* 当前对话。正文行内代码里的路径按其绑定项目根解析后,确认存在的变成
|
|
62
|
+
* 可点击(见 FilePathCode)。
|
|
63
|
+
*/
|
|
64
|
+
chatId?: string | null;
|
|
58
65
|
currentAgent: LocalChatAgent | null;
|
|
59
66
|
/**
|
|
60
67
|
* Tool calls keyed by the persisted message id (post-stream). Used for
|
|
@@ -73,6 +80,17 @@ interface MessageListProps {
|
|
|
73
80
|
currentTurnStartedAtMs?: number;
|
|
74
81
|
/** Frozen duration keyed by assistant message id (live freeze + history). */
|
|
75
82
|
durationByMessageId?: Record<string, number>;
|
|
83
|
+
/**
|
|
84
|
+
* 回合产物文件列表,按落库消息 id 键控(live 回合在 message_id 事件时
|
|
85
|
+
* 归档;历史回合从 messageMetadata.turnFiles 水合)。
|
|
86
|
+
*/
|
|
87
|
+
turnFilesByMessageId?: Record<string, TurnFile[]>;
|
|
88
|
+
/**
|
|
89
|
+
* 当轮产物文件:turn_files 事件在流尾声到达,此时尾部助手消息仍挂着
|
|
90
|
+
* 占位 id(框架不会在 message_id 后改写它),归档 map 按键查不到——
|
|
91
|
+
* 与 currentTurnActions 同款尾部回退。
|
|
92
|
+
*/
|
|
93
|
+
currentTurnFiles?: TurnFile[];
|
|
76
94
|
/**
|
|
77
95
|
* Tool calls accumulated for the in-flight assistant message that the
|
|
78
96
|
* backend hasn't assigned a DB id to yet. Rendered under the latest
|
|
@@ -116,6 +134,11 @@ interface MessageListProps {
|
|
|
116
134
|
onDismissFinishedTask?: (taskId: string) => void;
|
|
117
135
|
/** 分享当前对话(截图)。只画在最近一条助手消息的时间戳行上。 */
|
|
118
136
|
onShare?: () => Promise<boolean>;
|
|
137
|
+
/**
|
|
138
|
+
* 最近一条助手回复下的下一轮输入建议(WorkBuddy 式)。只在非流式时渲染。
|
|
139
|
+
*/
|
|
140
|
+
suggestedReplies?: string[];
|
|
141
|
+
onSelectSuggestion?: (text: string) => void;
|
|
119
142
|
}
|
|
120
143
|
|
|
121
144
|
export function MessageList({
|
|
@@ -124,6 +147,7 @@ export function MessageList({
|
|
|
124
147
|
emptyState,
|
|
125
148
|
agents,
|
|
126
149
|
chats = [],
|
|
150
|
+
chatId = null,
|
|
127
151
|
currentAgent,
|
|
128
152
|
executedActionsByMessageId,
|
|
129
153
|
currentTurnActions,
|
|
@@ -131,6 +155,8 @@ export function MessageList({
|
|
|
131
155
|
currentTurnTimeline,
|
|
132
156
|
currentTurnStartedAtMs,
|
|
133
157
|
durationByMessageId,
|
|
158
|
+
turnFilesByMessageId,
|
|
159
|
+
currentTurnFiles,
|
|
134
160
|
currentTurnChildren,
|
|
135
161
|
orchestrationChildrenByMessageId,
|
|
136
162
|
currentRound,
|
|
@@ -143,6 +169,8 @@ export function MessageList({
|
|
|
143
169
|
onInspectTask,
|
|
144
170
|
onDismissFinishedTask,
|
|
145
171
|
onShare,
|
|
172
|
+
suggestedReplies,
|
|
173
|
+
onSelectSuggestion,
|
|
146
174
|
}: MessageListProps) {
|
|
147
175
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
148
176
|
const endRef = useRef<HTMLDivElement>(null);
|
|
@@ -196,10 +224,11 @@ export function MessageList({
|
|
|
196
224
|
const lastTimelineSig = currentTurnTimeline
|
|
197
225
|
?.map((block) => (block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`))
|
|
198
226
|
.join('|') ?? '';
|
|
227
|
+
const suggestedSig = suggestedReplies?.join('\0') ?? '';
|
|
199
228
|
useEffect(() => {
|
|
200
229
|
if (!isAtBottom) return;
|
|
201
230
|
scrollToBottom('smooth');
|
|
202
|
-
}, [lastMessageId, lastContentLen, lastTimelineSig, isAtBottom, scrollToBottom]);
|
|
231
|
+
}, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, isAtBottom, scrollToBottom]);
|
|
203
232
|
|
|
204
233
|
return (
|
|
205
234
|
<div className="relative flex-1 overflow-hidden">
|
|
@@ -253,6 +282,12 @@ export function MessageList({
|
|
|
253
282
|
const turnTimeline = persistedTimeline
|
|
254
283
|
?? (isStreamingTail || isCurrentTimelineTail ? currentTurnTimeline : undefined);
|
|
255
284
|
|
|
285
|
+
const persistedTurnFiles = turnFilesByMessageId?.[message.id];
|
|
286
|
+
const isTurnFilesTail =
|
|
287
|
+
isLast && currentTurnFiles !== undefined && currentTurnFiles.length > 0;
|
|
288
|
+
const turnFiles = persistedTurnFiles
|
|
289
|
+
?? (isTurnFilesTail ? currentTurnFiles : undefined);
|
|
290
|
+
|
|
256
291
|
const metadataJson =
|
|
257
292
|
typeof message.messageMetadata === 'string'
|
|
258
293
|
? message.messageMetadata
|
|
@@ -271,23 +306,36 @@ export function MessageList({
|
|
|
271
306
|
?? inferDurationMs(previousUserCreatedAt, message.createdAt);
|
|
272
307
|
|
|
273
308
|
return (
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
309
|
+
<div key={message.id}>
|
|
310
|
+
<AssistantMessage
|
|
311
|
+
message={message}
|
|
312
|
+
isStreaming={isStreamingTail}
|
|
313
|
+
agents={agents}
|
|
314
|
+
chats={chats}
|
|
315
|
+
chatId={chatId}
|
|
316
|
+
currentAgent={currentAgent}
|
|
317
|
+
executedActions={actions}
|
|
318
|
+
timeline={turnTimeline}
|
|
319
|
+
orchestrationChildren={childList}
|
|
320
|
+
currentRound={isStreamingTail ? currentRound : undefined}
|
|
321
|
+
isPlanMode={isPlanMode}
|
|
322
|
+
onRegenerate={onRegenerate}
|
|
323
|
+
startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
|
|
324
|
+
durationMs={durationMs}
|
|
325
|
+
turnFiles={turnFiles}
|
|
326
|
+
onShare={message.id === lastAssistantId ? onShare : undefined}
|
|
327
|
+
/>
|
|
328
|
+
{!isStreaming &&
|
|
329
|
+
message.id === lastAssistantId &&
|
|
330
|
+
suggestedReplies &&
|
|
331
|
+
suggestedReplies.length > 0 &&
|
|
332
|
+
onSelectSuggestion ? (
|
|
333
|
+
<SuggestedReplies
|
|
334
|
+
suggestions={suggestedReplies}
|
|
335
|
+
onSelect={onSelectSuggestion}
|
|
336
|
+
/>
|
|
337
|
+
) : null}
|
|
338
|
+
</div>
|
|
291
339
|
);
|
|
292
340
|
})}
|
|
293
341
|
{/* W7-1: 中断提示卡在消息列尾部、与最后一条用户消息同列——
|
|
@@ -104,7 +104,9 @@ export function ModelPicker({
|
|
|
104
104
|
|
|
105
105
|
return (
|
|
106
106
|
<div ref={rootRef} className="flex min-w-0 items-center gap-1">
|
|
107
|
-
|
|
107
|
+
{/* flex + min-w-0 截断链:窄容器下模型 id 省略号收缩,而不是把
|
|
108
|
+
旁边的圆形图标按钮压扁。 */}
|
|
109
|
+
<div className="relative flex min-w-0">
|
|
108
110
|
<button
|
|
109
111
|
type="button"
|
|
110
112
|
onClick={() => {
|
|
@@ -115,7 +117,7 @@ export function ModelPicker({
|
|
|
115
117
|
if (next && status !== 'live' && !loading) void refresh();
|
|
116
118
|
}}
|
|
117
119
|
disabled={disabled}
|
|
118
|
-
className="inline-flex h-7 max-w-[160px] items-center gap-1.5 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
|
|
120
|
+
className="inline-flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
|
|
119
121
|
title={
|
|
120
122
|
status === 'offline'
|
|
121
123
|
? `模型目录不可用${catalog?.error ? `:${catalog.error}` : ''}(仍可手动指定模型)`
|
|
@@ -124,7 +126,7 @@ export function ModelPicker({
|
|
|
124
126
|
aria-haspopup="menu"
|
|
125
127
|
aria-expanded={modelMenuOpen}
|
|
126
128
|
>
|
|
127
|
-
<span className="truncate">{effectiveModel || '选择模型'}</span>
|
|
129
|
+
<span className="min-w-0 truncate">{effectiveModel || '选择模型'}</span>
|
|
128
130
|
{model != null && (
|
|
129
131
|
<span className="inline-flex shrink-0 items-center rounded bg-agent-foreground/10 px-1 py-0.5 text-[9px] font-medium text-agent-foreground">
|
|
130
132
|
本会话
|