Skip to content

Commit 2adeeee

Browse files
committed
feat(threads): add thread read RPC and subagent metadata hydration
1 parent c7ef76e commit 2adeeee

20 files changed

Lines changed: 839 additions & 70 deletions

src-tauri/src/bin/codex_monitor_daemon.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,14 @@ impl DaemonState {
692692
codex_core::resume_thread_core(&self.sessions, workspace_id, thread_id).await
693693
}
694694

695+
async fn read_thread(
696+
&self,
697+
workspace_id: String,
698+
thread_id: String,
699+
) -> Result<Value, String> {
700+
codex_core::read_thread_core(&self.sessions, workspace_id, thread_id).await
701+
}
702+
695703
async fn thread_live_subscribe(
696704
&self,
697705
workspace_id: String,

src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ pub(super) async fn try_handle(
4848
};
4949
Some(state.resume_thread(workspace_id, thread_id).await)
5050
}
51+
"read_thread" => {
52+
let workspace_id = match parse_string(params, "workspaceId") {
53+
Ok(value) => value,
54+
Err(err) => return Some(Err(err)),
55+
};
56+
let thread_id = match parse_string(params, "threadId") {
57+
Ok(value) => value,
58+
Err(err) => return Some(Err(err)),
59+
};
60+
Some(state.read_thread(workspace_id, thread_id).await)
61+
}
5162
"thread_live_subscribe" => {
5263
let workspace_id = match parse_string(params, "workspaceId") {
5364
Ok(value) => value,

src-tauri/src/codex/mod.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ pub(crate) async fn resume_thread(
110110
codex_core::resume_thread_core(&state.sessions, workspace_id, thread_id).await
111111
}
112112

113+
#[tauri::command]
114+
pub(crate) async fn read_thread(
115+
workspace_id: String,
116+
thread_id: String,
117+
state: State<'_, AppState>,
118+
app: AppHandle,
119+
) -> Result<Value, String> {
120+
if remote_backend::is_remote_mode(&*state).await {
121+
return remote_backend::call_remote(
122+
&*state,
123+
app,
124+
"read_thread",
125+
json!({ "workspaceId": workspace_id, "threadId": thread_id }),
126+
)
127+
.await;
128+
}
129+
130+
codex_core::read_thread_core(&state.sessions, workspace_id, thread_id).await
131+
}
132+
113133
#[tauri::command]
114134
pub(crate) async fn thread_live_subscribe(
115135
workspace_id: String,

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ pub fn run() {
219219
codex::generate_run_metadata,
220220
codex::generate_agent_description,
221221
codex::resume_thread,
222+
codex::read_thread,
222223
codex::thread_live_subscribe,
223224
codex::thread_live_unsubscribe,
224225
codex::fork_thread,

src-tauri/src/remote_backend/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ fn can_retry_after_disconnect(method: &str) -> bool {
173173
| "list_workspace_files"
174174
| "list_workspaces"
175175
| "model_list"
176+
| "read_thread"
176177
| "read_agent_config_toml"
177178
| "read_workspace_file"
178179
| "resume_thread"

src-tauri/src/shared/codex_core.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,18 @@ pub(crate) async fn resume_thread_core(
276276
.await
277277
}
278278

279+
pub(crate) async fn read_thread_core(
280+
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
281+
workspace_id: String,
282+
thread_id: String,
283+
) -> Result<Value, String> {
284+
let session = get_session_clone(sessions, &workspace_id).await?;
285+
let params = json!({ "threadId": thread_id });
286+
session
287+
.send_request_for_workspace(&workspace_id, "thread/read", params)
288+
.await
289+
}
290+
279291
pub(crate) async fn thread_live_subscribe_core(
280292
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
281293
workspace_id: String,

src/features/threads/hooks/useThreadActions.ts

Lines changed: 9 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,11 @@ import {
2424
previewThreadName,
2525
} from "@utils/threadItems";
2626
import { extractThreadCodexMetadata } from "@threads/utils/threadCodexMetadata";
27-
import {
28-
asString,
29-
normalizeRootPath,
30-
} from "@threads/utils/threadNormalize";
27+
import { buildThreadSummaryFromThread } from "@threads/utils/threadSummary";
28+
import { asString, normalizeRootPath } from "@threads/utils/threadNormalize";
3129
import {
3230
getParentThreadIdFromThread,
3331
getResumedTurnState,
34-
isSubagentThreadSource,
3532
shouldHideSubagentThreadFromSidebar,
3633
} from "@threads/utils/threadRpc";
3734
import { saveThreadActivity } from "@threads/utils/threadStorage";
@@ -113,20 +110,6 @@ function getThreadListNextCursor(result: Record<string, unknown>): string | null
113110
return null;
114111
}
115112

116-
function getThreadSubagentMetadata(thread: Record<string, unknown>) {
117-
const nickname =
118-
asString(thread.agentNickname ?? thread.agent_nickname ?? "").trim() || null;
119-
const role =
120-
asString(
121-
thread.agentRole ??
122-
thread.agent_role ??
123-
thread.agentType ??
124-
thread.agent_type ??
125-
"",
126-
).trim() || null;
127-
return { nickname, role };
128-
}
129-
130113
type UseThreadActionsOptions = {
131114
dispatch: Dispatch<ThreadAction>;
132115
itemsByThread: ThreadState["itemsByThread"];
@@ -521,43 +504,13 @@ export function useThreadActions({
521504
workspaceId: string,
522505
thread: Record<string, unknown>,
523506
fallbackIndex: number,
524-
): ThreadSummary | null => {
525-
const id = String(thread?.id ?? "");
526-
if (!id) {
527-
return null;
528-
}
529-
const preview = asString(thread?.preview ?? "").trim();
530-
const customName = getCustomName(workspaceId, id);
531-
const fallbackName = `Agent ${fallbackIndex + 1}`;
532-
const name = customName
533-
? customName
534-
: preview.length > 0
535-
? preview.length > 38
536-
? `${preview.slice(0, 38)}…`
537-
: preview
538-
: fallbackName;
539-
const metadata = extractThreadCodexMetadata(thread);
540-
if (shouldHideSubagentThreadFromSidebar(thread.source)) {
541-
return null;
542-
}
543-
const isSubagent = isSubagentThreadSource(thread.source);
544-
const subagentMetadata = getThreadSubagentMetadata(thread);
545-
return {
546-
id,
547-
name,
548-
updatedAt: getThreadTimestamp(thread),
549-
createdAt: getThreadCreatedTimestamp(thread),
550-
...(metadata.modelId ? { modelId: metadata.modelId } : {}),
551-
...(metadata.effort ? { effort: metadata.effort } : {}),
552-
...(isSubagent ? { isSubagent: true } : {}),
553-
...(isSubagent && subagentMetadata.nickname
554-
? { subagentNickname: subagentMetadata.nickname }
555-
: {}),
556-
...(isSubagent && subagentMetadata.role
557-
? { subagentRole: subagentMetadata.role }
558-
: {}),
559-
};
560-
},
507+
): ThreadSummary | null =>
508+
buildThreadSummaryFromThread({
509+
workspaceId,
510+
thread,
511+
fallbackIndex,
512+
getCustomName,
513+
}),
561514
[getCustomName],
562515
);
563516

src/features/threads/hooks/useThreadEventHandlers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react";
22
import type { Dispatch, MutableRefObject } from "react";
33
import type {
44
AppServerEvent,
5+
CollabAgentRef,
56
ConversationItem,
67
DebugEntry,
78
RateLimitSnapshot,
@@ -47,6 +48,10 @@ type ThreadEventHandlersOptions = {
4748
threadId: string,
4849
item: Record<string, unknown>,
4950
) => void;
51+
hydrateSubagentThreads?: (
52+
workspaceId: string,
53+
receivers: CollabAgentRef[],
54+
) => void | Promise<void>;
5055
onReviewExited?: (workspaceId: string, threadId: string) => void;
5156
approvalAllowlistRef: MutableRefObject<Record<string, string[][]>>;
5257
pendingInterruptsRef: MutableRefObject<Set<string>>;
@@ -72,6 +77,7 @@ export function useThreadEventHandlers({
7277
onDebug,
7378
onWorkspaceConnected,
7479
applyCollabThreadLinks,
80+
hydrateSubagentThreads,
7581
onReviewExited,
7682
approvalAllowlistRef,
7783
pendingInterruptsRef,
@@ -143,6 +149,7 @@ export function useThreadEventHandlers({
143149
safeMessageActivity,
144150
recordThreadActivity,
145151
applyCollabThreadLinks,
152+
hydrateSubagentThreads,
146153
onUserMessageCreated,
147154
onReviewExited,
148155
});

src/features/threads/hooks/useThreadItemEvents.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback } from "react";
22
import type { Dispatch } from "react";
33
import { buildConversationItem } from "@utils/threadItems";
4+
import type { CollabAgentRef } from "@/types";
45
import { asString } from "@threads/utils/threadNormalize";
56
import type { ThreadAction } from "./useThreadsReducer";
67

@@ -21,6 +22,10 @@ type UseThreadItemEventsOptions = {
2122
threadId: string,
2223
item: Record<string, unknown>,
2324
) => void;
25+
hydrateSubagentThreads?: (
26+
workspaceId: string,
27+
receivers: CollabAgentRef[],
28+
) => void | Promise<void>;
2429
onUserMessageCreated?: (
2530
workspaceId: string,
2631
threadId: string,
@@ -38,6 +43,7 @@ export function useThreadItemEvents({
3843
safeMessageActivity,
3944
recordThreadActivity,
4045
applyCollabThreadLinks,
46+
hydrateSubagentThreads,
4147
onUserMessageCreated,
4248
onReviewExited,
4349
}: UseThreadItemEventsOptions) {
@@ -72,6 +78,20 @@ export function useThreadItemEvents({
7278
: item;
7379
const converted = buildConversationItem(itemForDisplay);
7480
if (converted) {
81+
if (converted.kind === "tool" && converted.toolType === "collabToolCall") {
82+
const receivers = converted.collabReceivers?.length
83+
? converted.collabReceivers
84+
: converted.collabReceiver
85+
? [converted.collabReceiver]
86+
: [];
87+
const hydrationTargets = receivers.filter(
88+
(receiver) =>
89+
receiver.threadId && (!receiver.nickname || !receiver.role),
90+
);
91+
if (hydrationTargets.length > 0) {
92+
void hydrateSubagentThreads?.(workspaceId, hydrationTargets);
93+
}
94+
}
7595
if (converted.kind === "message" && converted.role === "user") {
7696
void onUserMessageCreated?.(workspaceId, threadId, converted.text);
7797
}
@@ -93,6 +113,7 @@ export function useThreadItemEvents({
93113
markReviewing,
94114
onReviewExited,
95115
onUserMessageCreated,
116+
hydrateSubagentThreads,
96117
safeMessageActivity,
97118
],
98119
);

src/features/threads/hooks/useThreadSelectors.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe("useThreadSelectors", () => {
1818
activeWorkspaceId: "workspace-1",
1919
activeThreadIdByWorkspace: { "workspace-1": "thread-1" },
2020
itemsByThread: { "thread-1": [messageItem] },
21+
threadsByWorkspace: {},
2122
}),
2223
);
2324

@@ -31,6 +32,7 @@ describe("useThreadSelectors", () => {
3132
activeWorkspaceId: null,
3233
activeThreadIdByWorkspace: { "workspace-1": "thread-1" },
3334
itemsByThread: { "thread-1": [messageItem] },
35+
threadsByWorkspace: {},
3436
}),
3537
);
3638

@@ -44,10 +46,75 @@ describe("useThreadSelectors", () => {
4446
activeWorkspaceId: "workspace-1",
4547
activeThreadIdByWorkspace: { "workspace-1": "thread-2" },
4648
itemsByThread: {},
49+
threadsByWorkspace: {},
4750
}),
4851
);
4952

5053
expect(result.current.activeThreadId).toBe("thread-2");
5154
expect(result.current.activeItems).toEqual([]);
5255
});
56+
57+
it("enriches collab tool items from active workspace thread metadata", () => {
58+
const collabItem: ConversationItem = {
59+
id: "collab-1",
60+
kind: "tool",
61+
toolType: "collabToolCall",
62+
title: "Collab: spawn_agent",
63+
detail: "From thread-parent → thread-child",
64+
status: "completed",
65+
output: "Investigate the issue\n\nthread-child: completed",
66+
collabSender: { threadId: "thread-parent" },
67+
collabReceiver: { threadId: "thread-child" },
68+
collabReceivers: [{ threadId: "thread-child" }],
69+
collabStatuses: [{ threadId: "thread-child", status: "completed" }],
70+
};
71+
72+
const { result } = renderHook(() =>
73+
useThreadSelectors({
74+
activeWorkspaceId: "workspace-1",
75+
activeThreadIdByWorkspace: { "workspace-1": "thread-parent" },
76+
itemsByThread: { "thread-parent": [collabItem] },
77+
threadsByWorkspace: {
78+
"workspace-1": [
79+
{
80+
id: "thread-child",
81+
name: "Review helper",
82+
updatedAt: 1,
83+
isSubagent: true,
84+
subagentNickname: "Atlas",
85+
subagentRole: "reviewer",
86+
},
87+
],
88+
},
89+
}),
90+
);
91+
92+
expect(result.current.activeItems).toEqual([
93+
{
94+
...collabItem,
95+
detail: "From thread-parent → Atlas [reviewer]",
96+
output: "Investigate the issue\n\nAtlas [reviewer]: completed",
97+
collabReceiver: {
98+
threadId: "thread-child",
99+
nickname: "Atlas",
100+
role: "reviewer",
101+
},
102+
collabReceivers: [
103+
{
104+
threadId: "thread-child",
105+
nickname: "Atlas",
106+
role: "reviewer",
107+
},
108+
],
109+
collabStatuses: [
110+
{
111+
threadId: "thread-child",
112+
nickname: "Atlas",
113+
role: "reviewer",
114+
status: "completed",
115+
},
116+
],
117+
},
118+
]);
119+
});
53120
});

0 commit comments

Comments
 (0)