diff --git a/docs/screenshots/home-dashboard-usage-20260313.png b/docs/screenshots/home-dashboard-usage-20260313.png new file mode 100644 index 000000000..4672781c2 Binary files /dev/null and b/docs/screenshots/home-dashboard-usage-20260313.png differ diff --git a/src-tauri/src/shared/codex_core.rs b/src-tauri/src/shared/codex_core.rs index fc74fc6d6..f1c925151 100644 --- a/src-tauri/src/shared/codex_core.rs +++ b/src-tauri/src/shared/codex_core.rs @@ -18,9 +18,18 @@ use crate::rules; use crate::shared::account::{build_account_response, read_auth_account}; use crate::types::WorkspaceEntry; -const LOGIN_START_TIMEOUT: Duration = Duration::from_secs(30); -#[allow(dead_code)] -const MAX_INLINE_IMAGE_BYTES: u64 = 50 * 1024 * 1024; +const LOGIN_START_TIMEOUT: Duration = Duration::from_secs(30); +#[allow(dead_code)] +const MAX_INLINE_IMAGE_BYTES: u64 = 50 * 1024 * 1024; +const THREAD_LIST_SOURCE_KINDS: &[&str] = &[ + "cli", + "vscode", + "appServer", + "subAgentReview", + "subAgentCompact", + "subAgentThreadSpawn", + "unknown", +]; #[allow(dead_code)] fn image_mime_type_for_path(path: &str) -> Option<&'static str> { @@ -229,33 +238,23 @@ pub(crate) async fn fork_thread_core( .await } -pub(crate) async fn list_threads_core( - sessions: &Mutex>>, - workspace_id: String, - cursor: Option, - limit: Option, +pub(crate) async fn list_threads_core( + sessions: &Mutex>>, + workspace_id: String, + cursor: Option, + limit: Option, sort_key: Option, ) -> Result { let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ - "cursor": cursor, - "limit": limit, - "sortKey": sort_key, - // Keep interactive and sub-agent sessions visible across CLI versions so - // thread/list refreshes do not drop valid historical conversations. - "sourceKinds": [ - "cli", - "vscode", - "appServer", - // Intentionally exclude generic "subAgent" to avoid pulling - // parentless internal sessions (for example memory consolidation). - // Keep only explicit parent-linked sub-agent kinds so internal - // background jobs (for example memory consolidation) stay hidden. - "subAgentReview", - "subAgentCompact", - "subAgentThreadSpawn", - "unknown" - ] + let params = json!({ + "cursor": cursor, + "limit": limit, + "sortKey": sort_key, + // Keep interactive and sub-agent sessions visible across CLI versions so + // thread/list refreshes do not drop valid historical conversations. + // Intentionally exclude generic "subAgent" so parentless internal jobs + // (for example memory consolidation) do not leak back into app state. + "sourceKinds": THREAD_LIST_SOURCE_KINDS }); session .send_request_for_workspace(&workspace_id, "thread/list", params) @@ -936,4 +935,12 @@ mod tests { ); assert_eq!(params.get("serviceTier"), Some(&json!("fast"))); } + + #[test] + fn thread_list_source_kinds_exclude_generic_subagent_and_keep_explicit_variants() { + assert!(!THREAD_LIST_SOURCE_KINDS.contains(&"subAgent")); + assert!(THREAD_LIST_SOURCE_KINDS.contains(&"subAgentReview")); + assert!(THREAD_LIST_SOURCE_KINDS.contains(&"subAgentCompact")); + assert!(THREAD_LIST_SOURCE_KINDS.contains(&"subAgentThreadSpawn")); + } } diff --git a/src/features/app/components/MainApp.tsx b/src/features/app/components/MainApp.tsx index 8ea9d9036..85e22abe1 100644 --- a/src/features/app/components/MainApp.tsx +++ b/src/features/app/components/MainApp.tsx @@ -47,6 +47,7 @@ import { useMainAppSidebarMenuOrchestration } from "@app/hooks/useMainAppSidebar import { useMainAppWorktreeState } from "@app/hooks/useMainAppWorktreeState"; import { useMainAppWorkspaceActions } from "@app/hooks/useMainAppWorkspaceActions"; import { useMainAppWorkspaceLifecycle } from "@app/hooks/useMainAppWorkspaceLifecycle"; +import { useHomeAccount } from "@app/hooks/useHomeAccount"; import type { ComposerEditorSettings, ServiceTier, @@ -1170,6 +1171,20 @@ export default function MainApp() { const activeRateLimits = activeWorkspaceId ? rateLimitsByWorkspace[activeWorkspaceId] ?? null : null; + const { + homeAccount, + homeRateLimits, + } = useHomeAccount({ + showHome, + usageWorkspaceId, + workspaces, + threadsByWorkspace, + threadListLoadingByWorkspace, + rateLimitsByWorkspace, + accountByWorkspace, + refreshAccountInfo, + refreshAccountRateLimits, + }); const activeTokenUsage = activeThreadId ? tokenUsageByThread[activeThreadId] ?? null : null; @@ -1707,6 +1722,8 @@ export default function MainApp() { approvals, activeRateLimits, activeAccount, + homeRateLimits, + homeAccount, accountSwitching, onSwitchAccount: handleSwitchAccount, onCancelSwitchAccount: handleCancelSwitchAccount, diff --git a/src/features/app/components/Sidebar.test.tsx b/src/features/app/components/Sidebar.test.tsx index 893e447bb..64800bcb7 100644 --- a/src/features/app/components/Sidebar.test.tsx +++ b/src/features/app/components/Sidebar.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createRef } from "react"; import { Sidebar } from "./Sidebar"; @@ -72,34 +71,22 @@ const baseProps = { describe("Sidebar", () => { it("toggles the search bar from the header icon", () => { - vi.useFakeTimers(); render(); const toggleButton = screen.getByRole("button", { name: "Toggle search" }); expect(screen.queryByLabelText("Search projects")).toBeNull(); - act(() => { - fireEvent.click(toggleButton); - }); + fireEvent.click(toggleButton); const input = screen.getByLabelText("Search projects") as HTMLInputElement; expect(input).toBeTruthy(); - act(() => { - fireEvent.change(input, { target: { value: "alpha" } }); - vi.runOnlyPendingTimers(); - }); + fireEvent.change(input, { target: { value: "alpha" } }); expect(input.value).toBe("alpha"); - act(() => { - fireEvent.click(toggleButton); - vi.runOnlyPendingTimers(); - }); + fireEvent.click(toggleButton); expect(screen.queryByLabelText("Search projects")).toBeNull(); - act(() => { - fireEvent.click(toggleButton); - vi.runOnlyPendingTimers(); - }); + fireEvent.click(toggleButton); const reopened = screen.getByLabelText("Search projects") as HTMLInputElement; expect(reopened.value).toBe(""); }); @@ -141,6 +128,31 @@ describe("Sidebar", () => { expect(onSetThreadListOrganizeMode).toHaveBeenCalledWith("threads_only"); }); + it("renders available credits in the footer when present", () => { + render( + , + ); + + const creditsLabel = screen.getByText(/^Available credits:/); + expect(creditsLabel.textContent ?? "").toContain("120"); + }); + it("renders threads-only mode as a global chronological list", () => { const older = Date.now() - 10_000; const newer = Date.now(); diff --git a/src/features/app/hooks/useHomeAccount.test.tsx b/src/features/app/hooks/useHomeAccount.test.tsx new file mode 100644 index 000000000..57075c4b6 --- /dev/null +++ b/src/features/app/hooks/useHomeAccount.test.tsx @@ -0,0 +1,949 @@ +// @vitest-environment jsdom +import { useState } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { + AccountSnapshot, + RateLimitSnapshot, + ThreadSummary, + WorkspaceInfo, +} from "@/types"; +import { + resolveHomeAccountWorkspaceId, + useHomeAccount, +} from "./useHomeAccount"; + +function makeWorkspace( + id: string, + overrides: Partial = {}, +): WorkspaceInfo { + return { + id, + name: id, + path: `/tmp/${id}`, + connected: true, + settings: { + sidebarCollapsed: false, + }, + ...overrides, + }; +} + +function makeAccount( + overrides: Partial = {}, +): AccountSnapshot { + return { + type: "chatgpt", + email: "user@example.com", + planType: "pro", + requiresOpenaiAuth: false, + ...overrides, + }; +} + +function makeRateLimits( + overrides: Partial = {}, +): RateLimitSnapshot { + return { + primary: { + usedPercent: 42, + windowDurationMins: 300, + resetsAt: 1_700_000_000, + }, + secondary: null, + credits: null, + planType: "pro", + ...overrides, + }; +} + +function makeThread( + id: string, + updatedAt: number, + overrides: Partial = {}, +): ThreadSummary { + return { + id, + name: id, + updatedAt, + ...overrides, + }; +} + +function makeThreadListLoadingState( + workspaces: WorkspaceInfo[], + isLoading = false, +): Record { + return Object.fromEntries( + workspaces.map((workspace) => [workspace.id, isLoading]), + ); +} + +describe("resolveHomeAccountWorkspaceId", () => { + it("prefers the workspace selected from Home usage controls", () => { + expect( + resolveHomeAccountWorkspaceId({ + usageWorkspaceId: "ws-2", + workspaces: [makeWorkspace("ws-1"), makeWorkspace("ws-2")], + threadsByWorkspace: {}, + rateLimitsByWorkspace: { "ws-1": makeRateLimits() }, + accountByWorkspace: { "ws-1": makeAccount() }, + }), + ).toBe("ws-2"); + }); + + it("prefers the most recently active connected workspace with account data for the All workspaces usage filter", () => { + expect( + resolveHomeAccountWorkspaceId({ + usageWorkspaceId: null, + workspaces: [makeWorkspace("ws-1"), makeWorkspace("ws-2")], + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 10)], + "ws-2": [makeThread("thread-2", 20)], + }, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 30, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + "ws-2": makeRateLimits({ primary: { usedPercent: 60, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "older@example.com" }), + "ws-2": makeAccount({ email: "newer@example.com" }), + }, + }), + ).toBe("ws-2"); + }); + + it("uses workspace order as a deterministic tiebreaker when activity timestamps match", () => { + expect( + resolveHomeAccountWorkspaceId({ + usageWorkspaceId: "missing", + workspaces: [makeWorkspace("ws-1"), makeWorkspace("ws-2")], + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 20)], + "ws-2": [makeThread("thread-2", 20)], + }, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits(), + "ws-2": makeRateLimits(), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "first@example.com" }), + "ws-2": makeAccount({ email: "second@example.com" }), + }, + }), + ).toBe("ws-1"); + }); + + it("ignores empty cached rate-limit snapshots when falling back from a stale selection", () => { + expect( + resolveHomeAccountWorkspaceId({ + usageWorkspaceId: "missing", + workspaces: [makeWorkspace("ws-1"), makeWorkspace("ws-2")], + threadsByWorkspace: {}, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ + primary: null, + secondary: null, + credits: null, + planType: null, + }), + "ws-2": makeRateLimits(), + }, + accountByWorkspace: {}, + }), + ).toBe("ws-2"); + }); + + it("prefers connected workspaces over disconnected cached data", () => { + expect( + resolveHomeAccountWorkspaceId({ + usageWorkspaceId: "missing", + workspaces: [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2"), + ], + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 20)], + "ws-2": [makeThread("thread-2", 10)], + }, + rateLimitsByWorkspace: { "ws-1": makeRateLimits() }, + accountByWorkspace: {}, + }), + ).toBe("ws-2"); + }); + + it("prefers connected workspaces with current data over disconnected cached data", () => { + expect( + resolveHomeAccountWorkspaceId({ + usageWorkspaceId: "missing", + workspaces: [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2"), + ], + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 20)], + "ws-2": [makeThread("thread-2", 10)], + }, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + "ws-2": makeRateLimits({ primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "stale@example.com" }), + "ws-2": makeAccount({ email: "current@example.com" }), + }, + }), + ).toBe("ws-2"); + }); + + it("skips placeholder unknown account snapshots when later workspaces have real data", () => { + expect( + resolveHomeAccountWorkspaceId({ + usageWorkspaceId: "missing", + workspaces: [makeWorkspace("ws-1"), makeWorkspace("ws-2")], + threadsByWorkspace: {}, + rateLimitsByWorkspace: {}, + accountByWorkspace: { + "ws-1": makeAccount({ + type: "unknown", + email: null, + planType: null, + }), + "ws-2": makeAccount(), + }, + }), + ).toBe("ws-2"); + }); +}); + +describe("useHomeAccount", () => { + it("returns Home account props for the All workspaces usage filter", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + const workspaces = [ + makeWorkspace("ws-1"), + makeWorkspace("ws-2"), + ]; + + const { result } = renderHook(() => + useHomeAccount({ + showHome: true, + usageWorkspaceId: null, + workspaces, + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 10)], + "ws-2": [makeThread("thread-2", 20)], + }, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces), + rateLimitsByWorkspace: { "ws-1": makeRateLimits(), "ws-2": makeRateLimits() }, + accountByWorkspace: { "ws-1": makeAccount(), "ws-2": makeAccount({ email: "recent@example.com" }) }, + refreshAccountInfo, + refreshAccountRateLimits, + }), + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-2"); + expect(result.current.homeAccountWorkspace?.name).toBe("ws-2"); + expect(result.current.homeAccount?.email).toBe("recent@example.com"); + expect(result.current.homeRateLimits?.primary?.usedPercent).toBe(42); + + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenCalledWith("ws-2"); + expect(refreshAccountRateLimits).toHaveBeenCalledWith("ws-2"); + }); + }); + + it("keeps the aggregate Home account workspace stable across thread activity updates", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + const workspaces = [ + makeWorkspace("ws-1"), + makeWorkspace("ws-2"), + ]; + + const { result, rerender } = renderHook( + ({ + threadsByWorkspace, + }: { + threadsByWorkspace: Record; + }) => + useHomeAccount({ + showHome: true, + usageWorkspaceId: null, + workspaces, + threadsByWorkspace, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces), + rateLimitsByWorkspace: { "ws-1": makeRateLimits(), "ws-2": makeRateLimits() }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "older@example.com" }), + "ws-2": makeAccount({ email: "recent@example.com" }), + }, + refreshAccountInfo, + refreshAccountRateLimits, + }), + { + initialProps: { + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 10)], + "ws-2": [makeThread("thread-2", 20)], + }, + }, + }, + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-2"); + + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenCalledTimes(1); + expect(refreshAccountRateLimits).toHaveBeenCalledTimes(1); + }); + + refreshAccountInfo.mockClear(); + refreshAccountRateLimits.mockClear(); + + rerender({ + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 30)], + "ws-2": [makeThread("thread-2", 20)], + }, + }); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-2"); + expect(result.current.homeAccountWorkspace?.name).toBe("ws-2"); + expect(result.current.homeAccount?.email).toBe("recent@example.com"); + expect(refreshAccountInfo).not.toHaveBeenCalled(); + expect(refreshAccountRateLimits).not.toHaveBeenCalled(); + }); + + it("returns Home account props from the selected workspace and refreshes them on Home", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + const workspaces = [ + makeWorkspace("ws-1"), + makeWorkspace("ws-2", { connected: false }), + ]; + + const { result } = renderHook(() => + useHomeAccount({ + showHome: true, + usageWorkspaceId: "ws-1", + workspaces, + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 10)], + "ws-2": [makeThread("thread-2", 20)], + }, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces), + rateLimitsByWorkspace: { "ws-1": makeRateLimits() }, + accountByWorkspace: { "ws-1": makeAccount() }, + refreshAccountInfo, + refreshAccountRateLimits, + }), + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + expect(result.current.homeAccountWorkspace?.name).toBe("ws-1"); + expect(result.current.homeAccount?.email).toBe("user@example.com"); + expect(result.current.homeRateLimits?.primary?.usedPercent).toBe(42); + + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenCalledWith("ws-1"); + expect(refreshAccountRateLimits).toHaveBeenCalledWith("ws-1"); + }); + }); + + it("refreshes the first connected workspace when a stale selection points elsewhere", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + + const { result } = renderHook(() => + useHomeAccount({ + showHome: true, + usageWorkspaceId: "missing", + workspaces: [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2"), + ], + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 20)], + "ws-2": [makeThread("thread-2", 10)], + }, + threadListLoadingByWorkspace: { + "ws-1": false, + "ws-2": false, + }, + rateLimitsByWorkspace: { "ws-1": makeRateLimits() }, + accountByWorkspace: { "ws-1": makeAccount() }, + refreshAccountInfo, + refreshAccountRateLimits, + }), + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-2"); + expect(result.current.homeAccountWorkspace?.name).toBe("ws-2"); + expect(result.current.homeAccount).toBeNull(); + expect(result.current.homeRateLimits).toBeNull(); + + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenCalledWith("ws-2"); + expect(refreshAccountRateLimits).toHaveBeenCalledWith("ws-2"); + }); + }); + + it("does not refresh account state when Home is hidden", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + + renderHook(() => + useHomeAccount({ + showHome: false, + usageWorkspaceId: "ws-1", + workspaces: [makeWorkspace("ws-1")], + threadsByWorkspace: { "ws-1": [makeThread("thread-1", 10)] }, + threadListLoadingByWorkspace: { "ws-1": false }, + rateLimitsByWorkspace: { "ws-1": makeRateLimits() }, + accountByWorkspace: { "ws-1": makeAccount() }, + refreshAccountInfo, + refreshAccountRateLimits, + }), + ); + + await waitFor(() => { + expect(refreshAccountInfo).not.toHaveBeenCalled(); + expect(refreshAccountRateLimits).not.toHaveBeenCalled(); + }); + }); + + it("does not refresh twice when same-tick refresh callbacks trigger a rerender", async () => { + const infoCalls: Array<{ workspaceId: string; tick: number }> = []; + const rateCalls: Array<{ workspaceId: string; tick: number }> = []; + const workspaces = [ + makeWorkspace("ws-1"), + makeWorkspace("ws-2"), + ]; + + const { result } = renderHook(() => { + const [tick, setTick] = useState(0); + + useHomeAccount({ + showHome: true, + usageWorkspaceId: null, + workspaces, + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 10)], + "ws-2": [makeThread("thread-2", 20)], + }, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces), + rateLimitsByWorkspace: { "ws-1": makeRateLimits(), "ws-2": makeRateLimits() }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "older@example.com" }), + "ws-2": makeAccount({ email: "recent@example.com" }), + }, + refreshAccountInfo: (workspaceId: string) => { + infoCalls.push({ workspaceId, tick }); + if (tick === 0) { + setTick(1); + } + }, + refreshAccountRateLimits: (workspaceId: string) => { + rateCalls.push({ workspaceId, tick }); + if (tick === 0) { + setTick((current) => (current === 0 ? 1 : current)); + } + }, + }); + + return { tick }; + }); + + await waitFor(() => { + expect(result.current.tick).toBe(1); + }); + + expect(infoCalls).toEqual([{ workspaceId: "ws-2", tick: 0 }]); + expect(rateCalls).toEqual([{ workspaceId: "ws-2", tick: 0 }]); + }); + + it("recomputes the aggregate workspace after thread lists finish hydrating", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + const workspaces = [ + makeWorkspace("ws-1"), + makeWorkspace("ws-2"), + ]; + + const { result, rerender } = renderHook( + ({ + threadsByWorkspace, + threadListLoadingByWorkspace, + }: { + threadsByWorkspace: Record; + threadListLoadingByWorkspace: Record; + }) => + useHomeAccount({ + showHome: true, + usageWorkspaceId: null, + workspaces, + threadsByWorkspace, + threadListLoadingByWorkspace, + rateLimitsByWorkspace: { "ws-1": makeRateLimits(), "ws-2": makeRateLimits() }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "older@example.com" }), + "ws-2": makeAccount({ email: "recent@example.com" }), + }, + refreshAccountInfo, + refreshAccountRateLimits, + }), + { + initialProps: { + threadsByWorkspace: {}, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces, true), + }, + }, + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + + rerender({ + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 10)], + "ws-2": [makeThread("thread-2", 20)], + }, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces), + }); + + await waitFor(() => { + expect(result.current.homeAccountWorkspaceId).toBe("ws-2"); + }); + + expect(result.current.homeAccount?.email).toBe("recent@example.com"); + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenLastCalledWith("ws-2"); + expect(refreshAccountRateLimits).toHaveBeenLastCalledWith("ws-2"); + }); + }); + + it("falls back when the retained aggregate workspace loses usable account data", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + const workspaces = [ + makeWorkspace("ws-1"), + makeWorkspace("ws-2"), + ]; + + const { result, rerender } = renderHook( + ({ + rateLimitsByWorkspace, + accountByWorkspace, + }: { + rateLimitsByWorkspace: Record; + accountByWorkspace: Record; + }) => + useHomeAccount({ + showHome: true, + usageWorkspaceId: null, + workspaces, + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 10)], + "ws-2": [makeThread("thread-2", 20)], + }, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces), + rateLimitsByWorkspace, + accountByWorkspace, + refreshAccountInfo, + refreshAccountRateLimits, + }), + { + initialProps: { + rateLimitsByWorkspace: { + "ws-1": makeRateLimits(), + "ws-2": makeRateLimits(), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "older@example.com" }), + "ws-2": makeAccount({ email: "recent@example.com" }), + }, + }, + }, + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-2"); + + rerender({ + rateLimitsByWorkspace: { + "ws-1": makeRateLimits(), + "ws-2": makeRateLimits({ + primary: null, + secondary: null, + credits: null, + planType: null, + }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "older@example.com" }), + "ws-2": makeAccount({ + type: "unknown", + email: null, + planType: null, + }), + }, + }); + + await waitFor(() => { + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + }); + + expect(result.current.homeAccount?.email).toBe("older@example.com"); + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenLastCalledWith("ws-1"); + expect(refreshAccountRateLimits).toHaveBeenLastCalledWith("ws-1"); + }); + }); + + it("falls back when the retained aggregate workspace disconnects", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + + const { result, rerender } = renderHook( + ({ + workspaces, + }: { + workspaces: WorkspaceInfo[]; + }) => + useHomeAccount({ + showHome: true, + usageWorkspaceId: null, + workspaces, + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 10)], + "ws-2": [makeThread("thread-2", 20)], + }, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces), + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 30, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + "ws-2": makeRateLimits({ primary: { usedPercent: 60, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "older@example.com" }), + "ws-2": makeAccount({ email: "recent@example.com" }), + }, + refreshAccountInfo, + refreshAccountRateLimits, + }), + { + initialProps: { + workspaces: [ + makeWorkspace("ws-1"), + makeWorkspace("ws-2"), + ], + }, + }, + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-2"); + + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenCalledWith("ws-2"); + expect(refreshAccountRateLimits).toHaveBeenCalledWith("ws-2"); + }); + + refreshAccountInfo.mockClear(); + refreshAccountRateLimits.mockClear(); + + rerender({ + workspaces: [ + makeWorkspace("ws-1"), + makeWorkspace("ws-2", { connected: false }), + ], + }); + + await waitFor(() => { + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + }); + + expect(result.current.homeAccount?.email).toBe("older@example.com"); + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenLastCalledWith("ws-1"); + expect(refreshAccountRateLimits).toHaveBeenLastCalledWith("ws-1"); + }); + }); + + it("keeps a committed disconnected aggregate workspace stable while all workspaces stay offline", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + type HookProps = { + threadsByWorkspace: Record; + rateLimitsByWorkspace: Record; + accountByWorkspace: Record; + }; + const workspaces = [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2", { connected: false }), + ]; + + const { result, rerender } = renderHook( + ({ + threadsByWorkspace, + rateLimitsByWorkspace, + accountByWorkspace, + }: HookProps) => + useHomeAccount({ + showHome: true, + usageWorkspaceId: null, + workspaces, + threadsByWorkspace, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces), + rateLimitsByWorkspace, + accountByWorkspace, + refreshAccountInfo, + refreshAccountRateLimits, + }), + { + initialProps: { + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 20)], + "ws-2": [makeThread("thread-2", 10)], + }, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + "ws-2": makeRateLimits({ primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "stale@example.com" }), + "ws-2": makeAccount({ email: "current@example.com" }), + }, + }, + }, + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + expect(refreshAccountInfo).not.toHaveBeenCalled(); + expect(refreshAccountRateLimits).not.toHaveBeenCalled(); + + rerender({ + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 20)], + "ws-2": [makeThread("thread-2", 30)], + }, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + "ws-2": makeRateLimits({ primary: { usedPercent: 15, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "stale@example.com" }), + "ws-2": makeAccount({ email: "newer@example.com" }), + }, + }); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + expect(result.current.homeAccount?.email).toBe("stale@example.com"); + expect(refreshAccountInfo).not.toHaveBeenCalled(); + expect(refreshAccountRateLimits).not.toHaveBeenCalled(); + }); + + it("retains a committed disconnected aggregate workspace until a reconnected workspace finishes hydrating", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + type HookProps = { + workspaces: WorkspaceInfo[]; + threadListLoadingByWorkspace: Record; + rateLimitsByWorkspace: Record; + accountByWorkspace: Record; + }; + const initialProps: HookProps = { + workspaces: [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2", { connected: false }), + ], + threadListLoadingByWorkspace: { + "ws-1": false, + "ws-2": false, + }, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "stale@example.com" }), + }, + }; + + const { result, rerender } = renderHook( + ({ + workspaces, + threadListLoadingByWorkspace, + rateLimitsByWorkspace, + accountByWorkspace, + }: HookProps) => + useHomeAccount({ + showHome: true, + usageWorkspaceId: null, + workspaces, + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 20)], + "ws-2": [makeThread("thread-2", 10)], + }, + threadListLoadingByWorkspace, + rateLimitsByWorkspace, + accountByWorkspace, + refreshAccountInfo, + refreshAccountRateLimits, + }), + { + initialProps, + }, + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + expect(result.current.homeAccount?.email).toBe("stale@example.com"); + expect(refreshAccountInfo).not.toHaveBeenCalled(); + expect(refreshAccountRateLimits).not.toHaveBeenCalled(); + + rerender({ + workspaces: [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2"), + ], + threadListLoadingByWorkspace: { + "ws-1": false, + "ws-2": true, + }, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "stale@example.com" }), + }, + }); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + expect(result.current.homeAccount?.email).toBe("stale@example.com"); + expect(refreshAccountInfo).not.toHaveBeenCalled(); + expect(refreshAccountRateLimits).not.toHaveBeenCalled(); + + rerender({ + workspaces: [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2"), + ], + threadListLoadingByWorkspace: { + "ws-1": false, + "ws-2": false, + }, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "stale@example.com" }), + }, + }); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + expect(result.current.homeAccount?.email).toBe("stale@example.com"); + expect(refreshAccountInfo).not.toHaveBeenCalled(); + expect(refreshAccountRateLimits).not.toHaveBeenCalled(); + + rerender({ + workspaces: [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2"), + ], + threadListLoadingByWorkspace: { + "ws-1": false, + "ws-2": false, + }, + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + "ws-2": makeRateLimits({ primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "stale@example.com" }), + "ws-2": makeAccount({ email: "current@example.com" }), + }, + }); + + await waitFor(() => { + expect(result.current.homeAccountWorkspaceId).toBe("ws-2"); + }); + + expect(result.current.homeAccount?.email).toBe("current@example.com"); + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenLastCalledWith("ws-2"); + expect(refreshAccountRateLimits).toHaveBeenLastCalledWith("ws-2"); + }); + }); + + it("drops a committed disconnected aggregate workspace after another workspace reconnects", async () => { + const refreshAccountInfo = vi.fn(); + const refreshAccountRateLimits = vi.fn(); + type HookProps = { + workspaces: WorkspaceInfo[]; + rateLimitsByWorkspace: Record; + accountByWorkspace: Record; + }; + const initialProps: HookProps = { + workspaces: [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2", { connected: false }), + ], + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "stale@example.com" }), + }, + }; + + const { result, rerender } = renderHook( + ({ + workspaces, + rateLimitsByWorkspace, + accountByWorkspace, + }: HookProps) => + useHomeAccount({ + showHome: true, + usageWorkspaceId: null, + workspaces, + threadsByWorkspace: { + "ws-1": [makeThread("thread-1", 20)], + "ws-2": [makeThread("thread-2", 10)], + }, + threadListLoadingByWorkspace: makeThreadListLoadingState(workspaces), + rateLimitsByWorkspace, + accountByWorkspace, + refreshAccountInfo, + refreshAccountRateLimits, + }), + { + initialProps, + }, + ); + + expect(result.current.homeAccountWorkspaceId).toBe("ws-1"); + expect(refreshAccountInfo).not.toHaveBeenCalled(); + expect(refreshAccountRateLimits).not.toHaveBeenCalled(); + + rerender({ + workspaces: [ + makeWorkspace("ws-1", { connected: false }), + makeWorkspace("ws-2"), + ], + rateLimitsByWorkspace: { + "ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + "ws-2": makeRateLimits({ primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_700_000_000 } }), + }, + accountByWorkspace: { + "ws-1": makeAccount({ email: "stale@example.com" }), + "ws-2": makeAccount({ email: "current@example.com" }), + }, + }); + + await waitFor(() => { + expect(result.current.homeAccountWorkspaceId).toBe("ws-2"); + }); + + expect(result.current.homeAccount?.email).toBe("current@example.com"); + await waitFor(() => { + expect(refreshAccountInfo).toHaveBeenLastCalledWith("ws-2"); + expect(refreshAccountRateLimits).toHaveBeenLastCalledWith("ws-2"); + }); + }); +}); diff --git a/src/features/app/hooks/useHomeAccount.ts b/src/features/app/hooks/useHomeAccount.ts new file mode 100644 index 000000000..90926e711 --- /dev/null +++ b/src/features/app/hooks/useHomeAccount.ts @@ -0,0 +1,348 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { + AccountSnapshot, + RateLimitSnapshot, + ThreadSummary, + WorkspaceInfo, +} from "@/types"; + +type UseHomeAccountArgs = { + showHome: boolean; + usageWorkspaceId: string | null; + workspaces: WorkspaceInfo[]; + threadsByWorkspace: Record; + threadListLoadingByWorkspace: Record; + rateLimitsByWorkspace: Record; + accountByWorkspace: Record; + refreshAccountInfo: (workspaceId: string) => Promise | void; + refreshAccountRateLimits: (workspaceId: string) => Promise | void; +}; + +type ResolveHomeAccountWorkspaceIdArgs = Pick< + UseHomeAccountArgs, + | "usageWorkspaceId" + | "workspaces" + | "threadsByWorkspace" + | "rateLimitsByWorkspace" + | "accountByWorkspace" +>; + +type AggregateHomeAccountSelectionState = { + workspaceId: string | null; + isCommitted: boolean; +}; + +function hasUsableAccountSnapshot( + account: AccountSnapshot | null | undefined, +): boolean { + if (!account) { + return false; + } + + return ( + account.type !== "unknown" || + Boolean(account.email?.trim()) || + Boolean(account.planType?.trim()) + ); +} + +function hasUsableRateLimitSnapshot( + rateLimits: RateLimitSnapshot | null | undefined, +): boolean { + if (!rateLimits) { + return false; + } + + const balance = rateLimits.credits?.balance?.trim() ?? ""; + return ( + rateLimits.primary !== null || + rateLimits.secondary !== null || + Boolean(rateLimits.planType?.trim()) || + Boolean( + rateLimits.credits && + (rateLimits.credits.hasCredits || + rateLimits.credits.unlimited || + balance.length > 0), + ) + ); +} + +function getWorkspaceLatestThreadUpdatedAt( + workspaceId: string, + threadsByWorkspace: Record, +): number { + const threads = threadsByWorkspace[workspaceId] ?? []; + return threads.reduce( + (latestUpdatedAt, thread) => + thread.updatedAt > latestUpdatedAt ? thread.updatedAt : latestUpdatedAt, + 0, + ); +} + +function workspaceHasAccountData( + workspace: WorkspaceInfo, + rateLimitsByWorkspace: Record, + accountByWorkspace: Record, +): boolean { + const account = accountByWorkspace[workspace.id]; + const rateLimits = rateLimitsByWorkspace[workspace.id]; + return hasUsableAccountSnapshot(account) || hasUsableRateLimitSnapshot(rateLimits); +} + +function hasConnectedWorkspaceWithAccountData( + workspaces: WorkspaceInfo[], + rateLimitsByWorkspace: Record, + accountByWorkspace: Record, +): boolean { + return workspaces.some( + (workspace) => + workspace.connected && + workspaceHasAccountData(workspace, rateLimitsByWorkspace, accountByWorkspace), + ); +} + +function canRetainAggregateHomeAccountWorkspaceId( + workspaceId: string | null, + workspaces: WorkspaceInfo[], + aggregateThreadListsSettled: boolean, + rateLimitsByWorkspace: Record, + accountByWorkspace: Record, +): boolean { + if (!workspaceId) { + return false; + } + + const workspace = workspaces.find((entry) => entry.id === workspaceId); + if (!workspace) { + return false; + } + + if ( + aggregateThreadListsSettled && + !workspace.connected && + hasConnectedWorkspaceWithAccountData( + workspaces, + rateLimitsByWorkspace, + accountByWorkspace, + ) + ) { + return false; + } + + return workspaceHasAccountData(workspace, rateLimitsByWorkspace, accountByWorkspace); +} + +function haveAggregateThreadListsSettled( + workspaces: WorkspaceInfo[], + threadListLoadingByWorkspace: Record, +): boolean { + const connectedWorkspaces = workspaces.filter((workspace) => workspace.connected); + if (connectedWorkspaces.length === 0) { + return true; + } + + return connectedWorkspaces.every((workspace) => + Object.prototype.hasOwnProperty.call(threadListLoadingByWorkspace, workspace.id) && + threadListLoadingByWorkspace[workspace.id] === false, + ); +} + +export function resolveHomeAccountWorkspaceId({ + usageWorkspaceId, + workspaces, + threadsByWorkspace, + rateLimitsByWorkspace, + accountByWorkspace, +}: ResolveHomeAccountWorkspaceIdArgs): string | null { + const workspaceHasCurrentAccountData = (workspace: WorkspaceInfo) => + workspaceHasAccountData(workspace, rateLimitsByWorkspace, accountByWorkspace); + const workspaceIndexById = new Map( + workspaces.map((workspace, index) => [workspace.id, index]), + ); + const workspaceLatestThreadUpdatedAtById = new Map( + workspaces.map((workspace) => [ + workspace.id, + getWorkspaceLatestThreadUpdatedAt(workspace.id, threadsByWorkspace), + ]), + ); + const workspaceOrder = [...workspaces].sort((left, right) => { + const activityDelta = + (workspaceLatestThreadUpdatedAtById.get(right.id) ?? 0) - + (workspaceLatestThreadUpdatedAtById.get(left.id) ?? 0); + if (activityDelta !== 0) { + return activityDelta; + } + return (workspaceIndexById.get(left.id) ?? 0) - (workspaceIndexById.get(right.id) ?? 0); + }); + + if (usageWorkspaceId && workspaces.some((workspace) => workspace.id === usageWorkspaceId)) { + return usageWorkspaceId; + } + + const connectedWorkspaceWithAccountData = workspaceOrder.find( + (workspace) => workspace.connected && workspaceHasCurrentAccountData(workspace), + ); + if (connectedWorkspaceWithAccountData) { + return connectedWorkspaceWithAccountData.id; + } + + const connectedWorkspace = workspaceOrder.find((workspace) => workspace.connected); + if (connectedWorkspace) { + return connectedWorkspace.id; + } + + const workspaceWithAccountData = workspaceOrder.find(workspaceHasCurrentAccountData); + if (workspaceWithAccountData) { + return workspaceWithAccountData.id; + } + + return workspaceOrder[0]?.id ?? null; +} + +export function useHomeAccount({ + showHome, + usageWorkspaceId, + workspaces, + threadsByWorkspace, + threadListLoadingByWorkspace, + rateLimitsByWorkspace, + accountByWorkspace, + refreshAccountInfo, + refreshAccountRateLimits, +}: UseHomeAccountArgs) { + const refreshAccountInfoRef = useRef(refreshAccountInfo); + const refreshAccountRateLimitsRef = useRef(refreshAccountRateLimits); + const resolvedHomeAccountWorkspaceId = useMemo( + () => + resolveHomeAccountWorkspaceId({ + usageWorkspaceId, + workspaces, + threadsByWorkspace, + rateLimitsByWorkspace, + accountByWorkspace, + }), + [ + usageWorkspaceId, + workspaces, + threadsByWorkspace, + rateLimitsByWorkspace, + accountByWorkspace, + ], + ); + + useEffect(() => { + refreshAccountInfoRef.current = refreshAccountInfo; + }, [refreshAccountInfo]); + + useEffect(() => { + refreshAccountRateLimitsRef.current = refreshAccountRateLimits; + }, [refreshAccountRateLimits]); + + const aggregateThreadListsSettled = useMemo( + () => + usageWorkspaceId + ? false + : haveAggregateThreadListsSettled(workspaces, threadListLoadingByWorkspace), + [threadListLoadingByWorkspace, usageWorkspaceId, workspaces], + ); + const [aggregateHomeAccountSelection, setAggregateHomeAccountSelection] = + useState(() => ({ + workspaceId: usageWorkspaceId ? null : resolvedHomeAccountWorkspaceId, + isCommitted: usageWorkspaceId ? false : aggregateThreadListsSettled, + })); + + const stableHomeAccountWorkspaceId = useMemo(() => { + if (usageWorkspaceId && workspaces.some((workspace) => workspace.id === usageWorkspaceId)) { + return usageWorkspaceId; + } + + if ( + aggregateHomeAccountSelection.isCommitted && + canRetainAggregateHomeAccountWorkspaceId( + aggregateHomeAccountSelection.workspaceId, + workspaces, + aggregateThreadListsSettled, + rateLimitsByWorkspace, + accountByWorkspace, + ) + ) { + return aggregateHomeAccountSelection.workspaceId; + } + + return resolvedHomeAccountWorkspaceId; + }, [ + accountByWorkspace, + aggregateThreadListsSettled, + aggregateHomeAccountSelection, + resolvedHomeAccountWorkspaceId, + rateLimitsByWorkspace, + usageWorkspaceId, + workspaces, + ]); + + useEffect(() => { + if (usageWorkspaceId) { + if ( + aggregateHomeAccountSelection.workspaceId !== null || + aggregateHomeAccountSelection.isCommitted + ) { + setAggregateHomeAccountSelection({ + workspaceId: null, + isCommitted: false, + }); + } + return; + } + + const nextSelection: AggregateHomeAccountSelectionState = { + workspaceId: stableHomeAccountWorkspaceId, + isCommitted: + aggregateHomeAccountSelection.workspaceId === stableHomeAccountWorkspaceId + ? aggregateHomeAccountSelection.isCommitted || aggregateThreadListsSettled + : aggregateThreadListsSettled, + }; + if ( + aggregateHomeAccountSelection.workspaceId !== nextSelection.workspaceId || + aggregateHomeAccountSelection.isCommitted !== nextSelection.isCommitted + ) { + setAggregateHomeAccountSelection(nextSelection); + } + }, [ + aggregateHomeAccountSelection, + aggregateThreadListsSettled, + stableHomeAccountWorkspaceId, + usageWorkspaceId, + ]); + + const homeAccountWorkspace = useMemo( + () => + workspaces.find((workspace) => workspace.id === stableHomeAccountWorkspaceId) ?? null, + [stableHomeAccountWorkspaceId, workspaces], + ); + + const stableHomeAccount = stableHomeAccountWorkspaceId + ? accountByWorkspace[stableHomeAccountWorkspaceId] ?? null + : null; + const stableHomeRateLimits = stableHomeAccountWorkspaceId + ? rateLimitsByWorkspace[stableHomeAccountWorkspaceId] ?? null + : null; + + useEffect(() => { + if (!showHome || !stableHomeAccountWorkspaceId || !homeAccountWorkspace?.connected) { + return; + } + void refreshAccountInfoRef.current(stableHomeAccountWorkspaceId); + void refreshAccountRateLimitsRef.current(stableHomeAccountWorkspaceId); + }, [ + homeAccountWorkspace?.connected, + showHome, + stableHomeAccountWorkspaceId, + ]); + + return { + homeAccountWorkspace, + homeAccountWorkspaceId: stableHomeAccountWorkspaceId, + homeAccount: stableHomeAccount, + homeRateLimits: stableHomeRateLimits, + }; +} diff --git a/src/features/app/hooks/useMainAppLayoutSurfaces.ts b/src/features/app/hooks/useMainAppLayoutSurfaces.ts index 008b9cf1c..f411c57d1 100644 --- a/src/features/app/hooks/useMainAppLayoutSurfaces.ts +++ b/src/features/app/hooks/useMainAppLayoutSurfaces.ts @@ -58,6 +58,8 @@ type UseMainAppLayoutSurfacesArgs = { approvals: LayoutNodesOptions["primary"]["approvalToastsProps"]["approvals"]; activeRateLimits: SidebarProps["accountRateLimits"]; activeAccount: SidebarProps["accountInfo"]; + homeRateLimits: LayoutNodesOptions["primary"]["homeProps"]["accountRateLimits"]; + homeAccount: LayoutNodesOptions["primary"]["homeProps"]["accountInfo"]; accountSwitching: SidebarProps["accountSwitching"]; onSwitchAccount: SidebarProps["onSwitchAccount"]; onCancelSwitchAccount: SidebarProps["onCancelSwitchAccount"]; @@ -254,6 +256,8 @@ export function useMainAppLayoutSurfaces({ approvals, activeRateLimits, activeAccount, + homeRateLimits, + homeAccount, accountSwitching, onSwitchAccount, onCancelSwitchAccount, @@ -383,6 +387,9 @@ export function useMainAppLayoutSurfaces({ showDebugButton, handleDebugClick, }: UseMainAppLayoutSurfacesArgs): LayoutNodesOptions { + const sidebarRateLimits = activeWorkspace ? activeRateLimits : homeRateLimits; + const sidebarAccount = activeWorkspace ? activeAccount : homeAccount; + return { primary: { sidebarProps: { @@ -407,9 +414,9 @@ export function useMainAppLayoutSurfaces({ activeWorkspaceId, activeThreadId, userInputRequests, - accountRateLimits: activeRateLimits, + accountRateLimits: sidebarRateLimits, usageShowRemaining: appSettings.usageShowRemaining, - accountInfo: activeAccount, + accountInfo: sidebarAccount, onSwitchAccount, onCancelSwitchAccount, accountSwitching, @@ -603,6 +610,9 @@ export function useMainAppLayoutSurfaces({ usageWorkspaceId, usageWorkspaceOptions, onUsageWorkspaceChange, + accountRateLimits: homeRateLimits, + usageShowRemaining: appSettings.usageShowRemaining, + accountInfo: homeAccount, onSelectThread: (workspaceId, threadId) => { threadNavigation.exitDiffView(); threadNavigation.clearDraftState(); diff --git a/src/features/app/hooks/useRemoteThreadLiveConnection.ts b/src/features/app/hooks/useRemoteThreadLiveConnection.ts index a367f2801..ee6136f77 100644 --- a/src/features/app/hooks/useRemoteThreadLiveConnection.ts +++ b/src/features/app/hooks/useRemoteThreadLiveConnection.ts @@ -417,6 +417,7 @@ export function useRemoteThreadLiveConnection({ let unlistenWindowFocus: (() => void) | null = null; let unlistenWindowBlur: (() => void) | null = null; let didCleanup = false; + const ignoreDetachedEventsUntil = ignoreDetachedEventsUntilRef.current; const reconnectActiveThread = () => { const workspaceId = activeWorkspaceRef.current?.id ?? null; @@ -503,7 +504,7 @@ export function useRemoteThreadLiveConnection({ window.removeEventListener("blur", handleBlur); document.removeEventListener("visibilitychange", handleVisibilityChange); desiredSubscriptionKeyRef.current = null; - ignoreDetachedEventsUntilRef.current.clear(); + ignoreDetachedEventsUntil.clear(); const currentKey = activeSubscriptionKeyRef.current; if (currentKey) { activeSubscriptionKeyRef.current = null; diff --git a/src/features/app/hooks/useTrayRecentThreads.test.tsx b/src/features/app/hooks/useTrayRecentThreads.test.tsx index 50e880df8..fdd61d730 100644 --- a/src/features/app/hooks/useTrayRecentThreads.test.tsx +++ b/src/features/app/hooks/useTrayRecentThreads.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { renderHook } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ThreadSummary, WorkspaceInfo } from "../../../types"; import { @@ -7,8 +7,13 @@ import { useTrayRecentThreads, } from "./useTrayRecentThreads"; +const isTauriMock = vi.hoisted(() => vi.fn(() => true)); const setTrayRecentThreadsMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + isTauri: isTauriMock, +})); + vi.mock("@services/tauri", () => ({ setTrayRecentThreads: (...args: unknown[]) => setTrayRecentThreadsMock(...args), })); @@ -36,6 +41,7 @@ function makeThread(overrides: Partial = {}): ThreadSummary { describe("useTrayRecentThreads", () => { beforeEach(() => { vi.useFakeTimers(); + isTauriMock.mockReturnValue(true); setTrayRecentThreadsMock.mockReset(); setTrayRecentThreadsMock.mockResolvedValue(undefined); }); @@ -144,4 +150,117 @@ describe("useTrayRecentThreads", () => { }, ]); }); + + it("retries the same payload after a tray sync failure", async () => { + setTrayRecentThreadsMock + .mockRejectedValueOnce(new Error("tray bridge not ready")) + .mockResolvedValueOnce(undefined); + + renderHook(() => + useTrayRecentThreads({ + workspaces: [makeWorkspace()], + threadsByWorkspace: { + "ws-1": [makeThread({ id: "thread-1", name: "Alpha", updatedAt: 10 })], + }, + isSubagentThread: () => false, + }), + ); + + await vi.advanceTimersByTimeAsync(150); + expect(setTrayRecentThreadsMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(150); + expect(setTrayRecentThreadsMock).toHaveBeenCalledTimes(2); + expect(setTrayRecentThreadsMock).toHaveBeenNthCalledWith(1, [ + { + workspaceId: "ws-1", + workspaceLabel: "Workspace One", + threadId: "thread-1", + threadLabel: "Workspace One: Alpha", + updatedAt: 10, + }, + ]); + expect(setTrayRecentThreadsMock).toHaveBeenNthCalledWith(2, [ + { + workspaceId: "ws-1", + workspaceLabel: "Workspace One", + threadId: "thread-1", + threadLabel: "Workspace One: Alpha", + updatedAt: 10, + }, + ]); + }); + + it("does not queue a duplicate sync while the same payload is still in flight", async () => { + let resolveSync: (() => void) | null = null; + setTrayRecentThreadsMock.mockImplementation( + () => + new Promise((resolve) => { + resolveSync = resolve; + }), + ); + + const { rerender } = renderHook( + ({ + threadsByWorkspace, + }: { + threadsByWorkspace: Record; + }) => + useTrayRecentThreads({ + workspaces: [makeWorkspace()], + threadsByWorkspace, + isSubagentThread: () => false, + }), + { + initialProps: { + threadsByWorkspace: { + "ws-1": [makeThread({ id: "thread-1", name: "Alpha", updatedAt: 10 })], + }, + }, + }, + ); + + await vi.advanceTimersByTimeAsync(150); + expect(setTrayRecentThreadsMock).toHaveBeenCalledTimes(1); + + rerender({ + threadsByWorkspace: { + "ws-1": [makeThread({ id: "thread-1", name: "Alpha", updatedAt: 10 })], + }, + }); + await vi.advanceTimersByTimeAsync(150); + + expect(setTrayRecentThreadsMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSync?.(); + await Promise.resolve(); + }); + + rerender({ + threadsByWorkspace: { + "ws-1": [makeThread({ id: "thread-1", name: "Alpha", updatedAt: 10 })], + }, + }); + await vi.runAllTimersAsync(); + + expect(setTrayRecentThreadsMock).toHaveBeenCalledTimes(1); + }); + + it("skips tray syncing outside the Tauri runtime", async () => { + isTauriMock.mockReturnValue(false); + + renderHook(() => + useTrayRecentThreads({ + workspaces: [makeWorkspace()], + threadsByWorkspace: { + "ws-1": [makeThread({ id: "thread-1", name: "Alpha", updatedAt: 10 })], + }, + isSubagentThread: () => false, + }), + ); + + await vi.runAllTimersAsync(); + expect(setTrayRecentThreadsMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/app/hooks/useTrayRecentThreads.ts b/src/features/app/hooks/useTrayRecentThreads.ts index 8b7c62768..6275948b8 100644 --- a/src/features/app/hooks/useTrayRecentThreads.ts +++ b/src/features/app/hooks/useTrayRecentThreads.ts @@ -1,3 +1,4 @@ +import { isTauri } from "@tauri-apps/api/core"; import { useEffect, useMemo, useRef } from "react"; import { setTrayRecentThreads } from "@services/tauri"; import type { ThreadSummary, TrayRecentThreadEntry, WorkspaceInfo } from "../../../types"; @@ -83,21 +84,49 @@ export function useTrayRecentThreads({ buildTrayRecentThreadEntries(workspaces, threadsByWorkspace, isSubagentThread), [isSubagentThread, threadsByWorkspace, workspaces], ); + const serializedEntries = useMemo(() => JSON.stringify(entries), [entries]); + const syncEntries = useMemo(() => entries, [serializedEntries]); const lastSyncedEntriesRef = useRef(null); useEffect(() => { - const serializedEntries = JSON.stringify(entries); + if (!isTauri()) { + return; + } + if (lastSyncedEntriesRef.current === serializedEntries) { return; } - const timeoutId = window.setTimeout(() => { - lastSyncedEntriesRef.current = serializedEntries; - void setTrayRecentThreads(entries).catch(() => { - // Ignore tray sync failures outside macOS or before the desktop bridge is ready. - }); - }, SYNC_DEBOUNCE_MS); + let cancelled = false; + let timeoutId: number | null = null; - return () => window.clearTimeout(timeoutId); - }, [entries]); + const scheduleSync = () => { + timeoutId = window.setTimeout(() => { + timeoutId = null; + void setTrayRecentThreads(syncEntries) + .then(() => { + if (cancelled) { + return; + } + lastSyncedEntriesRef.current = serializedEntries; + }) + .catch(() => { + if (cancelled) { + return; + } + // Retry until the desktop bridge or tray is ready for the same payload. + scheduleSync(); + }); + }, SYNC_DEBOUNCE_MS); + }; + + scheduleSync(); + + return () => { + cancelled = true; + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + }; + }, [serializedEntries, syncEntries]); } diff --git a/src/features/app/utils/usageLabels.ts b/src/features/app/utils/usageLabels.ts index 423276eac..12f66367b 100644 --- a/src/features/app/utils/usageLabels.ts +++ b/src/features/app/utils/usageLabels.ts @@ -28,7 +28,7 @@ function formatCreditsLabel(accountRateLimits: RateLimitSnapshot | null) { return null; } if (credits.unlimited) { - return "Credits: Unlimited"; + return "Available credits: Unlimited"; } const balance = credits.balance?.trim() ?? ""; if (!balance) { @@ -36,12 +36,12 @@ function formatCreditsLabel(accountRateLimits: RateLimitSnapshot | null) { } const intValue = Number.parseInt(balance, 10); if (Number.isFinite(intValue) && intValue > 0) { - return `Credits: ${intValue} credits`; + return `Available credits: ${intValue}`; } const floatValue = Number.parseFloat(balance); if (Number.isFinite(floatValue) && floatValue > 0) { const rounded = Math.round(floatValue); - return rounded > 0 ? `Credits: ${rounded} credits` : null; + return rounded > 0 ? `Available credits: ${rounded}` : null; } return null; } diff --git a/src/features/home/components/Home.test.tsx b/src/features/home/components/Home.test.tsx index 1d94e053e..4ef7ec215 100644 --- a/src/features/home/components/Home.test.tsx +++ b/src/features/home/components/Home.test.tsx @@ -1,8 +1,12 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Home } from "./Home"; +afterEach(() => { + cleanup(); +}); + const baseProps = { onOpenSettings: vi.fn(), onAddWorkspace: vi.fn(), @@ -18,6 +22,9 @@ const baseProps = { usageWorkspaceId: null, usageWorkspaceOptions: [], onUsageWorkspaceChange: vi.fn(), + accountRateLimits: null, + usageShowRemaining: false, + accountInfo: null, onSelectThread: vi.fn(), }; @@ -99,5 +106,262 @@ describe("Home", () => { expect(screen.getAllByText("agent time").length).toBeGreaterThan(0); expect(screen.getByText("Runs")).toBeTruthy(); expect(screen.getByText("Peak day")).toBeTruthy(); + expect(screen.getByText("Avg / run")).toBeTruthy(); + expect(screen.getByText("Avg / active day")).toBeTruthy(); + expect(screen.getByText("Longest streak")).toBeTruthy(); + expect(screen.getByText("Active days")).toBeTruthy(); + }); + + it("renders expanded token stats and account limits", () => { + render( + , + ); + + expect(screen.getByText("Cached tokens")).toBeTruthy(); + expect(screen.getByText("Avg / run")).toBeTruthy(); + expect(screen.getByText("Longest streak")).toBeTruthy(); + expect(screen.getByText("4 days")).toBeTruthy(); + expect(screen.getByText("Account limits")).toBeTruthy(); + expect(screen.getByText("Unlimited")).toBeTruthy(); + expect(screen.getByText("Pro")).toBeTruthy(); + expect(screen.getByText(/user@example\.com/)).toBeTruthy(); + expect(screen.queryByText("Workspace CodexMonitor")).toBeNull(); + + const todayCard = screen.getByText("Today").closest(".home-usage-card"); + expect(todayCard).toBeTruthy(); + if (!(todayCard instanceof HTMLElement)) { + throw new Error("Expected today usage card"); + } + expect(within(todayCard).getByText("36")).toBeTruthy(); + + expect( + screen.getByLabelText("Usage week 2026-01-14 to 2026-01-20"), + ).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "Show next week" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + screen.getByText("Jan 20").closest(".home-usage-bar")?.getAttribute("data-value"), + ).toBe("Jan 20 · 36 tokens"); + + fireEvent.click(screen.getByRole("button", { name: "Show previous week" })); + + expect( + screen.getByLabelText("Usage week 2026-01-07 to 2026-01-13"), + ).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "Show next week" }) as HTMLButtonElement) + .disabled, + ).toBe(false); + + fireEvent.click(screen.getByRole("button", { name: "Show next week" })); + + expect( + screen.getByLabelText("Usage week 2026-01-14 to 2026-01-20"), + ).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "Show next week" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it("renders account limits even when no local usage snapshot exists", () => { + render( + , + ); + + expect(screen.getByText("Account limits")).toBeTruthy(); + expect(screen.getByText("120")).toBeTruthy(); + expect(screen.getByText(/user@example\.com/)).toBeTruthy(); + expect(screen.getByText("No usage data yet")).toBeTruthy(); }); }); diff --git a/src/features/home/components/Home.tsx b/src/features/home/components/Home.tsx index f94e38df9..2be4389a6 100644 --- a/src/features/home/components/Home.tsx +++ b/src/features/home/components/Home.tsx @@ -1,6 +1,15 @@ +import ChevronLeft from "lucide-react/dist/esm/icons/chevron-left"; +import ChevronRight from "lucide-react/dist/esm/icons/chevron-right"; import RefreshCw from "lucide-react/dist/esm/icons/refresh-cw"; -import type { LocalUsageSnapshot } from "../../../types"; +import { useEffect, useState } from "react"; +import type { + AccountSnapshot, + LocalUsageDay, + LocalUsageSnapshot, + RateLimitSnapshot, +} from "../../../types"; import { formatRelativeTime } from "../../../utils/time"; +import { getUsageLabels } from "../../app/utils/usageLabels"; type LatestAgentRun = { message: string; @@ -19,6 +28,14 @@ type UsageWorkspaceOption = { label: string; }; +type HomeStatCard = { + label: string; + value: string; + suffix?: string | null; + caption: string; + compact?: boolean; +}; + type HomeProps = { onAddWorkspace: () => void; onAddWorkspaceFromUrl: () => void; @@ -33,9 +50,164 @@ type HomeProps = { usageWorkspaceId: string | null; usageWorkspaceOptions: UsageWorkspaceOption[]; onUsageWorkspaceChange: (workspaceId: string | null) => void; + accountRateLimits: RateLimitSnapshot | null; + usageShowRemaining: boolean; + accountInfo: AccountSnapshot | null; onSelectThread: (workspaceId: string, threadId: string) => void; }; +function formatCompactNumber(value: number | null | undefined) { + if (value === null || value === undefined) { + return "--"; + } + if (value >= 1_000_000_000) { + const scaled = value / 1_000_000_000; + return `${scaled.toFixed(scaled >= 10 ? 0 : 1)}b`; + } + if (value >= 1_000_000) { + const scaled = value / 1_000_000; + return `${scaled.toFixed(scaled >= 10 ? 0 : 1)}m`; + } + if (value >= 1_000) { + const scaled = value / 1_000; + return `${scaled.toFixed(scaled >= 10 ? 0 : 1)}k`; + } + return String(value); +} + +function formatCount(value: number | null | undefined) { + if (value === null || value === undefined) { + return "--"; + } + return new Intl.NumberFormat().format(value); +} + +function formatDuration(valueMs: number | null | undefined) { + if (valueMs === null || valueMs === undefined) { + return "--"; + } + const totalSeconds = Math.max(0, Math.round(valueMs / 1000)); + const totalMinutes = Math.floor(totalSeconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (totalMinutes > 0) { + return `${totalMinutes}m`; + } + return `${totalSeconds}s`; +} + +function formatDurationCompact(valueMs: number | null | undefined) { + if (valueMs === null || valueMs === undefined) { + return "--"; + } + const totalMinutes = Math.max(0, Math.round(valueMs / 60000)); + if (totalMinutes >= 60) { + const hours = totalMinutes / 60; + return `${hours.toFixed(hours >= 10 ? 0 : 1)}h`; + } + if (totalMinutes > 0) { + return `${totalMinutes}m`; + } + const seconds = Math.max(0, Math.round(valueMs / 1000)); + return `${seconds}s`; +} + +function formatDayLabel(value: string | null | undefined) { + if (!value) { + return "--"; + } + const [year, month, day] = value.split("-").map(Number); + if (!year || !month || !day) { + return value; + } + const date = new Date(year, month - 1, day); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + }).format(date); +} + +function formatWeekRange(days: LocalUsageDay[]) { + if (days.length === 0) { + return "No usage data"; + } + const first = days[0]; + const last = days[days.length - 1]; + const firstLabel = formatDayLabel(first?.day); + const lastLabel = formatDayLabel(last?.day); + return first?.day === last?.day ? firstLabel : `${firstLabel} to ${lastLabel}`; +} + +function isUsageDayActive(day: LocalUsageDay) { + return day.totalTokens > 0 || day.agentTimeMs > 0 || day.agentRuns > 0; +} + +function formatPlanType(value: string | null | undefined) { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + return trimmed + .split(/[_\s-]+/g) + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatAccountTypeLabel(value: AccountSnapshot["type"] | null | undefined) { + if (value === "chatgpt") { + return "ChatGPT account"; + } + if (value === "apikey") { + return "API key"; + } + return "Connected account"; +} + +function formatWindowDuration(valueMins: number | null | undefined) { + if (typeof valueMins !== "number" || !Number.isFinite(valueMins) || valueMins <= 0) { + return null; + } + if (valueMins >= 60 * 24) { + const days = Math.round(valueMins / (60 * 24)); + return `${days} day${days === 1 ? "" : "s"} window`; + } + if (valueMins >= 60) { + const hours = Math.round(valueMins / 60); + return `${hours}h window`; + } + return `${Math.round(valueMins)}m window`; +} + +function buildWindowCaption( + resetLabel: string | null, + windowDurationMins: number | null | undefined, + fallback: string, +) { + const parts = [resetLabel, formatWindowDuration(windowDurationMins)].filter(Boolean); + return parts.length > 0 ? parts.join(" · ") : fallback; +} + +function formatCreditsBalance(value: string | null | undefined) { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const numeric = Number.parseFloat(trimmed); + if (!Number.isFinite(numeric) || numeric <= 0) { + return trimmed; + } + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 0, + }).format(numeric); +} + export function Home({ onAddWorkspace, onAddWorkspaceFromUrl, @@ -50,88 +222,23 @@ export function Home({ usageWorkspaceId, usageWorkspaceOptions, onUsageWorkspaceChange, + accountRateLimits, + usageShowRemaining, + accountInfo, onSelectThread, }: HomeProps) { - const formatCompactNumber = (value: number | null | undefined) => { - if (value === null || value === undefined) { - return "--"; - } - if (value >= 1_000_000_000) { - const scaled = value / 1_000_000_000; - return `${scaled.toFixed(scaled >= 10 ? 0 : 1)}b`; - } - if (value >= 1_000_000) { - const scaled = value / 1_000_000; - return `${scaled.toFixed(scaled >= 10 ? 0 : 1)}m`; - } - if (value >= 1_000) { - const scaled = value / 1_000; - return `${scaled.toFixed(scaled >= 10 ? 0 : 1)}k`; - } - return String(value); - }; - - const formatCount = (value: number | null | undefined) => { - if (value === null || value === undefined) { - return "--"; - } - return new Intl.NumberFormat().format(value); - }; - - const formatDuration = (valueMs: number | null | undefined) => { - if (valueMs === null || valueMs === undefined) { - return "--"; - } - const totalSeconds = Math.max(0, Math.round(valueMs / 1000)); - const totalMinutes = Math.floor(totalSeconds / 60); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - if (hours > 0) { - return `${hours}h ${minutes}m`; - } - if (totalMinutes > 0) { - return `${totalMinutes}m`; - } - return `${totalSeconds}s`; - }; - - const formatDurationCompact = (valueMs: number | null | undefined) => { - if (valueMs === null || valueMs === undefined) { - return "--"; - } - const totalMinutes = Math.max(0, Math.round(valueMs / 60000)); - if (totalMinutes >= 60) { - const hours = totalMinutes / 60; - return `${hours.toFixed(hours >= 10 ? 0 : 1)}h`; - } - if (totalMinutes > 0) { - return `${totalMinutes}m`; - } - const seconds = Math.max(0, Math.round(valueMs / 1000)); - return `${seconds}s`; - }; - - const formatDayLabel = (value: string | null | undefined) => { - if (!value) { - return "--"; - } - const [year, month, day] = value.split("-").map(Number); - if (!year || !month || !day) { - return value; - } - const date = new Date(year, month - 1, day); - if (Number.isNaN(date.getTime())) { - return value; - } - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - }).format(date); - }; + const [chartWeekOffset, setChartWeekOffset] = useState(0); const usageTotals = localUsageSnapshot?.totals ?? null; const usageDays = localUsageSnapshot?.days ?? []; + const latestUsageDay = usageDays[usageDays.length - 1] ?? null; const last7Days = usageDays.slice(-7); + const last7Tokens = last7Days.reduce((total, day) => total + day.totalTokens, 0); + const last7Input = last7Days.reduce((total, day) => total + day.inputTokens, 0); + const last7Cached = last7Days.reduce( + (total, day) => total + day.cachedInputTokens, + 0, + ); const last7AgentMs = last7Days.reduce( (total, day) => total + (day.agentTimeMs ?? 0), 0, @@ -146,6 +253,18 @@ export function Home({ (total, day) => total + (day.agentRuns ?? 0), 0, ); + const last30AgentRuns = usageDays.reduce( + (total, day) => total + (day.agentRuns ?? 0), + 0, + ); + const averageTokensPerRun = + last7AgentRuns > 0 ? Math.round(last7Tokens / last7AgentRuns) : null; + const averageRunDurationMs = + last7AgentRuns > 0 ? Math.round(last7AgentMs / last7AgentRuns) : null; + const last7ActiveDays = last7Days.filter(isUsageDayActive).length; + const last30ActiveDays = usageDays.filter(isUsageDayActive).length; + const averageActiveDayAgentMs = + last7ActiveDays > 0 ? Math.round(last7AgentMs / last7ActiveDays) : null; const peakAgentDay = usageDays.reduce< | { day: string; agentTimeMs: number } | null @@ -161,12 +280,211 @@ export function Home({ }, null); const peakAgentDayLabel = peakAgentDay?.day ?? null; const peakAgentTimeMs = peakAgentDay?.agentTimeMs ?? 0; + const maxHistoricalWeekOffset = Math.max(0, Math.ceil(usageDays.length / 7) - 1); + useEffect(() => { + setChartWeekOffset((previous) => Math.min(previous, maxHistoricalWeekOffset)); + }, [maxHistoricalWeekOffset]); + const chartWeekEnd = Math.max(0, usageDays.length - chartWeekOffset * 7); + const chartWeekStart = Math.max(0, chartWeekEnd - 7); + const chartDays = usageDays.slice(chartWeekStart, chartWeekEnd); const maxUsageValue = Math.max( 1, - ...last7Days.map((day) => + ...chartDays.map((day) => usageMetric === "tokens" ? day.totalTokens : day.agentTimeMs ?? 0, ), ); + const canShowOlderWeek = chartWeekOffset < maxHistoricalWeekOffset; + const canShowNewerWeek = chartWeekOffset > 0; + const chartRangeLabel = formatWeekRange(chartDays); + const chartRangeAriaLabel = + chartDays.length > 0 + ? `Usage week ${chartDays[0]?.day} to ${chartDays[chartDays.length - 1]?.day}` + : "Usage week"; + let longestStreak = 0; + let runningStreak = 0; + for (const day of usageDays) { + if (isUsageDayActive(day)) { + runningStreak += 1; + longestStreak = Math.max(longestStreak, runningStreak); + } else { + runningStreak = 0; + } + } + + const longestStreakCard: HomeStatCard = { + label: "Longest streak", + value: longestStreak > 0 ? formatDayCount(longestStreak) : "--", + caption: + longestStreak > 0 + ? "Across current usage range" + : "No active streak yet", + compact: true, + }; + const activeDaysCard: HomeStatCard = { + label: "Active days", + value: last7Days.length > 0 ? `${last7ActiveDays} / ${last7Days.length}` : "--", + caption: + usageDays.length > 0 + ? `${last30ActiveDays} / ${usageDays.length} in current range` + : "No activity yet", + compact: true, + }; + const usageCards: HomeStatCard[] = + usageMetric === "tokens" + ? [ + { + label: "Today", + value: formatCompactNumber(latestUsageDay?.totalTokens ?? 0), + suffix: "tokens", + caption: latestUsageDay + ? `${formatDayLabel(latestUsageDay.day)} · ${formatCount( + latestUsageDay.inputTokens, + )} in / ${formatCount(latestUsageDay.outputTokens)} out` + : "Latest available day", + }, + { + label: "Last 7 days", + value: formatCompactNumber(usageTotals?.last7DaysTokens ?? last7Tokens), + suffix: "tokens", + caption: `Avg ${formatCompactNumber(usageTotals?.averageDailyTokens)} / day`, + }, + { + label: "Last 30 days", + value: formatCompactNumber(usageTotals?.last30DaysTokens ?? last7Tokens), + suffix: "tokens", + caption: `Total ${formatCount(usageTotals?.last30DaysTokens ?? last7Tokens)}`, + }, + { + label: "Cache hit rate", + value: usageTotals + ? `${usageTotals.cacheHitRatePercent.toFixed(1)}%` + : "--", + caption: "Last 7 days", + }, + { + label: "Cached tokens", + value: formatCompactNumber(last7Cached), + suffix: "saved", + caption: + last7Input > 0 + ? `${((last7Cached / last7Input) * 100).toFixed(1)}% of prompt tokens` + : "Last 7 days", + }, + { + label: "Avg / run", + value: + averageTokensPerRun === null + ? "--" + : formatCompactNumber(averageTokensPerRun), + suffix: "tokens", + caption: + last7AgentRuns > 0 + ? `${formatCount(last7AgentRuns)} runs in last 7 days` + : "No runs yet", + }, + { + label: "Peak day", + value: formatDayLabel(usageTotals?.peakDay), + caption: `${formatCompactNumber(usageTotals?.peakDayTokens)} tokens`, + }, + ] + : [ + { + label: "Last 7 days", + value: formatDurationCompact(last7AgentMs), + suffix: "agent time", + caption: `Avg ${formatDurationCompact(averageDailyAgentMs)} / day`, + }, + { + label: "Last 30 days", + value: formatDurationCompact(last30AgentMs), + suffix: "agent time", + caption: `Total ${formatDuration(last30AgentMs)}`, + }, + { + label: "Runs", + value: formatCount(last7AgentRuns), + suffix: "runs", + caption: `Last 30 days: ${formatCount(last30AgentRuns)} runs`, + }, + { + label: "Avg / run", + value: formatDurationCompact(averageRunDurationMs), + caption: + last7AgentRuns > 0 + ? `Across ${formatCount(last7AgentRuns)} runs` + : "No runs yet", + }, + { + label: "Avg / active day", + value: formatDurationCompact(averageActiveDayAgentMs), + caption: + last7ActiveDays > 0 + ? `${formatCount(last7ActiveDays)} active days in last 7` + : "No active days yet", + }, + { + label: "Peak day", + value: formatDayLabel(peakAgentDayLabel), + caption: `${formatDurationCompact(peakAgentTimeMs)} agent time`, + }, + ]; + const usageInsights = [longestStreakCard, activeDaysCard]; + const usagePercentLabels = getUsageLabels(accountRateLimits, usageShowRemaining); + const planLabel = formatPlanType(accountRateLimits?.planType ?? accountInfo?.planType); + const creditsBalance = formatCreditsBalance(accountRateLimits?.credits?.balance); + const accountCards: HomeStatCard[] = []; + + if (usagePercentLabels.sessionPercent !== null) { + accountCards.push({ + label: usageShowRemaining ? "Session left" : "Session usage", + value: `${usagePercentLabels.sessionPercent}%`, + caption: buildWindowCaption( + usagePercentLabels.sessionResetLabel, + accountRateLimits?.primary?.windowDurationMins, + "Current window", + ), + }); + } + + if (usagePercentLabels.showWeekly && usagePercentLabels.weeklyPercent !== null) { + accountCards.push({ + label: usageShowRemaining ? "Weekly left" : "Weekly usage", + value: `${usagePercentLabels.weeklyPercent}%`, + caption: buildWindowCaption( + usagePercentLabels.weeklyResetLabel, + accountRateLimits?.secondary?.windowDurationMins, + "Longer window", + ), + }); + } + + if (accountRateLimits?.credits?.hasCredits) { + accountCards.push( + accountRateLimits.credits.unlimited + ? { + label: "Credits", + value: "Unlimited", + caption: "Available balance", + } + : { + label: "Credits", + value: creditsBalance ?? "--", + suffix: creditsBalance ? "credits" : null, + caption: "Available balance", + }, + ); + } + + if (planLabel) { + accountCards.push({ + label: "Plan", + value: planLabel, + caption: formatAccountTypeLabel(accountInfo?.type), + }); + } + + const accountMeta = accountInfo?.email ?? null; const updatedLabel = localUsageSnapshot ? `Updated ${formatRelativeTime(localUsageSnapshot.updatedAt)}` : null; @@ -364,108 +682,52 @@ export function Home({ ) : ( <>
- {usageMetric === "tokens" ? ( - <> -
-
Last 7 days
-
- - {formatCompactNumber(usageTotals?.last7DaysTokens)} - - tokens -
-
- Avg {formatCompactNumber(usageTotals?.averageDailyTokens)} / day -
+ {usageCards.map((card) => ( +
+
{card.label}
+
+ {card.value} + {card.suffix && {card.suffix}}
-
-
Last 30 days
-
- - {formatCompactNumber(usageTotals?.last30DaysTokens)} - - tokens -
-
- Total {formatCount(usageTotals?.last30DaysTokens)} -
-
-
-
Cache hit rate
-
- - {usageTotals - ? `${usageTotals.cacheHitRatePercent.toFixed(1)}%` - : "--"} - -
-
Last 7 days
-
-
-
Peak day
-
- - {formatDayLabel(usageTotals?.peakDay)} - -
-
- {formatCompactNumber(usageTotals?.peakDayTokens)} tokens -
-
- - ) : ( - <> -
-
Last 7 days
-
- - {formatDurationCompact(last7AgentMs)} - - agent time -
-
- Avg {formatDurationCompact(averageDailyAgentMs)} / day -
-
-
-
Last 30 days
-
- - {formatDurationCompact(last30AgentMs)} - - agent time -
-
- Total {formatDuration(last30AgentMs)} -
-
-
-
Runs
-
- - {formatCount(last7AgentRuns)} - - runs -
-
Last 7 days
-
-
-
Peak day
-
- - {formatDayLabel(peakAgentDayLabel)} - -
-
- {formatDurationCompact(peakAgentTimeMs)} agent time -
-
- - )} +
{card.caption}
+
+ ))}
+
+
+ {chartRangeLabel} +
+
+ {canShowOlderWeek && ( + + )} + +
+
- {last7Days.map((day) => { + {chartDays.map((day) => { const value = usageMetric === "tokens" ? day.totalTokens : day.agentTimeMs ?? 0; const height = Math.max( @@ -494,6 +756,21 @@ export function Home({ })}
+
+ {usageInsights.map((card) => ( +
+
{card.label}
+
+ {card.value} + {card.suffix && {card.suffix}} +
+
{card.caption}
+
+ ))} +
Top models @@ -525,7 +802,39 @@ export function Home({
)} + {accountCards.length > 0 && ( +
+
+
Account limits
+ {accountMeta && ( +
+
{accountMeta}
+
+ )} +
+
+ {accountCards.map((card) => ( +
+
{card.label}
+
+ {card.value} + {card.suffix && ( + {card.suffix} + )} +
+
{card.caption}
+
+ ))} +
+
+ )}
); } +function formatDayCount(value: number | null | undefined) { + if (value === null || value === undefined) { + return "--"; + } + return `${value} day${value === 1 ? "" : "s"}`; +} diff --git a/src/features/threads/hooks/useThreadMessaging.ts b/src/features/threads/hooks/useThreadMessaging.ts index 27f23fa06..ea34bb960 100644 --- a/src/features/threads/hooks/useThreadMessaging.ts +++ b/src/features/threads/hooks/useThreadMessaging.ts @@ -427,6 +427,7 @@ export function useThreadMessaging({ ensureWorkspaceRuntimeCodexArgs, shouldPreflightRuntimeCodexArgsForSend, activeTurnIdByThread, + getCustomName, markProcessing, model, onDebug, @@ -667,7 +668,6 @@ export function useThreadMessaging({ reviewDeliveryMode, registerDetachedReviewChild, renameThread, - serviceTier, updateThreadParent, ], ); diff --git a/src/features/threads/utils/threadNormalize.test.ts b/src/features/threads/utils/threadNormalize.test.ts index 955fe98d8..0629875f2 100644 --- a/src/features/threads/utils/threadNormalize.test.ts +++ b/src/features/threads/utils/threadNormalize.test.ts @@ -138,4 +138,170 @@ describe("normalizeRateLimits", () => { expect(normalized.secondary?.usedPercent).toBe(60); expect(normalized.secondary?.windowDurationMins).toBe(10_080); }); + + it("infers credits availability from a balance when hasCredits is omitted", () => { + const normalized = normalizeRateLimits({ + credits: { + balance: "120", + }, + }); + + expect(normalized.credits).toEqual({ + hasCredits: true, + unlimited: false, + balance: "120", + }); + }); + + it("keeps credit balances visible when unlimited is explicitly false", () => { + const normalized = normalizeRateLimits({ + credits: { + unlimited: false, + balance: "120", + }, + }); + + expect(normalized.credits).toEqual({ + hasCredits: true, + unlimited: false, + balance: "120", + }); + }); + + it("does not infer available credits from a zero balance", () => { + const normalized = normalizeRateLimits({ + credits: { + balance: "0", + }, + }); + + expect(normalized.credits).toEqual({ + hasCredits: false, + unlimited: false, + balance: "0", + }); + }); + + it("clears previous credit availability when a partial update sets balance to zero", () => { + const previous = { + primary: null, + secondary: null, + credits: { + hasCredits: true, + unlimited: false, + balance: "120", + }, + planType: null, + } as const; + + const normalized = normalizeRateLimits( + { + credits: { + balance: "0", + }, + }, + previous, + ); + + expect(normalized.credits).toEqual({ + hasCredits: false, + unlimited: false, + balance: "0", + }); + }); + + it("clears previous credit availability when a partial update nulls the balance", () => { + const previous = { + primary: null, + secondary: null, + credits: { + hasCredits: true, + unlimited: false, + balance: "120", + }, + planType: null, + } as const; + + const normalized = normalizeRateLimits( + { + credits: { + balance: null, + }, + }, + previous, + ); + + expect(normalized.credits).toEqual({ + hasCredits: false, + unlimited: false, + balance: null, + }); + }); + + it.each([{ balance: "0" }, { balance: null }])( + "preserves unlimited credits when a partial update only changes balance to $balance", + (credits) => { + const previous = { + primary: null, + secondary: null, + credits: { + hasCredits: true, + unlimited: true, + balance: "120", + }, + planType: null, + } as const; + + const normalized = normalizeRateLimits( + { + credits, + }, + previous, + ); + + expect(normalized.credits).toEqual({ + hasCredits: true, + unlimited: true, + balance: credits.balance, + }); + }, + ); + + it("normalizes numeric credit balances", () => { + const normalized = normalizeRateLimits({ + credits: { + balance: 75, + }, + }); + + expect(normalized.credits).toEqual({ + hasCredits: true, + unlimited: false, + balance: "75", + }); + }); + + it("keeps the previous credits snapshot when the incoming balance is NaN", () => { + const previous = { + primary: null, + secondary: null, + credits: { + hasCredits: true, + unlimited: false, + balance: "120", + }, + planType: null, + } as const; + + const normalized = normalizeRateLimits( + { + credits: { + balance: Number.NaN, + }, + }, + previous, + ); + + expect(normalized.credits).toEqual(previous.credits); + }); }); diff --git a/src/features/threads/utils/threadNormalize.ts b/src/features/threads/utils/threadNormalize.ts index a10e05c42..cd90299a6 100644 --- a/src/features/threads/utils/threadNormalize.ts +++ b/src/features/threads/utils/threadNormalize.ts @@ -97,22 +97,44 @@ function normalizeCreditsSnapshot( const hasCreditsRaw = source.hasCredits ?? source.has_credits; const unlimitedRaw = source.unlimited; const balanceRaw = source.balance; + const normalizedUnlimited = + typeof unlimitedRaw === "boolean" + ? unlimitedRaw + : previousCredits?.unlimited ?? false; + const balanceHasExplicitValue = hasOwn(source, "balance"); + const normalizedBalance = + typeof balanceRaw === "string" + ? balanceRaw + : typeof balanceRaw === "number" && Number.isFinite(balanceRaw) + ? String(balanceRaw) + : balanceRaw === null + ? null + : previousCredits?.balance ?? null; + const explicitBalanceHasCredits = + balanceRaw === null + ? false + : typeof balanceRaw === "string" + ? (() => { + const parsed = Number.parseFloat(balanceRaw.trim()); + return Number.isFinite(parsed) ? parsed > 0 : null; + })() + : typeof balanceRaw === "number" && Number.isFinite(balanceRaw) + ? balanceRaw > 0 + : null; + const inferredHasCredits = + normalizedUnlimited + ? true + : balanceHasExplicitValue + ? explicitBalanceHasCredits + : null; return { hasCredits: typeof hasCreditsRaw === "boolean" ? hasCreditsRaw - : previousCredits?.hasCredits ?? false, - unlimited: - typeof unlimitedRaw === "boolean" - ? unlimitedRaw - : previousCredits?.unlimited ?? false, - balance: - typeof balanceRaw === "string" - ? balanceRaw - : balanceRaw === null - ? null - : previousCredits?.balance ?? null, + : inferredHasCredits ?? previousCredits?.hasCredits ?? false, + unlimited: normalizedUnlimited, + balance: normalizedBalance, }; } diff --git a/src/styles/home.css b/src/styles/home.css index 531752887..b08b4169c 100644 --- a/src/styles/home.css +++ b/src/styles/home.css @@ -80,6 +80,8 @@ .home-section-meta-row { display: inline-flex; align-items: center; + flex-wrap: wrap; + justify-content: flex-end; gap: 8px; min-width: 0; } @@ -222,6 +224,12 @@ gap: 12px; } +.home-usage-insights { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + .home-usage-card { padding: 12px 14px; border-radius: 14px; @@ -237,6 +245,21 @@ position: relative; } +.home-usage-card.is-compact { + min-height: 82px; + padding: 10px 12px; +} + +.home-usage-card.is-up { + background: linear-gradient(135deg, rgba(88, 190, 255, 0.18), transparent 58%), + var(--surface-card); +} + +.home-usage-card.is-down { + background: linear-gradient(135deg, rgba(255, 177, 124, 0.16), transparent 58%), + var(--surface-card); +} + .home-usage-label { font-size: 11px; text-transform: uppercase; @@ -266,6 +289,18 @@ max-width: 100%; } +.home-usage-number.is-up { + color: var(--text-stronger); +} + +.home-usage-number.is-down { + color: var(--text-stronger); +} + +.home-usage-number.is-flat { + color: var(--text-subtle); +} + .home-usage-suffix { font-size: 11px; color: var(--text-subtle); @@ -288,6 +323,61 @@ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2); } +.home-usage-chart-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.home-usage-chart-range { + font-size: 12px; + color: var(--text-stronger); + letter-spacing: 0.01em; +} + +.home-usage-chart-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.home-usage-chart-button { + width: 28px; + height: 28px; + border-radius: 999px; + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.04); + color: var(--text-stronger); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 120ms ease, transform 120ms ease, border-color 120ms ease; + padding: 0; +} + +.home-usage-chart-button:hover { + background: var(--surface-card-strong); + border-color: var(--border-strong); + transform: translateY(-1px); +} + +.home-usage-chart-button:disabled { + opacity: 0.38; + cursor: default; + background: rgba(255, 255, 255, 0.02); + color: var(--text-faint); + border-color: var(--border-subtle); + transform: none; +} + +.home-usage-chart-button svg { + width: 14px; + height: 14px; +} + .home-usage-chart { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); @@ -348,6 +438,16 @@ gap: 8px; } +.home-account { + display: flex; + flex-direction: column; + gap: 12px; +} + +.home-account-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + .home-usage-models-label { font-size: 12px; text-transform: uppercase; @@ -638,6 +738,10 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .home-usage-insights { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .home-usage-chart { grid-template-columns: repeat(7, minmax(0, 1fr)); } @@ -656,10 +760,23 @@ grid-template-columns: minmax(0, 1fr); } + .home-usage-insights { + grid-template-columns: minmax(0, 1fr); + } + .home-usage-controls { align-items: stretch; } + .home-usage-chart-nav { + align-items: flex-start; + flex-direction: column; + } + + .home-usage-chart-actions { + align-self: flex-end; + } + .home-usage-control-group { width: 100%; justify-content: space-between;