@steerable/agent-shell-web 0.6.66 → 0.6.68
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 +4 -4
- package/src/components/AgentSidebar.tsx +60 -25
- package/src/components/chat/ChatInput.tsx +28 -7
- package/src/components/chat/LocalChatPanel.tsx +4 -4
- package/src/hooks/useChatsAndAgents.ts +45 -1
- package/src/layouts/AgentLayout.tsx +14 -0
- package/src/lib/browser-dev-host-mock.ts +1 -0
- package/src/lib/host-bridge.ts +7 -0
- package/src/lib/host-paste.ts +23 -8
- package/src/lib/http-bridge.ts +3 -0
- package/src/lib/sidebar-order.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.68",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"react-router-dom": "^6.30.0",
|
|
46
46
|
"rehype-raw": "^7.0.0",
|
|
47
47
|
"remark-gfm": "^4.0.1",
|
|
48
|
-
"@steerable/agent-protocol": "0.6.
|
|
49
|
-
"@steerable/agent-ui": "0.6.
|
|
48
|
+
"@steerable/agent-protocol": "0.6.68",
|
|
49
|
+
"@steerable/agent-ui": "0.6.68"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@tailwindcss/vite": "^4.3.0",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"typescript": "^5.8.3",
|
|
60
60
|
"vite": "^5.4.21",
|
|
61
61
|
"vitest": "^3.2.4",
|
|
62
|
-
"@steerable/pack-sdk": "0.6.
|
|
62
|
+
"@steerable/pack-sdk": "0.6.68"
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
65
|
"gen": "echo agent-shell-web",
|
|
@@ -114,6 +114,7 @@ import {
|
|
|
114
114
|
nextProjectOrder,
|
|
115
115
|
type ProjectDropPlace,
|
|
116
116
|
} from "@/lib/project-order";
|
|
117
|
+
import { chatActivityTime, latestActivityTime } from "@/lib/sidebar-order";
|
|
117
118
|
import type { RightPanelState } from "@/layouts/AgentLayout";
|
|
118
119
|
import { BrandLockup } from "@/components/BrandLockup";
|
|
119
120
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
@@ -389,6 +390,10 @@ export function AgentSidebar({
|
|
|
389
390
|
const [streamingChatIds, setStreamingChatIds] = useState<Set<string>>(
|
|
390
391
|
() => new Set(),
|
|
391
392
|
);
|
|
393
|
+
// 回合已经开始、列表里的 updatedAt 还没刷新时,用这个时间把会话抬到前面。
|
|
394
|
+
const [liveActivityAt, setLiveActivityAt] = useState<ReadonlyMap<string, number>>(
|
|
395
|
+
() => new Map(),
|
|
396
|
+
);
|
|
392
397
|
const pendingAskUserChatIds = usePendingAskUserChatIds();
|
|
393
398
|
const pendingApprovalChatIds = usePendingApprovalChatIds();
|
|
394
399
|
|
|
@@ -409,6 +414,14 @@ export function AgentSidebar({
|
|
|
409
414
|
}
|
|
410
415
|
return next;
|
|
411
416
|
});
|
|
417
|
+
if (isStreaming) {
|
|
418
|
+
setLiveActivityAt((prev) => {
|
|
419
|
+
if (prev.has(chatId)) return prev;
|
|
420
|
+
const next = new Map(prev);
|
|
421
|
+
next.set(chatId, Date.now());
|
|
422
|
+
return next;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
412
425
|
};
|
|
413
426
|
window.addEventListener("chat:streaming-change", handleStreamingChange);
|
|
414
427
|
return () => {
|
|
@@ -419,6 +432,25 @@ export function AgentSidebar({
|
|
|
419
432
|
};
|
|
420
433
|
}, []);
|
|
421
434
|
|
|
435
|
+
// 列表里的 updatedAt 追上本地时间后丢掉抬升,避免旧回合一直占在最前。
|
|
436
|
+
useEffect(() => {
|
|
437
|
+
setLiveActivityAt((prev) => {
|
|
438
|
+
if (prev.size === 0) return prev;
|
|
439
|
+
let changed = false;
|
|
440
|
+
const next = new Map(prev);
|
|
441
|
+
for (const [id, stamp] of prev) {
|
|
442
|
+
if (streamingChatIds.has(id)) continue;
|
|
443
|
+
const chat = chats.find((item) => item.id === id);
|
|
444
|
+
const updated = chat?.updatedAt ? Date.parse(chat.updatedAt) : Number.NaN;
|
|
445
|
+
if (!chat || (Number.isFinite(updated) && updated >= stamp)) {
|
|
446
|
+
next.delete(id);
|
|
447
|
+
changed = true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return changed ? next : prev;
|
|
451
|
+
});
|
|
452
|
+
}, [chats, streamingChatIds]);
|
|
453
|
+
|
|
422
454
|
// 当前打开的会话由 AgentChatView 自己报开始/结束。切走后的会话回合还在
|
|
423
455
|
// 后端跑,卸载时不会再报结束,这里用 live-stream 对账,结束后摘掉指示。
|
|
424
456
|
const seenActiveBackgroundRef = useRef<Set<string>>(new Set());
|
|
@@ -752,35 +784,31 @@ export function AgentSidebar({
|
|
|
752
784
|
const normalizedChats = useMemo(() => {
|
|
753
785
|
return chats
|
|
754
786
|
.map((chat) => {
|
|
755
|
-
const updated = chat.updatedAt ? new Date(chat.updatedAt) : null;
|
|
756
|
-
const created = new Date(chat.createdAt);
|
|
757
|
-
const sortDate =
|
|
758
|
-
updated && !Number.isNaN(updated.getTime())
|
|
759
|
-
? updated
|
|
760
|
-
: !Number.isNaN(created.getTime())
|
|
761
|
-
? created
|
|
762
|
-
: new Date();
|
|
763
787
|
const { displayTitle, isAutomation } = parseChatTitle(chat.title);
|
|
764
788
|
return {
|
|
765
789
|
id: chat.id,
|
|
766
790
|
title: displayTitle || t("New conversation"),
|
|
767
791
|
isAutomation,
|
|
768
792
|
isPinned: chat.isPinned,
|
|
769
|
-
sortDate
|
|
793
|
+
sortDate: new Date(
|
|
794
|
+
chatActivityTime(
|
|
795
|
+
chat.updatedAt,
|
|
796
|
+
chat.createdAt,
|
|
797
|
+
liveActivityAt.get(chat.id),
|
|
798
|
+
),
|
|
799
|
+
),
|
|
770
800
|
agentId: chat.agentId ?? null,
|
|
771
801
|
projectId: chat.projectId ?? null,
|
|
772
802
|
isStreaming: chat.isStreaming,
|
|
773
803
|
needsUserInput: chat.needsUserInput,
|
|
774
804
|
};
|
|
775
805
|
})
|
|
776
|
-
.sort((a, b) =>
|
|
777
|
-
|
|
778
|
-
return b.sortDate.getTime() - a.sortDate.getTime();
|
|
779
|
-
});
|
|
780
|
-
}, [chats]);
|
|
806
|
+
.sort((a, b) => b.sortDate.getTime() - a.sortDate.getTime());
|
|
807
|
+
}, [chats, liveActivityAt]);
|
|
781
808
|
|
|
782
809
|
// 顶层按项目分组:无项目对话(含项目已被删但列表还没刷新的孤儿会话)
|
|
783
|
-
//
|
|
810
|
+
// 保持日期分组。会话按最后活动时间新到旧。项目按组内最近一次会话排序,
|
|
811
|
+
// 没有会话的项目留在后面,并保持它们原来的相对顺序。
|
|
784
812
|
const knownProjectIds = useMemo(
|
|
785
813
|
() =>
|
|
786
814
|
showProjectsChrome
|
|
@@ -797,16 +825,23 @@ export function AgentSidebar({
|
|
|
797
825
|
[normalizedChats, knownProjectIds],
|
|
798
826
|
);
|
|
799
827
|
|
|
800
|
-
const projectGroups = useMemo(
|
|
801
|
-
()
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
828
|
+
const projectGroups = useMemo(() => {
|
|
829
|
+
if (!showProjectsChrome) return [];
|
|
830
|
+
return projects
|
|
831
|
+
.map((project, index) => ({
|
|
832
|
+
project,
|
|
833
|
+
index,
|
|
834
|
+
items: normalizedChats.filter((c) => c.projectId === project.id),
|
|
835
|
+
}))
|
|
836
|
+
.sort((a, b) => {
|
|
837
|
+
const byActivity =
|
|
838
|
+
latestActivityTime(b.items.map((item) => item.sortDate.getTime())) -
|
|
839
|
+
latestActivityTime(a.items.map((item) => item.sortDate.getTime()));
|
|
840
|
+
if (byActivity !== 0) return byActivity;
|
|
841
|
+
return a.index - b.index;
|
|
842
|
+
})
|
|
843
|
+
.map(({ project, items }) => ({ project, items }));
|
|
844
|
+
}, [projects, normalizedChats, showProjectsChrome]);
|
|
810
845
|
|
|
811
846
|
const pinnedChats = useMemo(
|
|
812
847
|
() => normalizedChats.filter((c) => c.isPinned),
|
|
@@ -32,7 +32,13 @@ import {
|
|
|
32
32
|
import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
|
|
33
33
|
import type { ExecPolicy } from '@/lib/exec-policy';
|
|
34
34
|
import { getWebChatModes, hostToolCapability, settingsChrome } from '@/lib/host-tools';
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
hostClipboardAvailable,
|
|
37
|
+
releaseComposerPasteTarget,
|
|
38
|
+
requestHostPaste,
|
|
39
|
+
setComposerPasteTarget,
|
|
40
|
+
type HostClipboardFile,
|
|
41
|
+
} from '@/lib/host-paste';
|
|
36
42
|
import { attachmentFromPath, type AttachmentFile } from '@/lib/attachments';
|
|
37
43
|
import { dropPointHitsRect, hostFileDropAvailable, listenHostFileDrop } from '@/lib/host-file-drop';
|
|
38
44
|
import type { SteerOutcome } from '@steerable/agent-ui';
|
|
@@ -510,10 +516,17 @@ function getSelectionOffsets(root: HTMLDivElement): { start: number; end: number
|
|
|
510
516
|
return measureRange.toString().length;
|
|
511
517
|
};
|
|
512
518
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
519
|
+
try {
|
|
520
|
+
return {
|
|
521
|
+
start: getOffset(range.startContainer, range.startOffset),
|
|
522
|
+
end: getOffset(range.endContainer, range.endOffset),
|
|
523
|
+
};
|
|
524
|
+
} catch {
|
|
525
|
+
// The caret can sit on the empty scaffold's <br> with an offset the
|
|
526
|
+
// range rejects. Paste still belongs at the end of the draft.
|
|
527
|
+
const end = getEditableText(root).length;
|
|
528
|
+
return { start: end, end };
|
|
529
|
+
}
|
|
517
530
|
}
|
|
518
531
|
|
|
519
532
|
function setCaretOffset(root: HTMLDivElement, offset: number) {
|
|
@@ -1149,6 +1162,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1149
1162
|
return;
|
|
1150
1163
|
}
|
|
1151
1164
|
|
|
1165
|
+
const wrote =
|
|
1166
|
+
editorText !== value ||
|
|
1167
|
+
pendingCaretOffset !== null ||
|
|
1168
|
+
(!value && !hasEmptyImeScaffold(editor));
|
|
1152
1169
|
if (editorText !== value || pendingCaretOffset !== null) {
|
|
1153
1170
|
writeEditorValue(
|
|
1154
1171
|
editor,
|
|
@@ -1167,8 +1184,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1167
1184
|
lastPushedValueRef.current = value;
|
|
1168
1185
|
updateEditorHeight(editor);
|
|
1169
1186
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1187
|
+
// replaceChildren drops the DOM selection. Without a caret inside the
|
|
1188
|
+
// field, WKWebView ignores Ctrl/Cmd+V after a send.
|
|
1189
|
+
if (focused && wrote) {
|
|
1190
|
+
setCaretOffset(editor, Math.min(pendingCaretOffset ?? value.length, value.length));
|
|
1172
1191
|
pendingCaretOffsetRef.current = null;
|
|
1173
1192
|
}
|
|
1174
1193
|
}, [value, mentionReferences, agents, chats, disabled, skills, mcpTools]);
|
|
@@ -1494,6 +1513,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1494
1513
|
useLayoutEffect(() => {
|
|
1495
1514
|
const editor = editorRef.current;
|
|
1496
1515
|
if (!editor) return;
|
|
1516
|
+
setComposerPasteTarget(editor);
|
|
1497
1517
|
const onCompositionStart = () => {
|
|
1498
1518
|
isComposingRef.current = true;
|
|
1499
1519
|
inputSyncGenRef.current += 1;
|
|
@@ -1519,6 +1539,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1519
1539
|
editor.addEventListener('hostpaste', onHostPaste);
|
|
1520
1540
|
editor.addEventListener('hostpastefiles', onHostPasteFiles);
|
|
1521
1541
|
return () => {
|
|
1542
|
+
releaseComposerPasteTarget(editor);
|
|
1522
1543
|
editor.removeEventListener('compositionstart', onCompositionStart, true);
|
|
1523
1544
|
editor.removeEventListener('hostpaste', onHostPaste);
|
|
1524
1545
|
editor.removeEventListener('hostpastefiles', onHostPasteFiles);
|
|
@@ -224,10 +224,13 @@ export function LocalChatPanel({
|
|
|
224
224
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
|
225
225
|
const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
|
|
226
226
|
const inputRef = useRef<ChatInputHandle>(null);
|
|
227
|
+
// 空会话首屏和对话底部各有一棵输入框。发出第一条消息会卸掉正在聚焦的那棵;
|
|
228
|
+
// 不重新聚焦的话,Ctrl/Cmd+V 没有落点。
|
|
229
|
+
const showEmptyHero = Boolean(emptyHero) && messages.length === 0 && !isStreaming;
|
|
227
230
|
|
|
228
231
|
useEffect(() => {
|
|
229
232
|
inputRef.current?.focusAtEnd();
|
|
230
|
-
}, [chatId]);
|
|
233
|
+
}, [chatId, showEmptyHero]);
|
|
231
234
|
|
|
232
235
|
const handleSelectPrompt = useCallback((prompt: string) => {
|
|
233
236
|
setInputValue(prompt);
|
|
@@ -315,9 +318,6 @@ export function LocalChatPanel({
|
|
|
315
318
|
}
|
|
316
319
|
}, [inputValue, files, mentionReferences, onSubmit, mode, execPolicy, chatId, selectedAgentId]);
|
|
317
320
|
|
|
318
|
-
// 空会话 hero:messages 为空且未在流式时整面板切到居中首屏布局。
|
|
319
|
-
const showEmptyHero = Boolean(emptyHero) && messages.length === 0 && !isStreaming;
|
|
320
|
-
|
|
321
321
|
const sessionTodos = useMemo(
|
|
322
322
|
() =>
|
|
323
323
|
resolveLatestSessionTodos({
|
|
@@ -58,6 +58,12 @@ export interface UseChatsAndAgentsResult {
|
|
|
58
58
|
* 找不到 id 时静默忽略。
|
|
59
59
|
*/
|
|
60
60
|
patchChatTitle: (chatId: string, title: string) => void;
|
|
61
|
+
/**
|
|
62
|
+
* 就地把某个 chat 的最后更新时间改掉(不打后端、不重新拉已在列表里的会话)。
|
|
63
|
+
* 用于 `chat-activity`:消息落库后侧边栏按这个时间重排。
|
|
64
|
+
* 当前已加载的页里没有这条会话时,退回刷新第一页。
|
|
65
|
+
*/
|
|
66
|
+
patchChatUpdatedAt: (chatId: string, updatedAt: string) => void;
|
|
61
67
|
/** True while a `loadMoreChats` call is in flight. */
|
|
62
68
|
isLoadingMoreChats: boolean;
|
|
63
69
|
/** True iff the last list-chats response reported more pages available. */
|
|
@@ -149,6 +155,43 @@ export function useChatsAndAgents(): UseChatsAndAgentsResult {
|
|
|
149
155
|
[list],
|
|
150
156
|
);
|
|
151
157
|
|
|
158
|
+
// 消息落库后的活动时间。叠在已加载列表上,避免整表刷新把分页打回第一页。
|
|
159
|
+
const [activityAt, setActivityAt] = useState<ReadonlyMap<string, string>>(() => new Map());
|
|
160
|
+
const chats = useMemo(() => {
|
|
161
|
+
if (activityAt.size === 0) return list.chats;
|
|
162
|
+
return list.chats.map((chat) => {
|
|
163
|
+
const next = activityAt.get(chat.id);
|
|
164
|
+
if (!next || chat.updatedAt >= next) return chat;
|
|
165
|
+
return { ...chat, updatedAt: next };
|
|
166
|
+
});
|
|
167
|
+
}, [activityAt, list.chats]);
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
setActivityAt((prev) => {
|
|
170
|
+
if (prev.size === 0) return prev;
|
|
171
|
+
let changed = false;
|
|
172
|
+
const next = new Map(prev);
|
|
173
|
+
for (const [id, stamp] of prev) {
|
|
174
|
+
const chat = list.chats.find((item) => item.id === id);
|
|
175
|
+
if (chat && chat.updatedAt >= stamp) {
|
|
176
|
+
next.delete(id);
|
|
177
|
+
changed = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return changed ? next : prev;
|
|
181
|
+
});
|
|
182
|
+
}, [list.chats]);
|
|
183
|
+
const patchChatUpdatedAt = useCallback((chatId: string, updatedAt: string) => {
|
|
184
|
+
const known = list.chats.some((chat) => chat.id === chatId);
|
|
185
|
+
setActivityAt((prev) => {
|
|
186
|
+
const current = prev.get(chatId);
|
|
187
|
+
if (current && current >= updatedAt) return prev;
|
|
188
|
+
const next = new Map(prev);
|
|
189
|
+
next.set(chatId, updatedAt);
|
|
190
|
+
return next;
|
|
191
|
+
});
|
|
192
|
+
if (!known) void list.refreshChats();
|
|
193
|
+
}, [list.chats, list.refreshChats]);
|
|
194
|
+
|
|
152
195
|
const deleteChat = useCallback(
|
|
153
196
|
async (chatId: string) => {
|
|
154
197
|
try {
|
|
@@ -164,7 +207,7 @@ export function useChatsAndAgents(): UseChatsAndAgentsResult {
|
|
|
164
207
|
);
|
|
165
208
|
|
|
166
209
|
return {
|
|
167
|
-
chats
|
|
210
|
+
chats,
|
|
168
211
|
agents: list.agents,
|
|
169
212
|
isLoading: list.isLoading,
|
|
170
213
|
error: mergedError,
|
|
@@ -175,6 +218,7 @@ export function useChatsAndAgents(): UseChatsAndAgentsResult {
|
|
|
175
218
|
createChat,
|
|
176
219
|
deleteChat,
|
|
177
220
|
patchChatTitle: list.patchChatTitle,
|
|
221
|
+
patchChatUpdatedAt,
|
|
178
222
|
isLoadingMoreChats: list.isLoadingMoreChats,
|
|
179
223
|
hasMoreChats: list.hasMoreChats,
|
|
180
224
|
loadMoreChats: list.loadMoreChats,
|
|
@@ -164,6 +164,10 @@ function AgentLayoutContent() {
|
|
|
164
164
|
useEffect(() => {
|
|
165
165
|
patchChatTitleRef.current = data.patchChatTitle;
|
|
166
166
|
}, [data.patchChatTitle]);
|
|
167
|
+
const patchChatUpdatedAtRef = useRef(data.patchChatUpdatedAt);
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
patchChatUpdatedAtRef.current = data.patchChatUpdatedAt;
|
|
170
|
+
}, [data.patchChatUpdatedAt]);
|
|
167
171
|
const refreshChatsRef = useRef(data.refreshChats);
|
|
168
172
|
useEffect(() => {
|
|
169
173
|
refreshChatsRef.current = data.refreshChats;
|
|
@@ -187,6 +191,16 @@ function AgentLayoutContent() {
|
|
|
187
191
|
});
|
|
188
192
|
return unsubscribe;
|
|
189
193
|
}, []);
|
|
194
|
+
// 消息落库后的活动时间。只改这条会话在内存里的 updatedAt,侧边栏按它重排。
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
const bridge = getHostBridge();
|
|
197
|
+
if (!bridge?.onChatActivity) return;
|
|
198
|
+
const unsubscribe = bridge.onChatActivity((payload) => {
|
|
199
|
+
if (!payload?.chatId || !payload.updatedAt) return;
|
|
200
|
+
patchChatUpdatedAtRef.current(payload.chatId, payload.updatedAt);
|
|
201
|
+
});
|
|
202
|
+
return unsubscribe;
|
|
203
|
+
}, []);
|
|
190
204
|
useEffect(() => {
|
|
191
205
|
const bridge = getHostBridge();
|
|
192
206
|
if (!bridge?.onStoreChanged) return;
|
|
@@ -651,6 +651,7 @@ export function installBrowserDevHostMock() {
|
|
|
651
651
|
onMenuOpenTerminal: () => undefined,
|
|
652
652
|
offMenuOpenTerminal: () => undefined,
|
|
653
653
|
onChatTitleUpdated: () => () => undefined,
|
|
654
|
+
onChatActivity: () => () => undefined,
|
|
654
655
|
onSuggestedReplies: (callback) => {
|
|
655
656
|
suggestedReplyListeners.add(callback);
|
|
656
657
|
return () => {
|
package/src/lib/host-bridge.ts
CHANGED
|
@@ -239,6 +239,13 @@ export interface HostBridge {
|
|
|
239
239
|
onChatCreated?: (
|
|
240
240
|
callback: (payload: { chatId: string; agentId?: string | null }) => void,
|
|
241
241
|
) => () => void;
|
|
242
|
+
/**
|
|
243
|
+
* 会话有了新的活动时间(写入消息或改会话)。载荷是 `{chatId, updatedAt}`。
|
|
244
|
+
* 侧边栏据此改内存里的时间并重排,不整表刷新。
|
|
245
|
+
*/
|
|
246
|
+
onChatActivity?: (
|
|
247
|
+
callback: (payload: { chatId: string; updatedAt: string }) => void,
|
|
248
|
+
) => () => void;
|
|
242
249
|
/**
|
|
243
250
|
* 另一个进程提交了宿主库。侧栏据此重新拉会话列表。
|
|
244
251
|
* 载荷为空;渲染端不依赖里面的字段。
|
package/src/lib/host-paste.ts
CHANGED
|
@@ -19,8 +19,22 @@ import { getHostBridge } from './host-bridge';
|
|
|
19
19
|
|
|
20
20
|
let pending = false;
|
|
21
21
|
let lastEditable: HTMLElement | null = null;
|
|
22
|
+
let composerPasteTarget: HTMLElement | null = null;
|
|
22
23
|
let installed = false;
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Remember the live chat composer. A menu paste uses it when no other field
|
|
27
|
+
* is focused, including right after the empty-hero composer unmounts.
|
|
28
|
+
*/
|
|
29
|
+
export function setComposerPasteTarget(element: HTMLElement): void {
|
|
30
|
+
composerPasteTarget = element;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Drop the composer target when that element unmounts. */
|
|
34
|
+
export function releaseComposerPasteTarget(element: HTMLElement): void {
|
|
35
|
+
if (composerPasteTarget === element) composerPasteTarget = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
/** Remember the field that should receive a menu paste after focus moves. */
|
|
25
39
|
export function installHostPaste(): void {
|
|
26
40
|
if (installed || typeof document === 'undefined') return;
|
|
@@ -87,12 +101,9 @@ export function hostClipboardAvailable(): boolean {
|
|
|
87
101
|
}
|
|
88
102
|
|
|
89
103
|
function isEditable(target: EventTarget | null): target is HTMLElement {
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
target instanceof HTMLTextAreaElement ||
|
|
94
|
-
(target instanceof HTMLElement && target.isContentEditable))
|
|
95
|
-
);
|
|
104
|
+
if (isTerminalInput(target) || !(target instanceof HTMLElement)) return false;
|
|
105
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return true;
|
|
106
|
+
return target.isContentEditable || target.getAttribute('contenteditable') === 'true';
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
/**
|
|
@@ -105,8 +116,12 @@ function isTerminalInput(target: EventTarget | null): boolean {
|
|
|
105
116
|
|
|
106
117
|
function focusedEditable(): HTMLElement | null {
|
|
107
118
|
if (isTerminalInput(document.activeElement)) return null;
|
|
108
|
-
|
|
109
|
-
if (
|
|
119
|
+
const active = document.activeElement;
|
|
120
|
+
if (active instanceof HTMLElement && active.isConnected && isEditable(active)) return active;
|
|
121
|
+
if (lastEditable?.isConnected && isEditable(lastEditable)) return lastEditable;
|
|
122
|
+
if (composerPasteTarget?.isConnected && isEditable(composerPasteTarget)) {
|
|
123
|
+
return composerPasteTarget;
|
|
124
|
+
}
|
|
110
125
|
return null;
|
|
111
126
|
}
|
|
112
127
|
|
package/src/lib/http-bridge.ts
CHANGED
|
@@ -286,6 +286,9 @@ export function createHttpBridge(): HostBridge {
|
|
|
286
286
|
onChatCreated: (callback) =>
|
|
287
287
|
subscribeChannel<{ chatId: string; agentId?: string | null }>('chat-created', callback),
|
|
288
288
|
|
|
289
|
+
onChatActivity: (callback) =>
|
|
290
|
+
subscribeChannel<{ chatId: string; updatedAt: string }>('chat-activity', callback),
|
|
291
|
+
|
|
289
292
|
onStoreChanged: (callback) => subscribeChannel('store:changed', () => callback()),
|
|
290
293
|
|
|
291
294
|
onTaskUpdated: (callback) =>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧栏会话与项目的显示顺序。
|
|
3
|
+
* 会话按最后活动时间新到旧;项目按组内最近一次会话的活动时间,没有会话的项目留在后面。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 会话的排序时间。有效的 `updatedAt` 优先,否则用 `createdAt`。
|
|
8
|
+
* `liveAt` 是进行中回合在列表刷新前的本地时间,只在比已保存时间更新时抬高。
|
|
9
|
+
*/
|
|
10
|
+
export function chatActivityTime(
|
|
11
|
+
updatedAt: string | null | undefined,
|
|
12
|
+
createdAt: string | null | undefined,
|
|
13
|
+
liveAt?: number,
|
|
14
|
+
now = Date.now(),
|
|
15
|
+
): number {
|
|
16
|
+
const updated = updatedAt ? Date.parse(updatedAt) : Number.NaN;
|
|
17
|
+
const created = createdAt ? Date.parse(createdAt) : Number.NaN;
|
|
18
|
+
const persisted = Number.isFinite(updated)
|
|
19
|
+
? updated
|
|
20
|
+
: Number.isFinite(created)
|
|
21
|
+
? created
|
|
22
|
+
: now;
|
|
23
|
+
return liveAt !== undefined && Number.isFinite(liveAt) && liveAt > persisted
|
|
24
|
+
? liveAt
|
|
25
|
+
: persisted;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 一组会话里最近的活动时间。没有会话时为 0,这样空项目排在有对话的项目后面。 */
|
|
29
|
+
export function latestActivityTime(times: readonly number[]): number {
|
|
30
|
+
let latest = 0;
|
|
31
|
+
for (const time of times) {
|
|
32
|
+
if (time > latest) latest = time;
|
|
33
|
+
}
|
|
34
|
+
return latest;
|
|
35
|
+
}
|