From 10fc39826c00346845513a949dcc780e492e87b1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:25:12 +0800 Subject: [PATCH] feat(chat): continue exact managed Agent sessions Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 50 ++++++- .../src/data/use-conversation-history.ts | 32 ++-- .../src/data/use-managed-conversation.ts | 19 +++ .../attached-agent-conversations.css | 7 + .../attached-agent-conversations.tsx | 70 +-------- .../goal-agent-work-page.tsx | 95 ++++++++++-- .../personal-workspace-model.ts | 2 + .../personal-workspace-page.tsx | 26 +++- apps/presentation/dashboard/src/router.tsx | 1 + .../dashboard/src/views/dashboard-page.tsx | 62 +++++--- examples/personal-workspace-browser-smoke.mjs | 2 + .../codex-attached.mjs | 14 +- .../codex_attached_peer.py | 5 + .../managed-agent-sessions.mjs | 139 ++++++++++++++++++ .../managed_agent_fixture.py | 101 +++++++++++++ .../managed-agent-after-desktop.png | Bin 0 -> 96523 bytes .../managed-agent-after-narrow.png | Bin 0 -> 49107 bytes .../managed-agent-before-desktop.png | Bin 0 -> 59971 bytes .../managed-agent-before-narrow.png | Bin 0 -> 15733 bytes .../managed-agent-details-desktop.png | Bin 0 -> 104849 bytes loopx/chat_server.py | 32 ++++ .../test_codex_attached_conversation.py | 50 +++++++ 22 files changed, 581 insertions(+), 126 deletions(-) create mode 100644 apps/presentation/dashboard/src/data/use-managed-conversation.ts create mode 100644 examples/personal-workspace-browser/managed-agent-sessions.mjs create mode 100644 examples/personal-workspace-browser/managed_agent_fixture.py create mode 100644 examples/personal-workspace-browser/screenshots/managed-agent/managed-agent-after-desktop.png create mode 100644 examples/personal-workspace-browser/screenshots/managed-agent/managed-agent-after-narrow.png create mode 100644 examples/personal-workspace-browser/screenshots/managed-agent/managed-agent-before-desktop.png create mode 100644 examples/personal-workspace-browser/screenshots/managed-agent/managed-agent-before-narrow.png create mode 100644 examples/personal-workspace-browser/screenshots/managed-agent/managed-agent-details-desktop.png diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index bdae58f1a9..7f64dc1063 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -700,10 +700,12 @@ export type ConversationContext = | { kind: "manager" } | { kind: "goal"; goalId: string } | { kind: "project"; projectRef: string } + | ({ kind: "managed" } & ManagedConversationTarget) // A UI address for an existing source-owned Session, never a new Core channel. | ({ kind: "bound" } & BoundConversationTarget); export type BoundConversationTarget = { bindingId: string; sessionId: string }; +export type ManagedConversationTarget = {goalId: string; agentId: string; sessionId: string}; /** Goal projections cannot answer a source-owned or ordinary workspace Chat. */ export function conversationSupportsStatusProjection(context: ConversationContext) { @@ -715,12 +717,17 @@ const PROJECT_CONVERSATION_KEY_PREFIX = "project/"; export function conversationContextKey(context: ConversationContext): string { if (context.kind === "manager") return "manager"; + if (context.kind === "managed") return `managed/${encodeURIComponent(context.goalId)}/${encodeURIComponent(context.agentId)}/${encodeURIComponent(context.sessionId)}`; 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("managed/")) { + const [, goalId = "", agentId = "", sessionId = ""] = key.split("/"); + return {kind: "managed", goalId: decodeURIComponent(goalId), agentId: decodeURIComponent(agentId), sessionId: decodeURIComponent(sessionId)}; + } if (key.startsWith("bound/")) { const [, bindingId = "", sessionId = ""] = key.split("/"); return { kind: "bound", bindingId: decodeURIComponent(bindingId), sessionId: decodeURIComponent(sessionId) }; @@ -733,6 +740,7 @@ export function conversationContextOfKey(key: string): ConversationContext { export function conversationChannelId(context: ConversationContext): string { if (context.kind === "manager") return "manager"; + if (context.kind === "managed") return `agent.${context.agentId}`; if (context.kind === "bound") return conversationContextKey(context); return context.kind === "goal" ? `goal.${context.goalId}` : `project.${context.projectRef}`; } @@ -745,6 +753,14 @@ export async function createChatSession( agentModelId?: string, agentReasoningEffort?: string, ) { + if (context.kind === "managed") { + if (mode !== "resume_latest" || agentModelId || agentReasoningEffort) { + throw new Error("An original Agent Session cannot be replaced or reconfigured here."); + } + const session = await resolveManagedChatSession(context, signal); + if (!session.resumable || (agentId && agentId !== session.agent_id)) throw new Error("The original managed Session is unavailable."); + return {ok: true as const, resumed: true, session_id: session.session_id, agent_id: session.agent_id, goal_id: session.goal_id, session}; + } if (context.kind === "bound") { if (mode !== "resume_latest" || agentModelId || agentReasoningEffort) { throw new Error("An original conversation cannot be replaced or reconfigured here."); @@ -804,6 +820,8 @@ export type ChatSessionSummary = { goal_id: string | null; project_ref?: string | null; agent_id: string; + managed_agent_id?: string; + goal_instance_id?: string | null; agent_model_id?: string | null; agent_reasoning_effort?: string | null; agent_service_tier?: string | null; @@ -920,6 +938,26 @@ export async function fetchChatSession(sessionId: string, signal?: AbortSignal) return requestJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}`, { signal }); } +/** Read-only discovery through the existing Core Goal/Agent/Session identity. */ +export async function fetchManagedAgentSessions(goalId: string, agentId: string, signal?: AbortSignal) { + if (!/^[A-Za-z0-9._-]{1,160}$/.test(goalId) || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(agentId)) throw new Error("Invalid registered Agent address."); + const listed = await requestJson<{ok: true; schema_version: "loopx_chat_session_list_v1"; goal_id: string; managed_agent_id: string; goal_instance_id: string | null; sessions: ChatSessionSummary[]}>( + `/api/chat/sessions?${new URLSearchParams({goal_id: goalId, managed_agent_id: agentId})}`, {signal}); + if (listed.ok !== true || listed.schema_version !== "loopx_chat_session_list_v1" || listed.goal_id !== goalId || listed.managed_agent_id !== agentId || !Array.isArray(listed.sessions) + || !Object.hasOwn(listed, "goal_instance_id") || listed.sessions.some(row => row.goal_id !== goalId || row.managed_agent_id !== agentId + || row.goal_instance_id !== listed.goal_instance_id || row.channel_id !== `agent.${agentId}` || row.session_mode !== "managed_runtime")) { + throw new Error("This service cannot verify the registered Agent's original Sessions. Update LoopX and retry."); + } + return listed; +} + +export async function resolveManagedChatSession(target: ManagedConversationTarget, signal?: AbortSignal) { + const listed = await fetchManagedAgentSessions(target.goalId, target.agentId, signal); + const sessions = listed.sessions.filter(row => row.session_id === target.sessionId); + if (sessions.length !== 1) throw new Error("The original managed Session is no longer available from this Agent."); + return sessions[0]; +} + export async function fetchChatSessions(options: { agentId?: string; channelId?: string; @@ -999,25 +1037,31 @@ export async function fetchChatHistory(options: { channelId: string; goalId?: string; bound?: BoundConversationTarget; + managed?: ManagedConversationTarget; }, previous?: ChatHistory): Promise { // 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 exact = Boolean(options.bound || options.managed); + const listed = options.managed ? {sessions: [await resolveManagedChatSession(options.managed, AbortSignal.timeout(5000))]} + : (!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) => { const saved = known.get(session.session_id)?.session; - return !saved || (options.bound && (!session.transcript_revision + return !saved || (exact && (!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); + if (exact) 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."); } + if (options.managed && (snapshot.ok !== true || snapshot.schema_version !== "loopx_chat_store_v1" || snapshot.session.goal_id !== session.goal_id || snapshot.session.managed_agent_id !== session.managed_agent_id + || snapshot.session.goal_instance_id !== session.goal_instance_id || snapshot.session.channel_id !== session.channel_id + || snapshot.session.session_mode !== "managed_runtime")) throw new Error("The original managed Session scope changed during history read."); return snapshot; })); for (const result of results) { diff --git a/apps/presentation/dashboard/src/data/use-conversation-history.ts b/apps/presentation/dashboard/src/data/use-conversation-history.ts index efb77653b5..15aa12ede1 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, type BoundConversationTarget } from "./chat"; +import { fetchChatHistory, type ChatHistory, type BoundConversationTarget, type ManagedConversationTarget } from "./chat"; type HistoryState = { scope: string; @@ -16,33 +16,36 @@ export function currentChannelSession(history: Pick | n } /** Read recovery shared by steward and Goal conversations; never executes a Turn. */ -export function useConversationHistory({ agentId, currentAgentId, channelId, goalId, enabled, bound }: { +export function useConversationHistory({ agentId, currentAgentId, channelId, goalId, enabled, bound, managed }: { agentId?: string; currentAgentId: string; channelId: string; goalId?: string; enabled: boolean; bound?: BoundConversationTarget; + managed?: ManagedConversationTarget; }) { const bindingId = bound?.bindingId, sessionId = bound?.sessionId; - const scope = JSON.stringify([agentId ?? null, currentAgentId, channelId, goalId ?? null, bindingId, sessionId]); + const managedGoal = managed?.goalId, managedAgent = managed?.agentId, managedSession = managed?.sessionId; + const exact = Boolean(bound || managed); + const scope = JSON.stringify([agentId ?? null, currentAgentId, channelId, goalId ?? null, bindingId, sessionId, managedGoal, managedAgent, managedSession]); 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, bound }); - params.current = { agentId, channelId, goalId, scope, bound }; + const params = useRef({ agentId, channelId, goalId, scope, bound, managed }); + params.current = { agentId, channelId, goalId, scope, bound, managed }; // 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, bound } = params.current; + const { agentId, channelId, goalId, scope, bound, managed } = params.current; const generation = ++refreshGeneration.current; let loaded: ChatHistory; try { - loaded = await fetchChatHistory({ agentId, channelId, goalId, bound }); + loaded = await fetchChatHistory({ agentId, channelId, goalId, bound, managed }); } catch (error) { if (params.current.scope === scope && generation === refreshGeneration.current) { setState({ scope, history: cachedHistory(scope), reading: false, failed: true }); @@ -80,10 +83,11 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa const isCurrentRead = () => !cancelled && params.current.scope === scope && generation === refreshGeneration.current && cachedHistory(scope) === previous; setState(current => ({ scope, history: previous, reading: true, - failed: bindingId !== undefined && current?.scope === scope && current.failed })); + failed: exact && current?.scope === scope && current.failed })); try { const loaded = await fetchChatHistory({ agentId, channelId, goalId, - bound: bindingId === undefined ? undefined : { bindingId, sessionId: sessionId ?? "" } }, previous ?? undefined); + bound: bindingId === undefined ? undefined : { bindingId, sessionId: sessionId ?? "" }, + managed: managedGoal === undefined ? undefined : {goalId: managedGoal, agentId: managedAgent ?? "", sessionId: managedSession ?? ""} }, previous ?? undefined); if (!isCurrentRead()) return; cached.current = { scope, history: loaded }; setState({ scope, history: loaded, reading: false, failed: false }); @@ -93,7 +97,7 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa } finally { reading = false; const known = cachedHistory(scope); - if (!cancelled && (bindingId !== undefined || !known || known.unavailableSessionIds.length > 0)) { + if (!cancelled && (exact || !known || known.unavailableSessionIds.length > 0)) { // Back off sustained transport failures; a user can retry immediately. retryTimer = setTimeout(() => void read(), known && !known.unavailableSessionIds.length ? 5000 : Math.min(3000 * 2 ** attempts++, 30_000)); @@ -108,7 +112,7 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa clearTimeout(retryTimer); retryRead.current = null; }; - }, [agentId, channelId, goalId, enabled, scope, bindingId, sessionId]); + }, [agentId, channelId, goalId, enabled, scope, bindingId, sessionId, managedGoal, managedAgent, managedSession, exact]); const current = enabled && state?.scope === scope ? state : null; const history = current?.history ?? null; // A channel transcript spans executors; readability of another executor's @@ -124,10 +128,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 || (bound !== undefined && current?.failed === true)), + sendBlocked: enabled && (!currentSessionReadable || (exact && current?.failed === true)), // Missing older history must not restart a recovered current stream. - connectionKey: currentSessionReadable && !(bound && current?.failed) - ? `${scope}:${latest?.session_id ?? "empty"}${bound ? `:${latest?.active_turn_id ?? "idle"}` : ""}` : null, + connectionKey: currentSessionReadable && !(exact && current?.failed) + ? `${scope}:${latest?.session_id ?? "empty"}${exact ? `:${latest?.active_turn_id ?? "idle"}` : ""}` : null, retry, }; } diff --git a/apps/presentation/dashboard/src/data/use-managed-conversation.ts b/apps/presentation/dashboard/src/data/use-managed-conversation.ts new file mode 100644 index 0000000000..351c538200 --- /dev/null +++ b/apps/presentation/dashboard/src/data/use-managed-conversation.ts @@ -0,0 +1,19 @@ +import {useEffect, useState} from "react"; +import {resolveManagedChatSession, type ChatSessionSummary, type ManagedConversationTarget} from "./chat"; + +/** Resolve the original Session without opening an adapter or starting work. */ +export function useManagedConversation(target: ManagedConversationTarget | null, enabled: boolean) { + const goalId = target?.goalId, agentId = target?.agentId, sessionId = target?.sessionId; + const key = JSON.stringify([goalId, agentId, sessionId]); + const [state, setState] = useState<{key: string; session?: ChatSessionSummary; error?: string}>(); + const [revision, setRevision] = useState(0); + useEffect(() => { + if (!enabled || goalId === undefined || agentId === undefined || sessionId === undefined) return; + const controller = new AbortController(); + void resolveManagedChatSession({goalId, agentId, sessionId}, AbortSignal.any([controller.signal, AbortSignal.timeout(5000)])) + .then(session => {if (!controller.signal.aborted) setState({key, session});}) + .catch(error => {if (!controller.signal.aborted) setState({key, error: error instanceof Error ? error.message : String(error)});}); + return () => controller.abort(); + }, [goalId, agentId, sessionId, key, enabled, revision]); + return {...(enabled && state?.key === key ? state : {}), retry: () => setRevision(value => value + 1)}; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css index d31b945654..d94165c6bd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css @@ -77,3 +77,10 @@ .goal-agent-work-page form label {flex-basis: 100%;} .goal-agent-work-page form button[type="submit"] {margin-left: auto;} } + +.agent-work-session-source { display: flex; align-items: center; gap: 8px; margin-block: 8px 16px; color: var(--pw-muted); font-size: 14px; overflow-wrap: anywhere; } + +.goal-agent-work-page > .attached-conversation-heading { position: sticky; top: 0; z-index: 2; background: var(--pw-bg); } +.goal-agent-work-page > .attached-conversation-heading .agent-work-navigation { width: 100%; justify-content: space-between; } + +.goal-agent-work-page[data-managed] { height: auto; min-height: 100%; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx index 9ec869dc41..811e075b07 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -2,7 +2,7 @@ import {useEffect, useLayoutEffect, useRef, useState} from "react"; import {AttachedConversationTimeline, type ConversationRecord, type ContentPage} from "./attached-conversation-record"; import "./attached-agent-conversations.css"; -type Binding = {agent_id: string; thread_id: string; binding_ref: string}; +export type Binding = {agent_id: string; thread_id: string; binding_ref: string}; type Message = ConversationRecord; type Snapshot = {messages?: Message[]; before?: number | null; after?: number; has_more?: boolean; reset?: boolean; source_ref?: string; history_size?: number; revision: string; unchanged?: boolean; connected?: boolean; can_reply?: boolean; activity: {state: string}}; type Submission = {operation_id: string; text: string; status?: string}; @@ -14,7 +14,7 @@ class ConversationRequestError extends Error { constructor(message: string, readonly unsubmittedOperation?: string, readonly httpStatus?: number) {super(message);} } -async function request(url: string, options?: RequestInit): Promise { +export async function request(url: string, options?: RequestInit): Promise { const response = await fetch(url, {...options, signal: AbortSignal.timeout(6_000)}); // Read the body as text first: a service failure can answer with an HTML or // empty body, and the caller still needs the HTTP status to decide whether @@ -43,11 +43,11 @@ function interruptedRead(failure: unknown): boolean { return failure instanceof TypeError || (failure instanceof DOMException && failure.name === "TimeoutError"); } -function retryableRead(failure: unknown): boolean { +export function retryableRead(failure: unknown): boolean { return interruptedRead(failure) || (failure instanceof ConversationRequestError && (failure.httpStatus ?? 0) >= 500); } -function readFailureMessage(failure: unknown, zh: boolean): string { +export function readFailureMessage(failure: unknown, zh: boolean): string { return retryableRead(failure) ? (zh ? "暂时无法读取原会话。已读记录会保留,连接恢复后将自动更新。" : "The conversation is temporarily unavailable. Existing records are retained; updates resume when the connection recovers.") : String(failure); @@ -79,68 +79,8 @@ async function readVisiblePage(params: URLSearchParams, current: () => boolean): return forward ? page : {...page, after: newestBoundary, has_more: newerAvailable}; } -/** Select an exact original Session within the registered Agent, never an executor. */ -export function AttachedAgentConversations({goalId, zh, targetAgentId}: { - goalId: string; zh: boolean; targetAgentId: string; -}) { - const selectionKey = `loopx-attached-selection:${goalId}:${targetAgentId}`; - const [selected, setSelected] = useState(() => { - try {return sessionStorage.getItem(selectionKey) ?? "";} catch {return "";} - }); - const [bindings, setBindings] = useState([]); - const [discovering, setDiscovering] = useState(true); - const [error, setError] = useState(""); - const [refresh, setRefresh] = useState(0); - const locale = useRef(zh); - useEffect(() => {locale.current = zh;}, [zh]); - useEffect(() => { - let active = true; - let timer: ReturnType; - setBindings([]); setDiscovering(true); setError(""); - async function discover() { - try { - const value = await request<{agents: Binding[]}>(`/api/chat/codex-attached?${new URLSearchParams({goal_id: goalId})}`); - if (!Array.isArray(value.agents)) throw new Error("Conversation directory unavailable"); - const choices = value.agents.filter(row => row.agent_id === targetAgentId); - if (active) { - setBindings(choices); setError(""); - // A saved choice that was revoked stays unavailable. Never substitute - // another Session or carry its draft into a different conversation. - setSelected(previous => previous || (choices.length === 1 ? choices[0].binding_ref : "")); - } - } catch (failure) { - if (active) { - setError(readFailureMessage(failure, locale.current)); - if (retryableRead(failure)) timer = setTimeout(discover, 1000); - } - } finally {if (active) setDiscovering(false);} - } - void discover(); - return () => {active = false; clearTimeout(timer);}; - }, [goalId, targetAgentId, refresh]); - const binding = bindings.find(row => row.binding_ref === selected); - const reconnect = () => setRefresh(value => value + 1); - return <> - {!discovering && bindings.length > 0 && (bindings.length > 1 || selected && !binding) ? : null} - {discovering ?

{zh ? "正在连接原会话…" : "Connecting to the original conversation…"}

- : error ?

{error}

- : !bindings.length ?

{zh ? "此 Agent 暂无已绑定的本机 Codex App 原会话。" : "This Agent has no bound local Codex App conversation."}

: null} - {binding ? : null} - ; -} - /** Remount on binding changes so history, live readers and pending input cannot cross Sessions. */ -function AttachedConversation({goalId, binding, zh, reconnect}: { +export function AttachedConversation({goalId, binding, zh, reconnect}: { goalId: string; binding: Binding; zh: boolean; reconnect: () => void; }) { const [messages, setMessages] = useState([]); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-agent-work-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-agent-work-page.tsx index b6bbd73a2b..09d6ca5ecc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-agent-work-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-agent-work-page.tsx @@ -1,13 +1,15 @@ import {CircleHelp} from "lucide-react"; import {ContextHelp} from "./context-help"; -import {useEffect, useRef} from "react"; -import {AttachedAgentConversations} from "./attached-agent-conversations"; +import {useEffect, useRef, useState, type ReactNode} from "react"; +import {AttachedConversation, request, retryableRead, readFailureMessage, type Binding} from "./attached-agent-conversations"; +import {fetchManagedAgentSessions, type ChatSessionSummary} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; import type {WorkspaceGoal} from "./personal-workspace-model"; /** Navigation uses the Goal directory; only the native provider can authorize a conversation. */ -export function GoalAgentWorkPage({goal, agentId, readOnly, onSelect, onClose}: { +export function GoalAgentWorkPage({goal, agentId, readOnly, sessionId, onSession, children, onSelect, onClose}: { goal: WorkspaceGoal; agentId: string; readOnly: boolean; + sessionId?: string; onSession: (sessionId: string, replace?: boolean) => void; children?: ReactNode; onSelect: (agentId: string) => void; onClose: () => void; }) { const zh = useWorkspaceI18n().locale === "zh-CN"; @@ -17,13 +19,12 @@ export function GoalAgentWorkPage({goal, agentId, readOnly, onSelect, onClose}: const lane = goal.agentLanes?.find(item => item.agentId === agentId); const registered = ids.includes(agentId); const hosts = lane?.hostSurfaces ?? []; - const supported = hosts.includes("codex-app"); const title = zh ? "工作记录" : "Work conversation"; - return
+ return
-

{title}

+

{title}

- {ids.length ? onSelect(event.target.value)}> {ids.map(id => )} : null} @@ -35,9 +36,81 @@ export function GoalAgentWorkPage({goal, agentId, readOnly, onSelect, onClose}: {!ids.length ?

{zh ? "此 Goal 尚无已注册的 Agent。" : "This Goal has no registered Agents."}

: null}
: readOnly ?

{zh ? "此来源仅提供状态快照,无法在这里读取或回复原会话。" : "This source provides a status snapshot; original conversations cannot be read or replied to here."}

: !registered ?

{zh ? "此 Agent 不在当前 Goal 的已注册列表中,请选择列表中的 Agent。" : "This Agent is not registered in the current Goal. Choose an Agent from its directory."}

- : !supported ?

{hosts.length - ? (zh ? `来源:${hosts.join("、")}。此宿主的工作记录与回复尚未接入。` : `Source: ${hosts.join(", ")}. Work records and replies from this host are not connected yet.`) - : (zh ? "此 Agent 尚无已绑定的会话来源。" : "This Agent has no bound conversation source yet.")}

- : } + : {children}}
; } + +/** Both providers stay under the registered Agent; the reader retains its original owner. */ +function AgentSessions({goalId, agentId, hosts, attached, zh, sessionId, onSession, children}: { + goalId: string; agentId: string; hosts: string[]; attached: boolean; zh: boolean; sessionId?: string; + onSession: (sessionId: string, replace?: boolean) => void; children?: ReactNode; +}) { + const selectionKey = `loopx-attached-selection:${goalId}:${agentId}`; + const [selected, setSelected] = useState(() => { + if (sessionId !== undefined) return sessionId; + try {return sessionStorage.getItem(selectionKey) ?? "";} catch {return "";} + }); + const [directory, setDirectory] = useState<{bindings: Binding[]; managed: ChatSessionSummary[]; errors: string[]} | null>(null); + const [revision, setRevision] = useState(0); + const reconnect = () => setRevision(value => value + 1); + const previousRoute = useRef(sessionId); + const selectedValue = sessionId !== undefined ? sessionId : previousRoute.current !== sessionId ? "" : selected; + useEffect(() => { + if (previousRoute.current !== sessionId) {previousRoute.current = sessionId; setSelected(sessionId ?? "");} + }, [sessionId]); + useEffect(() => { + const controller = new AbortController(); + let retry: ReturnType; + setDirectory(null); + void Promise.allSettled([ + attached ? request<{agents: Binding[]}>(`/api/chat/codex-attached?${new URLSearchParams({goal_id: goalId})}`, + {signal: AbortSignal.any([controller.signal, AbortSignal.timeout(6000)])}) : Promise.resolve({agents: []}), + fetchManagedAgentSessions(goalId, agentId, AbortSignal.any([controller.signal, AbortSignal.timeout(5000)])), + ]).then(([host, native]) => { + if (controller.signal.aborted) return; + const errors = [host, native].flatMap(result => result.status === "rejected" ? [readFailureMessage(result.reason, zh)] : []); + if (host.status === "rejected" && retryableRead(host.reason)) retry = setTimeout(reconnect, 1000); + const bindings = host.status === "fulfilled" && Array.isArray(host.value.agents) ? host.value.agents.filter(row => row.agent_id === agentId) : []; + const managed = native.status === "fulfilled" ? native.value.sessions : []; + setDirectory({bindings, managed, errors}); + // A revoked saved choice never silently substitutes another Session. + if (!selected && bindings.length + managed.length === 1) { + const value = bindings[0]?.binding_ref ?? `managed:${managed[0].session_id}`; + setSelected(value); onSession(value, true); + } else if (selected.startsWith("managed:") && sessionId === undefined) onSession(selected, true); + }); + return () => {controller.abort(); clearTimeout(retry);}; + // The route is a read selection, not a reason to reload the directory. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [goalId, agentId, attached, revision]); + const binding = directory?.bindings.find(row => row.binding_ref === selectedValue); + const managed = directory?.managed.find(row => `managed:${row.session_id}` === selectedValue); + const count = (directory?.bindings.length ?? 0) + (directory?.managed.length ?? 0); + return <> + {directory && count > 0 && (count > 1 || selectedValue && !binding && !managed) ? : null} + {!directory ?

{zh ? "正在连接原会话…" : "Connecting to the original conversation…"}

: null} + {directory?.errors.length && !count ?

{directory.errors.join(" ")}

: null} + {directory?.errors.length && count > 0 ? + {directory.errors.map((error, index) =>

{error}

)} + +
: null} + {directory && !count && !directory.errors.length ? +

{hosts.length ? (zh ? `来源:${hosts.join("、")}。此宿主的工作记录与回复尚未接入。` : `Source: ${hosts.join(", ")}. Work records and replies from this host are not connected yet.`) + : (zh ? "此 Agent 尚无已绑定的工作会话。原 Goal 对话记录仍独立保留。" : "This Agent has no bound work Sessions. Existing Goal conversations remain separate.")}

+
: null} + {managed ? <>
LoopX · {managed.agent_id} +

{managed.session_id}

{managed.agent_model_id ?? (zh ? "模型未读取" : "Model not observed")} · {managed.agent_reasoning_effort ?? "—"}

+
{children} : binding ? : null} + ; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 69d8a1826c..501853021c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -116,6 +116,7 @@ export type WorkspaceConversationDirectory = { onOpenOriginal?: (target: BoundConversationTarget) => void; bound?: { target: BoundConversationTarget; session?: ChatSessionSummary; source?: PrivateConversation; error?: string; retry: () => void }; + managed?: {target: import("../../data/chat").ManagedConversationTarget; session?: import("../../data/chat").ChatSessionSummary; error?: string; retry: () => void}; }; export type WorkspaceTodo = WorkspaceAgentTodo & { @@ -625,6 +626,7 @@ export type PersonalWorkspaceCallbacks = { onSelectTodo?: (todoId: string | null) => void; onSelectView?: (view: WorkspaceGoalTab) => void; onSelectWorkAgent?: (agentId: string | null) => void; + onSelectWorkSession?: (sessionId: string, replace?: boolean) => void; onOpenNotificationSettings?: (goalId?: string) => void; onFetchNotificationTargets?: () => Promise>; onSetupGoalChannel?: (options: { execute: boolean; goalId: string; target: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 211a1c156b..b146564bf0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -924,6 +924,7 @@ export function PersonalWorkspacePage({ selectedView: controlledView, selectedTodoId: routedTodoId, selectedWorkAgentId, + selectedWorkSessionId, managerChannelBinding, managerRuntime, model, @@ -956,6 +957,7 @@ export function PersonalWorkspacePage({ selectedView?: WorkspaceGoalTab; selectedTodoId?: string | null; selectedWorkAgentId?: string | null; + selectedWorkSessionId?: string; managerChannelBinding?: ManagerChannelBinding | null; managerRuntime?: ManagerRuntimeSessionReadback | null; model: WorkspaceModel; @@ -1009,6 +1011,7 @@ export function PersonalWorkspacePage({ const [localView, setLocalView] = useState("overview"); const selectedGoalTab = controlledView ?? localView; const agentWorkActive = Boolean(controlledGoalId === undefined ? localGoalId : controlledGoalId) && selectedGoalTab === "chat" && selectedWorkAgentId != null; + const managedWorkActive = agentWorkActive && selectedWorkSessionId?.startsWith("managed:") === true; const workReturn = useRef<{scope: string; agentId: string} | null>(null); useEffect(() => { if (agentWorkActive) {workReturn.current = {scope: selectionScope, agentId: selectedWorkAgentId!}; return;} @@ -1100,10 +1103,10 @@ export function PersonalWorkspacePage({ setRecheckingRuntimes(true); callbacks.onRecheckRuntimes(); } - const conversationKey = originalConversation ? conversationContextKey({ kind: "bound", ...originalConversation.target }) : selectedWorkspaceRef + const conversationKey = workspaceConversations?.managed ? conversationContextKey({kind: "managed", ...workspaceConversations.managed.target}) : originalConversation ? conversationContextKey({ kind: "bound", ...originalConversation.target }) : selectedWorkspaceRef ? conversationContextKey({ kind: "project", projectRef: selectedWorkspaceRef }) : selectedGoalId ?? "manager"; - const composerDraftKey = `${conversationKey}:${selectedAgentId}`; + const composerDraftKey = managedWorkActive ? conversationKey : `${conversationKey}:${selectedAgentId}`; const { composer, setComposer, restoreFailedSubmission, sending, setSending, steering, setSteering, actionFeedback, setActionFeedback, imageAttachments, setImageAttachments, imageAttachmentError, setImageAttachmentError, @@ -1362,7 +1365,7 @@ export function PersonalWorkspacePage({ || managerChannelProposalIds.includes(item.proposal.previewId)))), [items, managerSessionProposalIds, managerChannelProposalIds], ); - const conversationOpen = !agentWorkActive && (selectedGoal ? selectedGoalTab === "chat" : managerChatOpen); + const conversationOpen = (!agentWorkActive || managedWorkActive) && (selectedGoal ? selectedGoalTab === "chat" : managerChatOpen); const conversationMessages = selectedGoal ? goalMessages : managerMessages; const latestMessage = conversationMessages.at(-1); const latestMessageTextLength = latestMessage?.text.length ?? 0; @@ -2319,7 +2322,7 @@ export function PersonalWorkspacePage({ async function sendMessage(messageOverride?: string, intent: "send" | "steer" = "send") { const pendingImages = messageOverride ? [] : imageAttachments; const message = (messageOverride ?? composer).trim() || (pendingImages.length ? t("composer.imageAnalysisPrompt") : ""); - if (!message || composerBlocked || workspaceUnavailable || (originalConversation && !originalConversation.session?.resumable) || conversationHistoryState?.sendBlocked) return; + if (!message || composerBlocked || workspaceUnavailable || (originalConversation && !originalConversation.session?.resumable) || (workspaceConversations?.managed && !workspaceConversations.managed.session?.resumable) || conversationHistoryState?.sendBlocked) return; const previousSteering = readSteeringRequest(composerDraftKey); const retry = previousSteering && previousSteering.sessionId === conversationSessionId && previousSteering.text.trim() === message ? previousSteering : undefined; @@ -2816,7 +2819,7 @@ export function PersonalWorkspacePage({ key={`${selectedGoalId}:${selectedAgentId}`} sessionId={conversationSessionId} onChange={setLoopxMode} onExecute={(operation, settings) => callbacks.onStartLoopX?.(operation, selectedAgentId, selectedGoalId, settings)} /> : null} -
{ if (!conversationOpen) return; const { scrollHeight, scrollTop, clientHeight } = event.currentTarget; @@ -2872,7 +2875,14 @@ export function PersonalWorkspacePage({ :

{locale === "zh-CN" ? "此来源尚未提供研究记录的读取入口。" : "This source does not provide a research history endpoint."}

, files: goalResults(selectedGoalTab === "files" && !settingsOpen), chat: agentWorkActive ? callbacks.onSelectWorkAgent?.(id)} onClose={() => callbacks.onSelectWorkAgent?.(null)}/> : (<> + sessionId={selectedWorkSessionId} onSession={(id, replace) => callbacks.onSelectWorkSession?.(id, replace)} + onSelect={id => callbacks.onSelectWorkAgent?.(id)} onClose={() => callbacks.onSelectWorkAgent?.(null)}> + {workspaceConversations?.managed?.error ?

{workspaceConversations.managed.error}

: null} + item.kind === "message")} onSelect={setSelection} selectedGoal={selectedGoal} + onAdjustTurn={steeringTurnId ? focusTurnComposer : undefined} + onCancelPreparation={!readOnly && callbacks.onCancelConversationPreparation ? () => callbacks.onCancelConversationPreparation!(conversationKey) : undefined} + onInterruptTurn={!readOnly && callbacks.onInterruptConversationTurn ? turnId => callbacks.onInterruptConversationTurn!(conversationKey, turnId) : undefined}/> +
: (<> {selectedGoal && activeSessionRun?.goalId === selectedGoal.goalId ? ( setActiveSessionRun(null)} @@ -2909,7 +2919,7 @@ export function PersonalWorkspacePage({ )} : null}
- {!rootUnavailable ?