From ccb64f2af8dcb6b66f26ba5d0c8448f21a13f08e Mon Sep 17 00:00:00 2001 From: ishanray Date: Wed, 4 Feb 2026 17:39:56 -0500 Subject: [PATCH 01/20] fix: accept alt collaboration modes responses and refine debug logging --- .../hooks/useCollaborationModes.test.tsx | 37 ++++++++++++++++- .../hooks/useCollaborationModes.ts | 40 ++++++++++++++++--- src/features/debug/hooks/useDebugLog.ts | 20 ++++++++-- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/features/collaboration/hooks/useCollaborationModes.test.tsx b/src/features/collaboration/hooks/useCollaborationModes.test.tsx index 43830e31..0c2f53cd 100644 --- a/src/features/collaboration/hooks/useCollaborationModes.test.tsx +++ b/src/features/collaboration/hooks/useCollaborationModes.test.tsx @@ -36,6 +36,12 @@ const makeModesResponse = () => ({ }, }); +const makeModesResponseArrayResult = () => ({ + result: [{ mode: "plan" }, { mode: "code" }], +}); + +const makeModesResponseTopLevelArray = () => [{ mode: "plan" }, { mode: "code" }]; + describe("useCollaborationModes", () => { afterEach(() => { vi.clearAllMocks(); @@ -94,5 +100,34 @@ describe("useCollaborationModes", () => { expect(result.current.selectedCollaborationModeId).toBeNull(); expect(result.current.collaborationModes).toEqual([]); }); -}); + it("accepts alternate response shapes from the backend", async () => { + vi.mocked(getCollaborationModes) + .mockResolvedValueOnce(makeModesResponseArrayResult() as any) + .mockResolvedValueOnce(makeModesResponseTopLevelArray() as any); + + const { result, rerender } = renderHook( + ({ workspace }: { workspace: WorkspaceInfo | null }) => + useCollaborationModes({ activeWorkspace: workspace, enabled: true }), + { + initialProps: { workspace: workspaceOne }, + }, + ); + + await waitFor(() => + expect(result.current.collaborationModes.map((mode) => mode.id)).toEqual([ + "plan", + "code", + ]), + ); + + rerender({ workspace: { ...workspaceOne, id: "workspace-1b" } }); + + await waitFor(() => + expect(result.current.collaborationModes.map((mode) => mode.id)).toEqual([ + "plan", + "code", + ]), + ); + }); +}); diff --git a/src/features/collaboration/hooks/useCollaborationModes.ts b/src/features/collaboration/hooks/useCollaborationModes.ts index b7d060b5..55282531 100644 --- a/src/features/collaboration/hooks/useCollaborationModes.ts +++ b/src/features/collaboration/hooks/useCollaborationModes.ts @@ -28,6 +28,35 @@ export function useCollaborationModes({ const workspaceId = activeWorkspace?.id ?? null; const isConnected = Boolean(activeWorkspace?.connected); + const extractModeList = useCallback((response: any): any[] => { + const candidates = [ + response?.result?.data, + response?.result?.modes, + response?.result, + response?.data, + response?.modes, + response, + ]; + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate; + } + if (candidate && typeof candidate === "object") { + const nested = (candidate as any).data ?? (candidate as any).modes; + if (Array.isArray(nested)) { + return nested; + } + if (nested && typeof nested === "object") { + const deep = (nested as any).data ?? (nested as any).modes; + if (Array.isArray(deep)) { + return deep; + } + } + } + } + return []; + }, []); + const selectedMode = useMemo( () => modes.find((mode) => mode.id === selectedModeId) ?? null, [modes, selectedModeId], @@ -57,7 +86,7 @@ export function useCollaborationModes({ label: "collaborationMode/list response", payload: response, }); - const rawData = response.result?.data ?? response.data ?? []; + const rawData = extractModeList(response); const data: CollaborationModeOption[] = rawData .map((item: any) => { if (!item || typeof item !== "object") { @@ -91,12 +120,12 @@ export function useCollaborationModes({ const labelSource = String(item.name ?? item.label ?? mode); - const normalizedValue = { + const normalizedValue: Record = { ...(item as Record), mode: normalizedMode, }; - return { + const option: CollaborationModeOption = { id: normalizedMode, label: formatCollaborationModeLabel(labelSource), mode: normalizedMode, @@ -107,8 +136,9 @@ export function useCollaborationModes({ : null, value: normalizedValue, }; + return option; }) - .filter(Boolean); + .filter((mode): mode is CollaborationModeOption => mode !== null); setModes(data); lastFetchedWorkspaceId.current = workspaceId; const preferredModeId = @@ -136,7 +166,7 @@ export function useCollaborationModes({ } finally { inFlight.current = false; } - }, [enabled, isConnected, onDebug, workspaceId]); + }, [enabled, extractModeList, isConnected, onDebug, workspaceId]); useEffect(() => { selectedModeIdRef.current = selectedModeId; diff --git a/src/features/debug/hooks/useDebugLog.ts b/src/features/debug/hooks/useDebugLog.ts index 15d135a4..f6bc7065 100644 --- a/src/features/debug/hooks/useDebugLog.ts +++ b/src/features/debug/hooks/useDebugLog.ts @@ -9,7 +9,7 @@ export function useDebugLog() { const [hasDebugAlerts, setHasDebugAlerts] = useState(false); const [debugPinned, setDebugPinned] = useState(false); - const shouldLogEntry = useCallback((entry: DebugEntry) => { + const isAlertEntry = useCallback((entry: DebugEntry) => { if (entry.source === "error" || entry.source === "stderr") { return true; } @@ -24,15 +24,27 @@ export function useDebugLog() { return false; }, []); + const shouldStoreEntry = useCallback( + (entry: DebugEntry) => { + if (debugOpen) { + return true; + } + return isAlertEntry(entry); + }, + [debugOpen, isAlertEntry], + ); + const addDebugEntry = useCallback( (entry: DebugEntry) => { - if (!shouldLogEntry(entry)) { + if (!shouldStoreEntry(entry)) { return; } - setHasDebugAlerts(true); + if (isAlertEntry(entry)) { + setHasDebugAlerts(true); + } setDebugEntries((prev) => [...prev, entry].slice(-MAX_DEBUG_ENTRIES)); }, - [shouldLogEntry], + [isAlertEntry, shouldStoreEntry], ); const handleCopyDebug = useCallback(async () => { From de1696fd0589aea78edeba86cdee1072c507fe4a Mon Sep 17 00:00:00 2001 From: ishanray Date: Wed, 4 Feb 2026 17:52:51 -0500 Subject: [PATCH 02/20] feat: include experimentalApi capability in initialize params --- src-tauri/src/backend/app_server.rs | 35 ++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/backend/app_server.rs b/src-tauri/src/backend/app_server.rs index 659e5d27..a0eb1c8e 100644 --- a/src-tauri/src/backend/app_server.rs +++ b/src-tauri/src/backend/app_server.rs @@ -34,6 +34,19 @@ fn extract_thread_id(value: &Value) -> Option { }) } +fn build_initialize_params(client_version: &str) -> Value { + json!({ + "clientInfo": { + "name": "codex_monitor", + "title": "Codex Monitor", + "version": client_version + }, + "capabilities": { + "experimentalApi": true + } + }) +} + pub(crate) struct WorkspaceSession { pub(crate) entry: WorkspaceEntry, pub(crate) child: Mutex, @@ -332,13 +345,7 @@ pub(crate) async fn spawn_workspace_session( } }); - let init_params = json!({ - "clientInfo": { - "name": "codex_monitor", - "title": "Codex Monitor", - "version": client_version - } - }); + let init_params = build_initialize_params(&client_version); let init_result = timeout( Duration::from_secs(15), session.send_request("initialize", init_params), @@ -372,7 +379,7 @@ pub(crate) async fn spawn_workspace_session( #[cfg(test)] mod tests { - use super::extract_thread_id; + use super::{build_initialize_params, extract_thread_id}; use serde_json::json; #[test] @@ -392,4 +399,16 @@ mod tests { let value = json!({ "params": {} }); assert_eq!(extract_thread_id(&value), None); } + + #[test] + fn build_initialize_params_enables_experimental_api() { + let params = build_initialize_params("1.2.3"); + assert_eq!( + params + .get("capabilities") + .and_then(|caps| caps.get("experimentalApi")) + .and_then(|value| value.as_bool()), + Some(true) + ); + } } From 0c722980af7f012968c672a9f4ba58c438a27019 Mon Sep 17 00:00:00 2001 From: ishanray Date: Wed, 4 Feb 2026 18:14:57 -0500 Subject: [PATCH 03/20] Show collaboration modes verbatim --- .../hooks/useCollaborationModes.test.tsx | 14 +++---- .../hooks/useCollaborationModes.ts | 40 ++++++++++--------- .../composer/components/ComposerMetaBar.tsx | 3 +- .../workspaces/components/WorkspaceHome.tsx | 3 +- src/utils/collaborationModes.ts | 3 ++ 5 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/features/collaboration/hooks/useCollaborationModes.test.tsx b/src/features/collaboration/hooks/useCollaborationModes.test.tsx index 0c2f53cd..37ee58e3 100644 --- a/src/features/collaboration/hooks/useCollaborationModes.test.tsx +++ b/src/features/collaboration/hooks/useCollaborationModes.test.tsx @@ -32,15 +32,15 @@ const workspaceTwoConnected: WorkspaceInfo = { const makeModesResponse = () => ({ result: { - data: [{ mode: "plan" }, { mode: "code" }], + data: [{ mode: "plan" }, { mode: "default" }], }, }); const makeModesResponseArrayResult = () => ({ - result: [{ mode: "plan" }, { mode: "code" }], + result: [{ mode: "plan" }, { mode: "default" }], }); -const makeModesResponseTopLevelArray = () => [{ mode: "plan" }, { mode: "code" }]; +const makeModesResponseTopLevelArray = () => [{ mode: "plan" }, { mode: "default" }]; describe("useCollaborationModes", () => { afterEach(() => { @@ -58,7 +58,7 @@ describe("useCollaborationModes", () => { }, ); - await waitFor(() => expect(result.current.selectedCollaborationModeId).toBe("code")); + await waitFor(() => expect(result.current.selectedCollaborationModeId).toBe("default")); act(() => { result.current.setSelectedCollaborationModeId("plan"); @@ -88,7 +88,7 @@ describe("useCollaborationModes", () => { }, ); - await waitFor(() => expect(result.current.selectedCollaborationModeId).toBe("code")); + await waitFor(() => expect(result.current.selectedCollaborationModeId).toBe("default")); act(() => { result.current.setSelectedCollaborationModeId("plan"); @@ -117,7 +117,7 @@ describe("useCollaborationModes", () => { await waitFor(() => expect(result.current.collaborationModes.map((mode) => mode.id)).toEqual([ "plan", - "code", + "default", ]), ); @@ -126,7 +126,7 @@ describe("useCollaborationModes", () => { await waitFor(() => expect(result.current.collaborationModes.map((mode) => mode.id)).toEqual([ "plan", - "code", + "default", ]), ); }); diff --git a/src/features/collaboration/hooks/useCollaborationModes.ts b/src/features/collaboration/hooks/useCollaborationModes.ts index 55282531..71ab17e7 100644 --- a/src/features/collaboration/hooks/useCollaborationModes.ts +++ b/src/features/collaboration/hooks/useCollaborationModes.ts @@ -5,7 +5,6 @@ import type { WorkspaceInfo, } from "../../../types"; import { getCollaborationModes } from "../../../services/tauri"; -import { formatCollaborationModeLabel } from "../../../utils/collaborationModes"; type UseCollaborationModesOptions = { activeWorkspace: WorkspaceInfo | null; @@ -92,12 +91,8 @@ export function useCollaborationModes({ if (!item || typeof item !== "object") { return null; } - const mode = String(item.mode ?? item.name ?? ""); - if (!mode) { - return null; - } - const normalizedMode = mode.trim().toLowerCase(); - if (normalizedMode && normalizedMode !== "plan" && normalizedMode !== "code") { + const modeId = String(item.mode ?? item.name ?? "").trim(); + if (!modeId) { return null; } @@ -118,23 +113,23 @@ export function useCollaborationModes({ const reasoningEffort = settings.reasoning_effort ?? null; const developerInstructions = settings.developer_instructions ?? null; - const labelSource = String(item.name ?? item.label ?? mode); - - const normalizedValue: Record = { - ...(item as Record), - mode: normalizedMode, - }; + const labelSource = + typeof item.label === "string" && item.label.trim() + ? item.label + : typeof item.name === "string" && item.name.trim() + ? item.name + : modeId; const option: CollaborationModeOption = { - id: normalizedMode, - label: formatCollaborationModeLabel(labelSource), - mode: normalizedMode, + id: modeId, + label: labelSource, + mode: modeId, model, reasoningEffort: reasoningEffort ? String(reasoningEffort) : null, developerInstructions: developerInstructions ? String(developerInstructions) : null, - value: normalizedValue, + value: item as Record, }; return option; }) @@ -142,7 +137,16 @@ export function useCollaborationModes({ setModes(data); lastFetchedWorkspaceId.current = workspaceId; const preferredModeId = - data.find((mode) => mode.mode === "code" || mode.id === "code")?.id ?? + data.find( + (mode) => + mode.id.trim().toLowerCase() === "default" || + mode.mode.trim().toLowerCase() === "default", + )?.id ?? + data.find( + (mode) => + mode.id.trim().toLowerCase() === "code" || + mode.mode.trim().toLowerCase() === "code", + )?.id ?? data[0]?.id ?? null; setSelectedModeId((currentSelection) => { diff --git a/src/features/composer/components/ComposerMetaBar.tsx b/src/features/composer/components/ComposerMetaBar.tsx index 439feebf..138cb5f6 100644 --- a/src/features/composer/components/ComposerMetaBar.tsx +++ b/src/features/composer/components/ComposerMetaBar.tsx @@ -1,6 +1,5 @@ import type { CSSProperties } from "react"; import type { AccessMode, ThreadTokenUsage } from "../../../types"; -import { formatCollaborationModeLabel } from "../../../utils/collaborationModes"; type ComposerMetaBarProps = { disabled: boolean; @@ -74,7 +73,7 @@ export function ComposerMetaBar({ > {collaborationModes.map((mode) => ( ))} diff --git a/src/features/workspaces/components/WorkspaceHome.tsx b/src/features/workspaces/components/WorkspaceHome.tsx index c8735cbe..dd578331 100644 --- a/src/features/workspaces/components/WorkspaceHome.tsx +++ b/src/features/workspaces/components/WorkspaceHome.tsx @@ -17,7 +17,6 @@ import type { SkillOption, WorkspaceInfo, } from "../../../types"; -import { formatCollaborationModeLabel } from "../../../utils/collaborationModes"; import { ComposerInput } from "../../composer/components/ComposerInput"; import { useComposerImages } from "../../composer/hooks/useComposerImages"; import { useComposerAutocompleteState } from "../../composer/hooks/useComposerAutocompleteState"; @@ -733,7 +732,7 @@ export function WorkspaceHome({ > {collaborationModes.map((mode) => ( ))} diff --git a/src/utils/collaborationModes.ts b/src/utils/collaborationModes.ts index 58a573ff..80ae931b 100644 --- a/src/utils/collaborationModes.ts +++ b/src/utils/collaborationModes.ts @@ -16,6 +16,9 @@ export function formatCollaborationModeLabel(value: string) { if (lower === "plan") { return "Plan"; } + if (lower === "default") { + return "Default"; + } if (lower === "execute") { return "Execute"; } From e93e790ec7f7f74099a9f853499d0f69e6ab3700 Mon Sep 17 00:00:00 2001 From: ishanray Date: Wed, 4 Feb 2026 18:24:02 -0500 Subject: [PATCH 04/20] refactor: remove unused collaboration mode label formatter --- src/utils/collaborationModes.ts | 36 --------------------------------- 1 file changed, 36 deletions(-) delete mode 100644 src/utils/collaborationModes.ts diff --git a/src/utils/collaborationModes.ts b/src/utils/collaborationModes.ts deleted file mode 100644 index 80ae931b..00000000 --- a/src/utils/collaborationModes.ts +++ /dev/null @@ -1,36 +0,0 @@ -export function formatCollaborationModeLabel(value: string) { - if (!value) { - return value; - } - const trimmed = value.trim(); - if (!trimmed) { - return value; - } - const normalized = trimmed - .replace(/[_-]+/g, " ") - .replace(/([a-z])([A-Z])/g, "$1 $2"); - const lower = normalized.toLowerCase().replace(/\s+/g, " ").trim(); - if (lower === "pairprogramming" || lower === "pair programming") { - return "Pair Programming"; - } - if (lower === "plan") { - return "Plan"; - } - if (lower === "default") { - return "Default"; - } - if (lower === "execute") { - return "Execute"; - } - if (lower === "custom") { - return "Custom"; - } - if (lower === "code") { - return "Code"; - } - return normalized - .split(" ") - .filter(Boolean) - .map((word) => `${word[0].toUpperCase()}${word.slice(1)}`) - .join(" "); -} From d3b0fe9296b0120b950e2946adcba99b1a659673 Mon Sep 17 00:00:00 2001 From: ishanray Date: Wed, 4 Feb 2026 18:37:55 -0500 Subject: [PATCH 05/20] feat(notifications): add response-required system notifications --- src/App.tsx | 10 + ...ResponseRequiredNotificationsController.ts | 31 +++ .../useAgentResponseRequiredNotifications.ts | 263 ++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 src/features/app/hooks/useResponseRequiredNotificationsController.ts create mode 100644 src/features/notifications/hooks/useAgentResponseRequiredNotifications.ts diff --git a/src/App.tsx b/src/App.tsx index 757b50d8..42a4c6e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -67,6 +67,7 @@ import { } from "./features/layout/components/SidebarToggleControls"; import { useAppSettingsController } from "./features/app/hooks/useAppSettingsController"; import { useUpdaterController } from "./features/app/hooks/useUpdaterController"; +import { useResponseRequiredNotificationsController } from "./features/app/hooks/useResponseRequiredNotificationsController"; import { useErrorToasts } from "./features/notifications/hooks/useErrorToasts"; import { useComposerShortcuts } from "./features/composer/hooks/useComposerShortcuts"; import { useComposerMenuActions } from "./features/composer/hooks/useComposerMenuActions"; @@ -703,6 +704,15 @@ function MainApp() { customPrompts: prompts, onMessageActivity: queueGitStatusRefresh }); + + useResponseRequiredNotificationsController({ + systemNotificationsEnabled: appSettings.systemNotificationsEnabled, + approvals, + userInputRequests, + getWorkspaceName, + onDebug: addDebugEntry, + }); + const { activeAccount, accountSwitching, diff --git a/src/features/app/hooks/useResponseRequiredNotificationsController.ts b/src/features/app/hooks/useResponseRequiredNotificationsController.ts new file mode 100644 index 00000000..905bd5b2 --- /dev/null +++ b/src/features/app/hooks/useResponseRequiredNotificationsController.ts @@ -0,0 +1,31 @@ +import type { ApprovalRequest, DebugEntry, RequestUserInputRequest } from "../../../types"; +import { useWindowFocusState } from "../../layout/hooks/useWindowFocusState"; +import { useAgentResponseRequiredNotifications } from "../../notifications/hooks/useAgentResponseRequiredNotifications"; + +type Params = { + systemNotificationsEnabled: boolean; + approvals: ApprovalRequest[]; + userInputRequests: RequestUserInputRequest[]; + getWorkspaceName?: (workspaceId: string) => string | undefined; + onDebug?: (entry: DebugEntry) => void; +}; + +export function useResponseRequiredNotificationsController({ + systemNotificationsEnabled, + approvals, + userInputRequests, + getWorkspaceName, + onDebug, +}: Params) { + const isWindowFocused = useWindowFocusState(); + + useAgentResponseRequiredNotifications({ + enabled: systemNotificationsEnabled, + isWindowFocused, + approvals, + userInputRequests, + getWorkspaceName, + onDebug, + }); +} + diff --git a/src/features/notifications/hooks/useAgentResponseRequiredNotifications.ts b/src/features/notifications/hooks/useAgentResponseRequiredNotifications.ts new file mode 100644 index 00000000..dff63312 --- /dev/null +++ b/src/features/notifications/hooks/useAgentResponseRequiredNotifications.ts @@ -0,0 +1,263 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { + ApprovalRequest, + DebugEntry, + RequestUserInputRequest, +} from "../../../types"; +import { sendNotification } from "../../../services/tauri"; +import { getApprovalCommandInfo } from "../../../utils/approvalRules"; +import { useAppServerEvents } from "../../app/hooks/useAppServerEvents"; + +const MAX_BODY_LENGTH = 200; +const MIN_NOTIFICATION_SPACING_MS = 1500; + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return text.slice(0, maxLength - 1) + "…"; +} + +function buildApprovalKey(workspaceId: string, requestId: string | number) { + return `${workspaceId}:${requestId}`; +} + +function buildUserInputKey(workspaceId: string, requestId: string | number) { + return `${workspaceId}:${requestId}`; +} + +function buildPlanKey(workspaceId: string, threadId: string, itemId: string) { + return `${workspaceId}:${threadId}:${itemId}`; +} + +function isCompletedStatus(status: unknown) { + const normalized = String(status ?? "").toLowerCase(); + if (!normalized) { + return false; + } + return ( + normalized === "completed" || + normalized === "complete" || + normalized === "done" || + normalized.includes("complete") + ); +} + +type ResponseRequiredNotificationOptions = { + enabled: boolean; + isWindowFocused: boolean; + approvals: ApprovalRequest[]; + userInputRequests: RequestUserInputRequest[]; + getWorkspaceName?: (workspaceId: string) => string | undefined; + onDebug?: (entry: DebugEntry) => void; +}; + +export function useAgentResponseRequiredNotifications({ + enabled, + isWindowFocused, + approvals, + userInputRequests, + getWorkspaceName, + onDebug, +}: ResponseRequiredNotificationOptions) { + const lastNotifiedAtRef = useRef(0); + const notifiedApprovalsRef = useRef(new Set()); + const notifiedUserInputsRef = useRef(new Set()); + const notifiedPlanItemsRef = useRef(new Set()); + + const canNotifyNow = useCallback(() => { + if (!enabled) { + return false; + } + if (isWindowFocused) { + return false; + } + const lastNotifiedAt = lastNotifiedAtRef.current; + if (lastNotifiedAt && Date.now() - lastNotifiedAt < MIN_NOTIFICATION_SPACING_MS) { + return false; + } + lastNotifiedAtRef.current = Date.now(); + return true; + }, [enabled, isWindowFocused]); + + const notify = useCallback( + async ( + title: string, + body: string, + extra?: Record, + ) => { + try { + await sendNotification(title, body, { + autoCancel: true, + extra, + }); + onDebug?.({ + id: `${Date.now()}-client-notification-attention`, + timestamp: Date.now(), + source: "client", + label: "notification/attention", + payload: { title, body }, + }); + } catch (error) { + onDebug?.({ + id: `${Date.now()}-client-notification-attention-error`, + timestamp: Date.now(), + source: "error", + label: "notification/error", + payload: error instanceof Error ? error.message : String(error), + }); + } + }, + [onDebug], + ); + + const latestUnnotifiedApproval = useMemo(() => { + for (let index = approvals.length - 1; index >= 0; index -= 1) { + const approval = approvals[index]; + if (!approval) { + continue; + } + const key = buildApprovalKey(approval.workspace_id, approval.request_id); + if (!notifiedApprovalsRef.current.has(key)) { + return approval; + } + } + return null; + }, [approvals]); + + useEffect(() => { + if (!latestUnnotifiedApproval) { + return; + } + if (!canNotifyNow()) { + return; + } + + approvals.forEach((approval) => { + const key = buildApprovalKey(approval.workspace_id, approval.request_id); + notifiedApprovalsRef.current.add(key); + }); + + const workspaceName = getWorkspaceName?.(latestUnnotifiedApproval.workspace_id); + const title = workspaceName + ? `Approval needed — ${workspaceName}` + : "Approval needed"; + const commandInfo = getApprovalCommandInfo(latestUnnotifiedApproval.params ?? {}); + const body = commandInfo?.preview + ? truncateText(commandInfo.preview, MAX_BODY_LENGTH) + : truncateText(latestUnnotifiedApproval.method, MAX_BODY_LENGTH); + + void notify(title, body, { + kind: "response_required", + type: "approval", + workspaceId: latestUnnotifiedApproval.workspace_id, + requestId: latestUnnotifiedApproval.request_id, + }); + }, [ + approvals, + canNotifyNow, + getWorkspaceName, + latestUnnotifiedApproval, + notify, + ]); + + const latestUnnotifiedQuestion = useMemo(() => { + for (let index = userInputRequests.length - 1; index >= 0; index -= 1) { + const request = userInputRequests[index]; + if (!request) { + continue; + } + const key = buildUserInputKey(request.workspace_id, request.request_id); + if (!notifiedUserInputsRef.current.has(key)) { + return request; + } + } + return null; + }, [userInputRequests]); + + useEffect(() => { + if (!latestUnnotifiedQuestion) { + return; + } + if (!canNotifyNow()) { + return; + } + + userInputRequests.forEach((request) => { + const key = buildUserInputKey(request.workspace_id, request.request_id); + notifiedUserInputsRef.current.add(key); + }); + + const workspaceName = getWorkspaceName?.(latestUnnotifiedQuestion.workspace_id); + const title = workspaceName ? `Question — ${workspaceName}` : "Question"; + const first = latestUnnotifiedQuestion.params.questions[0]; + const bodyRaw = first?.header?.trim() || first?.question?.trim() || "Your input is needed."; + const body = truncateText(bodyRaw, MAX_BODY_LENGTH); + + void notify(title, body, { + kind: "response_required", + type: "question", + workspaceId: latestUnnotifiedQuestion.workspace_id, + requestId: latestUnnotifiedQuestion.request_id, + threadId: latestUnnotifiedQuestion.params.thread_id, + turnId: latestUnnotifiedQuestion.params.turn_id, + itemId: latestUnnotifiedQuestion.params.item_id, + }); + }, [ + canNotifyNow, + getWorkspaceName, + latestUnnotifiedQuestion, + notify, + userInputRequests, + ]); + + const onItemCompleted = useCallback( + (workspaceId: string, threadId: string, item: Record) => { + const type = String(item.type ?? ""); + if (type !== "plan") { + return; + } + if (!isCompletedStatus(item.status)) { + return; + } + const itemId = String(item.id ?? ""); + if (!itemId) { + return; + } + const key = buildPlanKey(workspaceId, threadId, itemId); + if (notifiedPlanItemsRef.current.has(key)) { + return; + } + if (!canNotifyNow()) { + return; + } + notifiedPlanItemsRef.current.add(key); + + const workspaceName = getWorkspaceName?.(workspaceId); + const title = workspaceName ? `Plan ready — ${workspaceName}` : "Plan ready"; + const text = String(item.text ?? "").trim(); + const body = text + ? truncateText(text.split("\n")[0] ?? text, MAX_BODY_LENGTH) + : "Plan is ready. Open CodexMonitor to respond."; + + void notify(title, body, { + kind: "response_required", + type: "plan", + workspaceId, + threadId, + itemId, + }); + }, + [canNotifyNow, getWorkspaceName, notify], + ); + + useAppServerEvents( + useMemo( + () => ({ + onItemCompleted, + }), + [onItemCompleted], + ), + ); +} + From 9d98a3a3ebff1686d7d3e8cd7e5edbf3814d45cc Mon Sep 17 00:00:00 2001 From: ishanray Date: Wed, 4 Feb 2026 18:40:49 -0500 Subject: [PATCH 06/20] Fix plan panel visibility --- src/App.tsx | 9 ++-- .../hooks/useThreadTurnEvents.test.tsx | 21 ++++++++ .../threads/hooks/useThreadTurnEvents.ts | 9 ++-- .../threads/utils/threadNormalize.test.ts | 32 +++++++++++ src/features/threads/utils/threadNormalize.ts | 53 +++++++++++++------ 5 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 src/features/threads/utils/threadNormalize.test.ts diff --git a/src/App.tsx b/src/App.tsx index 42a4c6e1..f265e3fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1056,9 +1056,12 @@ function MainApp() { const activePlan = activeThreadId ? planByThread[activeThreadId] ?? null : null; - const hasActivePlan = Boolean( - activePlan && (activePlan.steps.length > 0 || activePlan.explanation) - ); + const activeThreadProcessing = activeThreadId + ? threadStatusById[activeThreadId]?.isProcessing ?? false + : false; + const hasActivePlan = + Boolean(activePlan && (activePlan.steps.length > 0 || activePlan.explanation)) || + activeThreadProcessing; const showHome = !activeWorkspace; const showWorkspaceHome = Boolean(activeWorkspace && !activeThreadId && !isNewAgentDraftMode); const showComposer = (!isCompact diff --git a/src/features/threads/hooks/useThreadTurnEvents.test.tsx b/src/features/threads/hooks/useThreadTurnEvents.test.tsx index 9822c3a1..e16e40af 100644 --- a/src/features/threads/hooks/useThreadTurnEvents.test.tsx +++ b/src/features/threads/hooks/useThreadTurnEvents.test.tsx @@ -267,6 +267,27 @@ describe("useThreadTurnEvents", () => { }); }); + it("does not clear a completed plan for a different turn", () => { + const { result, dispatch } = makeOptions({ + planByThread: { + "thread-1": { + turnId: "turn-2", + explanation: "Done", + steps: [{ step: "Finish task", status: "completed" }], + }, + }, + }); + + act(() => { + result.current.onTurnCompleted("ws-1", "thread-1", "turn-1"); + }); + + expect(dispatch).not.toHaveBeenCalledWith({ + type: "clearThreadPlan", + threadId: "thread-1", + }); + }); + it("keeps the active plan when at least one step is not completed", () => { const { result, dispatch } = makeOptions({ planByThread: { diff --git a/src/features/threads/hooks/useThreadTurnEvents.ts b/src/features/threads/hooks/useThreadTurnEvents.ts index c12fa517..d0ab9b33 100644 --- a/src/features/threads/hooks/useThreadTurnEvents.ts +++ b/src/features/threads/hooks/useThreadTurnEvents.ts @@ -38,11 +38,14 @@ export function useThreadTurnEvents({ safeMessageActivity, recordThreadActivity, }: UseThreadTurnEventsOptions) { - const shouldClearCompletedPlan = useCallback((threadId: string) => { + const shouldClearCompletedPlan = useCallback((threadId: string, turnId: string) => { const plan = planByThreadRef.current[threadId]; if (!plan || plan.steps.length === 0) { return false; } + if (turnId && plan.turnId !== turnId) { + return false; + } return plan.steps.every((step) => step.status === "completed"); }, [planByThreadRef]); @@ -124,11 +127,11 @@ export function useThreadTurnEvents({ ); const onTurnCompleted = useCallback( - (_workspaceId: string, threadId: string, _turnId: string) => { + (_workspaceId: string, threadId: string, turnId: string) => { markProcessing(threadId, false); setActiveTurnId(threadId, null); pendingInterruptsRef.current.delete(threadId); - if (shouldClearCompletedPlan(threadId)) { + if (shouldClearCompletedPlan(threadId, turnId)) { dispatch({ type: "clearThreadPlan", threadId }); } }, diff --git a/src/features/threads/utils/threadNormalize.test.ts b/src/features/threads/utils/threadNormalize.test.ts new file mode 100644 index 00000000..5cf50491 --- /dev/null +++ b/src/features/threads/utils/threadNormalize.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { normalizePlanUpdate } from "./threadNormalize"; + +describe("normalizePlanUpdate", () => { + it("normalizes a plan when the payload uses an array", () => { + expect( + normalizePlanUpdate("turn-1", " Note ", [{ step: "Do it", status: "in_progress" }]), + ).toEqual({ + turnId: "turn-1", + explanation: "Note", + steps: [{ step: "Do it", status: "inProgress" }], + }); + }); + + it("normalizes a plan when the payload uses an object with steps", () => { + expect( + normalizePlanUpdate("turn-2", null, { + explanation: "Hello", + steps: [{ step: "Ship it", status: "completed" }], + }), + ).toEqual({ + turnId: "turn-2", + explanation: "Hello", + steps: [{ step: "Ship it", status: "completed" }], + }); + }); + + it("returns null when there is no explanation or steps", () => { + expect(normalizePlanUpdate("turn-3", "", { steps: [] })).toBeNull(); + }); +}); + diff --git a/src/features/threads/utils/threadNormalize.ts b/src/features/threads/utils/threadNormalize.ts index 8961646b..dbcc0625 100644 --- a/src/features/threads/utils/threadNormalize.ts +++ b/src/features/threads/utils/threadNormalize.ts @@ -203,23 +203,42 @@ export function normalizePlanUpdate( explanation: unknown, plan: unknown, ): TurnPlan | null { - const steps = Array.isArray(plan) - ? plan - .map((entry) => { - const step = asString((entry as Record)?.step ?? ""); - if (!step) { - return null; - } - return { - step, - status: normalizePlanStepStatus( - (entry as Record)?.status, - ), - } satisfies TurnPlanStep; - }) - .filter((entry): entry is TurnPlanStep => Boolean(entry)) - : []; - const note = asString(explanation).trim(); + const planRecord = + plan && typeof plan === "object" && !Array.isArray(plan) + ? (plan as Record) + : null; + const rawSteps = (() => { + if (Array.isArray(plan)) { + return plan; + } + if (planRecord) { + const candidate = + planRecord.steps ?? + planRecord.plan ?? + planRecord.items ?? + planRecord.entries ?? + null; + return Array.isArray(candidate) ? candidate : []; + } + return []; + })(); + const steps = rawSteps + .map((entry) => { + if (!entry || typeof entry !== "object") { + return null; + } + const record = entry as Record; + const step = asString(record.step ?? record.text ?? record.title ?? ""); + if (!step) { + return null; + } + return { + step, + status: normalizePlanStepStatus(record.status), + } satisfies TurnPlanStep; + }) + .filter((entry): entry is TurnPlanStep => Boolean(entry)); + const note = asString(explanation ?? planRecord?.explanation ?? planRecord?.note).trim(); if (!steps.length && !note) { return null; } From 57a97fd51d847c63d08a17dede182213fe4a2162 Mon Sep 17 00:00:00 2001 From: ishanray Date: Wed, 4 Feb 2026 19:01:37 -0500 Subject: [PATCH 07/20] feat(composer): enable shortcuts on workspace home textarea --- src/App.tsx | 63 +++++++------ .../hooks/useComposerShortcuts.test.tsx | 92 +++++++++++++++++++ 2 files changed, 128 insertions(+), 27 deletions(-) create mode 100644 src/features/composer/hooks/useComposerShortcuts.test.tsx diff --git a/src/App.tsx b/src/App.tsx index f265e3fc..61409774 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -416,33 +416,42 @@ function MainApp() { selectedCollaborationMode, selectedCollaborationModeId, setSelectedCollaborationModeId, - } = useCollaborationModes({ - activeWorkspace, - enabled: appSettings.collaborationModesEnabled, - onDebug: addDebugEntry, - }); - - useComposerShortcuts({ - textareaRef: composerInputRef, - modelShortcut: appSettings.composerModelShortcut, - accessShortcut: appSettings.composerAccessShortcut, - reasoningShortcut: appSettings.composerReasoningShortcut, - collaborationShortcut: appSettings.collaborationModesEnabled - ? appSettings.composerCollaborationShortcut - : null, - models, - collaborationModes, - selectedModelId, - onSelectModel: setSelectedModelId, - selectedCollaborationModeId, - onSelectCollaborationMode: setSelectedCollaborationModeId, - accessMode, - onSelectAccessMode: setAccessMode, - reasoningOptions, - selectedEffort, - onSelectEffort: setSelectedEffort, - reasoningSupported, - }); + } = useCollaborationModes({ + activeWorkspace, + enabled: appSettings.collaborationModesEnabled, + onDebug: addDebugEntry, + }); + + const composerShortcuts = { + modelShortcut: appSettings.composerModelShortcut, + accessShortcut: appSettings.composerAccessShortcut, + reasoningShortcut: appSettings.composerReasoningShortcut, + collaborationShortcut: appSettings.collaborationModesEnabled + ? appSettings.composerCollaborationShortcut + : null, + models, + collaborationModes, + selectedModelId, + onSelectModel: setSelectedModelId, + selectedCollaborationModeId, + onSelectCollaborationMode: setSelectedCollaborationModeId, + accessMode, + onSelectAccessMode: setAccessMode, + reasoningOptions, + selectedEffort, + onSelectEffort: setSelectedEffort, + reasoningSupported, + }; + + useComposerShortcuts({ + textareaRef: composerInputRef, + ...composerShortcuts, + }); + + useComposerShortcuts({ + textareaRef: workspaceHomeTextareaRef, + ...composerShortcuts, + }); useComposerMenuActions({ models, diff --git a/src/features/composer/hooks/useComposerShortcuts.test.tsx b/src/features/composer/hooks/useComposerShortcuts.test.tsx new file mode 100644 index 00000000..e5f73e8d --- /dev/null +++ b/src/features/composer/hooks/useComposerShortcuts.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import { render } from "@testing-library/react"; +import { useRef } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { useComposerShortcuts } from "./useComposerShortcuts"; + +function ShortcutHarness(props: { + collaborationShortcut: string | null; + collaborationModes: { id: string; label: string }[]; + selectedCollaborationModeId: string | null; + onSelectCollaborationMode: (id: string | null) => void; +}) { + const textareaRef = useRef(null); + useComposerShortcuts({ + textareaRef, + modelShortcut: null, + accessShortcut: null, + reasoningShortcut: null, + collaborationShortcut: props.collaborationShortcut, + models: [], + collaborationModes: props.collaborationModes, + selectedModelId: null, + onSelectModel: () => {}, + selectedCollaborationModeId: props.selectedCollaborationModeId, + onSelectCollaborationMode: props.onSelectCollaborationMode, + accessMode: "read-only", + onSelectAccessMode: () => {}, + reasoningOptions: [], + selectedEffort: null, + onSelectEffort: () => {}, + reasoningSupported: false, + }); + + return