From 4893629ff5ff8938c129c322d6328dd183023095 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:55:22 +0200 Subject: [PATCH 1/4] Show the thinking level an agent runs on in its composer (#5804) The composer picked the thinking level from what was chosen at launch and fell back to the model's catalog default when nothing was chosen. A Pi agent started without a thinking level runs on Pi's own configured default, so the composer showed Medium while the agent ran on High. The composer now prefers the level the running agent reports when it is one of the model's options, the same way it already prefers the running model. Fixes #5800 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/src/composer/agent-controls/index.tsx | 3 + .../src/composer/agent-controls/utils.test.ts | 55 +++++++++++++++++++ .../app/src/composer/agent-controls/utils.ts | 22 +++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 8dadeec217..d5f09dc77f 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -394,6 +394,7 @@ type AgentControlsSlice = { runtimeModelId: string | null; model: string | null | undefined; features: AgentFeature[] | undefined; + runtimeThinkingOptionId: string | null; thinkingOptionId: string | null | undefined; lastUsage: unknown; } | null; @@ -413,6 +414,7 @@ function selectAgentControlsSlice( runtimeModelId: currentAgent.runtimeInfo?.model ?? null, model: currentAgent.model, features: currentAgent.features, + runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null, thinkingOptionId: currentAgent.thinkingOptionId, lastUsage: currentAgent.lastUsage, }; @@ -1594,6 +1596,7 @@ export const AgentControls = memo(function AgentControls({ models, runtimeModelId: agent?.runtimeModelId, configuredModelId: agent?.model, + runtimeThinkingOptionId: agent?.runtimeThinkingOptionId, explicitThinkingOptionId: agent?.thinkingOptionId, }); diff --git a/packages/app/src/composer/agent-controls/utils.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts index 993e21f0c9..1c91d8ce79 100644 --- a/packages/app/src/composer/agent-controls/utils.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -67,6 +67,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: null, configuredModelId: "claude-fable-5[1m]", + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -88,6 +89,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "a", configuredModelId: "b", + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -112,6 +114,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "a", configuredModelId: null, + runtimeThinkingOptionId: null, explicitThinkingOptionId: "high", }); @@ -119,6 +122,54 @@ describe("resolveAgentModelSelection", () => { expect(selection.displayThinking).toBe("High"); }); + it("shows the thinking level the agent runs on when none was chosen", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "openrouter/google/gemini-3.6-flash", + provider: "pi", + label: "Gemini 3.6 Flash", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "medium", + }, + ], + runtimeModelId: "openrouter/google/gemini-3.6-flash", + configuredModelId: null, + runtimeThinkingOptionId: "high", + explicitThinkingOptionId: null, + }); + + expect(selection.selectedThinkingId).toBe("high"); + expect(selection.displayThinking).toBe("High"); + }); + + it("falls back to the chosen thinking level when the runtime level is not a model option", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "a", + provider: "codex", + label: "Model A", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "low", + }, + ], + runtimeModelId: "a", + configuredModelId: null, + runtimeThinkingOptionId: "xhigh", + explicitThinkingOptionId: "high", + }); + + expect(selection.selectedThinkingId).toBe("high"); + }); + it("formats raw thinking labels in the selected model display", () => { const selection = resolveAgentModelSelection({ models: [ @@ -134,6 +185,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "a", configuredModelId: null, + runtimeThinkingOptionId: null, explicitThinkingOptionId: "xhigh", }); @@ -155,6 +207,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: null, configuredModelId: null, + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -178,6 +231,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "claude-sonnet-4-6-20260101", configuredModelId: "default", + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -204,6 +258,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "gpt-6.1-sol", configuredModelId: "gpt-6.1-sol", + runtimeThinkingOptionId: null, explicitThinkingOptionId: "max", }); diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index 56f9bcd49f..631b55753e 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -86,9 +86,16 @@ function pickSelectedModel( } function resolveThinkingId( + runtimeThinkingOptionId: string | null | undefined, explicitThinkingOptionId: string | null | undefined, selectedModel: AgentModelDefinition | null, ): string | null { + const runtimeThinkingOption = selectedModel?.thinkingOptions?.find( + (option) => option.id === runtimeThinkingOptionId, + ); + if (runtimeThinkingOption) { + return runtimeThinkingOption.id; + } if (explicitThinkingOptionId && explicitThinkingOptionId !== "default") { return explicitThinkingOptionId; } @@ -139,9 +146,16 @@ export function resolveAgentModelSelection(input: { models: AgentModelDefinition[] | null; runtimeModelId: string | null | undefined; configuredModelId: string | null | undefined; + runtimeThinkingOptionId: string | null | undefined; explicitThinkingOptionId: string | null | undefined; }) { - const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input; + const { + models, + runtimeModelId, + configuredModelId, + runtimeThinkingOptionId, + explicitThinkingOptionId, + } = input; const normalizedRuntimeModelId = normalizeModelId(runtimeModelId); const normalizedConfiguredModelId = normalizeModelId(configuredModelId); @@ -162,7 +176,11 @@ export function resolveAgentModelSelection(input: { ); const thinkingOptions = selectedModel?.thinkingOptions ?? null; - const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel); + const resolvedThinkingId = resolveThinkingId( + runtimeThinkingOptionId, + explicitThinkingOptionId, + selectedModel, + ); const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId); const selectedThinkingId = effectiveThinking?.id ?? resolvedThinkingId; const displayThinking = resolveThinkingDisplay( From b701cb2dcd732cf79f953289d01fd3269ccc59a5 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:57:56 +0200 Subject: [PATCH 2/4] Show failed Codex image generations in the chat (#5810) When a Codex image request fails, Codex completes the imageGeneration item with status "failed", an empty result, and an optional failure record. The image mapper only knew how to render a successful image, so it returned nothing and the item vanished from the live timeline and from reopened history. Failed items now map to a failed image_generation tool call carrying the failure details. Closes #5619 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com> --- .../providers/codex-app-server-agent.test.ts | 101 ++++++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 9 ++ 2 files changed, 110 insertions(+) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index fb27b68e96..032eeb65cb 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -6192,6 +6192,107 @@ describe("Codex app-server provider", () => { ]); }); + test.each([ + [ + "a usage limit", + { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: 1786150800 }, + { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: 1786150800 }, + ], + ["no failure details", null, { message: "Image generation failed" }], + ])( + "emits failed imageGeneration items with %s as failed tool calls", + (_label, failure, expectedError) => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + item: { + id: "image-generation-failed", + type: "imageGeneration", + status: "failed", + revisedPrompt: "paint a blue whale", + result: "", + transparentBackground: null, + failure, + savedPath: null, + }, + }); + + expect(events).toEqual([ + { + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: "image-generation-failed", + name: "image_generation", + status: "failed", + error: expectedError, + detail: { + type: "unknown", + input: { prompt: "paint a blue whale" }, + output: null, + }, + }, + }, + ]); + }, + ); + + test("keeps failed imageGeneration items in persisted history", async () => { + const session = createSession(); + session.client = { + request: vi.fn(async () => ({ + thread: { + turns: [ + { + items: [ + { + type: "imageGeneration", + id: "image-generation-failed", + status: "failed", + revisedPrompt: "paint a blue whale", + result: "", + transparentBackground: null, + failure: { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: null }, + savedPath: null, + }, + ], + }, + ], + }, + })), + }; + + await asInternals(session).loadPersistedHistory(session.client); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + + expect(history).toEqual([ + { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "image-generation-failed", + name: "image_generation", + status: "failed", + error: { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: null }, + detail: { + type: "unknown", + input: { prompt: "paint a blue whale" }, + output: null, + }, + }, + }, + ]); + }); + test("emits usage_updated on token usage updates and keeps usage on turn completion", () => { const session = createSession(); const events: AgentStreamEvent[] = []; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index f15c5a60ad..019a03bc59 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1949,6 +1949,15 @@ function mapCodexThreadImageItem( }); } + if (normalizedItem.status === "failed") { + return mapCodexToolCallEnvelope({ + callId: firstStringField(normalizedItem, ["id"]), + name: "image_generation", + input: { prompt: firstStringField(normalizedItem, ["revisedPrompt", "revised_prompt"]) }, + error: normalizedItem.failure ?? { message: "Image generation failed" }, + }); + } + const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]); const result = codexImageOutputFromResult(normalizedItem.result); return renderProviderImageOutputAsAssistantMarkdown( From 15f14314160722c8019f4d1ec65ec4a2844faff6 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:01:45 +0200 Subject: [PATCH 3/4] Let bundlers resolve the published @getpaseo/relay package (#5815) The relay exports map sent the import and default conditions to src/*.ts, which the published package does not include, so any bundler resolving @getpaseo/client failed with 'Could not resolve "@getpaseo/relay/e2ee"'. Every condition now resolves to dist, like @getpaseo/protocol and @getpaseo/client. The client build now builds relay first, and the Metro workaround for compiling relay source is removed. Fixes #4730 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/metro.config.cjs | 11 ----- packages/client/package.json | 2 +- packages/relay/package.json | 12 ++---- packages/relay/src/package-exports.test.ts | 49 ++++++++++++++++++++++ 4 files changed, 53 insertions(+), 21 deletions(-) create mode 100644 packages/relay/src/package-exports.test.ts diff --git a/packages/app/metro.config.cjs b/packages/app/metro.config.cjs index e26fa04c32..c1fdbdef77 100644 --- a/packages/app/metro.config.cjs +++ b/packages/app/metro.config.cjs @@ -1,12 +1,10 @@ const { getDefaultConfig } = require("expo/metro-config"); const { resolve } = require("metro-resolver"); -const fs = require("fs"); const path = require("path"); const projectRoot = __dirname; const appNodeModulesRoot = path.resolve(projectRoot, "node_modules"); const appSrcRoot = path.resolve(projectRoot, "src"); -const relaySrcRoot = path.resolve(projectRoot, "../relay/src"); const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1"; const fdroidModuleOverrides = { "expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"), @@ -81,15 +79,6 @@ config.resolver.resolveRequest = (context, moduleName, platform) => { return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform); } - const origin = context.originModulePath; - if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) { - const tsModuleName = moduleName.replace(/\.js$/, ".ts"); - const candidatePath = path.resolve(path.dirname(origin), tsModuleName); - if (fs.existsSync(candidatePath)) { - return resolveWithCustomWebOverlay(context, tsModuleName, platform); - } - } - return resolveWithCustomWebOverlay(context, moduleName, platform); }; diff --git a/packages/client/package.json b/packages/client/package.json index 44f3780b56..4e5a9124c8 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -31,7 +31,7 @@ }, "scripts": { "clean": "node ../../scripts/clean-package-dist.mjs", - "build": "npm run build --workspace=@getpaseo/protocol && tsc -p tsconfig.json --incremental false", + "build": "npm run build --workspace=@getpaseo/protocol && npm run build --workspace=@getpaseo/relay && tsc -p tsconfig.json --incremental false", "build:clean": "npm run clean && npm run build", "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", diff --git a/packages/relay/package.json b/packages/relay/package.json index 3693a832f6..10185476e3 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -11,21 +11,15 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "import": "./src/index.ts", - "default": "./src/index.ts" + "default": "./dist/index.js" }, "./e2ee": { "types": "./dist/e2ee.d.ts", - "node": "./dist/e2ee.js", - "import": "./src/e2ee.ts", - "default": "./src/e2ee.ts" + "default": "./dist/e2ee.js" }, "./cloudflare": { "types": "./dist/cloudflare-adapter.d.ts", - "node": "./dist/cloudflare-adapter.js", - "import": "./src/cloudflare-adapter.ts", - "default": "./src/cloudflare-adapter.ts" + "default": "./dist/cloudflare-adapter.js" } }, "publishConfig": { diff --git a/packages/relay/src/package-exports.test.ts b/packages/relay/src/package-exports.test.ts new file mode 100644 index 0000000000..2e8dda9395 --- /dev/null +++ b/packages/relay/src/package-exports.test.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); +const RELAY_PACKAGE_ROOT = path.resolve(THIS_DIR, ".."); + +interface PackageManifest { + exports: Record>; +} + +function readManifest(): PackageManifest { + return JSON.parse(readFileSync(path.join(RELAY_PACKAGE_ROOT, "package.json"), "utf8")); +} + +function listPublishedFiles(): Set { + if (!existsSync(path.join(RELAY_PACKAGE_ROOT, "dist/index.js"))) { + execFileSync("npm", ["run", "build"], { cwd: RELAY_PACKAGE_ROOT, stdio: "inherit" }); + } + const output = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: RELAY_PACKAGE_ROOT, + encoding: "utf8", + }); + const [pack] = JSON.parse(output) as Array<{ files: Array<{ path: string }> }>; + return new Set(pack.files.map((file) => file.path)); +} + +function listExportTargets(manifest: PackageManifest): Array<{ label: string; file: string }> { + const targets: Array<{ label: string; file: string }> = []; + for (const [subpath, conditions] of Object.entries(manifest.exports)) { + for (const [condition, target] of Object.entries(conditions)) { + targets.push({ label: `${subpath} [${condition}] -> ${target}`, file: target.slice(2) }); + } + } + return targets; +} + +describe("published relay package", () => { + it("ships every file its exports map resolves to, for every condition", () => { + const published = listPublishedFiles(); + const missing = listExportTargets(readManifest()) + .filter((target) => !published.has(target.file)) + .map((target) => target.label); + + expect(missing).toEqual([]); + }); +}); From e939dd4c7a5cb3f6da73006dc3eee9c90bcbdf3d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 1 Oct 2026 04:55:17 +0100 Subject: [PATCH 4/4] Streamline sidebar usage and stacked sheet interactions (#5805) * feat(app): streamline sidebar usage and stacked sheets * fix(app): restore compact sidebar navigation clicks The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript. * fix(app): cap menu sheets at eighty percent height Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing. --- docs/floating-panels.md | 16 ++ docs/glossary.md | 2 +- docs/menus.md | 2 + docs/plugins.md | 2 +- .../e2e/browser/bottom-sheet-reopen.spec.ts | 14 ++ .../e2e/browser/import-session-flow.spec.ts | 9 +- .../e2e/browser/sidebar-nav-settings.spec.ts | 4 +- packages/app/e2e/browser/usage-screen.spec.ts | 6 +- .../app/e2e/browser/usage-settings.spec.ts | 5 +- .../e2e/browser/usage-sidebar-item.spec.ts | 110 +++++++-- .../app/e2e/support/helpers/import-session.ts | 33 ++- .../app/e2e/support/helpers/new-workspace.ts | 3 +- .../support/helpers/sidebar-nav-settings.ts | 38 ++-- .../app/e2e/support/helpers/startup-dsl.ts | 1 - .../e2e/support/helpers/usage-sidebar-item.ts | 112 +++++++++- .../app/src/command-center/command-center.tsx | 14 +- .../src/components/adaptive-modal-sheet.tsx | 10 +- packages/app/src/components/left-sidebar.tsx | 178 ++++----------- .../components/sidebar/sidebar-header-row.tsx | 13 +- .../app/src/components/tool-call-sheet.tsx | 11 +- packages/app/src/components/ui/combobox.tsx | 20 +- .../ui/isolated-bottom-sheet-modal/index.tsx | 42 +++- .../sheet-backdrop.tsx | 5 + .../sheet-backdrop.web.tsx | 36 +++ .../src/components/ui/menu/menu-surface.tsx | 21 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../app/src/screens/new-workspace-screen.tsx | 24 +- .../new-workspace/import-session-button.tsx | 50 +++++ packages/app/src/sidebar-nav/model.test.ts | 42 +++- packages/app/src/sidebar-nav/model.ts | 21 +- .../src/sidebar-nav/use-sidebar-nav-items.ts | 12 +- .../detail-level/overview/sheet.tsx | 15 +- packages/app/src/usage/balance-bar.tsx | 15 +- packages/app/src/usage/controls.tsx | 36 +-- packages/app/src/usage/copy.ts | 3 + packages/app/src/usage/display-toggle.tsx | 22 -- packages/app/src/usage/format.test.ts | 15 ++ packages/app/src/usage/format.ts | 7 +- packages/app/src/usage/index.ts | 2 +- packages/app/src/usage/meter.tsx | 74 ++++++ packages/app/src/usage/options-menu.tsx | 103 +++++++++ packages/app/src/usage/pinned.test.ts | 139 +++++++++--- packages/app/src/usage/pinned.ts | 112 +++++++--- packages/app/src/usage/sidebar-item.tsx | 210 +++++++++++++++--- packages/app/src/usage/tone.ts | 8 +- packages/app/src/usage/window-bar.tsx | 64 +----- .../app/test-stubs/gorhom-bottom-sheet.ts | 4 + packages/plugin/src/server/usage.ts | 11 + packages/protocol/src/messages.ts | 7 + .../claude-usage-source/server/usage.test.ts | 32 +++ plugins/claude-usage-source/server/usage.ts | 9 +- .../codex-usage-source/server/usage.test.ts | 26 ++- plugins/codex-usage-source/server/usage.ts | 23 +- plugins/grok-usage-source/server/usage.ts | 1 + plugins/muse-provider/server/usage.ts | 4 + .../server/usage.test.ts | 2 + .../opencode-go-usage-source/server/usage.ts | 10 +- 65 files changed, 1292 insertions(+), 527 deletions(-) create mode 100644 packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx create mode 100644 packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx create mode 100644 packages/app/src/screens/new-workspace/import-session-button.tsx delete mode 100644 packages/app/src/usage/display-toggle.tsx create mode 100644 packages/app/src/usage/format.test.ts create mode 100644 packages/app/src/usage/meter.tsx create mode 100644 packages/app/src/usage/options-menu.tsx diff --git a/docs/floating-panels.md b/docs/floating-panels.md index b0c16b3e4a..9c4e793fb8 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -302,6 +302,22 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked another pushed sheet. Close React state from `onDismiss`; use `onChange` only to track phase. +All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This +includes native centered dialogs: separate React Native Modals cannot present a root-owned +sibling dialog over an existing iOS presenter. + +The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary +but stores it in the modal queue and later reads `.current`; provider dismissal, replacement, +and restoration then silently do nothing. Callers may use callback refs on the wrapper, which +forwards commands to the current Gorhom handle. + +Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web, +Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated +pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop +keeps its press target until portal teardown and asks the provider to dismiss the top modal. +Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native +retains Gorhom's gesture backdrop and the same push/replace presentation semantics. + ## Gotcha 7 — A sheet cannot read context from its call site React cannot copy contexts reflectively, so the only way across the teleport in diff --git a/docs/glossary.md b/docs/glossary.md index 03b65db022..512068ece8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo. - **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym. - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. -- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. +- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. - **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md). - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). diff --git a/docs/menus.md b/docs/menus.md index 3a6829d738..6791d28e71 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -191,6 +191,8 @@ its own. [floating-panels.md](floating-panels.md). - **One overlay per menu.** Submenus render inside their parent's layer and paint no second backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes. +- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only + request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth). - **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes inactive. An async action can navigate before its menu closes; a hidden panel must not leave a portal backdrop blocking the destination. On web the chat suspends one commit after it goes diff --git a/docs/plugins.md b/docs/plugins.md index 91c0841ffe..b4471d10e7 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -478,7 +478,7 @@ one section's order from built-ins, plugin groups, and the section's preference - The item's `Component` renders directly in the section, with no wrapper, so a fragment or array of rows lays out like separate items while Settings keeps one entry for the block. Footer items - are rows between Add project and the footer's icon row. The icon row (Hosts, Import session, + are rows between Add project and the footer's icon row. The icon row (Hosts, Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon button. - `openPopover` opens through the same `PluginPopoverSurface` as header buttons diff --git a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts index 967462f05b..89ee10af22 100644 --- a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts @@ -84,6 +84,18 @@ async function openModelSelector(page: Page) { await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 }); } +async function dismissStackedModelPickerWithBackdrop(page: Page) { + await page + .getByTestId("agent-controls-settings-list") + .getByRole("button", { name: /Select model/ }) + .click(); + await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible(); + await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24); + await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible(); + await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible(); + await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible(); +} + async function openAndCloseTabSwitcherTwice(page: Page) { await openTabSwitcher(page); await closeBottomSheetWithBackdrop(page); @@ -114,6 +126,8 @@ test.describe("mobile bottom sheet reopen", () => { await openModelSelector(page); const sheet = page.getByTestId("agent-controls-model-sheet"); + await dismissStackedModelPickerWithBackdrop(page); + await page.getByTestId("model-search-all-input").click(); const model = page.getByRole("button", { name: /^Ten second stream/ }); await expect(model).toBeVisible({ diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts index 592d31c330..788a6f93ec 100644 --- a/packages/app/e2e/browser/import-session-flow.spec.ts +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -128,7 +128,6 @@ test("an open Import Session row keeps its age current", async ({ page }) => { await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 }); const flow = new ImportSessionFlow(page); await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 }); - await flow.revealMobileEntryPoint(); await flow.openGlobally(); const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`); @@ -141,13 +140,13 @@ test("captures the compact import-session journey", async ({ page }, testInfo) = const flow = new ImportSessionFlow(page); await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 }); - await test.step("the mobile sidebar exposes import in its footer", async () => { - await flow.revealMobileEntryPoint(); - await capture(page, testInfo, "01-mobile-sidebar-footer.png"); + await test.step("the new workspace screen offers import at the top", async () => { + await flow.revealNewWorkspaceEntryPoint(); + await capture(page, testInfo, "01-mobile-new-workspace-import.png"); }); await test.step("the host-wide sheet is newest first and fits its provider filter", async () => { - await flow.openGlobally(); + await flow.openFromNewWorkspace(); await flow.expectScope("Sessions on", false); await flow.expectRows({ first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"], diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index 038ea63f95..ae7919e952 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -31,13 +31,13 @@ import { setSidebarNavItemVisible, } from "../support/helpers/sidebar-nav-settings"; -test("fixed footer line keeps the labeled Add project button and four icons", async ({ page }) => { +test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => { test.setTimeout(120_000); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]); await page.setViewportSize({ width: 1440, height: 900 }); await gotoAppShell(page); - await expectPinnedUsage(page, ["31%", "7%"]); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); await expectFooterIconRow(page); await expectFooterSeparator(page, true); await footerScreenshot(page, "footer-desktop-with-rows"); diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 78deb7835f..c7de5261aa 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -215,7 +215,7 @@ test.describe("usage screen", () => { }); // Nothing picked and no workspace open: the first host. - await expectPinnedUsage(page, ["31%"]); + await expectPinnedUsage(page, ["31% Weekly"]); await usageItem(page).click(); await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible(); @@ -226,7 +226,7 @@ test.describe("usage screen", () => { ).toBeVisible({ timeout: 30_000 }); await expect(hostFilter(page)).toContainText("Secondary box"); await qaScreenshot(page, "usage-screen-picked-host"); - await expectPinnedUsage(page, ["12%"]); + await expectPinnedUsage(page, ["12% Weekly"]); // The e2e seed resets the host list on every load, so reopening re-adds the second host. await addConnectedHostAndReload(page, { @@ -234,7 +234,7 @@ test.describe("usage screen", () => { label: "Secondary box", port: secondary.port, }); - await expectPinnedUsage(page, ["12%"]); + await expectPinnedUsage(page, ["12% Weekly"]); await expect( page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 1c590c38be..5ad817aeb1 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -7,6 +7,7 @@ import { installUsageReportsFixture, type UsageReportsFixture, } from "../support/helpers/usage-reports"; +import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item"; const ICON = ''; @@ -89,7 +90,7 @@ test.describe("usage settings", () => { await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible(); - await card.getByTestId("usage-display-remaining").click(); + await showUsageAs(page, "remaining"); await expect(card.getByText("30% left")).toBeVisible(); await expect(card.getByText("93% left")).toBeVisible(); }); @@ -117,7 +118,7 @@ test.describe("usage settings", () => { const card = page.getByTestId("usage-card"); await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 }); - await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await refreshAllUsage(page); await expect.poll(() => forcedRefreshCount(usage)).toBe(1); await expect(card.getByText("64%")).toBeVisible(); }); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index e09e4d22c6..124eaca5f6 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -4,7 +4,11 @@ import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getServerId } from "../support/helpers/server-id"; import { openSettingsHostSection } from "../support/helpers/settings"; -import { leaveSettings, openSidebarNavSettings } from "../support/helpers/sidebar-nav-settings"; +import { + leaveSettings, + openSidebarNavSettings, + setFooterItemVisible, +} from "../support/helpers/sidebar-nav-settings"; import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, @@ -13,11 +17,15 @@ import { leaveUsageScreen, openCompactSidebar, pinRow, + openUsageOptions, showUsageAs, togglePin, usageItem, usageSheet, + installTallUsageSource, } from "../support/helpers/usage-sidebar-item"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; +import { waitForSettledPosition } from "../support/helpers/sheet-layout"; const WIDE = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; @@ -47,6 +55,32 @@ async function footerClip(page: Page) { } test.describe("Usage item", () => { + test("long usage reports stay in an 80% sheet and scroll to the final window", async ({ + page, + }) => { + test.setTimeout(120_000); + const source = await installTallUsageSource(); + try { + await page.setViewportSize(COMPACT); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await openCompactSidebar(page); + await expect(usageItem(page)).toBeInViewport(); + await usageItem(page).click(); + const content = page.getByTestId("sidebar-usage-sheet-content"); + await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); + await waitForSettledPosition(content); + const bounds = (await content.boundingBox())!; + expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded(); + await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); + await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded(); + await expect(content.getByText("Window 1", { exact: true })).toBeInViewport(); + } finally { + await source.cleanup(); + } + }); test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({ page, }) => { @@ -59,15 +93,20 @@ test.describe("Usage item", () => { await test.step("a fresh device shows default windows, which opens the Usage screen", async () => { await expect(usageItem(page)).toBeVisible({ timeout: 30_000 }); - await expectPinnedUsage(page, ["31%", "7%"]); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); await openSidebarNavSettings(page); await qaScreenshot(page, "desktop-settings-sidebar-footer"); await page.setViewportSize(COMPACT); + // Phones start with the Usage item off; turning it on stores the choice. + await expect( + page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"), + ).toHaveAttribute("aria-checked", "false"); + await setFooterItemVisible(page, "usage", true); await qaScreenshot(page, "compact-settings-sidebar-footer"); await gotoAppShell(page); await openCompactSidebar(page); - await expectPinnedUsage(page, ["31%", "7%"]); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); await qaScreenshot(page, "compact-footer-defaults"); await page.setViewportSize(WIDE); await gotoAppShell(page); @@ -82,12 +121,12 @@ test.describe("Usage item", () => { 0, ); await togglePin(screen, "Claude", "Session"); - await expectPinnedUsage(page, ["31%"]); + await expectPinnedUsage(page, ["31% 5h"]); await togglePin(screen, "Codex", "Weekly"); - await expectPinnedUsage(page, ["31%", "12%"]); + await expectPinnedUsage(page, ["31% 5h", "12% wk"]); await expect(usageItem(page)).not.toHaveText("Usage"); await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" }); - await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Usage options", exact: true })).toBeVisible(); await qaScreenshot(page, "usage-screen-header", { kind: "element", locator: page.getByTestId("page-title").locator(".."), @@ -100,7 +139,7 @@ test.describe("Usage item", () => { await test.step("remaining flips the Usage item and the Usage screen", async () => { await showUsageAs(page, "remaining"); - await expectPinnedUsage(page, ["69%", "88%"]); + await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/); await expect( screen.getByTestId("usage-report-claude:default").getByText("69% left"), @@ -123,7 +162,7 @@ test.describe("Usage item", () => { await leaveUsageScreen(page); await openCompactSidebar(page); await expect(usageItem(page)).toBeInViewport(); - await expectPinnedUsage(page, ["69%", "88%"]); + await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await qaScreenshot(page, "compact-footer"); await usageItem(page).click(); const sheet = usageSheet(page); @@ -136,7 +175,18 @@ test.describe("Usage item", () => { /^Pin Claude Session, \d+% left( · .+)?$/, ); // The sheet carries the Usage screen's controls. - await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible(); + await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible(); + // The options open as a sheet of their own over the usage sheet. + await openUsageOptions(page); + await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( + "aria-checked", + "true", + ); + await qaScreenshot(page, "compact-options-sheet"); + // Tap the backdrop above both sheets; only the options sheet closes. + await page.mouse.click(COMPACT.width / 2, 40); + await expect(page.getByTestId("usage-display-used")).toHaveCount(0); + await expect(sheet).toBeVisible(); await expect(page).not.toHaveURL(/\/usage$/); const sheetBox = (await sheet.boundingBox())!; expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3); @@ -153,10 +203,14 @@ test.describe("Usage item", () => { await test.step("a reload keeps the pins and the toggle", async () => { await page.reload(); - await expectPinnedUsage(page, ["69%", "88%"]); - await expect( - page.locator('[data-testid="usage-display-remaining"]:visible').first(), - ).toHaveAttribute("aria-selected", "true"); + await expectPinnedUsage(page, ["69% 5h", "88% wk"]); + await openUsageOptions(page); + await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( + "aria-checked", + "true", + ); + await qaScreenshot(page, "usage-options-menu"); + await page.keyboard.press("Escape"); }); await test.step("the Settings usage section shares the pins and the toggle", async () => { @@ -173,10 +227,10 @@ test.describe("Usage item", () => { await test.step("unpinning both brings back default windows, and that survives a reload", async () => { await togglePin(screen, "Claude", "Session"); await togglePin(screen, "Codex", "Weekly"); - await expectPinnedUsage(page, ["69%", "93%"]); + await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]); await page.reload(); await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 }); - await expectPinnedUsage(page, ["69%", "93%"]); + await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]); await gotoAppShell(page); await usageItem(page).click(); await expectOnUsageScreen(page); @@ -194,6 +248,28 @@ test.describe("Usage item", () => { }); }); +test("the Usage options show and hide the sidebar Usage item", async ({ page }) => { + await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expect(usageItem(page)).toBeVisible({ timeout: 30_000 }); + await usageItem(page).click(); + await expectOnUsageScreen(page); + + await openUsageOptions(page); + const toggle = page.getByTestId("usage-show-in-sidebar"); + await expect(toggle).toHaveAttribute("aria-checked", "true"); + await toggle.click(); + await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0); + + await page.reload(); + await expectOnUsageScreen(page); + await openUsageOptions(page); + await expect(toggle).toHaveAttribute("aria-checked", "false"); + await toggle.click(); + await expect(usageItem(page)).toBeVisible(); +}); + test("released hosts supply source logos through the client conversion", async ({ page }) => { await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()], @@ -201,12 +277,12 @@ test("released hosts supply source logos through the client conversion", async ( }); await page.setViewportSize(WIDE); await gotoAppShell(page); - await expectPinnedUsage(page, ["31%", "7%"]); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); // Source logos are decorative SVGs with no accessible role. Their path data distinguishes // the source artwork from the fallback gauge. const claudePath = /]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!; const codexPath = /]* d="([^"]+)"/.exec(claudeAndCodexReports()[1]!.icon!)![1]!; - const summary = usageItem(page).getByTestId("sidebar-usage-pinned-window"); + const summary = usageItem(page).getByTestId("sidebar-usage-source"); await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath); await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath); await qaScreenshot(page, "released-host-footer", { kind: "footer" }); diff --git a/packages/app/e2e/support/helpers/import-session.ts b/packages/app/e2e/support/helpers/import-session.ts index 0bf2fc16e9..718b0d5965 100644 --- a/packages/app/e2e/support/helpers/import-session.ts +++ b/packages/app/e2e/support/helpers/import-session.ts @@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { gotoAppShell } from "./app"; import { openCommandCenter } from "./command-center"; +import { openGlobalNewWorkspaceComposer } from "./new-workspace"; import { getServerId } from "./server-id"; import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar"; @@ -18,16 +19,24 @@ export class ImportSessionFlow { timeout: 30_000, }); } - async revealMobileEntryPoint() { - await openMobileAgentSidebar(this.page); - await expectMobileAgentSidebarVisible(this.page); - const button = this.page.getByTestId("sidebar-import-session"); + /** The new workspace screen's Import session button, reached from the sidebar. */ + async revealNewWorkspaceEntryPoint() { + const compact = (this.page.viewportSize()?.width ?? 0) < 768; + if (compact) { + await openMobileAgentSidebar(this.page); + await expectMobileAgentSidebarVisible(this.page); + } + await openGlobalNewWorkspaceComposer(this.page); + const button = this.page.getByTestId("new-workspace-import-session"); await expect(button).toHaveAccessibleName("Import session"); await expect(button).toBeInViewport(); } async openGlobally() { - await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); - await this.page.getByTestId("sidebar-import-session").click(); + await this.revealNewWorkspaceEntryPoint(); + await this.openFromNewWorkspace(); + } + async openFromNewWorkspace() { + await this.page.getByTestId("new-workspace-import-session").click(); await this.expectSheetReady(); } async openFromWorkspaceHeader() { @@ -108,8 +117,12 @@ export class ImportSessionFlow { }); } async expectTranscript(userText: string, assistantText: string) { - await expect(this.page.getByTestId("user-message")).toContainText(userText); - await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText); + await expect(this.page.getByTestId("user-message").filter({ visible: true })).toContainText( + userText, + ); + await expect( + this.page.getByTestId("assistant-message").filter({ visible: true }), + ).toContainText(assistantText); } async showAll() { await this.page.getByTestId("import-session-show-all").click(); @@ -120,7 +133,9 @@ export class ImportSessionFlow { timeout: 30_000, }); const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`); - await expect(workspace.getByTestId("user-message")).toContainText(userText); + await expect(workspace.getByTestId("user-message").filter({ visible: true })).toContainText( + userText, + ); } async close() { await this.page.keyboard.press("Escape"); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index dace580491..63034d52a3 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -198,7 +198,8 @@ export async function openNewWorkspaceComposer( } export async function openGlobalNewWorkspaceComposer(page: Page): Promise { - await page.getByTestId("sidebar-global-new-workspace").click(); + // The shell keeps a hidden copy of the other layout's sidebar mounted. + await page.locator('[data-testid="sidebar-global-new-workspace"]:visible').first().click(); await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000, diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index b17b7b6bfb..6631d839cb 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -220,32 +220,32 @@ export async function expectFooterItemHidden(page: Page, key: string): Promise { - const boxes = await Promise.all( - FOOTER_ICON_TEST_IDS.map((testID) => - page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(), - ), - ); - const [first, ...rest] = boxes.map((box) => box!); - expect(first.width).toBeGreaterThan(rest[0]!.width); - await expect( - page - .locator('[data-testid="sidebar-add-project"]:visible') - .getByText("Add project", { exact: true }), - ).toBeVisible(); - let previous = first; - for (const box of rest) { - expect(Math.abs(box.y + box.height / 2 - first.y - first.height / 2)).toBeLessThan(2); - expect(box.x).toBeGreaterThan(previous.x); - previous = box; + const boxes = ( + await Promise.all( + FOOTER_ICON_TEST_IDS.map((testID) => + page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(), + ), + ) + ).map((box) => box!); + const [first] = boxes; + for (const box of boxes) { + expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2); + expect(box.width).toBe(first!.width); } + const gaps = boxes.slice(1).map((box, index) => box.x - (boxes[index]!.x + boxes[index]!.width)); + expect(gaps).toEqual([0, 0, expect.any(Number), 0]); + expect(gaps[2]).toBeGreaterThan(first!.width); } export async function expectFooterSeparator(page: Page, shown: boolean): Promise { diff --git a/packages/app/e2e/support/helpers/startup-dsl.ts b/packages/app/e2e/support/helpers/startup-dsl.ts index dc65b63e08..19dbeecdd0 100644 --- a/packages/app/e2e/support/helpers/startup-dsl.ts +++ b/packages/app/e2e/support/helpers/startup-dsl.ts @@ -163,7 +163,6 @@ class StartupAssertions { await expect(hostRow).toBeVisible({ timeout: 15_000 }); await expect(hostRow).toContainText(input.label); await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible(); - await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); await expect(this.page.getByTestId("sidebar-settings")).toBeVisible(); await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0); return this; diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 342045cd1d..56d4455099 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -1,7 +1,59 @@ import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expect, type Locator, type Page } from "@playwright/test"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { pluginRequirements } from "./plugin-fixture"; + +/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */ +export async function installTallUsageSource() { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const cleanup = async () => { + try { + await client.removePlugin("tall-usage"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + } finally { + await client.close(); + await rm(directory, { recursive: true, force: true }); + } + }; + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "tall-usage", + requirements: pluginRequirements, + }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { z } from "zod"; +export default function contribute(server) { + server.registerUsageSource({ + id: "tall-usage", label: "Scrolling account", input: z.object({}), + discover: async () => [{}], + identify: async () => ({ key: "scrolling-account" }), + fetch: async () => ({ status: "available", windows: Array.from({ length: 20 }, (_, i) => ({ + id: String(i), label: "Window " + (i + 1), usedPct: 25, + })) }), + }); + return () => {}; +} +`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: directory }); + return { cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins"); @@ -28,8 +80,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] { status: "available", planLabel: "Max", windows: [ - { id: "five_hour", label: "Session", usedPct: 31, resetsAt: inOneDay() }, - { id: "weekly", label: "Weekly", usedPct: 54, resetsAt: inOneDay() }, + { + id: "five_hour", + label: "Session", + shortLabel: "5h", + summary: true, + usedPct: 31, + resetsAt: inOneDay(), + }, + { + id: "weekly", + label: "Weekly", + shortLabel: "wk", + summary: true, + usedPct: 54, + resetsAt: inOneDay(), + }, ], }, }, @@ -44,8 +110,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] { status: "available", planLabel: "Pro", windows: [ - { id: "session", label: "Session", usedPct: 7, resetsAt: inOneDay() }, - { id: "weekly", label: "Weekly", usedPct: 12, resetsAt: inOneDay() }, + { + id: "session", + label: "Session", + shortLabel: "5h", + summary: true, + usedPct: 7, + resetsAt: inOneDay(), + }, + { + id: "weekly", + label: "Weekly", + shortLabel: "wk", + summary: true, + usedPct: 12, + resetsAt: inOneDay(), + }, ], }, }, @@ -71,9 +151,10 @@ export async function expectOnUsageScreen(page: Page): Promise { await expect(page).toHaveURL(/\/usage$/); } -export async function expectPinnedUsage(page: Page, percents: string[]): Promise { - const windows = usageItem(page).getByTestId("sidebar-usage-pinned-window"); - await expect(windows).toHaveText(percents); +/** Each pinned window as it reads: its percent and short label, "31% 5h". */ +export async function expectPinnedUsage(page: Page, windows: string[]): Promise { + const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window"); + await expect(pinned).toHaveText(windows); } /** Without pinned windows the Usage item is a plain row that reads "Usage". */ @@ -95,8 +176,21 @@ export async function togglePin(scope: Locator, source: string, window: string) await expect(row).toBeChecked({ checked: !pinned }); } -export async function showUsageAs(scope: Locator | Page, displayAs: "used" | "remaining") { - await scope.locator(`[data-testid="usage-display-${displayAs}"]:visible`).first().click(); +/** The usage title row's options menu: Refresh and Used/Remaining. */ +export async function openUsageOptions(page: Page): Promise { + await page.locator('[data-testid="usage-options-menu"]:visible').first().click(); + await expect(page.getByTestId("usage-display-used")).toBeVisible(); +} + +export async function showUsageAs(page: Page, displayAs: "used" | "remaining") { + await openUsageOptions(page); + await page.getByTestId(`usage-display-${displayAs}`).click(); + await expect(page.getByTestId("usage-display-used")).toHaveCount(0); +} + +export async function refreshAllUsage(page: Page): Promise { + await openUsageOptions(page); + await page.getByRole("menuitem", { name: "Refresh", exact: true }).click(); } /** Opens the compact sidebar drawer. */ diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index e909bf28e5..607f24c214 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -14,11 +14,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { - BottomSheetBackdrop, - BottomSheetFlatList, - type BottomSheetFlatListMethods, -} from "@gorhom/bottom-sheet"; +import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet"; import { AgentStatusDot } from "@/components/agent-status-dot"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -718,12 +714,6 @@ export function CommandCenter() { layer: modalLayer, onKeyDown: handleWebOverlayKeyDown, }); - const backdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); if (showBottomSheet) { return ( @@ -735,7 +725,7 @@ export function CommandCenter() { enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={backdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundStyle={styles.sheetBackground} handleIndicatorStyle={styles.sheetHandle} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 78cdfd2e7a..4ceb43d917 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -13,7 +13,6 @@ import { useWebOverlayRegistration, } from "../lib/overlay-root"; import { - BottomSheetBackdrop, KEYBOARD_STATUS, useBottomSheetInternal, type BottomSheetBackgroundProps, @@ -550,13 +549,6 @@ export function AdaptiveModalSheet({ onDismiss?.(); }, [handleSheetDismiss, onDismiss]); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); - const desktopCardStyle = useMemo( () => [ styles.desktopCard, @@ -649,7 +641,7 @@ export function AdaptiveModalSheet({ enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundComponent={SheetBackground} handleIndicatorStyle={handleIndicatorStyle} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index c2bff3febc..8095497ec7 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router } from "expo-router"; -import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native"; +import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -8,7 +8,6 @@ import { Text, useWindowDimensions, View, - type PressableStateCallbackType, } from "react-native"; import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; @@ -45,6 +44,7 @@ import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store"; import { useHosts } from "@/runtime/host-runtime"; import { PluginSidebarItem } from "@/plugins/sidebar-items"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; @@ -52,7 +52,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; -import { UsageSidebarItem } from "@/usage"; +import { UsageSidebarItem, useOpenUsageScreen } from "@/usage"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; @@ -89,9 +89,9 @@ interface SidebarSharedProps { interface SidebarLabels { addProject: string; hosts: string; - importSession: string; settings: string; searchHosts: string; + usage: string; closeSidebar: string; } @@ -196,9 +196,9 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), hosts: t("sidebar.actions.hosts"), - importSession: t("importSession.title"), settings: t("sidebar.actions.settings"), searchHosts: t("sidebar.host.searchPlaceholder"), + usage: t(builtinSidebarNavLabelKey("usage")), closeSidebar: t("sidebar.actions.closeSidebar"), }), [t], @@ -283,6 +283,7 @@ function FooterIconButton({ testID: string; label: string; icon: typeof FolderPlus; + /** Only for a glyph that reads larger than the others at the same size. */ iconSize?: number; shortcutKeys?: ReturnType; theme: SidebarTheme; @@ -317,64 +318,6 @@ function FooterIconButton({ ); } -function footerAddProjectButtonStyle({ - hovered, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered]; -} - -function FooterAddProjectButton({ - onPress, - label, - shortcutKeys, - theme, -}: { - onPress: () => void; - label: string; - shortcutKeys: ReturnType; - theme: SidebarTheme; -}) { - return ( - - - - {({ hovered }) => { - const isHovered = Boolean(hovered); - return ( - <> - - - {label} - - - ); - }} - - - - - - - ); -} - function SidebarHostPicker({ theme, label, @@ -423,7 +366,8 @@ function SidebarHostPicker({ testID="sidebar-hosts-trigger" label={label} icon={Server} - iconSize={theme.iconSize.sm} + // Server's two boxes fill more of the square than the other glyphs. + iconSize={theme.iconSize.md - 1} theme={theme} /> @@ -448,7 +392,6 @@ function IconTooltipContent({ function SidebarFooter({ theme, handleOpenProject, - handleImportSession, handleSettings, labels, handleAddHost, @@ -457,14 +400,13 @@ function SidebarFooter({ }: { theme: SidebarTheme; handleOpenProject: () => void; - handleImportSession: () => void; handleSettings: () => void; labels: { addProject: string; hosts: string; - importSession: string; settings: string; searchHosts: string; + usage: string; }; handleAddHost: () => void; handleOpenHostSettings: (serverId: string) => void; @@ -472,41 +414,44 @@ function SidebarFooter({ }) { const newAgentKeys = useShortcutKeys("new-agent"); const settingsKeys = useShortcutKeys("toggle-settings"); + const openUsageScreen = useOpenUsageScreen(); + // One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. return ( - - - - - - - + + + + + ); @@ -593,7 +538,11 @@ function MobileSidebar({ - + ({ right: 0, zIndex: 2, alignItems: "flex-end", - pointerEvents: "box-none", }, mobileCloseButton: { // The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph. @@ -971,48 +917,22 @@ const styles = StyleSheet.create((theme) => ({ borderTopWidth: 1, borderTopColor: theme.colors.border, }, + // The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs. sidebarFooter: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[3], - }, - footerIconRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - flexShrink: 0, - }, - footerAddProjectButton: { - minWidth: 0, - minHeight: 32, - flex: 1, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], paddingVertical: theme.spacing[1.5], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.lg, }, - footerAddProjectButtonHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, + // Pushes Help and Settings to the end of the icon line. + footerSpacer: { + flex: 1, }, - footerAddProjectLabel: { - minWidth: 0, - flexShrink: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - }, - footerAddProjectLabelHovered: { - color: theme.colors.foreground, - }, - // Usage and plugin rows sit above the restored footer line. + // Usage and plugin rows sit above the footer's icon line, spaced like the header nav rows. footerRows: { paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[3], - gap: theme.spacing[1], + paddingVertical: theme.spacing[1.5], + gap: 2, }, footerIconButton: { width: 28, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index f59672d388..4519f6d4ff 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -89,7 +89,7 @@ export function SidebarHeaderRow({ accessibilityLabel={accessibilityLabel ?? label} accessibilityState={isActive ? SELECTED_STATE : undefined} aria-selected={isActive} - style={variant === "inline" ? styles.buttonInline : styles.button} + style={styles.button} > {ThemedIcon ? ( ({ // edge with the list below. paddingHorizontal: theme.spacing[2], }, - // Footer rows put their icon on the footer's rail: where a 16px icon sits in a 28px button. - buttonInline: { - flex: 1, - minWidth: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minHeight: 28, - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1.5], - }, iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md }, iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm }, label: { diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 022a9bc7c2..33009ee376 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { View, Text, Pressable } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { @@ -115,13 +115,6 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) setSheetData(null); }, [handleSheetDismiss]); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); - const contextValue = useMemo( () => ({ openToolCall, closeToolCall }), [openToolCall, closeToolCall], @@ -138,7 +131,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleToolCallSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.5} enablePanDownToClose > {sheetData && } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 74748e423a..736ca3bfa5 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -28,11 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { - BottomSheetScrollView, - BottomSheetBackdrop, - BottomSheetBackgroundProps, -} from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { Check, File, Folder, Search } from "lucide-react-native"; @@ -948,18 +944,6 @@ interface MobileBodyProps { } function MobileComboboxBody(props: MobileBodyProps): ReactElement { - const renderBackdrop = useCallback( - (backdropProps: React.ComponentProps) => ( - - ), - [], - ); - const comboboxTitleStyle = useMemo( () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], @@ -991,7 +975,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { enableDynamicSizing={false} onChange={props.handleSheetChange} onDismiss={props.handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundComponent={ComboboxSheetBackground} handleIndicatorStyle={props.handleIndicatorStyle} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index f7869823b8..3f9c9113d0 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -1,9 +1,10 @@ import { BottomSheetModal as GorhomBottomSheetModal, type BottomSheetModalProps, + type BottomSheetBackdropProps, } from "@gorhom/bottom-sheet"; import React from "react"; -import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import type { ElementRef, ReactNode } from "react"; import { systemBackPress } from "./back-press"; import { @@ -11,6 +12,7 @@ import { createBottomSheetVisibilityTracker, } from "./visibility-tracker"; import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope"; +import { SheetBackdrop } from "./sheet-backdrop"; type GorhomBottomSheetModalMethods = ElementRef; @@ -36,7 +38,7 @@ export type ContextBridge = (children: ReactNode) => ReactNode; type IsolatedBottomSheetModalProps = Omit< BottomSheetModalProps, - "enableDismissOnClose" | "stackBehavior" | "children" + "enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent" > & { /** * Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge @@ -44,6 +46,8 @@ type IsolatedBottomSheetModalProps = Omit< */ children?: ReactNode; presentation?: "push" | "replace"; + /** Show a dismissible backdrop. Its interaction belongs to the modal stack. */ + backdropOpacity?: number; /** * Required, and `null` is a real answer: a sheet that needs nothing from its call site should * have to say so. The failure it prevents is invisible until someone adds a `useContext` deep @@ -58,13 +62,43 @@ export const IsolatedBottomSheetModal = forwardRef< IsolatedBottomSheetModalRef, IsolatedBottomSheetModalProps >(function IsolatedBottomSheetModal(props, ref) { - const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props; + // Gorhom puts this ref into its provider queue and reads `.current` for stack operations. + // A callback ref accepts the handle but leaves that queue unable to dismiss or restore it. + const modalRef = useRef(null); + useImperativeHandle( + ref, + () => ({ + present: (...args) => modalRef.current?.present(...args), + dismiss: (...args) => modalRef.current?.dismiss(...args), + snapToIndex: (...args) => modalRef.current?.snapToIndex(...args), + snapToPosition: (...args) => modalRef.current?.snapToPosition(...args), + expand: (...args) => modalRef.current?.expand(...args), + collapse: (...args) => modalRef.current?.collapse(...args), + close: (...args) => modalRef.current?.close(...args), + forceClose: (...args) => modalRef.current?.forceClose(...args), + }), + [], + ); + const { + children, + presentation = "push", + contextBridge, + backdropOpacity, + ...bottomSheetProps + } = props; + const renderBackdrop = useCallback( + (backdropProps: BottomSheetBackdropProps) => ( + + ), + [backdropOpacity], + ); const modal = ( {contextBridge ? contextBridge(children) : children} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx new file mode 100644 index 0000000000..1232566462 --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx @@ -0,0 +1,5 @@ +import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; + +export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { + return ; +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx new file mode 100644 index 0000000000..ba1e397a9e --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx @@ -0,0 +1,36 @@ +import { + BottomSheetBackdrop, + useBottomSheetModal, + type BottomSheetBackdropProps, +} from "@gorhom/bottom-sheet"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable, StyleSheet, View } from "react-native"; + +export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { + const { dismiss } = useBottomSheetModal(); + const { t } = useTranslation(); + const dismissTopSheet = useCallback(() => dismiss(), [dismiss]); + + return ( + + + + + ); +} diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 3a2729ad8e..0b5a6a77ac 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -5,14 +5,13 @@ import { useEffect, useMemo, useRef, - type ComponentProps, type ReactElement, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, View, useWindowDimensions } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { ChevronLeft } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { @@ -345,6 +344,7 @@ function MenuSheetSurface({ testID, keyboardFocusScope, }: MenuSurfaceProps): ReactElement | null { + const { height: windowHeight } = useWindowDimensions(); const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); const safeAreaInsets = useSafeAreaInsets(); @@ -370,18 +370,6 @@ function MenuSheetSurface({ onClose: handleClose, }); - const renderBackdrop = useCallback( - (backdropProps: ComponentProps) => ( - - ), - [], - ); - const openPageId = currentPageId(menu.path); const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const depth = menu.path.length; @@ -405,9 +393,10 @@ function MenuSheetSurface({ // Content-sized rather than fixed snap points: a pushed page is rarely the same height // as the page it replaced, and a fixed sheet would either clip it or leave dead space. enableDynamicSizing + maxDynamicContentSize={windowHeight * 0.8} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose // `interactive` rather than `extend`, which is what every other sheet in the app uses. // `extend` grows the sheet to its largest snap point, and with `enableDynamicSizing` that diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 07f3a6d00d..d29de2b33d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -26,6 +26,7 @@ export const ar: TranslationResources = { total: "{{total}} تطابقات", }, common: { + bottomSheetBackdrop: "خلفية اللوحة السفلية", back: "خلف", loading: "تحميل...", actions: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9230ef3155..1e2d4c3f10 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -22,6 +22,7 @@ export const en = { total: "{{total}} matches", }, common: { + bottomSheetBackdrop: "Bottom sheet backdrop", back: "Back", loading: "Loading...", actions: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index c61778633d..cc37432b5b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -27,6 +27,7 @@ export const es: TranslationResources = { total: "{{total}} coincidencias", }, common: { + bottomSheetBackdrop: "Fondo del panel inferior", back: "Atrás", loading: "Cargando...", actions: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6e6999e5f8..aaf2608f9e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -27,6 +27,7 @@ export const fr: TranslationResources = { total: "{{total}} résultats", }, common: { + bottomSheetBackdrop: "Arrière-plan du panneau inférieur", back: "Dos", loading: "Chargement...", actions: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 8e59c63234..3491b507a2 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -27,6 +27,7 @@ export const ja: TranslationResources = { total: "{{total}} 件の一致", }, common: { + bottomSheetBackdrop: "ボトムシートの背景", back: "戻る", loading: "読み込み中...", actions: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 0f9437720d..7afe54b158 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -26,6 +26,7 @@ export const ko: TranslationResources = { total: "일치 항목 {{total}}개", }, common: { + bottomSheetBackdrop: "하단 시트 배경", back: "뒤로", loading: "불러오는 중...", actions: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 75249797a2..a08088b2e4 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -27,6 +27,7 @@ export const ptBR: TranslationResources = { total: "{{total}} correspondências", }, common: { + bottomSheetBackdrop: "Fundo do painel inferior", back: "Voltar", loading: "Carregando...", actions: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index af11db8bf1..4526784896 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -27,6 +27,7 @@ export const ru: TranslationResources = { total: "Совпадений: {{total}}", }, common: { + bottomSheetBackdrop: "Фон нижней панели", back: "Назад", loading: "Загрузка...", actions: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 3bc8727cb4..d13007dbea 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -26,6 +26,7 @@ export const zhCN: TranslationResources = { total: "{{total}} 个匹配项", }, common: { + bottomSheetBackdrop: "底部面板背景", back: "返回", loading: "加载中...", actions: { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 9e0d25144c..c919ab476d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -122,6 +122,8 @@ import { } from "./new-workspace-initial-context"; import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +import { ImportSessionButton } from "./new-workspace/import-session-button"; +import { useImportSession } from "@/hooks/use-import-session"; import { buildTerminalsQueryKey, type ListTerminalsPayload, @@ -2367,6 +2369,7 @@ export function NewWorkspaceScreen({ }); const screenHeaderLeft = useMemo(() => , []); + const importSession = useImportSession({ serverId: selectedServerId }); const composer = isTerminalLaunch ? ( {composer} {errorMessage ? {errorMessage} : null} + {importSession.sheet} ); } @@ -2448,15 +2453,20 @@ function NewWorkspaceLayout({ isCompact, title, formStack, + onImportSession, children, }: { isCompact: boolean; title: string; formStack: ReactNode; + onImportSession: () => void; children: ReactNode; }) { + // At the top of the screen on compact layouts, under the composer otherwise. + const importSessionButton = ; const setupFields = ( <> + {isCompact ? {importSessionButton} : null} {title} @@ -2466,7 +2476,10 @@ function NewWorkspaceLayout({ return ( {setupFields} - {children} + <> + {children} + {isCompact ? null : importSessionButton} + ); } @@ -2481,6 +2494,12 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", flex: 1, }, + // Takes the free space above the setup fields, so its button sits at the top of the screen. + // The inset puts the ghost button's icon on the setup rows' icon rail. + compactTopActions: { + flex: 1, + paddingHorizontal: theme.spacing[3], + }, composerTitleContainer: { marginBottom: theme.spacing[8], paddingLeft: theme.spacing[6], @@ -2503,7 +2522,8 @@ const styles = StyleSheet.create((theme) => ({ formStackDesktop: { flexDirection: "row", alignItems: "center", - marginBottom: theme.spacing[8], + // Matches the gap between the composer and the Import session pill below it. + marginBottom: theme.spacing[4], // The badge adds its own left padding; offset it so the project icon's left // edge lands exactly on the "New workspace" title's left edge. The trailing // inset mirrors it so the launch chip stops on the composer's inner content diff --git a/packages/app/src/screens/new-workspace/import-session-button.tsx b/packages/app/src/screens/new-workspace/import-session-button.tsx new file mode 100644 index 0000000000..39b55e1b2e --- /dev/null +++ b/packages/app/src/screens/new-workspace/import-session-button.tsx @@ -0,0 +1,50 @@ +import { Import } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; + +/** + * The new workspace screen's way into Import session: a ghost button at the top on compact + * layouts, an outlined pill under the composer otherwise. Both are muted until hovered. + */ +export function ImportSessionButton({ + compact, + onPress, +}: { + compact: boolean; + onPress: () => void; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compact: { + alignItems: "flex-start", + }, + // The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's + // border lines up with the composer's border. + wide: { + alignItems: "flex-start", + paddingHorizontal: theme.spacing[4], + }, + pill: { + borderRadius: theme.borderRadius.full, + borderColor: theme.colors.borderAccent, + }, +})); diff --git a/packages/app/src/sidebar-nav/model.test.ts b/packages/app/src/sidebar-nav/model.test.ts index 4211a44410..f58acba007 100644 --- a/packages/app/src/sidebar-nav/model.test.ts +++ b/packages/app/src/sidebar-nav/model.test.ts @@ -34,6 +34,7 @@ describe("resolveSidebarNavItems", () => { it("yields builtins then plugins, all visible, when nothing is stored", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [kanban, notes], preferences: [], }); @@ -58,6 +59,7 @@ describe("resolveSidebarNavItems", () => { it("keeps the stored order and appends newly available items as visible", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [notes, kanban], preferences: [ { key: kanbanKey, visible: false }, @@ -79,6 +81,7 @@ describe("resolveSidebarNavItems", () => { it("skips keys that are unknown or not currently available", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [], preferences: [ { key: notesKey, visible: false }, @@ -98,6 +101,7 @@ describe("resolveSidebarNavItems", () => { it("lets the first of duplicate keys win", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [], preferences: [ { key: "history", visible: false }, @@ -114,10 +118,26 @@ describe("resolveSidebarNavItems", () => { }); }); +describe("resolveSidebarNavItems on compact layouts", () => { + it("hides the Usage item until it is turned on", () => { + const resolve = (compact: boolean, preferences: SidebarNavPreference[]) => + summarize( + resolveSidebarNavItems({ section: "footer", compact, pluginGroups: [], preferences }), + ); + + expect(resolve(true, [])).toEqual([{ key: "usage", visible: false }]); + expect(resolve(false, [])).toEqual([{ key: "usage", visible: true }]); + expect(resolve(true, [{ key: "usage", visible: true }])).toEqual([ + { key: "usage", visible: true }, + ]); + }); +}); + describe("setSidebarNavItemVisible", () => { it("toggles one item and writes the full resolved order", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [kanban], preferences: [], }); @@ -140,6 +160,7 @@ describe("setSidebarNavItemVisible", () => { ]; const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [], preferences: previous, }); @@ -165,6 +186,7 @@ describe("setSidebarNavItemVisible", () => { ]; const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [], preferences: previous, }); @@ -180,13 +202,23 @@ describe("setSidebarNavItemVisible", () => { ]); expect( summarize( - resolveSidebarNavItems({ section: "header", pluginGroups: [notes], preferences: next }), + resolveSidebarNavItems({ + section: "header", + compact: false, + pluginGroups: [notes], + preferences: next, + }), ), ).toEqual(next); }); it("returns the normalized list unchanged for an unknown key", () => { - const items = resolveSidebarNavItems({ section: "header", pluginGroups: [], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + compact: false, + pluginGroups: [], + preferences: [], + }); const next = setSidebarNavItemVisible({ items, key: "bogus", visible: false, previous: [] }); @@ -197,6 +229,7 @@ describe("setSidebarNavItemVisible", () => { describe("moveSidebarNavItem", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [kanban], preferences: [], }); @@ -274,6 +307,7 @@ describe("footer section", () => { it("resolves the Usage item first, then plugin rows", () => { const items = resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [sync], preferences: [], }); @@ -287,6 +321,7 @@ describe("footer section", () => { it("ignores header built-ins stored in footer preferences", () => { const items = resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [], preferences: [{ key: "history", visible: false }], }); @@ -297,6 +332,7 @@ describe("footer section", () => { it("ignores the footer icon buttons, which are fixed and not items", () => { const items = resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [sync], preferences: [ { key: "add-project", visible: false }, @@ -319,6 +355,7 @@ describe("footer section", () => { const previous: SidebarNavPreference[] = [notesPreference]; const items = resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [sync], preferences: previous, }); @@ -327,6 +364,7 @@ describe("footer section", () => { const hidden = setSidebarNavItemVisible({ items: resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [sync], preferences: moved, }), diff --git a/packages/app/src/sidebar-nav/model.ts b/packages/app/src/sidebar-nav/model.ts index 40e4db636f..b5da97c034 100644 --- a/packages/app/src/sidebar-nav/model.ts +++ b/packages/app/src/sidebar-nav/model.ts @@ -5,7 +5,7 @@ export type SidebarSection = PluginSidebarSection; /** * Each section's built-in items in their default order. The footer's bottom line - * (Add project and the Hosts, Import session, Help and support, Settings icons) is fixed. + * (Add project and the Hosts, Help and support, Settings icons) is fixed. */ export const BUILTIN_SIDEBAR_ITEM_IDS = { header: ["new-workspace", "history", "search", "schedules"], @@ -69,6 +69,16 @@ export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): strin return BUILTIN_SHORTCUT_ACTIONS[id]; } +/** + * Builtins that start hidden on compact layouts, until the user turns them on. A phone's footer + * has no room to spare for the Usage summary. + */ +const HIDDEN_BY_DEFAULT_ON_COMPACT: ReadonlySet = new Set(["usage"]); + +function builtinVisibleByDefault(id: BuiltinSidebarItemId, compact: boolean): boolean { + return !(compact && HIDDEN_BY_DEFAULT_ON_COMPACT.has(id)); +} + export function pluginSidebarNavKey( group: Pick, ): string { @@ -85,6 +95,8 @@ function isBuiltinSidebarItemId
( export function resolveSidebarNavItems
(input: { section: Section; + /** Compact layouts start some builtins hidden; a stored preference always wins. */ + compact: boolean; pluginGroups: readonly PluginSidebarGroup[]; preferences: readonly SidebarNavPreference[]; }): SidebarNavItem
[] { @@ -115,7 +127,12 @@ export function resolveSidebarNavItems
(input: { for (const id of builtinIds) { if (placed.has(id)) continue; - items.push({ kind: "builtin", key: id, id, visible: true }); + items.push({ + kind: "builtin", + key: id, + id, + visible: builtinVisibleByDefault(id, input.compact), + }); } for (const [key, group] of groupsByKey) { if (placed.has(key)) continue; diff --git a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts index dcebf670df..96b99a6f1b 100644 --- a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts +++ b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useAppSettings } from "@/hooks/use-settings"; import type { AppSettings } from "@/hooks/use-settings/storage"; import { useInstalledPlugins } from "@/plugins/registry"; @@ -27,14 +28,15 @@ export function useSidebarNavItems
( section: Section, ): UseSidebarNavItemsReturn
{ const plugins = useInstalledPlugins(); + const compact = useIsCompactFormFactor(); const { settings, updateSettings } = useAppSettings(); const field = PREFERENCE_FIELDS[section]; const preferences = settings[field]; const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]); const items = useMemo( - () => resolveSidebarNavItems({ section, pluginGroups, preferences }), - [pluginGroups, preferences, section], + () => resolveSidebarNavItems({ section, compact, pluginGroups, preferences }), + [compact, pluginGroups, preferences, section], ); const setVisible = useCallback( @@ -43,6 +45,7 @@ export function useSidebarNavItems
( const previous = current[field]; const currentItems = resolveSidebarNavItems({ section, + compact, pluginGroups, preferences: previous, }); @@ -51,7 +54,7 @@ export function useSidebarNavItems
( }; }); }, - [field, pluginGroups, section, updateSettings], + [compact, field, pluginGroups, section, updateSettings], ); const move = useCallback( @@ -60,6 +63,7 @@ export function useSidebarNavItems
( const previous = current[field]; const currentItems = resolveSidebarNavItems({ section, + compact, pluginGroups, preferences: previous, }); @@ -68,7 +72,7 @@ export function useSidebarNavItems
( }; }); }, - [field, pluginGroups, section, updateSettings], + [compact, field, pluginGroups, section, updateSettings], ); return { items, setVisible, move }; diff --git a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx index 77990b3341..b3b5bf39eb 100644 --- a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx @@ -1,10 +1,6 @@ import { useCallback, useEffect, useRef, type ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; -import { - BottomSheetBackdrop, - BottomSheetScrollView, - type BottomSheetScrollViewMethods, -} from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorhom/bottom-sheet"; import { Wrench, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -38,12 +34,7 @@ export function OverviewToolCallGroupSheet({ visible, onClose, }); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); + const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); @@ -64,7 +55,7 @@ export function OverviewToolCallGroupSheet({ enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.5} enablePanDownToClose > diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 8bf3ad9d83..751193eec0 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { clampPct, formatAmount, formatResetLabel } from "./format"; @@ -9,19 +10,20 @@ interface ResolvedBalance { usedPct: number | null; } -function resolveBalance(balance: UsageBalance): ResolvedBalance { +function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance { const { used, remaining, limit, unit } = balance; + const format = (value: number) => formatAmount(value, unit, locale); if (limit != null && limit > 0) { const usedAmount = used ?? (remaining != null ? limit - remaining : null); const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null; - const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—"; - return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct }; + const usedText = usedAmount != null ? format(usedAmount) : "—"; + return { amountText: `${usedText} / ${format(limit)}`, usedPct }; } if (remaining != null) { - return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null }; + return { amountText: `${format(remaining)} left`, usedPct: null }; } if (used != null) { - return { amountText: formatAmount(used, unit), usedPct: null }; + return { amountText: format(used), usedPct: null }; } return { amountText: "—", usedPct: null }; } @@ -40,7 +42,8 @@ function fillToneStyle(tone: UsageTone) { } export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { - const { amountText, usedPct } = resolveBalance(balance); + const { i18n } = useTranslation(); + const { amountText, usedPct } = resolveBalance(balance, i18n.language); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index b7942fddd9..3fc29dd3e8 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -1,22 +1,13 @@ -import { RotateCw } from "lucide-react-native"; import { View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { HostFilter } from "@/components/hosts/host-filter"; -import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { useMemo, type ReactElement } from "react"; -import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; -import { UsageDisplayToggle } from "./display-toggle"; +import { UsageOptionsMenu } from "./options-menu"; import type { UsageHost } from "./model"; import { useHostUsage } from "./queries"; import type { UsageView } from "./types"; -const ThemedRotateCw = withUnistyles(RotateCw); -const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); - /** The hosts to choose between, and which one is shown. */ export interface UsageHostSelection { hosts: UsageHost[]; @@ -26,8 +17,7 @@ export interface UsageHostSelection { /** * The controls on the right of every usage title row: the host filter when there is more than one - * host, the used/remaining toggle and Refresh. A host that cannot report usage keeps only the - * host filter. + * host, and the options menu. A host that cannot report usage keeps only the host filter. */ export function UsageControls({ view, @@ -41,8 +31,6 @@ export function UsageControls({ hostSelection?: UsageHostSelection; }) { const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); - const compact = useIsCompactFormFactor(); - const iconSize = paneContentToolbarIconSize(compact); return ( {hostSelection && hostSelection.hosts.length > 1 ? ( @@ -56,23 +44,7 @@ export function UsageControls({ /> ) : null} {view.kind === "unavailable" ? null : ( - <> - - {/* The Changes panel's refresh: an icon, with the label in its tooltip. */} - - {busy ? ( - - ) : ( - - )} - - + )} ); diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 5d84805ea2..731450f30d 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -3,6 +3,7 @@ export const usageCopy = { title: "Usage", planUsage: "Plan usage", + options: "Usage options", refresh: "Refresh", refreshing: "Refreshing...", refreshFailed: "Unable to refresh usage", @@ -16,6 +17,8 @@ export const usageCopy = { clientUnavailable: "Host connection is not ready", retry: "Try again", pin: "Pin", + displayAs: "Show", displayUsed: "Used", displayRemaining: "Remaining", + showInSidebar: "Show in sidebar", } as const; diff --git a/packages/app/src/usage/display-toggle.tsx b/packages/app/src/usage/display-toggle.tsx deleted file mode 100644 index f778172b7c..0000000000 --- a/packages/app/src/usage/display-toggle.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; -import { usageCopy } from "./copy"; -import type { UsageDisplay } from "./display"; -import type { UsageDisplayAs } from "./preferences"; - -const OPTIONS: SegmentedControlOption[] = [ - { value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" }, - { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" }, -]; - -/** Switches every usage surface between the share used and the share left. */ -export function UsageDisplayToggle({ display }: { display: UsageDisplay }) { - return ( - - ); -} diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts new file mode 100644 index 0000000000..88fe227ae6 --- /dev/null +++ b/packages/app/src/usage/format.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { formatAmount } from "./format"; + +describe("formatAmount", () => { + it("groups thousands in the app's language", () => { + expect(formatAmount(12345, "credits", "en")).toBe("12,345"); + expect(formatAmount(12345, "requests", "en")).toBe("12,345"); + expect(formatAmount(12345, "credits", "fr").replace(/\s/g, " ")).toBe("12 345"); + }); + + it("formats dollars as the language writes currency", () => { + expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50"); + expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); + }); +}); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index 253586c173..5adef2154d 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -34,13 +34,14 @@ export function formatResetLabel(iso: string | null | undefined): string | null return rel === "now" ? "resetting now" : `resets ${rel}`; } -export function formatAmount(value: number, unit: UsageBalanceUnit): string { +/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */ +export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string { switch (unit) { case "usd": - return `$${value.toFixed(2)}`; + return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value); case "tokens": return formatTokenCount(value); default: - return value.toLocaleString(); + return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value); } } diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index 6ab6c1447d..c94fbb01f9 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -1,3 +1,3 @@ export { HostUsageSection } from "./host-usage-section"; export { UsageScreen } from "./usage-screen"; -export { UsageSidebarItem } from "./sidebar-item"; +export { UsageSidebarItem, useOpenUsageScreen } from "./sidebar-item"; diff --git a/packages/app/src/usage/meter.tsx b/packages/app/src/usage/meter.tsx new file mode 100644 index 0000000000..37842cafb5 --- /dev/null +++ b/packages/app/src/usage/meter.tsx @@ -0,0 +1,74 @@ +import { useMemo } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { clampPct } from "./format"; +import type { UsageTone } from "./types"; + +function fillToneStyle(tone: UsageTone) { + switch (tone) { + case "ok": + return styles.fillOk; + case "warning": + return styles.fillWarning; + case "danger": + return styles.fillDanger; + default: + return styles.fillDefault; + } +} + +/** + * A window's share as a bar, coloured by its tone. `quiet` fills it with a surface colour instead, + * for summaries where a row of coloured bars would be noise. The caller sizes the track. + */ +export function UsageMeter({ + percent, + tone, + quiet = false, + style, +}: { + percent: number; + tone: UsageTone; + quiet?: boolean; + style?: StyleProp; +}) { + const fillWidth = clampPct(percent); + const fillStyle = useMemo>( + () => [styles.fill, quiet ? styles.fillQuiet : fillToneStyle(tone), { width: `${fillWidth}%` }], + [fillWidth, quiet, tone], + ); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + track: { + height: 4, + borderRadius: 2, + backgroundColor: theme.colors.surface3, + overflow: "hidden", + }, + // Follows the track, so a caller that sizes the track sizes the bar. + fill: { + height: "100%", + borderRadius: theme.borderRadius.full, + }, + fillQuiet: { + backgroundColor: theme.colors.surface4, + }, + fillDefault: { + backgroundColor: theme.colors.foregroundMuted, + }, + fillOk: { + backgroundColor: theme.colors.statusSuccess, + }, + fillWarning: { + backgroundColor: theme.colors.statusWarning, + }, + fillDanger: { + backgroundColor: theme.colors.statusDanger, + }, +})); diff --git a/packages/app/src/usage/options-menu.tsx b/packages/app/src/usage/options-menu.tsx new file mode 100644 index 0000000000..05184d5d5a --- /dev/null +++ b/packages/app/src/usage/options-menu.tsx @@ -0,0 +1,103 @@ +import { Settings } from "lucide-react-native"; +import { useCallback } from "react"; +import { withUnistyles } from "react-native-unistyles"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; +import { usageCopy } from "./copy"; +import type { UsageDisplay } from "./display"; + +const ThemedSettings = withUnistyles(Settings); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); + +/** + * The usage title row's options: Refresh every report, whether percents read as used or + * remaining, and whether the sidebar shows the Usage item (the same switch as Settings > Sidebar). The trigger spins while a refresh is in flight, since the menu hides the one it came + * from. + */ +export function UsageOptionsMenu({ + display, + busy, + onRefresh, +}: { + display: UsageDisplay; + busy: boolean; + onRefresh: () => void; +}) { + const compact = useIsCompactFormFactor(); + const iconSize = paneContentToolbarIconSize(compact); + const { displayAs, setDisplayAs } = display; + const showUsed = useCallback(() => setDisplayAs("used"), [setDisplayAs]); + const showRemaining = useCallback(() => setDisplayAs("remaining"), [setDisplayAs]); + const sidebarItems = useSidebarNavItems("footer"); + const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible); + const { setVisible } = sidebarItems; + const toggleInSidebar = useCallback( + () => setVisible("usage", !inSidebar), + [inSidebar, setVisible], + ); + return ( + + + {busy ? ( + + ) : ( + + )} + + + + {usageCopy.refresh} + + + {usageCopy.displayAs} + + {usageCopy.displayUsed} + + + {usageCopy.displayRemaining} + + + + {usageCopy.showInSidebar} + + + + ); +} diff --git a/packages/app/src/usage/pinned.test.ts b/packages/app/src/usage/pinned.test.ts index af6d48cb12..24ba59be7a 100644 --- a/packages/app/src/usage/pinned.test.ts +++ b/packages/app/src/usage/pinned.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { UsagePreferences } from "./preferences"; -import { resolvePinnedUsage } from "./pinned"; +import { + choosePinnedUsageLayout, + resolvePinnedUsage, + type PinnedUsageSource, + type PinnedUsageWindow, +} from "./pinned"; import type { UsageReportEntry, UsageWindow } from "./types"; function report(input: { @@ -26,8 +31,8 @@ const claude = report({ sourceLabel: "Claude", icon: "", windows: [ - { id: "five-hour", label: "5-hour", usedPct: 31 }, - { id: "weekly", label: "Weekly", usedPct: 80 }, + { id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31 }, + { id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80 }, ], }); const codex = report({ @@ -36,6 +41,10 @@ const codex = report({ windows: [{ id: "weekly", label: "Weekly", remainingPct: 88 }], }); +function windows(reports: UsageReportEntry[], prefs: UsagePreferences): PinnedUsageWindow[] { + return resolvePinnedUsage(reports, prefs).flatMap((source) => source.windows); +} + function preferences( pinned: UsagePreferences["pinned"], displayAs: UsagePreferences["displayAs"] = "used", @@ -43,6 +52,26 @@ function preferences( return { displayAs, pinned, serverId: null }; } +describe("choosePinnedUsageLayout", () => { + const widths = { labelsWidth: 200, percentsWidth: 120, windowCount: 2 }; + + it("shows meters only when every window's meter fits beside the labels", () => { + expect(choosePinnedUsageLayout({ ...widths, available: 240 })).toBe("meters"); + expect(choosePinnedUsageLayout({ ...widths, available: 239 })).toBe("labels"); + }); + + it("drops the labels before it lets the line overflow", () => { + expect(choosePinnedUsageLayout({ ...widths, available: 199 })).toBe("percents"); + }); + + it("shows labels until the widths are measured", () => { + expect(choosePinnedUsageLayout({ ...widths, available: null })).toBe("labels"); + expect(choosePinnedUsageLayout({ ...widths, available: 999, labelsWidth: null })).toBe( + "labels", + ); + }); +}); + describe("resolvePinnedUsage", () => { it("defaults to the first window with a percent for each source account", () => { const work = report({ @@ -60,57 +89,113 @@ describe("resolvePinnedUsage", () => { windows: [{ id: "empty", label: "Empty" }], }); expect( - resolvePinnedUsage([claude, codex, work, empty], preferences([])).map((item) => [ + windows([claude, codex, work, empty], preferences([])).map((item) => [ item.key, item.percentText, ]), ).toEqual([ ["claude:default/five-hour", "31%"], - ["codex:default/weekly", "12%"], ["claude:work/weekly", "90%"], + ["codex:default/weekly", "12%"], ]); }); it("pins replace defaults, and removing all pins restores defaults", () => { const reports = [claude, codex]; expect( - resolvePinnedUsage(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map( + windows(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map( (item) => item.key, ), ).toEqual(["claude:default/weekly"]); - expect(resolvePinnedUsage(reports, preferences([])).map((item) => item.key)).toEqual([ + expect(windows(reports, preferences([])).map((item) => item.key)).toEqual([ "claude:default/five-hour", "codex:default/weekly", ]); }); - it("shows pinned windows in pin order, not report order", () => { - const items = resolvePinnedUsage( - [claude, codex], + it("groups pinned windows by source in report order, not pin order", () => { + const work = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "work", + windows: [{ id: "weekly", label: "Weekly", usedPct: 90 }], + }); + const sources = resolvePinnedUsage( + [claude, codex, work], preferences([ + { sourceId: "claude", windowId: "weekly" }, { sourceId: "codex", windowId: "weekly" }, { sourceId: "claude", windowId: "five-hour" }, ]), ); - expect(items).toEqual([ - { - key: "codex:default/weekly", - icon: null, - label: "Codex Weekly 12% used", - percentText: "12%", - }, - { - key: "claude:default/five-hour", - icon: "", - label: "Claude 5-hour 31% used", - percentText: "31%", - }, + const windowKeys = (source: PinnedUsageSource) => source.windows.map((window) => window.key); + expect(sources.map((source) => [source.key, windowKeys(source)])).toEqual([ + ["claude:default", ["claude:default/five-hour", "claude:default/weekly"]], + ["claude:work", ["claude:work/weekly"]], + ["codex:default", ["codex:default/weekly"]], + ]); + expect(sources[0]).toEqual({ + key: "claude:default", + icon: "", + windows: [ + { + key: "claude:default/five-hour", + label: "Claude Session 31% used", + shortLabel: "5h", + percent: 31, + percentText: "31%", + tone: "default", + }, + { + key: "claude:default/weekly", + label: "Claude Weekly 80% used", + shortLabel: "wk", + percent: 80, + percentText: "80%", + tone: "warning", + }, + ], + }); + }); + + it("names a window by its label when the source gives no short label", () => { + const items = windows([codex], preferences([{ sourceId: "codex", windowId: "weekly" }])); + + expect(items.map((item) => item.shortLabel)).toEqual(["Weekly"]); + }); + + it("defaults to every window the source marks as summary, when it marks any", () => { + const marked = report({ + sourceId: "claude", + sourceLabel: "Claude", + windows: [ + { id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31, summary: true }, + { id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80, summary: true }, + { id: "weekly-fable", label: "Weekly · Fable", usedPct: 8 }, + ], + }); + + expect(windows([marked, codex], preferences([])).map((item) => item.key)).toEqual([ + "claude:default/five-hour", + "claude:default/weekly", + "codex:default/weekly", ]); }); + it("keeps an empty short label empty, for a percent that needs no name", () => { + const opencode = report({ + sourceId: "opencode", + sourceLabel: "OpenCode Go", + windows: [{ id: "rolling", label: "Rolling", shortLabel: "", usedPct: 21 }], + }); + const items = windows([opencode], preferences([])); + + expect(items.map((item) => item.shortLabel)).toEqual([""]); + }); + it("formats the share left when the user reads usage as remaining", () => { - const items = resolvePinnedUsage( + const items = windows( [claude, codex], preferences( [ @@ -123,7 +208,7 @@ describe("resolvePinnedUsage", () => { expect(items.map((item) => item.percentText)).toEqual(["69%", "88%"]); expect(items.map((item) => item.label)).toEqual([ - "Claude 5-hour 69% left", + "Claude Session 69% left", "Codex Weekly 88% left", ]); }); @@ -134,7 +219,7 @@ describe("resolvePinnedUsage", () => { sourceLabel: "OpenCode", windows: [{ id: "monthly", label: "Monthly" }], }); - const items = resolvePinnedUsage( + const items = windows( [claude, noPercent], preferences([ { sourceId: "claude", windowId: "monthly" }, @@ -159,7 +244,7 @@ describe("resolvePinnedUsage", () => { account: "work", windows: [{ id: "five-hour", label: "5-hour", usedPct: 90 }], }); - const items = resolvePinnedUsage( + const items = windows( [personal, codex, work], preferences([{ sourceId: "claude", windowId: "five-hour" }]), ); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts index 11cf0ecabf..003d56d4e3 100644 --- a/packages/app/src/usage/pinned.ts +++ b/packages/app/src/usage/pinned.ts @@ -1,15 +1,53 @@ import { formatPct } from "./format"; import { displayPercent } from "./model"; import type { UsagePreferences } from "./preferences"; -import type { UsageReportEntry } from "./types"; +import { windowTone } from "./tone"; +import type { UsageReportEntry, UsageTone, UsageWindow } from "./types"; /** One summary window of one account, as the sidebar Usage item shows it. */ export interface PinnedUsageWindow { key: string; - icon: string | null; /** Accessible description, including the source, account, window and percent meaning. */ label: string; + /** The window's short name ("5h"), empty for none, or its label when the source sends none. */ + shortLabel: string; + /** The share shown, under the user's used/remaining preference. */ + percent: number; percentText: string; + tone: UsageTone; +} + +/** One account's summary windows, under its source's icon. */ +export interface PinnedUsageSource { + key: string; + icon: string | null; + windows: PinnedUsageWindow[]; +} + +/** How much of each window the one-line summary has room for. */ +export type PinnedUsageLayout = "meters" | "labels" | "percents"; + +// A meter narrower than this is a sliver that reads as noise rather than a share. +export const MIN_METER_WIDTH = 16; +/** The space between a meter and its text. */ +export const METER_GAP = 4; + +/** + * The richest layout that fits on one line: every window's meter, else its short label, else its + * percent alone. Meters are all or none; a line never mixes them. Until the widths are known it + * shows labels, which never flashes meters that turn out not to fit. + */ +export function choosePinnedUsageLayout(input: { + available: number | null; + labelsWidth: number | null; + percentsWidth: number | null; + windowCount: number; +}): PinnedUsageLayout { + const { available, labelsWidth, percentsWidth, windowCount } = input; + if (available === null || labelsWidth === null || percentsWidth === null) return "labels"; + if (labelsWidth + windowCount * (MIN_METER_WIDTH + METER_GAP) <= available) return "meters"; + if (labelsWidth <= available) return "labels"; + return percentsWidth < labelsWidth ? "percents" : "labels"; } function describe(entry: UsageReportEntry, windowLabel: string): string { @@ -17,40 +55,52 @@ function describe(entry: UsageReportEntry, windowLabel: string): string { return `${entry.sourceLabel}${account} ${windowLabel}`; } -/** Pins replace defaults; without pins, summarize each account's first window with a percent. */ +/** Reports with every account of a source together, sources in the order they first appear. */ +function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] { + const sourceIds = [...new Set(reports.map((entry) => entry.sourceId))]; + return sourceIds.flatMap((sourceId) => reports.filter((entry) => entry.sourceId === sourceId)); +} + +/** + * Pins replace defaults. Without pins, an account shows the windows its source marks as summary, + * or its first window with a percent when the source marks none. + */ +function summaryWindows(entry: UsageReportEntry, preferences: UsagePreferences): UsageWindow[] { + const withPercent = entry.report.windows.filter( + (window) => displayPercent(window, preferences.displayAs) !== null, + ); + if (preferences.pinned.length === 0) { + const marked = withPercent.filter((window) => window.summary); + return marked.length > 0 ? marked : withPercent.slice(0, 1); + } + return withPercent.filter((window) => + preferences.pinned.some((pin) => pin.sourceId === entry.sourceId && pin.windowId === window.id), + ); +} + +/** + * The sidebar Usage item's summary: one group per account, grouped by source, each with its + * windows in its report's order. Accounts with nothing to show are left out. + */ export function resolvePinnedUsage( reports: readonly UsageReportEntry[], preferences: UsagePreferences, -): PinnedUsageWindow[] { - const selections = - preferences.pinned.length === 0 - ? reports.map((entry) => ({ - entry, - window: entry.report.windows.find( - (window) => displayPercent(window, preferences.displayAs) !== null, - ), - })) - : preferences.pinned.flatMap((pin) => - reports - .filter((entry) => entry.sourceId === pin.sourceId) - .map((entry) => ({ - entry, - window: entry.report.windows.find((window) => window.id === pin.windowId), - })), - ); - return selections.flatMap(({ entry, window }) => { - if (!window) return []; - const percent = displayPercent(window, preferences.displayAs); - if (percent === null) return []; - const percentText = formatPct(percent); - const meaning = preferences.displayAs === "remaining" ? "left" : "used"; - return [ - { +): PinnedUsageSource[] { + const meaning = preferences.displayAs === "remaining" ? "left" : "used"; + return groupBySource(reports).flatMap((entry) => { + const windows = summaryWindows(entry, preferences).map((window) => { + const percent = displayPercent(window, preferences.displayAs) ?? 0; + const percentText = formatPct(percent); + return { key: `${entry.id}/${window.id}`, - icon: entry.icon ?? null, label: `${describe(entry, window.label)} ${percentText} ${meaning}`, + shortLabel: window.shortLabel ?? window.label, + percent, percentText, - }, - ]; + tone: windowTone(window), + }; + }); + if (windows.length === 0) return []; + return [{ key: entry.id, icon: entry.icon ?? null, windows }]; }); } diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 9b9e9a80ef..ee8f6f657b 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -1,8 +1,14 @@ import { router } from "expo-router"; -import { Gauge } from "lucide-react-native"; -import { useCallback, useMemo, useState } from "react"; +import { CircleGauge } from "lucide-react-native"; +import { Fragment, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { + Pressable, + Text, + View, + type LayoutChangeEvent, + type PressableStateCallbackType, +} from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover"; @@ -16,13 +22,21 @@ import { useUsageHostId, useUsageHostSelection } from "./hosts"; import type { UsagePreferences } from "./preferences"; import { useHostUsage } from "./queries"; import { UsageSourceIcon } from "./source-icon"; -import { resolvePinnedUsage, type PinnedUsageWindow } from "./pinned"; +import { UsageMeter } from "./meter"; +import { + choosePinnedUsageLayout, + METER_GAP, + MIN_METER_WIDTH, + resolvePinnedUsage, + type PinnedUsageLayout, + type PinnedUsageSource, +} from "./pinned"; import type { UsageReportEntry } from "./types"; import type { UsageHost } from "./model"; import { UsageBody } from "./usage-section"; const NO_REPORTS: UsageReportEntry[] = []; -const NO_ITEMS: PinnedUsageWindow[] = []; +const NO_SOURCES: PinnedUsageSource[] = []; /** * The sidebar footer's usage entry: each summary window's source icon and percent, or a plain @@ -33,7 +47,7 @@ export function UsageSidebarItem() { const { preferences, display } = useUsagePreferences(); const serverId = useUsageHostId(); if (!serverId) { - return ; + return ; } return ( resolvePinnedUsage(reports, preferences), [preferences, reports]); - return ; + const sources = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]); + return ; } -function useOpenUsageScreen(): () => void { +/** Opens the Usage screen, over the sidebar on compact layouts. */ +export function useOpenUsageScreen(): () => void { const isCompact = useIsCompactFormFactor(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); return useCallback(() => { @@ -71,11 +86,11 @@ function useOpenUsageScreen(): () => void { function UsageEntry({ serverId, - items, + sources, display, }: { serverId: string | null; - items: readonly PinnedUsageWindow[]; + sources: readonly PinnedUsageSource[]; display: UsageDisplay; }) { const { t } = useTranslation(); @@ -97,12 +112,12 @@ function UsageEntry({ }, [openUsageScreen, usesSheet]); const trigger = - items.length > 0 ? ( - + sources.length > 0 ? ( + ) : ( item.label).join(", ")}`; +function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string { + const windows = sources.flatMap((source) => source.windows); + return `${label}: ${windows.map((window) => window.label).join(", ")}`; } function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { return hovered ? [styles.trigger, styles.triggerHovered] : styles.trigger; } +function layoutWidth(event: LayoutChangeEvent): number { + return event.nativeEvent.layout.width; +} + +/** + * One line: each account's icon, then per window a meter and "31% 5h". Off-screen copies measure + * the line with labels and with percents alone; the richest layout that fits is shown, and what + * it leaves out takes no space at all. + */ function PinnedUsageTrigger({ label, - items, + sources, onPress, }: { label: string; - items: readonly PinnedUsageWindow[]; + sources: readonly PinnedUsageSource[]; onPress: () => void; }) { + const [available, setAvailable] = useState(null); + const [labelsWidth, setLabelsWidth] = useState(null); + const [percentsWidth, setPercentsWidth] = useState(null); + const handleAvailableLayout = useCallback((event: LayoutChangeEvent) => { + setAvailable(layoutWidth(event)); + }, []); + const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => { + setLabelsWidth(layoutWidth(event)); + }, []); + const handlePercentsLayout = useCallback((event: LayoutChangeEvent) => { + setPercentsWidth(layoutWidth(event)); + }, []); + const layout = choosePinnedUsageLayout({ + available, + labelsWidth, + percentsWidth, + windowCount: sources.reduce((count, source) => count + source.windows.length, 0), + }); return ( - {items.map((item) => ( - - - - {item.percentText} - + + + + + + - ))} + + + + ); } +/** + * The line's items as direct children of one row, so every meter shares the leftover width + * equally. + */ +function PinnedUsageLine({ + sources, + layout, + measuring = false, +}: { + sources: readonly PinnedUsageSource[]; + layout: PinnedUsageLayout; + measuring?: boolean; +}) { + return sources.map((source, sourceIndex) => ( + + + + + {source.windows.map((window, windowIndex) => { + const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap; + return ( + + {layout === "meters" ? ( + + + + ) : null} + + {window.percentText} + {layout === "percents" || window.shortLabel === "" ? null : ( + {` ${window.shortLabel}`} + )} + + + ); + })} + + )); +} + const styles = StyleSheet.create((theme) => ({ trigger: { width: "100%", flexDirection: "row", - flexWrap: "wrap", alignItems: "center", - // A wrapping row packs its lines at the top; center them in the 28px row instead. - alignContent: "center", - columnGap: theme.spacing[3], - rowGap: theme.spacing[1], + overflow: "hidden", // Same row geometry and leading rail as Add project and the footer icons. minHeight: 28, paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1.5], + paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, triggerHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, - item: { + line: { + flex: 1, + minWidth: 0, flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + overflow: "hidden", + }, + // Wide enough that nothing inside wraps or shrinks; each child reports its natural width. + measure: { + position: "absolute", + top: 0, + left: 0, + width: 10_000, + opacity: 0, + }, + measured: { + alignSelf: "flex-start", + flexDirection: "row", + alignItems: "center", + }, + sourceGap: { + marginLeft: theme.spacing[3], + }, + iconGap: { + marginLeft: theme.spacing[1.5], + }, + windowGap: { + marginLeft: theme.spacing[2], + }, + // Every slot takes an equal share of the leftover width, so the line fills the row. + meterSlot: { + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + minWidth: MIN_METER_WIDTH, + marginRight: METER_GAP, + }, + meter: { + height: 6, + borderRadius: 3, }, percent: { - color: theme.colors.foregroundMuted, + flexShrink: 0, + color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontVariant: ["tabular-nums"], }, + windowLabel: { + color: theme.colors.foregroundMuted, + }, sheetBody: { padding: theme.spacing[3], gap: theme.spacing[3], diff --git a/packages/app/src/usage/tone.ts b/packages/app/src/usage/tone.ts index 4727853c2f..d35d836f8b 100644 --- a/packages/app/src/usage/tone.ts +++ b/packages/app/src/usage/tone.ts @@ -1,6 +1,10 @@ -import type { UsageTone } from "./types"; +import { usedPercent } from "./model"; +import type { UsageTone, UsageWindow } from "./types"; -export function deriveTone(usedPct: number | null | undefined): UsageTone { +/** The source's tone for a window, or one derived from how much of it is used. */ +export function windowTone(window: UsageWindow): UsageTone { + if (window.tone) return window.tone; + const usedPct = usedPercent(window); if (usedPct == null) return "default"; if (usedPct > 90) return "danger"; if (usedPct >= 70) return "warning"; diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index ce6e43b5e6..4412836cc4 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,25 +1,13 @@ import { useMemo } from "react"; import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { clampPct, formatDisplayPct, formatResetLabel } from "./format"; -import { displayPercent, usageWindowRowLabel, usedPercent } from "./model"; +import { formatDisplayPct, formatResetLabel } from "./format"; +import { UsageMeter } from "./meter"; +import { displayPercent, usageWindowRowLabel } from "./model"; import type { UsageDisplayAs } from "./preferences"; -import { deriveTone } from "./tone"; +import { windowTone } from "./tone"; import type { UsageTone, UsageWindow } from "./types"; -function fillToneStyle(tone: UsageTone) { - switch (tone) { - case "ok": - return styles.fillOk; - case "warning": - return styles.fillWarning; - case "danger": - return styles.fillDanger; - default: - return styles.fillDefault; - } -} - // Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength, // so a hover never reads as the selection. Pinned rows do not react to hover. function highlightStyle(pinned: boolean, hovered: boolean) { @@ -43,15 +31,8 @@ export function UsageWindowBar({ pinLabel: string; pinTestID: string; }) { - const usedPct = usedPercent(window); const shownPct = displayPercent(window, displayAs); - const tone = window.tone ?? deriveTone(usedPct); - - const fillWidth = clampPct(shownPct ?? 0); - const fillStyle = useMemo>( - () => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }], - [fillWidth, tone], - ); + const tone = windowTone(window); const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; const trailing = isAtRisk @@ -80,7 +61,8 @@ export function UsageWindowBar({ value={value} trailing={trailing} isAtRisk={isAtRisk} - fillStyle={fillStyle} + percent={shownPct ?? 0} + tone={tone} /> )} @@ -93,14 +75,16 @@ function WindowRowContent({ value, trailing, isAtRisk, - fillStyle, + percent, + tone, }: { highlight: StyleProp; label: string; value: string; trailing: string | null | undefined; isAtRisk: boolean; - fillStyle: StyleProp; + percent: number; + tone: UsageTone; }) { return ( <> @@ -116,9 +100,7 @@ function WindowRowContent({ ) : null} - - - + ); } @@ -174,26 +156,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.statusDanger, fontWeight: theme.fontWeight.normal, }, - track: { - height: 4, - borderRadius: 2, - backgroundColor: theme.colors.surface3, - overflow: "hidden", - }, - fill: { - height: 4, - borderRadius: 2, - }, - fillDefault: { - backgroundColor: theme.colors.foregroundMuted, - }, - fillOk: { - backgroundColor: theme.colors.statusSuccess, - }, - fillWarning: { - backgroundColor: theme.colors.statusWarning, - }, - fillDanger: { - backgroundColor: theme.colors.statusDanger, - }, })); diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 0ab1cd7215..784a7569ff 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -12,6 +12,10 @@ export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub; +export function useBottomSheetModal(): never { + throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests"); +} + // The app imports the internal layout values to compute the visible sheet body. Browser unit tests // never render the native sheet, but Vite still validates this module's export surface. const sharedValue = (value: unknown) => ({ get: () => value }); diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index ed49db0f1b..11906dd955 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -5,6 +5,13 @@ import type { JsonValue } from "@getpaseo/protocol/agent-types"; export interface UsageWindow { id: string; label: string; + /** + * A few characters naming the window where space is tight, e.g. "5h" or "wk". An empty string + * shows the percent alone; leaving it out shows `label`. + */ + shortLabel?: string; + /** Shown in the usage summary until the user pins windows of their own. */ + summary?: boolean; usedPct?: number | null; remainingPct?: number | null; resetsAt?: string | null; @@ -54,6 +61,8 @@ export interface UsageSourceRegistration { export function windowFromUsedPct(input: { id: string; label: string; + shortLabel?: string; + summary?: boolean; utilizationPct: number | null | undefined; resetsAt?: string | null; tone?: UsageWindow["tone"]; @@ -66,6 +75,8 @@ export function windowFromUsedPct(input: { remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), resetsAt: input.resetsAt ?? null, }; + if (input.shortLabel !== undefined) window.shortLabel = input.shortLabel; + if (input.summary) window.summary = true; if (input.tone) window.tone = input.tone; return window; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 69595c9ad6..861ddf4a37 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6189,6 +6189,13 @@ export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "er export const ProviderUsageWindowSchema = z.object({ id: z.string(), label: z.string(), + /** + * A few characters naming the window where space is tight, e.g. "5h" or "wk". An empty string + * shows the percent alone; leaving it out shows `label`. + */ + shortLabel: z.string().optional(), + /** Shown in the usage summary until the user pins windows of their own. */ + summary: z.boolean().optional(), usedPct: z.number().nullable().optional(), remainingPct: z.number().nullable().optional(), resetsAt: z.string().nullable().optional(), diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index d20162e93d..b9eb4b6ecf 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -307,6 +307,38 @@ describe("Claude usage source scoped weekly limits", () => { ); }); + it("summarizes the session and weekly windows by default, not scoped ones", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => [window.id, window.summary ?? false])).toEqual([ + ["five_hour", true], + ["weekly", true], + ["weekly_model_fable", false], + ]); + }); + + it("gives every window a short label for tight spaces", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => [window.id, window.shortLabel])).toEqual([ + ["five_hour", "5h"], + ["weekly", "wk"], + ["weekly_model_fable", "wk Fable"], + ]); + }); + it("renders a scoped window that is at zero and inactive", async () => { const { provider } = claudeProvider({ seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 9e280df0e5..70ba4f9273 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -112,9 +112,11 @@ const UNSCOPED_WINDOWS: ReadonlyArray<{ field: "five_hour" | "seven_day"; id: string; label: string; + shortLabel: string; + summary: boolean; }> = [ - { field: "five_hour", id: "five_hour", label: "Session" }, - { field: "seven_day", id: "weekly", label: "Weekly" }, + { field: "five_hour", id: "five_hour", label: "Session", shortLabel: "5h", summary: true }, + { field: "seven_day", id: "weekly", label: "Weekly", shortLabel: "wk", summary: true }, ]; // Scoped windows from before `limits[]` existed. Declaring the dimension here is what @@ -253,6 +255,8 @@ function unscopedWindows(resp: ClaudeUsageResponse): UsageWindow[] { windowFromUsedPct({ id: spec.id, label: spec.label, + shortLabel: spec.shortLabel, + summary: spec.summary, utilizationPct: window.utilization, resetsAt: window.resets_at ?? null, tone: toneFromUsedPct(window.utilization), @@ -272,6 +276,7 @@ function scopedWindows(limits: ScopedLimit[]): UsageWindow[] { return windowFromUsedPct({ id, label: `Weekly \u00b7 ${limit.name}`, + shortLabel: `wk ${limit.name}`, utilizationPct: limit.usedPct, resetsAt: limit.resetsAt, tone: toneFromUsedPct(limit.usedPct), diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index dfa58ef603..e7572f4efe 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -103,7 +103,31 @@ test("coerces credit balance and marks a 96 percent window dangerous", async () expect.objectContaining({ id: "weekly", tone: "danger" }), ]), ); - expect(report.balances).toEqual([expect.objectContaining({ remaining: 0, tone: "danger" })]); + expect(report.balances).toEqual([ + expect.objectContaining({ remaining: 0, unit: "credits", tone: "danger" }), + ]); +}); + +test("summarizes the session and weekly windows by default, not code review", async () => { + const report = await fetchUsage( + {}, + async () => + new Response( + JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 12 }, + secondary_window: { used_percent: 40 }, + }, + code_review_rate_limit: { primary_window: { used_percent: 5 } }, + }), + { status: 200 }, + ), + ); + expect(report.windows.map((window) => [window.id, window.summary ?? false])).toEqual([ + ["session", true], + ["weekly", true], + ["code_review", false], + ]); }); test("HTML usage body is unavailable", async () => { diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index 3fb660ae09..8620484b6e 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -57,15 +57,13 @@ export async function readAuth( } function usageWindow( - id: string, - label: string, + spec: { id: string; label: string; shortLabel: string; summary?: boolean }, value: z.infer | null | undefined, ): UsageWindow | null { if (!value) return null; const usedPct = value.used_percent ?? 0; return windowFromUsedPct({ - id, - label, + ...spec, utilizationPct: usedPct, resetsAt: value.reset_at != null ? new Date(value.reset_at * 1000).toISOString() : null, tone: toneFromUsedPct(usedPct), @@ -95,9 +93,18 @@ export async function fetchUsage( if (text.trim().startsWith("<")) return { status: "unavailable", windows: [] }; const usage = responseSchema.parse(JSON.parse(text)); const windows = [ - usageWindow("session", "Session", usage.rate_limit?.primary_window), - usageWindow("weekly", "Weekly", usage.rate_limit?.secondary_window), - usageWindow("code_review", "Code review", usage.code_review_rate_limit?.primary_window), + usageWindow( + { id: "session", label: "Session", shortLabel: "5h", summary: true }, + usage.rate_limit?.primary_window, + ), + usageWindow( + { id: "weekly", label: "Weekly", shortLabel: "wk", summary: true }, + usage.rate_limit?.secondary_window, + ), + usageWindow( + { id: "code_review", label: "Code review", shortLabel: "review" }, + usage.code_review_rate_limit?.primary_window, + ), ].filter((window): window is UsageWindow => window !== null); const balance = usage.credits?.balance; return { @@ -112,7 +119,7 @@ export async function fetchUsage( id: "credits", label: "Credits", remaining: balance, - unit: "usd", + unit: "credits", tone: balanceToneFromRemaining(balance), }, ], diff --git a/plugins/grok-usage-source/server/usage.ts b/plugins/grok-usage-source/server/usage.ts index ba4ab829cf..65367070b4 100644 --- a/plugins/grok-usage-source/server/usage.ts +++ b/plugins/grok-usage-source/server/usage.ts @@ -98,6 +98,7 @@ function grokUsageWindow(response: z.infer): Usa return windowFromUsedPct({ id: weekly ? "weekly" : "monthly", label: weekly ? "Weekly" : "Monthly", + shortLabel: weekly ? "wk" : "mo", utilizationPct: percent, resetsAt: period?.end ?? null, tone: toneFromUsedPct(percent), diff --git a/plugins/muse-provider/server/usage.ts b/plugins/muse-provider/server/usage.ts index a60b0a34b2..36dae811d8 100644 --- a/plugins/muse-provider/server/usage.ts +++ b/plugins/muse-provider/server/usage.ts @@ -78,6 +78,8 @@ export class Usage { windowFromUsedPct({ id: "five_hour", label: `${usage.window.windowDurationMins / 60} hours`, + shortLabel: `${usage.window.windowDurationMins / 60}h`, + summary: true, utilizationPct: usage.window.usedPercent, resetsAt: new Date(usage.window.resetsAtMs).toISOString(), tone: toneFromUsedPct(usage.window.usedPercent), @@ -85,6 +87,8 @@ export class Usage { windowFromUsedPct({ id: "weekly", label: "Weekly", + shortLabel: "wk", + summary: true, utilizationPct: usage.weekly.usedPercent, resetsAt: new Date(usage.weekly.resetsAtMs).toISOString(), tone: toneFromUsedPct(usage.weekly.usedPercent), diff --git a/plugins/opencode-go-usage-source/server/usage.test.ts b/plugins/opencode-go-usage-source/server/usage.test.ts index c475d96b36..849fbe874f 100644 --- a/plugins/opencode-go-usage-source/server/usage.test.ts +++ b/plugins/opencode-go-usage-source/server/usage.test.ts @@ -59,6 +59,8 @@ test("discovers and fetches the default key from read-only auth.json", async () ["weekly", 42, "2026-09-28T00:00:00.000Z"], ["monthly", 100, "2026-10-01T00:00:00.000Z"], ]); + // The rolling window's length is not reported, so its percent stands without a name. + expect(report.windows.map((window) => window.shortLabel)).toEqual(["", "wk", "mo"]); // The current upstream endpoint returns windows only; do not invent balances. expect(report.balances).toBeUndefined(); expect(await readFile(path, "utf8")).toBe(content); diff --git a/plugins/opencode-go-usage-source/server/usage.ts b/plugins/opencode-go-usage-source/server/usage.ts index dd9944544e..77e83613df 100644 --- a/plugins/opencode-go-usage-source/server/usage.ts +++ b/plugins/opencode-go-usage-source/server/usage.ts @@ -65,14 +65,16 @@ export async function fetchUsage( const data = responseSchema.parse(await response.json()); const windows = ( [ - ["rolling", "Rolling", data.usage.rolling], - ["weekly", "Weekly", data.usage.weekly], - ["monthly", "Monthly", data.usage.monthly], + // The rolling window's length is not reported, so its percent stands without a name. + ["rolling", "Rolling", "", data.usage.rolling], + ["weekly", "Weekly", "wk", data.usage.weekly], + ["monthly", "Monthly", "mo", data.usage.monthly], ] as const - ).map(([id, label, value]) => + ).map(([id, label, shortLabel, value]) => windowFromUsedPct({ id, label, + shortLabel, utilizationPct: value.percent, resetsAt: value.resetsAt, tone: toneFromUsedPct(value.percent),