@blocksdiy/react-common 1.40.0 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/new-agent-chat.d.ts +7 -0
- package/dist/components/new-agent-chat.d.ts.map +1 -1
- package/dist/components/new-agent-chat.js +22 -6
- package/dist/hooks/agent-thread-live-update.d.ts +72 -0
- package/dist/hooks/agent-thread-live-update.d.ts.map +1 -0
- package/dist/hooks/agent-thread-live-update.js +115 -0
- package/dist/hooks/use-agent-threads.d.ts +31 -20
- package/dist/hooks/use-agent-threads.d.ts.map +1 -1
- package/dist/hooks/use-agent-threads.js +224 -22
- package/package.json +1 -1
|
@@ -114,6 +114,13 @@ export interface AgentChatRootProps {
|
|
|
114
114
|
* between an app that can draw these pauses and one that cannot.
|
|
115
115
|
*/
|
|
116
116
|
supportsServerInterrupt?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Fires once a user send has been added to the conversation, with its text (empty
|
|
119
|
+
* string for attachment-only sends) — the seam for ChatGPT-style optimistic thread
|
|
120
|
+
* listing: hosts pair it with useAgentThreads' notifyThreadActivity. Covers sends
|
|
121
|
+
* routed through `sendMessage`, not the auto-sent `initialPrompt`.
|
|
122
|
+
*/
|
|
123
|
+
onUserMessageSent?: (text: string) => void;
|
|
117
124
|
}
|
|
118
125
|
export declare const AgentChatRoot: ({ ...props }: AgentChatRootProps) => import("react").JSX.Element | null;
|
|
119
126
|
export interface AgentChatMessageProps {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"new-agent-chat.d.ts","sourceRoot":"","sources":["../../src/components/new-agent-chat.tsx"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"new-agent-chat.d.ts","sourceRoot":"","sources":["../../src/components/new-agent-chat.tsx"],"names":[],"mappings":"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"}
|
|
@@ -242,7 +242,7 @@ const SessionReadables = () => {
|
|
|
242
242
|
// });
|
|
243
243
|
return null;
|
|
244
244
|
};
|
|
245
|
-
const ChatInitializer = ({ agentBlock, agentChatBlock, chatId, token, appId, components, currentThreadId, setCurrentThreadId, children, }) => {
|
|
245
|
+
const ChatInitializer = ({ agentBlock, agentChatBlock, chatId, token, appId, components, currentThreadId, setCurrentThreadId, children, onUserMessageSent, }) => {
|
|
246
246
|
const [agentChat, setAgentChat] = useState(null);
|
|
247
247
|
const [agent, setAgent] = useState(null);
|
|
248
248
|
// `isConfigLoaded` covers the (small, parallel) async fetch of the
|
|
@@ -388,9 +388,13 @@ const ChatInitializer = ({ agentBlock, agentChatBlock, chatId, token, appId, com
|
|
|
388
388
|
const attachmentsEnabled = agentChat ? agentChat.disableAttachments !== true : false;
|
|
389
389
|
const canSendMessage = Boolean(agentChatBlock && agentChat && agent && isConfigLoaded && isAgentReady && !isAgentRunning);
|
|
390
390
|
const isFetchingMessages = !isConfigLoaded || (!isAgentReady && copilotMessages.length === 0);
|
|
391
|
-
// CopilotKit reports `isLoading` during connect/replay as well as real runs.
|
|
392
|
-
//
|
|
393
|
-
|
|
391
|
+
// CopilotKit reports `isLoading` during connect/replay as well as real runs. Only expose
|
|
392
|
+
// thinking once the bootstrap connect phase has completed — and that needs the readiness
|
|
393
|
+
// latch, not just `isFetchingMessages`, which goes false as soon as ONE replayed message
|
|
394
|
+
// exists: without `isAgentReady`, thinking leaks true for the tail of every replay, and
|
|
395
|
+
// every load or thread switch flashes the typing dots under the transcript. Real runs are
|
|
396
|
+
// unaffected — sending requires readiness (canSendMessage), so the latch is already set.
|
|
397
|
+
const isThinking = isAgentRunning && !isFetchingMessages && isAgentReady;
|
|
394
398
|
const runAgentWithRetry = useCallback(async (agentToRun, messageCountAtRunStart, forwardedProps) => {
|
|
395
399
|
if (agentToRun.isRunning) {
|
|
396
400
|
return false;
|
|
@@ -434,8 +438,20 @@ const ChatInitializer = ({ agentBlock, agentChatBlock, chatId, token, appId, com
|
|
|
434
438
|
return;
|
|
435
439
|
}
|
|
436
440
|
copilotAgent.addMessage({ ...message, id: generateId(), role: "user" });
|
|
441
|
+
// The send seam, fired once the message is IN the conversation, so hosts can react
|
|
442
|
+
// to it (e.g. list the thread optimistically). Covers every send routed through
|
|
443
|
+
// `sendMessage` — composer, empty-state heroes, user-generated chat components —
|
|
444
|
+
// but deliberately NOT the auto-sent `initialPrompt` below, whose hidden variant is
|
|
445
|
+
// a primer rather than something the user wrote.
|
|
446
|
+
if (onUserMessageSent) {
|
|
447
|
+
const { content } = message;
|
|
448
|
+
const textPart = Array.isArray(content)
|
|
449
|
+
? content.find((part) => part.type === "text")
|
|
450
|
+
: undefined;
|
|
451
|
+
onUserMessageSent(typeof content === "string" ? content : (textPart?.text ?? ""));
|
|
452
|
+
}
|
|
437
453
|
await runAgentWithRetry(copilotAgent, copilotAgent.messages.length);
|
|
438
|
-
}, [canSendMessage, copilotAgent, runAgentWithRetry]);
|
|
454
|
+
}, [canSendMessage, copilotAgent, runAgentWithRetry, onUserMessageSent]);
|
|
439
455
|
// Not sourced from `useInterrupt`: that hook only hands `resolve` to its own
|
|
440
456
|
// `render`, which it skips whenever nothing is pending — so after a reload there
|
|
441
457
|
// is no way to obtain it. `runAgentWithRetry`'s `isRunning` guard makes a
|
|
@@ -868,7 +884,7 @@ export const AgentChatRoot = ({ ...props
|
|
|
868
884
|
},
|
|
869
885
|
// Explicit false: when undefined, CopilotKit auto-enables its inspector
|
|
870
886
|
// devtools on localhost hostnames.
|
|
871
|
-
enableInspector: false, children: [_jsx(SessionReadables, {}), _jsx(ChatInitializer, { agentBlock: agentBlock, agentChatBlock: agentChatBlock, chatId: chatId, noPersistency: noPersistency, token: token, appId: appId, components: components, currentThreadId: currentThreadId, setCurrentThreadId: setCurrentThreadId, children: children })] }));
|
|
887
|
+
enableInspector: false, children: [_jsx(SessionReadables, {}), _jsx(ChatInitializer, { agentBlock: agentBlock, agentChatBlock: agentChatBlock, chatId: chatId, noPersistency: noPersistency, token: token, appId: appId, components: components, currentThreadId: currentThreadId, setCurrentThreadId: setCurrentThreadId, onUserMessageSent: props.onUserMessageSent, children: children })] }));
|
|
872
888
|
};
|
|
873
889
|
export function AgentChatMessage({ message, index, ...props }) {
|
|
874
890
|
if (message.role === "user" && message.name === HIDDEN_INITIAL_PROMPT_MESSAGE_NAME) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type AgentThreadStatus = "idle" | "interrupted" | "error";
|
|
2
|
+
export type AgentThreadSource = "direct_chat" | "agent_call" | "send_message" | "unknown";
|
|
3
|
+
export interface AgentThreadItem {
|
|
4
|
+
agentId: string | null;
|
|
5
|
+
/** Never null — pass back verbatim to open the conversation. */
|
|
6
|
+
chatId: string;
|
|
7
|
+
/** Never null — pass back verbatim to open the conversation. */
|
|
8
|
+
chatThreadId: string;
|
|
9
|
+
source: AgentThreadSource;
|
|
10
|
+
status: AgentThreadStatus;
|
|
11
|
+
title: string | null;
|
|
12
|
+
turnCount: number;
|
|
13
|
+
/** First seen by the index — NOT thread creation time. */
|
|
14
|
+
firstSeenAt: string;
|
|
15
|
+
lastActivityAt: string;
|
|
16
|
+
}
|
|
17
|
+
/** The (chatId, chatThreadId) pair — the only stable identity of a thread row. */
|
|
18
|
+
export declare const agentThreadKey: (thread: Pick<AgentThreadItem, "chatId" | "chatThreadId">) => string;
|
|
19
|
+
/**
|
|
20
|
+
* Whether a realtime payload's `thread` is a complete list-item row. The server
|
|
21
|
+
* serializes the exact item shape into every event, so this normally passes; when
|
|
22
|
+
* it doesn't (contract drift), the consumer falls back to the getOne endpoint.
|
|
23
|
+
*/
|
|
24
|
+
export declare const isAgentThreadItem: (value: unknown) => value is AgentThreadItem;
|
|
25
|
+
/**
|
|
26
|
+
* Provisional row title from the prompt that started a conversation (ChatGPT-style):
|
|
27
|
+
* one line, whitespace collapsed, capped like the server's generated titles. Null when
|
|
28
|
+
* there's nothing usable (attachment-only sends) — the UI keeps its id-based fallback.
|
|
29
|
+
*/
|
|
30
|
+
export declare const provisionalThreadTitle: (prompt: string | undefined) => string | null;
|
|
31
|
+
export interface AgentThreadLiveUpdateOptions {
|
|
32
|
+
/** Active server-side filters — an updated row that stops matching leaves the list. */
|
|
33
|
+
status?: AgentThreadStatus;
|
|
34
|
+
source?: AgentThreadSource;
|
|
35
|
+
chatId?: string;
|
|
36
|
+
/** Active sort, mirroring the endpoint's defaults (lastActivity, desc). */
|
|
37
|
+
sort?: "lastActivity" | "firstSeen";
|
|
38
|
+
order?: "asc" | "desc";
|
|
39
|
+
/** True when more pages exist server-side beyond the loaded window. */
|
|
40
|
+
hasMore?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Merge one realtime thread update into a loaded list. Pure; returns the input array
|
|
44
|
+
* untouched when the event is stale, filtered out, or out of the loaded window.
|
|
45
|
+
*
|
|
46
|
+
* - Out-of-order delivery can't regress a row: events older than the loaded row (by
|
|
47
|
+
* lastActivityAt) are dropped — checked FIRST, so a stale event can't evict a
|
|
48
|
+
* still-current row through the filter check below either. Renames keep
|
|
49
|
+
* lastActivityAt, so equal passes.
|
|
50
|
+
* - A row that stops matching the active filters is removed; a matching one is upserted
|
|
51
|
+
* and the list re-sorted by the active sort (stable, so ties keep their server order).
|
|
52
|
+
* - A NEW row sorting past every loaded row while more pages exist may belong beyond the
|
|
53
|
+
* loaded window; it's skipped so the list stays contiguous with the pagination cursor
|
|
54
|
+
* (unreachable under the default lastActivity-desc sort, where fresh activity sorts first).
|
|
55
|
+
*/
|
|
56
|
+
/** Whether a row passes the list's active server-side filters. */
|
|
57
|
+
export declare const matchesAgentThreadFilters: (item: AgentThreadItem, options?: AgentThreadLiveUpdateOptions) => boolean;
|
|
58
|
+
export declare const applyAgentThreadUpdate: (threads: AgentThreadItem[], incoming: AgentThreadItem, options?: AgentThreadLiveUpdateOptions) => AgentThreadItem[];
|
|
59
|
+
/**
|
|
60
|
+
* Overlay optimistic rows (conversations the user just acted on, ChatGPT-style) onto the
|
|
61
|
+
* loaded list. Each row goes through the same merge as a realtime event, so filters and
|
|
62
|
+
* sorting apply — except that a server row still untitled borrows the optimistic
|
|
63
|
+
* (prompt-derived) title until the generated one lands. Pure; returns `threads` untouched
|
|
64
|
+
* when nothing changes.
|
|
65
|
+
*
|
|
66
|
+
* An entry steps aside once the loaded row has recorded MORE turns than it did: turnCount
|
|
67
|
+
* is server-monotonic, so the handover can't be held off by a client clock running ahead
|
|
68
|
+
* of the server (the entry's lastActivityAt is stamped locally, and the merge's staleness
|
|
69
|
+
* guard compares it against server timestamps).
|
|
70
|
+
*/
|
|
71
|
+
export declare const overlayOptimisticThreads: (threads: AgentThreadItem[], optimistic: ReadonlyMap<string, AgentThreadItem>, options?: AgentThreadLiveUpdateOptions) => AgentThreadItem[];
|
|
72
|
+
//# sourceMappingURL=agent-thread-live-update.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-thread-live-update.d.ts","sourceRoot":"","sources":["../../src/hooks/agent-thread-live-update.ts"],"names":[],"mappings":"AAUA,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,aAAa,GAAG,OAAO,CAAC;AAEjE,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG,YAAY,GAAG,cAAc,GAAG,SAAS,CAAC;AAE1F,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,kFAAkF;AAClF,eAAO,MAAM,cAAc,GAAI,QAAQ,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,cAAc,CAAC,KAAG,MAC9C,CAAC;AAK7C;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,eAiB3D,CAAC;AAKF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,GAAI,QAAQ,MAAM,GAAG,SAAS,KAAG,MAAM,GAAG,IAQ5E,CAAC;AAEF,MAAM,WAAW,4BAA4B;IAC3C,uFAAuF;IACvF,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,cAAc,GAAG,WAAW,CAAC;IACpC,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACvB,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAOD;;;;;;;;;;;;;GAaG;AACH,kEAAkE;AAClE,eAAO,MAAM,yBAAyB,GAAI,MAAM,eAAe,EAAE,UAAS,4BAAiC,KAAG,OAGzD,CAAC;AAEtD,eAAO,MAAM,sBAAsB,GACjC,SAAS,eAAe,EAAE,EAC1B,UAAU,eAAe,EACzB,UAAS,4BAAiC,KACzC,eAAe,EAqBjB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,GACnC,SAAS,eAAe,EAAE,EAC1B,YAAY,WAAW,CAAC,MAAM,EAAE,eAAe,CAAC,EAChD,UAAS,4BAAiC,KACzC,eAAe,EAkBjB,CAAC"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Wire types and pure merge logic for the agent-threads index and its realtime
|
|
2
|
+
// AGENT_THREAD_UPDATED events. Dependency-free on purpose: use-agent-threads
|
|
3
|
+
// re-exports everything here, and node tests load this module directly.
|
|
4
|
+
/** The (chatId, chatThreadId) pair — the only stable identity of a thread row. */
|
|
5
|
+
export const agentThreadKey = (thread) => `${thread.chatId}::${thread.chatThreadId}`;
|
|
6
|
+
const THREAD_STATUSES = ["idle", "interrupted", "error"];
|
|
7
|
+
const THREAD_SOURCES = ["direct_chat", "agent_call", "send_message", "unknown"];
|
|
8
|
+
/**
|
|
9
|
+
* Whether a realtime payload's `thread` is a complete list-item row. The server
|
|
10
|
+
* serializes the exact item shape into every event, so this normally passes; when
|
|
11
|
+
* it doesn't (contract drift), the consumer falls back to the getOne endpoint.
|
|
12
|
+
*/
|
|
13
|
+
export const isAgentThreadItem = (value) => {
|
|
14
|
+
const item = value;
|
|
15
|
+
return (typeof item === "object" &&
|
|
16
|
+
item !== null &&
|
|
17
|
+
(typeof item.agentId === "string" || item.agentId === null) &&
|
|
18
|
+
typeof item.chatId === "string" &&
|
|
19
|
+
item.chatId.length > 0 &&
|
|
20
|
+
typeof item.chatThreadId === "string" &&
|
|
21
|
+
item.chatThreadId.length > 0 &&
|
|
22
|
+
THREAD_SOURCES.includes(item.source) &&
|
|
23
|
+
THREAD_STATUSES.includes(item.status) &&
|
|
24
|
+
(typeof item.title === "string" || item.title === null) &&
|
|
25
|
+
typeof item.turnCount === "number" &&
|
|
26
|
+
typeof item.firstSeenAt === "string" &&
|
|
27
|
+
typeof item.lastActivityAt === "string");
|
|
28
|
+
};
|
|
29
|
+
// Matches the server's cap for LLM-generated titles (wf-actions-py TITLE_MAX_CHARS).
|
|
30
|
+
const PROVISIONAL_TITLE_MAX_CHARS = 80;
|
|
31
|
+
/**
|
|
32
|
+
* Provisional row title from the prompt that started a conversation (ChatGPT-style):
|
|
33
|
+
* one line, whitespace collapsed, capped like the server's generated titles. Null when
|
|
34
|
+
* there's nothing usable (attachment-only sends) — the UI keeps its id-based fallback.
|
|
35
|
+
*/
|
|
36
|
+
export const provisionalThreadTitle = (prompt) => {
|
|
37
|
+
const collapsed = prompt?.replace(/\s+/g, " ").trim();
|
|
38
|
+
if (!collapsed) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return collapsed.length > PROVISIONAL_TITLE_MAX_CHARS
|
|
42
|
+
? `${collapsed.slice(0, PROVISIONAL_TITLE_MAX_CHARS - 1).trimEnd()}…`
|
|
43
|
+
: collapsed;
|
|
44
|
+
};
|
|
45
|
+
const sortTime = (item, sort) => {
|
|
46
|
+
const parsed = new Date(sort === "firstSeen" ? item.firstSeenAt : item.lastActivityAt).getTime();
|
|
47
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Merge one realtime thread update into a loaded list. Pure; returns the input array
|
|
51
|
+
* untouched when the event is stale, filtered out, or out of the loaded window.
|
|
52
|
+
*
|
|
53
|
+
* - Out-of-order delivery can't regress a row: events older than the loaded row (by
|
|
54
|
+
* lastActivityAt) are dropped — checked FIRST, so a stale event can't evict a
|
|
55
|
+
* still-current row through the filter check below either. Renames keep
|
|
56
|
+
* lastActivityAt, so equal passes.
|
|
57
|
+
* - A row that stops matching the active filters is removed; a matching one is upserted
|
|
58
|
+
* and the list re-sorted by the active sort (stable, so ties keep their server order).
|
|
59
|
+
* - A NEW row sorting past every loaded row while more pages exist may belong beyond the
|
|
60
|
+
* loaded window; it's skipped so the list stays contiguous with the pagination cursor
|
|
61
|
+
* (unreachable under the default lastActivity-desc sort, where fresh activity sorts first).
|
|
62
|
+
*/
|
|
63
|
+
/** Whether a row passes the list's active server-side filters. */
|
|
64
|
+
export const matchesAgentThreadFilters = (item, options = {}) => (!options.status || item.status === options.status) &&
|
|
65
|
+
(!options.source || item.source === options.source) &&
|
|
66
|
+
(!options.chatId || item.chatId === options.chatId);
|
|
67
|
+
export const applyAgentThreadUpdate = (threads, incoming, options = {}) => {
|
|
68
|
+
const key = agentThreadKey(incoming);
|
|
69
|
+
const existing = threads.find((thread) => agentThreadKey(thread) === key);
|
|
70
|
+
if (existing && sortTime(incoming, "lastActivity") < sortTime(existing, "lastActivity")) {
|
|
71
|
+
return threads;
|
|
72
|
+
}
|
|
73
|
+
if (!matchesAgentThreadFilters(incoming, options)) {
|
|
74
|
+
return existing ? threads.filter((thread) => thread !== existing) : threads;
|
|
75
|
+
}
|
|
76
|
+
const sort = options.sort ?? "lastActivity";
|
|
77
|
+
const direction = options.order === "asc" ? 1 : -1;
|
|
78
|
+
const next = existing ? threads.map((thread) => (thread === existing ? incoming : thread)) : [...threads, incoming];
|
|
79
|
+
next.sort((a, b) => direction * (sortTime(a, sort) - sortTime(b, sort)));
|
|
80
|
+
if (!existing && options.hasMore && threads.length > 0 && next[next.length - 1] === incoming) {
|
|
81
|
+
return threads;
|
|
82
|
+
}
|
|
83
|
+
return next;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Overlay optimistic rows (conversations the user just acted on, ChatGPT-style) onto the
|
|
87
|
+
* loaded list. Each row goes through the same merge as a realtime event, so filters and
|
|
88
|
+
* sorting apply — except that a server row still untitled borrows the optimistic
|
|
89
|
+
* (prompt-derived) title until the generated one lands. Pure; returns `threads` untouched
|
|
90
|
+
* when nothing changes.
|
|
91
|
+
*
|
|
92
|
+
* An entry steps aside once the loaded row has recorded MORE turns than it did: turnCount
|
|
93
|
+
* is server-monotonic, so the handover can't be held off by a client clock running ahead
|
|
94
|
+
* of the server (the entry's lastActivityAt is stamped locally, and the merge's staleness
|
|
95
|
+
* guard compares it against server timestamps).
|
|
96
|
+
*/
|
|
97
|
+
export const overlayOptimisticThreads = (threads, optimistic, options = {}) => {
|
|
98
|
+
if (optimistic.size === 0) {
|
|
99
|
+
return threads;
|
|
100
|
+
}
|
|
101
|
+
let merged = threads;
|
|
102
|
+
for (const [key, row] of optimistic) {
|
|
103
|
+
const handedOver = threads.some((thread) => agentThreadKey(thread) === key && thread.turnCount > row.turnCount);
|
|
104
|
+
if (!handedOver) {
|
|
105
|
+
merged = applyAgentThreadUpdate(merged, row, options);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!merged.some((row) => row.title === null && optimistic.get(agentThreadKey(row))?.title)) {
|
|
109
|
+
return merged;
|
|
110
|
+
}
|
|
111
|
+
return merged.map((row) => {
|
|
112
|
+
const provisional = row.title === null ? optimistic.get(agentThreadKey(row))?.title : null;
|
|
113
|
+
return provisional ? { ...row, title: provisional } : row;
|
|
114
|
+
});
|
|
115
|
+
};
|
|
@@ -1,19 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
export interface AgentThreadItem {
|
|
4
|
-
agentId: string | null;
|
|
5
|
-
/** Never null — pass back verbatim to open the conversation. */
|
|
6
|
-
chatId: string;
|
|
7
|
-
/** Never null — pass back verbatim to open the conversation. */
|
|
8
|
-
chatThreadId: string;
|
|
9
|
-
source: AgentThreadSource;
|
|
10
|
-
status: AgentThreadStatus;
|
|
11
|
-
title: string | null;
|
|
12
|
-
turnCount: number;
|
|
13
|
-
/** First seen by the index — NOT thread creation time. */
|
|
14
|
-
firstSeenAt: string;
|
|
15
|
-
lastActivityAt: string;
|
|
16
|
-
}
|
|
1
|
+
import { type AgentThreadItem, type AgentThreadSource, type AgentThreadStatus } from "./agent-thread-live-update.js";
|
|
2
|
+
export * from "./agent-thread-live-update.js";
|
|
17
3
|
export interface AgentThreadsPage {
|
|
18
4
|
items: AgentThreadItem[];
|
|
19
5
|
nextCursor: string | null;
|
|
@@ -49,6 +35,19 @@ export interface UseAgentThreadsResult {
|
|
|
49
35
|
isDraftThread: boolean;
|
|
50
36
|
/** Move between threads; null mints a fresh draft ("new chat"). Resets when agentId changes. */
|
|
51
37
|
setCurrentThread: (thread: AgentThreadItem | null) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Report a user send on the current conversation (pass the prompt text) — ChatGPT-style
|
|
40
|
+
* optimistic listing. A conversation with no server row yet appears in `threads`
|
|
41
|
+
* immediately, titled by the truncated prompt, and every send bumps the row's recency;
|
|
42
|
+
* server rows take over under the same identity as events land. Identity-stable — safe
|
|
43
|
+
* to hand to memoized chat surfaces (e.g. a chat root's onUserMessageSent).
|
|
44
|
+
*/
|
|
45
|
+
notifyThreadActivity: (prompt?: string) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Rename a conversation. Optimistic — the list shows the new title immediately and
|
|
48
|
+
* rolls back on failure (the promise rejects so callers can surface an error).
|
|
49
|
+
*/
|
|
50
|
+
renameThread: (thread: Pick<AgentThreadItem, "chatId" | "chatThreadId">, title: string) => Promise<void>;
|
|
52
51
|
/** First page (or refresh) in flight. */
|
|
53
52
|
isLoading: boolean;
|
|
54
53
|
/** A cursor page is being appended. */
|
|
@@ -56,10 +55,13 @@ export interface UseAgentThreadsResult {
|
|
|
56
55
|
error: Error | null;
|
|
57
56
|
hasMore: boolean;
|
|
58
57
|
loadMore: () => Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Re-walk every loaded page. Freshness is otherwise live (the hook subscribes to
|
|
60
|
+
* AGENT_THREAD_UPDATED itself) — keep refresh for recovery, e.g. a throttled
|
|
61
|
+
* window-focus refresh after a dropped connection.
|
|
62
|
+
*/
|
|
59
63
|
refresh: () => Promise<void>;
|
|
60
64
|
}
|
|
61
|
-
/** The (chatId, chatThreadId) pair — the only stable identity of a thread row. */
|
|
62
|
-
export declare const agentThreadKey: (thread: Pick<AgentThreadItem, "chatId" | "chatThreadId">) => string;
|
|
63
65
|
/**
|
|
64
66
|
* The platform inbox's fixed chatId for an agent: one shared value per agent block, so
|
|
65
67
|
* index rows are recognizable as "direct chats with this agent on the platform" at a
|
|
@@ -76,10 +78,19 @@ export declare const agentInboxChatId: (agentId: string) => string;
|
|
|
76
78
|
* the real half rather than labeling every such row "_".
|
|
77
79
|
*/
|
|
78
80
|
export declare const agentThreadShortLabel: (thread: Pick<AgentThreadItem, "chatId" | "chatThreadId">) => string;
|
|
81
|
+
/** How a row is labelled: its title, or the id-based short label while it has none. */
|
|
82
|
+
export declare const agentThreadDisplayTitle: (thread: Pick<AgentThreadItem, "chatId" | "chatThreadId" | "title">) => string;
|
|
79
83
|
/**
|
|
80
84
|
* Headless agent-threads index: fetches an agent's conversation threads from the platform
|
|
81
|
-
* endpoint with keyset-cursor pagination
|
|
82
|
-
*
|
|
85
|
+
* endpoint with keyset-cursor pagination, then keeps them live — every threads-table write
|
|
86
|
+
* publishes AGENT_THREAD_UPDATED on the app's Pusher channel and the hook upserts the row,
|
|
87
|
+
* falling back to the getOne endpoint when a payload lacks the full row. New conversations
|
|
88
|
+
* list optimistically the moment the first prompt is sent (wire notifyThreadActivity to the
|
|
89
|
+
* chat's send seam). Data only — pair it with whatever list UI the host app uses. No
|
|
90
|
+
* per-host wiring: both hosts already run the shared websocketsService singleton and
|
|
91
|
+
* subscribe the app channel (the platform via its layouts' useWebsockets hooks, a
|
|
92
|
+
* boilerplate app via the client SDK's ClientProvider), and listening before the socket
|
|
93
|
+
* connects just parks the handler.
|
|
83
94
|
*/
|
|
84
95
|
export declare const useAgentThreads: (options: UseAgentThreadsOptions) => UseAgentThreadsResult;
|
|
85
96
|
export default useAgentThreads;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-agent-threads.d.ts","sourceRoot":"","sources":["../../src/hooks/use-agent-threads.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"use-agent-threads.d.ts","sourceRoot":"","sources":["../../src/hooks/use-agent-threads.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,KAAK,eAAe,EAEpB,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EAMvB,MAAM,4BAA4B,CAAC;AAKpC,cAAc,4BAA4B,CAAC;AAO3C,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,cAAc,GAAG,WAAW,CAAC;IACpC,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACvB,4FAA4F;IAC5F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B;;;;;;;;OAQG;IACH,aAAa,EAAE,eAAe,CAAC;IAC/B,gFAAgF;IAChF,aAAa,EAAE,OAAO,CAAC;IACvB,gGAAgG;IAChG,gBAAgB,EAAE,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,KAAK,IAAI,CAAC;IAC3D;;;;;;OAMG;IACH,oBAAoB,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAChD;;;OAGG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,cAAc,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzG,yCAAyC;IACzC,SAAS,EAAE,OAAO,CAAC;IACnB,uCAAuC;IACvC,aAAa,EAAE,OAAO,CAAC;IACvB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B;;;;OAIG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,iBAAiB,CAAC;AACtD,eAAO,MAAM,gBAAgB,GAAI,SAAS,MAAM,KAAG,MAAgD,CAAC;AAEpG;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,GAAI,QAAQ,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,cAAc,CAAC,KAAG,MAIhG,CAAC;AAEF,uFAAuF;AACvF,eAAO,MAAM,uBAAuB,GAAI,QAAQ,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,cAAc,GAAG,OAAO,CAAC,KAAG,MACpD,CAAC;AA8D1D;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,eAAe,GAAI,SAAS,sBAAsB,KAAG,qBAsdjE,CAAC;AAEF,eAAe,eAAe,CAAC"}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { getApiHost } from "@blocksdiy/blocks-client-api/envService";
|
|
2
|
+
import { websocketsService } from "@blocksdiy/blocks-client-api/websocketService";
|
|
2
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
import { agentThreadKey, applyAgentThreadUpdate, isAgentThreadItem, matchesAgentThreadFilters, overlayOptimisticThreads, provisionalThreadTitle, } from "./agent-thread-live-update.js";
|
|
5
|
+
// Wire types, the identity key, and the realtime merge logic live in
|
|
6
|
+
// ./agent-thread-live-update (dependency-free, unit-testable); re-exported so
|
|
7
|
+
// consumers keep importing everything from this module.
|
|
8
|
+
export * from "./agent-thread-live-update.js";
|
|
9
|
+
/* public decouple - AGENT_THREAD_UPDATED rides AgentThreadEventPayload (packages/common
|
|
10
|
+
agent-thread-events.ts): the event's `thread` field is exactly the list/GET item shape,
|
|
11
|
+
relayed verbatim from wf-actions-py to the app's Pusher channel. */
|
|
12
|
+
const AGENT_THREAD_UPDATED = "AGENT_THREAD_UPDATED";
|
|
5
13
|
/**
|
|
6
14
|
* The platform inbox's fixed chatId for an agent: one shared value per agent block, so
|
|
7
15
|
* index rows are recognizable as "direct chats with this agent on the platform" at a
|
|
@@ -22,6 +30,8 @@ export const agentThreadShortLabel = (thread) => {
|
|
|
22
30
|
const other = varying === thread.chatId ? thread.chatThreadId : thread.chatId;
|
|
23
31
|
return (varying === "_" ? other : varying).slice(0, 8);
|
|
24
32
|
};
|
|
33
|
+
/** How a row is labelled: its title, or the id-based short label while it has none. */
|
|
34
|
+
export const agentThreadDisplayTitle = (thread) => thread.title ?? `Chat ${agentThreadShortLabel(thread)}`;
|
|
25
35
|
const buildQuery = (filters, cursor) => {
|
|
26
36
|
const params = new URLSearchParams();
|
|
27
37
|
if (filters.status) {
|
|
@@ -76,8 +86,15 @@ const makeDraftThread = (agentId) => {
|
|
|
76
86
|
};
|
|
77
87
|
/**
|
|
78
88
|
* Headless agent-threads index: fetches an agent's conversation threads from the platform
|
|
79
|
-
* endpoint with keyset-cursor pagination
|
|
80
|
-
*
|
|
89
|
+
* endpoint with keyset-cursor pagination, then keeps them live — every threads-table write
|
|
90
|
+
* publishes AGENT_THREAD_UPDATED on the app's Pusher channel and the hook upserts the row,
|
|
91
|
+
* falling back to the getOne endpoint when a payload lacks the full row. New conversations
|
|
92
|
+
* list optimistically the moment the first prompt is sent (wire notifyThreadActivity to the
|
|
93
|
+
* chat's send seam). Data only — pair it with whatever list UI the host app uses. No
|
|
94
|
+
* per-host wiring: both hosts already run the shared websocketsService singleton and
|
|
95
|
+
* subscribe the app channel (the platform via its layouts' useWebsockets hooks, a
|
|
96
|
+
* boilerplate app via the client SDK's ClientProvider), and listening before the socket
|
|
97
|
+
* connects just parks the handler.
|
|
81
98
|
*/
|
|
82
99
|
export const useAgentThreads = (options) => {
|
|
83
100
|
const { agentId, status, source, chatId, limit, sort, order, baseUrl } = options;
|
|
@@ -106,6 +123,16 @@ export const useAgentThreads = (options) => {
|
|
|
106
123
|
const [error, setError] = useState(null);
|
|
107
124
|
const [selectedThread, setSelectedThread] = useState(null);
|
|
108
125
|
const [draftThread, setDraftThread] = useState(() => makeDraftThread(agentId));
|
|
126
|
+
// ChatGPT-style optimistic rows, keyed by thread identity: conversations listed from
|
|
127
|
+
// the moment a prompt is sent (see notifyThreadActivity), before/ahead of their server
|
|
128
|
+
// row. The display overlay hands each one over to its server row as events land.
|
|
129
|
+
// Ref-mirrored like threadsRef, for reads outside render (renameThread's snapshots).
|
|
130
|
+
const [optimisticThreads, setOptimisticThreads] = useState(new Map());
|
|
131
|
+
const optimisticThreadsRef = useRef(optimisticThreads);
|
|
132
|
+
const applyOptimisticThreads = useCallback((next) => {
|
|
133
|
+
optimisticThreadsRef.current = next;
|
|
134
|
+
setOptimisticThreads(next);
|
|
135
|
+
}, []);
|
|
109
136
|
const setCurrentThread = useCallback((thread) => {
|
|
110
137
|
// Null means "new chat": mint a fresh draft so each one is its own conversation.
|
|
111
138
|
if (!thread) {
|
|
@@ -125,31 +152,61 @@ export const useAgentThreads = (options) => {
|
|
|
125
152
|
prevAgentIdRef.current = agentId;
|
|
126
153
|
setSelectedThread(null);
|
|
127
154
|
setDraftThread(makeDraftThread(agentId));
|
|
128
|
-
|
|
155
|
+
applyOptimisticThreads(new Map());
|
|
156
|
+
}, [agentId, applyOptimisticThreads]);
|
|
157
|
+
// The realtime merge's list-shape inputs, shared by the display overlay, the live
|
|
158
|
+
// event handler, and the walk-result reconciliation in loadFirstPage.
|
|
159
|
+
const liveUpdateOptions = useMemo(() => ({ status, source, chatId, sort, order }), [status, source, chatId, sort, order]);
|
|
160
|
+
// What consumers see: the loaded pages with the optimistic rows overlaid.
|
|
161
|
+
const displayThreads = useMemo(() => overlayOptimisticThreads(threads, optimisticThreads, liveUpdateOptions), [threads, optimisticThreads, liveUpdateOptions]);
|
|
129
162
|
// Rows are rewritten server-side every turn, so after a refresh the stored item is stale;
|
|
130
163
|
// prefer the list's row with the same identity, keep the stored one if it's not loaded.
|
|
131
|
-
// The draft goes through the same lookup: once its
|
|
132
|
-
//
|
|
164
|
+
// The draft goes through the same lookup: once its row is listed — optimistically on the
|
|
165
|
+
// first send, or when its first turn is indexed — that row wins under the same key: the
|
|
166
|
+
// draft has materialized, without changing identity.
|
|
133
167
|
const currentThread = useMemo(() => {
|
|
134
168
|
const stored = selectedThread ?? draftThread;
|
|
135
169
|
const storedKey = agentThreadKey(stored);
|
|
136
|
-
return
|
|
137
|
-
}, [
|
|
170
|
+
return displayThreads.find((thread) => agentThreadKey(thread) === storedKey) ?? stored;
|
|
171
|
+
}, [displayThreads, selectedThread, draftThread]);
|
|
138
172
|
const isDraftThread = !selectedThread && currentThread === draftThread;
|
|
139
|
-
// Once the draft's
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
173
|
+
// Once the draft's row is listed, it IS the conversation — adopt it as the selection.
|
|
174
|
+
// Otherwise a later filter change (which blanks the list before refetching) would flip
|
|
175
|
+
// `isDraftThread` back to true for a conversation that has messages, and the consumer's
|
|
176
|
+
// draft-only presentation ("New chat" header, empty-state slot) with it.
|
|
143
177
|
useEffect(() => {
|
|
144
178
|
if (selectedThread) {
|
|
145
179
|
return;
|
|
146
180
|
}
|
|
147
181
|
const draftKey = agentThreadKey(draftThread);
|
|
148
|
-
const materialized =
|
|
182
|
+
const materialized = displayThreads.find((thread) => agentThreadKey(thread) === draftKey);
|
|
149
183
|
if (materialized) {
|
|
150
184
|
setSelectedThread(materialized);
|
|
151
185
|
}
|
|
152
|
-
}, [
|
|
186
|
+
}, [displayThreads, selectedThread, draftThread]);
|
|
187
|
+
// Read at call time via a ref so notifyThreadActivity stays identity-stable for
|
|
188
|
+
// memoized chat surfaces (same render-time-assignment pattern as authRef below).
|
|
189
|
+
const currentThreadRef = useRef(currentThread);
|
|
190
|
+
currentThreadRef.current = currentThread;
|
|
191
|
+
/**
|
|
192
|
+
* Report a user send on the current conversation — ChatGPT-style optimistic listing.
|
|
193
|
+
* A conversation with no server row yet appears in `threads` immediately, titled by
|
|
194
|
+
* the truncated prompt, and every send bumps the row's recency. Server rows take over
|
|
195
|
+
* under the same identity as events land (turn end, title generation), the provisional
|
|
196
|
+
* title holding until the generated one arrives.
|
|
197
|
+
*/
|
|
198
|
+
const notifyThreadActivity = useCallback((prompt) => {
|
|
199
|
+
const current = currentThreadRef.current;
|
|
200
|
+
const key = agentThreadKey(current);
|
|
201
|
+
const prev = optimisticThreadsRef.current;
|
|
202
|
+
const next = new Map(prev);
|
|
203
|
+
next.set(key, {
|
|
204
|
+
...current,
|
|
205
|
+
title: prev.get(key)?.title ?? current.title ?? provisionalThreadTitle(prompt),
|
|
206
|
+
lastActivityAt: new Date().toISOString(),
|
|
207
|
+
});
|
|
208
|
+
applyOptimisticThreads(next);
|
|
209
|
+
}, [applyOptimisticThreads]);
|
|
153
210
|
// Auth material is read at request time via refs so an inline `headers` object literal
|
|
154
211
|
// doesn't churn the effect below into a refetch loop.
|
|
155
212
|
const authRef = useRef({ token: options.token, headers: options.headers });
|
|
@@ -157,20 +214,95 @@ export const useAgentThreads = (options) => {
|
|
|
157
214
|
// Every state-writing path checks it still owns the latest request before touching state,
|
|
158
215
|
// so a stale response (filter switched mid-flight, unmount) can never clobber a newer one.
|
|
159
216
|
const requestSeqRef = useRef(0);
|
|
160
|
-
|
|
217
|
+
// Authed request against the platform host, JSON-decoded — shared by the page and
|
|
218
|
+
// getOne fetchers below and by renameThread.
|
|
219
|
+
const fetchJson = useCallback(async (path, init) => {
|
|
161
220
|
const { token, headers } = authRef.current;
|
|
162
|
-
const
|
|
163
|
-
|
|
221
|
+
const response = await fetch(`${baseUrl ?? getApiHost()}${path}`, {
|
|
222
|
+
...init,
|
|
164
223
|
headers: {
|
|
165
224
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
166
225
|
...headers,
|
|
226
|
+
...init?.headers,
|
|
167
227
|
},
|
|
168
228
|
});
|
|
169
229
|
if (!response.ok) {
|
|
170
230
|
throw new Error(`Agent threads request failed with status ${response.status}`);
|
|
171
231
|
}
|
|
172
232
|
return (await response.json());
|
|
173
|
-
}, [
|
|
233
|
+
}, [baseUrl]);
|
|
234
|
+
const fetchPage = useCallback((cursor) => fetchJson(`/agent-threads/${encodeURIComponent(agentId)}${buildQuery({ status, source, chatId, limit, sort, order }, cursor)}`), [agentId, status, source, chatId, limit, sort, order, fetchJson]);
|
|
235
|
+
// getOne, mirroring the upstream GET /thread endpoint — the realtime fallback for
|
|
236
|
+
// events whose payload doesn't carry the full row.
|
|
237
|
+
const fetchThread = useCallback((threadChatId, threadChatThreadId) => fetchJson(`/agent-threads/${encodeURIComponent(agentId)}/thread?${new URLSearchParams({ chatId: threadChatId, chatThreadId: threadChatThreadId })}`), [agentId, fetchJson]);
|
|
238
|
+
/**
|
|
239
|
+
* Rename a conversation — optimistic with rollback, rejecting on failure so callers
|
|
240
|
+
* can surface it. The new title is applied to EVERY local title source: the loaded
|
|
241
|
+
* row, any optimistic entry (whose snapshot the overlay may currently be showing),
|
|
242
|
+
* and the stored selection — `currentThread`'s fallback when a filter change dropped
|
|
243
|
+
* the row from the list; without it, a rename of the open-but-unlisted conversation
|
|
244
|
+
* would never reach the header. Other tabs get the rename via the pusher event; this
|
|
245
|
+
* tab is excluded from the echo (x-socket-id), matching the optimistic apply.
|
|
246
|
+
*/
|
|
247
|
+
const renameThread = useCallback(async (thread, title) => {
|
|
248
|
+
const trimmed = title.trim();
|
|
249
|
+
if (!trimmed) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const key = agentThreadKey(thread);
|
|
253
|
+
// `undefined` means "that source held no row when we started" — leave whatever is
|
|
254
|
+
// there now alone. Collapsing it to null would let a rollback erase a title that
|
|
255
|
+
// landed from the server while the request was in flight.
|
|
256
|
+
const setLocalTitle = (rowTitle, entryTitle, selectedTitle) => {
|
|
257
|
+
const row = threadsRef.current.find((item) => agentThreadKey(item) === key);
|
|
258
|
+
if (row && rowTitle !== undefined && row.title !== rowTitle) {
|
|
259
|
+
applyThreads(threadsRef.current.map((item) => (item === row ? { ...item, title: rowTitle } : item)));
|
|
260
|
+
}
|
|
261
|
+
const entry = optimisticThreadsRef.current.get(key);
|
|
262
|
+
if (entry && entryTitle !== undefined && entry.title !== entryTitle) {
|
|
263
|
+
const next = new Map(optimisticThreadsRef.current);
|
|
264
|
+
next.set(key, { ...entry, title: entryTitle });
|
|
265
|
+
applyOptimisticThreads(next);
|
|
266
|
+
}
|
|
267
|
+
setSelectedThread((prev) => prev && agentThreadKey(prev) === key && selectedTitle !== undefined && prev.title !== selectedTitle
|
|
268
|
+
? { ...prev, title: selectedTitle }
|
|
269
|
+
: prev);
|
|
270
|
+
};
|
|
271
|
+
const previousRowTitle = threadsRef.current.find((item) => agentThreadKey(item) === key)?.title;
|
|
272
|
+
const previousEntryTitle = optimisticThreadsRef.current.get(key)?.title;
|
|
273
|
+
// currentThread IS the stored selection whenever the selection matters as a
|
|
274
|
+
// fallback (and the fresher list row otherwise) — either way, the right value
|
|
275
|
+
// to restore the selection's title to.
|
|
276
|
+
const current = currentThreadRef.current;
|
|
277
|
+
const previousSelectedTitle = agentThreadKey(current) === key ? current.title : undefined;
|
|
278
|
+
setLocalTitle(trimmed, trimmed, trimmed);
|
|
279
|
+
try {
|
|
280
|
+
const socketId = websocketsService.getSocketId();
|
|
281
|
+
const { updated } = await fetchJson(`/agent-threads/${encodeURIComponent(agentId)}/thread`, {
|
|
282
|
+
method: "POST",
|
|
283
|
+
headers: {
|
|
284
|
+
"Content-Type": "application/json",
|
|
285
|
+
...(socketId ? { "x-socket-id": socketId } : {}),
|
|
286
|
+
},
|
|
287
|
+
body: JSON.stringify({ chatId: thread.chatId, chatThreadId: thread.chatThreadId, title: trimmed }),
|
|
288
|
+
});
|
|
289
|
+
// `updated: false` means no such row upstream (e.g. renaming before the first
|
|
290
|
+
// turn indexes the conversation) — a failure like any other, or the optimistic
|
|
291
|
+
// title would stick locally and silently vanish on reload.
|
|
292
|
+
if (!updated) {
|
|
293
|
+
throw new Error("Thread rename was not applied");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
setLocalTitle(previousRowTitle, previousEntryTitle, previousSelectedTitle);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}, [agentId, fetchJson, applyThreads, applyOptimisticThreads]);
|
|
301
|
+
// Realtime rows that land while a refresh walk is in flight: the walk's fetches may
|
|
302
|
+
// predate those writes, so replacing the list with the raw walk result could silently
|
|
303
|
+
// revert or drop them — re-merging afterwards keeps whichever side is newer (the
|
|
304
|
+
// merge's staleness guard makes this idempotent).
|
|
305
|
+
const liveWhileWalkingRef = useRef(null);
|
|
174
306
|
// The in-flight refresh, if any, so loadMore can wait it out instead of dropping the
|
|
175
307
|
// gesture (a click on "Load more" often arrives right behind the window-focus refresh
|
|
176
308
|
// the same gesture triggered).
|
|
@@ -183,6 +315,7 @@ export const useAgentThreads = (options) => {
|
|
|
183
315
|
return Promise.resolve();
|
|
184
316
|
}
|
|
185
317
|
const requestSeq = ++requestSeqRef.current;
|
|
318
|
+
liveWhileWalkingRef.current = new Map();
|
|
186
319
|
setIsLoading(true);
|
|
187
320
|
setError(null);
|
|
188
321
|
const run = (async () => {
|
|
@@ -218,6 +351,9 @@ export const useAgentThreads = (options) => {
|
|
|
218
351
|
}
|
|
219
352
|
cursor = page.nextCursor;
|
|
220
353
|
} while (cursor !== null && items.length < loadedCount);
|
|
354
|
+
for (const live of liveWhileWalkingRef.current?.values() ?? []) {
|
|
355
|
+
items = applyAgentThreadUpdate(items, live, { ...liveUpdateOptions, hasMore: cursor !== null });
|
|
356
|
+
}
|
|
221
357
|
applyThreads(items);
|
|
222
358
|
applyCursor(cursor);
|
|
223
359
|
}
|
|
@@ -230,12 +366,13 @@ export const useAgentThreads = (options) => {
|
|
|
230
366
|
finally {
|
|
231
367
|
if (requestSeqRef.current === requestSeq) {
|
|
232
368
|
setIsLoading(false);
|
|
369
|
+
liveWhileWalkingRef.current = null;
|
|
233
370
|
}
|
|
234
371
|
}
|
|
235
372
|
})();
|
|
236
373
|
refreshPromiseRef.current = run;
|
|
237
374
|
return run;
|
|
238
|
-
}, [agentId, fetchPage, applyThreads, applyCursor]);
|
|
375
|
+
}, [agentId, fetchPage, liveUpdateOptions, applyThreads, applyCursor]);
|
|
239
376
|
// Ref-guarded rather than state-guarded: after the awaited refresh below, the closure's
|
|
240
377
|
// state flags are stale by definition — and a ref also makes double-clicks race-free.
|
|
241
378
|
const loadMoreInFlightRef = useRef(false);
|
|
@@ -286,17 +423,82 @@ export const useAgentThreads = (options) => {
|
|
|
286
423
|
useEffect(() => {
|
|
287
424
|
applyThreads([]);
|
|
288
425
|
applyCursor(null);
|
|
426
|
+
// Server-side filters know nothing of the optimistic rows — drop them on a query
|
|
427
|
+
// change rather than risk re-listing a snapshot whose server truth the new filter
|
|
428
|
+
// excludes (see the retire-on-eviction note in the realtime handler below).
|
|
429
|
+
applyOptimisticThreads(new Map());
|
|
289
430
|
void loadFirstPage();
|
|
290
431
|
return () => {
|
|
291
432
|
// Invalidate in-flight requests on unmount / query change.
|
|
292
433
|
requestSeqRef.current += 1;
|
|
293
434
|
};
|
|
294
|
-
}, [loadFirstPage, applyThreads, applyCursor]);
|
|
435
|
+
}, [loadFirstPage, applyThreads, applyCursor, applyOptimisticThreads]);
|
|
436
|
+
// Live sync: every threads-table write lands here as a serialized row, so the happy
|
|
437
|
+
// path is a pure client-side upsert; a payload without the full row falls back to the
|
|
438
|
+
// getOne endpoint. The service-level filter drops other agents' events.
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
if (!agentId) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const onThreadUpdated = async (data) => {
|
|
444
|
+
// Same superseded-request contract as the fetch paths, guarding the async fallback
|
|
445
|
+
// (trivially true on the no-await happy path).
|
|
446
|
+
const requestSeq = requestSeqRef.current;
|
|
447
|
+
try {
|
|
448
|
+
const raw = data?.thread;
|
|
449
|
+
let incoming;
|
|
450
|
+
if (isAgentThreadItem(raw)) {
|
|
451
|
+
incoming = raw;
|
|
452
|
+
}
|
|
453
|
+
else if (typeof raw?.chatId === "string" && typeof raw.chatThreadId === "string") {
|
|
454
|
+
incoming = await fetchThread(raw.chatId, raw.chatThreadId);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (requestSeqRef.current !== requestSeq) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const key = agentThreadKey(incoming);
|
|
463
|
+
// An authoritative row the active filters reject must retire the thread's
|
|
464
|
+
// optimistic entry too: the merge below evicts the server row, and with no
|
|
465
|
+
// listed row there is no staleness guard to stop the overlay from re-adding
|
|
466
|
+
// the entry's stale snapshot — which no later event or refresh walk could
|
|
467
|
+
// correct, since the server filters the row out as well. Gated on turnCount
|
|
468
|
+
// (server-monotonic, like the overlay's handover) rather than the entry's
|
|
469
|
+
// CLIENT-stamped lastActivityAt, which a fast browser clock could hold ahead
|
|
470
|
+
// of every server timestamp.
|
|
471
|
+
const entry = optimisticThreadsRef.current.get(key);
|
|
472
|
+
if (entry && !matchesAgentThreadFilters(incoming, liveUpdateOptions) && incoming.turnCount >= entry.turnCount) {
|
|
473
|
+
const next = new Map(optimisticThreadsRef.current);
|
|
474
|
+
next.delete(key);
|
|
475
|
+
applyOptimisticThreads(next);
|
|
476
|
+
}
|
|
477
|
+
// Park the row for the in-flight refresh walk (if any) — its fetches may
|
|
478
|
+
// predate this write — then merge it into the current list.
|
|
479
|
+
liveWhileWalkingRef.current?.set(key, incoming);
|
|
480
|
+
applyThreads(applyAgentThreadUpdate(threadsRef.current, incoming, {
|
|
481
|
+
...liveUpdateOptions,
|
|
482
|
+
hasMore: nextCursorRef.current !== null,
|
|
483
|
+
}));
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// Best-effort: a failed fallback fetch skips this event — the next event or a
|
|
487
|
+
// recovery refresh re-converges. Must not reject; the websocket service doesn't catch.
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
websocketsService.listen(AGENT_THREAD_UPDATED, onThreadUpdated, { filter: { agentId } });
|
|
491
|
+
return () => {
|
|
492
|
+
websocketsService.unlisten(AGENT_THREAD_UPDATED, onThreadUpdated);
|
|
493
|
+
};
|
|
494
|
+
}, [agentId, liveUpdateOptions, fetchThread, applyThreads, applyOptimisticThreads]);
|
|
295
495
|
return {
|
|
296
|
-
threads,
|
|
496
|
+
threads: displayThreads,
|
|
297
497
|
currentThread,
|
|
298
498
|
isDraftThread,
|
|
299
499
|
setCurrentThread,
|
|
500
|
+
notifyThreadActivity,
|
|
501
|
+
renameThread,
|
|
300
502
|
isLoading,
|
|
301
503
|
isLoadingMore,
|
|
302
504
|
error,
|