From 959b42c012313bcec29f64f70fa50ef0638cae1c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:28:56 +0800 Subject: [PATCH 1/5] feat(chat): continue verified original private conversations Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 97 ++++++++++++++++--- .../src/data/use-bound-conversation.ts | 28 ++++++ .../src/data/use-conversation-history.ts | 34 ++++--- .../personal-workspace/channel-header.tsx | 12 ++- .../personal-workspace/lark-settings-page.tsx | 4 +- .../personal-workspace-model.ts | 4 + .../personal-workspace-page.tsx | 35 +++++-- .../private-conversation-panel.tsx | 62 ++++++++---- .../workspace-settings-page.tsx | 3 + apps/presentation/dashboard/src/router.tsx | 4 + .../dashboard/src/views/dashboard-page.tsx | 62 +++++++++--- 11 files changed, 275 insertions(+), 70 deletions(-) create mode 100644 apps/presentation/dashboard/src/data/use-bound-conversation.ts diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index b560d69753..e8c56bdcff 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -692,18 +692,32 @@ export async function recordProjectionExchange(options: { export type ConversationContext = | { kind: "manager" } | { kind: "goal"; goalId: string } - | { kind: "project"; projectRef: string }; + | { kind: "project"; projectRef: string } + // A UI address for an existing source-owned Session, never a new Core channel. + | ({ kind: "bound" } & BoundConversationTarget); + +export type BoundConversationTarget = { bindingId: string; sessionId: string }; + +/** Goal projections cannot answer a source-owned or ordinary workspace Chat. */ +export function conversationSupportsStatusProjection(context: ConversationContext) { + return context.kind === "manager" || context.kind === "goal"; +} // Goal ids are single path segments, so a key containing "/" never names a Goal. const PROJECT_CONVERSATION_KEY_PREFIX = "project/"; export function conversationContextKey(context: ConversationContext): string { if (context.kind === "manager") return "manager"; + if (context.kind === "bound") return `bound/${encodeURIComponent(context.bindingId)}/${encodeURIComponent(context.sessionId)}`; return context.kind === "goal" ? context.goalId : `${PROJECT_CONVERSATION_KEY_PREFIX}${context.projectRef}`; } export function conversationContextOfKey(key: string): ConversationContext { if (key === "manager") return { kind: "manager" }; + if (key.startsWith("bound/")) { + const [, bindingId = "", sessionId = ""] = key.split("/"); + return { kind: "bound", bindingId: decodeURIComponent(bindingId), sessionId: decodeURIComponent(sessionId) }; + } if (key.startsWith(PROJECT_CONVERSATION_KEY_PREFIX)) { return { kind: "project", projectRef: key.slice(PROJECT_CONVERSATION_KEY_PREFIX.length) }; } @@ -712,6 +726,7 @@ export function conversationContextOfKey(key: string): ConversationContext { export function conversationChannelId(context: ConversationContext): string { if (context.kind === "manager") return "manager"; + if (context.kind === "bound") return conversationContextKey(context); return context.kind === "goal" ? `goal.${context.goalId}` : `project.${context.projectRef}`; } @@ -723,6 +738,17 @@ export async function createChatSession( agentModelId?: string, agentReasoningEffort?: string, ) { + if (context.kind === "bound") { + if (mode !== "resume_latest" || agentModelId || agentReasoningEffort) { + throw new Error("An original conversation cannot be replaced or reconfigured here."); + } + const session = await resolveBoundChatSession(context, signal); + if (!session.resumable || (agentId && agentId !== session.agent_id)) { + throw new Error("The original conversation is unavailable on its executor."); + } + return { ok: true as const, resumed: true, session_id: session.session_id, + agent_id: session.agent_id, goal_id: session.goal_id, session }; + } return requestJson<{ agent_id: string; goal_id: string | null; @@ -873,24 +899,58 @@ export type ChatSessionSnapshot = { }; export async function fetchChatSession(sessionId: string, signal?: AbortSignal) { - return requestJson(`/api/chat/sessions/${sessionId}`, { signal }); + return requestJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}`, { signal }); } export async function fetchChatSessions(options: { agentId?: string; channelId?: string; goalId?: string; + bound?: BoundConversationTarget; signal?: AbortSignal; }) { const query = new URLSearchParams(); - if (options.agentId) query.set("agent_id", options.agentId); - if (options.channelId) query.set("channel_id", options.channelId); - if (options.goalId) query.set("goal_id", options.goalId); - return requestJson<{ + if (options.bound) { + if (!/^[a-f0-9]{24}$/.test(options.bound.bindingId) + || !/^[A-Za-z0-9._-]{1,160}$/.test(options.bound.sessionId)) { + throw new Error("Invalid original conversation address."); + } + query.set("conversation_binding_id", options.bound.bindingId); + } else { + if (options.agentId) query.set("agent_id", options.agentId); + if (options.channelId) query.set("channel_id", options.channelId); + if (options.goalId) query.set("goal_id", options.goalId); + } + const listed = await requestJson<{ ok: true; schema_version: "loopx_chat_session_list_v1"; + conversation_binding_id?: string; sessions: ChatSessionSummary[]; }>(`/api/chat/sessions?${query.toString()}`, { signal: options.signal }); + if (!options.bound) return listed; + // An older service may ignore the selector and return unrelated ordinary + // history. The owner's echo is mandatory before any transcript is read. + if (listed.conversation_binding_id !== options.bound.bindingId) { + throw new Error("This service cannot verify the original conversation. Update LoopX and retry."); + } + const sessions = listed.sessions.filter(row => row.session_id === options.bound!.sessionId); + if (sessions.length !== 1) throw new Error("The original conversation is no longer available from this connection."); + return { ...listed, sessions }; +} + +export async function resolveBoundChatSession(target: BoundConversationTarget, signal?: AbortSignal) { + return (await fetchChatSessions({ bound: target, signal })).sessions[0]; +} + +/** Discovery chooses once; subsequent navigation freezes the exact Session. */ +export async function discoverBoundChatSession(bindingId: string, signal?: AbortSignal) { + if (!/^[a-f0-9]{24}$/.test(bindingId)) throw new Error("Invalid conversation connection."); + const listed = await requestJson<{ conversation_binding_id?: string; sessions: ChatSessionSummary[] }>( + `/api/chat/sessions?conversation_binding_id=${bindingId}`, { signal }); + if (listed.conversation_binding_id !== bindingId) throw new Error("This service cannot verify the original conversation. Update LoopX and retry."); + const session = [...listed.sessions].sort((a, b) => b.last_activity_at.localeCompare(a.last_activity_at))[0]; + if (!session || !/^[A-Za-z0-9._-]{1,160}$/.test(session.session_id)) throw new Error("No original conversation is available yet. Start it in Lark first."); + return session; } export function mergeChatSessionMessages(snapshots: ChatSessionSnapshot[]) { @@ -920,13 +980,28 @@ export async function fetchChatHistory(options: { agentId?: string; channelId: string; goalId?: string; + bound?: BoundConversationTarget; }, previous?: ChatHistory): Promise { - const listed = previous ?? await fetchChatSessions({ ...options, signal: AbortSignal.timeout(5000) }); + // Source authorization must be re-read even when recovering an older snapshot. + const listed = (!options.bound ? previous : undefined) ?? await fetchChatSessions({ ...options, signal: AbortSignal.timeout(5000) }); const known = new Map(previous?.snapshots.map((snapshot) => [snapshot.session.session_id, snapshot])); // A failed historical read is not an empty transcript. Retrying only the // missing snapshots keeps this recovery read-only and bounds repeated work. - const missing = listed.sessions.filter((session) => !known.has(session.session_id)); - const results = await Promise.allSettled(missing.map((session) => fetchChatSession(session.session_id, AbortSignal.timeout(5000)))); + const missing = listed.sessions.filter((session) => { + const saved = known.get(session.session_id)?.session; + return !saved || (options.bound && (!session.transcript_revision + || session.transcript_revision !== saved.transcript_revision + || session.updated_at !== saved.updated_at || session.active_turn_id !== saved.active_turn_id + || session.status !== saved.status)); + }); + if (options.bound) for (const session of missing) known.delete(session.session_id); + const results = await Promise.allSettled(missing.map(async (session) => { + const snapshot = await fetchChatSession(session.session_id, AbortSignal.timeout(5000)); + if (snapshot.session.session_id !== session.session_id || snapshot.session.agent_id !== session.agent_id) { + throw new Error("The original Session identity changed during history read."); + } + return snapshot; + })); for (const result of results) { if (result.status === "fulfilled") known.set(result.value.session.session_id, result.value); } @@ -2536,8 +2611,8 @@ export type PrivateConversationStopRecovery = z.infer; -export async function fetchPrivateConversations() { - return privateConversationsSchema.parse(await requestJson("/api/chat/lark/private-conversations")); +export async function fetchPrivateConversations(signal?: AbortSignal) { + return privateConversationsSchema.parse(await requestJson("/api/chat/lark/private-conversations", { signal })); } export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project", projectGrant: "workspace_read" | "workspace_write" = "workspace_write", groupIds?: string[]) { return privateConversationsSchema.parse(await requestJson("/api/chat/lark/private-conversations", { diff --git a/apps/presentation/dashboard/src/data/use-bound-conversation.ts b/apps/presentation/dashboard/src/data/use-bound-conversation.ts new file mode 100644 index 0000000000..8c44d0090d --- /dev/null +++ b/apps/presentation/dashboard/src/data/use-bound-conversation.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; +import { fetchPrivateConversations, resolveBoundChatSession, + type BoundConversationTarget, type ChatSessionSummary, type PrivateConversation } from "./chat"; + +/** Reads the source's title and executor. Admission remains with the Core owner. */ +export function useBoundConversation(target: BoundConversationTarget | null, enabled: boolean) { + const bindingId = target?.bindingId, sessionId = target?.sessionId; + const key = JSON.stringify([bindingId, sessionId]); + const [state, setState] = useState<{ key: string; session?: ChatSessionSummary; source?: PrivateConversation; error?: string }>(); + const [revision, setRevision] = useState(0); + useEffect(() => { + if (!enabled || bindingId === undefined || sessionId === undefined) return; + let active = true; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + Promise.all([resolveBoundChatSession({ bindingId, sessionId }, controller.signal), fetchPrivateConversations(controller.signal)]) + .then(([session, connections]) => { + if (!active) return; + const source = connections.connections.find(row => row.binding_id === bindingId && row.audience === "owner" && row.context_available); + if (!source) throw new Error("This private conversation connection is unavailable."); + if (source.executor_endpoint_id !== session.agent_id) throw new Error("The original conversation's executor no longer matches its connection."); + setState({ key, session, source }); + }).catch(error => { if (active) setState({ key, error: error instanceof Error ? error.message : String(error) }); }) + .finally(() => clearTimeout(timeout)); + return () => { active = false; controller.abort(); clearTimeout(timeout); }; + }, [bindingId, sessionId, key, enabled, revision]); + return { ...(enabled && state?.key === key ? state : {}), retry: () => setRevision(value => value + 1) }; +} diff --git a/apps/presentation/dashboard/src/data/use-conversation-history.ts b/apps/presentation/dashboard/src/data/use-conversation-history.ts index 95e43a2349..8559fe4b03 100644 --- a/apps/presentation/dashboard/src/data/use-conversation-history.ts +++ b/apps/presentation/dashboard/src/data/use-conversation-history.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { fetchChatHistory, type ChatHistory } from "./chat"; +import { fetchChatHistory, type ChatHistory, type BoundConversationTarget } from "./chat"; type HistoryState = { scope: string; @@ -16,31 +16,33 @@ export function currentChannelSession(history: ChatHistory | null, agentId: stri } /** Read recovery shared by steward and Goal conversations; never executes a Turn. */ -export function useConversationHistory({ agentId, currentAgentId, channelId, goalId, enabled }: { +export function useConversationHistory({ agentId, currentAgentId, channelId, goalId, enabled, bound }: { agentId?: string; currentAgentId: string; channelId: string; goalId?: string; enabled: boolean; + bound?: BoundConversationTarget; }) { - const scope = JSON.stringify([agentId ?? null, currentAgentId, channelId, goalId ?? null]); + const bindingId = bound?.bindingId, sessionId = bound?.sessionId; + const scope = JSON.stringify([agentId ?? null, currentAgentId, channelId, goalId ?? null, bindingId, sessionId]); const [state, setState] = useState(null); const retryRead = useRef<(() => void) | null>(null); const cached = useRef<{ scope: string; history: ChatHistory } | null>(null); const refreshGeneration = useRef(0); const cachedHistory = (forScope: string) => cached.current?.scope === forScope ? cached.current.history : null; - const params = useRef({ agentId, channelId, goalId, scope }); - params.current = { agentId, channelId, goalId, scope }; + const params = useRef({ agentId, channelId, goalId, scope, bound }); + params.current = { agentId, channelId, goalId, scope, bound }; // A caller that learned of a Turn the cached read cannot show -- the service // refused a send because that Turn is running -- needs what the Session looks // like now, not what this hook read earlier. The re-read stays here so the // conversation keeps a single history owner. const refresh = useCallback(async () => { - const { agentId, channelId, goalId, scope } = params.current; + const { agentId, channelId, goalId, scope, bound } = params.current; const generation = ++refreshGeneration.current; let loaded: ChatHistory; try { - loaded = await fetchChatHistory({ agentId, channelId, goalId }); + loaded = await fetchChatHistory({ agentId, channelId, goalId, bound }); } catch (error) { if (params.current.scope === scope && generation === refreshGeneration.current) { setState({ scope, history: cachedHistory(scope), reading: false, failed: true }); @@ -77,9 +79,11 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa // A retry can start during a refresh but still carry the older cached snapshot. const isCurrentRead = () => !cancelled && params.current.scope === scope && generation === refreshGeneration.current && cachedHistory(scope) === previous; - setState({ scope, history: previous, reading: true, failed: false }); + setState(current => ({ scope, history: previous, reading: true, + failed: bindingId !== undefined && current?.scope === scope && current.failed })); try { - const loaded = await fetchChatHistory({ agentId, channelId, goalId }, previous ?? undefined); + const loaded = await fetchChatHistory({ agentId, channelId, goalId, + bound: bindingId === undefined ? undefined : { bindingId, sessionId: sessionId ?? "" } }, previous ?? undefined); if (!isCurrentRead()) return; cached.current = { scope, history: loaded }; setState({ scope, history: loaded, reading: false, failed: false }); @@ -89,9 +93,10 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa } finally { reading = false; const known = cachedHistory(scope); - if (!cancelled && (!known || known.unavailableSessionIds.length > 0)) { + if (!cancelled && (bindingId !== undefined || !known || known.unavailableSessionIds.length > 0)) { // Back off sustained transport failures; a user can retry immediately. - retryTimer = setTimeout(() => void read(), Math.min(3000 * 2 ** attempts++, 30_000)); + retryTimer = setTimeout(() => void read(), known && !known.unavailableSessionIds.length + ? 5000 : Math.min(3000 * 2 ** attempts++, 30_000)); } } } @@ -103,7 +108,7 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa clearTimeout(retryTimer); retryRead.current = null; }; - }, [agentId, channelId, goalId, enabled, scope]); + }, [agentId, channelId, goalId, enabled, scope, bindingId, sessionId]); const current = enabled && state?.scope === scope ? state : null; const history = current?.history ?? null; // A channel transcript spans executors; readability of another executor's @@ -119,9 +124,10 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa : history?.unavailableSessionIds.length ? (history.snapshots.length ? "partial" as const : "unavailable" as const) : current.reading ? "loading" as const : "ready" as const, reading: current?.reading ?? enabled, - sendBlocked: enabled && !currentSessionReadable, + sendBlocked: enabled && (!currentSessionReadable || (bound !== undefined && current?.failed === true)), // Missing older history must not restart a recovered current stream. - connectionKey: currentSessionReadable ? `${scope}:${latest?.session_id ?? "empty"}` : null, + connectionKey: currentSessionReadable && !(bound && current?.failed) + ? `${scope}:${latest?.session_id ?? "empty"}${bound ? `:${latest?.active_turn_id ?? "idle"}` : ""}` : null, retry, }; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index e8f265ef31..8bb1d5ff71 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -43,6 +43,7 @@ export function ChannelHeader({ selectedGoalTab, scopeUnavailable = false, workspaceGrantLabel, + originalConversationTitle, }: { agents: WorkspaceAgentOption[]; agentWorkActive?: boolean; @@ -51,6 +52,7 @@ export function ChannelHeader({ goalLifecycleBusy?: boolean; /** Set while the steward conversation is scoped to a workspace. */ workspaceGrantLabel?: string | null; + originalConversationTitle?: string; managerChannelBinding?: ManagerChannelBinding | null; managerChatOpen?: boolean; managerRuntime?: ManagerRuntimeSessionReadback | null; @@ -159,7 +161,11 @@ export function ChannelHeader({ const runtimeControl = scopeUnavailable || agentWorkActive ? null : readOnlySourceLabel ? ( {readOnlySourceLabel}{t("common.readOnly")} - ) : <> + ) : originalConversationTitle ? <> + {selectedAgentId} + + : <>
- {selectedGoal ? undefined)} /> :

{t(scopeUnavailable ? "header.goalView" : "header.manager")}

} + {selectedGoal ? undefined)} /> :

{originalConversationTitle ?? t(scopeUnavailable ? "header.goalView" : "header.manager")}

} {workspaceGrantLabel ?

{t("workspace.conversation", { grant: workspaceGrantLabel })}

: null}
{stewardBindingVisible && managerChannelBinding ? ( diff --git a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx index 7839a36b02..80d8ef99cd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx @@ -191,6 +191,7 @@ function larkErrorMessage(cause: unknown, fallback: string, t: WorkspaceTranslat } export function LarkSettingsPage({ + onOpenOriginalConversation, embedded = false, focusGoalConnection = false, goals, @@ -204,6 +205,7 @@ export function LarkSettingsPage({ initialGoalId?: string | null; onChanged?: () => void; onClose: () => void; + onOpenOriginalConversation?: (target: import("../../data/chat").BoundConversationTarget) => void; }) { const { t } = useWorkspaceI18n(); const [tab, setTab] = useState("connections"); @@ -543,7 +545,7 @@ export function LarkSettingsPage({ )} - +