From 8be17e14037539e8e4dc12d45cf3dcb5dc080716 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 22 Jul 2026 21:47:32 +0800 Subject: [PATCH 1/8] perf(diagnostics): bound RPC latency aggregation Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src-tauri/src/usage_diagnostics/sanitize.rs | 24 ++++- src-tauri/src/usage_diagnostics/types.rs | 2 +- .../usage_diagnostics_tests.rs | 29 +++++- src/diagnostics/aggregate.ts | 2 +- src/diagnostics/runtimeCounters.test.ts | 68 +++++++++++++ src/diagnostics/runtimeCounters.ts | 95 ++++++++++++++++--- src/diagnostics/types.ts | 3 +- src/diagnostics/useDiagnosticsBootstrap.ts | 11 ++- 8 files changed, 209 insertions(+), 25 deletions(-) create mode 100644 src/diagnostics/runtimeCounters.test.ts diff --git a/src-tauri/src/usage_diagnostics/sanitize.rs b/src-tauri/src/usage_diagnostics/sanitize.rs index 62892537b..cad7f2ed2 100644 --- a/src-tauri/src/usage_diagnostics/sanitize.rs +++ b/src-tauri/src/usage_diagnostics/sanitize.rs @@ -6,7 +6,7 @@ use super::types::{ }; const UNKNOWN_BUCKET: &str = "unknown"; -const MAX_RUNTIME_OPERATIONS: usize = 100; +const MAX_RUNTIME_OPERATIONS: usize = 128; const MAX_LIST_ENTRIES: usize = 25; pub fn bucket_duration_ms(duration_ms: f64) -> &'static str { @@ -146,10 +146,24 @@ fn sanitize_runtime_summary(summary: DiagnosticsRuntimeSummary) -> DiagnosticsRu item.insert("total".into(), Value::from(total)); item.insert("success".into(), Value::from(success)); item.insert("failure".into(), Value::from(failure)); - item.insert( - "durationBucket".into(), - Value::from(bucket_string_field(&value, "durationBucket")), - ); + if value.get("averageDurationBucket").is_some() || value.get("p95DurationBucket").is_some() + { + item.insert( + "averageDurationBucket".into(), + Value::from(bucket_string_field(&value, "averageDurationBucket")), + ); + item.insert( + "p95DurationBucket".into(), + Value::from(bucket_string_field(&value, "p95DurationBucket")), + ); + } else { + // Queue records produced by diagnostics schema v1 used one coarse + // duration bucket. Keep accepting them while emitting v2 snapshots. + item.insert( + "durationBucket".into(), + Value::from(bucket_string_field(&value, "durationBucket")), + ); + } by_operation.insert(sanitize_operation_name(&operation), Value::Object(item)); } diff --git a/src-tauri/src/usage_diagnostics/types.rs b/src-tauri/src/usage_diagnostics/types.rs index 0ace7fc40..d1c509b14 100644 --- a/src-tauri/src/usage_diagnostics/types.rs +++ b/src-tauri/src/usage_diagnostics/types.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -pub const DIAGNOSTICS_SCHEMA_VERSION: u32 = 1; +pub const DIAGNOSTICS_SCHEMA_VERSION: u32 = 2; pub const DEFAULT_UPLOAD_INTERVAL_HOURS: u64 = 12; pub const MIN_UPLOAD_INTERVAL_HOURS: u64 = 1; pub const MAX_UPLOAD_INTERVAL_HOURS: u64 = 24; diff --git a/src-tauri/src/usage_diagnostics/usage_diagnostics_tests.rs b/src-tauri/src/usage_diagnostics/usage_diagnostics_tests.rs index 8aa7a4d75..117bae684 100644 --- a/src-tauri/src/usage_diagnostics/usage_diagnostics_tests.rs +++ b/src-tauri/src/usage_diagnostics/usage_diagnostics_tests.rs @@ -110,7 +110,7 @@ fn performance_only_strips_usage_aggregates() { snapshot(DiagnosticsLevel::Default), DiagnosticsLevel::PerformanceOnly, ); - assert_eq!(sanitized.schema_version, 1); + assert_eq!(sanitized.schema_version, 2); assert_eq!(sanitized.app_version.as_deref(), Some("1.0.1+test")); assert!(sanitized.app_usage_duration_bucket.is_some()); assert!(sanitized.system_profile.is_some()); @@ -126,7 +126,7 @@ fn performance_only_strips_usage_aggregates() { #[test] fn off_keeps_minimal_existence_usage_only() { let sanitized = sanitize_snapshot(snapshot(DiagnosticsLevel::Default), DiagnosticsLevel::Off); - assert_eq!(sanitized.schema_version, 1); + assert_eq!(sanitized.schema_version, 2); assert_eq!(sanitized.diagnostics_level, DiagnosticsLevel::Off); assert_eq!(sanitized.app_version.as_deref(), Some("1.0.1+test")); assert!(sanitized.app_usage_duration_bucket.is_some()); @@ -159,6 +159,31 @@ fn default_sanitization_drops_raw_or_path_fields() { assert!(serialized.contains("agent_send_message")); } +#[test] +fn runtime_sanitizer_accepts_v1_and_v2_duration_shapes() { + let mut legacy = snapshot(DiagnosticsLevel::PerformanceOnly); + let sanitized_legacy = sanitize_snapshot(legacy.clone(), DiagnosticsLevel::PerformanceOnly); + let legacy_operation = &sanitized_legacy.rpc.unwrap().by_operation["agent_send_message"]; + assert_eq!(legacy_operation["durationBucket"], "lt_1m"); + + legacy.rpc.as_mut().unwrap().by_operation.insert( + "cli_agent_message".to_string(), + json!({ + "total": 2, + "success": 1, + "failure": 1, + "averageDurationBucket": "20_100ms", + "p95DurationBucket": "500ms_2s", + "payload": "drop" + }), + ); + let sanitized_v2 = sanitize_snapshot(legacy, DiagnosticsLevel::PerformanceOnly); + let v2_operation = &sanitized_v2.rpc.unwrap().by_operation["cli_agent_message"]; + assert_eq!(v2_operation["averageDurationBucket"], "20_100ms"); + assert_eq!(v2_operation["p95DurationBucket"], "500ms_2s"); + assert!(v2_operation.get("payload").is_none()); +} + #[test] fn queue_reads_only_unsent_and_marks_sent() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/diagnostics/aggregate.ts b/src/diagnostics/aggregate.ts index 0f64ac250..8e4209f4c 100644 --- a/src/diagnostics/aggregate.ts +++ b/src/diagnostics/aggregate.ts @@ -36,7 +36,7 @@ import type { DiagnosticsUsageSnapshot, } from "./types"; -const SCHEMA_VERSION = 1; +const SCHEMA_VERSION = 2; const MAX_TOP_MODELS = 10; const MAX_RUST_AGENT_TOP_SESSIONS_PER_DAY = 10; const EXTERNAL_HISTORY_LIMIT = 200; diff --git a/src/diagnostics/runtimeCounters.test.ts b/src/diagnostics/runtimeCounters.test.ts new file mode 100644 index 000000000..18caed1ac --- /dev/null +++ b/src/diagnostics/runtimeCounters.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + consumeHttpDiagnosticsSummary, + consumeRpcDiagnosticsSummary, + discardRuntimeDiagnosticsCounters, + recordDiagnosticsRpc, +} from "./runtimeCounters"; + +describe("runtime diagnostics counters", () => { + beforeEach(() => { + consumeRpcDiagnosticsSummary(); + consumeHttpDiagnosticsSummary(); + }); + + it("reports average, p95, and failures from a fixed histogram", () => { + for (let index = 0; index < 95; index += 1) { + recordDiagnosticsRpc("fast", 4, true); + } + for (let index = 0; index < 5; index += 1) { + recordDiagnosticsRpc("fast", 600, index !== 4); + } + + const summary = consumeRpcDiagnosticsSummary(); + + expect(summary).toMatchObject({ total: 100, success: 99, failure: 1 }); + expect(summary.byOperation.fast).toEqual({ + total: 100, + success: 99, + failure: 1, + averageDurationBucket: "20_100ms", + p95DurationBucket: "1_5ms", + }); + }); + + it("keeps at most 128 operation entries and merges overflow", () => { + for (let index = 0; index < 1_000; index += 1) { + recordDiagnosticsRpc(`operation-${index}`, index % 10, index % 11 !== 0); + } + + const summary = consumeRpcDiagnosticsSummary(); + const operations = Object.keys(summary.byOperation); + + expect(operations).toHaveLength(128); + expect(summary.byOperation.__other__).toMatchObject({ + total: 873, + failure: 79, + }); + }); + + it("consumes and releases the current interval", () => { + recordDiagnosticsRpc("one", 1, false); + expect(consumeRpcDiagnosticsSummary().total).toBe(1); + expect(consumeRpcDiagnosticsSummary()).toEqual({ + total: 0, + success: 0, + failure: 0, + byOperation: {}, + }); + }); + + it("discards bounded counters while diagnostics cannot upload", () => { + recordDiagnosticsRpc("offline", 2_500, true); + discardRuntimeDiagnosticsCounters(); + + expect(consumeRpcDiagnosticsSummary().total).toBe(0); + }); +}); diff --git a/src/diagnostics/runtimeCounters.ts b/src/diagnostics/runtimeCounters.ts index 747895222..037c8c7df 100644 --- a/src/diagnostics/runtimeCounters.ts +++ b/src/diagnostics/runtimeCounters.ts @@ -1,35 +1,83 @@ -import { bucketDurationMs } from "./buckets"; import type { DiagnosticsRuntimeSummary } from "./types"; interface RuntimeCounter { total: number; failure: number; - durations: number[]; + totalDurationMs: number; + durationHistogram: number[]; } +const MAX_RUNTIME_OPERATIONS = 128; +const OTHER_OPERATION = "__other__"; +const DURATION_BUCKETS = [ + { upperBoundMs: 1, label: "lt_1ms" }, + { upperBoundMs: 5, label: "1_5ms" }, + { upperBoundMs: 20, label: "5_20ms" }, + { upperBoundMs: 100, label: "20_100ms" }, + { upperBoundMs: 500, label: "100_500ms" }, + { upperBoundMs: 2_000, label: "500ms_2s" }, + { upperBoundMs: Number.POSITIVE_INFINITY, label: "2s_plus" }, +] as const; + const rpcCounters = new Map(); const httpCounters = new Map(); +function createCounter(): RuntimeCounter { + return { + total: 0, + failure: 0, + totalDurationMs: 0, + durationHistogram: Array.from({ length: DURATION_BUCKETS.length }, () => 0), + }; +} + function getCounter( counters: Map, operation: string ): RuntimeCounter { const existing = counters.get(operation); if (existing) return existing; - const created: RuntimeCounter = { total: 0, failure: 0, durations: [] }; - counters.set(operation, created); + + const boundedOperation = + counters.size < MAX_RUNTIME_OPERATIONS - 1 ? operation : OTHER_OPERATION; + const overflow = counters.get(boundedOperation); + if (overflow) return overflow; + + const created = createCounter(); + counters.set(boundedOperation, created); return created; } +function normalizeDuration(durationMs: number): number { + return Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0; +} + +function durationBucketIndex(durationMs: number): number { + const index = DURATION_BUCKETS.findIndex( + ({ upperBoundMs }) => durationMs < upperBoundMs + ); + return index === -1 ? DURATION_BUCKETS.length - 1 : index; +} + +function recordCounter( + counter: RuntimeCounter, + durationMs: number, + ok: boolean +): void { + const normalizedDurationMs = normalizeDuration(durationMs); + counter.total += 1; + counter.totalDurationMs += normalizedDurationMs; + counter.durationHistogram[durationBucketIndex(normalizedDurationMs)] += 1; + if (!ok) counter.failure += 1; +} + export function recordDiagnosticsRpc( command: string, durationMs: number, ok: boolean ): void { const counter = getCounter(rpcCounters, command); - counter.total += 1; - if (!ok) counter.failure += 1; - counter.durations.push(durationMs); + recordCounter(counter, durationMs, ok); } export function recordDiagnosticsHttp( @@ -38,14 +86,22 @@ export function recordDiagnosticsHttp( ok: boolean ): void { const counter = getCounter(httpCounters, target); - counter.total += 1; - if (!ok) counter.failure += 1; - counter.durations.push(durationMs); + recordCounter(counter, durationMs, ok); +} + +function bucketLabelForDuration(durationMs: number): string { + return DURATION_BUCKETS[durationBucketIndex(durationMs)].label; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; +function percentileBucket(histogram: number[], total: number): string { + if (total === 0) return DURATION_BUCKETS[0].label; + const target = Math.ceil(total * 0.95); + let cumulative = 0; + for (let index = 0; index < histogram.length; index += 1) { + cumulative += histogram[index] ?? 0; + if (cumulative >= target) return DURATION_BUCKETS[index].label; + } + return DURATION_BUCKETS[DURATION_BUCKETS.length - 1].label; } function consumeDiagnosticsSummary( @@ -62,7 +118,13 @@ function consumeDiagnosticsSummary( total: counter.total, success: counter.total - counter.failure, failure: counter.failure, - durationBucket: bucketDurationMs(average(counter.durations)), + averageDurationBucket: bucketLabelForDuration( + counter.total === 0 ? 0 : counter.totalDurationMs / counter.total + ), + p95DurationBucket: percentileBucket( + counter.durationHistogram, + counter.total + ), }; } @@ -77,3 +139,8 @@ export function consumeRpcDiagnosticsSummary(): DiagnosticsRuntimeSummary { export function consumeHttpDiagnosticsSummary(): DiagnosticsRuntimeSummary { return consumeDiagnosticsSummary(httpCounters); } + +export function discardRuntimeDiagnosticsCounters(): void { + consumeRpcDiagnosticsSummary(); + consumeHttpDiagnosticsSummary(); +} diff --git a/src/diagnostics/types.ts b/src/diagnostics/types.ts index 0939200dc..a649f7438 100644 --- a/src/diagnostics/types.ts +++ b/src/diagnostics/types.ts @@ -16,7 +16,8 @@ export interface DiagnosticsRuntimeOperationSummary { total: number; success: number; failure: number; - durationBucket: string; + averageDurationBucket: string; + p95DurationBucket: string; } export interface DiagnosticsRuntimeSummary { diff --git a/src/diagnostics/useDiagnosticsBootstrap.ts b/src/diagnostics/useDiagnosticsBootstrap.ts index 44fe518b8..dbc5ce07e 100644 --- a/src/diagnostics/useDiagnosticsBootstrap.ts +++ b/src/diagnostics/useDiagnosticsBootstrap.ts @@ -10,6 +10,7 @@ import { workspaceFoldersAtom } from "@src/store/ui/workspaceFoldersAtom"; import type { WorkspaceFolder } from "@src/types/workspace"; import { createDiagnosticsUsageSnapshot } from "./aggregate"; +import { discardRuntimeDiagnosticsCounters } from "./runtimeCounters"; import { diagnosticsConfigure, diagnosticsFlushNow, @@ -24,6 +25,10 @@ const HOUR_MS = 60 * MINUTE_MS; const LAST_FLUSH_STORAGE_KEY = "orgii:diagnostics:lastFlushAt"; const logger = createLogger("DiagnosticsBootstrap"); +function discardRuntimeDiagnostics(): void { + discardRuntimeDiagnosticsCounters(); +} + function reportDiagnosticsFailure(operation: string, error: unknown): void { logger.warn(`${operation} failed`, error); } @@ -114,7 +119,11 @@ export function useDiagnosticsBootstrap(): void { const collectAndSendSnapshot = useCallback( async (force = false) => { if (runningRef.current) return; - if (!settingsLoaded || offlineMode) { + if (!settingsLoaded) { + return; + } + if (offlineMode) { + discardRuntimeDiagnostics(); return; } From 7f412303d08dda4c08ec0571396d53f4fb461d92 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 22 Jul 2026 21:49:36 +0800 Subject: [PATCH 2/8] perf(streaming): coalesce state IPC edges Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../createRustAgentAdapter.streaming.test.ts | 31 +++++++++++ .../sync/adapters/createRustAgentAdapter.ts | 51 ++++++++++++++++--- 2 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 src/engines/SessionCore/sync/adapters/createRustAgentAdapter.streaming.test.ts diff --git a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.streaming.test.ts b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.streaming.test.ts new file mode 100644 index 000000000..2708120c3 --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.streaming.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createStreamingEdgeController } from "./createRustAgentAdapter"; + +describe("Rust Agent streaming edge controller", () => { + it("coalesces 1,000 deltas into one true and one terminal false write", () => { + const write = vi.fn(); + const controller = createStreamingEdgeController(write); + + for (let index = 0; index < 1_000; index += 1) { + controller.set(true); + } + controller.set(false); + controller.set(false); + controller.set(false); + + expect(write.mock.calls).toEqual([[true], [false]]); + expect(controller.value).toBe(false); + }); + + it("forces an unknown owner to converge to false only once", () => { + const write = vi.fn(); + const controller = createStreamingEdgeController(write); + + controller.set(false); + controller.set(false); + + expect(write).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts index 59b28ec16..7e687e76b 100644 --- a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts @@ -92,6 +92,27 @@ export interface RustAgentConfig { const logger = createLogger("RustAgentAdapter"); +export interface StreamingEdgeController { + readonly value: boolean; + set(value: boolean): void; +} + +export function createStreamingEdgeController( + write: (value: boolean) => void +): StreamingEdgeController { + let lastValue: boolean | undefined; + return { + get value(): boolean { + return lastValue ?? false; + }, + set(value: boolean): void { + if (lastValue === value) return; + lastValue = value; + write(value); + }, + }; +} + interface TokenUsageRecord { inputTokens: number; contextTokens: number; @@ -172,6 +193,17 @@ export function createRustAgentAdapter( transformUserText, features, } = config; + const streamingControllers = new Map(); + + const getStreamingController = (sessionId: string) => { + const existing = streamingControllers.get(sessionId); + if (existing) return existing; + const created = createStreamingEdgeController((value) => { + void eventStoreProxy.setStreaming(value, sessionId); + }); + streamingControllers.set(sessionId, created); + return created; + }; return { category, @@ -283,7 +315,7 @@ export function createRustAgentAdapter( sessionId: string, callbacks: EventHandlerCallbacks ): SessionEventHandler { - let _streaming = false; + const streamingController = getStreamingController(sessionId); // Two-flag system for status signaling: // @@ -369,8 +401,7 @@ export function createRustAgentAdapter( } : undefined, setStreaming: (value: boolean) => { - _streaming = value; - eventStoreProxy.setStreaming(value, sessionId); + streamingController.set(value); }, }); @@ -582,20 +613,22 @@ export function createRustAgentAdapter( ctx.trackedCodingSessionsRef?.current.clear(); - _streaming = false; _runningSignaled = false; _turnCompleted = false; _consecutiveDispatchFailures = 0; - eventStoreProxy.setStreaming(false, sessionId); + streamingController.set(false); }, get isStreaming(): boolean { - return _streaming; + return streamingController.value; }, dispose(): void { _disposed = true; this.reset(); + if (streamingControllers.get(sessionId) === streamingController) { + streamingControllers.delete(sessionId); + } }, }; }, @@ -644,7 +677,11 @@ export function createRustAgentAdapter( async stopSession(sessionId: string, reason: CancelReason): Promise { markSessionStreamingStopped(sessionId); - void eventStoreProxy.setStreaming(false, sessionId); + const existingController = streamingControllers.get(sessionId); + const streamingController = + existingController ?? getStreamingController(sessionId); + streamingController.set(false); + if (!existingController) streamingControllers.delete(sessionId); await cancel(sessionId, reason); }, }; From 24f8bbb67efdfa077d1a1f2e862504ba5958e420 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 22 Jul 2026 21:54:50 +0800 Subject: [PATCH 3/8] perf(cli): replace reconciliation polling with intent push Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../session-persistence/src/turn_intents.rs | 86 +++- .../agent_sessions/cli/agent_core_bridge.rs | 3 + src-tauri/src/agent_sessions/cli/commands.rs | 329 ++++++++++--- .../src/agent_sessions/cli/persistence/mod.rs | 71 +++ .../cli/persistence/session_crud.rs | 156 +++++- .../agent_sessions/cli/persistence/types.rs | 7 + .../cli/session_runner/cursor_usage.rs | 41 +- .../cli/session_runner/env_setup.rs | 106 +++-- .../cli/session_runner/finalize.rs | 117 +++-- .../cli/session_runner/helpers.rs | 109 ++++- .../cli/session_runner/lifecycle.rs | 79 ++- .../cli/session_runner/oauth_setup.rs | 8 +- .../cli/session_runner/proxy_release.rs | 11 +- .../cli/session_runner/session.rs | 449 +++++++++++------- src-tauri/src/api/agent/test/cli.rs | 6 + src-tauri/src/commands/handler_list.inc | 1 + src/api/realtime/websocket/schemas.ts | 1 + src/api/tauri/rpc/procedures/cli.ts | 27 ++ src/api/tauri/rpc/procedures/index.ts | 1 + src/api/tauri/rpc/router.ts | 1 + src/api/tauri/rpc/schemas/cli.ts | 53 +++ src/api/tauri/rpc/schemas/index.ts | 1 + .../control/turnIntentDispatchLifecycle.ts | 6 + .../SessionCore/control/turnLifecycle.ts | 20 +- .../hooks/session/useQueueDispatch.ts | 8 +- .../SessionCore/services/SessionService.ts | 6 +- .../cli/__tests__/cliTransport.test.ts | 70 +++ .../sync/adapters/cli/cliHistory.ts | 14 +- .../sync/adapters/cli/cliLifecycle.ts | 168 +------ .../sync/adapters/cli/cliTransport.ts | 94 +--- .../adapters/cli/createCliEventHandler.ts | 83 +--- .../cliTurnLifecycleCoordinator.test.ts | 177 +++++++ .../cliSession/cliTurnLifecycleCoordinator.ts | 213 +++++++++ .../cliSession/useBackgroundSessionMonitor.ts | 44 +- .../useWorkstationSidebarHandlers.ts | 2 + 35 files changed, 1863 insertions(+), 705 deletions(-) create mode 100644 src/api/tauri/rpc/procedures/cli.ts create mode 100644 src/api/tauri/rpc/schemas/cli.ts create mode 100644 src/engines/SessionCore/sync/adapters/cli/__tests__/cliTransport.test.ts create mode 100644 src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts create mode 100644 src/hooks/cliSession/cliTurnLifecycleCoordinator.ts diff --git a/src-tauri/crates/session-persistence/src/turn_intents.rs b/src-tauri/crates/session-persistence/src/turn_intents.rs index 0c236357b..b8ff499a5 100644 --- a/src-tauri/crates/session-persistence/src/turn_intents.rs +++ b/src-tauri/crates/session-persistence/src/turn_intents.rs @@ -29,6 +29,7 @@ use chrono::Utc; use rusqlite::{params, Connection, OptionalExtension, Result as SqliteResult}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use super::connection::get_connection; @@ -266,6 +267,26 @@ pub fn upsert_initial( status: TurnIntentStatus, ) -> Result { let conn = get_connection()?; + upsert_initial_on( + &conn, + session_id, + turn_intent_id, + client_message_id, + source, + status, + ) +} + +/// Connection-scoped variant for callers that atomically update adjacent +/// session lifecycle state in the same SQLite transaction. +pub fn upsert_initial_on( + conn: &Connection, + session_id: &str, + turn_intent_id: &str, + client_message_id: Option<&str>, + source: TurnIntentSource, + status: TurnIntentStatus, +) -> Result { let now = Utc::now().to_rfc3339(); let inserted = conn.execute( "INSERT OR IGNORE INTO session_turn_intents @@ -292,7 +313,7 @@ pub fn upsert_initial( updated_at: now, }); } - get_intent(&conn, session_id, turn_intent_id)? + get_intent(conn, session_id, turn_intent_id)? .ok_or_else(|| IntentError::NotFound(turn_intent_id.to_string(), session_id.to_string())) } @@ -304,7 +325,17 @@ pub fn update_status( new_status: TurnIntentStatus, ) -> Result { let conn = get_connection()?; - let existing = get_intent(&conn, session_id, turn_intent_id)? + update_status_on(&conn, session_id, turn_intent_id, new_status) +} + +/// Connection-scoped variant for atomic session + turn-intent transitions. +pub fn update_status_on( + conn: &Connection, + session_id: &str, + turn_intent_id: &str, + new_status: TurnIntentStatus, +) -> Result { + let existing = get_intent(conn, session_id, turn_intent_id)? .ok_or_else(|| IntentError::NotFound(turn_intent_id.to_string(), session_id.to_string()))?; if !transition_allowed(existing.status, new_status) { return Err(IntentError::IllegalTransition { @@ -400,6 +431,28 @@ pub fn list_for_session(session_id: &str) -> SqliteResult> { Ok(rows) } +/// Latest lifecycle row for each requested session, read through one +/// connection so reconnect/focus reconciliation does not fan out into one +/// SQLite task per session. +pub fn latest_for_sessions(session_ids: &[String]) -> SqliteResult> { + let conn = get_connection()?; + let mut stmt = conn.prepare_cached( + "SELECT session_id, turn_intent_id, client_message_id, source, status, + created_at, updated_at + FROM session_turn_intents + WHERE session_id = ?1 + ORDER BY updated_at DESC, created_at DESC + LIMIT 1", + )?; + let mut latest = HashMap::with_capacity(session_ids.len()); + for session_id in session_ids { + if let Some(row) = stmt.query_row([session_id], row_from_sql).optional()? { + latest.insert(session_id.clone(), row); + } + } + Ok(latest) +} + // ============================================ // Tests // ============================================ @@ -601,6 +654,35 @@ mod tests { }); } + #[test] + fn latest_for_sessions_returns_one_current_intent_per_requested_session() { + with_temp_orgii_home(|| { + let first_session = "test-session-batch-a"; + let second_session = "test-session-batch-b"; + let _ = fresh_intent(first_session, "intent-a-old"); + let _ = + update_status(first_session, "intent-a-old", TurnIntentStatus::Running).unwrap(); + let _ = + update_status(first_session, "intent-a-old", TurnIntentStatus::Completed).unwrap(); + let _ = fresh_intent(first_session, "intent-a-current"); + let _ = update_status(first_session, "intent-a-current", TurnIntentStatus::Running) + .unwrap(); + let _ = fresh_intent(second_session, "intent-b-current"); + + let rows = latest_for_sessions(&[ + first_session.to_string(), + second_session.to_string(), + "missing-session".to_string(), + ]) + .unwrap(); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[first_session].turn_intent_id, "intent-a-current"); + assert_eq!(rows[first_session].status, TurnIntentStatus::Running); + assert_eq!(rows[second_session].turn_intent_id, "intent-b-current"); + }); + } + #[test] fn restart_reconciliation_closes_every_in_flight_intent() { with_temp_orgii_home(|| { diff --git a/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs b/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs index 6f7f9f14f..23eb323ac 100644 --- a/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs +++ b/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs @@ -190,8 +190,11 @@ fn respond_plan_approval( None, Some(AgentExecMode::Build.as_str().to_string()), None, + None, + None, ) .await + .map(|_| ()) }) } diff --git a/src-tauri/src/agent_sessions/cli/commands.rs b/src-tauri/src/agent_sessions/cli/commands.rs index 397161237..cb7ec1f2e 100644 --- a/src-tauri/src/agent_sessions/cli/commands.rs +++ b/src-tauri/src/agent_sessions/cli/commands.rs @@ -13,39 +13,67 @@ use core_types::activity::ActivityChunk; use core_types::session::CLI_SESSION_PREFIX; use core_types::worktree::{MergeStrategy, WorktreeMergeResult}; use git::worktree; +use serde::Serialize; use settings; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; const WORKTREE_MAX_COUNT_SETTING: &str = "git.worktree.maxCount"; +const MAX_CLI_STATUS_BATCH_SESSIONS: usize = 256; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliRunReceipt { + pub session_id: String, + pub turn_intent_id: String, + pub status: SessionStatus, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliAgentStatusBatchItem { + pub session_id: String, + pub status: SessionStatus, + pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_intent_id: Option, +} #[tauri::command] -pub fn cli_launch_profile_get(agent_name: String) -> Result { - launch_profile_store::cli_launch_profile_get(agent_name) +pub async fn cli_launch_profile_get(agent_name: String) -> Result { + tokio::task::spawn_blocking(move || launch_profile_store::cli_launch_profile_get(agent_name)) + .await + .map_err(|err| format!("Task error: {err}"))? } #[tauri::command] -pub fn cli_launch_profile_update( +pub async fn cli_launch_profile_update( agent_name: String, permission_mode: CliPermissionMode, command_override: Option, args_override: Option>, env_override: Option>, ) -> Result { - launch_profile_store::cli_launch_profile_update(CliLaunchProfileUpdate { - agent_name, - permission_mode, - command_override, - args_override, - env_override, - // Experimental app-server transport opt-in is not exposed in the - // settings UI; `None` preserves whatever the store already holds. - transport: None, + tokio::task::spawn_blocking(move || { + launch_profile_store::cli_launch_profile_update(CliLaunchProfileUpdate { + agent_name, + permission_mode, + command_override, + args_override, + env_override, + // Experimental app-server transport opt-in is not exposed in the + // settings UI; `None` preserves whatever the store already holds. + transport: None, + }) }) + .await + .map_err(|err| format!("Task error: {err}"))? } #[tauri::command] -pub fn cli_launch_profile_reset(agent_name: String) -> Result { - launch_profile_store::cli_launch_profile_reset(agent_name) +pub async fn cli_launch_profile_reset(agent_name: String) -> Result { + tokio::task::spawn_blocking(move || launch_profile_store::cli_launch_profile_reset(agent_name)) + .await + .map_err(|err| format!("Task error: {err}"))? } /// Prepend IDE context (open files, git status, etc.) to the user prompt @@ -284,6 +312,32 @@ pub async fn cli_agent_run( ide_context: Option, mode: Option, images: Option>, +) -> Result<(), String> { + let turn_intent_id = uuid::Uuid::new_v4().to_string(); + let client_message_id = uuid::Uuid::new_v4().to_string(); + cli_agent_run_internal( + session_id, + user_input, + cli_resume_id, + ide_context, + mode, + images, + turn_intent_id, + client_message_id, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn cli_agent_run_internal( + session_id: String, + user_input: String, + cli_resume_id: Option, + ide_context: Option, + mode: Option, + images: Option>, + turn_intent_id: String, + client_message_id: String, ) -> Result<(), String> { tracing::info!( session_id = %session_id, @@ -304,9 +358,10 @@ pub async fn cli_agent_run( .map_err(|err| format!("Task error: {}", err))??; } - // Hold lock across check + spawn + insert to prevent duplicate agents from - // concurrent calls (e.g., double-click). tokio::spawn returns immediately so - // the lock is held only briefly. + // Hold the per-run registry lock across acceptance persistence + spawn so + // two concurrent calls cannot both create a Running intent. The SQLite + // work runs on the blocking pool; the lock window contains one bounded + // transaction-sized helper and no network/process wait. let mut sessions = session_runner::RUNNING_SESSIONS.lock().await; // Guard: prevent duplicate parallel agents for the same session @@ -319,10 +374,32 @@ pub async fn cli_agent_run( } } + let persist_session_id = session_id.clone(); + let persist_turn_intent_id = turn_intent_id.clone(); + tokio::task::spawn_blocking(move || { + persistence::accept_cli_turn( + &persist_session_id, + &persist_turn_intent_id, + &client_message_id, + ) + .map_err(|err| format!("failed to accept CLI turn lifecycle: {err}")) + }) + .await + .map_err(|err| format!("Task error: {err}"))??; + + let mut running_msg = serde_json::json!({ + "type": "code_session.status_changed", + "session_id": session_id, + "status": "running", + }); + running_msg["turn_intent_id"] = serde_json::Value::String(turn_intent_id.clone()); + crate::api::websocket_handler::broadcast(running_msg.to_string()); + let sid = session_id.clone(); let cli_input = inject_ide_context_into_prompt(&user_input, ide_context.as_ref()); let resume_id = cli_resume_id.clone(); let agent_mode = mode.clone(); + let runner_turn_intent_id = turn_intent_id.clone(); tracing::info!(session_id = %session_id, "cli_agent_run: spawning background runner"); @@ -334,17 +411,34 @@ pub async fn cli_agent_run( resume_id, agent_mode.as_deref(), images, + Some(&runner_turn_intent_id), ) .await { tracing::error!("[CodeSession] Session {} failed: {}", sid, e); - session_runner::flush_cli_streams_for_session(&sid); + session_runner::flush_cli_streams_for_session(&sid).await; // Best-effort: if marking the row as Failed itself fails, log // it explicitly rather than silently dropping the persistence // error — the session row may be left in `Running` until the // health checker repairs it on next pass. - if let Err(persist_err) = - persistence::update_status_with_error(&sid, SessionStatus::Failed, &e) + let failed_sid = sid.clone(); + let failed_error = e.clone(); + let failed_intent = runner_turn_intent_id.clone(); + let persist_result = tokio::task::spawn_blocking(move || { + persistence::update_cli_turn_lifecycle( + &failed_sid, + SessionStatus::Failed, + Some(&failed_error), + Some(( + &failed_intent, + session_persistence::turn_intents::TurnIntentStatus::Failed, + )), + ) + }) + .await; + if let Err(persist_err) = persist_result + .map_err(|err| err.to_string()) + .and_then(|result| result) { tracing::error!( "[CodeSession] failed to mark session {} as Failed: {}", @@ -354,23 +448,23 @@ pub async fn cli_agent_run( } integrations::proxy::server::stop_session_proxy(&sid).await; session_runner::release_proxy_token_for_session_pub(&sid).await; + let mut failed_msg = serde_json::json!({ + "type": "code_session.status_changed", + "session_id": sid, + "status": "failed", + "error_message": e, + }); + failed_msg["turn_intent_id"] = serde_json::Value::String(runner_turn_intent_id.clone()); + crate::api::websocket_handler::broadcast(failed_msg.to_string()); } // Remove finished entry from RUNNING_SESSIONS to prevent unbounded growth session_runner::RUNNING_SESSIONS.lock().await.remove(&sid); }); sessions.insert(session_id.clone(), handle); + drop(sessions); tracing::info!(session_id = %session_id, "cli_agent_run: background runner registered"); - persistence::update_status(&session_id, SessionStatus::Running) - .map_err(|err| format!("DB error updating status: {err}"))?; - let running_msg = serde_json::json!({ - "type": "code_session.status_changed", - "session_id": session_id, - "status": "running", - }); - crate::api::websocket_handler::broadcast(running_msg.to_string()); - Ok(()) } @@ -383,6 +477,7 @@ pub async fn cli_agent_run( /// If `model` or `account_id` is provided, updates the session config before /// re-running so the CLI uses the newly selected model/key. #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn cli_agent_message( session_id: String, content: String, @@ -391,7 +486,11 @@ pub async fn cli_agent_message( ide_context: Option, mode: Option, images: Option>, -) -> Result<(), String> { + turn_intent_id: Option, + client_message_id: Option, +) -> Result { + let turn_intent_id = turn_intent_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let client_message_id = client_message_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); tracing::info!( session_id = %session_id, has_model_override = model.is_some(), @@ -470,18 +569,27 @@ pub async fn cli_agent_message( .map_err(|err| format!("DB error: {}", err))? .and_then(|s| s.cli_session_id) }; - let cli_resume_id = persistence::get_cli_session_id_for_account(&session_id, target_account_id) - .map_err(|err| format!("DB error: {}", err))? - .or_else(|| { - if account_id - .as_deref() - .is_some_and(|new_account_id| session.account_id.as_deref() != Some(new_account_id)) - { - None - } else { - fresh_cli_session_id - } - }); + let resume_session_id = session_id.clone(); + let resume_account_id = target_account_id.map(str::to_string); + let account_scoped_resume_id = tokio::task::spawn_blocking(move || { + persistence::get_cli_session_id_for_account( + &resume_session_id, + resume_account_id.as_deref(), + ) + .map_err(|err| format!("DB error: {err}")) + }) + .await + .map_err(|err| format!("Task error: {err}"))??; + let cli_resume_id = account_scoped_resume_id.or_else(|| { + if account_id + .as_deref() + .is_some_and(|new_account_id| session.account_id.as_deref() != Some(new_account_id)) + { + None + } else { + fresh_cli_session_id + } + }); tracing::info!( session_id = %session_id, @@ -533,15 +641,22 @@ pub async fn cli_agent_message( // Re-run the session with the new message tracing::info!(session_id = %session_id, "cli_agent_message: dispatching rerun"); - cli_agent_run( - session_id, + cli_agent_run_internal( + session_id.clone(), content, cli_resume_id, ide_context, mode, images, + turn_intent_id.clone(), + client_message_id, ) - .await + .await?; + Ok(CliRunReceipt { + session_id, + turn_intent_id, + status: SessionStatus::Running, + }) } /// Respond to a pending approval request from a CLI agent. @@ -592,6 +707,40 @@ pub async fn cli_agent_status(session_id: String) -> Result, .map_err(|e| format!("Task error: {}", e))? } +/// Minimal status snapshot for reconnect/focus recovery. The entire request +/// is one blocking database job and is intentionally not used on the healthy +/// push path. +#[tauri::command] +pub async fn cli_agent_status_batch( + session_ids: Vec, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let mut seen = HashSet::new(); + let session_ids: Vec = session_ids + .into_iter() + .filter(|session_id| !session_id.is_empty() && seen.insert(session_id.clone())) + .take(MAX_CLI_STATUS_BATCH_SESSIONS) + .collect(); + let intents = session_persistence::turn_intents::latest_for_sessions(&session_ids) + .map_err(|err| format!("DB error loading turn intents: {err}"))?; + let rows = persistence::status_snapshots(&session_ids) + .map_err(|err| format!("DB error: {err}"))?; + Ok(rows + .into_iter() + .map(|session| CliAgentStatusBatchItem { + turn_intent_id: intents + .get(&session.session_id) + .map(|intent| intent.turn_intent_id.clone()), + session_id: session.session_id, + status: session.status, + updated_at: session.updated_at, + }) + .collect()) + }) + .await + .map_err(|err| format!("Task error: {err}"))? +} + /// Get the last ORGII-side history mutation that invalidated native CLI resume state. #[tauri::command] pub async fn cli_agent_history_mutation( @@ -813,9 +962,14 @@ pub async fn cli_agent_truncate_after_chunk( // resume id the CLI opens a fresh conversation, and the superseded store // stays on disk hidden behind the native-transcript ledger — the same // semantics Claude/Codex native forks already have. - if persistence::session_persists_chunks(&session_id) { - session_runner::cleanup_cursor_config_dir(&session_id); - } + let cleanup_session_id = session_id.clone(); + tokio::task::spawn_blocking(move || { + if persistence::session_persists_chunks(&cleanup_session_id) { + session_runner::cleanup_cursor_config_dir(&cleanup_session_id); + } + }) + .await + .map_err(|err| format!("Task error: {err}"))?; let should_revert_files = revert_files.unwrap_or(true); if should_revert_files { @@ -880,10 +1034,18 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { return Err("No user input found for session — cannot resume.".to_string()); } - let cli_resume_id = - persistence::get_cli_session_id_for_account(&session_id, session.account_id.as_deref()) - .map_err(|err| format!("DB error: {}", err))? - .or(session.cli_session_id); + let resume_lookup_session_id = session_id.clone(); + let resume_lookup_account_id = session.account_id.clone(); + let account_resume_id = tokio::task::spawn_blocking(move || { + persistence::get_cli_session_id_for_account( + &resume_lookup_session_id, + resume_lookup_account_id.as_deref(), + ) + .map_err(|err| format!("DB error: {err}")) + }) + .await + .map_err(|err| format!("Task error: {err}"))??; + let cli_resume_id = account_resume_id.or(session.cli_session_id); // Guard before expensive cleanup. Do not hold the global RUNNING_SESSIONS // mutex across process/proxy/DB awaits: one slow resume cleanup must not @@ -920,21 +1082,37 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { integrations::proxy::server::stop_session_proxy(&session_id).await; // Reset status to pending - persistence::update_status(&session_id, SessionStatus::Pending) - .map_err(|e| format!("DB error: {}", e))?; + let pending_session_id = session_id.clone(); + tokio::task::spawn_blocking(move || { + persistence::update_status(&pending_session_id, SessionStatus::Pending) + .map_err(|e| format!("DB error: {}", e)) + }) + .await + .map_err(|err| format!("Task error: {err}"))??; let sid = session_id.clone(); let input = user_input.clone(); let handle = tokio::spawn(async move { if let Err(e) = - session_runner::run_session(sid.clone(), input, cli_resume_id, None, None).await + session_runner::run_session(sid.clone(), input, cli_resume_id, None, None, None).await { tracing::error!("[CodeSession] Resume of {} failed: {}", sid, e); // Same fail-loud principle as the create path above: log the // persistence failure so a stuck Running row is traceable. - if let Err(persist_err) = - persistence::update_status_with_error(&sid, SessionStatus::Failed, &e) + let failed_session_id = sid.clone(); + let failed_error = e.clone(); + let persist_result = tokio::task::spawn_blocking(move || { + persistence::update_status_with_error( + &failed_session_id, + SessionStatus::Failed, + &failed_error, + ) + }) + .await; + if let Err(persist_err) = persist_result + .map_err(|join_err| join_err.to_string()) + .and_then(|result| result.map_err(|db_err| db_err.to_string())) { tracing::error!( "[CodeSession] failed to mark resumed session {} as Failed: {}", @@ -980,7 +1158,12 @@ pub async fn cli_agent_delete(session_id: String) -> Result { session_runner::release_proxy_token_for_session_pub(&session_id).await; // Clean up persistent Cursor config dir (contains chat session data for --resume) - session_runner::cleanup_cursor_config_dir(&session_id); + let cleanup_session_id = session_id.clone(); + tokio::task::spawn_blocking(move || { + session_runner::cleanup_cursor_config_dir(&cleanup_session_id) + }) + .await + .map_err(|err| format!("Task error: {err}"))?; // Clean up worktree if session had isolation enabled let session = tokio::task::spawn_blocking({ @@ -1180,3 +1363,31 @@ pub async fn cli_agent_worktree_discard(session_id: String) -> Result String { Utc::now().to_rfc3339() @@ -166,6 +168,39 @@ pub fn list_sessions() -> SqliteResult> { rows.collect() } +/// Load only lifecycle fields for a bounded set of sessions. This is used by +/// reconnect/focus reconciliation and deliberately avoids hydrating complete +/// session rows or scanning unrelated sessions. +pub fn status_snapshots(session_ids: &[String]) -> SqliteResult> { + if session_ids.is_empty() { + return Ok(Vec::new()); + } + let conn = get_connection()?; + let placeholders = std::iter::repeat_n("?", session_ids.len()) + .collect::>() + .join(","); + let query = format!( + "SELECT session_id, status, updated_at FROM code_sessions WHERE session_id IN ({placeholders})" + ); + let mut stmt = conn.prepare(&query)?; + let rows = stmt.query_map(rusqlite::params_from_iter(session_ids), |row| { + let raw_status: String = row.get(1)?; + let status = SessionStatus::parse(&raw_status).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + format!("invalid code session status: {raw_status}").into(), + ) + })?; + Ok(CliSessionStatusSnapshot { + session_id: row.get(0)?, + status, + updated_at: row.get(2)?, + }) + })?; + rows.collect() +} + /// One page of sessions ordered by recent activity. Serves the sidebar's /// paginated category view without loading the whole table. pub fn list_sessions_page(limit: usize, offset: usize) -> SqliteResult> { @@ -184,21 +219,38 @@ pub fn list_sessions_page(limit: usize, offset: usize) -> SqliteResult SqliteResult { let conn = get_connection()?; + let affected = update_status_row(&conn, session_id, status, None)?; + if affected { + sync_orgtrack_mirror(session_id); + } + Ok(affected) +} + +fn update_status_row( + conn: &Connection, + session_id: &str, + status: SessionStatus, + error: Option<&str>, +) -> SqliteResult { let now = now_iso(); - let affected = if status.is_terminal() { - conn.execute( + let affected = match (status.is_terminal(), error) { + (true, Some(error)) => conn.execute( + "UPDATE code_sessions SET status = ?2, error_message = ?3, pid = NULL, updated_at = ?4 WHERE session_id = ?1", + params![session_id, status.as_ref(), error, now], + )?, + (false, Some(error)) => conn.execute( + "UPDATE code_sessions SET status = ?2, error_message = ?3, updated_at = ?4 WHERE session_id = ?1", + params![session_id, status.as_ref(), error, now], + )?, + (true, None) => conn.execute( "UPDATE code_sessions SET status = ?2, pid = NULL, updated_at = ?3 WHERE session_id = ?1", params![session_id, status.as_ref(), now], - )? - } else { - conn.execute( + )?, + (false, None) => conn.execute( "UPDATE code_sessions SET status = ?2, updated_at = ?3 WHERE session_id = ?1", params![session_id, status.as_ref(), now], - )? + )?, }; - if affected > 0 { - sync_orgtrack_mirror(session_id); - } Ok(affected > 0) } @@ -209,22 +261,76 @@ pub fn update_status_with_error( error: &str, ) -> SqliteResult { let conn = get_connection()?; - let now = now_iso(); - let affected = if status.is_terminal() { - conn.execute( - "UPDATE code_sessions SET status = ?2, error_message = ?3, pid = NULL, updated_at = ?4 WHERE session_id = ?1", - params![session_id, status.as_ref(), error, now], - )? - } else { - conn.execute( - "UPDATE code_sessions SET status = ?2, error_message = ?3, updated_at = ?4 WHERE session_id = ?1", - params![session_id, status.as_ref(), error, now], - )? - }; - if affected > 0 { + let affected = update_status_row(&conn, session_id, status, Some(error))?; + if affected { sync_orgtrack_mirror(session_id); } - Ok(affected > 0) + Ok(affected) +} + +/// Atomically accept a CLI turn: the session and its intent become running +/// together, so reconnect cannot observe a split-brain lifecycle snapshot. +pub fn accept_cli_turn( + session_id: &str, + turn_intent_id: &str, + client_message_id: &str, +) -> Result<(), String> { + let conn = get_connection().map_err(|err| err.to_string())?; + let tx = conn + .unchecked_transaction() + .map_err(|err| err.to_string())?; + if !update_status_row(&tx, session_id, SessionStatus::Running, None) + .map_err(|err| err.to_string())? + { + return Err(format!("session not found: {session_id}")); + } + session_persistence::turn_intents::upsert_initial_on( + &tx, + session_id, + turn_intent_id, + Some(client_message_id), + session_persistence::turn_intents::TurnIntentSource::UserSubmit, + session_persistence::turn_intents::TurnIntentStatus::Queued, + ) + .map_err(|err| err.to_string())?; + session_persistence::turn_intents::update_status_on( + &tx, + session_id, + turn_intent_id, + session_persistence::turn_intents::TurnIntentStatus::Running, + ) + .map_err(|err| err.to_string())?; + tx.commit().map_err(|err| err.to_string())?; + sync_orgtrack_mirror(session_id); + Ok(()) +} + +/// Atomically persist a CLI session status and the matching intent terminal. +pub fn update_cli_turn_lifecycle( + session_id: &str, + status: SessionStatus, + error: Option<&str>, + turn_intent: Option<(&str, session_persistence::turn_intents::TurnIntentStatus)>, +) -> Result<(), String> { + let conn = get_connection().map_err(|err| err.to_string())?; + let tx = conn + .unchecked_transaction() + .map_err(|err| err.to_string())?; + if !update_status_row(&tx, session_id, status, error).map_err(|err| err.to_string())? { + return Err(format!("session not found: {session_id}")); + } + if let Some((turn_intent_id, intent_status)) = turn_intent { + session_persistence::turn_intents::update_status_on( + &tx, + session_id, + turn_intent_id, + intent_status, + ) + .map_err(|err| err.to_string())?; + } + tx.commit().map_err(|err| err.to_string())?; + sync_orgtrack_mirror(session_id); + Ok(()) } /// Store the PID of the CLI subprocess. diff --git a/src-tauri/src/agent_sessions/cli/persistence/types.rs b/src-tauri/src/agent_sessions/cli/persistence/types.rs index b80e341ed..9e875ad8c 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/types.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/types.rs @@ -3,6 +3,13 @@ use serde::{Deserialize, Serialize}; use super::super::types::KeySource; use super::super::types::SessionStatus; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliSessionStatusSnapshot { + pub session_id: String, + pub status: SessionStatus, + pub updated_at: String, +} + /// A code generation session record. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/agent_sessions/cli/session_runner/cursor_usage.rs b/src-tauri/src/agent_sessions/cli/session_runner/cursor_usage.rs index 484b594df..0b6368de3 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/cursor_usage.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/cursor_usage.rs @@ -78,19 +78,34 @@ pub(super) async fn fetch_cursor_usage_for_session( return; } - if let Err(err) = session_persistence::token_usage::insert_token_usage_record( - session_id, - "code", - summary.dominant_model.as_deref(), - account_id, - summary.input_tokens as i64, - summary.output_tokens as i64, - summary.cache_read_tokens as i64, - summary.cache_write_tokens as i64, - summary.total_tokens as i64, - 0, - None, - ) { + let persist_session_id = session_id.to_string(); + let persist_model = summary.dominant_model.clone(); + let persist_account_id = account_id.map(str::to_string); + let input_tokens = summary.input_tokens as i64; + let output_tokens = summary.output_tokens as i64; + let cache_read_tokens = summary.cache_read_tokens as i64; + let cache_write_tokens = summary.cache_write_tokens as i64; + let total_tokens = summary.total_tokens as i64; + let persist_result = tokio::task::spawn_blocking(move || { + session_persistence::token_usage::insert_token_usage_record( + &persist_session_id, + "code", + persist_model.as_deref(), + persist_account_id.as_deref(), + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens, + 0, + None, + ) + }) + .await; + if let Err(err) = persist_result + .map_err(|join_err| join_err.to_string()) + .and_then(|db_result| db_result.map_err(|db_err| db_err.to_string())) + { tracing::warn!( "[CursorUsage] Failed to insert per-round token usage for session {}: {}", session_id, diff --git a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs index ae90b0ca4..20e0d68df 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs @@ -355,21 +355,8 @@ pub(super) fn apply_system_proxy_passthrough(env_vars: &mut HashMap, -) { - if !(matches!(agent, ModelType::Codex) && session.key_source == KeySource::HostedKey) { - return; - } - +fn ensure_codex_hosted_proxy_config(proxy_url_val: &str) { if let Some(home) = dirs::home_dir() { - let proxy_url_val = session.proxy_url.as_deref().unwrap_or(""); let codex_dir = home.join(".codex"); let config_file = codex_dir.join("config.toml"); @@ -419,6 +406,29 @@ pub(super) async fn setup_codex_hosted_proxy( } } } +} + +/// For a Codex hosted-key session: ensure `~/.codex/config.toml` has the proxy +/// `model_providers.proxy` section, then run `codex login --with-api-key` with +/// the proxy token. No-op for any other agent/key-source. Failures are logged +/// and the session continues. +pub(super) async fn setup_codex_hosted_proxy( + agent: &ModelType, + session: &CodeSession, + env_vars: &HashMap, +) { + if !(matches!(agent, ModelType::Codex) && session.key_source == KeySource::HostedKey) { + return; + } + + let proxy_url = session.proxy_url.clone().unwrap_or_default(); + if let Err(err) = tokio::task::spawn_blocking(move || { + ensure_codex_hosted_proxy_config(&proxy_url); + }) + .await + { + tracing::warn!("[CodeSession] Codex proxy config task failed: {err}"); + } let api_key_val = session.proxy_token.as_deref().unwrap_or(""); if !api_key_val.is_empty() { @@ -484,37 +494,43 @@ pub(super) async fn setup_opencode_sse_sanitizer( return; } - if let Ok(config_text) = std::fs::read_to_string( - dirs::config_dir() - .unwrap_or_default() - .join("opencode") - .join("opencode.json"), - ) { - if let Ok(config) = serde_json::from_str::(&config_text) { - let base_url = config - .get("provider") - .and_then(|p| p.get("anthropic")) - .and_then(|a| a.get("options")) - .and_then(|o| o.get("baseURL")) - .and_then(|v| v.as_str()); - if let Some(upstream) = base_url { - if !upstream.contains("127.0.0.1") && !upstream.contains("localhost") { - match integrations::proxy::sse_sanitizer::ensure_running(upstream).await { - Ok(local_url) => { - tracing::info!( - "[CodeSession] SSE sanitizer active: {} → {}", - local_url, - upstream - ); - env_vars.insert("ANTHROPIC_BASE_URL".to_string(), local_url); - } - Err(err) => { - tracing::warn!( - "[CodeSession] SSE sanitizer failed: {} — using direct connection", - err - ); - } - } + let upstream = tokio::task::spawn_blocking(|| { + let config_text = std::fs::read_to_string( + dirs::config_dir() + .unwrap_or_default() + .join("opencode") + .join("opencode.json"), + ) + .ok()?; + let config = serde_json::from_str::(&config_text).ok()?; + config + .get("provider")? + .get("anthropic")? + .get("options")? + .get("baseURL")? + .as_str() + .map(str::to_string) + }) + .await + .ok() + .flatten(); + + if let Some(upstream) = upstream { + if !upstream.contains("127.0.0.1") && !upstream.contains("localhost") { + match integrations::proxy::sse_sanitizer::ensure_running(&upstream).await { + Ok(local_url) => { + tracing::info!( + "[CodeSession] SSE sanitizer active: {} → {}", + local_url, + upstream + ); + env_vars.insert("ANTHROPIC_BASE_URL".to_string(), local_url); + } + Err(err) => { + tracing::warn!( + "[CodeSession] SSE sanitizer failed: {} — using direct connection", + err + ); } } } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs index e4abf4204..9fa9b8ef5 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs @@ -48,6 +48,7 @@ pub(super) async fn finalize_session_run( use_codex_app_server: bool, is_acp_agent: bool, synced_rule_files: &[std::path::PathBuf], + turn_intent_id: Option<&str>, outcome: SessionRunOutcome, ) { let SessionRunOutcome { @@ -62,29 +63,44 @@ pub(super) async fn finalize_session_run( let session_id = session.session_id.as_str(); let account_id = session.account_id.as_deref(); - if *agent == ModelType::Codex && session.key_source == KeySource::OwnKey { - let launched_access_token = env_vars.get("OPENAI_API_KEY").map(String::as_str); - if let Err(err) = sync_codex_cli_auth_to_key_vault(account_id, launched_access_token) { - tracing::warn!( - "[CodeSession] Failed to sync Codex CLI auth tokens: {}", - err - ); - } - if exit_code == 0 { - if let Some(account_id) = account_id { - if let Err(err) = KEY_SERVICE.reset_oauth_refresh_failures(account_id) { - tracing::warn!( - "[CodeSession] Failed to reset Codex OAuth refresh failures: {}", - err - ); + let setup_is_codex_own_key = + *agent == ModelType::Codex && session.key_source == KeySource::OwnKey; + let setup_access_token = env_vars.get("OPENAI_API_KEY").cloned(); + let setup_account_id = account_id.map(str::to_string); + let setup_session_id = session_id.to_string(); + let setup_cli_session_id = cli_session_id_out.clone(); + let _ = tokio::task::spawn_blocking(move || { + if setup_is_codex_own_key { + if let Err(err) = sync_codex_cli_auth_to_key_vault( + setup_account_id.as_deref(), + setup_access_token.as_deref(), + ) { + tracing::warn!( + "[CodeSession] Failed to sync Codex CLI auth tokens: {}", + err + ); + } + if exit_code == 0 { + if let Some(account_id) = setup_account_id.as_deref() { + if let Err(err) = KEY_SERVICE.reset_oauth_refresh_failures(account_id) { + tracing::warn!( + "[CodeSession] Failed to reset Codex OAuth refresh failures: {}", + err + ); + } } } } - } - - if let Some(ref cli_sid) = cli_session_id_out { - persistence::update_cli_session_id_for_account(session_id, account_id, cli_sid).ok(); - } + if let Some(cli_session_id) = setup_cli_session_id.as_deref() { + persistence::update_cli_session_id_for_account( + &setup_session_id, + setup_account_id.as_deref(), + cli_session_id, + ) + .ok(); + } + }) + .await; let raw_final_status = if cli_plan_approval_gate_reached { SessionStatus::Completed @@ -162,15 +178,25 @@ pub(super) async fn finalize_session_run( None }; - if *agent == ModelType::Codex + let should_record_oauth_failure = *agent == ModelType::Codex && session.key_source == KeySource::OwnKey && error_message .as_deref() - .is_some_and(is_cli_oauth_failure_message) - { - if let Some(account_id) = account_id { - if let Some(ref err_msg) = error_message { - if let Err(err) = KEY_SERVICE.record_oauth_refresh_failure(account_id, err_msg) { + .is_some_and(is_cli_oauth_failure_message); + + let persist_session_id = session_id.to_string(); + let persist_error_message = error_message.clone(); + let persist_turn_intent_id = turn_intent_id.map(str::to_string); + let persist_account_id = account_id.map(str::to_string); + let persist_result = tokio::task::spawn_blocking(move || { + if should_record_oauth_failure { + if let (Some(account_id), Some(error_message)) = ( + persist_account_id.as_deref(), + persist_error_message.as_deref(), + ) { + if let Err(err) = + KEY_SERVICE.record_oauth_refresh_failure(account_id, error_message) + { tracing::warn!( "[CodeSession] Failed to record Codex OAuth refresh failure: {}", err @@ -178,17 +204,29 @@ pub(super) async fn finalize_session_run( } } } - } - - if let Some(ref err_msg) = error_message { - if let Err(err) = persistence::update_status_with_error(session_id, final_status, err_msg) { - tracing::error!( - "[CodeSession] Failed to update final status with error: {}", - err - ); - } - } else if let Err(err) = persistence::update_status(session_id, final_status) { - tracing::error!("[CodeSession] Failed to update final status: {}", err); + let intent_status = persist_turn_intent_id.as_deref().map(|turn_intent_id| { + ( + turn_intent_id, + if raw_final_status == SessionStatus::Completed { + session_persistence::turn_intents::TurnIntentStatus::Completed + } else { + session_persistence::turn_intents::TurnIntentStatus::Failed + }, + ) + }); + persistence::update_cli_turn_lifecycle( + &persist_session_id, + final_status, + persist_error_message.as_deref(), + intent_status, + ) + }) + .await; + if let Err(err) = persist_result + .map_err(|join_err| join_err.to_string()) + .and_then(|result| result) + { + tracing::error!("[CodeSession] Failed to persist final lifecycle: {}", err); } if final_status.is_terminal() { @@ -213,7 +251,7 @@ pub(super) async fn finalize_session_run( } // Flush any pending streaming deltas before signaling session end - flush_and_broadcast(session_id); + flush_and_broadcast(session_id).await; let mut status_msg = serde_json::json!({ "type": "code_session.status_changed", @@ -226,6 +264,9 @@ pub(super) async fn finalize_session_run( if let Some(ref err_msg) = error_message { status_msg["error_message"] = serde_json::Value::String(err_msg.clone()); } + if let Some(turn_intent_id) = turn_intent_id { + status_msg["turn_intent_id"] = serde_json::Value::String(turn_intent_id.to_string()); + } websocket_handler::broadcast(status_msg.to_string()); // ── Worktree: commit changes on completion ── diff --git a/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs b/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs index 79d231f31..557c8594b 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs @@ -55,7 +55,54 @@ pub(super) fn strip_ide_context(input: &str) -> String { /// /// Shared helper used by both the ACP flow (Copilot) and the standard /// CliAgentParser loop (all other agents). -pub(super) fn emit_chunk( +pub(super) async fn emit_chunk( + chunk: &core_types::activity::ActivityChunk, + session_id: &str, + sequence: &mut i64, +) { + let action_type = chunk.action_type.as_str(); + let is_delta = action_type.contains("delta") + && chunk + .result + .get("is_delta") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let delta_requires_flush = action_type == "tool_call_delta" + && (chunk + .result + .get("tool_call_id") + .and_then(|v| v.as_str()) + .is_some_and(|value| !value.is_empty()) + || chunk + .result + .get("tool_name") + .and_then(|v| v.as_str()) + .is_some_and(|value| !value.is_empty())); + + // Ordinary token deltas are memory-only and latency-sensitive. Keep them + // on the async runner; only chunks that may touch SQLite, the event cache, + // or filesystem side effects cross onto the blocking pool. + if is_delta && !delta_requires_flush { + emit_chunk_blocking(chunk, session_id, sequence); + return; + } + + let owned_chunk = chunk.clone(); + let owned_session_id = session_id.to_string(); + let initial_sequence = *sequence; + match tokio::task::spawn_blocking(move || { + let mut next_sequence = initial_sequence; + emit_chunk_blocking(&owned_chunk, &owned_session_id, &mut next_sequence); + next_sequence + }) + .await + { + Ok(next_sequence) => *sequence = next_sequence, + Err(err) => tracing::error!("[CodeSession] chunk persistence task failed: {err}"), + } +} + +fn emit_chunk_blocking( chunk: &core_types::activity::ActivityChunk, session_id: &str, sequence: &mut i64, @@ -91,7 +138,7 @@ pub(super) fn emit_chunk( .filter(|v| !v.is_empty()) .is_some(); if has_tool_identity { - flush_and_broadcast(session_id); + flush_and_broadcast_blocking(session_id); } } @@ -145,7 +192,7 @@ pub(super) fn emit_chunk( } else { // Non-streaming chunk (tool_call, user_message, etc.): flush any // pending streams before appending, same as UnifiedEventHandler. - flush_and_broadcast(session_id); + flush_and_broadcast_blocking(session_id); } // Persist non-delta chunks to DB (legacy mode). Native-transcript @@ -253,7 +300,7 @@ fn persist_streaming_complete_chunk( } /// Flush all pending CLI streams and broadcast completion events. -pub(super) fn flush_and_broadcast(session_id: &str) { +fn flush_and_broadcast_blocking(session_id: &str) { let mut sequence = next_chunk_sequence(session_id); for event in crate::agent_sessions::event_pipeline::streaming::cli_flush_session(session_id) { let stream_type = if event.action_type == "assistant" { @@ -270,8 +317,19 @@ pub(super) fn flush_and_broadcast(session_id: &str) { } } -pub fn flush_cli_streams_for_session(session_id: &str) { - flush_and_broadcast(session_id); +pub(super) async fn flush_and_broadcast(session_id: &str) { + let owned_session_id = session_id.to_string(); + if let Err(err) = tokio::task::spawn_blocking(move || { + flush_and_broadcast_blocking(&owned_session_id); + }) + .await + { + tracing::error!("[CodeSession] stream flush task failed: {err}"); + } +} + +pub async fn flush_cli_streams_for_session(session_id: &str) { + flush_and_broadcast(session_id).await; } /// Drop hook-derived live status for a finished managed session. The @@ -328,7 +386,7 @@ fn is_cli_file_edit_function(function_name: &str) -> bool { /// /// Non-fatal: snapshot failures are logged at `warn` level and never block the /// chunk from being persisted and broadcast. -pub(super) fn snapshot_cli_file_edit( +fn snapshot_cli_file_edit_blocking( session_id: &str, snapshot_id: &str, chunk: &core_types::activity::ActivityChunk, @@ -422,6 +480,30 @@ pub(super) fn snapshot_cli_file_edit( } } +pub(super) async fn snapshot_cli_file_edit( + session_id: &str, + snapshot_id: &str, + chunk: &core_types::activity::ActivityChunk, + repo_path: &str, +) { + let owned_session_id = session_id.to_string(); + let owned_snapshot_id = snapshot_id.to_string(); + let owned_chunk = chunk.clone(); + let owned_repo_path = repo_path.to_string(); + if let Err(err) = tokio::task::spawn_blocking(move || { + snapshot_cli_file_edit_blocking( + &owned_session_id, + &owned_snapshot_id, + &owned_chunk, + &owned_repo_path, + ); + }) + .await + { + tracing::warn!("[cli_snapshot] snapshot task failed: {err}"); + } +} + /// Save base64 data-URL images to `~/.orgii/session-images/` and return file paths. /// /// Delegates to `agent_core::images::persist_images` which uses content-hash @@ -436,7 +518,18 @@ pub(super) async fn persist_attached_images( return vec![]; } - let paths = agent_core::persistence::images::persist_images(imgs); + let owned_images = imgs.to_vec(); + let paths = match tokio::task::spawn_blocking(move || { + agent_core::persistence::images::persist_images(&owned_images) + }) + .await + { + Ok(paths) => paths, + Err(err) => { + tracing::warn!("[CodeSession] image persistence task failed: {err}"); + Vec::new() + } + }; if !paths.is_empty() { tracing::info!( diff --git a/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs b/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs index cf064a895..699800c5d 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs @@ -55,18 +55,23 @@ pub async fn terminate_process_tree(pid: i64, _label: &str) { /// in the database — callers are responsible for setting the appropriate final status /// (e.g., Cancelled for user cancel, or nothing before a re-run). pub async fn kill_running_agent(session_id: &str) -> bool { - let had_running_task = { + let running_task = { let mut sessions = RUNNING_SESSIONS.lock().await; - if let Some(handle) = sessions.remove(session_id) { - flush_cli_streams_for_session(session_id); - handle.abort(); - true - } else { - false - } + sessions.remove(session_id) }; + let had_running_task = running_task.is_some(); + if let Some(handle) = running_task { + // The flush can touch SQLite. Never hold the global runner registry + // lock across that blocking-pool round trip or unrelated sessions' + // start/stop operations would serialize behind it. + flush_cli_streams_for_session(session_id).await; + handle.abort(); + } - if let Ok(Some(session)) = persistence::get_session(session_id) { + let process_session_id = session_id.to_string(); + let persisted_session = + tokio::task::spawn_blocking(move || persistence::get_session(&process_session_id)).await; + if let Ok(Ok(Some(session))) = persisted_session { if let Some(pid) = session.pid { terminate_process_tree(pid, session_id).await; } @@ -92,15 +97,39 @@ pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result s, - Err(err) => { + let lookup_session_id = session_id.to_string(); + let (session, active_turn_intent_id) = match tokio::task::spawn_blocking(move || { + let session = + persistence::get_session(&lookup_session_id).map_err(|err| err.to_string())?; + let latest = session_persistence::turn_intents::latest_for_sessions(std::slice::from_ref( + &lookup_session_id, + )) + .map_err(|err| err.to_string())? + .remove(&lookup_session_id) + .filter(|intent| { + intent.status == session_persistence::turn_intents::TurnIntentStatus::Running + }) + .map(|intent| intent.turn_intent_id); + Ok::<_, String>((session, latest)) + }) + .await + { + Ok(Ok(result)) => result, + Ok(Err(err)) => { tracing::warn!( session_id = %session_id, error = %err, "cli::cancel_session: get_session DB error; broadcast will lack session metadata" ); - None + (None, None) + } + Err(err) => { + tracing::warn!( + session_id = %session_id, + error = %err, + "cli::cancel_session: status lookup task failed" + ); + (None, None) } }; @@ -112,8 +141,23 @@ pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result Result Result s, - _ => return, - }; + let load_session_id = session_id.to_string(); + let session = + match tokio::task::spawn_blocking(move || persistence::get_session(&load_session_id)).await + { + Ok(Ok(Some(s))) => s, + _ => return, + }; if session.key_source != KeySource::HostedKey { return; diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session.rs b/src-tauri/src/agent_sessions/cli/session_runner/session.rs index 5658f7831..75935ee96 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session.rs @@ -39,6 +39,41 @@ const SPAWN_RETRY_ATTEMPTS: usize = 3; const SPAWN_RETRY_BASE_DELAY_MS: u64 = 250; const CLI_PLAN_GATE_NATURAL_EXIT_GRACE_SECS: u64 = 45; +#[allow(clippy::too_many_arguments)] +async fn persist_round_token_usage( + session_id: String, + model: Option, + account_id: Option, + input_tokens: i64, + output_tokens: i64, + cache_read_tokens: i64, + cache_write_tokens: i64, + total_tokens: i64, +) { + let result = tokio::task::spawn_blocking(move || { + session_persistence::token_usage::insert_token_usage_record( + &session_id, + "code", + model.as_deref(), + account_id.as_deref(), + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens, + 0, + None, + ) + }) + .await; + if let Err(err) = result + .map_err(|join_err| join_err.to_string()) + .and_then(|db_result| db_result.map_err(|db_err| db_err.to_string())) + { + tracing::warn!("[CodeSession] Failed to insert per-round token usage: {err}"); + } +} + fn is_transient_spawn_error(err: &io::Error) -> bool { matches!( err.kind(), @@ -67,8 +102,12 @@ pub async fn run_session( cli_resume_id: Option, mode: Option<&str>, images: Option>, + turn_intent_id: Option<&str>, ) -> Result<(), String> { - let session = persistence::get_session(&session_id) + let load_session_id = session_id.clone(); + let session = tokio::task::spawn_blocking(move || persistence::get_session(&load_session_id)) + .await + .map_err(|err| format!("Task error: {err}"))? .map_err(|e| format!("DB error: {}", e))? .ok_or_else(|| format!("Session not found: {}", session_id))?; @@ -134,15 +173,6 @@ pub async fn run_session( } } - // Sync .orgii/agent-rules.md → agent-native rules file - let mut synced_rule_files: Vec = Vec::new(); - if let Some(path) = repo_path { - let project = std::path::Path::new(path); - synced_rule_files.extend(super::super::skill_sync::sync_conventions_for_agent( - &agent, project, - )); - } - // Sync skills to agent-native rules files. // // Agent resolve contract (design doc §11.4) row 17: resolve the built-in SDE agent (the CLI @@ -150,68 +180,94 @@ pub async fn run_session( // are a host-wide concern carried on the SDE definition) and read // `skills.enabled` + `skills.disabled` off `ResolvedAgent`. let skills_cfg = resolve_sde_skills(); - if let Some(path) = repo_path { - let project = std::path::Path::new(path); - synced_rule_files.extend(super::super::skill_sync::sync_skills_for_agent( - &agent, - project, - skills_cfg.enabled, - &skills_cfg.disabled, - )); - } + let setup_agent = agent.clone(); + let setup_repo_path = repo_path.map(str::to_string); + let setup_session_id = session_id.clone(); + let setup_skills_cfg = skills_cfg.clone(); + let (synced_rule_files, pre_message_snapshot_id) = tokio::task::spawn_blocking(move || { + let mut synced_rule_files = Vec::new(); + if let Some(path) = setup_repo_path.as_deref() { + let project = std::path::Path::new(path); + synced_rule_files.extend(super::super::skill_sync::sync_conventions_for_agent( + &setup_agent, + project, + )); + synced_rule_files.extend(super::super::skill_sync::sync_skills_for_agent( + &setup_agent, + project, + setup_skills_cfg.enabled, + &setup_skills_cfg.disabled, + )); + } - // Pre-message anchor snapshot for CLI rollback support. - // `snapshot_cli_file_edit` populates this snapshot with git-HEAD bytes of - // each file the agent touches, filling the gap that SDE Agent closes via - // `UnifiedEventHandler::take_snapshot` (which fires before the tool runs). - let pre_message_snapshot_id = match agent_core::tools::file_history::make_snapshot(&session_id) - { - Ok(snapshot_id) => { - tracing::info!( - "[code_session] Pre-message anchor snapshot: {}", - snapshot_id - ); - if let Err(err) = agent_core::session::persistence::save_snapshot( - &session_id, - "__pre_message__", - &snapshot_id, - ) { - tracing::warn!( - "[code_session] Failed to persist pre-message snapshot: {}", - err + // Pre-message anchor snapshot for CLI rollback support. + let snapshot_id = match agent_core::tools::file_history::make_snapshot(&setup_session_id) { + Ok(snapshot_id) => { + tracing::info!( + "[code_session] Pre-message anchor snapshot: {}", + snapshot_id ); + if let Err(err) = agent_core::session::persistence::save_snapshot( + &setup_session_id, + "__pre_message__", + &snapshot_id, + ) { + tracing::warn!( + "[code_session] Failed to persist pre-message snapshot: {}", + err + ); + } + Some(snapshot_id) } - Some(snapshot_id) - } - Err(err) => { - tracing::warn!("[code_session] Pre-message snapshot failed: {}", err); - None - } - }; + Err(err) => { + tracing::warn!("[code_session] Pre-message snapshot failed: {}", err); + None + } + }; + (synced_rule_files, snapshot_id) + }) + .await + .map_err(|err| format!("runner setup task failed: {err}"))?; let run_started_at = chrono::Utc::now(); // Resolved early: the experimental codex app-server transport gate // changes prompt assembly (images travel as native localImage inputs) // as well as argv and the stdout-processing branch below. - let launch_profile = resolve_cli_launch_profile(&agent)?; + let launch_profile_agent = agent.clone(); + let launch_profile = + tokio::task::spawn_blocking(move || resolve_cli_launch_profile(&launch_profile_agent)) + .await + .map_err(|err| format!("launch profile task failed: {err}"))??; let use_codex_app_server = super::launch_profiles::uses_codex_app_server(&agent, &launch_profile); let image_paths = persist_attached_images(&session_id, images.as_deref()).await; - let effective_input = super::input_assembly::build_effective_input( - &user_input, - mode, - &session_id, - cli_resume_id.is_none(), - &agent, - &image_paths, - use_codex_app_server, - repo_path, - skills_cfg.enabled, - &skills_cfg.disabled, - ); + let prompt_user_input = user_input.clone(); + let prompt_mode = mode.map(str::to_string); + let prompt_session_id = session_id.clone(); + let prompt_agent = agent.clone(); + let prompt_image_paths = image_paths.clone(); + let prompt_repo_path = repo_path.map(str::to_string); + let prompt_skills = skills_cfg.clone(); + let is_fresh_session = cli_resume_id.is_none(); + let effective_input = tokio::task::spawn_blocking(move || { + super::input_assembly::build_effective_input( + &prompt_user_input, + prompt_mode.as_deref(), + &prompt_session_id, + is_fresh_session, + &prompt_agent, + &prompt_image_paths, + use_codex_app_server, + prompt_repo_path.as_deref(), + prompt_skills.enabled, + &prompt_skills.disabled, + ) + }) + .await + .map_err(|err| format!("prompt assembly task failed: {err}"))?; // Build CLI command let api_key_for_cli = if session.key_source == KeySource::HostedKey @@ -342,26 +398,19 @@ pub async fn run_session( // Store user input (without IDE context) let display_input = strip_ide_context(&user_input); - { + let input_session_id = session_id.clone(); + let persisted_display_input = display_input.clone(); + tokio::task::spawn_blocking(move || { let conn = session_persistence::get_connection().map_err(|e| format!("DB: {}", e))?; conn.execute( "UPDATE code_sessions SET user_input = ?2 WHERE session_id = ?1", - rusqlite::params![session_id, display_input], + rusqlite::params![input_session_id, persisted_display_input], ) .map_err(|e| format!("DB: failed to store user_input: {}", e))?; - } - - if let Err(err) = persistence::update_status(&session_id, SessionStatus::Running) { - tracing::error!("[CodeSession] Failed to update status to running: {}", err); - return Err(format!("DB error updating status: {}", err)); - } - - let running_msg = serde_json::json!({ - "type": "code_session.status_changed", - "session_id": session_id, - "status": "running", - }); - websocket_handler::broadcast(running_msg.to_string()); + Ok::<(), String>(()) + }) + .await + .map_err(|err| format!("Task error: {err}"))??; // Start per-session MITM proxy if needed let needs_mitm = session.key_source == KeySource::HostedKey && agent.needs_mitm_proxy(); @@ -370,15 +419,27 @@ pub async fn run_session( super::env_setup::start_session_mitm_proxy(&session, &session_id, &mut env_vars).await?; } - super::env_setup::configure_agent_profile( - &agent, - &session, - account_id, - selected_key.as_ref(), - &session_id, - cli_resume_id.as_deref(), - &mut env_vars, - )?; + let profile_agent = agent.clone(); + let profile_session = session.clone(); + let profile_account_id = account_id.map(str::to_string); + let profile_selected_key = selected_key.clone(); + let profile_session_id = session_id.clone(); + let profile_resume_id = cli_resume_id.clone(); + env_vars = tokio::task::spawn_blocking(move || { + let mut profile_env = env_vars; + super::env_setup::configure_agent_profile( + &profile_agent, + &profile_session, + profile_account_id.as_deref(), + profile_selected_key.as_ref(), + &profile_session_id, + profile_resume_id.as_deref(), + &mut profile_env, + )?; + Ok::<_, String>(profile_env) + }) + .await + .map_err(|err| format!("agent profile setup task failed: {err}"))??; super::env_setup::apply_system_proxy_passthrough(&mut env_vars); @@ -420,7 +481,12 @@ pub async fn run_session( const MAX_OVERLOAD_RETRIES: u32 = 3; const OVERLOAD_RETRY_BASE_DELAY_SECS: u64 = 2; - let base_sequence: i64 = persistence::max_chunk_sequence(&session_id).unwrap_or(-1) + 1; + let sequence_session_id = session_id.clone(); + let base_sequence: i64 = tokio::task::spawn_blocking(move || { + persistence::max_chunk_sequence(&sequence_session_id).unwrap_or(-1) + 1 + }) + .await + .map_err(|err| format!("Task error: {err}"))?; // Emit user_message chunk { @@ -451,19 +517,34 @@ pub async fn run_session( // broadcast: the frontend's synthetic event already renders the user // bubble instantly, and the CLI's native store is the transcript of // record. Broadcasting here too would render a duplicate bubble. - if persistence::session_persists_chunks(&session_id) { - if let Err(err) = persistence::insert_chunk(&user_chunk, base_sequence) { + let persist_session_id = session_id.clone(); + let persist_user_chunk = user_chunk.clone(); + let persisted_user_chunk = tokio::task::spawn_blocking(move || { + if !persistence::session_persists_chunks(&persist_session_id) { + return Ok(false); + } + persistence::insert_chunk(&persist_user_chunk, base_sequence) + .map_err(|err| err.to_string())?; + Ok::(true) + }) + .await + .map_err(|err| format!("Task error: {err}"))?; + match persisted_user_chunk { + Err(err) => { tracing::error!( "[CodeSession] Failed to persist user_message chunk: {}", err ); } - let ws_msg = serde_json::json!({ - "type": "code_session.activity", - "session_id": session_id, - "chunk": user_chunk, - }); - websocket_handler::broadcast(ws_msg.to_string()); + Ok(false) => {} + Ok(true) => { + let ws_msg = serde_json::json!({ + "type": "code_session.activity", + "session_id": session_id, + "chunk": user_chunk, + }); + websocket_handler::broadcast(ws_msg.to_string()); + } } } @@ -537,7 +618,14 @@ pub async fn run_session( }; if let Some(pid) = child.id() { - if let Err(err) = persistence::update_pid(&session_id, pid) { + let pid_session_id = session_id.clone(); + let pid_result = + tokio::task::spawn_blocking(move || persistence::update_pid(&pid_session_id, pid)) + .await; + if let Err(err) = pid_result + .map_err(|join_err| join_err.to_string()) + .and_then(|result| result.map_err(|db_err| db_err.to_string())) + { tracing::warn!("[CodeSession] Failed to store PID: {}", err); } } @@ -598,11 +686,21 @@ pub async fn run_session( if cli_session_id_out.is_none() { if let Some(ref tid) = chunk.thread_id { cli_session_id_out = Some(tid.clone()); - if let Err(err) = persistence::update_cli_session_id_for_account( - &session_id, - account_id, - tid, - ) { + let binding_session_id = session_id.clone(); + let binding_account_id = account_id.map(str::to_string); + let binding_thread_id = tid.clone(); + let binding_result = tokio::task::spawn_blocking(move || { + persistence::update_cli_session_id_for_account( + &binding_session_id, + binding_account_id.as_deref(), + &binding_thread_id, + ) + }) + .await; + if let Err(err) = binding_result + .map_err(|join_err| join_err.to_string()) + .and_then(|result| result.map_err(|db_err| db_err.to_string())) + { tracing::warn!( "[CodeSession] Failed to bind early cli_session_id: {}", err @@ -619,9 +717,10 @@ pub async fn run_session( } } if let Some(snap_id) = &pre_message_snapshot_id { - snapshot_cli_file_edit(&session_id, snap_id, &chunk, &snapshot_working_dir); + snapshot_cli_file_edit(&session_id, snap_id, &chunk, &snapshot_working_dir) + .await; } - emit_chunk(&chunk, &session_id, &mut sequence); + emit_chunk(&chunk, &session_id, &mut sequence).await; } }) .await; @@ -632,27 +731,17 @@ pub async fn run_session( cli_session_id_out = Some(result.thread_id); codex_app_server_turn_ok = result.turn_status != "failed"; if let Some(ref usage) = result.usage { - let round_model = usage.model.as_deref().or(model); - if let Err(err) = - session_persistence::token_usage::insert_token_usage_record( - &session_id, - "code", - round_model, - account_id, - usage.input_tokens as i64, - usage.output_tokens as i64, - usage.cache_read_tokens as i64, - usage.cache_write_tokens as i64, - usage.total_tokens as i64, - 0, - None, - ) - { - tracing::warn!( - "[CodeSession] Failed to insert per-round token usage: {}", - err - ); - } + persist_round_token_usage( + session_id.clone(), + usage.model.clone().or_else(|| model.map(str::to_string)), + account_id.map(str::to_string), + usage.input_tokens as i64, + usage.output_tokens as i64, + usage.cache_read_tokens as i64, + usage.cache_write_tokens as i64, + usage.total_tokens as i64, + ) + .await; } } Ok(Err(err)) if !timed_out => { @@ -737,9 +826,10 @@ pub async fn run_session( let timeout_result = tokio::time::timeout(session_timeout, async { while let Some(chunk) = chunk_rx.recv().await { if let Some(snap_id) = &pre_message_snapshot_id { - snapshot_cli_file_edit(&session_id, snap_id, &chunk, &snapshot_working_dir); + snapshot_cli_file_edit(&session_id, snap_id, &chunk, &snapshot_working_dir) + .await; } - emit_chunk(&chunk, &session_id, &mut sequence); + emit_chunk(&chunk, &session_id, &mut sequence).await; } }) .await; @@ -773,13 +863,16 @@ pub async fn run_session( if matches!(agent, ModelType::Kiro) { if let Some(home) = env_vars.get("HOME") { let lock_dir = std::path::Path::new(home).join(".kiro/sessions/cli"); - if let Ok(entries) = std::fs::read_dir(&lock_dir) { - for entry in entries.flatten() { - if entry.path().extension().is_some_and(|e| e == "lock") { - let _ = std::fs::remove_file(entry.path()); + let _ = tokio::task::spawn_blocking(move || { + if let Ok(entries) = std::fs::read_dir(&lock_dir) { + for entry in entries.flatten() { + if entry.path().extension().is_some_and(|e| e == "lock") { + let _ = std::fs::remove_file(entry.path()); + } } } - } + }) + .await; } } } else { @@ -839,11 +932,21 @@ pub async fn run_session( if cli_session_id_out.is_none() { if let Some(cli_sid) = parser.cli_session_id() { cli_session_id_out = Some(cli_sid.clone()); - if let Err(err) = persistence::update_cli_session_id_for_account( - &session_id, - account_id, - &cli_sid, - ) { + let binding_session_id = session_id.clone(); + let binding_account_id = account_id.map(str::to_string); + let binding_cli_session_id = cli_sid.clone(); + let binding_result = tokio::task::spawn_blocking(move || { + persistence::update_cli_session_id_for_account( + &binding_session_id, + binding_account_id.as_deref(), + &binding_cli_session_id, + ) + }) + .await; + if let Err(err) = binding_result + .map_err(|join_err| join_err.to_string()) + .and_then(|result| result.map_err(|db_err| db_err.to_string())) + { tracing::warn!( "[CodeSession] Failed to bind early cli_session_id: {}", err @@ -889,7 +992,8 @@ pub async fn run_session( snap_id, &chunk, &snapshot_working_dir, - ); + ) + .await; } if is_successful_mode_tool(&chunk, "enter_plan_mode") { cli_plan_active = true; @@ -913,7 +1017,8 @@ pub async fn run_session( .await { Ok(plan_chunk) => { - emit_chunk(&plan_chunk, &session_id, &mut sequence); + emit_chunk(&plan_chunk, &session_id, &mut sequence) + .await; cli_plan_registered_this_turn = true; cli_plan_approval_gate_triggered = true; } @@ -942,7 +1047,8 @@ pub async fn run_session( .await { Ok(plan_chunk) => { - emit_chunk(&plan_chunk, &session_id, &mut sequence); + emit_chunk(&plan_chunk, &session_id, &mut sequence) + .await; cli_plan_registered_this_turn = true; cli_plan_approval_gate_triggered = true; } @@ -967,7 +1073,12 @@ pub async fn run_session( .await { Ok(plan_chunk) => { - emit_chunk(&plan_chunk, &session_id, &mut sequence); + emit_chunk( + &plan_chunk, + &session_id, + &mut sequence, + ) + .await; cli_plan_registered_this_turn = true; cli_plan_approval_gate_triggered = true; } @@ -988,7 +1099,7 @@ pub async fn run_session( } cli_plan_active = false; } - emit_chunk(&chunk, &session_id, &mut sequence); + emit_chunk(&chunk, &session_id, &mut sequence).await; if cli_plan_approval_gate_triggered && !cli_plan_gate_announced { cli_plan_gate_announced = true; tracing::info!( @@ -1000,7 +1111,7 @@ pub async fn run_session( // instead of holding Stop for up to the 45s drain window // while the child process winds down. The final // status_changed after child exit is idempotent. - flush_and_broadcast(&session_id); + flush_and_broadcast(&session_id).await; // The plan card supersedes any hook-derived // waiting/working entry for this turn. clear_live_status( @@ -1008,25 +1119,43 @@ pub async fn run_session( &session_id, cli_session_id_out.as_deref(), ); - if let Err(err) = persistence::update_status( - &session_id, - SessionStatus::Completed, - ) { + let plan_session_id = session_id.clone(); + let plan_turn_intent_id = turn_intent_id.map(str::to_string); + let plan_persist_result = tokio::task::spawn_blocking(move || { + persistence::update_cli_turn_lifecycle( + &plan_session_id, + SessionStatus::Completed, + None, + plan_turn_intent_id.as_deref().map(|turn_intent_id| { + ( + turn_intent_id, + session_persistence::turn_intents::TurnIntentStatus::Completed, + ) + }), + ) + }) + .await; + if let Err(err) = plan_persist_result + .map_err(|join_err| join_err.to_string()) + .and_then(|result| result) + { tracing::warn!( "[CodeSession] Failed to persist plan-gate completed status for {}: {}", session_id, err ); } - websocket_handler::broadcast( - serde_json::json!({ + let mut plan_status_message = serde_json::json!({ "type": "code_session.status_changed", "session_id": session_id, "status": SessionStatus::Completed.as_ref(), "plan_gate": true, - }) - .to_string(), - ); + }); + if let Some(turn_intent_id) = turn_intent_id { + plan_status_message["turn_intent_id"] = + serde_json::Value::String(turn_intent_id.to_string()); + } + websocket_handler::broadcast(plan_status_message.to_string()); } } if retryable_oauth_message.is_some() @@ -1141,9 +1270,10 @@ pub async fn run_session( break; } if let Some(snap_id) = &pre_message_snapshot_id { - snapshot_cli_file_edit(&session_id, snap_id, chunk, &snapshot_working_dir); + snapshot_cli_file_edit(&session_id, snap_id, chunk, &snapshot_working_dir) + .await; } - emit_chunk(chunk, &session_id, &mut sequence); + emit_chunk(chunk, &session_id, &mut sequence).await; } } @@ -1155,25 +1285,17 @@ pub async fn run_session( } if let Some(ref usage) = parser.token_usage() { - let round_model = usage.model.as_deref().or(model); - if let Err(err) = session_persistence::token_usage::insert_token_usage_record( - &session_id, - "code", - round_model, - account_id, + persist_round_token_usage( + session_id.clone(), + usage.model.clone().or_else(|| model.map(str::to_string)), + account_id.map(str::to_string), usage.input_tokens as i64, usage.output_tokens as i64, usage.cache_read_tokens as i64, usage.cache_write_tokens as i64, usage.total_tokens as i64, - 0, - None, - ) { - tracing::warn!( - "[CodeSession] Failed to insert per-round token usage: {}", - err - ); - } + ) + .await; } } } @@ -1258,6 +1380,7 @@ pub async fn run_session( use_codex_app_server, is_acp_agent, &synced_rule_files, + turn_intent_id, super::finalize::SessionRunOutcome { exit_code, cli_session_id_out, diff --git a/src-tauri/src/api/agent/test/cli.rs b/src-tauri/src/api/agent/test/cli.rs index 6d756c478..2f7572136 100644 --- a/src-tauri/src/api/agent/test/cli.rs +++ b/src-tauri/src/api/agent/test/cli.rs @@ -326,6 +326,8 @@ pub async fn test_cursor_cli_account_switch( None, None, None, + None, + None, ) .await { @@ -447,6 +449,8 @@ pub async fn test_claude_code_cli_account_switch( None, None, None, + None, + None, ) .await { @@ -605,6 +609,8 @@ pub async fn test_codex_cli_account_switch( None, None, None, + None, + None, ) .await { diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 8fef2f80f..7907b37b5 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -525,6 +525,7 @@ agent_sessions::cli::commands::cli_agent_run, agent_sessions::cli::commands::cli_agent_message, agent_sessions::cli::commands::cli_agent_approval_response, agent_sessions::cli::commands::cli_agent_status, +agent_sessions::cli::commands::cli_agent_status_batch, agent_sessions::cli::commands::cli_agent_history_mutation, agent_sessions::cli::commands::cli_agent_cancel, agent_sessions::cli::commands::cli_agent_tui_release, diff --git a/src/api/realtime/websocket/schemas.ts b/src/api/realtime/websocket/schemas.ts index 16d7693f4..c06e52c4a 100644 --- a/src/api/realtime/websocket/schemas.ts +++ b/src/api/realtime/websocket/schemas.ts @@ -344,6 +344,7 @@ export const CodeEditorWebSocketMessageSchema = z data: z.unknown().optional(), payload: z.unknown().optional(), status: z.unknown().optional(), + turn_intent_id: z.string().optional(), files: z.array(z.unknown()).optional(), timestamp: z.number().optional(), }) diff --git a/src/api/tauri/rpc/procedures/cli.ts b/src/api/tauri/rpc/procedures/cli.ts new file mode 100644 index 000000000..2135df3de --- /dev/null +++ b/src/api/tauri/rpc/procedures/cli.ts @@ -0,0 +1,27 @@ +import { z } from "zod/v4"; + +import { defineProcedure } from "../invoke"; +import * as schemas from "../schemas"; + +export const cli = { + message: defineProcedure("cli_agent_message") + .input(schemas.cli.CliMessageInputSchema) + .output(schemas.cli.CliRunReceiptSchema) + .build(), + status: defineProcedure("cli_agent_status") + .input(schemas.cli.CliSessionIdInputSchema) + .output(schemas.cli.CliStatusSchema.nullable()) + .build(), + statusBatch: defineProcedure("cli_agent_status_batch") + .input(schemas.cli.CliStatusBatchInputSchema) + .output(z.array(schemas.cli.CliStatusBatchItemSchema)) + .build(), + chunks: defineProcedure("cli_agent_chunks") + .input(schemas.cli.CliSessionIdInputSchema) + .output(schemas.cli.CliChunksSchema) + .build(), + cancel: defineProcedure("cli_agent_cancel") + .input(schemas.cli.CliCancelInputSchema) + .output(z.boolean()) + .build(), +} as const; diff --git a/src/api/tauri/rpc/procedures/index.ts b/src/api/tauri/rpc/procedures/index.ts index 72b0d358f..3dd2401ac 100644 --- a/src/api/tauri/rpc/procedures/index.ts +++ b/src/api/tauri/rpc/procedures/index.ts @@ -17,3 +17,4 @@ export { terminal } from "./terminal"; export { tools } from "./tools"; export { validation } from "./validation"; export { workspaceMemory } from "./workspaceMemory"; +export { cli } from "./cli"; diff --git a/src/api/tauri/rpc/router.ts b/src/api/tauri/rpc/router.ts index 79a8bfbe0..7757b4111 100644 --- a/src/api/tauri/rpc/router.ts +++ b/src/api/tauri/rpc/router.ts @@ -46,6 +46,7 @@ export const procedures = { tools: p.tools, mcp: p.mcp, flow: p.flow, + cli: p.cli, } as const; // ============================================================================ diff --git a/src/api/tauri/rpc/schemas/cli.ts b/src/api/tauri/rpc/schemas/cli.ts new file mode 100644 index 000000000..96e91b70b --- /dev/null +++ b/src/api/tauri/rpc/schemas/cli.ts @@ -0,0 +1,53 @@ +import { z } from "zod/v4"; + +import { ActivityChunkSchema } from "@src/api/realtime/websocket/schemas"; + +export const CliMessageInputSchema = z.object({ + sessionId: z.string().min(1), + content: z.string(), + turnIntentId: z.string().min(1), + clientMessageId: z.string().min(1), + model: z.string().optional(), + accountId: z.string().optional(), + ideContext: z.unknown().optional(), + mode: z.string().optional(), + images: z.array(z.string()).optional(), +}); + +export const CliRunReceiptSchema = z.object({ + sessionId: z.string(), + turnIntentId: z.string(), + status: z.string(), +}); + +export const CliSessionIdInputSchema = z.object({ + sessionId: z.string().min(1), +}); + +export const CliCancelInputSchema = CliSessionIdInputSchema.extend({ + reason: z.string().optional(), +}); + +export const CliStatusSchema = z + .object({ + sessionId: z.string(), + status: z.string(), + updatedAt: z.string(), + errorMessage: z.string().nullable().optional(), + totalTokens: z.number().optional(), + transcriptSource: z.string().optional(), + }) + .passthrough(); + +export const CliStatusBatchInputSchema = z.object({ + sessionIds: z.array(z.string().min(1)).max(256), +}); + +export const CliStatusBatchItemSchema = z.object({ + sessionId: z.string(), + status: z.string(), + updatedAt: z.string(), + turnIntentId: z.string().optional(), +}); + +export const CliChunksSchema = z.array(ActivityChunkSchema); diff --git a/src/api/tauri/rpc/schemas/index.ts b/src/api/tauri/rpc/schemas/index.ts index 38dee470a..8c0fcfb6b 100644 --- a/src/api/tauri/rpc/schemas/index.ts +++ b/src/api/tauri/rpc/schemas/index.ts @@ -24,3 +24,4 @@ export * as tools from "./tools"; export * as mcp from "./mcp"; export * as flow from "./flow"; export * as sessionCore from "./sessionCore"; +export * as cli from "./cli"; diff --git a/src/engines/SessionCore/control/turnIntentDispatchLifecycle.ts b/src/engines/SessionCore/control/turnIntentDispatchLifecycle.ts index 22267a7f4..44f914bb2 100644 --- a/src/engines/SessionCore/control/turnIntentDispatchLifecycle.ts +++ b/src/engines/SessionCore/control/turnIntentDispatchLifecycle.ts @@ -60,6 +60,12 @@ export function waitForTurnIntentDispatch( }); } +export function getTurnIntentDispatch( + turnIntentId: string +): TurnIntentDispatch | undefined { + return recentDispatches.get(turnIntentId); +} + export function resetTurnIntentDispatchLifecycleForTests(): void { recentDispatches.clear(); waiters.clear(); diff --git a/src/engines/SessionCore/control/turnLifecycle.ts b/src/engines/SessionCore/control/turnLifecycle.ts index f9657ca38..c9bb30c90 100644 --- a/src/engines/SessionCore/control/turnLifecycle.ts +++ b/src/engines/SessionCore/control/turnLifecycle.ts @@ -184,8 +184,17 @@ export function beginTurnDispatch(sessionId: string): number { * turns, org-coordinator dispatches) and confirms a pending dispatch. * Never downgrades "stopping" — a late running ack must not cancel a Stop. */ -export function markTurnRunning(sessionId: string): void { +export function markTurnRunning( + sessionId: string, + options: { generation?: number } = {} +): void { const state = getState(sessionId); + if ( + options.generation !== undefined && + options.generation !== state.generation + ) { + return; + } if (state.phase === "working" || state.phase === "stopping") return; if (state.phase === "idle") { state.generation += 1; @@ -288,6 +297,15 @@ export function getLastTurnTerminal( return stateBySession.get(sessionId)?.lastTerminal ?? null; } +/** Release all retained lifecycle state when a session is permanently removed. */ +export function clearTurnLifecycleSession(sessionId: string): void { + const state = stateBySession.get(sessionId); + if (!state) return; + clearDeadman(state); + stateBySession.delete(sessionId); + bumpSignal(); +} + export function resetTurnLifecycleForTests(): void { for (const state of stateBySession.values()) { clearDeadman(state); diff --git a/src/engines/SessionCore/hooks/session/useQueueDispatch.ts b/src/engines/SessionCore/hooks/session/useQueueDispatch.ts index 66cab9de8..3be6cedcc 100644 --- a/src/engines/SessionCore/hooks/session/useQueueDispatch.ts +++ b/src/engines/SessionCore/hooks/session/useQueueDispatch.ts @@ -66,7 +66,6 @@ import { queueEditingAtom, queueFlushRequestAtom, } from "@src/store/ui/messageQueueAtom"; -import { invokeTauri } from "@src/util/platform/tauri/init"; import { resolveModelForMessage } from "@src/util/session/resolveModelForMessage"; import { selectionFromSession } from "@src/util/session/selectionFromSession"; import { @@ -117,10 +116,9 @@ async function getBackendDispatchVerdict( ): Promise { try { if (isCliSession(sessionId)) { - const status = (await invokeTauri("cli_agent_status", { sessionId })) as { - status?: string; - } | null; - return classifyBackendSessionStatus(status?.status); + // CLI finality is push-owned by CliTurnLifecycleCoordinator. Re-reading + // status here would reintroduce one polling RPC per queued turn. + return "ready"; } if (isAgentSession(sessionId)) { const meta = await getSession(sessionId); diff --git a/src/engines/SessionCore/services/SessionService.ts b/src/engines/SessionCore/services/SessionService.ts index b6d5122e6..e7b57f381 100644 --- a/src/engines/SessionCore/services/SessionService.ts +++ b/src/engines/SessionCore/services/SessionService.ts @@ -20,6 +20,7 @@ import { respondQuestion, sessionLaunch, } from "@src/api/tauri/agent"; +import { rpc } from "@src/api/tauri/rpc"; import { ROUTES } from "@src/config/routes"; import { getAdapterForSession } from "@src/engines/SessionCore/sync/types"; import { @@ -252,10 +253,7 @@ export const SessionService = { }; } - const session = await invokeTauri<{ - sessionId: string; - status: string; - } | null>("cli_agent_status", { sessionId }); + const session = await rpc.cli.status({ sessionId }); if (!session) { throw new Error(`CLI session not found: ${sessionId}`); diff --git a/src/engines/SessionCore/sync/adapters/cli/__tests__/cliTransport.test.ts b/src/engines/SessionCore/sync/adapters/cli/__tests__/cliTransport.test.ts new file mode 100644 index 000000000..2aece764b --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/cli/__tests__/cliTransport.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { sendCliMessage } from "../cliTransport"; + +const mocks = vi.hoisted(() => ({ + enterIntervention: vi.fn(), + message: vi.fn(), + registerReceipt: vi.fn(), +})); + +vi.mock("@src/api/tauri/agent", () => ({ + enterAgentOrgSessionIntervention: mocks.enterIntervention, +})); +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { cli: { message: mocks.message, cancel: vi.fn() } }, +})); +vi.mock("@src/hooks/cliSession/cliTurnLifecycleCoordinator", () => ({ + cliTurnLifecycleCoordinator: { registerReceipt: mocks.registerReceipt }, +})); + +describe("sendCliMessage acceptance boundary", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.message.mockResolvedValue({ + sessionId: "cliagent-worker", + turnIntentId: "intent-1", + status: "running", + }); + mocks.enterIntervention.mockResolvedValue(undefined); + }); + + it("resolves from the receipt without status or history reconciliation", async () => { + await expect( + sendCliMessage({ + sessionId: "cliagent-worker", + content: "continue", + turnIntentId: "intent-1", + clientMessageId: "message-1", + }) + ).resolves.toBeUndefined(); + + expect(mocks.message).toHaveBeenCalledWith({ + sessionId: "cliagent-worker", + content: "continue", + turnIntentId: "intent-1", + clientMessageId: "message-1", + }); + expect(mocks.registerReceipt).toHaveBeenCalledWith({ + sessionId: "cliagent-worker", + turnIntentId: "intent-1", + status: "running", + }); + }); + + it("rejects when the backend command rejects", async () => { + mocks.message.mockRejectedValue(new Error("ipc unavailable")); + + await expect( + sendCliMessage({ + sessionId: "cliagent-worker", + content: "retry", + isResume: true, + turnIntentId: "intent-2", + clientMessageId: "message-2", + }) + ).rejects.toThrow("ipc unavailable"); + + expect(mocks.registerReceipt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts b/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts index 88b69c5d9..82b8fc51d 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts @@ -1,5 +1,6 @@ -import { convertFileSrc, invoke as tauriInvoke } from "@tauri-apps/api/core"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { rpc } from "@src/api/tauri/rpc"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { createLogger } from "@src/hooks/logger"; @@ -34,9 +35,7 @@ export async function loadCliHistory( sessionId: string, signal: AbortSignal ): Promise { - const chunks = await tauriInvoke("cli_agent_chunks", { - sessionId, - }); + const chunks = (await rpc.cli.chunks({ sessionId })) as ActivityChunk[]; if (signal.aborted || !Array.isArray(chunks)) return []; const events = await processChunksRust(chunks, sessionId); if (signal.aborted) return []; @@ -49,10 +48,9 @@ export async function postLoadCliSession( ): Promise { const result: PostLoadResult = {}; try { - const storedSession = await tauriInvoke( - "cli_agent_status", - { sessionId } - ); + const storedSession = (await rpc.cli.status({ + sessionId, + })) as StoredSession | null; if (signal.aborted || !storedSession) return result; registerSessionTranscriptSource(sessionId, storedSession.transcriptSource); diff --git a/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts index 0119336a7..f2f5bd377 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts @@ -1,10 +1,9 @@ -import { invoke as tauriInvoke } from "@tauri-apps/api/core"; - -import { confirmTurnRunning } from "@src/engines/SessionCore/control/turnLifecycle"; -import { loadSessionAtom } from "@src/engines/SessionCore/core/atoms"; +import { + type TurnTerminalStatus, + markTurnRunning, +} from "@src/engines/SessionCore/control/turnLifecycle"; import { isTurnBlockingRuntimeEvent } from "@src/engines/SessionCore/core/runningEventGate"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { createLogger } from "@src/hooks/logger"; import { setSessionRuntimeStatusAtom } from "@src/store/session/cliSessionStatusAtom"; import type { CliSessionStatus } from "@src/types/session/session"; @@ -13,16 +12,8 @@ import { isStoreInitialized, } from "@src/util/core/state/instrumentedStore"; -import { isNativeTranscriptSession } from "../../nativeTranscriptReconcile"; -import { loadCliHistory } from "./cliHistory"; - const log = createLogger("CliAdapter"); -export type CliStatusResponse = { - status?: CliSessionStatus; - updatedAt?: string; -}; - const CLI_TERMINAL_STATUSES = new Set([ "completed", "failed", @@ -33,56 +24,12 @@ const CLI_TERMINAL_STATUSES = new Set([ "archived", ]); -export const protectedRunningTurnBySession = new Map< - string, - { content: string; startedAt: number } ->(); - export function isCliTerminalStatus( status: CliSessionStatus | undefined ): status is CliSessionStatus { return status !== undefined && CLI_TERMINAL_STATUSES.has(status); } -export async function readCliStatus( - sessionId: string -): Promise { - return (await tauriInvoke("cli_agent_status", { - sessionId, - })) as CliStatusResponse | null; -} - -export async function waitForCliRunBoundary( - sessionId: string, - previousStatus: CliStatusResponse | null -): Promise { - const deadline = Date.now() + 15_000; - const previousUpdatedAt = previousStatus?.updatedAt; - const previousWasTerminal = isCliTerminalStatus(previousStatus?.status); - let lastStatus: CliStatusResponse | null = null; - while (Date.now() < deadline) { - lastStatus = await readCliStatus(sessionId); - const hasNewStatus = - !previousUpdatedAt || lastStatus?.updatedAt !== previousUpdatedAt; - const hasDurableBoundary = - Boolean(previousUpdatedAt) && lastStatus?.updatedAt !== previousUpdatedAt; - if (lastStatus?.status === "running" && hasNewStatus) { - return lastStatus; - } - if ( - isCliTerminalStatus(lastStatus?.status) && - (hasDurableBoundary || !previousWasTerminal) - ) { - return lastStatus; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - throw new Error( - `CLI run boundary was not observed for ${sessionId}; lastStatus=${JSON.stringify(lastStatus)}` - ); -} - async function closeObservedCliTerminalEvents( sessionId: string, status: CliSessionStatus @@ -111,111 +58,40 @@ async function closeObservedCliTerminalEvents( ); } -export function markCliRuntimeRunning(sessionId: string): void { - // FSM running ack is visibility-independent: the dispatch reserved the - // turn, so promote it to "working" even for background sessions. - confirmTurnRunning(sessionId); +export function markCliRuntimeRunning( + sessionId: string, + generation?: number +): void { + markTurnRunning(sessionId, { generation }); if (!isStoreInitialized()) return; - const store = getInstrumentedStore(); - store.set(setSessionRuntimeStatusAtom, { + getInstrumentedStore().set(setSessionRuntimeStatusAtom, { sessionId, status: "running", source: "sync", }); } -export function isProtectedCliTurnTerminal( - sessionId: string, - status: CliSessionStatus | undefined -): boolean { - return ( - isCliTerminalStatus(status) && protectedRunningTurnBySession.has(sessionId) - ); -} - export function markObservedCliTerminalStatus( sessionId: string, status: CliSessionStatus | undefined ): void { if (!isCliTerminalStatus(status) || !isStoreInitialized()) return; - if (isProtectedCliTurnTerminal(sessionId, status)) return; - const store = getInstrumentedStore(); - store.set(setSessionRuntimeStatusAtom, { sessionId, status, source: "sync" }); + getInstrumentedStore().set(setSessionRuntimeStatusAtom, { + sessionId, + status, + source: "sync", + }); void closeObservedCliTerminalEvents(sessionId, status).catch((error) => { log.warn("[cliAdapter] failed to close terminal CLI events:", error); }); } -export async function waitForCliTerminalBoundary( - sessionId: string, - previousUpdatedAt: string | null | undefined, - timeoutMs = 90_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastStatus: CliStatusResponse | null = null; - while (Date.now() < deadline) { - lastStatus = await readCliStatus(sessionId); - const hasNewStatus = - !previousUpdatedAt || lastStatus?.updatedAt !== previousUpdatedAt; - if (hasNewStatus && isCliTerminalStatus(lastStatus?.status)) { - return lastStatus; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - return lastStatus; -} - -async function refreshLoadedCliHistory( - sessionId: string -): Promise { - if (!isStoreInitialized()) return []; - const events = await loadCliHistory(sessionId, new AbortController().signal); - if (events.length === 0) return events; - // Native-transcript sessions render the live turn from in-memory events - // only. The replay is read here purely to observe persistence for the send - // handshake; terminal reconcile remains the single on-screen replacement. - if (!isNativeTranscriptSession(sessionId)) { - await eventStoreProxy.mergeEvents(events, sessionId); - getInstrumentedStore().set(loadSessionAtom, { sessionId, events }); - } - return events; -} - -function eventContainsText(event: SessionEvent, text: string): boolean { - return JSON.stringify(event).includes(text); -} - -export async function waitForPersistedCliUserEvent( - sessionId: string, - content: string -): Promise { - const deadline = Date.now() + 15_000; - let lastEventCount = 0; - while (Date.now() < deadline) { - const events = await refreshLoadedCliHistory(sessionId); - lastEventCount = events.length; - if (events.some((event) => eventContainsText(event, content))) { - return events; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - throw new Error( - `CLI user event was not persisted for ${sessionId}; eventCount=${lastEventCount}` - ); -} - -export function hasRuntimeOutputAfterUserEvent( - events: SessionEvent[], - content: string -): boolean { - let userIndex = -1; - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index]; - if (event.source === "user" && eventContainsText(event, content)) { - userIndex = index; - break; - } +export function cliTerminalStatus( + status: CliSessionStatus +): TurnTerminalStatus { + if (status === "failed" || status === "error" || status === "timeout") { + return "failed"; } - if (userIndex < 0) return false; - return events.slice(userIndex + 1).some((event) => event.source !== "user"); + if (status === "cancelled" || status === "abandoned") return "cancelled"; + return "completed"; } diff --git a/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts b/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts index 52d486fb1..b19d70e49 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts @@ -1,25 +1,13 @@ -import { invoke as tauriInvoke } from "@tauri-apps/api/core"; - import { enterAgentOrgSessionIntervention } from "@src/api/tauri/agent"; import type { CancelReason } from "@src/api/tauri/agent/session"; -import { - getTurnGeneration, - markTurnTerminal, - toTurnTerminalStatus, -} from "@src/engines/SessionCore/control/turnLifecycle"; +import { rpc } from "@src/api/tauri/rpc"; +import { cliTurnLifecycleCoordinator } from "@src/hooks/cliSession/cliTurnLifecycleCoordinator"; import type { AdapterSendInput } from "../../types"; -import { - hasRuntimeOutputAfterUserEvent, - isCliTerminalStatus, - markCliRuntimeRunning, - markObservedCliTerminalStatus, - protectedRunningTurnBySession, - readCliStatus, - waitForCliRunBoundary, - waitForCliTerminalBoundary, - waitForPersistedCliUserEvent, -} from "./cliLifecycle"; + +function newMessageId(): string { + return crypto.randomUUID(); +} export async function sendCliMessage(input: AdapterSendInput): Promise { const { @@ -35,67 +23,27 @@ export async function sendCliMessage(input: AdapterSendInput): Promise { if (!isResume && content.trim()) { await enterAgentOrgSessionIntervention(sessionId); } - const previousStatus = await readCliStatus(sessionId); - protectedRunningTurnBySession.set(sessionId, { - content, - startedAt: Date.now(), - }); - markCliRuntimeRunning(sessionId); - try { - await tauriInvoke("cli_agent_message", { - sessionId, - content, - ...(model ? { model } : {}), - ...(accountId ? { accountId } : {}), - ...(mode ? { mode } : {}), - ...(imageDataUrls && imageDataUrls.length > 0 - ? { images: imageDataUrls } - : {}), - ...(adeContext ? { ideContext: adeContext } : {}), - }); - } catch (error) { - protectedRunningTurnBySession.delete(sessionId); - throw error; - } - const acceptedStatus = await waitForCliRunBoundary(sessionId, previousStatus); - markCliRuntimeRunning(sessionId); - // Capture this dispatch's generation so a late terminal can never close a - // newer turn. - const dispatchGeneration = getTurnGeneration(sessionId); - const persistedEvents = await waitForPersistedCliUserEvent( + const turnIntentId = input.turnIntentId ?? newMessageId(); + const clientMessageId = input.clientMessageId ?? newMessageId(); + const receipt = await rpc.cli.message({ sessionId, - content - ); - const acceptedTerminalIsCurrentTurn = - isCliTerminalStatus(acceptedStatus?.status) && - hasRuntimeOutputAfterUserEvent(persistedEvents, content); - if (acceptedTerminalIsCurrentTurn) { - protectedRunningTurnBySession.delete(sessionId); - markObservedCliTerminalStatus(sessionId, acceptedStatus.status); - markTurnTerminal( - sessionId, - toTurnTerminalStatus(acceptedStatus?.status ?? "completed"), - { generation: dispatchGeneration } - ); - return; - } - - void waitForCliTerminalBoundary( - sessionId, - acceptedStatus?.updatedAt ?? previousStatus?.updatedAt - ).then((terminalStatus) => { - if (!isCliTerminalStatus(terminalStatus?.status)) return; - protectedRunningTurnBySession.delete(sessionId); - markObservedCliTerminalStatus(sessionId, terminalStatus.status); - markTurnTerminal(sessionId, toTurnTerminalStatus(terminalStatus.status), { - generation: dispatchGeneration, - }); + content, + turnIntentId, + clientMessageId, + ...(model ? { model } : {}), + ...(accountId ? { accountId } : {}), + ...(mode ? { mode } : {}), + ...(imageDataUrls && imageDataUrls.length > 0 + ? { images: imageDataUrls } + : {}), + ...(adeContext ? { ideContext: adeContext } : {}), }); + cliTurnLifecycleCoordinator.registerReceipt(receipt); } export async function stopCliSession( sessionId: string, reason: CancelReason ): Promise { - await tauriInvoke("cli_agent_cancel", { sessionId, reason }); + await rpc.cli.cancel({ sessionId, reason }); } diff --git a/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts b/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts index fa1f23000..02397e6af 100644 --- a/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts +++ b/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts @@ -1,8 +1,4 @@ import type { MergeStatus } from "@src/api/tauri/rpc/schemas/validation"; -import { - markTurnTerminal, - toTurnTerminalStatus, -} from "@src/engines/SessionCore/control/turnLifecycle"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { normalizeChunkRust } from "@src/engines/SessionCore/ingestion/rustBridge"; @@ -41,11 +37,7 @@ import { import type { AgentWSEvent, PermissionRequestEvent } from "../shared/types"; import { isCliTerminalStatus, - isProtectedCliTurnTerminal, - markCliRuntimeRunning, markObservedCliTerminalStatus, - protectedRunningTurnBySession, - readCliStatus, } from "./cliLifecycle"; import { buildCliStreamingEvent } from "./streamingEvent"; @@ -68,7 +60,6 @@ export function createCliEventHandler( let thinkStreamId = ""; let thinkStartedAt = ""; let observedTerminalStatus: CliSessionStatus | undefined; - let finalAssistantSettleTimer: ReturnType | undefined; const finalizedStreamEventIds = new Set(); const toolCallDeltaBuffers = new Map< number, @@ -98,57 +89,11 @@ export function createCliEventHandler( toolCallDeltaBuffers.clear(); } - function clearFinalAssistantSettleTimer(): void { - if (!finalAssistantSettleTimer) return; - clearTimeout(finalAssistantSettleTimer); - finalAssistantSettleTimer = undefined; - } - function reconcileTerminalEventsIfNeeded(): void { if (!observedTerminalStatus) return; - clearFinalAssistantSettleTimer(); markObservedCliTerminalStatus(sessionId, observedTerminalStatus); } - function scheduleFinalAssistantSettleFallback(): void { - if (observedTerminalStatus) return; - const protectedTurn = protectedRunningTurnBySession.get(sessionId); - if (!protectedTurn) return; - clearFinalAssistantSettleTimer(); - finalAssistantSettleTimer = setTimeout(() => { - if (observedTerminalStatus) return; - if (protectedRunningTurnBySession.get(sessionId) !== protectedTurn) { - return; - } - void readCliStatus(sessionId) - .then((statusResponse) => { - if (observedTerminalStatus) return; - if (protectedRunningTurnBySession.get(sessionId) !== protectedTurn) { - return; - } - const terminalStatus = isCliTerminalStatus(statusResponse?.status) - ? statusResponse.status - : "completed"; - observedTerminalStatus = terminalStatus; - protectedRunningTurnBySession.delete(sessionId); - callbacks.onStatusChange?.(terminalStatus); - markObservedCliTerminalStatus(sessionId, terminalStatus); - markTurnTerminal(sessionId, toTurnTerminalStatus(terminalStatus)); - clearMessageStream(); - clearThinkingStream(); - clearToolCallDeltaBuffers(); - setStreamingMode(false); - callbacks.onAgentComplete?.(); - }) - .catch((error) => { - log.warn( - "[CliAdapter] final assistant settle fallback failed:", - error - ); - }); - }, 1_500); - } - function asString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -285,10 +230,6 @@ export function createCliEventHandler( } function handleActivity(chunk: ActivityChunk): void { - if (!observedTerminalStatus) { - callbacks.onStatusChange?.("running"); - } - if ( chunk.function === "user_message" && (chunk.action_type === "raw" || chunk.action_type === "raw_event") @@ -368,11 +309,8 @@ export function createCliEventHandler( // Final message/thinking chunks replace any TS typewriter placeholder. if (isMessageType || isThinkingType) { const tempId = isMessageType ? msgStreamId : thinkStreamId; - const isFinalAssistantMessage = - isMessageType && chunk.result?.is_full_content === true; const reconcileAfterFinalEvent = () => { reconcileTerminalEventsIfNeeded(); - if (isFinalAssistantMessage) scheduleFinalAssistantSettleFallback(); }; normalizeChunkRust(chunk, sessionId) .then((event) => { @@ -435,7 +373,6 @@ export function createCliEventHandler( clearMessageStream(); const reconcileAfterCompleteMessage = () => { reconcileTerminalEventsIfNeeded(); - scheduleFinalAssistantSettleFallback(); }; if (tsTempId && tsTempId !== completeEvent.id) { eventStoreProxy @@ -465,20 +402,12 @@ export function createCliEventHandler( } } - function handleStatusChange(status: string, errorMessage?: string): void { + function handleStatusChange(status: string): void { const terminalStatus = isCliTerminalStatus(status as CliSessionStatus) ? (status as CliSessionStatus) : undefined; - if (isProtectedCliTurnTerminal(sessionId, terminalStatus)) { - markCliRuntimeRunning(sessionId); - return; - } - - callbacks.onStatusChange?.(status, errorMessage); - if (terminalStatus) { observedTerminalStatus = terminalStatus; - clearFinalAssistantSettleTimer(); clearMessageStream(); clearThinkingStream(); clearToolCallDeltaBuffers(); @@ -490,7 +419,6 @@ export function createCliEventHandler( if (status === "running") { observedTerminalStatus = undefined; - protectedRunningTurnBySession.delete(sessionId); cancelled = false; } } @@ -537,10 +465,7 @@ export function createCliEventHandler( } else if (raw.type === "agent:streaming_complete") { handleStreamingComplete(raw); } else if (raw.type === "code_session.status_changed") { - handleStatusChange( - raw.status as string, - raw.error_message as string | undefined - ); + handleStatusChange(raw.status as string); } else if (raw.type === "code_session.token_usage_updated") { const total = raw.total_tokens; if (typeof total === "number") callbacks.onTokenUpdate?.(total); @@ -570,14 +495,12 @@ export function createCliEventHandler( }, reset(): void { - clearFinalAssistantSettleTimer(); clearMessageStream(); clearThinkingStream(); clearToolCallDeltaBuffers(); observedTerminalStatus = undefined; cancelled = false; - streaming = false; - eventStoreProxy.setStreaming(false, sessionId); + setStreamingMode(false); }, get isStreaming(): boolean { diff --git a/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts b/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts new file mode 100644 index 000000000..58dfd488e --- /dev/null +++ b/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + publishTurnIntentDispatch, + resetTurnIntentDispatchLifecycleForTests, +} from "@src/engines/SessionCore/control/turnIntentDispatchLifecycle"; +import { + beginTurnDispatch, + getTurnPhase, + resetTurnLifecycleForTests, +} from "@src/engines/SessionCore/control/turnLifecycle"; + +import { CliTurnLifecycleCoordinator } from "./cliTurnLifecycleCoordinator"; + +describe("CliTurnLifecycleCoordinator", () => { + beforeEach(() => { + resetTurnLifecycleForTests(); + resetTurnIntentDispatchLifecycleForTests(); + }); + + it("binds current terminals to the dispatched generation and drops stale intents", () => { + const coordinator = new CliTurnLifecycleCoordinator(vi.fn()); + const sessionId = "cliagent-current"; + const generation = beginTurnDispatch(sessionId); + publishTurnIntentDispatch("intent-current", { sessionId, generation }); + + expect( + coordinator.handleStatus({ + sessionId, + status: "running", + turnIntentId: "intent-current", + }) + ).toBe(true); + expect(getTurnPhase(sessionId)).toBe("working"); + + expect( + coordinator.handleStatus({ + sessionId, + status: "completed", + turnIntentId: "intent-old", + }) + ).toBe(false); + expect(getTurnPhase(sessionId)).toBe("working"); + + expect( + coordinator.handleStatus({ + sessionId, + status: "completed", + turnIntentId: "intent-current", + }) + ).toBe(true); + expect(getTurnPhase(sessionId)).toBe("idle"); + expect(coordinator.activeSessionCount).toBe(0); + }); + + it("creates and closes a local generation for an unknown cross-window intent", () => { + const coordinator = new CliTurnLifecycleCoordinator(vi.fn()); + const sessionId = "cliagent-cross-window"; + + coordinator.handleStatus({ + sessionId, + status: "running", + turnIntentId: "remote-intent", + }); + expect(getTurnPhase(sessionId)).toBe("working"); + + coordinator.handleStatus({ + sessionId, + status: "failed", + turnIntentId: "remote-intent", + }); + expect(getTurnPhase(sessionId)).toBe("idle"); + + expect( + coordinator.handleStatus({ + sessionId, + status: "running", + turnIntentId: "remote-intent", + }) + ).toBe(false); + expect(getTurnPhase(sessionId)).toBe("idle"); + }); + + it("does not let an unattributed terminal close a tracked intent", () => { + const coordinator = new CliTurnLifecycleCoordinator(vi.fn()); + const sessionId = "cliagent-attributed"; + coordinator.handleStatus({ + sessionId, + status: "running", + turnIntentId: "intent-attributed", + }); + + expect(coordinator.handleStatus({ sessionId, status: "completed" })).toBe( + false + ); + expect(getTurnPhase(sessionId)).toBe("working"); + + coordinator.clearSession(sessionId); + expect(getTurnPhase(sessionId)).toBe("idle"); + expect(coordinator.activeSessionCount).toBe(0); + }); + + it("rejects excess unknown intents without evicting active generations", () => { + const coordinator = new CliTurnLifecycleCoordinator(vi.fn()); + for (let index = 0; index < 256; index += 1) { + expect( + coordinator.handleStatus({ + sessionId: `cliagent-cap-${index}`, + status: "running", + turnIntentId: `intent-cap-${index}`, + }) + ).toBe(true); + } + + expect( + coordinator.handleStatus({ + sessionId: "cliagent-cap-overflow", + status: "running", + turnIntentId: "intent-cap-overflow", + }) + ).toBe(false); + expect(coordinator.activeSessionCount).toBe(256); + + expect( + coordinator.handleStatus({ + sessionId: "cliagent-cap-0", + status: "completed", + turnIntentId: "intent-cap-0", + }) + ).toBe(true); + expect(coordinator.activeSessionCount).toBe(255); + }); + + it("shares one batch request across concurrent reconnect and focus triggers", async () => { + let resolveBatch!: (value: never[]) => void; + const loadBatch = vi.fn( + () => + new Promise((resolve) => { + resolveBatch = resolve; + }) + ); + const coordinator = new CliTurnLifecycleCoordinator(loadBatch); + coordinator.handleStatus({ + sessionId: "cliagent-reconcile", + status: "running", + turnIntentId: "intent-reconcile", + }); + + const first = coordinator.reconcile(); + const second = coordinator.reconcile(); + + expect(first).toBe(second); + expect(loadBatch).toHaveBeenCalledOnce(); + expect(loadBatch).toHaveBeenCalledWith({ + sessionIds: ["cliagent-reconcile"], + }); + resolveBatch([]); + await first; + }); + + it("does not reconcile while the document is hidden", async () => { + const loadBatch = vi.fn(async () => []); + const coordinator = new CliTurnLifecycleCoordinator(loadBatch); + coordinator.handleStatus({ + sessionId: "cliagent-hidden", + status: "running", + turnIntentId: "intent-hidden", + }); + const originalDocument = globalThis.document; + vi.stubGlobal("document", { visibilityState: "hidden" }); + + await coordinator.reconcile(); + + expect(loadBatch).not.toHaveBeenCalled(); + vi.stubGlobal("document", originalDocument); + }); +}); diff --git a/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts b/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts new file mode 100644 index 000000000..6616b7238 --- /dev/null +++ b/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts @@ -0,0 +1,213 @@ +import { rpc } from "@src/api/tauri/rpc"; +import { getTurnIntentDispatch } from "@src/engines/SessionCore/control/turnIntentDispatchLifecycle"; +import { + beginTurnDispatch, + clearTurnLifecycleSession, + getTurnGeneration, + getTurnPhase, + markTurnTerminal, +} from "@src/engines/SessionCore/control/turnLifecycle"; +import { + cliTerminalStatus, + isCliTerminalStatus, + markCliRuntimeRunning, + markObservedCliTerminalStatus, +} from "@src/engines/SessionCore/sync/adapters/cli/cliLifecycle"; +import { + type SessionStatus, + sessionsAtom, + updateSessionStatus, +} from "@src/store/session"; +import type { CliSessionStatus } from "@src/types/session/session"; +import { + getInstrumentedStore, + isStoreInitialized, +} from "@src/util/core/state/instrumentedStore"; +import { isCliSession } from "@src/util/session/sessionDispatch"; + +export interface CliRunReceipt { + sessionId: string; + turnIntentId: string; + status: string; +} + +export interface CliLifecycleStatus { + sessionId: string; + status: string; + updatedAt?: string; + turnIntentId?: string; +} + +interface ActiveCliTurn { + turnIntentId: string; + generation: number; +} + +const MAX_ACTIVE_SESSIONS = 256; +const MAX_RECENT_TERMINALS = 256; +const RECONCILE_STATUSES = new Set([ + "pending", + "running", + "waiting_for_user", + "waiting_for_funds", +]); + +type BatchLoader = (input: { + sessionIds: string[]; +}) => Promise; + +export class CliTurnLifecycleCoordinator { + private readonly activeBySession = new Map(); + private readonly recentTerminalIntents = new Set(); + private reconcilePromise: Promise | null = null; + + constructor(private readonly loadStatusBatch: BatchLoader) {} + + get activeSessionCount(): number { + return this.activeBySession.size; + } + + registerReceipt(receipt: CliRunReceipt): void { + this.handleStatus({ + sessionId: receipt.sessionId, + turnIntentId: receipt.turnIntentId, + status: receipt.status, + }); + } + + handleStatus(event: CliLifecycleStatus): boolean { + if (!isCliSession(event.sessionId)) return false; + const status = event.status as CliSessionStatus; + const turnIntentId = event.turnIntentId; + const existing = this.activeBySession.get(event.sessionId); + + if (status === "running") { + if (!turnIntentId || this.recentTerminalIntents.has(turnIntentId)) + return false; + if (existing?.turnIntentId === turnIntentId) { + markCliRuntimeRunning(event.sessionId, existing.generation); + return false; + } + + const dispatch = getTurnIntentDispatch(turnIntentId); + if (dispatch && dispatch.sessionId !== event.sessionId) return false; + if (dispatch && existing && dispatch.generation < existing.generation) { + return false; + } + // Unknown cross-window intents need a retained generation so their + // terminal cannot close a newer turn. Never evict another active + // session merely to admit one beyond the bounded coordinator capacity. + if ( + !dispatch && + !existing && + this.activeBySession.size >= MAX_ACTIVE_SESSIONS + ) { + return false; + } + + const generation = + dispatch?.generation ?? beginTurnDispatch(event.sessionId); + markCliRuntimeRunning(event.sessionId, generation); + if (getTurnGeneration(event.sessionId) !== generation) return false; + this.setActive(event.sessionId, { turnIntentId, generation }); + return true; + } + + if (!isCliTerminalStatus(status)) return false; + if (!turnIntentId && existing) return false; + if (turnIntentId && this.recentTerminalIntents.has(turnIntentId)) + return false; + if (turnIntentId && existing && existing.turnIntentId !== turnIntentId) { + return false; + } + + const dispatch = turnIntentId + ? getTurnIntentDispatch(turnIntentId) + : undefined; + if (dispatch && dispatch.sessionId !== event.sessionId) return false; + const generation = existing?.generation ?? dispatch?.generation; + markTurnTerminal(event.sessionId, cliTerminalStatus(status), { + generation, + }); + markObservedCliTerminalStatus(event.sessionId, status); + if (isStoreInitialized()) { + updateSessionStatus(event.sessionId, status as SessionStatus); + } + this.activeBySession.delete(event.sessionId); + if (turnIntentId) this.rememberTerminal(turnIntentId); + return true; + } + + reconcile(): Promise { + if ( + typeof document !== "undefined" && + document.visibilityState === "hidden" + ) { + return Promise.resolve(); + } + if (this.reconcilePromise) return this.reconcilePromise; + + const sessionIds = this.collectReconcileSessionIds(); + if (sessionIds.length === 0) return Promise.resolve(); + this.reconcilePromise = this.loadStatusBatch({ sessionIds }) + .then((statuses) => { + for (const status of statuses) this.handleStatus(status); + }) + .finally(() => { + this.reconcilePromise = null; + }); + return this.reconcilePromise; + } + + clearSession(sessionId: string): void { + this.activeBySession.delete(sessionId); + clearTurnLifecycleSession(sessionId); + } + + resetForTests(): void { + this.activeBySession.clear(); + this.recentTerminalIntents.clear(); + this.reconcilePromise = null; + } + + private collectReconcileSessionIds(): string[] { + const ids = new Set(this.activeBySession.keys()); + if (isStoreInitialized()) { + for (const session of getInstrumentedStore().get(sessionsAtom)) { + if ( + isCliSession(session.session_id) && + (RECONCILE_STATUSES.has(session.status) || + getTurnPhase(session.session_id) !== "idle") + ) { + ids.add(session.session_id); + } + } + } + return [...ids].slice(0, MAX_ACTIVE_SESSIONS); + } + + private setActive(sessionId: string, active: ActiveCliTurn): void { + this.activeBySession.delete(sessionId); + this.activeBySession.set(sessionId, active); + } + + private rememberTerminal(turnIntentId: string): void { + this.recentTerminalIntents.delete(turnIntentId); + this.recentTerminalIntents.add(turnIntentId); + while (this.recentTerminalIntents.size > MAX_RECENT_TERMINALS) { + const oldest = this.recentTerminalIntents.values().next().value as + | string + | undefined; + if (!oldest) break; + this.recentTerminalIntents.delete(oldest); + } + } +} + +export const cliTurnLifecycleCoordinator = new CliTurnLifecycleCoordinator( + rpc.cli.statusBatch +); + +export function clearCliTurnLifecycleSession(sessionId: string): void { + cliTurnLifecycleCoordinator.clearSession(sessionId); +} diff --git a/src/hooks/cliSession/useBackgroundSessionMonitor.ts b/src/hooks/cliSession/useBackgroundSessionMonitor.ts index 6293476af..abb1b8a46 100644 --- a/src/hooks/cliSession/useBackgroundSessionMonitor.ts +++ b/src/hooks/cliSession/useBackgroundSessionMonitor.ts @@ -1,15 +1,15 @@ /** * useBackgroundSessionMonitor Hook * - * Listens for WebSocket status changes on background ("fire and forget") - * CLI sessions and delivers system notifications + in-app toasts when - * they complete or fail. + * Owns the single window-level CLI lifecycle status subscription. It routes + * every CLI status through the global coordinator and additionally delivers + * notifications for background ("fire and forget") sessions. * * This hook runs at the app root level (via GlobalSessionSync) so it is * always active, regardless of which view the user is on. * - * It complements the cliAdapter sync, which only tracks the *active* session. - * This hook watches ALL background sessions globally. + * Active adapters remain responsible for transcript/UI mirroring only; turn + * finality for active and background sessions is owned here. */ import { useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; @@ -20,14 +20,11 @@ import { notifyTaskCompletion, } from "@src/api/services/notification"; import Message from "@src/components/Message"; -import { - markTurnTerminal, - toTurnTerminalStatus, -} from "@src/engines/SessionCore/control/turnLifecycle"; -import { type SessionStatus, updateSessionStatus } from "@src/store/session"; import { notificationSettingsAtom } from "@src/store/ui/notificationAtom"; import { isTerminalStatus } from "@src/types/session/session"; +import { cliTurnLifecycleCoordinator } from "./cliTurnLifecycleCoordinator"; + interface BackgroundStatusMessage { type: "code_session.status_changed"; session_id: string; @@ -36,6 +33,7 @@ interface BackgroundStatusMessage { session_name?: string; error_message?: string; exit_code?: number; + turn_intent_id?: string; } export function useBackgroundSessionMonitor(): void { @@ -52,15 +50,18 @@ export function useBackgroundSessionMonitor(): void { const unsubscribe = wsClient.on("code_session.status_changed", (raw) => { const msg = raw as unknown as BackgroundStatusMessage; + const applied = cliTurnLifecycleCoordinator.handleStatus({ + sessionId: msg.session_id, + status: msg.status, + turnIntentId: msg.turn_intent_id, + }); if (!msg.background) return; if (!isTerminalStatus(msg.status)) return; + if (!applied) return; const sessionName = msg.session_name || "Background session"; - markTurnTerminal(msg.session_id, toTurnTerminalStatus(msg.status)); - updateSessionStatus(msg.session_id, msg.status as SessionStatus); - if (msg.status === "completed") { notifyTaskCompletion( `"${sessionName}" completed — ready for review`, @@ -95,6 +96,21 @@ export function useBackgroundSessionMonitor(): void { } }); - return unsubscribe; + const reconcile = () => { + void cliTurnLifecycleCoordinator.reconcile(); + }; + const unsubscribeConnected = wsClient.on("connected", reconcile); + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") reconcile(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", reconcile); + + return () => { + unsubscribe(); + unsubscribeConnected(); + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", reconcile); + }; }, []); } diff --git a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts index 850b488ec..d154b1abc 100644 --- a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts +++ b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts @@ -29,6 +29,7 @@ import { isSessionTaggedToCloudOrg, sessionOrgTagsAtom, } from "@src/features/TeamCollaboration/sessionOrgTagsAtom"; +import { clearCliTurnLifecycleSession } from "@src/hooks/cliSession/cliTurnLifecycleCoordinator"; import { createLogger } from "@src/hooks/logger"; import type { GoToNewSessionOptions } from "@src/hooks/navigation/useAppNavigation"; import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; @@ -187,6 +188,7 @@ export function useWorkstationSidebarHandlers({ } if (isCliSession(sessionId)) { await invokeTauri("cli_agent_delete", { sessionId }); + clearCliTurnLifecycleSession(sessionId); } else { await deleteSession(sessionId); } From fe557eca2923e0a16929a1efaf4d843c7645c264 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 23 Jul 2026 22:30:47 +0800 Subject: [PATCH 4/8] refactor(async): add scoped resource lifecycle Pre-commit hook ran. Total eslint: 10, total circular: 0 --- src/hooks/async/index.ts | 20 +- src/hooks/async/useAsyncData.ts | 237 -------------- src/hooks/async/useAsyncResource.test.ts | 296 ++++++++++++++++++ src/hooks/async/useAsyncResource.ts | 234 ++++++++++++++ .../async/useVisibilityPolledData.test.ts | 203 ++++++++++++ src/hooks/async/useVisibilityPolledData.ts | 68 ++++ .../core/__tests__/latestScopedTask.test.ts | 50 +++ .../__tests__/visibilityAwarePoll.test.ts | 112 +++++++ src/util/core/latestScopedTask.ts | 49 +++ src/util/core/visibilityAwarePoll.ts | 120 +++++++ 10 files changed, 1144 insertions(+), 245 deletions(-) delete mode 100644 src/hooks/async/useAsyncData.ts create mode 100644 src/hooks/async/useAsyncResource.test.ts create mode 100644 src/hooks/async/useAsyncResource.ts create mode 100644 src/hooks/async/useVisibilityPolledData.test.ts create mode 100644 src/hooks/async/useVisibilityPolledData.ts create mode 100644 src/util/core/__tests__/latestScopedTask.test.ts create mode 100644 src/util/core/__tests__/visibilityAwarePoll.test.ts create mode 100644 src/util/core/latestScopedTask.ts create mode 100644 src/util/core/visibilityAwarePoll.ts diff --git a/src/hooks/async/index.ts b/src/hooks/async/index.ts index a2a538279..b77495af7 100644 --- a/src/hooks/async/index.ts +++ b/src/hooks/async/index.ts @@ -1,9 +1,13 @@ -// Async data hooks export { - useAsyncData, - useAsyncAction, - type UseAsyncDataOptions, - type UseAsyncDataReturn, - type UseAsyncActionOptions, - type UseAsyncActionReturn, -} from "./useAsyncData"; + useAsyncResource, + type AsyncResourceFetchContext, + type AsyncResourceReloadOptions, + type AsyncResourceStatus, + type UseAsyncResourceOptions, + type UseAsyncResourceResult, +} from "./useAsyncResource"; +export { + useVisibilityPolledData, + type UseVisibilityPolledDataOptions, + type UseVisibilityPolledDataResult, +} from "./useVisibilityPolledData"; diff --git a/src/hooks/async/useAsyncData.ts b/src/hooks/async/useAsyncData.ts deleted file mode 100644 index 1d4ae0221..000000000 --- a/src/hooks/async/useAsyncData.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * useAsyncData Hook - * - * Generic hook for async data fetching with loading/error state management. - * Consolidates the common pattern found across 60+ hooks in the codebase. - * - * Features: - * - Unified loading/error/data state management - * - Auto-load on mount with dependency tracking - * - Success/error callbacks - * - Manual refresh capability - * - Type-safe with generics - * - * @example - * const { data, loading, error, refresh } = useAsyncData({ - * fetcher: () => api.fetchItems(), - * initialData: [], - * errorPrefix: "Failed to load items", - * }); - */ -import { - type Dispatch, - type SetStateAction, - useCallback, - useEffect, - useState, -} from "react"; - -import { useMounted } from "@src/hooks/lifecycle/useMounted"; - -// ============================================ -// Type Definitions -// ============================================ - -export interface UseAsyncDataOptions { - /** Async function to fetch data */ - fetcher: () => Promise; - /** Auto-load on mount (default: true) */ - autoLoad?: boolean; - /** Dependencies that trigger refetch when changed */ - deps?: unknown[]; - /** Success callback */ - onSuccess?: (data: T) => void; - /** Error callback */ - onError?: (error: Error) => void; - /** Initial data value */ - initialData?: T; - /** Error message prefix for generic errors */ - errorPrefix?: string; - /** Skip fetch if condition is false */ - enabled?: boolean; -} - -export interface UseAsyncDataReturn { - /** The fetched data */ - data: T; - /** Loading state */ - loading: boolean; - /** Error message (null if no error) */ - error: string | null; - /** Manually trigger a refresh */ - refresh: () => Promise; - /** Directly update the data state */ - setData: Dispatch>; - /** Clear the error state */ - clearError: () => void; -} - -// ============================================ -// Hook Implementation -// ============================================ - -export function useAsyncData( - options: UseAsyncDataOptions -): UseAsyncDataReturn { - const { - fetcher, - autoLoad = true, - deps = [], - onSuccess, - onError, - initialData, - errorPrefix = "Failed to load data", - enabled = true, - } = options; - - // State - const [data, setData] = useState(initialData as T); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const mountedRef = useMounted(); - - // Refresh function - const refresh = useCallback(async () => { - if (!enabled) { - return; - } - - setLoading(true); - setError(null); - - try { - const result = await fetcher(); - - if (mountedRef.current) { - setData(result); - onSuccess?.(result); - } - } catch (err) { - if (mountedRef.current) { - const message = - err instanceof Error ? err.message : `${errorPrefix}: ${String(err)}`; - setError(message); - onError?.(err instanceof Error ? err : new Error(message)); - } - } finally { - if (mountedRef.current) { - setLoading(false); - } - } - }, [fetcher, enabled, errorPrefix, onSuccess, onError, mountedRef]); - - // Clear error helper - const clearError = useCallback(() => { - setError(null); - }, []); - - // Auto-load on mount and when deps change - useEffect(() => { - if (autoLoad && enabled) { - refresh(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoLoad, enabled, ...deps]); - - return { - data, - loading, - error, - refresh, - setData, - clearError, - }; -} - -// ============================================ -// Utility: useAsyncAction (for mutations) -// ============================================ - -export interface UseAsyncActionOptions { - /** Success callback */ - onSuccess?: () => void; - /** Error callback */ - onError?: (error: Error) => void; - /** Error message prefix */ - errorPrefix?: string; -} - -export interface UseAsyncActionReturn { - /** Execute the action */ - execute: (...args: TArgs) => Promise; - /** Loading state */ - loading: boolean; - /** Error message */ - error: string | null; - /** Clear error */ - clearError: () => void; -} - -/** - * Hook for async actions/mutations (create, update, delete operations) - * - * @example - * const { execute: createItem, loading } = useAsyncAction( - * async (name: string) => { - * return await api.createItem({ name }); - * }, - * { onSuccess: refresh } - * ); - */ -export function useAsyncAction( - action: (...args: TArgs) => Promise, - options: UseAsyncActionOptions = {} -): UseAsyncActionReturn { - const { onSuccess, onError, errorPrefix = "Action failed" } = options; - - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const mountedRef = useMounted(); - - const execute = useCallback( - async (...args: TArgs): Promise => { - setLoading(true); - setError(null); - - try { - const result = await action(...args); - - if (mountedRef.current) { - onSuccess?.(); - } - - return result; - } catch (err) { - if (mountedRef.current) { - const message = - err instanceof Error - ? err.message - : `${errorPrefix}: ${String(err)}`; - setError(message); - onError?.(err instanceof Error ? err : new Error(message)); - } - return null; - } finally { - if (mountedRef.current) { - setLoading(false); - } - } - }, - [action, errorPrefix, onSuccess, onError, mountedRef] - ); - - const clearError = useCallback(() => { - setError(null); - }, []); - - return { - execute, - loading, - error, - clearError, - }; -} - -export default useAsyncData; diff --git a/src/hooks/async/useAsyncResource.test.ts b/src/hooks/async/useAsyncResource.test.ts new file mode 100644 index 000000000..fc3a98fff --- /dev/null +++ b/src/hooks/async/useAsyncResource.test.ts @@ -0,0 +1,296 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + type UseAsyncResourceResult, + useAsyncResource, +} from "./useAsyncResource"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("useAsyncResource", () => { + let container: HTMLDivElement; + let root: Root; + let current: UseAsyncResourceResult; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + function Harness({ + autoLoad = true, + enabled = true, + fetcher, + initialData = "", + initialStatus = "idle", + scopeKey, + }: { + autoLoad?: boolean; + enabled?: boolean; + fetcher: Parameters>[0]["fetcher"]; + initialData?: string; + initialStatus?: "idle" | "ready"; + scopeKey: string | null; + }) { + const result = useAsyncResource({ + autoLoad, + enabled, + fetcher, + initialData, + initialStatus, + scopeKey, + }); + useEffect(() => { + current = result; + }, [result]); + return createElement("div", { + "data-error": result.error ?? "", + "data-loading": String(result.loading), + "data-refreshing": String(result.refreshing), + "data-status": result.status, + "data-value": result.data, + }); + } + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("loads and exposes one cohesive resource state", async () => { + const request = deferred(); + const fetcher = vi.fn().mockReturnValue(request.promise); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "loading" + ); + request.resolve("loaded"); + await flush(); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "loaded" + ); + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "ready" + ); + }); + + it("drops a late response and hides old data after switching scope", async () => { + const first = deferred(); + const second = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockImplementation((scope) => + scope === "a" ? first.promise : second.promise + ); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "b" }))); + expect(container.firstElementChild?.getAttribute("data-value")).toBe(""); + + second.resolve("new"); + await flush(); + first.resolve("old"); + await flush(); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe("new"); + }); + + it("starts a new generation for manual refresh and preserves visible data", async () => { + const stale = deferred(); + const fresh = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(fresh.promise); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + stale.resolve("initial"); + await flush(); + + let refresh!: Promise; + act(() => { + refresh = current.refresh(); + }); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "initial" + ); + expect(container.firstElementChild?.getAttribute("data-refreshing")).toBe( + "true" + ); + + fresh.resolve("fresh"); + await act(async () => refresh); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "fresh" + ); + }); + + it("prevents an active initial load from overwriting a manual refresh", async () => { + const stale = deferred(); + const fresh = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(fresh.promise); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + let refresh!: Promise; + act(() => { + refresh = current.refresh(); + }); + + fresh.resolve("fresh"); + await act(async () => refresh); + stale.resolve("stale"); + await flush(); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "fresh" + ); + }); + + it("publishes cache data before the current live request settles", async () => { + const live = deferred(); + const fetcher = vi.fn( + async (_scopeKey: string, context: { publish(data: string): void }) => { + context.publish("cached"); + return live.promise; + } + ); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "cached" + ); + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "ready" + ); + + live.resolve("live"); + await flush(); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "live" + ); + }); + + it("can expose seeded cache data without starting a request", () => { + const fetcher = vi.fn().mockResolvedValue("unused"); + act(() => + root.render( + createElement(Harness, { + autoLoad: false, + fetcher, + initialData: "cached", + initialStatus: "ready", + scopeKey: "a", + }) + ) + ); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "cached" + ); + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "ready" + ); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("joins equal non-superseding loads", async () => { + const request = deferred(); + const fetcher = vi.fn().mockReturnValue(request.promise); + act(() => + root.render( + createElement(Harness, { + autoLoad: false, + fetcher, + scopeKey: "a", + }) + ) + ); + + let first!: Promise; + let second!: Promise; + act(() => { + first = current.reload(); + second = current.reload(); + }); + expect(fetcher).toHaveBeenCalledTimes(1); + + request.resolve("joined"); + await act(async () => Promise.all([first, second])); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "joined" + ); + }); + + it("recovers from error and resets when disabled", async () => { + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce("recovered"); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + await flush(); + expect(container.firstElementChild?.getAttribute("data-error")).toBe( + "offline" + ); + + await act(async () => current.refresh()); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "recovered" + ); + + act(() => + root.render( + createElement(Harness, { + enabled: false, + fetcher, + scopeKey: "a", + }) + ) + ); + expect(container.firstElementChild?.getAttribute("data-value")).toBe(""); + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "idle" + ); + }); +}); diff --git a/src/hooks/async/useAsyncResource.ts b/src/hooks/async/useAsyncResource.ts new file mode 100644 index 000000000..e08e5cf93 --- /dev/null +++ b/src/hooks/async/useAsyncResource.ts @@ -0,0 +1,234 @@ +import { + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; + +export type AsyncResourceStatus = + | "idle" + | "loading" + | "ready" + | "refreshing" + | "error"; + +interface AsyncResourceState { + data: T; + error: string | null; + scopeKey: string | null; + status: AsyncResourceStatus; +} + +export interface UseAsyncResourceOptions { + autoLoad?: boolean; + enabled?: boolean; + fetcher: ( + scopeKey: string, + context: AsyncResourceFetchContext + ) => Promise; + initialData: T; + initialStatus?: "idle" | "ready"; + scopeKey: string | null; +} + +export interface AsyncResourceFetchContext { + cause: "background" | "load" | "refresh"; + isCurrent(): boolean; + /** Commit an intermediate cache/stale-while-revalidate value if still current. */ + publish(data: T, options?: { keepLoading?: boolean }): void; +} + +export interface AsyncResourceReloadOptions { + /** Keep the current loading indicator unchanged, for background revalidation. */ + background?: boolean; + /** Start a new generation instead of joining an equal in-flight scope. */ + supersede?: boolean; +} + +export interface UseAsyncResourceResult { + data: T; + error: string | null; + loading: boolean; + refreshing: boolean; + reload: (options?: AsyncResourceReloadOptions) => Promise; + refresh: () => Promise; + setData: Dispatch>; + status: AsyncResourceStatus; +} + +/** + * Own one scope-fenced async resource. + * + * Equal automatic loads share an in-flight promise. Manual refreshes start a + * new generation, and every completion verifies that its scope/generation is + * still current before committing state. + */ +export function useAsyncResource({ + autoLoad = true, + enabled = true, + fetcher, + initialData, + initialStatus = "idle", + scopeKey, +}: UseAsyncResourceOptions): UseAsyncResourceResult { + const coordinator = useMemo(() => new LatestScopedTask(), []); + const initialDataRef = useRef(initialData); + initialDataRef.current = initialData; + const initialStatusRef = useRef(initialStatus); + initialStatusRef.current = initialStatus; + const [state, setState] = useState>({ + data: initialData, + error: null, + scopeKey: null, + status: initialStatus, + }); + + const reload = useCallback( + async ({ + background = false, + supersede = false, + }: AsyncResourceReloadOptions = {}) => { + if (!enabled || !scopeKey) return; + if (supersede) coordinator.supersede(); + + setState((current) => { + const isCurrentScope = current.scopeKey === scopeKey; + const data = isCurrentScope ? current.data : initialDataRef.current; + if (background && isCurrentScope && current.status === "ready") { + return { ...current, error: null }; + } + return { + data, + error: null, + scopeKey, + status: + isCurrentScope && current.status !== "idle" + ? "refreshing" + : "loading", + }; + }); + + await coordinator.run(scopeKey, async (context) => { + try { + const publish = (data: T, options?: { keepLoading?: boolean }) => { + if (context.isCurrent()) { + setState((current) => ({ + data, + error: null, + scopeKey, + status: options?.keepLoading ? current.status : "ready", + })); + } + }; + const cause = background + ? "background" + : supersede + ? "refresh" + : "load"; + const data = await fetcher(scopeKey, { + cause, + isCurrent: context.isCurrent, + publish, + }); + if (context.isCurrent()) { + setState({ + data, + error: null, + scopeKey, + status: "ready", + }); + } + } catch (error) { + if (context.isCurrent()) { + setState((current) => ({ + data: + current.scopeKey === scopeKey + ? current.data + : initialDataRef.current, + error: error instanceof Error ? error.message : String(error), + scopeKey, + status: "error", + })); + } + } + }); + }, + [coordinator, enabled, fetcher, scopeKey] + ); + + useEffect(() => { + coordinator.supersede(); + if (!enabled || !scopeKey) { + setState({ + data: initialDataRef.current, + error: null, + scopeKey: null, + status: initialStatusRef.current, + }); + return undefined; + } + + if (autoLoad) { + void reload(); + } else { + setState({ + data: initialDataRef.current, + error: null, + scopeKey, + status: initialStatusRef.current, + }); + } + + return () => { + coordinator.supersede(); + }; + }, [autoLoad, coordinator, enabled, reload, scopeKey]); + + const visibleState = + enabled && scopeKey && state.scopeKey === scopeKey + ? state + : { + data: initialDataRef.current, + error: null, + scopeKey: null, + status: initialStatusRef.current, + }; + + const setData = useCallback>>( + (next) => { + if (!enabled || !scopeKey) return; + setState((current) => { + const currentData = + current.scopeKey === scopeKey ? current.data : initialDataRef.current; + return { + ...current, + data: + typeof next === "function" + ? (next as (current: T) => T)(currentData) + : next, + scopeKey, + }; + }); + }, + [enabled, scopeKey] + ); + + const refresh = useCallback(() => reload({ supersede: true }), [reload]); + + return { + data: visibleState.data, + error: visibleState.error, + loading: + visibleState.status === "loading" || visibleState.status === "refreshing", + refreshing: visibleState.status === "refreshing", + reload, + refresh, + setData, + status: visibleState.status, + }; +} diff --git a/src/hooks/async/useVisibilityPolledData.test.ts b/src/hooks/async/useVisibilityPolledData.test.ts new file mode 100644 index 000000000..acac91c1c --- /dev/null +++ b/src/hooks/async/useVisibilityPolledData.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + type UseVisibilityPolledDataResult, + useVisibilityPolledData, +} from "./useVisibilityPolledData"; + +const pollMocks = vi.hoisted(() => ({ + start: vi.fn(), +})); + +vi.mock("@src/util/core/visibilityAwarePoll", () => ({ + startVisibilityAwarePoll: pollMocks.start, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("useVisibilityPolledData", () => { + let container: HTMLDivElement; + let root: Root; + let current: UseVisibilityPolledDataResult; + let pollTasks: Array<() => Promise | void>; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + function Harness({ + enabled = true, + fetcher, + scopeKey, + }: { + enabled?: boolean; + fetcher: (scopeKey: string) => Promise; + scopeKey: string | null; + }) { + const result = useVisibilityPolledData({ + enabled, + fetcher, + initialData: "", + intervalMs: 1_500, + scopeKey, + }); + useEffect(() => { + current = result; + }, [result]); + return createElement("div", { + "data-error": result.error ?? "", + "data-loading": String(result.loading), + "data-value": result.data, + }); + } + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + pollTasks = []; + pollMocks.start.mockReset().mockImplementation((options) => { + pollTasks.push(options.task); + if (options.runImmediately) void options.task(); + return { runNow: vi.fn(), stop: vi.fn() }; + }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("loads once, then refreshes in the background without clearing data", async () => { + const first = deferred(); + const second = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + expect(container.firstElementChild?.getAttribute("data-loading")).toBe( + "true" + ); + + first.resolve("first"); + await flush(); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "first" + ); + expect(container.firstElementChild?.getAttribute("data-loading")).toBe( + "false" + ); + + let background!: Promise; + act(() => { + background = Promise.resolve(pollTasks.at(-1)?.()); + }); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "first" + ); + expect(container.firstElementChild?.getAttribute("data-loading")).toBe( + "false" + ); + + second.resolve("second"); + await act(async () => background); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "second" + ); + }); + + it("drops a late response after switching scope", async () => { + const first = deferred(); + const second = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockImplementation((scope) => + scope === "a" ? first.promise : second.promise + ); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "b" }))); + + second.resolve("new scope"); + await flush(); + first.resolve("old scope"); + await flush(); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "new scope" + ); + }); + + it("recovers from failure through manual refresh", async () => { + const failed = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockReturnValueOnce(failed.promise) + .mockResolvedValueOnce("recovered"); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + failed.reject(new Error("offline")); + await flush(); + expect(container.firstElementChild?.getAttribute("data-error")).toBe( + "offline" + ); + + await act(async () => current.refresh()); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "recovered" + ); + expect(container.firstElementChild?.getAttribute("data-error")).toBe(""); + }); + + it("clears scoped data and stops polling when disabled", async () => { + const fetcher = vi.fn().mockResolvedValue("loaded"); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + await flush(); + + act(() => + root.render( + createElement(Harness, { enabled: false, fetcher, scopeKey: "a" }) + ) + ); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe(""); + expect(container.firstElementChild?.getAttribute("data-loading")).toBe( + "false" + ); + }); +}); diff --git a/src/hooks/async/useVisibilityPolledData.ts b/src/hooks/async/useVisibilityPolledData.ts new file mode 100644 index 000000000..929f7bc3d --- /dev/null +++ b/src/hooks/async/useVisibilityPolledData.ts @@ -0,0 +1,68 @@ +import { useEffect } from "react"; + +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; + +import { useAsyncResource } from "./useAsyncResource"; + +export interface UseVisibilityPolledDataOptions { + enabled: boolean; + fetcher: (scopeKey: string) => Promise; + initialData: T; + intervalMs: number; + scopeKey: string | null; +} + +export interface UseVisibilityPolledDataResult { + data: T; + error: string | null; + loading: boolean; + refresh: () => Promise; +} + +/** + * Own one visibility-aware, scope-fenced polling resource. + * + * The first load and manual refresh expose loading state. Background ticks + * retain the current data without flashing the loading indicator. + */ +export function useVisibilityPolledData({ + enabled, + fetcher, + initialData, + intervalMs, + scopeKey, +}: UseVisibilityPolledDataOptions): UseVisibilityPolledDataResult { + const resource = useAsyncResource({ + autoLoad: false, + enabled, + fetcher, + initialData, + scopeKey, + }); + const { reload } = resource; + + useEffect(() => { + if (!enabled || !scopeKey) return undefined; + + let initialLoad = true; + const poll = startVisibilityAwarePoll({ + intervalMs, + runImmediately: true, + task: () => { + const background = !initialLoad; + initialLoad = false; + return reload({ background }); + }, + }); + return () => { + poll.stop(); + }; + }, [enabled, intervalMs, reload, scopeKey]); + + return { + data: resource.data, + error: resource.error, + loading: resource.loading, + refresh: resource.refresh, + }; +} diff --git a/src/util/core/__tests__/latestScopedTask.test.ts b/src/util/core/__tests__/latestScopedTask.test.ts new file mode 100644 index 000000000..022f8cb38 --- /dev/null +++ b/src/util/core/__tests__/latestScopedTask.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; + +import { LatestScopedTask } from "../latestScopedTask"; + +describe("LatestScopedTask", () => { + it("shares one promise for the same scope", async () => { + const coordinator = new LatestScopedTask(); + const operation = vi.fn().mockResolvedValue("done"); + + const first = coordinator.run("same", operation); + const second = coordinator.run("same", operation); + + expect(first).toBe(second); + await expect(first).resolves.toBe("done"); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it("marks an older scope stale as soon as a newer scope starts", async () => { + const coordinator = new LatestScopedTask(); + let oldIsCurrent!: () => boolean; + let release!: () => void; + const old = coordinator.run( + "old", + (context) => + new Promise((resolve) => { + oldIsCurrent = context.isCurrent; + release = resolve; + }) + ); + + await coordinator.run("new", async (context) => { + expect(context.isCurrent()).toBe(true); + }); + expect(oldIsCurrent()).toBe(false); + release(); + await old; + }); + + it("retries a scope after failure", async () => { + const coordinator = new LatestScopedTask(); + const operation = vi + .fn() + .mockRejectedValueOnce(new Error("failed")) + .mockResolvedValueOnce("retried"); + + await expect(coordinator.run("scope", operation)).rejects.toThrow("failed"); + await expect(coordinator.run("scope", operation)).resolves.toBe("retried"); + expect(operation).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/util/core/__tests__/visibilityAwarePoll.test.ts b/src/util/core/__tests__/visibilityAwarePoll.test.ts new file mode 100644 index 000000000..3e16f5cdc --- /dev/null +++ b/src/util/core/__tests__/visibilityAwarePoll.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + type PollEnvironment, + startVisibilityAwarePoll, +} from "../visibilityAwarePoll"; + +function createEnvironment() { + let visible = true; + let visibilityListener: (() => void) | undefined; + const environment: PollEnvironment = { + clearTimer: (timer) => clearTimeout(timer as ReturnType), + isVisible: () => visible, + scheduleTimer: (callback, delayMs) => setTimeout(callback, delayMs), + subscribeToVisibilityChange: (callback) => { + visibilityListener = callback; + return () => { + visibilityListener = undefined; + }; + }, + }; + return { + environment, + setVisible(next: boolean) { + visible = next; + visibilityListener?.(); + }, + }; +} + +describe("startVisibilityAwarePoll", () => { + it("waits for the active task before scheduling another pass", async () => { + vi.useFakeTimers(); + const { environment } = createEnvironment(); + let release!: () => void; + const task = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + const poll = startVisibilityAwarePoll({ + environment, + intervalMs: 2_000, + task, + }); + + await vi.advanceTimersByTimeAsync(2_000); + expect(task).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(10_000); + expect(task).toHaveBeenCalledTimes(1); + + release(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(2_000); + expect(task).toHaveBeenCalledTimes(2); + + poll.stop(); + vi.useRealTimers(); + }); + + it("drops its timer while hidden and catches up once on visibility", async () => { + vi.useFakeTimers(); + const controlled = createEnvironment(); + const task = vi.fn().mockResolvedValue(undefined); + const poll = startVisibilityAwarePoll({ + environment: controlled.environment, + intervalMs: 2_000, + task, + }); + + controlled.setVisible(false); + await vi.advanceTimersByTimeAsync(10_000); + expect(task).not.toHaveBeenCalled(); + + controlled.setVisible(true); + await vi.runAllTicks(); + expect(task).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2_000); + expect(task).toHaveBeenCalledTimes(2); + + poll.stop(); + vi.useRealTimers(); + }); + + it("does not reschedule after stop while a task is settling", async () => { + vi.useFakeTimers(); + const { environment } = createEnvironment(); + let release!: () => void; + const task = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + const poll = startVisibilityAwarePoll({ + environment, + intervalMs: 2_000, + runImmediately: true, + task, + }); + + expect(task).toHaveBeenCalledTimes(1); + poll.stop(); + release(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(10_000); + expect(task).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/src/util/core/latestScopedTask.ts b/src/util/core/latestScopedTask.ts new file mode 100644 index 000000000..ddec2d3a5 --- /dev/null +++ b/src/util/core/latestScopedTask.ts @@ -0,0 +1,49 @@ +export interface ScopedTaskContext { + readonly generation: number; + isCurrent(): boolean; +} + +interface ActiveScopedTask { + key: string; + promise: Promise; +} + +/** + * Join equal async scopes while allowing a changed scope to supersede them. + * + * Callers use `context.isCurrent()` before committing results so late + * responses from a previous filter/project cannot overwrite newer state. + */ +export class LatestScopedTask { + private active?: ActiveScopedTask; + private generation = 0; + + run( + key: string, + operation: (context: ScopedTaskContext) => Promise + ): Promise { + if (this.active?.key === key) { + return this.active.promise as Promise; + } + + const generation = ++this.generation; + const context: ScopedTaskContext = { + generation, + isCurrent: () => this.generation === generation, + }; + const promise = operation(context); + this.active = { key, promise }; + const release = () => { + if (this.active?.promise === promise) { + this.active = undefined; + } + }; + void promise.then(release, release); + return promise; + } + + supersede(): void { + this.generation += 1; + this.active = undefined; + } +} diff --git a/src/util/core/visibilityAwarePoll.ts b/src/util/core/visibilityAwarePoll.ts new file mode 100644 index 000000000..d50ca196d --- /dev/null +++ b/src/util/core/visibilityAwarePoll.ts @@ -0,0 +1,120 @@ +export interface PollEnvironment { + clearTimer(timer: unknown): void; + isVisible(): boolean; + scheduleTimer(callback: () => void, delayMs: number): unknown; + subscribeToVisibilityChange(callback: () => void): () => void; +} + +export interface VisibilityAwarePollOptions { + environment?: PollEnvironment; + intervalMs: number; + onError?: (error: unknown) => void; + runImmediately?: boolean; + runOnVisible?: boolean; + task: () => Promise | void; +} + +export interface VisibilityAwarePollController { + runNow(): void; + stop(): void; +} + +function browserPollEnvironment(): PollEnvironment { + return { + clearTimer: (timer) => window.clearTimeout(timer as number), + isVisible: () => document.visibilityState !== "hidden", + scheduleTimer: (callback, delayMs) => window.setTimeout(callback, delayMs), + subscribeToVisibilityChange: (callback) => { + document.addEventListener("visibilitychange", callback); + return () => document.removeEventListener("visibilitychange", callback); + }, + }; +} + +/** + * Run a non-critical background task without overlapping executions. + * + * The next delay starts only after the previous task settles. Hidden pages + * retain no timer; becoming visible triggers one immediate catch-up pass. + */ +export function startVisibilityAwarePoll( + options: VisibilityAwarePollOptions +): VisibilityAwarePollController { + const environment = options.environment ?? browserPollEnvironment(); + const runOnVisible = options.runOnVisible ?? true; + let stopped = false; + let running = false; + let rerunRequested = false; + let timer: unknown; + + const clearScheduledTimer = () => { + if (timer === undefined) return; + environment.clearTimer(timer); + timer = undefined; + }; + + const schedule = () => { + if (stopped || running || timer !== undefined || !environment.isVisible()) { + return; + } + timer = environment.scheduleTimer(() => { + timer = undefined; + void run(); + }, options.intervalMs); + }; + + const run = async () => { + if (stopped || !environment.isVisible()) return; + if (running) { + rerunRequested = true; + return; + } + + clearScheduledTimer(); + running = true; + try { + await options.task(); + } catch (error) { + options.onError?.(error); + } finally { + running = false; + if (!stopped) { + if (rerunRequested && environment.isVisible()) { + rerunRequested = false; + void run(); + } else { + rerunRequested = false; + schedule(); + } + } + } + }; + + const unsubscribe = environment.subscribeToVisibilityChange(() => { + if (!environment.isVisible()) { + clearScheduledTimer(); + return; + } + if (runOnVisible) { + void run(); + } else { + schedule(); + } + }); + + if (options.runImmediately) { + void run(); + } else { + schedule(); + } + + return { + runNow: () => void run(), + stop: () => { + stopped = true; + rerunRequested = false; + clearScheduledTimer(); + unsubscribe(); + }, + }; +} From 9fc4f134fc26b42499ca963f7d24c54308db529e Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 23 Jul 2026 22:31:19 +0800 Subject: [PATCH 5/8] refactor(data): scope shared application loaders Pre-commit hook ran. Total eslint: 10, total circular: 0 --- .../hooks/session/useSessionDiscovery.ts | 157 ++++++++++-------- .../dependencies/useSystemDependencies.ts | 108 ++++++------ src/hooks/policies/useSharedPolicies.ts | 82 +++------ .../session/useOrgtrackSessionArtifacts.ts | 140 +++++++--------- .../settings/useLearningsBrowser.test.ts | 122 ++++++++++++++ src/hooks/settings/useLearningsBrowser.ts | 149 +++++++++-------- src/hooks/skills/useSkillsHub.ts | 106 ++++++------ src/hooks/terminal/useAvailableShells.ts | 60 +++---- .../AgentOrgs/config/skills/useSkills.ts | 63 +++---- .../MainApp/AgentOrgs/hooks/useAgentOrgs.ts | 51 ++---- .../BuiltInTools/useUnifiedToolsMetadata.ts | 71 +++----- .../KeyVault/CliClients/hooks/useCliAgents.ts | 53 +++--- .../Memory/useWorkspaceMemoryStatus.ts | 104 +++++------- .../hooks/lsp/useLspGlobalConfig.ts | 53 +++--- .../Integrations/hooks/useChannelState.ts | 60 ++----- .../Browser/hooks/useGlobalTokens.ts | 95 ++++++----- .../KeyVault/hooks/useProviderConfig.ts | 57 +++---- .../KeyVault/hooks/useProviderRegistry.ts | 80 ++++----- 18 files changed, 779 insertions(+), 832 deletions(-) create mode 100644 src/hooks/settings/useLearningsBrowser.test.ts diff --git a/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts b/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts index a39c3d206..2b64b3af8 100644 --- a/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts +++ b/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts @@ -10,7 +10,7 @@ * useAgentCompatibility() stays in sync. */ import { useSetAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import type { AgentInfo, ProviderInfo } from "@src/api/http/config"; import { rpc } from "@src/api/tauri/rpc"; @@ -19,6 +19,10 @@ import type { AvailableApiProvider, KeyInfo, } from "@src/api/tauri/rpc/schemas/validation"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { loadSharedLocalKeys } from "@src/hooks/keyVault/sharedLocalKeyStore"; import { createLogger } from "@src/hooks/logger"; import { agentRegistryAtom } from "@src/store/session/agentRegistryAtom"; @@ -142,6 +146,47 @@ function mapAgents(agents: AvailableAgent[]): AgentInfo[] { })); } +interface SessionDiscoveryData { + apiProviders: AvailableApiProvider[]; + mappedAgents: AgentInfo[]; + providers: ProviderInfo[]; + rawAgents: AvailableAgent[]; +} + +const EMPTY_SESSION_DISCOVERY: SessionDiscoveryData = { + apiProviders: [], + mappedAgents: [], + providers: [], + rawAgents: [], +}; + +let discoveryInFlight: Promise | null = null; + +function loadSessionDiscovery(force: boolean): Promise { + if (!force && discoveryInFlight) return discoveryInFlight; + + const promise = Promise.all([ + rpc.validation.getAvailableApiProviders(), + rpc.validation.getAvailableAgents(), + loadSharedLocalKeys(force), + ]).then(([apiProviders, rawAgents, allKeys]) => ({ + apiProviders, + mappedAgents: mapAgents(rawAgents), + providers: buildProviderInfoList(apiProviders, allKeys), + rawAgents, + })); + discoveryInFlight = promise; + void promise.then( + () => { + if (discoveryInFlight === promise) discoveryInFlight = null; + }, + () => { + if (discoveryInFlight === promise) discoveryInFlight = null; + } + ); + return promise; +} + // ============================================ // Hook Implementation // ============================================ @@ -150,22 +195,44 @@ export function useSessionDiscovery( options: UseSessionDiscoveryOptions = {} ): UseSessionDiscoveryReturn { const { autoLoad = true, onSuccess, onError } = options; - - const [providers, setProviders] = useState([]); - const [agents, setAgents] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const hasLoadedRef = useRef(false); - const mountedRef = useRef(true); - const setAgentRegistry = useSetAtom(agentRegistryAtom); - + const callbacksRef = useRef({ onError, onSuccess }); useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); + callbacksRef.current = { onError, onSuccess }; + }, [onError, onSuccess]); + + const fetchDiscovery = useCallback( + async ( + _scopeKey: string, + context: AsyncResourceFetchContext + ) => { + try { + const data = await loadSessionDiscovery(context.cause === "refresh"); + callbacksRef.current.onSuccess?.({ + agents: data.mappedAgents, + providers: data.providers, + }); + return data; + } catch (error) { + const normalizedError = + error instanceof Error + ? error + : new Error("Failed to load session data"); + log.error("[useSessionDiscovery] Refresh failed:", error); + callbacksRef.current.onError?.(normalizedError); + throw normalizedError; + } + }, + [] + ); + const resource = useAsyncResource({ + autoLoad, + fetcher: fetchDiscovery, + initialData: EMPTY_SESSION_DISCOVERY, + scopeKey: "session-discovery", + }); + const providers = resource.data.providers; + const agents = resource.data.mappedAgents; const availableAgents = useMemo( () => agents.filter((agent) => agent.available), @@ -200,56 +267,14 @@ export function useSessionDiscovery( [agents] ); - // ============================================ - // Refresh - // ============================================ - - const refresh = useCallback(async () => { - if (!mountedRef.current) return; - setLoading(true); - setError(null); - - try { - const [apiProviders, rawAgents, allKeys] = await Promise.all([ - rpc.validation.getAvailableApiProviders(), - rpc.validation.getAvailableAgents(), - loadSharedLocalKeys(), - ]); - - if (!mountedRef.current) return; - - // Populate agentRegistryAtom so useAgentCompatibility stays current - setAgentRegistry({ agents: rawAgents, apiProviders }); - - const mappedProviders = buildProviderInfoList(apiProviders, allKeys); - const mappedAgents = mapAgents(rawAgents); - - setProviders(mappedProviders); - setAgents(mappedAgents); - - onSuccess?.({ providers: mappedProviders, agents: mappedAgents }); - } catch (err) { - if (!mountedRef.current) return; - const errorMessage = - err instanceof Error ? err.message : "Failed to load session data"; - log.error("[useSessionDiscovery] Refresh failed:", err); - setError(errorMessage); - onError?.(err as Error); - } finally { - if (mountedRef.current) setLoading(false); - } - }, [onSuccess, onError, setAgentRegistry]); - - // ============================================ - // Effects - // ============================================ - useEffect(() => { - if (autoLoad && !hasLoadedRef.current) { - hasLoadedRef.current = true; - refresh(); + if (resource.status === "ready") { + setAgentRegistry({ + agents: resource.data.rawAgents, + apiProviders: resource.data.apiProviders, + }); } - }, [autoLoad, refresh]); + }, [resource.data, resource.status, setAgentRegistry]); // ============================================ // Return @@ -259,9 +284,9 @@ export function useSessionDiscovery( providers, agents, availableAgents, - loading, - error, - refresh, + loading: resource.loading, + error: resource.error, + refresh: resource.refresh, getModelsForProvider, isProviderAvailable, isAgentAvailable, diff --git a/src/hooks/dependencies/useSystemDependencies.ts b/src/hooks/dependencies/useSystemDependencies.ts index b54758db6..ac39fd643 100644 --- a/src/hooks/dependencies/useSystemDependencies.ts +++ b/src/hooks/dependencies/useSystemDependencies.ts @@ -5,8 +5,12 @@ * Returns the full list plus helpers for filtering by category. */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; const log = createLogger("Dependencies"); @@ -49,58 +53,58 @@ export const NON_DB_CATEGORIES: DepCategoryId[] = [ ]; export function useSystemDependencies() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - - useEffect(() => { - let cancelled = false; - - invoke("get_cached_dependencies") - .then((result) => { - if (!cancelled) { - setData(result); - setIsLoading(false); - } - }) - .catch((err: unknown) => { - // Cache miss is non-fatal — `detect_system_dependencies` below - // performs the live scan. Surface the failure for debugging. - log.warn("[Dependencies] cache load failed:", err); - }); - - invoke("detect_system_dependencies") - .then((result) => { - if (!cancelled) { - setData(result); - setIsLoading(false); - } - }) - .catch((error) => { - if (!cancelled) { - log.error("[Dependencies] scan failed:", error); - setIsLoading(false); + const fetchDependencies = useCallback( + async ( + _scopeKey: string, + context: AsyncResourceFetchContext + ) => { + if (context.cause !== "load") { + return invoke("detect_system_dependencies"); + } + + let liveSettled = false; + const cachedPromise = invoke( + "get_cached_dependencies" + ) + .then((cached) => { + if (!liveSettled) context.publish(cached); + return cached; + }) + .catch((error: unknown) => { + log.warn("[Dependencies] cache load failed:", error); + throw error; + }); + const livePromise = invoke( + "detect_system_dependencies" + ).then( + (result) => { + liveSettled = true; + return result; + }, + (error: unknown) => { + liveSettled = true; + throw error; } - }); + ); - return () => { - cancelled = true; - }; - }, []); + const [cachedResult, liveResult] = await Promise.allSettled([ + cachedPromise, + livePromise, + ]); + if (liveResult.status === "fulfilled") return liveResult.value; + log.error("[Dependencies] scan failed:", liveResult.reason); + if (cachedResult.status === "fulfilled") return cachedResult.value; + throw liveResult.reason; + }, + [] + ); - const refresh = useCallback(async () => { - setIsRefreshing(true); - try { - const result = await invoke( - "detect_system_dependencies" - ); - setData(result); - } catch (error) { - log.error("[Dependencies] refresh failed:", error); - } finally { - setIsRefreshing(false); - } - }, []); + const resource = useAsyncResource({ + fetcher: fetchDependencies, + initialData: null, + scopeKey: "system-dependencies", + }); + const { data, refresh, refreshing, status } = resource; const dependencies = useMemo(() => data?.dependencies ?? [], [data]); @@ -114,8 +118,8 @@ export function useSystemDependencies() { return { dependencies, - isLoading, - isRefreshing, + isLoading: status === "loading", + isRefreshing: refreshing, refresh, byCategory, }; diff --git a/src/hooks/policies/useSharedPolicies.ts b/src/hooks/policies/useSharedPolicies.ts index d70111041..b506a1903 100644 --- a/src/hooks/policies/useSharedPolicies.ts +++ b/src/hooks/policies/useSharedPolicies.ts @@ -5,8 +5,9 @@ * `.orgii/rules/` files + per-rule agent scope in `rules-config.json`. */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; const log = createLogger("SharedPolicies"); @@ -44,64 +45,29 @@ export interface UseSharedPoliciesOptions { export function useSharedPolicies(options: UseSharedPoliciesOptions = {}) { const { workspacePath, autoLoad = true } = options; - const [policies, setPolicies] = useState([]); - // Default false so remounts of this hook on navigation don't paint - // a synthetic spinner before the IPC begins. `refresh` below raises - // loading to true for the actual fetch window; Placeholder's loading - // variant debounces sub-250ms spinners globally. - const [loading, setLoading] = useState(false); - const cancelRef = useRef<(() => void) | null>(null); - - const refresh = useCallback(() => { - cancelRef.current?.(); - let cancelled = false; - cancelRef.current = () => { - cancelled = true; - }; - - setLoading(true); - invoke("policies_list", { - workspacePath: workspacePath ?? null, - }) - .then((result) => { - if (!cancelled) setPolicies(result); - }) - .catch((err: unknown) => { - if (!cancelled) - log.error("[SharedPolicies] Failed to list policies:", err); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - }, [workspacePath]); - - useEffect(() => { - if (!autoLoad) return; - - cancelRef.current?.(); - let cancelled = false; - cancelRef.current = () => { - cancelled = true; + const fetchPolicies = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + workspacePath: string | null; }; - - invoke("policies_list", { - workspacePath: workspacePath ?? null, - }) - .then((result) => { - if (!cancelled) setPolicies(result); - }) - .catch((err: unknown) => { - if (!cancelled) - log.error("[SharedPolicies] Failed to list policies:", err); - }) - .finally(() => { - if (!cancelled) setLoading(false); + try { + return await invoke("policies_list", { + workspacePath: scope.workspacePath, }); - - return () => { - cancelled = true; - }; - }, [workspacePath, autoLoad]); + } catch (error) { + log.error("[SharedPolicies] Failed to list policies:", error); + throw error; + } + }, []); + const policyResource = useAsyncResource({ + autoLoad, + fetcher: fetchPolicies, + initialData: [], + scopeKey: JSON.stringify({ workspacePath: workspacePath ?? null }), + }); + const policies = policyResource.data; + const loading = policyResource.loading; + const refresh = policyResource.refresh; + const setPolicies = policyResource.setData; const readRule = useCallback( async ( @@ -208,7 +174,7 @@ export function useSharedPolicies(options: UseSharedPoliciesOptions = {}) { throw err; } }, - [workspacePath, refresh] + [workspacePath, refresh, setPolicies] ); const setAgents = useCallback( diff --git a/src/hooks/session/useOrgtrackSessionArtifacts.ts b/src/hooks/session/useOrgtrackSessionArtifacts.ts index bd9aae4df..caebfaec7 100644 --- a/src/hooks/session/useOrgtrackSessionArtifacts.ts +++ b/src/hooks/session/useOrgtrackSessionArtifacts.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback } from "react"; import { getOrgtrackCheckpointFileStates, @@ -14,6 +14,7 @@ import type { OrgtrackSessionEditArtifact, OrgtrackSessionFinalDiff, } from "@src/api/tauri/lineage"; +import { useAsyncResource } from "@src/hooks/async"; interface UseOrgtrackSessionArtifactsInput { source?: string; @@ -28,62 +29,48 @@ interface OrgtrackSessionArtifactsState { checkpoints: OrgtrackSessionCheckpoint[]; checkpointFileStatesById: Map; loading: boolean; - error: Error | null; + error: string | null; reload: () => Promise; } +interface OrgtrackSessionArtifactsData { + editArtifacts: OrgtrackSessionEditArtifact[]; + diffChunks: OrgtrackSessionDiffChunk[]; + finalDiffs: OrgtrackSessionFinalDiff[]; + checkpoints: OrgtrackSessionCheckpoint[]; + checkpointFileStatesById: Map; +} + +function emptyArtifacts(): OrgtrackSessionArtifactsData { + return { + editArtifacts: [], + diffChunks: [], + finalDiffs: [], + checkpoints: [], + checkpointFileStatesById: new Map(), + }; +} + export function useOrgtrackSessionArtifacts({ source, sessionId, enabled = true, }: UseOrgtrackSessionArtifactsInput): OrgtrackSessionArtifactsState { - const [editArtifacts, setEditArtifacts] = useState< - OrgtrackSessionEditArtifact[] - >([]); - const [diffChunks, setDiffChunks] = useState([]); - const [finalDiffs, setFinalDiffs] = useState([]); - const [checkpoints, setCheckpoints] = useState( - [] - ); - const [checkpointFileStatesById, setCheckpointFileStatesById] = useState< - Map - >(new Map()); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const requestIdRef = useRef(0); - - const query = useMemo(() => ({ source, sessionId }), [source, sessionId]); - - const reload = useCallback(async () => { - const requestId = requestIdRef.current + 1; - requestIdRef.current = requestId; - if (!enabled || !sessionId) { - setEditArtifacts([]); - setDiffChunks([]); - setFinalDiffs([]); - setCheckpoints([]); - setCheckpointFileStatesById(new Map()); - setLoading(false); - setError(null); - return; - } - - setLoading(true); - setError(null); - try { - const [ - nextEditArtifacts, - nextDiffChunks, - nextFinalDiffs, - nextCheckpoints, - ] = await Promise.all([ - getOrgtrackSessionEditArtifacts(query), - getOrgtrackSessionDiffChunks(query), - getOrgtrackSessionFinalDiffs(query), - getOrgtrackSessionCheckpoints(query), - ]); + const fetchArtifacts = useCallback( + async (serializedScope: string): Promise => { + const query = JSON.parse(serializedScope) as { + source?: string; + sessionId: string; + }; + const [editArtifacts, diffChunks, finalDiffs, checkpoints] = + await Promise.all([ + getOrgtrackSessionEditArtifacts(query), + getOrgtrackSessionDiffChunks(query), + getOrgtrackSessionFinalDiffs(query), + getOrgtrackSessionCheckpoints(query), + ]); const stateEntries = await Promise.all( - nextCheckpoints.map( + checkpoints.map( async (checkpoint) => [ checkpoint.checkpointId, @@ -91,42 +78,29 @@ export function useOrgtrackSessionArtifacts({ ] as const ) ); - if (requestIdRef.current !== requestId) { - return; - } - setEditArtifacts(nextEditArtifacts); - setDiffChunks(nextDiffChunks); - setFinalDiffs(nextFinalDiffs); - setCheckpoints(nextCheckpoints); - setCheckpointFileStatesById(new Map(stateEntries)); - } catch (caughtError) { - if (requestIdRef.current !== requestId) { - return; - } - setError( - caughtError instanceof Error - ? caughtError - : new Error(String(caughtError)) - ); - } finally { - if (requestIdRef.current === requestId) { - setLoading(false); - } - } - }, [enabled, query, sessionId]); - - useEffect(() => { - void reload(); - }, [reload]); + return { + editArtifacts, + diffChunks, + finalDiffs, + checkpoints, + checkpointFileStatesById: new Map(stateEntries), + }; + }, + [] + ); + const scopeKey = + enabled && sessionId ? JSON.stringify({ source, sessionId }) : null; + const resource = useAsyncResource({ + enabled: Boolean(scopeKey), + fetcher: fetchArtifacts, + initialData: emptyArtifacts(), + scopeKey, + }); return { - editArtifacts, - diffChunks, - finalDiffs, - checkpoints, - checkpointFileStatesById, - loading, - error, - reload, + ...resource.data, + loading: resource.loading, + error: resource.error, + reload: resource.refresh, }; } diff --git a/src/hooks/settings/useLearningsBrowser.test.ts b/src/hooks/settings/useLearningsBrowser.test.ts new file mode 100644 index 000000000..5fb706611 --- /dev/null +++ b/src/hooks/settings/useLearningsBrowser.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { LearningRecord } from "@src/api/tauri/rpc/schemas/learning"; + +import { + type UseLearningsBrowserReturn, + useLearningsBrowser, +} from "./useLearningsBrowser"; + +const learningMocks = vi.hoisted(() => ({ + browseList: vi.fn(), + getStatus: vi.fn(), + remove: vi.fn(), + setStatus: vi.fn(), +})); + +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { + learning: learningMocks, + }, +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("useLearningsBrowser", () => { + let container: HTMLDivElement; + let root: Root; + let current: UseLearningsBrowserReturn; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + function Harness() { + const result = useLearningsBrowser(); + useEffect(() => { + current = result; + }, [result]); + return createElement("div", { + "data-item": result.items[0]?.id ?? "", + "data-loading": String(result.loading), + }); + } + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + learningMocks.browseList.mockReset(); + learningMocks.getStatus.mockReset(); + learningMocks.remove.mockReset(); + learningMocks.setStatus.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("keeps the newest filter result when an older request finishes last", async () => { + const oldList = deferred(); + const oldStatus = deferred(); + const newList = deferred(); + const newStatus = deferred(); + learningMocks.browseList + .mockReturnValueOnce(oldList.promise) + .mockReturnValueOnce(newList.promise); + learningMocks.getStatus + .mockReturnValueOnce(oldStatus.promise) + .mockReturnValueOnce(newStatus.promise); + + act(() => root.render(createElement(Harness))); + act(() => current.setFilters({ search: "new" })); + + newList.resolve([ + { id: "new", updated_at: "2026-07-23T10:00:00Z" } as LearningRecord, + ]); + newStatus.resolve({} as never); + await flush(); + expect(container.firstElementChild?.getAttribute("data-item")).toBe("new"); + + oldList.resolve([ + { id: "old", updated_at: "2026-07-22T10:00:00Z" } as LearningRecord, + ]); + oldStatus.resolve({} as never); + await flush(); + + expect(container.firstElementChild?.getAttribute("data-item")).toBe("new"); + expect(learningMocks.browseList).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/hooks/settings/useLearningsBrowser.ts b/src/hooks/settings/useLearningsBrowser.ts index b50396713..1d8b59400 100644 --- a/src/hooks/settings/useLearningsBrowser.ts +++ b/src/hooks/settings/useLearningsBrowser.ts @@ -7,7 +7,7 @@ * so the hook lives under `src/hooks/settings/` (single-module use). */ import { useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { rpc } from "@src/api/tauri/rpc"; import type { @@ -18,6 +18,7 @@ import type { LearningsStatusReport, SettableLearningStatusValue, } from "@src/api/tauri/rpc/schemas/learning"; +import { useAsyncResource } from "@src/hooks/async"; import { learningsBrowserInitialFilterAtom } from "@src/store"; export interface LearningsBrowserFilters { @@ -47,88 +48,96 @@ export interface UseLearningsBrowserReturn { remove: (id: string) => Promise; } +interface LearningsBrowserData { + items: LearningRecord[]; + status: LearningsStatusReport | null; +} + +interface LearningsBrowserRequest { + agentScopes?: string[]; + filters: LearningsBrowserFilters; +} + +const EMPTY_LEARNINGS_BROWSER_DATA: LearningsBrowserData = { + items: [], + status: null, +}; + export function useLearningsBrowser( options: UseLearningsBrowserOptions = {} ): UseLearningsBrowserReturn { - const [items, setItems] = useState([]); - const [status, setStatusReport] = useState( - null - ); - const [filters, setFiltersState] = useState({}); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [initialFilter, setInitialFilter] = useAtom( learningsBrowserInitialFilterAtom ); + const [filters, setFiltersState] = useState(() => + initialFilter ? { status: initialFilter } : {} + ); useEffect(() => { if (initialFilter) { - setFiltersState((prev) => ({ ...prev, status: initialFilter })); setInitialFilter(null); } }, [initialFilter, setInitialFilter]); - const fetchAll = useCallback( - async (current: LearningsBrowserFilters) => { - setLoading(true); - setError(null); - try { - const scopes = current.agentScope - ? [current.agentScope] - : options.agentScopes; - if (scopes && scopes.length > 0) { - const lists = await Promise.all( - scopes.map((agentScope) => - rpc.learning.browseList({ - agentScope, - status: current.status, - source: current.source, - category: current.category, - search: current.search, - }) - ) - ); - const byId = new Map(); - for (const list of lists) { - for (const row of list) byId.set(row.id, row); - } - const merged = [...byId.values()].sort((rowA, rowB) => - rowB.updated_at.localeCompare(rowA.updated_at) - ); - setItems(merged); - setStatusReport(null); - return; - } - - const [list, report] = await Promise.all([ + const scopeKey = useMemo( + () => + JSON.stringify({ + agentScopes: options.agentScopes, + filters, + } satisfies LearningsBrowserRequest), + [filters, options.agentScopes] + ); + + const fetchAll = useCallback(async (serializedRequest: string) => { + const request = JSON.parse(serializedRequest) as LearningsBrowserRequest; + const current = request.filters; + const scopes = current.agentScope + ? [current.agentScope] + : request.agentScopes; + if (scopes && scopes.length > 0) { + const lists = await Promise.all( + scopes.map((agentScope) => rpc.learning.browseList({ - agentScope: current.agentScope, + agentScope, status: current.status, source: current.source, category: current.category, search: current.search, - }), - rpc.learning.getStatus({ agentScope: current.agentScope }), - ]); - setItems(list); - setStatusReport(report); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - } finally { - setLoading(false); + }) + ) + ); + const byId = new Map(); + for (const list of lists) { + for (const row of list) byId.set(row.id, row); } - }, - [options.agentScopes] - ); + return { + items: [...byId.values()].sort((rowA, rowB) => + rowB.updated_at.localeCompare(rowA.updated_at) + ), + status: null, + }; + } - useEffect(() => { - void fetchAll(filters); - }, [filters, fetchAll]); + const [items, status] = await Promise.all([ + rpc.learning.browseList({ + agentScope: current.agentScope, + status: current.status, + source: current.source, + category: current.category, + search: current.search, + }), + rpc.learning.getStatus({ agentScope: current.agentScope }), + ]); + return { items, status }; + }, []); + + const resource = useAsyncResource({ + fetcher: fetchAll, + initialData: EMPTY_LEARNINGS_BROWSER_DATA, + scopeKey, + }); - const refresh = useCallback(async () => { - await fetchAll(filters); - }, [fetchAll, filters]); + const refresh = resource.refresh; const setFilters = useCallback((next: LearningsBrowserFilters) => { setFiltersState(next); @@ -137,25 +146,25 @@ export function useLearningsBrowser( const setStatus = useCallback( async (id: string, next: SettableLearningStatusValue) => { await rpc.learning.setStatus({ learningId: id, next }); - await fetchAll(filters); + await refresh(); }, - [fetchAll, filters] + [refresh] ); const remove = useCallback( async (id: string) => { await rpc.learning.remove({ learningId: id }); - await fetchAll(filters); + await refresh(); }, - [fetchAll, filters] + [refresh] ); return { - items, - loading, - error, + items: resource.data.items, + loading: resource.loading, + error: resource.error, filters, - status, + status: resource.data.status, setFilters, refresh, setStatus, diff --git a/src/hooks/skills/useSkillsHub.ts b/src/hooks/skills/useSkillsHub.ts index 5d9ce0658..b04054775 100644 --- a/src/hooks/skills/useSkillsHub.ts +++ b/src/hooks/skills/useSkillsHub.ts @@ -10,6 +10,10 @@ import { invoke } from "@tauri-apps/api/core"; import { useAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { useMounted } from "@src/hooks/lifecycle/useMounted"; import { createLogger } from "@src/hooks/logger"; import { scanInstalledSkills } from "@src/hooks/skills/installedSkillsScan"; @@ -64,9 +68,7 @@ export function useSkillsHub({ installedSkillsLoadingAtom ); - const [skillDetail, setSkillDetail] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); - const [detailError, setDetailError] = useState(null); + const [detailSlug, setDetailSlug] = useState(null); const [updates, setUpdates] = useState([]); const [updatesLoading, setUpdatesLoading] = useState(false); @@ -193,55 +195,61 @@ export function useSkillsHub({ setInstalledSkills, ]); - const fetchDetail = useCallback(async (slug: string) => { - setDetailLoading(true); - setDetailError(null); - setSkillDetail(null); - - let hasCached = false; - - // 1. Try loading cached detail first for instant display - try { - const cached = await invoke( - "skills_hub_detail_cache_read", - { name: slug } - ); - if (cached) { - setSkillDetail(cached); - setDetailLoading(false); - hasCached = true; + const loadSkillDetail = useCallback( + async ( + slug: string, + context: AsyncResourceFetchContext + ) => { + let cached: HubSkillDetail | null = null; + try { + cached = await invoke( + "skills_hub_detail_cache_read", + { name: slug } + ); + if (cached) context.publish(cached); + } catch { + // Cache miss is fine, continue to network. } - } catch { - // Cache miss is fine, continue to network - } - // 2. Fetch fresh detail from network (background refresh if cached) - try { - const detail = await invoke("skills_hub_detail", { - slug, - }); - setSkillDetail(detail); + try { + const detail = await invoke("skills_hub_detail", { + slug, + }); + void invoke("skills_hub_detail_cache_write", { + name: slug, + detail, + }).catch(() => { + // Cache write failure is non-critical. + }); + return detail; + } catch (error) { + if (cached) return cached; + throw error; + } + }, + [] + ); + const detailResource = useAsyncResource({ + enabled: Boolean(detailSlug), + fetcher: loadSkillDetail, + initialData: null, + scopeKey: detailSlug, + }); + const refreshDetail = detailResource.refresh; - // 3. Persist to cache for offline access - invoke("skills_hub_detail_cache_write", { - name: slug, - detail, - }).catch(() => { - // Cache write failure is non-critical - }); - } catch (err) { - if (!hasCached) { - setDetailError(err instanceof Error ? err.message : String(err)); + const fetchDetail = useCallback( + (slug: string) => { + if (slug === detailSlug) { + void refreshDetail(); + return; } - } finally { - setDetailLoading(false); - } - }, []); + setDetailSlug(slug); + }, + [detailSlug, refreshDetail] + ); const clearDetail = useCallback(() => { - setSkillDetail(null); - setDetailError(null); - setDetailLoading(false); + setDetailSlug(null); }, []); const uninstall = useCallback( @@ -318,9 +326,9 @@ export function useSkillsHub({ toggleSkill, uninstall, readSkill, - skillDetail, - detailLoading, - detailError, + skillDetail: detailResource.data, + detailLoading: detailResource.loading, + detailError: detailResource.error, fetchDetail, clearDetail, updates, diff --git a/src/hooks/terminal/useAvailableShells.ts b/src/hooks/terminal/useAvailableShells.ts index 6e1cf5ec0..c3dbc92a0 100644 --- a/src/hooks/terminal/useAvailableShells.ts +++ b/src/hooks/terminal/useAvailableShells.ts @@ -7,8 +7,9 @@ * Results are cached after the first successful fetch — the set of available * shells doesn't change during a single app session. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; +import { useAsyncResource } from "@src/hooks/async"; import type { DetectedShell, ShellProfile } from "@src/types/terminal"; import { invokeTauri, isTauriReady } from "@src/util/platform/tauri/init"; @@ -20,6 +21,7 @@ interface UseAvailableShellsReturn { } let cachedProfiles: ShellProfile[] | null = null; +const EMPTY_SHELL_PROFILES: ShellProfile[] = []; function detectedShellToProfile(shell: DetectedShell): ShellProfile { return { @@ -35,44 +37,32 @@ function detectedShellToProfile(shell: DetectedShell): ShellProfile { } export function useAvailableShells(): UseAvailableShellsReturn { - const [profiles, setProfiles] = useState( - cachedProfiles ?? [] - ); - const [loading, setLoading] = useState(cachedProfiles === null); - const [error, setError] = useState(null); - const fetchedRef = useRef(cachedProfiles !== null); - const fetchShells = useCallback(async () => { - if (!isTauriReady()) return; - - setLoading(true); - setError(null); - - try { - const detected = await invokeTauri( - "detect_available_shells" - ); - const mapped = detected.map(detectedShellToProfile); - cachedProfiles = mapped; - setProfiles(mapped); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } + if (cachedProfiles) return cachedProfiles; + if (!isTauriReady()) return EMPTY_SHELL_PROFILES; + const detected = await invokeTauri( + "detect_available_shells" + ); + cachedProfiles = detected.map(detectedShellToProfile); + return cachedProfiles; }, []); - - useEffect(() => { - if (!fetchedRef.current) { - fetchedRef.current = true; - fetchShells(); - } - }, [fetchShells]); + const resource = useAsyncResource({ + fetcher: fetchShells, + initialData: cachedProfiles ?? EMPTY_SHELL_PROFILES, + initialStatus: cachedProfiles ? "ready" : "idle", + scopeKey: "available-shells", + }); + const refreshResource = resource.refresh; const refresh = useCallback(() => { cachedProfiles = null; - fetchShells(); - }, [fetchShells]); + void refreshResource(); + }, [refreshResource]); - return { profiles, loading, error, refresh }; + return { + profiles: resource.data, + loading: resource.loading, + error: resource.error, + refresh, + }; } diff --git a/src/modules/MainApp/AgentOrgs/config/skills/useSkills.ts b/src/modules/MainApp/AgentOrgs/config/skills/useSkills.ts index 601b9c54f..d65c504aa 100644 --- a/src/modules/MainApp/AgentOrgs/config/skills/useSkills.ts +++ b/src/modules/MainApp/AgentOrgs/config/skills/useSkills.ts @@ -1,9 +1,10 @@ /** * Hook for managing coding agent skills. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; import { rpc } from "@src/api/tauri/rpc"; +import { useAsyncResource } from "@src/hooks/async"; import type { DescriptionQuality } from "@src/types/extensions/types"; export interface SkillInfo { @@ -30,43 +31,21 @@ export interface SkillInfo { * agent UIs so per-agent toggles do not silently rewrite OS/SDE state. */ export function useSkills(workspacePath?: string, agentId?: string) { - const [skills, setSkills] = useState([]); - // Default false: a fresh mount of this hook should not flash a - // spinner before the IPC even kicks off. `refresh` raises loading - // for the actual fetch window; the Placeholder loading variant is - // debounced to suppress sub-250ms flashes globally. - const [loading, setLoading] = useState(false); - const cancelRef = useRef<(() => void) | null>(null); - - const refresh = useCallback(() => { - cancelRef.current?.(); - let cancelled = false; - cancelRef.current = () => { - cancelled = true; - }; - - queueMicrotask(() => { - if (!cancelled) setLoading(true); - }); - rpc.agentOrgs.skills - .list({ workspacePath, agentId }) - .then((result) => { - if (!cancelled) setSkills(result); - }) - .catch(() => { - // Fetch failure: leave existing skills displayed. - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - }, [workspacePath, agentId]); - - useEffect(() => { - refresh(); - return () => { - cancelRef.current?.(); + const fetchSkills = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + agentId?: string; + workspacePath?: string; }; - }, [refresh]); + return rpc.agentOrgs.skills.list(scope); + }, []); + const scopeKey = JSON.stringify({ agentId, workspacePath }); + const resource = useAsyncResource({ + fetcher: fetchSkills, + initialData: [], + scopeKey, + }); + const setSkills = resource.setData; + const refresh = resource.refresh; const readSkill = useCallback( async (name: string) => { @@ -102,8 +81,14 @@ export function useSkills(workspacePath?: string, agentId?: string) { throw err; } }, - [workspacePath, agentId, refresh] + [workspacePath, agentId, refresh, setSkills] ); - return { skills, loading, refresh, readSkill, toggleSkill }; + return { + skills: resource.data, + loading: resource.loading, + refresh, + readSkill, + toggleSkill, + }; } diff --git a/src/modules/MainApp/AgentOrgs/hooks/useAgentOrgs.ts b/src/modules/MainApp/AgentOrgs/hooks/useAgentOrgs.ts index 47e241879..b1ab998c3 100644 --- a/src/modules/MainApp/AgentOrgs/hooks/useAgentOrgs.ts +++ b/src/modules/MainApp/AgentOrgs/hooks/useAgentOrgs.ts @@ -4,10 +4,10 @@ * Returns the list of OrgMember (top-level org definitions) for use in * assignee pickers and orchestrator config resolution. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { rpc } from "@src/api/tauri/rpc"; -import { useMounted } from "@src/hooks/lifecycle/useMounted"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import type { OrgMember } from "../types"; @@ -15,42 +15,23 @@ import type { OrgMember } from "../types"; const log = createLogger("AgentOrgs"); export function useAgentOrgs() { - const [orgs, setOrgs] = useState([]); - const [loading, setLoading] = useState(false); - const mountedRef = useMounted(); - - const refresh = useCallback(async () => { - setLoading(true); + const fetchOrgs = useCallback(async () => { try { - const result = await rpc.agentOrgs.orgs.list(); - if (mountedRef.current) setOrgs(result); + return await rpc.agentOrgs.orgs.list(); } catch (error) { log.error("[AgentOrgs] Failed to fetch:", error); - } finally { - if (mountedRef.current) setLoading(false); + throw error; } - }, [mountedRef]); - - useEffect(() => { - let cancelled = false; - - const load = async () => { - setLoading(true); - try { - const result = await rpc.agentOrgs.orgs.list(); - if (!cancelled) setOrgs(result); - } catch (error) { - log.error("[AgentOrgs] Failed to fetch:", error); - } finally { - if (!cancelled) setLoading(false); - } - }; - load(); - - return () => { - cancelled = true; - }; }, []); - - return { orgs, loading, refresh }; + const resource = useAsyncResource({ + fetcher: fetchOrgs, + initialData: [], + scopeKey: "agent-orgs", + }); + + return { + orgs: resource.data, + loading: resource.loading, + refresh: resource.refresh, + }; } diff --git a/src/modules/MainApp/Integrations/BuiltInTools/useUnifiedToolsMetadata.ts b/src/modules/MainApp/Integrations/BuiltInTools/useUnifiedToolsMetadata.ts index 46cf2ad4a..197895056 100644 --- a/src/modules/MainApp/Integrations/BuiltInTools/useUnifiedToolsMetadata.ts +++ b/src/modules/MainApp/Integrations/BuiltInTools/useUnifiedToolsMetadata.ts @@ -4,8 +4,9 @@ * Uses module-level caching to prevent re-fetching on every component mount. * Similar to simulatorMap.ts caching pattern. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { invokeTauri } from "@src/util/platform/tauri/init"; @@ -19,6 +20,7 @@ const log = createLogger("Tools"); /** Cached tools list (null = not fetched yet). */ let cachedTools: RawToolInfo[] | null = null; +const EMPTY_TOOLS: RawToolInfo[] = []; /** In-flight fetch promise to prevent duplicate requests. */ let fetchPromise: Promise | null = null; @@ -63,50 +65,31 @@ export function clearToolsCache(): void { // ============================================ export function useUnifiedToolsMetadata() { - const [rawTools, setRawTools] = useState(cachedTools ?? []); - const [loading, setLoading] = useState(cachedTools === null); - const [error, setError] = useState(null); - - const refresh = useCallback(() => { - clearToolsCache(); - setLoading(true); - setError(null); - fetchToolsOnce() - .then((result) => { - setRawTools(result); - setLoading(false); - }) - .catch((err: unknown) => { - log.error("[Tools] Failed to list tools:", err); - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); - }); - }, []); - - useEffect(() => { - if (cachedTools !== null) { - return; + const loadTools = useCallback(async () => { + try { + return await fetchToolsOnce(); + } catch (error) { + log.error("[Tools] Failed to list tools:", error); + throw error; } - - let cancelled = false; - fetchToolsOnce() - .then((result) => { - if (!cancelled) { - setRawTools(result); - setLoading(false); - } - }) - .catch((err: unknown) => { - log.error("[Tools] Failed to list tools:", err); - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); - } - }); - return () => { - cancelled = true; - }; }, []); + const resource = useAsyncResource({ + fetcher: loadTools, + initialData: cachedTools ?? EMPTY_TOOLS, + initialStatus: cachedTools ? "ready" : "idle", + scopeKey: "unified-tools", + }); + const refreshResource = resource.refresh; - return { rawTools, loading, error, refresh }; + const refresh = useCallback(() => { + clearToolsCache(); + void refreshResource(); + }, [refreshResource]); + + return { + rawTools: resource.data, + loading: resource.loading, + error: resource.error, + refresh, + }; } diff --git a/src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents.ts b/src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents.ts index 179794444..9137a08cf 100644 --- a/src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents.ts +++ b/src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents.ts @@ -3,13 +3,14 @@ */ import { invoke } from "@tauri-apps/api/core"; import { useSetAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { autoDetectKey } from "@src/api/services/keyValidation"; import type { ModelType } from "@src/api/types/keys"; import Message from "@src/components/Message"; import type { AgentAction, AvailableAgent } from "@src/config/cliAgents"; +import { useAsyncResource } from "@src/hooks/async"; import { TerminalService } from "@src/services/terminal/TerminalService"; import { invalidateDepsAtom } from "@src/store/platform/systemDepsAtom"; @@ -18,41 +19,29 @@ export interface UseCliAgentsOptions { enabled?: boolean; } +const EMPTY_CLI_AGENTS: AvailableAgent[] = []; + export function useCliAgents({ enabled = true }: UseCliAgentsOptions = {}) { const { t } = useTranslation("settings"); - const [agents, setAgents] = useState([]); - // Start false so remounts triggered by `enabled` flips (e.g. the - // Integrations models tab toggling on navigation) don't paint a - // spinner before the IPC begins. `fetchAgents` below flips it true - // for the actual fetch window. - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [actionMap, setActionMap] = useState>({}); const executeInTerminal = TerminalService.execute; const invalidateDeps = useSetAtom(invalidateDepsAtom); - const fetchAgents = useCallback(async () => { - setLoading(true); - setError(null); - try { - const raw = await invoke("get_available_agents"); - const sorted = [...raw].sort((agentA, agentB) => { - const installedDiff = - Number(agentB.installed) - Number(agentA.installed); - if (installedDiff !== 0) return installedDiff; - return agentA.displayName.localeCompare(agentB.displayName); - }); - setAgents(sorted); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } + const loadAgents = useCallback(async () => { + const raw = await invoke("get_available_agents"); + return [...raw].sort((agentA, agentB) => { + const installedDiff = Number(agentB.installed) - Number(agentA.installed); + if (installedDiff !== 0) return installedDiff; + return agentA.displayName.localeCompare(agentB.displayName); + }); }, []); - - useEffect(() => { - if (enabled) fetchAgents(); - }, [enabled, fetchAgents]); + const resource = useAsyncResource({ + enabled, + fetcher: loadAgents, + initialData: EMPTY_CLI_AGENTS, + scopeKey: enabled ? "cli-agents" : null, + }); + const fetchAgents = resource.refresh; const handleInstall = useCallback( async (agentName: string, installCmd?: string) => { @@ -130,9 +119,9 @@ export function useCliAgents({ enabled = true }: UseCliAgentsOptions = {}) { ); return { - agents, - loading, - error, + agents: resource.data, + loading: resource.loading, + error: resource.error, actionMap, fetchAgents, handleInstall, diff --git a/src/modules/MainApp/Integrations/RulesMemoryEvolution/Memory/useWorkspaceMemoryStatus.ts b/src/modules/MainApp/Integrations/RulesMemoryEvolution/Memory/useWorkspaceMemoryStatus.ts index fe6a0fec4..3ea8aee21 100644 --- a/src/modules/MainApp/Integrations/RulesMemoryEvolution/Memory/useWorkspaceMemoryStatus.ts +++ b/src/modules/MainApp/Integrations/RulesMemoryEvolution/Memory/useWorkspaceMemoryStatus.ts @@ -13,10 +13,11 @@ * (`~/.orgii/personal/workspace/`) regardless of the active folder. */ import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { rpc } from "@src/api/tauri/rpc"; import type { WorkspaceMemoryStatus } from "@src/api/tauri/rpc/schemas/workspaceMemory"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { activeFolderAtom } from "@src/store/workspace/derived"; @@ -30,76 +31,51 @@ interface ResolvedWorkspace { function useWorkspacePath(scope: WorkspaceMemoryScope): ResolvedWorkspace { const activeFolder = useAtomValue(activeFolderAtom); - const [personalWs, setPersonalWs] = useState(null); + const fetchPersonalWorkspace = useCallback(async () => { + try { + return await rpc.agentOrgs.memory.personalWorkspace(); + } catch (error) { + log.warn( + "[useWorkspaceMemoryStatus] project_personal_workspace failed:", + error + ); + throw error; + } + }, []); + const personalWorkspace = useAsyncResource({ + enabled: scope === "personal", + fetcher: fetchPersonalWorkspace, + initialData: null, + scopeKey: scope === "personal" ? "personal-workspace" : null, + }); - useEffect(() => { - let cancelled = false; - if (scope !== "personal") return undefined; - rpc.agentOrgs.memory - .personalWorkspace() - .then((path) => { - if (!cancelled) setPersonalWs(path); - }) - .catch((err: unknown) => { - log.warn( - "[useWorkspaceMemoryStatus] project_personal_workspace failed:", - err - ); - }); - return () => { - cancelled = true; - }; - }, [scope]); - - if (scope === "personal") return { path: personalWs }; + if (scope === "personal") return { path: personalWorkspace.data }; return { path: activeFolder?.path ?? null }; } -function fetchStatus( - workspace: string, - onResult: (result: WorkspaceMemoryStatus) => void, - onDone: () => void, - signal: { cancelled: boolean } -): void { - rpc.workspaceMemory - .status({ workspace }) - .then((result: WorkspaceMemoryStatus) => { - if (!signal.cancelled) onResult(result); - }) - .catch((err: unknown) => { - log.warn("[WorkspaceMemoryStatus] fetch failed:", err); - }) - .finally(() => { - if (!signal.cancelled) onDone(); - }); -} - export function useWorkspaceMemoryStatus( scope: WorkspaceMemoryScope = "workspace" ) { const { path: workspace } = useWorkspacePath(scope); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); - - const refresh = useCallback(() => { - if (!workspace) return; - setLoading(true); - const signal = { cancelled: false }; - fetchStatus(workspace, setStatus, () => setLoading(false), signal); - }, [workspace]); - - useEffect(() => { - if (!workspace) return; - const signal = { cancelled: false }; - const timer = setTimeout(() => { - setLoading(true); - fetchStatus(workspace, setStatus, () => setLoading(false), signal); - }, 0); - return () => { - signal.cancelled = true; - clearTimeout(timer); - }; - }, [workspace]); + const fetchStatus = useCallback(async (workspacePath: string) => { + try { + return await rpc.workspaceMemory.status({ workspace: workspacePath }); + } catch (error) { + log.warn("[WorkspaceMemoryStatus] fetch failed:", error); + throw error; + } + }, []); + const statusResource = useAsyncResource({ + enabled: Boolean(workspace), + fetcher: fetchStatus, + initialData: null, + scopeKey: workspace, + }); - return { status, loading, workspace, refresh }; + return { + status: statusResource.data, + loading: statusResource.loading, + workspace, + refresh: statusResource.refresh, + }; } diff --git a/src/modules/MainApp/Integrations/hooks/lsp/useLspGlobalConfig.ts b/src/modules/MainApp/Integrations/hooks/lsp/useLspGlobalConfig.ts index a8d44266d..2f4b8fd52 100644 --- a/src/modules/MainApp/Integrations/hooks/lsp/useLspGlobalConfig.ts +++ b/src/modules/MainApp/Integrations/hooks/lsp/useLspGlobalConfig.ts @@ -8,8 +8,9 @@ * the corresponding UI lands. */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; const log = createLogger("useLspGlobalConfig"); @@ -47,42 +48,38 @@ const DEFAULT_CONFIG: GlobalLspConfig = { }; export function useLspGlobalConfig() { - const [config, setConfig] = useState(DEFAULT_CONFIG); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const loadConfig = useCallback(async () => { - setIsLoading(true); - setError(null); try { - const result = await invoke("lsp_get_global_config"); - setConfig(result); + return await invoke("lsp_get_global_config"); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); log.error("[useLspGlobalConfig] Failed to load config:", err); - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - loadConfig(); - }, [loadConfig]); - - const setAutoInstall = useCallback(async (enabled: boolean) => { - try { - await invoke("lsp_set_auto_install", { enabled }); - setConfig((prev) => ({ ...prev, autoInstall: enabled })); - } catch (err) { - log.error("[useLspGlobalConfig] Failed to set auto-install:", err); throw err; } }, []); + const configResource = useAsyncResource({ + fetcher: loadConfig, + initialData: DEFAULT_CONFIG, + scopeKey: "lsp-global-config", + }); + const setConfig = configResource.setData; + + const setAutoInstall = useCallback( + async (enabled: boolean) => { + try { + await invoke("lsp_set_auto_install", { enabled }); + setConfig((prev) => ({ ...prev, autoInstall: enabled })); + } catch (err) { + log.error("[useLspGlobalConfig] Failed to set auto-install:", err); + throw err; + } + }, + [setConfig] + ); return { - config, - isLoading, - error, + config: configResource.data, + isLoading: configResource.loading, + error: configResource.error, setAutoInstall, }; } diff --git a/src/modules/MainApp/Integrations/hooks/useChannelState.ts b/src/modules/MainApp/Integrations/hooks/useChannelState.ts index b580ef18a..f572ee0a2 100644 --- a/src/modules/MainApp/Integrations/hooks/useChannelState.ts +++ b/src/modules/MainApp/Integrations/hooks/useChannelState.ts @@ -5,7 +5,7 @@ * "Add channel" wizard open-state lives in the URL via * {@link useWizardParam} (`?wizard=channel-add`). */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -15,6 +15,7 @@ import { } from "@src/api/http/integrations"; import { toggleChannel } from "@src/api/tauri/agent"; import { WIZARD_IDS, buildWizardPath } from "@src/config/mainAppPaths"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { useWizardParam } from "@src/hooks/navigation"; import type { WizardCategory } from "@src/scaffold/WizardSystem/variants/Channel/channelWizardTypes"; @@ -41,6 +42,7 @@ import type { } from "../Connections/Channels"; const log = createLogger("integrations"); +const EMPTY_SYNC_CONNECTIONS: SyncConnection[] = []; function resolveConnectionStatus( enabled: boolean, @@ -90,54 +92,20 @@ export function useChannelState(options: UseChannelStateOptions = {}) { const [channelProbing, setChannelProbing] = useState(false); const [channelProbeResult, setChannelProbeResult] = useState(null); - const [projectConnections, setProjectConnections] = useState< - SyncConnection[] - >([]); - const [projectConnectionsLoading, setProjectConnectionsLoading] = - useState(false); - const [projectConnectionsError, setProjectConnectionsError] = useState< - string | null - >(null); const probeIdRef = useRef(0); - const refreshProjectConnections = useCallback(async () => { - setProjectConnectionsLoading(true); - setProjectConnectionsError(null); - try { - const connections = await syncConnectionsApi.list(); - setProjectConnections(connections); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setProjectConnectionsError(message); - throw err; - } finally { - setProjectConnectionsLoading(false); - } - }, []); - - useEffect(() => { - let cancelled = false; - setProjectConnectionsLoading(true); - setProjectConnectionsError(null); - syncConnectionsApi - .list() - .then((connections) => { - if (!cancelled) setProjectConnections(connections); - }) - .catch((err) => { - if (!cancelled) { - setProjectConnectionsError( - err instanceof Error ? err.message : String(err) - ); - } - }) - .finally(() => { - if (!cancelled) setProjectConnectionsLoading(false); - }); - return () => { - cancelled = true; - }; + const loadProjectConnections = useCallback(() => { + return syncConnectionsApi.list(); }, []); + const projectConnectionsResource = useAsyncResource({ + fetcher: loadProjectConnections, + initialData: EMPTY_SYNC_CONNECTIONS, + scopeKey: "project-connections", + }); + const projectConnections = projectConnectionsResource.data; + const projectConnectionsLoading = projectConnectionsResource.loading; + const projectConnectionsError = projectConnectionsResource.error; + const refreshProjectConnections = projectConnectionsResource.refresh; // ── Derived data ── const channelInstances = useMemo(() => { diff --git a/src/modules/WorkStation/Browser/hooks/useGlobalTokens.ts b/src/modules/WorkStation/Browser/hooks/useGlobalTokens.ts index 526f50f9d..3b19a818c 100644 --- a/src/modules/WorkStation/Browser/hooks/useGlobalTokens.ts +++ b/src/modules/WorkStation/Browser/hooks/useGlobalTokens.ts @@ -10,8 +10,12 @@ */ import { invoke } from "@tauri-apps/api/core"; import { useSetAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useMemo } from "react"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { type TokenDefinition, @@ -129,49 +133,55 @@ export function useGlobalTokens( options: UseGlobalTokensOptions = {} ): UseGlobalTokensReturn { const { repoPath, autoScan = true, maxDepth = 5 } = options; - - const [tokens, setTokens] = useState([]); - const [categories, setCategories] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // Update global atom when tokens change const setScannedTokens = useSetAtom(scannedTokensAtom); - /** - * Scan repo for token definitions - */ + const fetchTokens = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext + ) => { + const scope = JSON.parse(serializedScope) as { + maxDepth: number; + repoPath: string; + }; + try { + const result = await invoke( + "scan_global_tokens", + { + repoPath: scope.repoPath, + maxDepth: scope.maxDepth, + } + ); + if (context.isCurrent()) setScannedTokens(result.tokens); + return result.tokens; + } catch (error) { + log.error( + "[useGlobalTokens] Scan failed:", + error instanceof Error ? error.message : String(error) + ); + throw error; + } + }, + [setScannedTokens] + ); + const scopeKey = repoPath ? JSON.stringify({ maxDepth, repoPath }) : null; + const resource = useAsyncResource({ + autoLoad: autoScan, + enabled: Boolean(scopeKey), + fetcher: fetchTokens, + initialData: [], + scopeKey, + }); + const tokens = resource.data; + const categories = useMemo(() => categorizeTokens(tokens), [tokens]); + const refreshTokens = resource.refresh; const scan = useCallback(async () => { if (!repoPath) { log.warn("[useGlobalTokens] No repo path provided"); return; } - - setLoading(true); - setError(null); - - try { - const result = await invoke( - "scan_global_tokens", - { - repoPath, - maxDepth, - } - ); - - setTokens(result.tokens); - setCategories(categorizeTokens(result.tokens)); - - // Update global token cache - setScannedTokens(result.tokens); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - log.error("[useGlobalTokens] Scan failed:", message); - setError(message); - } finally { - setLoading(false); - } - }, [repoPath, maxDepth, setScannedTokens]); + await refreshTokens(); + }, [refreshTokens, repoPath]); /** * Search tokens by name @@ -221,18 +231,11 @@ export function useGlobalTokens( [tokens] ); - // Auto-scan on mount - useEffect(() => { - if (autoScan && repoPath) { - scan(); - } - }, [autoScan, repoPath, scan]); - return { tokens, categories, - loading, - error, + loading: resource.loading, + error: resource.error, scan, search, getToken, diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderConfig.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderConfig.ts index 1bd38cecd..299254837 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderConfig.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderConfig.ts @@ -4,8 +4,6 @@ * Fetches provider configuration from Rust backend (single source of truth). * Caches configs in memory for the session duration. */ -import { useEffect, useState } from "react"; - import { rpc } from "@src/api/tauri/rpc"; import type { ProviderConfig, @@ -13,6 +11,7 @@ import type { ProviderProtocol, } from "@src/api/tauri/rpc/schemas/validation"; import type { ModelType } from "@src/api/types/keys"; +import { useAsyncResource } from "@src/hooks/async"; // ============================================ // Cache @@ -25,13 +24,20 @@ async function loadAllConfigs(): Promise> { if (configCache) return configCache; if (loadingPromise) return loadingPromise; - loadingPromise = rpc.validation.getAllProviderConfigs().then((result) => { + const promise = rpc.validation.getAllProviderConfigs().then((result) => { configCache = result; - loadingPromise = null; return result; }); - - return loadingPromise; + loadingPromise = promise; + void promise.then( + () => { + if (loadingPromise === promise) loadingPromise = null; + }, + () => { + if (loadingPromise === promise) loadingPromise = null; + } + ); + return promise; } // ============================================ @@ -87,36 +93,15 @@ export function useProviderConfig(modelType: ModelType | undefined): { loading: boolean; error: string | null; } { - const [allConfigs, setAllConfigs] = useState | null>(configCache); - const [loading, setLoading] = useState(!configCache); - const [error, setError] = useState(null); - - useEffect(() => { - // Already have cached data - no need to fetch - if (configCache) return; - - let cancelled = false; - loadAllConfigs() - .then((result) => { - if (!cancelled) { - setAllConfigs(result); - setLoading(false); - } - }) - .catch((err) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, []); + const resource = useAsyncResource | null>({ + fetcher: loadAllConfigs, + initialData: configCache, + initialStatus: configCache ? "ready" : "idle", + scopeKey: "provider-configs", + }); + const allConfigs = resource.data; + const loading = resource.loading; + const error = resource.error; if (!modelType || loading) { return { config: null, loading, error }; diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts index 5c77e04f4..4442c907c 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts @@ -6,7 +6,7 @@ */ import type { TFunction } from "i18next"; import { useSetAtom } from "jotai"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { rpc } from "@src/api/tauri/rpc"; @@ -16,6 +16,7 @@ import type { ProviderProtocol, } from "@src/api/tauri/rpc/schemas/validation"; import { LOCAL_MODEL_PROVIDER } from "@src/api/types/keys"; +import { useAsyncResource } from "@src/hooks/async"; import { agentRegistryAtom } from "@src/store/session/agentRegistryAtom"; // ============================================ @@ -197,16 +198,23 @@ async function loadRegistry(): Promise { if (registryCache) return registryCache; if (loadingPromise) return loadingPromise; - loadingPromise = Promise.all([ + const promise = Promise.all([ rpc.validation.getAvailableAgents(), rpc.validation.getAvailableApiProviders(), ]).then(([agents, apiProviders]) => { registryCache = { agents, apiProviders }; - loadingPromise = null; return registryCache; }); - - return loadingPromise; + loadingPromise = promise; + void promise.then( + () => { + if (loadingPromise === promise) loadingPromise = null; + }, + () => { + if (loadingPromise === promise) loadingPromise = null; + } + ); + return promise; } // ============================================ @@ -513,53 +521,27 @@ export function useProviderRegistry( ): UseProviderRegistryResult { const { primaryOnly = false } = options; const { t } = useTranslation("integrations"); - const [data, setData] = useState(registryCache); - const [loading, setLoading] = useState(!registryCache); - const [error, setError] = useState(null); const setAgentRegistry = useSetAtom(agentRegistryAtom); + const resource = useAsyncResource({ + fetcher: loadRegistry, + initialData: registryCache, + initialStatus: registryCache ? "ready" : "idle", + scopeKey: "provider-registry", + }); + const data = resource.data; + const refreshResource = resource.refresh; useEffect(() => { - if (registryCache) { - setAgentRegistry(registryCache); - return; + if (resource.status === "ready" && data) { + setAgentRegistry(data); } + }, [data, resource.status, setAgentRegistry]); - let cancelled = false; - loadRegistry() - .then((result) => { - if (!cancelled) { - setData(result); - setAgentRegistry(result); - setLoading(false); - } - }) - .catch((err) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [setAgentRegistry]); - - const reload = async () => { + const reload = useCallback(async () => { registryCache = null; loadingPromise = null; - setLoading(true); - setError(null); - try { - const result = await loadRegistry(); - setData(result); - setAgentRegistry(result); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }; + await refreshResource(); + }, [refreshResource]); const unifiedProviders = useMemo(() => { if (!data) return []; @@ -578,8 +560,8 @@ export function useProviderRegistry( apiProviders: [], unifiedProviders: [], modelTypeToProviderKey: {}, - loading, - error, + loading: resource.loading, + error: resource.error, reload, }; } @@ -589,8 +571,8 @@ export function useProviderRegistry( apiProviders: data.apiProviders, unifiedProviders, modelTypeToProviderKey, - loading, - error, + loading: resource.loading, + error: resource.error, reload, }; } From a3743503103d9091167bb8430d4e28f99f895f24 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 23 Jul 2026 22:31:45 +0800 Subject: [PATCH 6/8] refactor(projects): fence scoped loader results Pre-commit hook ran. Total eslint: 10, total circular: 0 --- .../ChatPanel/panels/ProjectPanelView.tsx | 81 +-- src/hooks/git/useFileHistory.ts | 126 ++-- src/hooks/git/useOrgtrackFileTimeline.ts | 55 +- .../MainApp/Inbox/hooks/useCommitFiles.ts | 147 +++-- .../useGitHubWorkItemsLoadLifecycle.ts | 239 ++++---- .../LinearProjects/useLinearIndexData.tsx | 189 +++--- .../hooks/useProjectOrgCatalogData.ts | 166 ++--- .../hooks/useProjectData/useProjectData.ts | 275 ++++----- .../useProjectData/useProjectDataFile.ts | 2 +- .../hooks/useGitWorktrees.ts | 86 +-- .../hooks/useStashState.ts | 84 +-- .../hooks/useWorkstationIssues.ts | 568 +++++++++++------- 12 files changed, 1073 insertions(+), 945 deletions(-) diff --git a/src/engines/ChatPanel/panels/ProjectPanelView.tsx b/src/engines/ChatPanel/panels/ProjectPanelView.tsx index 7562c4f77..d08436b1e 100644 --- a/src/engines/ChatPanel/panels/ProjectPanelView.tsx +++ b/src/engines/ChatPanel/panels/ProjectPanelView.tsx @@ -25,6 +25,7 @@ import { usePublishChatPanelHeader } from "@src/engines/ChatPanel/header"; import KanbanBoard from "@src/features/KanbanBoard"; import type { KanbanTask, TaskStatus } from "@src/features/KanbanBoard"; import { allocateCloudAwareWorkItemId } from "@src/features/Org2Cloud/cloudShortId"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { useCurrentUserMemberIds, @@ -74,6 +75,16 @@ interface ProjectPanelViewProps { const PROJECT_PANEL_TABS: ProjectPanelTab[] = ["overview", "list", "kanban"]; +interface ProjectWorkItemsResource { + shortIds: Map; + workItems: WorkItem[]; +} + +const EMPTY_PROJECT_WORK_ITEMS: ProjectWorkItemsResource = { + shortIds: new Map(), + workItems: [], +}; + function getProjectOverviewDescription( project: ChatPanelSelectedProject["project"] ) { @@ -100,12 +111,6 @@ export const ProjectPanelView: React.FC = ({ const [projectBodyLoading, setProjectBodyLoading] = useState(false); const [projectBodyError, setProjectBodyError] = useState(null); const lastSavedDescriptionRef = useRef(sidebarProjectDescription); - const [workItems, setWorkItems] = useState([]); - const [workItemShortIds, setWorkItemShortIds] = useState>( - new Map() - ); - const [workItemsLoading, setWorkItemsLoading] = useState(false); - const [workItemsError, setWorkItemsError] = useState(null); const [projectSyncAdapter, setProjectSyncAdapter] = useState<{ projectSlug: string; adapterId: string | null; @@ -241,34 +246,31 @@ export const ProjectPanelView: React.FC = ({ }; }, [projectSlug, selectedProject.project.id, sidebarProjectDescription]); - const loadProjectWorkItems = useCallback(async () => { - if (!projectSlug) { - setWorkItems([]); - setWorkItemShortIds(new Map()); - return; - } - - setWorkItemsLoading(true); - setWorkItemsError(null); - try { - const viewData = await projectApi.readWorkItemsViewData(projectSlug); - setWorkItemShortIds( - new Map(viewData.items.map((item) => [item.id, item.shortId])) - ); - setWorkItems(viewData.items.map(enrichedWorkItemToUI)); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to load work items"; - logger.error("Failed to load project work items:", error); - setWorkItemsError(message); - } finally { - setWorkItemsLoading(false); - } - }, [projectSlug]); - - useEffect(() => { - void loadProjectWorkItems(); - }, [loadProjectWorkItems]); + const fetchProjectWorkItems = useCallback( + async (scopeProjectSlug: string) => { + const viewData = await projectApi.readWorkItemsViewData(scopeProjectSlug); + return { + shortIds: new Map( + viewData.items.map((item) => [item.id, item.shortId]) + ), + workItems: viewData.items.map(enrichedWorkItemToUI), + }; + }, + [] + ); + const workItemsResource = useAsyncResource({ + enabled: Boolean(projectSlug), + fetcher: fetchProjectWorkItems, + initialData: EMPTY_PROJECT_WORK_ITEMS, + scopeKey: projectSlug || null, + }); + const { + data: { shortIds: workItemShortIds, workItems }, + error: workItemsError, + loading: workItemsLoading, + refresh: loadProjectWorkItems, + setData: setWorkItemsData, + } = workItemsResource; useProjectDataChanged( useCallback(() => { @@ -489,13 +491,14 @@ export const ProjectPanelView: React.FC = ({ payload ); const updatedItem = enrichedWorkItemToUI(updated); - setWorkItems((currentItems) => - currentItems.map((item) => + setWorkItemsData((current) => ({ + ...current, + workItems: current.workItems.map((item) => item.session_id === workItemId ? updatedItem : item - ) - ); + ), + })); }, - [getWorkItemShortId, projectSlug] + [getWorkItemShortId, projectSlug, setWorkItemsData] ); const handleAddKanbanTask = useCallback( diff --git a/src/hooks/git/useFileHistory.ts b/src/hooks/git/useFileHistory.ts index f0ae0626c..891db4bde 100644 --- a/src/hooks/git/useFileHistory.ts +++ b/src/hooks/git/useFileHistory.ts @@ -3,9 +3,13 @@ * * Fetches Git commit history for a specific file using the Rust Git API. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { type GitCommitInfo, getGitCommits } from "@src/api/http/git"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; export interface UseFileHistoryOptions { /** Repository ID */ @@ -35,6 +39,16 @@ export interface UseFileHistoryResult { totalCount: number | null; } +interface FileHistoryData { + commits: GitCommitInfo[]; + totalCount: number | null; +} + +const EMPTY_FILE_HISTORY: FileHistoryData = { + commits: [], + totalCount: null, +}; + /** * Hook to fetch and manage file commit history */ @@ -46,69 +60,63 @@ export function useFileHistory({ onSuccess, onError, }: UseFileHistoryOptions): UseFileHistoryResult { - const [commits, setCommits] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [totalCount, setTotalCount] = useState(null); - - // Callback props are mirrored into refs so `refresh` stays stable. Keeping - // them in the dep array meant any caller passing an inline arrow rebuilt - // `refresh` every render, and the autoLoad effect below — keyed on - // `refresh` — would then re-issue GET /commits on every render. const onSuccessRef = useRef(onSuccess); - onSuccessRef.current = onSuccess; const onErrorRef = useRef(onError); - onErrorRef.current = onError; - - const refresh = useCallback(async () => { - // Don't fetch if no file is selected - if (!filePath) { - setCommits([]); - setTotalCount(null); - return; - } - - setLoading(true); - setError(null); - - try { - const result = await getGitCommits({ - repo_id: repoId, - file_path: filePath, - limit, - }); + useEffect(() => { + onSuccessRef.current = onSuccess; + onErrorRef.current = onError; + }, [onError, onSuccess]); - if (result) { - setCommits(result.commits); - setTotalCount(result.total_count); - onSuccessRef.current?.(result.commits); - } else { - setCommits([]); - setTotalCount(null); + const fetchHistory = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext + ): Promise => { + const scope = JSON.parse(serializedScope) as { + filePath: string; + limit: number; + repoId: string; + }; + try { + const result = await getGitCommits({ + repo_id: scope.repoId, + file_path: scope.filePath, + limit: scope.limit, + }); + const data = result + ? { commits: result.commits, totalCount: result.total_count } + : EMPTY_FILE_HISTORY; + if (context.isCurrent()) { + onSuccessRef.current?.(data.commits); + } + return data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (context.isCurrent()) { + context.publish(EMPTY_FILE_HISTORY); + onErrorRef.current?.(message); + } + throw error; } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - setError(errorMessage); - setCommits([]); - setTotalCount(null); - onErrorRef.current?.(errorMessage); - } finally { - setLoading(false); - } - }, [repoId, filePath, limit]); - - // Auto-load on mount or when dependencies change - useEffect(() => { - if (autoLoad) { - refresh(); - } - }, [autoLoad, refresh]); + }, + [] + ); + const scopeKey = filePath + ? JSON.stringify({ filePath, limit, repoId }) + : null; + const resource = useAsyncResource({ + autoLoad, + enabled: Boolean(scopeKey), + fetcher: fetchHistory, + initialData: EMPTY_FILE_HISTORY, + scopeKey, + }); return { - commits, - loading, - error, - refresh, - totalCount, + commits: resource.data.commits, + loading: resource.loading, + error: resource.error, + refresh: resource.refresh, + totalCount: resource.data.totalCount, }; } diff --git a/src/hooks/git/useOrgtrackFileTimeline.ts b/src/hooks/git/useOrgtrackFileTimeline.ts index 5cf57509e..c087234fe 100644 --- a/src/hooks/git/useOrgtrackFileTimeline.ts +++ b/src/hooks/git/useOrgtrackFileTimeline.ts @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { type OrgtrackFileTimeline, getOrgtrackFileTimeline, } from "@src/api/tauri/lineage"; +import { useAsyncResource } from "@src/hooks/async"; export interface UseOrgtrackFileTimelineOptions { repoPath: string; @@ -23,33 +24,27 @@ export function useOrgtrackFileTimeline({ filePath, autoLoad = true, }: UseOrgtrackFileTimelineOptions): UseOrgtrackFileTimelineResult { - const [timeline, setTimeline] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - if (!filePath || !repoPath) { - setTimeline(null); - return; - } - - setLoading(true); - setError(null); - try { - setTimeline(await getOrgtrackFileTimeline({ repoPath, filePath })); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setTimeline(null); - } finally { - setLoading(false); - } - }, [filePath, repoPath]); - - useEffect(() => { - if (autoLoad) { - void refresh(); - } - }, [autoLoad, refresh]); - - return { timeline, loading, error, refresh }; + const fetchTimeline = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + filePath: string; + repoPath: string; + }; + return getOrgtrackFileTimeline(scope); + }, []); + const scopeKey = + filePath && repoPath ? JSON.stringify({ filePath, repoPath }) : null; + const resource = useAsyncResource({ + autoLoad, + enabled: Boolean(scopeKey), + fetcher: fetchTimeline, + initialData: null, + scopeKey, + }); + + return { + timeline: resource.data, + loading: resource.loading, + error: resource.error, + refresh: resource.refresh, + }; } diff --git a/src/modules/MainApp/Inbox/hooks/useCommitFiles.ts b/src/modules/MainApp/Inbox/hooks/useCommitFiles.ts index 3846c1a64..ae5073fb8 100644 --- a/src/modules/MainApp/Inbox/hooks/useCommitFiles.ts +++ b/src/modules/MainApp/Inbox/hooks/useCommitFiles.ts @@ -6,13 +6,14 @@ * Caches results by commit SHA to avoid re-fetching. */ import { useAtomValue } from "jotai"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useRef } from "react"; import { getGitCommitDiff } from "@src/api/http/git/diff"; import type { CommitDiffResult, GitFileDiffStatus, } from "@src/api/http/git/types"; +import { useAsyncResource } from "@src/hooks/async"; import { selectedRepoIdAtom, selectedRepoPathAtom } from "@src/store/repo"; import { decodeOctalPath } from "@src/util/file/pathUtils"; @@ -31,91 +32,85 @@ interface UseCommitFilesResult { const MAX_CACHE_SIZE = 50; +interface CommitFilesData { + files: CommitFileInfo[]; + totalStats: { additions: number; deletions: number } | null; +} + +const EMPTY_COMMIT_FILES: CommitFilesData = { + files: [], + totalStats: null, +}; + +function mapCommitDiff(result: CommitDiffResult): CommitFilesData { + return { + files: result.files.map((file) => ({ + path: decodeOctalPath(file.file_path), + status: file.status, + additions: file.insertions ?? 0, + deletions: file.deletions ?? 0, + })), + totalStats: { + additions: result.stats?.insertions ?? 0, + deletions: result.stats?.deletions ?? 0, + }, + }; +} + export function useCommitFiles(messageId: string): UseCommitFilesResult { const selectedRepoId = useAtomValue(selectedRepoIdAtom); const selectedRepoPath = useAtomValue(selectedRepoPathAtom); - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(false); - const [totalStats, setTotalStats] = useState<{ - additions: number; - deletions: number; - } | null>(null); - const cacheRef = useRef>(new Map()); const isCommit = messageId.startsWith("git-commit-"); const commitSha = isCommit ? messageId.replace("git-commit-", "") : null; - useEffect(() => { - if (!commitSha || !selectedRepoId) { - setFiles([]); - setTotalStats(null); - return; - } + const fetchCommitFiles = useCallback(async (serializedScope: string) => { + const cached = cacheRef.current.get(serializedScope); + if (cached) return mapCommitDiff(cached); - // Check cache - const cached = cacheRef.current.get(commitSha); - if (cached) { - setFiles( - cached.files.map((file) => ({ - path: decodeOctalPath(file.file_path), - status: file.status, - additions: file.insertions ?? 0, - deletions: file.deletions ?? 0, - })) - ); - setTotalStats({ - additions: cached.stats?.insertions ?? 0, - deletions: cached.stats?.deletions ?? 0, + const scope = JSON.parse(serializedScope) as { + commitSha: string; + repoId: string; + repoPath: string | null; + }; + try { + const result = await getGitCommitDiff({ + repo_id: scope.repoId, + repo_path: scope.repoPath || undefined, + commit_sha: scope.commitSha, + context_lines: 0, }); - return; - } + if (!result) return EMPTY_COMMIT_FILES; - let cancelled = false; - setLoading(true); - - const fetchDiff = async () => { - try { - const result = await getGitCommitDiff({ - repo_id: selectedRepoId, - repo_path: selectedRepoPath || undefined, - commit_sha: commitSha, - context_lines: 0, // We only need stats, not full diff - }); - if (cancelled || !result) return; - - // Cache with eviction - if (cacheRef.current.size >= MAX_CACHE_SIZE) { - const firstKey = cacheRef.current.keys().next().value; - if (firstKey) cacheRef.current.delete(firstKey); - } - cacheRef.current.set(commitSha, result); - - setFiles( - result.files.map((file) => ({ - path: decodeOctalPath(file.file_path), - status: file.status, - additions: file.insertions ?? 0, - deletions: file.deletions ?? 0, - })) - ); - setTotalStats({ - additions: result.stats?.insertions ?? 0, - deletions: result.stats?.deletions ?? 0, - }); - } catch { - // Silently ignore — commit may not be accessible - } finally { - if (!cancelled) setLoading(false); + if (cacheRef.current.size >= MAX_CACHE_SIZE) { + const firstKey = cacheRef.current.keys().next().value; + if (firstKey) cacheRef.current.delete(firstKey); } - }; - - fetchDiff(); - - return () => { - cancelled = true; - }; - }, [commitSha, selectedRepoId, selectedRepoPath]); - - return { files, loading, totalStats }; + cacheRef.current.set(serializedScope, result); + return mapCommitDiff(result); + } catch { + return EMPTY_COMMIT_FILES; + } + }, []); + const scopeKey = + commitSha && selectedRepoId + ? JSON.stringify({ + commitSha, + repoId: selectedRepoId, + repoPath: selectedRepoPath, + }) + : null; + const resource = useAsyncResource({ + enabled: Boolean(scopeKey), + fetcher: fetchCommitFiles, + initialData: EMPTY_COMMIT_FILES, + scopeKey, + }); + + return { + files: resource.data.files, + loading: resource.loading, + totalStats: resource.data.totalStats, + }; } diff --git a/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts b/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts index f76333813..49d0e6775 100644 --- a/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts +++ b/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo } from "react"; import { getGitRemotes } from "@src/api/http/git/remotes"; import { @@ -10,6 +10,10 @@ import type { OpenPRItem, PullRequestListState, } from "@src/api/tauri/github"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { coalesceGitHubListRequest, getCachedIssues, @@ -87,6 +91,28 @@ export const EMPTY_REPO_PRS: RepoPrState = { closedError: null, }; +interface GitHubWorkItemsLoadData { + loadError: string | null; + repoIssueMap: Record; + repoPrMap: Record; + repoSources: GitHubRepoSource[]; +} + +interface GitHubWorkItemsLoadRequest { + issueStates: GitHubIssuePageState[]; + prStates: PullRequestListState[]; + refreshNonce: number; + repos: Repo[]; + scope: Extract; +} + +const EMPTY_GITHUB_WORK_ITEMS_LOAD_DATA: GitHubWorkItemsLoadData = { + loadError: null, + repoIssueMap: {}, + repoPrMap: {}, + repoSources: [], +}; + export function getRepoIssueMapKey(source: GitHubRepoSource): string { return source.repoFullName; } @@ -242,137 +268,148 @@ export function useGitHubWorkItemsLoadLifecycle({ prStates: PullRequestListState[]; refreshNonce: number; }) { - const [repoSources, setRepoSources] = useState([]); - const [repoIssueMap, setRepoIssueMap] = useState< - Record - >({}); - const [repoPrMap, setRepoPrMap] = useState>({}); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const handledRefreshNonceRef = useRef(0); const gitRepos = useMemo( () => repos.filter((repo) => repo.kind === REPO_KIND.GIT && repo.path), [repos] ); - - useEffect(() => { - let cancelled = false; - const forceRefresh = refreshNonce !== handledRefreshNonceRef.current; - handledRefreshNonceRef.current = refreshNonce; - void (async () => { - setLoading(true); - setLoadError(null); + const scopeKey = JSON.stringify({ + issueStates, + prStates, + refreshNonce, + repos: gitRepos, + scope, + } satisfies GitHubWorkItemsLoadRequest); + const loadWorkItems = useCallback( + async ( + serializedRequest: string, + context: AsyncResourceFetchContext + ) => { + const request = JSON.parse( + serializedRequest + ) as GitHubWorkItemsLoadRequest; const resolvedSources = ( - await Promise.all(gitRepos.map(resolveGitHubRepoSource)) + await Promise.all(request.repos.map(resolveGitHubRepoSource)) ).filter((source): source is GitHubRepoSource => Boolean(source)); - if (cancelled) return; - setRepoSources(resolvedSources); - setRepoIssueMap( - scope === "issue" - ? Object.fromEntries( - resolvedSources.map((source) => [ - getRepoIssueMapKey(source), - getCachedRepoIssues(source), - ]) - ) - : {} - ); - setRepoPrMap( - scope === "pr" - ? Object.fromEntries( - resolvedSources.map((source) => [ - getRepoIssueMapKey(source), - getCachedRepoPrs(source), - ]) - ) - : {} - ); - if (resolvedSources.length === 0) { - setLoading(false); - return; - } + const cachedData: GitHubWorkItemsLoadData = { + loadError: null, + repoIssueMap: + request.scope === "issue" + ? Object.fromEntries( + resolvedSources.map((source) => [ + getRepoIssueMapKey(source), + getCachedRepoIssues(source), + ]) + ) + : {}, + repoPrMap: + request.scope === "pr" + ? Object.fromEntries( + resolvedSources.map((source) => [ + getRepoIssueMapKey(source), + getCachedRepoPrs(source), + ]) + ) + : {}, + repoSources: resolvedSources, + }; + context.publish(cachedData, { keepLoading: true }); + if (resolvedSources.length === 0) return cachedData; + + const forceRefresh = + context.cause === "refresh" || request.refreshNonce > 0; const [issueResults, prResults] = await Promise.all([ - scope === "issue" + request.scope === "issue" ? Promise.all( resolvedSources.map((source) => - loadRepoIssues(source, issueStates, forceRefresh) + loadRepoIssues(source, request.issueStates, forceRefresh) ) ) : Promise.resolve([]), - scope === "pr" + request.scope === "pr" ? Promise.all( resolvedSources.flatMap((source) => - prStates.map((state) => + request.prStates.map((state) => loadRepoPrs(source, state, forceRefresh) ) ) ) : Promise.resolve([]), ]); - if (cancelled) return; - if (scope === "issue") { - setRepoIssueMap( - Object.fromEntries( - issueResults.map(({ source, error: _error, ...state }) => [ - getRepoIssueMapKey(source), - state, - ]) - ) - ); - } else { - setRepoPrMap((current) => { - const next = { ...current }; - for (const result of prResults) { - const key = getRepoIssueMapKey(result.source); - const currentState = next[key] ?? EMPTY_REPO_PRS; - next[key] = - result.state === "open" - ? { - ...currentState, - openPrs: result.prs, - openLoaded: result.loaded, - openError: result.error, - } - : { - ...currentState, - closedPrs: result.prs, - closedLoaded: result.loaded, - closedError: result.error, - }; - } - return next; - }); + + const repoIssueMap = + request.scope === "issue" + ? Object.fromEntries( + issueResults.map(({ source, error: _error, ...state }) => [ + getRepoIssueMapKey(source), + state, + ]) + ) + : {}; + const repoPrMap: Record = {}; + if (request.scope === "pr") { + for (const result of prResults) { + const key = getRepoIssueMapKey(result.source); + const currentState = repoPrMap[key] ?? EMPTY_REPO_PRS; + repoPrMap[key] = + result.state === "open" + ? { + ...currentState, + openPrs: result.prs, + openLoaded: result.loaded, + openError: result.error, + } + : { + ...currentState, + closedPrs: result.prs, + closedLoaded: result.loaded, + closedError: result.error, + }; + } } - setLoadError( - issueResults.find((result) => result.error)?.error ?? + return { + loadError: + issueResults.find((result) => result.error)?.error ?? prResults.find((result) => result.error)?.error ?? - null - ); - setLoading(false); - })(); - return () => { - cancelled = true; - }; - }, [gitRepos, issueStates, prStates, refreshNonce, scope]); + null, + repoIssueMap, + repoPrMap, + repoSources: resolvedSources, + }; + }, + [] + ); + const resource = useAsyncResource({ + fetcher: loadWorkItems, + initialData: EMPTY_GITHUB_WORK_ITEMS_LOAD_DATA, + scopeKey, + }); + const setLoadData = resource.setData; const updateIssueMap = useCallback( ( update: ( current: Record ) => Record - ) => setRepoIssueMap(update), - [] + ) => + setLoadData((current) => ({ + ...current, + repoIssueMap: update(current.repoIssueMap), + })), + [setLoadData] + ); + const setListError = useCallback( + (error: string | null) => { + setLoadData((current) => ({ ...current, loadError: error })); + }, + [setLoadData] ); - const setListError = useCallback((error: string | null) => { - setLoadError(error); - }, []); return { - repoSources, - repoIssueMap, - repoPrMap, - loading, - loadError, + repoSources: resource.data.repoSources, + repoIssueMap: resource.data.repoIssueMap, + repoPrMap: resource.data.repoPrMap, + loading: resource.loading, + loadError: resource.error ?? resource.data.loadError, updateIssueMap, setListError, }; diff --git a/src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx b/src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx index 2886af712..7226ccec1 100644 --- a/src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx +++ b/src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx @@ -8,7 +8,7 @@ * - Expose groupMode state and derived Project groupings for the projects list */ import { CalendarClock, Circle, Flag } from "lucide-react"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -18,6 +18,10 @@ import { } from "@src/api/http/integrations"; import type { LinearProjectSummary } from "@src/api/http/integrations"; import type { SelectOption } from "@src/components/Select"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import type { StatusFilterType } from "@src/modules/ProjectManager/WorkItems/types"; import { countWorkItemsByStatus, @@ -75,6 +79,22 @@ const TARGET_DATE_GROUPS = [ type TargetDateGroup = (typeof TARGET_DATE_GROUPS)[number]; +interface LinearIndexResource { + projects: LinearProjectSummary[]; + workItems: WorkItem[]; +} + +interface LinearIndexScope { + connectionId: string; + surface: "projects" | "work-items"; + teamId?: string; +} + +const EMPTY_LINEAR_INDEX: LinearIndexResource = { + projects: [], + workItems: [], +}; + // ============================================ // Helpers (pure) // ============================================ @@ -164,132 +184,108 @@ export function useLinearIndexData({ const { t } = useTranslation(["projects", "common"]); // ---- Connection discovery ---- - const [defaultConnectionId, setDefaultConnectionId] = useState( - null - ); - const [loadingConnections, setLoadingConnections] = useState(false); - const [connectionLoadError, setConnectionLoadError] = useState( - null - ); - - useEffect(() => { - if (connectionId) return; - - let cancelled = false; - setLoadingConnections(true); - setConnectionLoadError(null); - syncConnectionsApi - .list() - .then((connections) => { - if (cancelled) return; - const linearConnection = connections.find( - (connection) => connection.adapter_id === STORY_SYNC_ADAPTER.LINEAR - ); - setDefaultConnectionId(linearConnection?.id ?? null); - }) - .catch((error: unknown) => { - if (cancelled) return; - const message = error instanceof Error ? error.message : String(error); - setDefaultConnectionId(null); - setConnectionLoadError(message); - }) - .finally(() => { - if (!cancelled) setLoadingConnections(false); - }); - - return () => { - cancelled = true; - }; - }, [connectionId]); + const discoverDefaultConnection = useCallback(async () => { + const connections = await syncConnectionsApi.list(); + return ( + connections.find( + (connection) => connection.adapter_id === STORY_SYNC_ADAPTER.LINEAR + )?.id ?? null + ); + }, []); + const connectionResource = useAsyncResource({ + enabled: !connectionId, + fetcher: discoverDefaultConnection, + initialData: null, + scopeKey: connectionId ? null : "linear-default-connection", + }); + const defaultConnectionId = connectionResource.data; + const loadingConnections = connectionResource.loading; + const connectionLoadError = connectionResource.error; const effectiveConnectionId = connectionId ?? defaultConnectionId ?? undefined; // ---- Index data (projects + optional issues) ---- - const [indexProjects, setIndexProjects] = useState( - [] - ); - const [indexWorkItems, setIndexWorkItems] = useState([]); - const [indexLoading, setIndexLoading] = useState(false); - const [indexLoaded, setIndexLoaded] = useState(false); - const [indexError, setIndexError] = useState(null); const [indexStatusFilter, setIndexStatusFilter] = useState("all"); - const loadIndexData = useCallback( + const fetchIndexData = useCallback( async ( - cancelled?: () => boolean, - options: { forceRefresh?: boolean } = {} + serializedScope: string, + context: AsyncResourceFetchContext ) => { - if (!effectiveConnectionId || projectId) return; - - setIndexLoading(true); - setIndexLoaded(false); - setIndexError(null); + const scope = JSON.parse(serializedScope) as LinearIndexScope; + const forceRefresh = context.cause === "refresh"; try { const projectsResult = await cachedLinearProjectsApi.listProjects( - effectiveConnectionId, - { forceRefresh: options.forceRefresh } + scope.connectionId, + { forceRefresh } ); - if (cancelled?.()) return; - const visibleProjects = teamId + const projects = scope.teamId ? projectsResult.projects.filter((linearProject) => - linearProject.teams.some((team) => team.id === teamId) + linearProject.teams.some((team) => team.id === scope.teamId) ) : projectsResult.projects; - setIndexProjects(visibleProjects); - if (surface === "work-items") { + if (scope.surface === "work-items") { const issueResults = await Promise.all( - visibleProjects.map((linearProject) => + projects.map((linearProject) => cachedLinearProjectsApi.listProjectIssues( - effectiveConnectionId, + scope.connectionId, linearProject.id, - { forceRefresh: options.forceRefresh } + { forceRefresh } ) ) ); - if (cancelled?.()) return; - setIndexWorkItems( - issueResults.flatMap((result, resultIndex) => { - const linearProject = visibleProjects[resultIndex]; + return { + projects, + workItems: issueResults.flatMap((result, resultIndex) => { + const linearProject = projects[resultIndex]; return result.issues.map((issue) => linearIssueToWorkItem(issue, linearProject) ); - }) - ); - return; + }), + }; } - setIndexWorkItems([]); + return { projects, workItems: [] }; } catch (error: unknown) { - if (cancelled?.()) return; - setIndexProjects([]); - setIndexWorkItems([]); - setIndexError( + throw new Error( errorMessage(error, t("linearProjects.errors.loadProjects")) ); - } finally { - if (!cancelled?.()) { - setIndexLoaded(true); - setIndexLoading(false); - } } }, - [effectiveConnectionId, projectId, surface, teamId, t] + [t] ); - useEffect(() => { - let cancelled = false; - void loadIndexData(() => cancelled); - return () => { - cancelled = true; - }; - }, [loadIndexData]); - + const indexScopeKey = useMemo( + () => + effectiveConnectionId && !projectId + ? JSON.stringify({ + connectionId: effectiveConnectionId, + surface, + teamId, + } satisfies LinearIndexScope) + : null, + [effectiveConnectionId, projectId, surface, teamId] + ); + const indexResource = useAsyncResource({ + enabled: Boolean(indexScopeKey), + fetcher: fetchIndexData, + initialData: EMPTY_LINEAR_INDEX, + scopeKey: indexScopeKey, + }); + const indexProjects = indexResource.data.projects; + const indexWorkItems = indexResource.data.workItems; + const indexLoading = indexResource.loading; + const indexLoaded = + indexResource.status === "ready" || indexResource.status === "error"; + const indexError = indexResource.error; + const refreshIndex = indexResource.refresh; + const setIndexData = indexResource.setData; const handleIndexRefresh = useCallback(() => { - void loadIndexData(undefined, { forceRefresh: true }); - }, [loadIndexData]); + void refreshIndex(); + }, [refreshIndex]); const [indexUpdateError, setIndexUpdateError] = useState(null); @@ -314,23 +310,24 @@ export function useLinearIndexData({ updatedIssue.project.id ); } - setIndexWorkItems((currentItems) => - currentItems.map((currentItem) => { + setIndexData((current) => ({ + ...current, + workItems: current.workItems.map((currentItem) => { if (currentItem.session_id !== workItemId) return currentItem; const parentProject = indexProjects.find( (linearProject) => linearProject.id === currentItem.project?.id ); if (!parentProject) return currentItem; return linearIssueToWorkItem(updatedIssue, parentProject); - }) - ); + }), + })); } catch (err) { setIndexUpdateError( errorMessage(err, t("linearProjects.errors.updateIssue")) ); } }, - [effectiveConnectionId, indexProjects, t] + [effectiveConnectionId, indexProjects, setIndexData, t] ); // ---- Index work item derived views ---- diff --git a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectOrgCatalogData.ts b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectOrgCatalogData.ts index 37bd65d03..247d76b4a 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectOrgCatalogData.ts +++ b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectOrgCatalogData.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { PROJECT_ORG_SYNC_PROVIDER, projectApi } from "@src/api/http/project"; import type { @@ -7,6 +7,7 @@ import type { ProjectData, ProjectOrg, } from "@src/api/http/project"; +import { useAsyncResource } from "@src/hooks/async"; import type { Label } from "@src/types/core/shared"; interface MembersByProject { @@ -64,58 +65,82 @@ function mergeLabels(projectLabels: LabelsByProject[]): Label[] { ); } +interface ProjectOrgCatalogResource { + labelsByProject: LabelsByProject[]; + membersByProject: MembersByProject[]; + org: ProjectOrg | null; + projects: ProjectData[]; +} + +const EMPTY_PROJECT_ORG_CATALOG: ProjectOrgCatalogResource = { + labelsByProject: [], + membersByProject: [], + org: null, + projects: [], +}; + export function useProjectOrgCatalogData(orgId: string) { - const [org, setOrg] = useState(null); - const [projects, setProjects] = useState([]); - const [membersByProject, setMembersByProject] = useState( - [] - ); - const [labelsByProject, setLabelsByProject] = useState([]); - const [folderPath, setFolderPath] = useState(""); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - - const loadOrgCatalog = useCallback(async () => { - setLoading(true); - setLoadError(null); - try { - const [allOrgs, orgProjects] = await Promise.all([ - projectApi.readOrgs(), - projectApi.readProjects({ orgId }), - ]); - const currentOrg = allOrgs.find((entry) => entry.id === orgId); - if (!currentOrg) { - throw new Error(`Project org not found: ${orgId}`); - } - const [nextMembersByProject, nextLabelsByProject] = await Promise.all([ - Promise.all( - orgProjects.map(async (project) => ({ - projectSlug: project.slug, - members: (await projectApi.readMembers(project.slug)).members, - })) - ), - Promise.all( - orgProjects.map(async (project) => ({ - projectSlug: project.slug, - labels: (await projectApi.readLabels(project.slug)).labels, - })) - ), - ]); - setOrg(currentOrg); - setProjects(orgProjects); - setMembersByProject(nextMembersByProject); - setLabelsByProject(nextLabelsByProject); - setFolderPath(parseGitFolderPath(currentOrg)); - } catch (err) { - setLoadError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); + const [folderDraft, setFolderDraft] = useState<{ + baseValue: string; + orgId: string; + value: string; + } | null>(null); + + const fetchOrgCatalog = useCallback(async (scopeOrgId: string) => { + const [allOrgs, projects] = await Promise.all([ + projectApi.readOrgs(), + projectApi.readProjects({ orgId: scopeOrgId }), + ]); + const org = allOrgs.find((entry) => entry.id === scopeOrgId); + if (!org) { + throw new Error(`Project org not found: ${scopeOrgId}`); } - }, [orgId]); - - useEffect(() => { - void loadOrgCatalog(); - }, [loadOrgCatalog]); + const [membersByProject, labelsByProject] = await Promise.all([ + Promise.all( + projects.map(async (project) => ({ + projectSlug: project.slug, + members: (await projectApi.readMembers(project.slug)).members, + })) + ), + Promise.all( + projects.map(async (project) => ({ + projectSlug: project.slug, + labels: (await projectApi.readLabels(project.slug)).labels, + })) + ), + ]); + return { labelsByProject, membersByProject, org, projects }; + }, []); + + const resource = useAsyncResource({ + enabled: Boolean(orgId), + fetcher: fetchOrgCatalog, + initialData: EMPTY_PROJECT_ORG_CATALOG, + scopeKey: orgId || null, + }); + const { + data: catalog, + error: loadError, + loading, + refresh: reload, + setData: setCatalog, + } = resource; + const { labelsByProject, membersByProject, org, projects } = catalog; + const storedFolderPath = parseGitFolderPath(org); + const folderPath = + folderDraft?.orgId === orgId && folderDraft.baseValue === storedFolderPath + ? folderDraft.value + : storedFolderPath; + const setFolderPath = useCallback( + (value: string) => { + setFolderDraft({ + baseValue: storedFolderPath, + orgId, + value, + }); + }, + [orgId, storedFolderPath] + ); const members = useMemo( () => mergeMembers(membersByProject), @@ -131,14 +156,15 @@ export function useProjectOrgCatalogData(orgId: string) { projectApi.writeMembers(project.slug, { members: updatedMembers }) ) ); - setMembersByProject( - projects.map((project) => ({ + setCatalog((current) => ({ + ...current, + membersByProject: projects.map((project) => ({ projectSlug: project.slug, members: updatedMembers, - })) - ); + })), + })); }, - [projects] + [projects, setCatalog] ); const handleUpdateLabels = useCallback( @@ -149,14 +175,15 @@ export function useProjectOrgCatalogData(orgId: string) { projectApi.writeLabels(project.slug, { labels: updatedLabels }) ) ); - setLabelsByProject( - projects.map((project) => ({ + setCatalog((current) => ({ + ...current, + labelsByProject: projects.map((project) => ({ projectSlug: project.slug, labels: updatedLabels, - })) - ); + })), + })); }, - [projects] + [projects, setCatalog] ); const handleConfigureGitFolder = useCallback(async () => { @@ -164,15 +191,20 @@ export function useProjectOrgCatalogData(orgId: string) { org_id: orgId, folder_path: folderPath.trim(), }); - setOrg(configuredOrg); - setFolderPath(parseGitFolderPath(configuredOrg)); - }, [folderPath, orgId]); + setCatalog((current) => ({ ...current, org: configuredOrg })); + const configuredFolderPath = parseGitFolderPath(configuredOrg); + setFolderDraft({ + baseValue: configuredFolderPath, + orgId, + value: configuredFolderPath, + }); + }, [folderPath, orgId, setCatalog]); const handleSyncGitFolder = useCallback(async () => { const result = await projectApi.syncOrgGitFolder({ org_id: orgId }); - await loadOrgCatalog(); + await reload(); return result; - }, [loadOrgCatalog, orgId]); + }, [orgId, reload]); const isGitFolderSynced = org?.sync_provider === PROJECT_ORG_SYNC_PROVIDER.GIT_FOLDER; @@ -191,6 +223,6 @@ export function useProjectOrgCatalogData(orgId: string) { handleUpdateLabels, handleConfigureGitFolder, handleSyncGitFolder, - reload: loadOrgCatalog, + reload, }; } diff --git a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts index 8ac647c9d..8af8ea5b1 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { type MemberEntry, projectApi } from "@src/api/http/project"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { useProjectDataChanged } from "@src/hooks/project"; import type { ProjectData } from "@src/modules/ProjectManager/shared"; @@ -13,8 +14,18 @@ import type { Label, Person } from "@src/types/core/shared"; import type { UseProjectDataOptions, UseProjectDataReturn } from "./types"; import { useProjectDataFile } from "./useProjectDataFile"; +import type { FetchFromFilesResult } from "./useProjectDataFile"; const log = createLogger("useProjectData"); +const AUTO_PROJECT_SCOPE = "__auto_project__"; +const EMPTY_PROJECT_DATA: FetchFromFilesResult = { + allProjects: [], + autoSelectedId: null, + labels: [], + members: [], + project: null, + rawMembers: [], +}; export function useProjectData( options: UseProjectDataOptions = {} @@ -24,212 +35,134 @@ export function useProjectData( autoLoad = true, isActive = true, } = options; - - const [project, setProject] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState( initialProjectId || null ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const { fetchFromFiles, updateProjectFile } = useProjectDataFile(); + const fetchProjectData = useCallback( + async (scopeKey: string) => { + try { + return await fetchFromFiles( + scopeKey === AUTO_PROJECT_SCOPE ? null : scopeKey + ); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to load project from store"; + log.error("[useProjectData] Load error:", error); + throw new Error(message); + } + }, + [fetchFromFiles] + ); + const { + data, + error, + loading, + refresh: loadFromFiles, + setData: setProjectData, + } = useAsyncResource({ + autoLoad, + fetcher: fetchProjectData, + initialData: EMPTY_PROJECT_DATA, + scopeKey: selectedProjectId ?? AUTO_PROJECT_SCOPE, + }); + + const project = data.project; const projectRef = useRef(project); projectRef.current = project; + const availableMembers: Person[] = data.members; + const availableLabels: Label[] = data.labels; - const [storeMembers, setStoreMembers] = useState([]); - const [storeLabels, setStoreLabels] = useState([]); - const [storeProjects, setStoreProjects] = useState< - { id: string; name: string }[] - >([]); - const [rawMembers, setRawMembers] = useState([]); - const [rawLabels, setRawLabels] = useState([]); - - const file = useProjectDataFile(); - - const availableMembers = storeMembers; - const availableLabels = storeLabels; + useEffect(() => { + if (initialProjectId && initialProjectId !== selectedProjectId) { + setSelectedProjectId(initialProjectId); + } + // selectedProjectId is deliberately omitted: this effect mirrors prop + // changes into the local selection without undoing a user selection. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialProjectId]); - const loadFromFiles = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await file.fetchFromFiles(selectedProjectId); - setProject(result.project); - setStoreMembers(result.members); - setStoreLabels(result.labels); - setStoreProjects(result.allProjects); - setRawMembers(result.rawMembers); - setRawLabels(result.labels); - if (result.autoSelectedId) { - setSelectedProjectId(result.autoSelectedId); - } - } catch (err) { - const message = - err instanceof Error - ? err.message - : "Failed to load project from store"; - setError(message); - log.error("[useProjectData] Load error:", err); - } finally { - setLoading(false); + useEffect(() => { + if (data.autoSelectedId && data.autoSelectedId !== selectedProjectId) { + setSelectedProjectId(data.autoSelectedId); } - }, [file, selectedProjectId]); + }, [data.autoSelectedId, selectedProjectId]); const updateProject = useCallback( async (updates: Partial): Promise => { if (!selectedProjectId) return false; - setProject((prev: ProjectData | null) => - prev ? { ...prev, ...updates } : prev - ); + setProjectData((current) => ({ + ...current, + project: current.project ? { ...current.project, ...updates } : null, + })); try { const currentProject = projectRef.current; if (!currentProject) return false; const merged = { ...currentProject, ...updates }; - await file.updateProjectFile(merged, updates); + await updateProjectFile(merged, updates); return true; - } catch (err) { - log.error("[useProjectData] Update error:", err); + } catch (error) { + log.error("[useProjectData] Update error:", error); await loadFromFiles(); return false; } }, - [selectedProjectId, file, loadFromFiles] + [loadFromFiles, selectedProjectId, setProjectData, updateProjectFile] + ); + + const updateMembers = useCallback( + async (updatedMembers: MemberEntry[]) => { + const slug = projectRef.current?.slug; + if (!slug) return; + setProjectData((current) => ({ + ...current, + members: updatedMembers + .filter((member) => member.active) + .map((member) => ({ + id: member.id, + name: member.name, + email: member.email, + avatar: member.avatar, + })), + rawMembers: updatedMembers, + })); + await projectApi.writeMembers(slug, { members: updatedMembers }); + }, + [setProjectData] ); - const refresh = useCallback(async () => { - await loadFromFiles(); - }, [loadFromFiles]); + const updateLabels = useCallback( + async (updatedLabels: Label[]) => { + const slug = projectRef.current?.slug; + if (!slug) return; + setProjectData((current) => ({ + ...current, + labels: updatedLabels, + })); + await projectApi.writeLabels(slug, { labels: updatedLabels }); + }, + [setProjectData] + ); const selectProject = useCallback((newProjectId: string) => { setSelectedProjectId(newProjectId); }, []); - const updateMembers = useCallback(async (updatedMembers: MemberEntry[]) => { - const slug = projectRef.current?.slug; - if (!slug) return; - setRawMembers(updatedMembers); - setStoreMembers( - updatedMembers - .filter((member) => member.active) - .map((member) => ({ - id: member.id, - name: member.name, - email: member.email, - avatar: member.avatar, - })) - ); - await projectApi.writeMembers(slug, { - members: updatedMembers, - }); - }, []); - - const updateLabels = useCallback(async (updatedLabels: Label[]) => { - const slug = projectRef.current?.slug; - if (!slug) return; - setRawLabels(updatedLabels); - setStoreLabels(updatedLabels); - await projectApi.writeLabels(slug, { - labels: updatedLabels, - }); - }, []); - - useEffect(() => { - if (initialProjectId && initialProjectId !== selectedProjectId) { - setSelectedProjectId(initialProjectId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialProjectId]); - - useEffect(() => { - if (!autoLoad) return; - - let cancelled = false; - - const load = async () => { - setLoading(true); - setError(null); - try { - const result = await file.fetchFromFiles(selectedProjectId); - if (cancelled) return; - setProject(result.project); - setStoreMembers(result.members); - setStoreLabels(result.labels); - setStoreProjects(result.allProjects); - setRawMembers(result.rawMembers); - setRawLabels(result.labels); - if (result.autoSelectedId) { - setSelectedProjectId(result.autoSelectedId); - } - } catch (err) { - if (cancelled) return; - const message = - err instanceof Error - ? err.message - : "Failed to load project from store"; - setError(message); - log.error("[useProjectData] Load error:", err); - } finally { - if (!cancelled) setLoading(false); - } - }; - - load(); - - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoLoad]); - - useEffect(() => { - if (!selectedProjectId) return; - let cancelled = false; - - const load = async () => { - setLoading(true); - setError(null); - try { - const result = await file.fetchFromFiles(selectedProjectId); - if (cancelled) return; - setProject(result.project); - setStoreMembers(result.members); - setStoreLabels(result.labels); - setStoreProjects(result.allProjects); - setRawMembers(result.rawMembers); - setRawLabels(result.labels); - } catch (err) { - if (cancelled) return; - const message = - err instanceof Error - ? err.message - : "Failed to load project from store"; - setError(message); - log.error("[useProjectData] Load error:", err); - } finally { - if (!cancelled) setLoading(false); - } - }; - - load(); - - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedProjectId]); - const activeLoadFromFiles = useCallback(() => { if (!isActive) return; - loadFromFiles(); + void loadFromFiles(); }, [isActive, loadFromFiles]); - useProjectDataChanged(activeLoadFromFiles); const wasActiveRef = useRef(isActive); useEffect(() => { if (isActive && !wasActiveRef.current && project !== null) { - loadFromFiles(); + void loadFromFiles(); } wasActiveRef.current = isActive; }, [isActive, loadFromFiles, project]); @@ -241,11 +174,11 @@ export function useProjectData( availableMembers, availableTeams: [], availableLabels, - availableProjects: storeProjects, + availableProjects: data.allProjects, availableMilestones: [], - rawMembers, - rawLabels, - refresh, + rawMembers: data.rawMembers, + rawLabels: data.labels, + refresh: loadFromFiles, updateProject, updateMembers, updateLabels, diff --git a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectDataFile.ts b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectDataFile.ts index e945da17d..12d80a0b3 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectDataFile.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectDataFile.ts @@ -26,7 +26,7 @@ function normalizeWorkItemPrefix(prefix: string): string { return prefix.trim().toUpperCase(); } -interface FetchFromFilesResult { +export interface FetchFromFilesResult { project: ProjectData | null; allProjects: { id: string; name: string; slug: string }[]; labels: Label[]; diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useGitWorktrees.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useGitWorktrees.ts index 05a8f461a..b5ac161c6 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useGitWorktrees.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useGitWorktrees.ts @@ -5,7 +5,7 @@ * Returns linked (non-main) worktrees only — the main worktree * is already displayed by the primary Source Control section. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect } from "react"; import type { GitWorktreeDiffSummary, @@ -13,7 +13,7 @@ import type { } from "@src/api/http/git/types"; import { getGitWorktrees } from "@src/api/http/git/worktrees"; import { getCodeEditorWebSocket } from "@src/api/realtime/codeEditorWebSocket"; -import { useMountedCleanup } from "@src/hooks/lifecycle/useMounted"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { DEBOUNCE_DELAYS, @@ -39,58 +39,60 @@ export interface UseGitWorktreesResult { refresh: () => Promise; } +interface GitWorktreesData { + worktrees: GitWorktreeEntry[]; + mainDiffSummary: GitWorktreeDiffSummary | null; +} + +const EMPTY_WORKTREES: GitWorktreesData = { + worktrees: [], + mainDiffSummary: null, +}; + export function useGitWorktrees({ repoId, repoPath, enabled = true, }: UseGitWorktreesOptions): UseGitWorktreesResult { - const [worktrees, setWorktrees] = useState([]); - const [mainDiffSummary, setMainDiffSummary] = - useState(null); - const [loading, setLoading] = useState(enabled); - const loadedRef = useRef(false); - const mountedRef = useRef(true); - useMountedCleanup(mountedRef); - - const fetchWorktrees = useCallback(async () => { - if (!enabled) return; - - if (!loadedRef.current) setLoading(true); + const fetchWorktrees = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + repoId: string; + repoPath: string; + }; try { const entries = await getGitWorktrees({ - repo_id: repoId, - repo_path: repoPath, + repo_id: scope.repoId, + repo_path: scope.repoPath, }); - if (!mountedRef.current) return; - setWorktrees(entries.filter((entry) => !entry.is_main)); - setMainDiffSummary(extractMainWorktreeDiffSummary(entries)); + return { + worktrees: entries.filter((entry) => !entry.is_main), + mainDiffSummary: extractMainWorktreeDiffSummary(entries), + }; } catch (error) { logger.warn("Failed to fetch git worktrees", error); - } finally { - if (mountedRef.current) { - loadedRef.current = true; - setLoading(false); - } + throw error; } - }, [enabled, repoId, repoPath, mountedRef]); + }, []); + const scopeKey = + enabled && repoId ? JSON.stringify({ repoId, repoPath }) : null; + const resource = useAsyncResource({ + enabled: Boolean(scopeKey), + fetcher: fetchWorktrees, + initialData: EMPTY_WORKTREES, + scopeKey, + }); + const reloadWorktrees = resource.reload; + const resourceStatus = resource.status; + const refresh = useCallback( + () => reloadWorktrees({ background: resourceStatus === "ready" }), + [reloadWorktrees, resourceStatus] + ); const debouncedFetch = useDebouncedCallback( - () => fetchWorktrees(), + () => reloadWorktrees({ background: true }), DEBOUNCE_DELAYS.API ); - useEffect(() => { - loadedRef.current = false; - setLoading(enabled); - if (!enabled) { - setWorktrees([]); - setMainDiffSummary(null); - return; - } - - void fetchWorktrees(); - }, [enabled, fetchWorktrees]); - useEffect(() => { if (!enabled) return; @@ -114,13 +116,13 @@ export function useGitWorktrees({ }; }, [enabled, repoId, debouncedFetch]); - const visibleWorktrees = enabled ? worktrees : []; + const visibleWorktrees = resource.data.worktrees; return { worktrees: visibleWorktrees, - mainDiffSummary: enabled ? mainDiffSummary : null, + mainDiffSummary: resource.data.mainDiffSummary, hasWorktrees: visibleWorktrees.length > 0, - loading, - refresh: fetchWorktrees, + loading: resource.loading, + refresh, }; } diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useStashState.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useStashState.ts index da6fc7a1e..cfa31114e 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useStashState.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useStashState.ts @@ -15,6 +15,10 @@ import { useTranslation } from "react-i18next"; import { useActionSystemOptional } from "@src/ActionSystem"; import { gitApi } from "@src/api/http/git"; import type { StashEntry } from "@src/api/http/git/types"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { showGitActionDialogSafely } from "@src/util/dialogs/gitActionDialog"; @@ -69,38 +73,55 @@ export function useStashState( const actionSystem = useActionSystemOptional(); const dispatch = actionSystem?.dispatch; - const [stashes, setStashes] = useState([]); - const [loading, setLoading] = useState(false); const [operationLoading, setOperationLoading] = useState(false); const [error, setError] = useState(null); - // Fetch stash list (data fetching, not an action - remains gitApi) + const fetchStashes = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext + ) => { + const scope = JSON.parse(serializedScope) as { + repoId: string; + repoPath: string; + }; + try { + const result = await gitApi.gitStashList({ + repo_id: scope.repoId, + repo_path: scope.repoPath, + }); + return result?.stashes ?? []; + } catch (caughtError) { + log.error("[useStashState] Failed to fetch stash list:", caughtError); + if (context.isCurrent()) context.publish([]); + throw caughtError; + } + }, + [] + ); + const scopeKey = repoId ? JSON.stringify({ repoId, repoPath }) : null; + const stashResource = useAsyncResource({ + autoLoad, + enabled: Boolean(scopeKey), + fetcher: fetchStashes, + initialData: [], + scopeKey, + }); + const refreshResource = stashResource.refresh; const refresh = useCallback(async () => { - if (!repoId) return; - - setLoading(true); setError(null); + await refreshResource(); + }, [refreshResource]); - try { - const result = await gitApi.gitStashList({ - repo_id: repoId, - repo_path: repoPath, - }); - - if (result) { - setStashes(result.stashes); - } else { - setStashes([]); - } - } catch (err) { - log.error("[useStashState] Failed to fetch stash list:", err); - setError(err instanceof Error ? err.message : "Failed to fetch stashes"); - setStashes([]); - } finally { - setLoading(false); - } + useEffect(() => { + setError(null); }, [repoId, repoPath]); + /* + * Operation state remains local: it represents an explicit user mutation, + * while the list resource above owns only read/refresh lifecycle. + */ + // Create a new stash - uses dispatch const stashPush = useCallback( async (message?: string, includeUntracked = false): Promise => { @@ -390,18 +411,11 @@ export function useStashState( [repoId, repoPath, refresh, dispatch, t] ); - // Auto-load on mount - useEffect(() => { - if (autoLoad && repoId) { - refresh(); - } - }, [autoLoad, repoId, refresh]); - return { - stashes, - loading, - error, - stashCount: stashes.length, + stashes: stashResource.data, + loading: stashResource.loading, + error: error ?? stashResource.error, + stashCount: stashResource.data.length, refresh, stashPush, stashApply, diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationIssues.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationIssues.ts index 38c92e88d..2f53ed18d 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationIssues.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationIssues.ts @@ -10,6 +10,10 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getGitRemotes } from "@src/api/http/git/remotes"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { getCachedIssues, @@ -42,6 +46,7 @@ import { } from "@src/store/workstation/codeEditor/workstationIssueAtom"; import type { IssueFilterState } from "@src/store/workstation/codeEditor/workstationIssueAtom"; import { workstationRepoScopeKey } from "@src/store/workstation/codeEditor/workstationPrAtom"; +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; import { filterIssuesByQuery } from "./workstationIssueHelpers"; @@ -75,6 +80,39 @@ function mergeUniqueIssues( ]; } +interface IssueSectionData { + hasMore: boolean; + issues: GitHubIssue[]; + nextPage: number | null; +} + +interface IssueSectionScope { + remoteUrl: string; + repoKey: string; + state: "closed" | "open"; +} + +interface PaginationState { + error: string | null; + scopeKey: string | null; + status: "error" | "idle" | "loading"; +} + +interface IssueRepoMetadata { + collaborators: GitHubIssueUser[]; + labels: GitHubIssueLabel[]; +} + +const EMPTY_PAGINATION_STATE: PaginationState = { + error: null, + scopeKey: null, + status: "idle", +}; +const EMPTY_ISSUE_REPO_METADATA: IssueRepoMetadata = { + collaborators: [], + labels: [], +}; + export function useWorkstationIssues({ repoPath, repoId, @@ -103,66 +141,59 @@ export function useWorkstationIssues({ // ── Auth / remote URL resolution ────────────────────────────────────────── - const [resolvedRemoteUrl, setResolvedRemoteUrl] = useState( - null - ); // Optimistic auth flag: true when the remote is a GitHub URL. // Credentials are resolved Rust-side from connection_token_store — no // pre-flight token ping needed. Real auth failures from API calls will // flip this to false, matching the trust model used by the PR panel. // Track whether we're still waiting for the remote URL to resolve so the // panel shows a spinner instead of the empty-state placeholder. - const [remoteUrlLoading, setRemoteUrlLoading] = useState(true); // Set to true when the API returns a re-authorization error so the UI can // show a targeted prompt instead of a generic error or empty state. - const [needsReAuth, setNeedsReAuth] = useState(false); - - const [repoLabels, setRepoLabels] = useState([]); - const [collaborators, setCollaborators] = useState([]); - - // Resolve origin remote URL if not provided via props - useEffect(() => { - let cancelled = false; - - void (async () => { - if (remoteUrlProp) { - logger.debug("remote URL from prop", remoteUrlProp); - if (!cancelled) { - setResolvedRemoteUrl(remoteUrlProp); - setRemoteUrlLoading(false); - } - return; - } - if (!repoPath) { - if (!cancelled) setRemoteUrlLoading(false); - return; - } - - logger.debug("fetching remotes", { repoPath, repoId: apiRepoId }); - try { - const remotesData = await getGitRemotes({ - repo_id: apiRepoId, - repo_path: repoPath, - }); - logger.debug("getGitRemotes result", remotesData); - const origin = remotesData?.remotes?.find((r) => r.name === "origin"); - logger.debug("origin remote", origin); - if (!cancelled) { - if (origin?.url) { - setResolvedRemoteUrl(origin.url); - } - setRemoteUrlLoading(false); - } - } catch (err) { - logger.warn("getGitRemotes failed", err); - if (!cancelled) setRemoteUrlLoading(false); - } - })(); - - return () => { - cancelled = true; + const [reAuthScopeKey, setReAuthScopeKey] = useState(null); + + const remoteScopeKey = JSON.stringify({ + apiRepoId, + remoteUrl: remoteUrlProp ?? null, + repoPath, + }); + const resolveRemoteUrl = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + apiRepoId: string; + remoteUrl: string | null; + repoPath: string; }; - }, [repoPath, apiRepoId, remoteUrlProp]); + if (scope.remoteUrl) { + logger.debug("remote URL from prop", scope.remoteUrl); + return scope.remoteUrl; + } + if (!scope.repoPath) return null; + + logger.debug("fetching remotes", { + repoPath: scope.repoPath, + repoId: scope.apiRepoId, + }); + try { + const remotesData = await getGitRemotes({ + repo_id: scope.apiRepoId, + repo_path: scope.repoPath, + }); + const origin = remotesData?.remotes?.find( + (remote) => remote.name === "origin" + ); + logger.debug("origin remote", origin); + return origin?.url ?? null; + } catch (error) { + logger.warn("getGitRemotes failed", error); + return null; + } + }, []); + const remoteResource = useAsyncResource({ + fetcher: resolveRemoteUrl, + initialData: null, + scopeKey: remoteScopeKey, + }); + const resolvedRemoteUrl = remoteResource.data; + const remoteUrlLoading = remoteResource.loading; // Optimistically true when the remote resolves to a GitHub URL. // A valid GitHub URL means credentials should be available via @@ -202,179 +233,261 @@ export function useWorkstationIssues({ type SectionLoadState = "idle" | "loading" | "ready" | "error"; // Seed from cache immediately so the list shows on re-entry without a spinner - const cached = getCachedIssues(repoKey); - const [openLoadState, setOpenLoadState] = useState( - cached ? "ready" : "idle" - ); - const [closedLoadState, setClosedLoadState] = useState( - cached?.closedIssues.length && !isIssueCacheStale(repoKey, "closed") - ? "ready" - : "idle" - ); - const [openIssues, setOpenIssues] = useState( - cached?.openIssues ?? [] + const cached = useMemo(() => getCachedIssues(repoKey), [repoKey]); + const openCacheStale = useMemo(() => isIssueCacheStale(repoKey), [repoKey]); + const closedCacheReady = useMemo( + () => + Boolean(cached?.closedIssues.length) && + !isIssueCacheStale(repoKey, "closed"), + [cached, repoKey] ); - const [closedIssues, setClosedIssues] = useState( - cached?.closedIssues ?? [] + const openInitialData = useMemo( + () => ({ + hasMore: (cached?.openIssues.length ?? 0) >= ISSUE_PAGE_SIZE, + issues: cached?.openIssues ?? [], + nextPage: (cached?.openIssues.length ?? 0) >= ISSUE_PAGE_SIZE ? 2 : null, + }), + [cached] ); - const [openHasMore, setOpenHasMore] = useState( - (cached?.openIssues.length ?? 0) >= ISSUE_PAGE_SIZE + const closedInitialData = useMemo( + () => ({ + hasMore: (cached?.closedIssues.length ?? 0) >= ISSUE_PAGE_SIZE, + issues: cached?.closedIssues ?? [], + nextPage: + (cached?.closedIssues.length ?? 0) >= ISSUE_PAGE_SIZE ? 2 : null, + }), + [cached] ); - const [closedHasMore, setClosedHasMore] = useState( - (cached?.closedIssues.length ?? 0) >= ISSUE_PAGE_SIZE - ); - const [openNextPage, setOpenNextPage] = useState( - (cached?.openIssues.length ?? 0) >= ISSUE_PAGE_SIZE ? 2 : null + + const openScopeKey = useMemo( + () => + resolvedRemoteUrl && hasGitHubAuth + ? JSON.stringify({ + remoteUrl: resolvedRemoteUrl, + repoKey, + state: "open", + } satisfies IssueSectionScope) + : null, + [hasGitHubAuth, repoKey, resolvedRemoteUrl] ); - const [closedNextPage, setClosedNextPage] = useState( - (cached?.closedIssues.length ?? 0) >= ISSUE_PAGE_SIZE ? 2 : null + const closedScopeKey = useMemo( + () => + resolvedRemoteUrl && hasGitHubAuth + ? JSON.stringify({ + remoteUrl: resolvedRemoteUrl, + repoKey, + state: "closed", + } satisfies IssueSectionScope) + : null, + [hasGitHubAuth, repoKey, resolvedRemoteUrl] ); - const [openLoadingMore, setOpenLoadingMore] = useState(false); - const [closedLoadingMore, setClosedLoadingMore] = useState(false); - const [openError, setOpenError] = useState(null); - const [closedError, setClosedError] = useState(null); - - const handleFetchError = useCallback( - ( - error: string, - setError: (e: string | null) => void, - setLoad: (s: SectionLoadState) => void + const needsReAuth = + reAuthScopeKey === openScopeKey || reAuthScopeKey === closedScopeKey; + + const fetchIssueSection = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext ) => { - const isReAuth = - /ReAuthError/i.test(error) || /re-authorization required/i.test(error); - if (isReAuth) { - setNeedsReAuth(true); - } else { - setError(error); + const scope = JSON.parse(serializedScope) as IssueSectionScope; + const result = await fetchIssues(scope.remoteUrl, { + state: scope.state, + page: 1, + perPage: ISSUE_PAGE_SIZE, + }); + if (result.error) { + const isReAuth = + /ReAuthError/i.test(result.error) || + /re-authorization required/i.test(result.error); + if (isReAuth && context.isCurrent()) { + setReAuthScopeKey(serializedScope); + } + throw new Error(result.error); } - setLoad("error"); + const data = { + hasMore: result.data!.has_more, + issues: result.data!.issues, + nextPage: result.data!.next_page, + }; + if (context.isCurrent()) { + if (scope.state === "open") { + updateCachedOpenIssues(scope.repoKey, data.issues); + } else { + updateCachedClosedIssues(scope.repoKey, data.issues); + } + } + return data; }, - [setNeedsReAuth] + [] ); - const fetchOpen = useCallback(async () => { - if (!resolvedRemoteUrl || !hasGitHubAuth) return; - setOpenLoadState("loading"); - setOpenError(null); - const result = await fetchIssues(resolvedRemoteUrl, { - state: "open", - page: 1, - perPage: ISSUE_PAGE_SIZE, - }); - if (!mountedRef.current) return; - if (result.error) { - handleFetchError(result.error, setOpenError, setOpenLoadState); - return; - } - const issues = result.data!.issues; - setOpenIssues(issues); - setOpenHasMore(result.data!.has_more); - setOpenNextPage(result.data!.next_page); - setOpenLoadState("ready"); - updateCachedOpenIssues(repoKey, issues); - }, [resolvedRemoteUrl, hasGitHubAuth, handleFetchError, repoKey]); - - const fetchClosed = useCallback(async () => { - if (!resolvedRemoteUrl || !hasGitHubAuth) return; - setClosedLoadState("loading"); - setClosedError(null); - const result = await fetchIssues(resolvedRemoteUrl, { - state: "closed", - page: 1, - perPage: ISSUE_PAGE_SIZE, - }); - if (!mountedRef.current) return; - if (result.error) { - handleFetchError(result.error, setClosedError, setClosedLoadState); - return; - } - const issues = result.data!.issues; - setClosedIssues(issues); - setClosedHasMore(result.data!.has_more); - setClosedNextPage(result.data!.next_page); - setClosedLoadState("ready"); - updateCachedClosedIssues(repoKey, issues); - }, [resolvedRemoteUrl, hasGitHubAuth, handleFetchError, repoKey]); + const openResource = useAsyncResource({ + autoLoad: Boolean(openScopeKey) && openCacheStale, + enabled: Boolean(openScopeKey), + fetcher: fetchIssueSection, + initialData: openInitialData, + initialStatus: cached ? "ready" : "idle", + scopeKey: openScopeKey, + }); + const closedResource = useAsyncResource({ + autoLoad: false, + enabled: Boolean(closedScopeKey), + fetcher: fetchIssueSection, + initialData: closedInitialData, + initialStatus: closedCacheReady ? "ready" : "idle", + scopeKey: closedScopeKey, + }); + + const { + data: openData, + error: openResourceError, + refresh: fetchOpen, + setData: setOpenData, + status: openResourceStatus, + } = openResource; + const { + data: closedData, + error: closedResourceError, + refresh: fetchClosed, + setData: setClosedData, + status: closedResourceStatus, + } = closedResource; + const [openPagination, setOpenPagination] = useState( + EMPTY_PAGINATION_STATE + ); + const [closedPagination, setClosedPagination] = useState( + EMPTY_PAGINATION_STATE + ); + const openPageCoordinator = useMemo(() => new LatestScopedTask(), []); + const closedPageCoordinator = useMemo(() => new LatestScopedTask(), []); + + useEffect(() => { + openPageCoordinator.supersede(); + return () => openPageCoordinator.supersede(); + }, [openPageCoordinator, openScopeKey]); + useEffect(() => { + closedPageCoordinator.supersede(); + return () => closedPageCoordinator.supersede(); + }, [closedPageCoordinator, closedScopeKey]); const loadMoreOpen = useCallback(async () => { - if (!resolvedRemoteUrl || !hasGitHubAuth || !openHasMore || !openNextPage) - return; - setOpenLoadingMore(true); - setOpenError(null); - const result = await fetchIssues(resolvedRemoteUrl, { - state: "open", - page: openNextPage, - perPage: ISSUE_PAGE_SIZE, - }); - if (!mountedRef.current) return; - setOpenLoadingMore(false); - if (result.error) { - handleFetchError(result.error, setOpenError, setOpenLoadState); - return; - } - setOpenIssues((current) => { - const issues = mergeUniqueIssues(current, result.data!.issues); - updateCachedOpenIssues(repoKey, issues); - return issues; - }); - setOpenHasMore(result.data!.has_more); - setOpenNextPage(result.data!.next_page); - }, [ - resolvedRemoteUrl, - hasGitHubAuth, - openHasMore, - openNextPage, - handleFetchError, - repoKey, - ]); + if (!openScopeKey || !openData.hasMore || !openData.nextPage) return; + const scope = JSON.parse(openScopeKey) as IssueSectionScope; + await openPageCoordinator.run( + `${openScopeKey}:${openData.nextPage}`, + async (context) => { + setOpenPagination({ + error: null, + scopeKey: openScopeKey, + status: "loading", + }); + const result = await fetchIssues(scope.remoteUrl, { + state: "open", + page: openData.nextPage!, + perPage: ISSUE_PAGE_SIZE, + }); + if (!context.isCurrent()) return; + if (result.error) { + const isReAuth = + /ReAuthError/i.test(result.error) || + /re-authorization required/i.test(result.error); + if (isReAuth) setReAuthScopeKey(openScopeKey); + setOpenPagination({ + error: isReAuth ? null : result.error, + scopeKey: openScopeKey, + status: "error", + }); + return; + } + setOpenData((current) => { + const issues = mergeUniqueIssues(current.issues, result.data!.issues); + updateCachedOpenIssues(scope.repoKey, issues); + return { + hasMore: result.data!.has_more, + issues, + nextPage: result.data!.next_page, + }; + }); + setOpenPagination({ + error: null, + scopeKey: openScopeKey, + status: "idle", + }); + } + ); + }, [openData, openPageCoordinator, openScopeKey, setOpenData]); const loadMoreClosed = useCallback(async () => { - if ( - !resolvedRemoteUrl || - !hasGitHubAuth || - !closedHasMore || - !closedNextPage - ) - return; - setClosedLoadingMore(true); - setClosedError(null); - const result = await fetchIssues(resolvedRemoteUrl, { - state: "closed", - page: closedNextPage, - perPage: ISSUE_PAGE_SIZE, - }); - if (!mountedRef.current) return; - setClosedLoadingMore(false); - if (result.error) { - handleFetchError(result.error, setClosedError, setClosedLoadState); - return; - } - setClosedIssues((current) => { - const issues = mergeUniqueIssues(current, result.data!.issues); - updateCachedClosedIssues(repoKey, issues); - return issues; - }); - setClosedHasMore(result.data!.has_more); - setClosedNextPage(result.data!.next_page); - }, [ - resolvedRemoteUrl, - hasGitHubAuth, - closedHasMore, - closedNextPage, - handleFetchError, - repoKey, - ]); - - // Fetch open issues on mount / auth ready. - // Skip the network hit when the cache is still fresh (< 10 min) — the UI - // already shows cached rows so there's no spinner flash on re-entry. - // Deferred via setTimeout to avoid synchronous setState inside effect body. - useEffect(() => { - if (!resolvedRemoteUrl || !hasGitHubAuth) return; - if (!isIssueCacheStale(repoKey)) return; - const timer = setTimeout(() => void fetchOpen(), 0); - return () => clearTimeout(timer); - }, [resolvedRemoteUrl, hasGitHubAuth, fetchOpen, repoKey]); + if (!closedScopeKey || !closedData.hasMore || !closedData.nextPage) return; + const scope = JSON.parse(closedScopeKey) as IssueSectionScope; + await closedPageCoordinator.run( + `${closedScopeKey}:${closedData.nextPage}`, + async (context) => { + setClosedPagination({ + error: null, + scopeKey: closedScopeKey, + status: "loading", + }); + const result = await fetchIssues(scope.remoteUrl, { + state: "closed", + page: closedData.nextPage!, + perPage: ISSUE_PAGE_SIZE, + }); + if (!context.isCurrent()) return; + if (result.error) { + const isReAuth = + /ReAuthError/i.test(result.error) || + /re-authorization required/i.test(result.error); + if (isReAuth) setReAuthScopeKey(closedScopeKey); + setClosedPagination({ + error: isReAuth ? null : result.error, + scopeKey: closedScopeKey, + status: "error", + }); + return; + } + setClosedData((current) => { + const issues = mergeUniqueIssues(current.issues, result.data!.issues); + updateCachedClosedIssues(scope.repoKey, issues); + return { + hasMore: result.data!.has_more, + issues, + nextPage: result.data!.next_page, + }; + }); + setClosedPagination({ + error: null, + scopeKey: closedScopeKey, + status: "idle", + }); + } + ); + }, [closedData, closedPageCoordinator, closedScopeKey, setClosedData]); + + const openLoadState: SectionLoadState = + openResourceStatus === "refreshing" ? "loading" : openResourceStatus; + const closedLoadState: SectionLoadState = + closedResourceStatus === "refreshing" ? "loading" : closedResourceStatus; + const openIssues = openData.issues; + const closedIssues = closedData.issues; + const openHasMore = openData.hasMore; + const closedHasMore = closedData.hasMore; + const openLoadingMore = + openPagination.scopeKey === openScopeKey && + openPagination.status === "loading"; + const closedLoadingMore = + closedPagination.scopeKey === closedScopeKey && + closedPagination.status === "loading"; + const openError = needsReAuth + ? null + : (openResourceError ?? + (openPagination.scopeKey === openScopeKey ? openPagination.error : null)); + const closedError = needsReAuth + ? null + : (closedResourceError ?? + (closedPagination.scopeKey === closedScopeKey + ? closedPagination.error + : null)); const refresh = useCallback(() => { void fetchOpen(); @@ -401,25 +514,24 @@ export function useWorkstationIssues({ // Refetch on debounced search change (client-side filter applied in UI) // Search filtering is done client-side via filterIssuesByQuery helper - // Fetch repo labels + collaborators once auth is available - useEffect(() => { - if (!resolvedRemoteUrl || !hasGitHubAuth) return; - let cancelled = false; - - void (async () => { - const [labelsResult, collabResult] = await Promise.all([ - fetchRepoLabels(resolvedRemoteUrl), - fetchRepoCollaborators(resolvedRemoteUrl), - ]); - if (cancelled) return; - if (labelsResult.data) setRepoLabels(labelsResult.data); - if (collabResult.data) setCollaborators(collabResult.data); - })(); - - return () => { - cancelled = true; + const fetchRepoMetadata = useCallback(async (remoteUrl: string) => { + const [labelsResult, collaboratorsResult] = await Promise.all([ + fetchRepoLabels(remoteUrl), + fetchRepoCollaborators(remoteUrl), + ]); + return { + collaborators: collaboratorsResult.data ?? [], + labels: labelsResult.data ?? [], }; - }, [resolvedRemoteUrl, hasGitHubAuth]); + }, []); + const repoMetadataResource = useAsyncResource({ + enabled: Boolean(resolvedRemoteUrl && hasGitHubAuth), + fetcher: fetchRepoMetadata, + initialData: EMPTY_ISSUE_REPO_METADATA, + scopeKey: resolvedRemoteUrl && hasGitHubAuth ? resolvedRemoteUrl : null, + }); + const repoLabels = repoMetadataResource.data.labels; + const collaborators = repoMetadataResource.data.collaborators; // ── Issue selection ─────────────────────────────────────────────────────── From 823433a149148bdf1f51d65228362f93bbaa14d6 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 23 Jul 2026 22:32:16 +0800 Subject: [PATCH 7/8] perf(polling): suspend hidden resource refreshes Pre-commit hook ran. Total eslint: 10, total circular: 0 --- src/hooks/git/useGitAutoFetch.ts | 22 +- .../config/osAgent/useOSAgentGateway.ts | 50 ++--- .../Integrations/hooks/lsp/useLspServerLog.ts | 60 ++--- .../Browser/hooks/useBrowserConsole.ts | 184 +++++++-------- .../Browser/hooks/useBrowserNetworkLogs.ts | 112 +++++----- .../Browser/hooks/useWebviewDOMTree.ts | 209 +++++++----------- .../Browser/hooks/useWebviewInspector.ts | 73 +++--- 7 files changed, 319 insertions(+), 391 deletions(-) diff --git a/src/hooks/git/useGitAutoFetch.ts b/src/hooks/git/useGitAutoFetch.ts index a88071f83..4c24bcfb7 100644 --- a/src/hooks/git/useGitAutoFetch.ts +++ b/src/hooks/git/useGitAutoFetch.ts @@ -10,7 +10,7 @@ * a no-op. */ import { useAtomValue } from "jotai"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { gitApi } from "@src/api/http/git"; import { useGitStatus } from "@src/contexts/git"; @@ -20,6 +20,7 @@ import { gitAutoFetchAtom, gitAutoFetchIntervalAtom, } from "@src/store/ui/editorSettingsAtom"; +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; const MIN_INTERVAL_MS = 30_000; const DEFAULT_REMOTE_NAME = "origin"; @@ -32,19 +33,15 @@ export function useGitAutoFetch(): void { const selectedRepo = useAtomValue(selectedRepoAtom); const { forceRefresh, hasActiveRepo } = useGitStatus(); - const activeFetchKeyRef = useRef(null); const repoPath = selectedRepo?.path || selectedRepo?.fs_uri; useEffect(() => { if (!autoFetch || !hasActiveRepo || !selectedRepoId || !repoPath) return; let cancelled = false; - const fetchKey = `${selectedRepoId}:${repoPath}`; const intervalMs = Math.max(intervalSeconds * 1000, MIN_INTERVAL_MS); const tick = async () => { - if (activeFetchKeyRef.current === fetchKey) return; - activeFetchKeyRef.current = fetchKey; try { await gitApi.gitFetch({ repo_id: selectedRepoId, @@ -57,20 +54,17 @@ export function useGitAutoFetch(): void { } } catch (error) { logger.warn("background fetch failed:", error); - } finally { - if (activeFetchKeyRef.current === fetchKey) { - activeFetchKeyRef.current = null; - } } }; - void tick(); - const id = setInterval(() => { - void tick(); - }, intervalMs); + const poll = startVisibilityAwarePoll({ + intervalMs, + runImmediately: true, + task: tick, + }); return () => { cancelled = true; - clearInterval(id); + poll.stop(); }; }, [ autoFetch, diff --git a/src/modules/MainApp/AgentOrgs/config/osAgent/useOSAgentGateway.ts b/src/modules/MainApp/AgentOrgs/config/osAgent/useOSAgentGateway.ts index e0ad2f0c8..21b87085f 100644 --- a/src/modules/MainApp/AgentOrgs/config/osAgent/useOSAgentGateway.ts +++ b/src/modules/MainApp/AgentOrgs/config/osAgent/useOSAgentGateway.ts @@ -4,13 +4,14 @@ * Manages gateway status polling and start/stop actions. * Polls every 10s when loaded so live connection status stays fresh. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { getGatewayStatus, startGateway, stopGateway, } from "@src/api/tauri/agent"; +import { useVisibilityPolledData } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import type { GatewayStatusInfo } from "./types"; @@ -28,36 +29,27 @@ export interface UseOSAgentGatewayReturn { } export function useOSAgentGateway(loaded: boolean): UseOSAgentGatewayReturn { - const [gatewayStatus, setGatewayStatus] = useState( - null - ); const [gatewayLoading, setGatewayLoading] = useState(false); - const pollRef = useRef | null>(null); - const refreshGatewayStatus = useCallback(() => { - getGatewayStatus() - .then((status) => - setGatewayStatus(status as unknown as GatewayStatusInfo) - ) - .catch((err) => { - log.warn("Failed to fetch OS agent gateway status:", err); - setGatewayStatus(null); - }); + const fetchGatewayStatus = useCallback(async () => { + try { + return (await getGatewayStatus()) as unknown as GatewayStatusInfo; + } catch (err) { + log.warn("Failed to fetch OS agent gateway status:", err); + return null; + } }, []); - - useEffect(() => { - if (!loaded) return; - - refreshGatewayStatus(); - pollRef.current = setInterval(refreshGatewayStatus, POLL_INTERVAL_MS); - - return () => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - }; - }, [loaded, refreshGatewayStatus]); + const gatewayResource = useVisibilityPolledData({ + enabled: loaded, + fetcher: fetchGatewayStatus, + initialData: null, + intervalMs: POLL_INTERVAL_MS, + scopeKey: loaded ? "os-agent-gateway" : null, + }); + const refreshGateway = gatewayResource.refresh; + const refreshGatewayStatus = useCallback(() => { + void refreshGateway(); + }, [refreshGateway]); const handleStartGateway = useCallback(async () => { setGatewayLoading(true); @@ -86,7 +78,7 @@ export function useOSAgentGateway(loaded: boolean): UseOSAgentGatewayReturn { }, [refreshGatewayStatus]); return { - gatewayStatus, + gatewayStatus: gatewayResource.data, gatewayLoading, refreshGatewayStatus, handleStartGateway, diff --git a/src/modules/MainApp/Integrations/hooks/lsp/useLspServerLog.ts b/src/modules/MainApp/Integrations/hooks/lsp/useLspServerLog.ts index bd7c9def7..1fc38f33e 100644 --- a/src/modules/MainApp/Integrations/hooks/lsp/useLspServerLog.ts +++ b/src/modules/MainApp/Integrations/hooks/lsp/useLspServerLog.ts @@ -13,11 +13,13 @@ * realtime tailing we can reuse the existing code-editor WebSocket; * for now this is the simplest correct path. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; +import { useVisibilityPolledData } from "@src/hooks/async"; import type { LspLogLine } from "@src/modules/MainApp/Integrations/DevTools/LanguageServersPage/types"; const POLL_INTERVAL_MS = 1500; +const EMPTY_LOG: LspLogLine[] = []; async function tauriInvoke( command: string, @@ -43,46 +45,18 @@ export function useLspServerLog({ language, enabled, }: UseLspServerLogOptions): UseLspServerLogResult { - const [log, setLog] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const cancelledRef = useRef(false); - - const fetchOnce = useCallback(async () => { - if (!language) { - setLog([]); - return; - } - setIsLoading(true); - try { - const next = await tauriInvoke("lsp_get_server_log", { - language, - }); - if (cancelledRef.current) return; - setLog(next); - setError(null); - } catch (err) { - if (cancelledRef.current) return; - setError(err instanceof Error ? err.message : String(err)); - } finally { - if (!cancelledRef.current) setIsLoading(false); - } - }, [language]); - - useEffect(() => { - cancelledRef.current = false; - if (!enabled || !language) { - setLog([]); - return undefined; - } - - fetchOnce(); - const handle = window.setInterval(fetchOnce, POLL_INTERVAL_MS); - return () => { - cancelledRef.current = true; - window.clearInterval(handle); - }; - }, [enabled, language, fetchOnce]); - - return { log, isLoading, error, refresh: fetchOnce }; + const fetchLog = useCallback( + (scope: string) => + tauriInvoke("lsp_get_server_log", { language: scope }), + [] + ); + const { data, loading, error, refresh } = useVisibilityPolledData({ + enabled: enabled && Boolean(language), + fetcher: fetchLog, + initialData: EMPTY_LOG, + intervalMs: POLL_INTERVAL_MS, + scopeKey: language, + }); + + return { log: data, isLoading: loading, error, refresh }; } diff --git a/src/modules/WorkStation/Browser/hooks/useBrowserConsole.ts b/src/modules/WorkStation/Browser/hooks/useBrowserConsole.ts index 6878412bd..c46ae6d5b 100644 --- a/src/modules/WorkStation/Browser/hooks/useBrowserConsole.ts +++ b/src/modules/WorkStation/Browser/hooks/useBrowserConsole.ts @@ -12,6 +12,8 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createLogger } from "@src/hooks/logger"; +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; const log = createLogger("useBrowserConsole"); @@ -116,7 +118,7 @@ export function useBrowserConsole( const [entries, setEntries] = useState([]); const entryIdCounter = useRef(0); - const pollTimerRef = useRef | null>(null); + const pollCoordinator = useMemo(() => new LatestScopedTask(), []); // Generate unique ID const generateId = useCallback(() => { @@ -237,92 +239,97 @@ export function useBrowserConsole( const pollNow = useCallback(async () => { if (!webviewLabel || !sessionId) return; - try { - const rustEntries = await invoke( - "get_webview_console_logs", - { label: webviewLabel } - ); + const scopeKey = JSON.stringify({ sessionId, webviewLabel }); + await pollCoordinator.run(scopeKey, async (context) => { + try { + const rustEntries = await invoke( + "get_webview_console_logs", + { label: webviewLabel } + ); + if (!context.isCurrent()) return; + + if (rustEntries && rustEntries.length > 0) { + const cache = getSessionCache(sessionId); + + // Rate limit: only process up to maxEntriesPerPoll + const limitedEntries = rustEntries.slice(0, maxEntriesPerPoll); + const droppedCount = rustEntries.length - limitedEntries.length; - if (rustEntries && rustEntries.length > 0) { - const cache = getSessionCache(sessionId); - - // Rate limit: only process up to maxEntriesPerPoll - const limitedEntries = rustEntries.slice(0, maxEntriesPerPoll); - const droppedCount = rustEntries.length - limitedEntries.length; - - // Transform entries with truncation - let newEntries: ConsoleEntry[] = limitedEntries.map((entry) => ({ - id: generateId(), - level: (entry.level as LogLevel) || "log", - message: truncateMessage(entry.message || ""), - timestamp: entry.timestamp || Date.now(), - url: entry.url || "", - stack: entry.stack ? truncateMessage(entry.stack) : undefined, - })); - - // Deduplicate: collapse repeated consecutive logs - if (deduplicateRepeated && newEntries.length > 0) { - const dedupedEntries: ConsoleEntry[] = []; - let repeatCount = 0; - let lastEntry: ConsoleEntry | null = - cache.entries.length > 0 - ? cache.entries[cache.entries.length - 1] - : null; - - for (const entry of newEntries) { - if (lastEntry && isDuplicate(lastEntry, entry)) { - repeatCount++; - } else { - // Add repeat indicator to previous entry if needed - if (repeatCount > 0 && dedupedEntries.length > 0) { - const prev = dedupedEntries[dedupedEntries.length - 1]; - prev.message = `${prev.message} [×${repeatCount + 1}]`; + // Transform entries with truncation + let newEntries: ConsoleEntry[] = limitedEntries.map((entry) => ({ + id: generateId(), + level: (entry.level as LogLevel) || "log", + message: truncateMessage(entry.message || ""), + timestamp: entry.timestamp || Date.now(), + url: entry.url || "", + stack: entry.stack ? truncateMessage(entry.stack) : undefined, + })); + + // Deduplicate: collapse repeated consecutive logs + if (deduplicateRepeated && newEntries.length > 0) { + const dedupedEntries: ConsoleEntry[] = []; + let repeatCount = 0; + let lastEntry: ConsoleEntry | null = + cache.entries.length > 0 + ? cache.entries[cache.entries.length - 1] + : null; + + for (const entry of newEntries) { + if (lastEntry && isDuplicate(lastEntry, entry)) { + repeatCount++; + } else { + // Add repeat indicator to previous entry if needed + if (repeatCount > 0 && dedupedEntries.length > 0) { + const prev = dedupedEntries[dedupedEntries.length - 1]; + prev.message = `${prev.message} [×${repeatCount + 1}]`; + } + dedupedEntries.push(entry); + lastEntry = entry; + repeatCount = 0; } - dedupedEntries.push(entry); - lastEntry = entry; - repeatCount = 0; } + + // Handle trailing repeats + if (repeatCount > 0 && dedupedEntries.length > 0) { + const prev = dedupedEntries[dedupedEntries.length - 1]; + prev.message = `${prev.message} [×${repeatCount + 1}]`; + } + + newEntries = dedupedEntries; } - // Handle trailing repeats - if (repeatCount > 0 && dedupedEntries.length > 0) { - const prev = dedupedEntries[dedupedEntries.length - 1]; - prev.message = `${prev.message} [×${repeatCount + 1}]`; + // Add rate limit warning if entries were dropped + if (droppedCount > 0) { + newEntries.push({ + id: generateId(), + level: "warn", + message: `[DevTools] Rate limited: ${droppedCount} log entries dropped`, + timestamp: Date.now(), + url: "", + }); } - newEntries = dedupedEntries; - } + let combined = [...cache.entries, ...newEntries]; + if (combined.length > maxEntries) { + combined = combined.slice(-maxEntries); + } - // Add rate limit warning if entries were dropped - if (droppedCount > 0) { - newEntries.push({ - id: generateId(), - level: "warn", - message: `[DevTools] Rate limited: ${droppedCount} log entries dropped`, - timestamp: Date.now(), - url: "", - }); + updateSessionEntries(sessionId, combined); } - - let combined = [...cache.entries, ...newEntries]; - if (combined.length > maxEntries) { - combined = combined.slice(-maxEntries); + } catch (error) { + // Silently ignore - webview might not exist yet or be closing + if ( + process.env.NODE_ENV === "development" && + !String(error).includes("not found") + ) { + log.debug("[useBrowserConsole] Poll error:", error); } - - updateSessionEntries(sessionId, combined); } - } catch (error) { - // Silently ignore - webview might not exist yet or be closing - if ( - process.env.NODE_ENV === "development" && - !String(error).includes("not found") - ) { - log.debug("[useBrowserConsole] Poll error:", error); - } - } + }); }, [ webviewLabel, sessionId, + pollCoordinator, generateId, maxEntries, maxEntriesPerPoll, @@ -336,27 +343,26 @@ export function useBrowserConsole( // Start/stop polling useEffect(() => { if (!enabled || !webviewLabel || !sessionId || pollInterval <= 0) { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } return; } - // Start polling - pollTimerRef.current = setInterval(pollNow, pollInterval); - - // Initial poll - defer to next tick to avoid setState in effect - const timer = setTimeout(() => pollNow(), 0); - + const poll = startVisibilityAwarePoll({ + intervalMs: pollInterval, + runImmediately: true, + task: pollNow, + }); return () => { - clearTimeout(timer); - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } + poll.stop(); + pollCoordinator.supersede(); }; - }, [enabled, webviewLabel, sessionId, pollInterval, pollNow]); + }, [ + enabled, + pollCoordinator, + pollInterval, + pollNow, + sessionId, + webviewLabel, + ]); // Compute counts from current entries const { errorCount, warningCount } = useMemo(() => { diff --git a/src/modules/WorkStation/Browser/hooks/useBrowserNetworkLogs.ts b/src/modules/WorkStation/Browser/hooks/useBrowserNetworkLogs.ts index 9b91f7c8d..873e14a4b 100644 --- a/src/modules/WorkStation/Browser/hooks/useBrowserNetworkLogs.ts +++ b/src/modules/WorkStation/Browser/hooks/useBrowserNetworkLogs.ts @@ -9,6 +9,8 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createLogger } from "@src/hooks/logger"; +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; const log = createLogger("useBrowserNetworkLogs"); @@ -103,7 +105,7 @@ export function useBrowserNetworkLogs( // Current session's entries (state) const [entries, setEntries] = useState([]); - const pollTimerRef = useRef | null>(null); + const pollCoordinator = useMemo(() => new LatestScopedTask(), []); // Get or create cache entry for a session const getSessionCache = useCallback((sid: string): SessionNetworkCache => { @@ -165,47 +167,52 @@ export function useBrowserNetworkLogs( const pollNow = useCallback(async () => { if (!webviewLabel || !sessionId) return; - try { - const rustEntries = await invoke( - "get_webview_network_logs", - { label: webviewLabel } - ); - - if (rustEntries && rustEntries.length > 0) { - const cache = getSessionCache(sessionId); - - // Transform entries - const newEntries: NetworkEntry[] = rustEntries.map((entry) => ({ - id: entry.id, - type: (entry.type as "fetch" | "xhr") || "fetch", - method: entry.method || "GET", - url: entry.url || "", - startTime: entry.startTime || Date.now(), - status: entry.status, - duration: entry.duration, - size: entry.size, - error: entry.error, - })); - - let combined = [...cache.entries, ...newEntries]; - if (combined.length > maxEntries) { - combined = combined.slice(-maxEntries); + const scopeKey = JSON.stringify({ sessionId, webviewLabel }); + await pollCoordinator.run(scopeKey, async (context) => { + try { + const rustEntries = await invoke( + "get_webview_network_logs", + { label: webviewLabel } + ); + if (!context.isCurrent()) return; + + if (rustEntries && rustEntries.length > 0) { + const cache = getSessionCache(sessionId); + + // Transform entries + const newEntries: NetworkEntry[] = rustEntries.map((entry) => ({ + id: entry.id, + type: (entry.type as "fetch" | "xhr") || "fetch", + method: entry.method || "GET", + url: entry.url || "", + startTime: entry.startTime || Date.now(), + status: entry.status, + duration: entry.duration, + size: entry.size, + error: entry.error, + })); + + let combined = [...cache.entries, ...newEntries]; + if (combined.length > maxEntries) { + combined = combined.slice(-maxEntries); + } + + updateSessionEntries(sessionId, combined); + } + } catch (error) { + // Silently ignore - webview might not exist yet or be closing + if ( + process.env.NODE_ENV === "development" && + !String(error).includes("not found") + ) { + log.debug("[useBrowserNetworkLogs] Poll error:", error); } - - updateSessionEntries(sessionId, combined); - } - } catch (error) { - // Silently ignore - webview might not exist yet or be closing - if ( - process.env.NODE_ENV === "development" && - !String(error).includes("not found") - ) { - log.debug("[useBrowserNetworkLogs] Poll error:", error); } - } + }); }, [ webviewLabel, sessionId, + pollCoordinator, maxEntries, getSessionCache, updateSessionEntries, @@ -214,27 +221,26 @@ export function useBrowserNetworkLogs( // Start/stop polling useEffect(() => { if (!enabled || !webviewLabel || !sessionId || pollInterval <= 0) { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } return; } - // Start polling - pollTimerRef.current = setInterval(pollNow, pollInterval); - - // Initial poll - defer to next tick to avoid setState in effect - const timer = setTimeout(() => pollNow(), 0); - + const poll = startVisibilityAwarePoll({ + intervalMs: pollInterval, + runImmediately: true, + task: pollNow, + }); return () => { - clearTimeout(timer); - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } + poll.stop(); + pollCoordinator.supersede(); }; - }, [enabled, webviewLabel, sessionId, pollInterval, pollNow]); + }, [ + enabled, + pollCoordinator, + pollInterval, + pollNow, + sessionId, + webviewLabel, + ]); // Compute error count from current entries const errorCount = useMemo(() => { diff --git a/src/modules/WorkStation/Browser/hooks/useWebviewDOMTree.ts b/src/modules/WorkStation/Browser/hooks/useWebviewDOMTree.ts index 42564bb5b..22ab89f11 100644 --- a/src/modules/WorkStation/Browser/hooks/useWebviewDOMTree.ts +++ b/src/modules/WorkStation/Browser/hooks/useWebviewDOMTree.ts @@ -11,11 +11,16 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { DEBOUNCE_DELAYS, useDebouncedCallback, } from "@src/hooks/perf/useDebouncedCallback"; +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; const log = createLogger("useWebviewDOMTree"); @@ -164,163 +169,99 @@ export function useWebviewDOMTree( onTreeFetched, } = options; - const [tree, setTree] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [expandedNodes, setExpandedNodes] = useState>( new Set(["/body"]) ); const [highlightedXpath, setHighlightedXpath] = useState(null); - // In-flight guard — prevents dirty-poll tick or navigation from stacking - // concurrent refetches on slow pages (YouTube search with 10k nodes). - const inFlightRef = useRef(false); - // Keep callback ref up to date const onTreeFetchedRef = useRef(onTreeFetched); useEffect(() => { onTreeFetchedRef.current = onTreeFetched; }, [onTreeFetched]); - // Fetch DOM tree - const refresh = useCallback(async () => { - if (!webviewLabel || !enabled) return; - if (inFlightRef.current) return; - - inFlightRef.current = true; - setLoading(true); - setError(null); - - try { - const result = await invoke("get_webview_dom_tree", { - label: webviewLabel, - maxDepth, - }); - - setTree(result); - onTreeFetchedRef.current?.(result); - - // Auto-expand first 2 levels on initial fetch only. - // Functional update preserves prior expandToNode changes made during - // an overlapping async fetch. - if (result) { - setExpandedNodes((currentExpanded) => { - if (currentExpanded.size <= 1) { - return new Set(collectXpathsToDepth(result, 2)); - } - return currentExpanded; - }); - } - } catch (err) { - if (isMissingWebviewError(err)) { - setTree(null); - onTreeFetchedRef.current?.(null); - } else { - const message = err instanceof Error ? err.message : String(err); - setError(message); - } - } finally { - setLoading(false); - inFlightRef.current = false; - } - }, [webviewLabel, enabled, maxDepth]); - - // Initial fetch - useEffect(() => { - if (!enabled || !webviewLabel) return; - - let cancelled = false; - - const doFetch = async () => { - if (inFlightRef.current) return; - inFlightRef.current = true; - setLoading(true); - setError(null); - + const fetchTree = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext + ) => { + const scope = JSON.parse(serializedScope) as { + maxDepth: number; + webviewLabel: string; + }; try { const result = await invoke( "get_webview_dom_tree", { - label: webviewLabel, - maxDepth, + label: scope.webviewLabel, + maxDepth: scope.maxDepth, } ); - - if (cancelled) return; - - setTree(result); - onTreeFetchedRef.current?.(result); - - if (result) { - setExpandedNodes((currentExpanded) => { - if (currentExpanded.size <= 1) { - return new Set(collectXpathsToDepth(result, 2)); - } - return currentExpanded; - }); + if (context.isCurrent()) { + onTreeFetchedRef.current?.(result); + if (result) { + setExpandedNodes((currentExpanded) => { + if (currentExpanded.size <= 1) { + return new Set(collectXpathsToDepth(result, 2)); + } + return currentExpanded; + }); + } } - } catch (err) { - if (cancelled) return; - if (isMissingWebviewError(err)) { - setTree(null); - onTreeFetchedRef.current?.(null); - } else { - const message = err instanceof Error ? err.message : String(err); - setError(message); + return result; + } catch (error) { + if (isMissingWebviewError(error)) { + if (context.isCurrent()) onTreeFetchedRef.current?.(null); + return null; } - } finally { - if (!cancelled) setLoading(false); - inFlightRef.current = false; + log.error("[useWebviewDOMTree] Fetch failed:", error); + throw error; } - }; - - doFetch(); - - return () => { - cancelled = true; - }; - }, [enabled, webviewLabel, maxDepth]); - - // Smart dirty-check polling. - // - // Rather than unconditionally refetching the whole tree every tick, we - // poll a cheap boolean command that returns `true` only when - // MutationObserver in the webview recorded structural changes since the - // last read. On an idle page, this costs one eval per tick; the - // expensive walk + JSON.stringify only runs when the DOM actually - // changed. - // - // If a refresh is already in-flight (initial fetch, navigation debounce, - // user click), the tick skips — `refresh` itself also guards via - // `inFlightRef`, this is just an extra short-circuit to avoid noise. + }, + [] + ); + const treeScopeKey = + enabled && webviewLabel ? JSON.stringify({ maxDepth, webviewLabel }) : null; + const treeResource = useAsyncResource({ + enabled: Boolean(treeScopeKey), + fetcher: fetchTree, + initialData: null, + scopeKey: treeScopeKey, + }); + const tree = treeResource.data; + const refresh = treeResource.refresh; + const reloadTree = treeResource.reload; + + // Smart dirty-check polling. The visibility-aware recursive timer retains no + // hidden-page timer and never overlaps a tree fetch. useEffect(() => { if (!enabled || !webviewLabel || pollInterval <= 0) return; - - let cancelled = false; - - const tick = async () => { - if (cancelled) return; - if (inFlightRef.current) return; - try { - const dirty = await invoke("check_webview_dom_dirty", { - label: webviewLabel, - }); - if (cancelled) return; - if (dirty) { - await refresh(); + let active = true; + const poll = startVisibilityAwarePoll({ + intervalMs: pollInterval, + task: async () => { + try { + const dirty = await invoke("check_webview_dom_dirty", { + label: webviewLabel, + }); + if (active && dirty) { + await reloadTree({ background: true }); + } + } catch { + // The webview may have been torn down between scheduling and invoke. } - } catch { - // Swallow — the webview may have been torn down between the - // interval scheduling and the actual call. Next tick will retry. - } - }; - - const interval = setInterval(tick, pollInterval); + }, + }); return () => { - cancelled = true; - clearInterval(interval); + active = false; + poll.stop(); }; - }, [enabled, webviewLabel, pollInterval, refresh]); + }, [enabled, pollInterval, reloadTree, webviewLabel]); + + /* + * Tree loading is owned by useAsyncResource above. Interaction state below + * remains local because expansion and hover are user intent, not fetch state. + */ // Toggle expanded state const toggleExpanded = useCallback((xpath: string) => { @@ -446,8 +387,8 @@ export function useWebviewDOMTree( return { tree, - loading, - error, + loading: treeResource.loading, + error: treeResource.error, refresh, expandedNodes, toggleExpanded, diff --git a/src/modules/WorkStation/Browser/hooks/useWebviewInspector.ts b/src/modules/WorkStation/Browser/hooks/useWebviewInspector.ts index 0cc10a9de..78088336e 100644 --- a/src/modules/WorkStation/Browser/hooks/useWebviewInspector.ts +++ b/src/modules/WorkStation/Browser/hooks/useWebviewInspector.ts @@ -7,9 +7,11 @@ * - Clear selection */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createLogger } from "@src/hooks/logger"; +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; const log = createLogger("useWebviewInspector"); @@ -134,6 +136,7 @@ export function useWebviewInspector( null ); const [isLoading, setIsLoading] = useState(false); + const selectionCoordinator = useMemo(() => new LatestScopedTask(), []); // Track previous selection to detect changes const prevSelectionRef = useRef(null); @@ -199,28 +202,31 @@ export function useWebviewInspector( const refreshSelection = useCallback(async () => { if (!webviewLabel) return; - try { - const element = await invoke( - "get_selected_element_info", - { label: webviewLabel } - ); - - if (element) { - // Check if selection changed - const selectionKey = element.xpath || element.selector; - if (selectionKey !== prevSelectionRef.current) { - prevSelectionRef.current = selectionKey; - setSelectedElement(element); - onElementSelectedRef.current?.(element); + await selectionCoordinator.run(webviewLabel, async (context) => { + try { + const element = await invoke( + "get_selected_element_info", + { label: webviewLabel } + ); + if (!context.isCurrent()) return; + + if (element) { + // Check if selection changed + const selectionKey = element.xpath || element.selector; + if (selectionKey !== prevSelectionRef.current) { + prevSelectionRef.current = selectionKey; + setSelectedElement(element); + onElementSelectedRef.current?.(element); + } } + } catch (error) { + log.warn( + "[useWebviewInspector] Polling error:", + error instanceof Error ? error.message : String(error) + ); } - } catch (error) { - log.warn( - "[useWebviewInspector] Polling error:", - error instanceof Error ? error.message : String(error) - ); - } - }, [webviewLabel]); + }); + }, [selectionCoordinator, webviewLabel]); // Clear selection const clearSelection = useCallback(async () => { @@ -241,14 +247,23 @@ export function useWebviewInspector( return; } - // Immediate check - refreshSelection(); - - // Then poll at interval - const intervalId = setInterval(refreshSelection, pollInterval); - - return () => clearInterval(intervalId); - }, [isInspectMode, webviewLabel, enabled, pollInterval, refreshSelection]); + const poll = startVisibilityAwarePoll({ + intervalMs: pollInterval, + runImmediately: true, + task: refreshSelection, + }); + return () => { + poll.stop(); + selectionCoordinator.supersede(); + }; + }, [ + enabled, + isInspectMode, + pollInterval, + refreshSelection, + selectionCoordinator, + webviewLabel, + ]); // Cleanup on unmount or webview change useEffect(() => { From fbdcae4540f12e4dee473245b23bc20ed8718758 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 23 Jul 2026 22:32:27 +0800 Subject: [PATCH 8/8] docs(audit): record async lifecycle verdicts Pre-commit hook ran. Total eslint: 10, total circular: 0 --- .../AsyncResourceLifecycle.md | 109 ++++++++++++++++++ .../AsyncResourceConsumers.md | 42 +++++++ .../AsyncResourceLifecycle.md | 35 ++++++ 3 files changed, 186 insertions(+) create mode 100644 docs/architecture-audit-2026-07-23/AsyncResourceLifecycle.md create mode 100644 docs/frontend-ui-audit-2026-07-23/AsyncResourceConsumers.md create mode 100644 docs/org2-performance-guard-2026-07-23/AsyncResourceLifecycle.md diff --git a/docs/architecture-audit-2026-07-23/AsyncResourceLifecycle.md b/docs/architecture-audit-2026-07-23/AsyncResourceLifecycle.md new file mode 100644 index 000000000..f299b22a7 --- /dev/null +++ b/docs/architecture-audit-2026-07-23/AsyncResourceLifecycle.md @@ -0,0 +1,109 @@ +# Architecture Audit — Async Resource Lifecycle + +**Scope:** shared async-resource state, visibility-aware polling, and the migrated TypeScript query owners. +**Date:** 2026-07-23 +**Auditor:** ORGII implementation session + +## Acceptance criteria + +- One owner for `data / error / loading / refreshing / reload` state. +- Equal in-flight scope loads coalesce; explicit refresh starts a new generation. +- A completion from an old repo, workspace, project, filter, language, or webview cannot commit. +- Disabled and changed scopes cannot display data from the previous scope. +- Background polling pauses while hidden, never overlaps, and stops cleanly. +- Mutation/action loading remains separate from query loading. +- Caches are bounded and include the complete resource identity. +- TypeScript, focused lint, lifecycle tests, and `git diff --check` pass. + +## Layer 1 — Compilation correctness + +- `pnpm run typecheck`: passed. +- Focused ESLint across all changed frontend files: passed. +- Focused Vitest run: 10 files and 118 tests passed. +- `git diff --check`: passed. + +## Layer 2 — Dead code and structural deduplication + +- Removed the unused `useAsyncData` abstraction. +- `useAsyncResource` is now the single generic owner for request state and generation fencing. +- `useVisibilityPolledData` composes the same owner instead of maintaining a second polling-specific state machine. +- Duplicate initial-load/manual-refresh implementations were removed from the migrated hooks. + +## Layer 3 — Naming consistency + +| Term | Meaning | Verdict | +| --- | --- | --- | +| `scopeKey` | Complete identity of the visible resource | Explicit and consistent | +| `reload` | Load or background-revalidate, joining an equal in-flight generation | Explicit | +| `refresh` | User-requested superseding generation | Explicit | +| `loading` | Initial load or foreground refresh | Kept for consumer compatibility | +| `refreshing` | Existing data is retained during foreground refresh | Separate state, not overloaded with action progress | +| `operationLoading` / `gatewayLoading` | Explicit user mutation in progress | Correctly remains outside the query resource | + +## Layer 4 — Semantic overloading + +- Query state and mutation state remain separate in Stash, Gateway, Work Item, and provider flows. +- `background` controls presentation only; it does not weaken generation checks. +- `publish` means an intermediate current-scope cache value, not completion. +- `setData` is limited to optimistic/current-resource updates and cannot write while the resource is disabled. + +## Layer 5 — Default branch analysis + +| Condition | Result | +| --- | --- | +| `enabled === false` or `scopeKey === null` | Reset to initial data/status and supersede active work | +| Same automatic scope already in flight | Join the existing promise | +| Manual refresh | Supersede and start a new generation | +| Scope changes | Hide old data immediately and reject late completion | +| Fetch rejects | Preserve current-scope data, expose normalized error | +| Hidden document | Retain no polling timer | +| Visibility returns | Run one immediate catch-up pass | +| Poll stops during DOM dirty-check | Effect-local active fence prevents a post-teardown reload | + +## Layer 6 — Cross-domain concept leakage + +- `useAsyncResource`, `LatestScopedTask`, and `startVisibilityAwarePoll` contain no project, Git, LSP, provider, or webview domain imports. +- Domain fetchers own payload parsing and cache policy; the generic lifecycle layer owns only scheduling and commit eligibility. +- Tauri and HTTP command names remain at their domain call sites. + +## Layer 7 — New developer confusion test + +- The hook contract documents the difference between automatic load, manual refresh, background reload, and intermediate cache publish. +- A resource's visible state is derived from the current `scopeKey`; consumers do not need local cancellation refs. +- Action states are visibly named and remain local where they represent distinct user operations. + +## Layer 8 — Wire protocol and serialization + +- No backend command schema or wire payload was changed. +- Serialized scope keys are frontend-only coordinator identities and are parsed by the matching local fetcher. +- Scope keys include all relevant identity fields, including repo ID/path, connection/team/surface, filter, language, and webview label/depth. + +## Layer 9 — Init parity + +| Entry path | Resource owner | Generation guard | Error normalization | +| --- | --- | ---: | ---: | +| Automatic first load | `useAsyncResource` | yes | yes | +| Manual refresh | `useAsyncResource.refresh` | yes, superseding | yes | +| Background poll | `reload({ background: true })` | yes | yes | +| Cache then live result | `context.publish` + final return | yes | yes | +| Disabled/unmounted scope | effect cleanup | yes | n/a | + +## Layer 10 — Resolver symmetry + +- Every migrated resource uses the same scope value for loading, visibility, stale-result rejection, and optimistic updates. +- Cached and live values use the same resource identity. +- The commit-diff cache now includes repository identity as well as commit SHA, eliminating cross-repository collisions. + +## Systematic sweep + +- Searched query hooks for repeated `loading/error/data` owners and manual cancellation/request-ID patterns. +- Searched active runtime code for `setInterval`; migrated non-critical IPC polling peers. +- Kept animation clocks, debounces, durable persistence heartbeats, editor/document FSMs, user-triggered searches, and mutation progress states with their specialized owners. +- Remaining literal `setInterval` hits in the audited directories are UI clocks/simulations or domain-specific lifecycles, not duplicate query polling. + +## Completion verdict + +- One teardown issue found during audit was fixed: DOM dirty polling now cannot schedule a tree reload after its effect has stopped. +- Relevant architecture layers 1–10 were checked; backend init, schema migration, and resolver-chain changes were not applicable because no backend or wire contract changed. + +**Architecture verdict: pass for the audited async-resource and polling scope.** diff --git a/docs/frontend-ui-audit-2026-07-23/AsyncResourceConsumers.md b/docs/frontend-ui-audit-2026-07-23/AsyncResourceConsumers.md new file mode 100644 index 000000000..d2626be93 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-23/AsyncResourceConsumers.md @@ -0,0 +1,42 @@ +# Frontend UI Audit — Async Resource Consumers + +**Files:** `src/engines/ChatPanel/panels/ProjectPanelView.tsx`, `src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx` +**Date:** 2026-07-23 +**Auditor:** ORGII implementation session + +The diff in both files changes data ownership only. It does not add or modify rendered JSX, class names, interactive elements, or layout. + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| Changed hunks | none | keep | The refactor introduces no raw interactive or structural HTML. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| Changed hunks | none | keep | No Tailwind or CSS-variable class changed. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| Changed hunks | none | keep | No size or color literal changed. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| Changed hunks | none | keep | No rendered element or interaction contract changed. | — | + +## D5 — Visual Patterns Observed + +- No new visual pattern was introduced. +- The shared abstraction is a data-lifecycle hook and is not a design-system component candidate. + +## Summary + +- 0 fixes recommended +- 0 kept exceptions requiring future review +- 0 abstract UI candidates diff --git a/docs/org2-performance-guard-2026-07-23/AsyncResourceLifecycle.md b/docs/org2-performance-guard-2026-07-23/AsyncResourceLifecycle.md new file mode 100644 index 000000000..549985ff2 --- /dev/null +++ b/docs/org2-performance-guard-2026-07-23/AsyncResourceLifecycle.md @@ -0,0 +1,35 @@ +# Performance Guard — Async Resource Lifecycle + +**Scope:** migrated resource fetches, background polling, request caches, and multi-scope lifecycle. +**Date:** 2026-07-23 + +## Lifecycle matrix + +| State | Required behavior | Audited behavior | +| --- | --- | --- | +| Visible and active | Fetch on demand at configured cadence | Recursive polling; next delay starts after settlement | +| Visible and idle | Avoid full reload unless dirty or scheduled safety refresh | DOM uses dirty-check; other retained polls are bounded safety refreshes | +| Hidden | No non-critical polling timer | Timer cleared; visibility return triggers one catch-up pass | +| Scope switch | Previous data and completion cannot appear | Complete scope key plus generation fence | +| Unmount/disable | Stop timer/listener and reject late commits | Poll controller cleanup plus coordinator supersede | +| Offline/error | Set current resource error without a retry storm | No automatic tight retry; configured cadence resumes | +| Repeated mount | No app-lifetime accumulation | Per-hook coordinator owns one active promise; listeners/timers dispose | + +## Findings and evidence + +| Area | Verdict | Evidence | Change or reason kept | Verification | +| --- | --- | --- | --- | --- | +| Background work | fix | DOM, Inspector, Console, Network, LSP, Git auto-fetch, and Gateway used or consumed polling | Replaced non-critical intervals with visibility-aware non-overlapping recursive polling; DOM teardown received an additional active fence | Visibility controller tests plus focused lifecycle review | +| Memory | fix/keep | Resource retains one state and one active promise; Console cache is 10 sessions × 500 rows, Network is 10 × 200, commit cache is 50 | Preserved existing caps; removed duplicate per-resource state; no new unbounded collection | Unit tests, code inspection | +| Scope/isolation | fix | Previous implementations used local mounted/cancelled flags or incomplete commit cache identity | Complete scope keys and generations now gate every commit; commit cache includes repo identity | Stale-filter, stale-scope, superseding-refresh tests | +| Rendering/hot path | fix | Foreground state was repeatedly toggled by background refreshes | Background reload retains data and avoids spinner flashes; derived grouping remains memoized | Async-resource and polling hook tests | + +## Verification + +- `pnpm run typecheck`: passed. +- Focused ESLint: passed. +- Focused Vitest: 10 files, 118 tests passed. +- `git diff --check`: passed. +- Real Tauri visible/hidden IPC and CPU measurement was not run in this audit environment. + +**Performance verdict: blocked only on real Tauri runtime measurement; static lifecycle gates, compilation, lint, and focused regression tests pass.**