From d14ace8233ad3cf7856b6b834824f888240d67a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 20:47:24 -0700 Subject: [PATCH 1/9] fix(mobile): Live Activity stops shouting the running-agent count (#24329) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/widgets/AgentActivity.tsx | 43 ++++++++++------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 53063393bbd6..ca0a6d50d423 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,4 +1,4 @@ -import { Circle, HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; +import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; import type { ComponentProps } from "react"; import { accessibilityElement, @@ -152,8 +152,7 @@ export function AgentActivity( : environment.isStale ? "Out of date" : `${props.activeCount} active`; - const showCompactCount = !attentionRow && !allDone && !environment.isStale; - const compactCountLabel = props.activeCount > 99 ? "99+" : `${props.activeCount}`; + const showCompactGlyph = !attentionRow && !failedRow && !allDone && !environment.isStale; const summary = attentionSuffix || activeLabel; // Any registered scheme variant routes back to this app; taps are delivered @@ -182,7 +181,8 @@ export function AgentActivity( return "clock.arrow.circlepath"; case "running": default: - return "arrow.triangle.2.circlepath"; + // Matches the thread list's Working indicator. + return "circle.dashed"; } }; @@ -331,28 +331,20 @@ export function AgentActivity( ), compactLeading: renderLogo(14, tint, linkModifiers), - compactTrailing: showCompactCount ? ( - - - - {compactCountLabel} - - + {renderGlyph(phaseSymbol(heroRow?.phase ?? "running"), 14, tint)} + ) : ( ), - minimal: - (attentionRow || failedRow || allDone) && heroRow - ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase), linkModifiers) - : renderLogo(11, tint, linkModifiers), + minimal: heroRow + ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase), linkModifiers) + : renderLogo(11, tint, linkModifiers), expandedLeading: ( {renderLogo(15, tint)} From c89335361870087b64a2fc6ba0f7f3459aa23f39 Mon Sep 17 00:00:00 2001 From: Wiktor Golicz <56449542+Wiktor102@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:12:12 +0200 Subject: [PATCH 2/9] feat(usage): include Muse Code session history (#22443) Co-authored-by: Theo Browne Co-authored-by: Claude Opus 5.5 --- .../src/features/usage/usageProviders.ts | 3 + apps/server/src/provider/builtInDrivers.ts | 4 +- apps/server/src/server.ts | 2 + apps/server/src/usage/UsageService.test.ts | 232 ++++++++++ apps/server/src/usage/usageTestFormats.ts | 4 +- .../usage/usageTranscriptStreaming.test.ts | 47 ++ .../usage/UsageProviderChart.test.ts | 6 +- .../src/components/usage/usageProviders.ts | 5 + docs/user/usage.md | 5 +- packages/contracts/src/usage.ts | 1 + packages/provider-muse/package.json | 4 + packages/provider-muse/src/server.ts | 1 + .../provider-muse/src/server/MuseUsageHome.ts | 58 +++ packages/provider-muse/src/server/driver.ts | 9 +- packages/provider-muse/src/server/sdk.ts | 3 +- .../provider-muse/src/server/usage.test.ts | 409 ++++++++++++++++++ packages/provider-muse/src/server/usage.ts | 140 ++++++ 17 files changed, 926 insertions(+), 7 deletions(-) create mode 100644 packages/provider-muse/src/server/MuseUsageHome.ts create mode 100644 packages/provider-muse/src/server/usage.test.ts create mode 100644 packages/provider-muse/src/server/usage.ts diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index d90d0fda591d..9e3213f90c52 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -9,6 +9,7 @@ export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ "opencode", "antigravity", "pi", + "muse", ]; /** The usage series a provider driver reports under, or null for drivers without one. */ @@ -25,6 +26,7 @@ export const PROVIDER_LABEL: Record = { opencode: "OpenCode", antigravity: "Antigravity", pi: "Pi", + muse: "Muse Code", }; export function useProviderColors(): Record { @@ -37,6 +39,7 @@ export function useProviderColors(): Record { opencode: "#5b9bbd", antigravity: "#8c7bd1", pi: "#4d9f8a", + muse: "#0866ff", }; } diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 065bce727117..b66dac4c15a6 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -45,7 +45,8 @@ export type BuiltInUsageReadersEnv = | ProviderUsageReaderEnv | ProviderUsageReaderEnv | ProviderUsageReaderEnv - | ProviderUsageReaderEnv; + | ProviderUsageReaderEnv + | ProviderUsageReaderEnv; /** * Aggregation keeps the first copy of a duplicate record, so the order is part @@ -59,6 +60,7 @@ export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< GrokDriver, PiDriver, OpenCodeDriver, + MuseDriver, AntigravityDriver, CursorDriver, ]; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 403b30af1c71..ff0aca4126bc 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -168,6 +168,7 @@ import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinar import * as ProcessGroupMetrics from "./resourceTelemetry/ProcessGroupMetrics.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as AntigravityUsage from "./provider/Drivers/AntigravityUsage.ts"; +import { MuseUsageHome } from "@t3tools/provider-muse/server"; import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader"; import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts"; @@ -269,6 +270,7 @@ const layerUsage = UsageService.layer.pipe( Layer.provide( Layer.mergeAll( AntigravityUsage.layer, + MuseUsageHome.layer, CodexUsageHistory.layer, CursorUsageAccounts.layer.pipe( Layer.provide(CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer))), diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 0fd7e4f6b803..a7256aabef2b 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -48,9 +48,19 @@ import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts"; import * as ProviderHostLive from "../provider/ProviderHostLive.ts"; import { totalTokens, type UsageRecord } from "@t3tools/provider-core/server/usage"; import * as UsageService from "./UsageService.ts"; +import * as MuseSdk from "../../../../packages/provider-muse/src/server/sdk.ts"; +import { MuseUsageHome } from "@t3tools/provider-muse/server"; import { encodeScanCache, SCAN_CACHE_FILE_NAME } from "./usageScanCache.ts"; vi.mock("node:fs/promises", { spy: true }); +vi.mock("../../../../packages/provider-muse/src/server/sdk.ts", async (importOriginal) => ({ + ...(await importOriginal()), + createMuseSdkHostEffect: vi.fn(() => + Effect.try(() => { + throw new Error("Muse is not installed in this test"); + }), + ), +})); const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -137,6 +147,7 @@ const layerService = (input: { CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), ).pipe( Layer.provideMerge(AntigravityUsage.layer), + Layer.provideMerge(MuseUsageHome.layer), Layer.provideMerge(ProviderHostLive.layer), Layer.provideMerge(CodexUsageHistory.layer.pipe(Layer.provideMerge(Sqlite.layerMemory))), Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), @@ -797,6 +808,227 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "reads Muse inference history once, persists appended usage, and reprices unknown models", + () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const museHome = NodePath.join(home, "configured-data", "muse"); + const dir = NodePath.join(museHome, "sessions", "2026", "08", "01", "parent"); + const child = NodePath.join(dir, "subagent", "child"); + const entry = ( + id: string, + sessionId = "parent", + timestamp = "2026-08-01T00:00:00Z", + model = "muse-unknown", + ) => ({ + schema_version: 1, + record_type: "event", + payload_type: "runtime.session", + payload_schema_version: 1, + id, + recorded_at: Date.parse(timestamp) * 1000 + 999, + stream: { kind: "session", id: sessionId }, + payload: { + source_run_record_id: id, + event: { + kind: "model_completed", + model, + usage: { + input_tokens: 100, + cached_tokens: 60, + cache_read_tokens: 60, + cache_write_tokens: 10, + output_tokens: 20, + reasoning_tokens: 8, + }, + }, + }, + }); + const parent = entry("inference-1"); + const review = { + ...entry("review", "child"), + payload_schema_version: 3, + payload: { + event: { + ...parent.payload.event, + kind: "automated_review_completed", + model: { model_id: "priced-model" }, + usage: { + input_tokens: 100, + cached_input_tokens: 60, + output_tokens: 20, + reasoning_tokens: 8, + }, + }, + }, + }; + yield* Effect.promise(async () => { + await NodeFSP.mkdir(child, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(dir, "session.jsonl"), + [ + parent, + parent, + { + ...parent, + payload: { event: { ...parent.payload.event, kind: "workflow_completed" } }, + }, + { ...parent, payload_type: "goal_usage_attribution" }, + entry("before", "parent", "2026-07-31T23:59:59.999Z"), + entry("after", "parent", "2026-08-02T00:00:00Z"), + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n{broken}\n", + ); + await NodeFSP.writeFile( + NodePath.join(child, "session.jsonl"), + [ + { ...parent, id: "copied-envelope", stream: { kind: "session", id: "child" } }, + entry("child-inference", "child"), + review, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + await NodeFSP.writeFile( + NodePath.join(child, "telemetry.jsonl"), + encodeUnknownJsonString(entry("not-a-session")) + "\n", + ); + }); + using _create = vi.spyOn(MuseSdk, "createMuseSdkHostEffect").mockImplementation((options) => + Effect.succeed({ + initializeResult: { + grantedCapabilities: [], + museHome: NodePath.join(options.environment?.XDG_DATA_HOME ?? home, "muse"), + }, + close: async () => {}, + } as unknown as MuseSdk.MuseSdkHost), + ); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const window = { + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-01"), + }; + const muse = (summary: UsageSummary) => + summary.buckets.filter((bucket) => bucket.provider === "muse"); + const first = yield* service.readSummary(window); + const buckets = muse(first); + assert.strictEqual( + buckets.reduce((sum, b) => sum + b.records, 0), + 3, + ); + assert.strictEqual( + buckets.reduce((sum, b) => sum + totalTokens(b.totals), 0), + 360, + ); + const unknown = buckets.find((b) => b.model === "muse-unknown"); + assert.strictEqual(unknown?.costSource, "unpriced"); + assert.strictEqual(unknown?.unpricedRecords, 2); + assert.deepStrictEqual(unknown?.totals, { + uncachedInputTokens: 60, + cachedInputTokens: 120, + cacheCreationTokens: 20, + outputTokens: 40, + reasoningTokens: 16, + }); + const priced = buckets.find((b) => b.model === "priced-model"); + assert.strictEqual(priced?.costSource, "modelPriced"); + assert.closeTo(priced?.costUsd ?? NaN, 0.000172, 1e-12); + assert.closeTo(priced?.cacheSavingsUsd ?? NaN, 0.000108, 1e-12); + const sources = first.sources.filter((source) => source.fingerprint.provider === "muse"); + assert.strictEqual(sources.length, 1); + assert.strictEqual(sources[0]?.scannedFiles, 2); + assert.strictEqual(sources[0]?.distinctSessions, 2); + const local = muse( + yield* service.readSummary({ ...window, timeZone: "America/Los_Angeles" }), + ); + assert.strictEqual( + local.reduce((sum, b) => sum + b.records, 0), + 1, + ); + assert.strictEqual(local[0]?.day, "2026-08-01"); + const hourly = muse( + yield* service.readSummary({ + ...window, + resolution: "hour", + sinceTime: "2026-08-01T00:00:00.000Z", + untilTime: "2026-08-02T00:00:00.000Z", + }), + ); + assert.strictEqual( + hourly.reduce((sum, b) => sum + b.records, 0), + 3, + ); + const appended = encodeUnknownJsonString(entry("appended", "child")); + const file = NodePath.join(child, "session.jsonl"); + yield* Effect.promise(() => NodeFSP.appendFile(file, appended.slice(0, -10))); + assert.deepStrictEqual(muse(yield* service.readSummary(window)), buckets); + yield* service.awaitPersisted; + const restarted = yield* UsageService.make; + yield* Effect.promise(() => NodeFSP.appendFile(file, appended.slice(-10) + "\n")); + const grown = muse(yield* restarted.readSummary(window)); + assert.strictEqual( + grown.reduce((sum, b) => sum + b.records, 0), + 4, + ); + assert.strictEqual( + grown.reduce((sum, b) => sum + totalTokens(b.totals), 0), + 480, + ); + const settingsService = yield* ServerSettings.ServerSettingsService; + yield* settingsService.updateSettings({ + usagePriceOverrides: { + "muse-unknown": { + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 4, + cacheReadCostPerMillionTokens: 0.2, + cacheWriteCostPerMillionTokens: 1, + }, + }, + }); + const repriced = muse(yield* restarted.readSummary(window)).find( + (b) => b.model === "muse-unknown", + ); + assert.strictEqual(repriced?.unpricedRecords, 0); + assert.closeTo(repriced?.costUsd ?? NaN, 0.000486, 1e-12); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-muse", + home, + environment: { XDG_DATA_HOME: NodePath.dirname(museHome) }, + settings: { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("muse")]: { + driver: ProviderDriverKind.make("muse"), + enabled: false, + }, + [ProviderInstanceId.make("muse-alias")]: { + driver: ProviderDriverKind.make("muse"), + enabled: false, + }, + }, + }, + ratesDocument: { + "priced-model": { + input_cost_per_token: 0.000002, + output_cost_per_token: 0.000004, + cache_read_input_token_cost: 0.0000002, + cache_creation_input_token_cost: 0.0000002, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("reports missing Pi history and skips malformed records without losing valid usage", () => Effect.gen(function* () { const { home, settings } = yield* setup; diff --git a/apps/server/src/usage/usageTestFormats.ts b/apps/server/src/usage/usageTestFormats.ts index 8c5bca3bb0d4..ecc4ede6efd3 100644 --- a/apps/server/src/usage/usageTestFormats.ts +++ b/apps/server/src/usage/usageTestFormats.ts @@ -2,18 +2,20 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; import { piUsageFormat } from "@t3tools/provider-pi/server/usage"; import { grokUsageFormat } from "@t3tools/provider-grok/server/usage"; +import { museUsageFormat } from "@t3tools/provider-muse/server/usage"; import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts"; import { codexUsageFormat } from "../provider/Drivers/codexUsage.ts"; export const TEST_FORMATS: Record< - "claude" | "codex" | "grok" | "pi", + "claude" | "codex" | "grok" | "pi" | "muse", TranscriptUsageFormat > = { claude: claudeUsageFormat, codex: codexUsageFormat, grok: grokUsageFormat, pi: piUsageFormat, + muse: museUsageFormat, }; export const TEST_FORMAT_MAP = new Map>( diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts index 8dda52d1e8ef..856ba2ac5b9d 100644 --- a/apps/server/src/usage/usageTranscriptStreaming.test.ts +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -108,6 +108,53 @@ async function scan( } describe("large usage records", () => { + it("streams Muse completion envelopes and resumes a partial appended event", async () => { + const entry = { + schema_version: 1, + record_type: "event", + payload_type: "runtime.session", + payload_schema_version: 3, + id: "envelope-1", + recorded_at: 1_785_578_400_123_456, + stream: { kind: "session", id: "parent" }, + payload: { + source_run_record_id: "inference-1", + event: { + kind: "automated_review_completed", + model: { model_id: "review-model" }, + usage: { + input_tokens: 100, + cached_input_tokens: 60, + output_tokens: 20, + reasoning_tokens: 8, + }, + }, + }, + }; + const small = await scan([entry], "muse", "small-muse"); + expect(small.records[0]?.totals).toMatchObject({ + uncachedInputTokens: 40, + cachedInputTokens: 60, + }); + const path = NodePath.join(dir, "large-muse.jsonl"); + await NodeFSP.writeFile(path, JSON.stringify({ ...entry, discarded: { content } }) + "\n"); + const large = await readTranscriptRecords(path, "muse"); + expect(large?.records).toEqual(small.records); + const appended = JSON.stringify({ + ...entry, + id: "envelope-2", + payload: { ...entry.payload, source_run_record_id: "inference-2" }, + }); + await NodeFSP.appendFile(path, appended.slice(0, -7)); + const partial = await readTranscriptRecords(path, "muse", large!.position); + expect(partial?.records).toEqual([]); + await NodeFSP.appendFile(path, appended.slice(-7) + "\n"); + const completed = await readTranscriptRecords(path, "muse", partial!.position); + expect(completed?.records).toHaveLength(1); + expect(completed?.records[0]?.dedupeKey).not.toBe(large?.records[0]?.dedupeKey); + expect(completed?.records[0]?.totals).toEqual(small.records[0]?.totals); + }); + it("keeps Pi model, cost, and fork identity after large assistant content", async () => { const entry = { type: "message", diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 2d03ac416923..c01a8b9def2e 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -9,12 +9,13 @@ describe("buildPeriodColumns", () => { "2026-08-01", { day: "2026-08-01", - costUsd: 35, - totalTokens: 350, + costUsd: 42, + totalTokens: 420, byProvider: new Map([ ["codex" as const, { costUsd: 10, totalTokens: 100 }], ["claude" as const, { costUsd: 20, totalTokens: 200 }], ["pi" as const, { costUsd: 5, totalTokens: 50 }], + ["muse" as const, { costUsd: 7, totalTokens: 70 }], ]), }, ], @@ -40,6 +41,7 @@ describe("buildPeriodColumns", () => { { provider: "opencode", value: 0 }, { provider: "antigravity", value: 0 }, { provider: "pi", value: 5 }, + { provider: "muse", value: 7 }, ]); }); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 003a225f2621..408327f3cc20 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -40,6 +40,11 @@ export const PROVIDER_PRESENTATION = { color: "#4d9f8a", driverKind: ProviderDriverKind.make("pi"), }, + muse: { + label: "Muse Code", + color: "#0866ff", + driverKind: ProviderDriverKind.make("muse"), + }, } satisfies Record; export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; diff --git a/docs/user/usage.md b/docs/user/usage.md index a012e73d94b8..e77b3ca2e1a9 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -6,7 +6,7 @@ desktop when the terminal is not focused. Customize `usage.open` in ## Understand your usage -**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, Pi, and Cursor history +**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, Pi, Muse Code, and Cursor history from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent cost, split by token type and by speed. These estimates are not your subscription bill. **Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be @@ -34,6 +34,9 @@ variable. Pi reads `~/.pi/agent/sessions`, or the `sessions` directory under `PI_CODING_AGENT_DIR`. Set `PI_CODING_AGENT_SESSION_DIR` to read a different session directory. To include sessions saved through `--session-dir` or Pi's `sessionDir` setting, set this environment variable to the same directory. Pi includes recorded tool and summary costs under `Tools/summaries`. +Muse Code reads native session history, including child sessions, from the home reported by the +configured Muse CLI. Its environment settings, including `XDG_DATA_HOME`, apply. Models without +known prices still contribute tokens and appear as unpriced; set custom prices to estimate their cost. Use absolute paths or `~/` paths in the account's environment settings; relative environment paths depend on each project's working directory and cannot be reliably discovered by Usage. Accounts sharing a history directory count once. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 29cddca8ce38..50c6cdcb610b 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -19,6 +19,7 @@ export const UsageProviderKind = Schema.Literals([ "opencode", "antigravity", "pi", + "muse", ]); export type UsageProviderKind = typeof UsageProviderKind.Type; diff --git a/packages/provider-muse/package.json b/packages/provider-muse/package.json index e6e6852c2ba4..2b81b55db91d 100644 --- a/packages/provider-muse/package.json +++ b/packages/provider-muse/package.json @@ -15,6 +15,10 @@ "types": "./src/server.ts", "import": "./src/server.ts" }, + "./server/usage": { + "types": "./src/server/usage.ts", + "import": "./src/server/usage.ts" + }, "./testing": { "types": "./src/testing.ts", "import": "./src/testing.ts" diff --git a/packages/provider-muse/src/server.ts b/packages/provider-muse/src/server.ts index eab3a275e598..122028d6599f 100644 --- a/packages/provider-muse/src/server.ts +++ b/packages/provider-muse/src/server.ts @@ -1,2 +1,3 @@ export { MuseDriver, type MuseDriverEnv } from "./server/driver.ts"; +export * as MuseUsageHome from "./server/MuseUsageHome.ts"; export { makeMuseAdapterV2, type MuseAdapterV2Options } from "./server/adapter.ts"; diff --git a/packages/provider-muse/src/server/MuseUsageHome.ts b/packages/provider-muse/src/server/MuseUsageHome.ts new file mode 100644 index 000000000000..de2689d042ef --- /dev/null +++ b/packages/provider-muse/src/server/MuseUsageHome.ts @@ -0,0 +1,58 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; + +import { createMuseSdkHostEffect } from "./sdk.ts"; + +export class MuseUsageHome extends Context.Service< + MuseUsageHome, + { + readonly resolve: (input: { + readonly binaryPath: string; + readonly environment: NodeJS.ProcessEnv; + }) => Effect.Effect; + } +>()("@t3tools/provider-muse/server/MuseUsageHome") {} + +const make = Effect.gen(function* () { + const cache = yield* Cache.makeWith( + Effect.fn("MuseUsageHome.initialize")(function* ( + input: Parameters[0], + ) { + return yield* Effect.scoped( + Effect.gen(function* () { + const host = yield* Effect.acquireRelease( + createMuseSdkHostEffect({ ...input, readOnly: true, startupTimeoutMs: 5_000 }), + (host) => Effect.tryPromise(() => host.close()).pipe(Effect.ignore), + { interruptible: true }, + ); + return host.initializeResult.museHome; + }), + ); + }), + { + capacity: 32, + // Retry unavailable installations, but keep resolved homes across scans. + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value ? Duration.infinity : Duration.seconds(30), + }, + ); + const resolve: MuseUsageHome["Service"]["resolve"] = Effect.fn("MuseUsageHome.resolve")( + function* ({ binaryPath, environment }) { + // Cache keys compare structurally. Omitted and undefined variables both + // disappear from the child process environment and must share an entry. + return yield* Cache.get(cache, { + binaryPath, + environment: Object.fromEntries( + Object.entries(environment).filter(([, value]) => value !== undefined), + ), + }).pipe(Effect.orElseSucceed(() => undefined)); + }, + ); + return MuseUsageHome.of({ resolve }); +}); + +export const layer = Layer.effect(MuseUsageHome, make); diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 9a39487a5a2f..390764c20bcd 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -22,6 +22,8 @@ import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEve import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "./maintenance.ts"; import { makeMuseEnvironment } from "./sdk.ts"; +import { museUsageReader } from "./usage.ts"; +import type * as MuseUsageHome from "./MuseUsageHome.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -53,9 +55,14 @@ export type MuseDriverEnv = | Path.Path | ProviderEventLoggers.ProviderEventLoggers; -export const MuseDriver: ProviderDriver = { +export const MuseDriver: ProviderDriver< + MuseSettings, + MuseDriverEnv, + Path.Path | MuseUsageHome.MuseUsageHome +> = { driverKind: DRIVER_KIND, metadata: { displayName: "Muse Code", supportsMultipleInstances: true }, + usage: museUsageReader, configSchema: MuseSettings, defaultConfig: () => decodeMuseSettings({}), create: Effect.fn("MuseDriver.create")(function* ({ diff --git a/packages/provider-muse/src/server/sdk.ts b/packages/provider-muse/src/server/sdk.ts index 5f3514b0d6d8..cb79af94edb9 100644 --- a/packages/provider-muse/src/server/sdk.ts +++ b/packages/provider-muse/src/server/sdk.ts @@ -25,7 +25,8 @@ export interface MuseSdkHost { | "onProtocolError" | "closed" >; - readonly initializeResult: Pick; + readonly initializeResult: Pick & + Partial>; readonly exited: Promise; readonly stderrTail?: () => ReadonlyArray; readonly close: () => Promise; diff --git a/packages/provider-muse/src/server/usage.test.ts b/packages/provider-muse/src/server/usage.test.ts new file mode 100644 index 000000000000..266b6097b8ef --- /dev/null +++ b/packages/provider-muse/src/server/usage.test.ts @@ -0,0 +1,409 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { totalTokens } from "@t3tools/provider-core/server/usage"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { it } from "@effect/vitest"; +import { describe, expect, vi } from "vite-plus/test"; +import { MuseSettings } from "../settings.ts"; +import * as MuseSdk from "./sdk.ts"; +import * as MuseUsageHome from "./MuseUsageHome.ts"; +import { museUsageFormat, museUsageReader } from "./usage.ts"; + +vi.mock("./sdk.ts", { spy: true }); + +const entry = () => ({ + schema_version: 1, + record_type: "event", + payload_type: "runtime.session", + payload_schema_version: 1, + id: "envelope-1", + recorded_at: 1_785_578_400_123_456, + stream: { kind: "session", id: "session-1" }, + payload: { + source_run_record_id: "inference-1", + event: { + kind: "model_completed", + model: "muse-wonder-full-1.0", + usage: { + input_tokens: 100, + cached_tokens: 60, + cache_read_tokens: 60, + cache_write_tokens: 10, + output_tokens: 20, + reasoning_tokens: 8, + }, + }, + }, +}); +const decodeSettings = Schema.decodeSync(MuseSettings); +const parse = (value: unknown) => museUsageFormat.parseLine(JSON.stringify(value), undefined); + +it("normalizes inclusive cache and reasoning counters without doubling the subsets", () => { + const [record] = parse(entry()); + expect(record).toMatchObject({ + provider: "muse", + timestampMs: Date.parse("2026-08-01T10:00:00.123Z"), + sessionId: "session-1", + model: "muse-wonder-full-1.0", + reportedCostUsd: null, + totals: { + uncachedInputTokens: 30, + cachedInputTokens: 60, + cacheCreationTokens: 10, + outputTokens: 20, + reasoningTokens: 8, + }, + }); + expect(totalTokens(record!.totals)).toBe(120); + const value = entry(); + const { cache_read_tokens: _read, ...usage } = value.payload.event.usage; + expect( + parse({ ...value, payload: { ...value.payload, event: { ...value.payload.event, usage } } }), + ).toEqual([record]); +}); + +it("keeps individual inference identity across copied parent/child envelopes", () => { + const original = entry(); + const [first] = parse(original); + const [copy] = parse({ ...original, id: "copy", stream: { kind: "session", id: "child" } }); + expect(copy?.dedupeKey).toBe(first?.dedupeKey); + const next = entry(); + next.payload.source_run_record_id = "inference-2"; + expect(parse(next)[0]?.dedupeKey).not.toBe(first?.dedupeKey); + const { source_run_record_id: _source, ...payload } = original.payload; + const fallback = parse({ ...original, payload }); + expect(fallback[0]?.dedupeKey).toBe(JSON.stringify(["muse", original.id])); +}); + +it("includes automated review inference models and ignores workflow and telemetry rollups", () => { + const value = entry(); + expect( + parse({ + ...value, + payload: { + ...value.payload, + event: { + ...value.payload.event, + kind: "automated_review_completed", + model: { model_id: "review-model", provider_id: "meta" }, + }, + }, + })[0]?.model, + ).toBe("review-model"); + for (const kind of [ + "workflow_completed", + "goal_usage_attribution", + "turn_completed", + "usage_summary", + ]) { + expect( + parse({ ...value, payload: { ...value.payload, event: { ...value.payload.event, kind } } }), + ).toEqual([]); + } + expect(parse({ ...value, payload_type: "run.telemetry" })).toEqual([]); + expect(parse({ ...value, record_type: "snapshot" })).toEqual([]); + expect(parse({ ...value, stream: { kind: "run", id: "run-1" } })).toEqual([]); +}); + +it("includes native version 3 automated review inferences", () => { + const value = entry(); + const review = { + ...value, + payload_schema_version: 3, + payload: { + ...value.payload, + event: { + ...value.payload.event, + kind: "automated_review_completed", + model: { provider_id: "meta", model_id: "review-model", reasoning_effort: "high" }, + usage: { + input_tokens: 100, + cached_input_tokens: 60, + output_tokens: 20, + reasoning_tokens: 8, + }, + }, + }, + }; + expect(parse(review)).toEqual([ + { + ...parse(value)[0], + model: "review-model", + totals: { + uncachedInputTokens: 40, + cachedInputTokens: 60, + cacheCreationTokens: 0, + outputTokens: 20, + reasoningTokens: 8, + }, + }, + ]); + expect(museUsageFormat.parseProjected(review, undefined)).toEqual(parse(review)); +}); + +it("handles malformed and partial data and rejects unsupported timestamps or schemas", () => { + expect(museUsageFormat.parseLine("not json", undefined)).toEqual([]); + expect(museUsageFormat.parseLine(JSON.stringify(entry()).slice(0, -1), undefined)).toEqual([]); + for (const recorded_at of [NaN, Infinity, -1, "1785578400123456", Number.MAX_SAFE_INTEGER + 1]) { + expect(museUsageFormat.parseProjected({ ...entry(), recorded_at }, undefined)).toEqual([]); + } + expect(parse({ ...entry(), schema_version: 2 })).toEqual([]); + expect(parse({ ...entry(), payload_schema_version: 2 })).toEqual([]); + expect(parse({ ...entry(), id: "" })).toEqual([]); + expect(parse({ ...entry(), payload: { event: { kind: "model_completed" } } })).toEqual([]); + const value = entry(); + const result = parse({ + ...value, + payload: { + event: { + ...value.payload.event, + usage: { + input_tokens: 10, + cache_read_tokens: 20, + cache_write_tokens: 8, + output_tokens: 3, + reasoning_tokens: 9, + }, + }, + }, + }); + expect(result[0]?.totals).toEqual({ + uncachedInputTokens: 0, + cachedInputTokens: 10, + cacheCreationTokens: 0, + outputTokens: 3, + reasoningTokens: 3, + }); + expect( + parse({ + ...value, + payload: { + event: { ...value.payload.event, usage: { input_tokens: -5, output_tokens: "20" } }, + }, + }), + ).toEqual([]); +}); + +describe("Muse history discovery", () => { + it.effect.each([undefined, "/custom/xdg-data"])( + "uses the initialized home with XDG_DATA_HOME=%s and closes without starting a session", + (xdg) => + Effect.gen(function* () { + const path = yield* Path.Path; + const close = vi.fn(async () => {}); + using create = vi.spyOn(MuseSdk, "createMuseSdkHostEffect").mockReturnValue( + Effect.succeed({ + initializeResult: { grantedCapabilities: [], museHome: "/native/configured-home" }, + close, + } as unknown as MuseSdk.MuseSdkHost), + ); + if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); + const directories = yield* museUsageReader.directories({ + instanceId: ProviderInstanceId.make("muse-other"), + configured: true, + config: decodeSettings({ binaryPath: "~/bin/custom-muse" }), + environment: { XDG_DATA_HOME: xdg, MUSE_NO_AUTO_UPDATE: "0" }, + }); + expect(directories).toEqual([ + { dir: path.join("/native/configured-home", "sessions"), fileName: "session.jsonl" }, + ]); + expect(create).toHaveBeenCalledWith({ + binaryPath: path.join("/test-home", "bin/custom-muse"), + environment: { + PATH: "/host/bin", + XDG_DATA_HOME: xdg ?? "/host/data", + MUSE_NO_AUTO_UPDATE: "1", + }, + readOnly: true, + startupTimeoutMs: 5_000, + }); + expect(close).toHaveBeenCalledOnce(); + }).pipe( + Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + Effect.provideService(HostProcess.HomeDirectory, "/test-home"), + Effect.provideService(HostProcess.Environment, { + PATH: "/host/bin", + XDG_DATA_HOME: "/host/data", + }), + ), + ); + + it.effect("does not guess a directory when initialization fails", () => + Effect.gen(function* () { + using _create = vi.spyOn(MuseSdk, "createMuseSdkHostEffect").mockReturnValue( + Effect.try(() => { + throw new Error("not installed"); + }), + ); + if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); + const directories = yield* museUsageReader.directories({ + instanceId: ProviderInstanceId.make("muse"), + configured: false, + config: undefined, + environment: {}, + }); + expect(directories).toEqual([]); + }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer])), + ); + + it.effect( + "reuses discovery across accounts, date scans, and equivalent effective environments", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const close = vi.fn(async () => {}); + using create = vi.spyOn(MuseSdk, "createMuseSdkHostEffect").mockReturnValue( + Effect.succeed({ + initializeResult: { museHome: "/native/home" }, + close, + } as unknown as MuseSdk.MuseSdkHost), + ); + if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); + const instance = { + instanceId: ProviderInstanceId.make("disabled-muse"), + configured: true, + config: decodeSettings({ binaryPath: "~/bin/muse", enabled: false }), + environment: { PATH: "/bin", XDG_DATA_HOME: "/data" }, + }; + const first = yield* museUsageReader.directories(instance); + const second = yield* museUsageReader.directories({ + ...instance, + instanceId: ProviderInstanceId.make("another-account"), + config: decodeSettings({ + binaryPath: path.join("/test-home", "bin/muse"), + enabled: true, + }), + environment: { + XDG_DATA_HOME: "/data", + PATH: "/bin", + UNUSED: undefined, + META_API_KEY: "ignored", + }, + }); + expect(second).toEqual(first); + expect(first).toEqual([ + { dir: path.join("/native/home", "sessions"), fileName: "session.jsonl" }, + ]); + expect(create).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }).pipe( + Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + Effect.provideService(HostProcess.HomeDirectory, "/test-home"), + Effect.provideService(HostProcess.Environment, {}), + ), + ); + + it.effect( + "rediscovers when the binary, instance environment, or inherited environment changes", + () => + Effect.gen(function* () { + using create = vi.spyOn(MuseSdk, "createMuseSdkHostEffect").mockImplementation((options) => + Effect.succeed({ + initializeResult: { museHome: options.environment?.XDG_DATA_HOME }, + close: async () => {}, + } as unknown as MuseSdk.MuseSdkHost), + ); + if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); + const instance = { + instanceId: ProviderInstanceId.make("muse"), + configured: true, + config: undefined, + environment: {}, + }; + const first = yield* museUsageReader.directories(instance); + expect( + yield* museUsageReader.directories({ + ...instance, + config: decodeSettings({ binaryPath: "custom-muse" }), + }), + ).toEqual(first); + const changed = yield* museUsageReader.directories({ + ...instance, + environment: { XDG_DATA_HOME: "/other-data" }, + }); + expect(changed).not.toEqual(first); + const inherited = yield* museUsageReader + .directories(instance) + .pipe( + Effect.provideService(HostProcess.Environment, { XDG_DATA_HOME: "/new-host-data" }), + ); + expect(inherited).not.toEqual(first); + expect(inherited).not.toEqual(changed); + expect(create).toHaveBeenCalledTimes(4); + }).pipe( + Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + Effect.provideService(HostProcess.Environment, { XDG_DATA_HOME: "/host-data" }), + ), + ); + + it.effect("shares pending initialization across concurrent directory reads", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const ready = yield* Deferred.make(); + using create = vi + .spyOn(MuseSdk, "createMuseSdkHostEffect") + .mockImplementation(() => + Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(ready))), + ); + const close = vi.fn(async () => {}); + if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); + const instance = { + instanceId: ProviderInstanceId.make("muse"), + configured: true, + config: undefined, + environment: {}, + }; + const reads = yield* Effect.all( + [museUsageReader.directories(instance), museUsageReader.directories(instance)], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Deferred.succeed(ready, { + initializeResult: { museHome: "/native/home" }, + close, + } as unknown as MuseSdk.MuseSdkHost); + const [first, second] = yield* Fiber.join(reads); + expect(first).toEqual(second); + expect(first).toHaveLength(1); + expect(create).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer])), + ); + + it.effect("briefly caches failed discovery and retries after installation recovers", () => + Effect.gen(function* () { + using create = vi + .spyOn(MuseSdk, "createMuseSdkHostEffect") + .mockReturnValueOnce( + Effect.try(() => { + throw new Error("not installed"); + }), + ) + .mockReturnValue( + Effect.succeed({ + initializeResult: { museHome: "/installed/home" }, + close: async () => {}, + } as unknown as MuseSdk.MuseSdkHost), + ); + if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); + const instance = { + instanceId: ProviderInstanceId.make("muse"), + configured: false, + config: undefined, + environment: {}, + }; + expect(yield* museUsageReader.directories(instance)).toEqual([]); + expect(yield* museUsageReader.directories(instance)).toEqual([]); + expect(create).toHaveBeenCalledOnce(); + yield* TestClock.adjust("30 seconds"); + expect(yield* museUsageReader.directories(instance)).toHaveLength(1); + expect(create).toHaveBeenCalledTimes(2); + }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer])), + ); +}); diff --git a/packages/provider-muse/src/server/usage.ts b/packages/provider-muse/src/server/usage.ts new file mode 100644 index 000000000000..b495767c9571 --- /dev/null +++ b/packages/provider-muse/src/server/usage.ts @@ -0,0 +1,140 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { + tokenCount, + totalTokens, + type ProviderUsageReader, + type TranscriptUsageFormat, + type UsageRecord, +} from "@t3tools/provider-core/server/usage"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { MuseSettings } from "../settings.ts"; +import { makeMuseEnvironment } from "./sdk.ts"; +import * as MuseUsageHome from "./MuseUsageHome.ts"; + +const Entry = Schema.Struct({ + schema_version: Schema.Literal(1), + record_type: Schema.Literal("event"), + payload_type: Schema.Literal("runtime.session"), + // Native automated review completions use session payload version 3. + payload_schema_version: Schema.Literals([1, 3]), + id: Schema.NonEmptyString, + recorded_at: Schema.Number, + stream: Schema.Struct({ kind: Schema.Literal("session"), id: Schema.NonEmptyString }), + payload: Schema.Struct({ + source_run_record_id: Schema.optional(Schema.NonEmptyString), + event: Schema.Struct({ + kind: Schema.Literals(["model_completed", "automated_review_completed"]), + model: Schema.Union([ + Schema.NonEmptyString, + Schema.Struct({ model_id: Schema.NonEmptyString }), + ]), + usage: Schema.Struct({ + input_tokens: Schema.optional(Schema.Unknown), + output_tokens: Schema.optional(Schema.Unknown), + cached_tokens: Schema.optional(Schema.Unknown), + cached_input_tokens: Schema.optional(Schema.Unknown), + cache_read_tokens: Schema.optional(Schema.Unknown), + cache_write_tokens: Schema.optional(Schema.Unknown), + reasoning_tokens: Schema.optional(Schema.Unknown), + }), + }), + }), +}); +const decodeEntry = Schema.decodeUnknownOption(Entry); + +function parseRecord(parsed: unknown): readonly UsageRecord[] { + const decoded = decodeEntry(parsed); + if (Option.isNone(decoded)) return []; + const entry = decoded.value; + if (!Number.isSafeInteger(entry.recorded_at) || entry.recorded_at < 0) return []; + const { event } = entry.payload; + const { usage } = event; + const input = tokenCount(usage.input_tokens); + const cachedInputTokens = Math.min( + input, + tokenCount(usage.cache_read_tokens ?? usage.cached_input_tokens ?? usage.cached_tokens), + ); + const cacheCreationTokens = Math.min( + input - cachedInputTokens, + tokenCount(usage.cache_write_tokens), + ); + const outputTokens = tokenCount(usage.output_tokens); + const totals = { + // Muse's cache counters are subsets of input; reasoning is a subset of output. + uncachedInputTokens: input - cachedInputTokens - cacheCreationTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens: Math.min(outputTokens, tokenCount(usage.reasoning_tokens)), + }; + if (totalTokens(totals) === 0) return []; + return [ + { + provider: "muse", + timestampMs: Math.trunc(entry.recorded_at / 1_000), + model: typeof event.model === "string" ? event.model : event.model.model_id, + sessionId: entry.stream.id, + totals, + reportedCostUsd: null, + speed: "standard", + // Runtime envelopes can be copied into another session. The source record + // identifies the inference even when the enclosing envelope has a new ID. + dedupeKey: JSON.stringify(["muse", entry.payload.source_run_record_id ?? entry.id]), + }, + ]; +} + +export const museUsageFormat: TranscriptUsageFormat = { + selectFields: { + schema_version: true, + record_type: true, + payload_type: true, + payload_schema_version: true, + id: true, + recorded_at: true, + stream: { kind: true, id: true }, + payload: { source_run_record_id: true, event: { kind: true, model: true, usage: true } }, + }, + usageMarkers: ['"model_completed"', '"automated_review_completed"'], + parseLine: (line) => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + return parseRecord(parsed); + }, + parseProjected: parseRecord, +}; + +export const museUsageReader: ProviderUsageReader< + MuseSettings, + Path.Path | MuseUsageHome.MuseUsageHome +> = { + kind: "transcripts", + provider: "muse", + format: museUsageFormat, + directories: Effect.fn("museUsageReader.directories")(function* ({ config, environment }) { + const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const usageHome = yield* MuseUsageHome.MuseUsageHome; + const processEnvironment = yield* mergeProviderInstanceEnvironment( + Object.entries(environment).flatMap(([name, value]) => + value === undefined ? [] : [{ name, value, sensitive: false }], + ), + yield* HostProcess.Environment, + ); + const museHome = yield* usageHome.resolve({ + binaryPath: expandHomePath(config?.binaryPath ?? "muse", home), + environment: makeMuseEnvironment(processEnvironment), + }); + return museHome ? [{ dir: path.join(museHome, "sessions"), fileName: "session.jsonl" }] : []; + }), +}; From 91455de0f2220a63b3778042dda0e3e797cbbef1 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 23 Oct 2026 22:13:08 -0700 Subject: [PATCH 3/9] feat(mobile): add Usage and Settings to workspace navigation (#23841) --- .../T3WorkspaceNavigationModule.kt | 20 +- .../src/main/res/drawable/t3_usage.xml | 3 + .../ios/T3NativeControlsModule.swift | 7 +- .../ios/T3SidebarTabBarView.swift | 139 ++++++++++++-- apps/mobile/src/Stack.tsx | 48 ++--- .../src/components/ui/ScreenScrollView.tsx | 10 +- .../home/home-thread-navigation.test.ts | 4 + .../layout/AdaptiveWorkspaceLayout.tsx | 74 +------- .../features/layout/WorkspaceEmptyDetail.tsx | 2 + .../layout/adaptive-workspace-context.ts | 71 +++++++ .../createWorkspaceStackNavigator.ios.tsx | 20 +- .../layout/workspace-sidebar-toolbar.tsx | 2 +- .../SettingsNavigationRouteScreen.tsx | 128 +++++++++++++ .../features/settings/SettingsRouteScreen.tsx | 103 ++++++++--- .../SettingsEnvironmentFilterHeader.tsx | 19 -- .../settings/components/SettingsScreen.tsx | 39 +++- .../components/settings-sheet-targets.ts | 1 + .../src/features/usage/UsageRouteScreen.tsx | 39 ++-- .../AndroidWorkspaceNavigation.android.tsx | 32 ++-- .../AndroidWorkspaceSidebar.android.tsx | 9 +- .../features/workspace/WorkspaceExtraTab.tsx | 16 ++ .../workspace/WorkspaceNativeTabs.ios.tsx | 174 ++++++++++++------ .../features/workspace/WorkspaceNoTabs.tsx | 23 +++ .../workspace/WorkspaceRootScreen.android.tsx | 48 +++-- .../workspace/WorkspaceRootScreen.tsx | 144 +++++++++++---- .../workspace/WorkspaceSectionToolbar.tsx | 91 +++++++++ .../workspace/WorkspaceSidebarTabs.ios.tsx | 65 ++++++- .../workspace/WorkspaceSidebarTabs.tsx | 6 +- .../workspace/WorkspaceTabsContext.tsx | 17 +- .../workspace/WorkspaceToolbar.ios.tsx | 54 ++---- .../workspace/navigation-tab-order.test.ts | 69 +++++++ .../workspace/navigation-tab-order.ts | 87 +++++++++ .../src/features/workspace/navigation-tabs.ts | 51 +++++ .../workspace/useSelectWorkspaceTab.ts | 10 +- .../workspace/useSystemWorkspaceTabBar.ts | 37 +++- .../features/workspace/useWorkspaceSurface.ts | 11 ++ .../workspace/workspace-tabs-state.test.ts | 89 ++++++++- .../workspace/workspace-tabs-state.ts | 61 +++++- apps/mobile/src/lib/storage.test.ts | 28 +++ .../src/native/NativeColumnContent.ios.tsx | 10 +- apps/mobile/src/native/V5StackHeader.ios.tsx | 15 +- .../src/native/createV5StackNavigator.ios.tsx | 3 + .../src/persistence/mobile-preferences.ts | 16 ++ docs/user/usage.md | 2 + 44 files changed, 1487 insertions(+), 410 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml create mode 100644 apps/mobile/src/features/layout/adaptive-workspace-context.ts create mode 100644 apps/mobile/src/features/settings/SettingsNavigationRouteScreen.tsx create mode 100644 apps/mobile/src/features/workspace/WorkspaceExtraTab.tsx create mode 100644 apps/mobile/src/features/workspace/WorkspaceNoTabs.tsx create mode 100644 apps/mobile/src/features/workspace/WorkspaceSectionToolbar.tsx create mode 100644 apps/mobile/src/features/workspace/navigation-tab-order.test.ts create mode 100644 apps/mobile/src/features/workspace/navigation-tab-order.ts create mode 100644 apps/mobile/src/features/workspace/navigation-tabs.ts create mode 100644 apps/mobile/src/features/workspace/useWorkspaceSurface.ts diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index 3a924af4889e..315d25c13007 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -63,12 +63,11 @@ import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView -private val TABS = listOf("threads", "pullRequests", "settings") - class T3WorkspaceNavigationModule : Module() { override fun definition() = ModuleDefinition { Name("T3WorkspaceNavigation") View(T3WorkspaceNavigationView::class) { + Prop("tabs") { view: T3WorkspaceNavigationView, value: List -> view.tabs = value } Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String -> view.selectedTab = value } @@ -121,6 +120,7 @@ class T3WorkspaceNavigationView( private val onTabSelected by EventDispatcher() private val onNewThread by EventDispatcher() private val onOverlayChange by EventDispatcher() + var tabs by mutableStateOf(listOf("threads", "pullRequests", "usage", "settings")) var selectedTab by mutableStateOf("threads") var rail by mutableStateOf(false) var dark by mutableStateOf(false) @@ -175,7 +175,7 @@ class T3WorkspaceNavigationView( tonalElevation = androidx.compose.ui.unit.Dp(0f), windowInsets = WindowInsets(0), ) { - TABS.forEach { tab -> + tabs.forEach { tab -> NavigationBarItem( selected = selectedTab == tab, onClick = { onTabSelected(mapOf("tab" to tab)) }, @@ -267,12 +267,14 @@ class T3WorkspaceNavigationView( ) } } - newThreadButton(fraction) { - expanded = false - onNewThread(emptyMap()) + if (selectedTab == "threads") { + newThreadButton(fraction) { + expanded = false + onNewThread(emptyMap()) + } } Spacer(Modifier.weight(1f)) - TABS.forEach { tab -> + tabs.forEach { tab -> railItem(tab, fraction) { expanded = false onTabSelected(mapOf("tab" to tab)) @@ -356,7 +358,8 @@ class T3WorkspaceNavigationView( private fun tabTitle(tab: String) = when (tab) { "threads" -> "Chats" - "pullRequests" -> "PRs" + "pullRequests" -> "Pulls" + "usage" -> "Usage" else -> "Settings" } @@ -368,6 +371,7 @@ class T3WorkspaceNavigationView( when (tab) { "threads" -> R.drawable.t3_chats "pullRequests" -> R.drawable.t3_pull_requests + "usage" -> R.drawable.t3_usage else -> R.drawable.t3_settings } ) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml new file mode 100644 index 000000000000..da09809d177f --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 81a892c8becf..bbd71e72db0e 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -34,9 +34,12 @@ public final class T3NativeControlsModule: Module { View(T3SidebarTabBarView.self) { ViewName("SidebarTabBar") - Events("onSelectionChange") - Prop("selectedIndex") { (view: T3SidebarTabBarView, value: Int) in view.selectedIndex = value } + Events("onSelectionChange", "onCompose") + Prop("tabs") { (view: T3SidebarTabBarView, value: [String]) in view.tabs = value } + Prop("selectedTab") { (view: T3SidebarTabBarView, value: String) in view.selectedTab = value } Prop("selectedColor") { (view: T3SidebarTabBarView, value: UIColor) in view.selectedColor = value } + Prop("showsCompose") { (view: T3SidebarTabBarView, value: Bool) in view.showsCompose = value } + Prop("compact") { (view: T3SidebarTabBarView, value: Bool) in view.compact = value } Prop("appearance") { (view: T3SidebarTabBarView, value: String) in view.appearance = value } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift index 3bfd730facd1..2f9fa98f0d5a 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -1,37 +1,139 @@ import ExpoModulesCore import UIKit +private final class WorkspaceTabBar: UITabBar { + var onContentLayout: ((CGRect) -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + let row = tabButtons(in: self).reduce(CGRect.null) { $0.union(convert($1.bounds, from: $1)) } + if !row.isNull { onContentLayout?(row) } + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + invalidateIntrinsicContentSize() + } + + private func tabButtons(in view: UIView) -> [UIView] { + view.subviews.filter { !$0.isHidden }.flatMap { subview in + if subview is UIControl, subview.accessibilityIdentifier?.hasPrefix("workspace-tab-") == true { + return [subview] + } + return tabButtons(in: subview) + } + } +} + final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { let onSelectionChange = EventDispatcher() - private let tabBar = UITabBar() - var selectedIndex = 0 { didSet { updateSelection() } } + let onCompose = EventDispatcher() + private let tabBar = WorkspaceTabBar() + private let composeButton = UIButton(type: .system) + private lazy var tabBarTrailing = tabBar.trailingAnchor.constraint(equalTo: trailingAnchor) + private var itemsByTab: [String: UITabBarItem] = [:] + var tabs = ["threads", "pullRequests", "usage", "settings"] { + didSet { if tabs != oldValue { updateItems() } } + } + var selectedTab = "threads" { didSet { updateSelection() } } var selectedColor: UIColor = .systemBlue { didSet { tabBar.tintColor = selectedColor } } + var compact = false { + didSet { + tabBarTrailing.constant = showsCompose ? (compact ? -64 : -76) : 0 + tabBar.setNeedsLayout() + } + } + var showsCompose = false { + didSet { + composeButton.isHidden = !showsCompose + tabBarTrailing.constant = showsCompose ? (compact ? -64 : -76) : 0 + setNeedsLayout() + } + } var appearance = "light" { didSet { overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light } } required init(appContext: AppContext? = nil) { super.init(appContext: appContext) - let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:)) - let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) - tabBar.items = [ - UITabBarItem(title: "Threads", image: tabIcon(UIImage(systemName: "text.bubble")), tag: 0), - UITabBarItem(title: "Pulls", image: tabIcon(pullRequestIcon), tag: 1), - ] - tabBar.items?.last?.accessibilityLabel = "Pull requests" - tabBar.items?.first?.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) tabBar.itemPositioning = .fill + // iPad's regular bar uses a compressed inline row even with fill positioning. + // This floating sidebar control needs the same full-size items as a phone bar. + if #available(iOS 17.0, *) { + tabBar.traitOverrides.horizontalSizeClass = .compact + } tabBar.delegate = self + tabBar.translatesAutoresizingMaskIntoConstraints = false addSubview(tabBar) - updateSelection() + NSLayoutConstraint.activate([ + tabBar.leadingAnchor.constraint(equalTo: leadingAnchor), + tabBarTrailing, + tabBar.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + // UIKit's floating row changes with window geometry. Follow its laid-out items + // instead of estimating their position from a second set of safe-area updates. + tabBar.onContentLayout = { [weak self] frame in self?.layoutCompose(alongside: frame) } + composeButton.accessibilityLabel = "New chat" + composeButton.addAction(UIAction { [weak self] _ in self?.onCompose() }, for: .touchUpInside) + composeButton.isHidden = true + addSubview(composeButton) + configureCompose() + prepareItems() + updateItems() } - override func layoutSubviews() { - super.layoutSubviews() - tabBar.frame = bounds + private func configureCompose() { + var configuration: UIButton.Configuration + if #available(iOS 26.0, *) { + configuration = .glass() + } else { + configuration = .gray() + } + configuration.cornerStyle = .capsule + configuration.baseForegroundColor = .label + configuration.image = UIImage(systemName: "square.and.pencil") + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24) + composeButton.configuration = configuration + } + + private func prepareItems() { + let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:)) + let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) + let labels = ["threads": "Chats", "pullRequests": "PRs", "usage": "Usage", "settings": "Settings"] + let symbols = ["threads": "text.bubble", "usage": "chart.bar.xaxis", "settings": "gearshape"] + for (tab, label) in labels { + let icon = tab == "pullRequests" ? pullRequestIcon : UIImage(systemName: symbols[tab] ?? "circle") + let item = UITabBarItem(title: nil, image: tabIcon(icon), tag: 0) + item.accessibilityLabel = label + item.accessibilityIdentifier = "workspace-tab-\(tab)" + if tab == "threads" { item.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) } + itemsByTab[tab] = item + } + } + + private func updateItems() { + tabBar.items = tabs.enumerated().compactMap { index, tab in + guard let item = itemsByTab[tab] else { return nil } + item.tag = index + return item + } updateSelection() } + private func layoutCompose(alongside frame: CGRect) { + guard showsCompose, frame.height > 0 else { return } + let row = convert(frame, from: tabBar) + let size = row.height + 8 + composeButton.frame = CGRect(x: row.maxX + 16, y: row.midY - size / 2, width: size, height: size) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if showsCompose, let target = composeButton.hitTest(convert(point, to: composeButton), with: event) { + return target + } + return tabBar.hitTest(convert(point, to: tabBar), with: event) + } + override func didMoveToWindow() { super.didMoveToWindow() updateSelection() @@ -49,12 +151,13 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { } private func updateSelection() { - guard let items = tabBar.items, items.indices.contains(selectedIndex) else { return } - guard tabBar.selectedItem !== items[selectedIndex] else { return } - tabBar.selectedItem = items[selectedIndex] + guard let item = itemsByTab[selectedTab], tabBar.items?.contains(item) == true else { return } + guard tabBar.selectedItem !== item else { return } + tabBar.selectedItem = item } func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { - onSelectionChange(["index": item.tag]) + guard tabs.indices.contains(item.tag) else { return } + onSelectionChange(["tab": tabs[item.tag]]) } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index d89baa82e402..bcd1a97ce529 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -85,6 +85,7 @@ import { } from "./features/threads/ThreadSettingsSheet"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; +import { SettingsNavigationRouteScreen } from "./features/settings/SettingsNavigationRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; @@ -237,6 +238,7 @@ const SettingsContentStack = createV5SheetStackNavigator({ // Android draws its own app bar here. Hidden only from inside the screen, the native one // showed for the first frame of the Settings tab and pushed the page down. headerShown: Platform.OS !== "android", + headerBackVisible: false, }, }), SettingsEnvironments: createNativeStackScreen({ @@ -305,6 +307,11 @@ const SettingsContentStack = createV5SheetStackNavigator({ title: "Archived Threads", }, }), + SettingsNavigation: createNativeStackScreen({ + screen: SettingsNavigationRouteScreen, + linking: "navigation-tabs", + options: { title: "Navigation tabs" }, + }), SettingsAppearance: createNativeStackScreen({ screen: SettingsAppearanceRouteScreen, linking: "appearance", @@ -975,8 +982,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...NESTED_NAVIGATOR_OPTIONS, gestureEnabled: true, headerShown: false, - // Android opens Settings as a top-level tab, so it swaps like the other tabs. - ...(Platform.OS === "android" ? { animation: "none" as const } : null), + presentation: "card", + animation: "none", }, }), SettingsLegal: createNativeStackScreen({ @@ -1066,42 +1073,15 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig const { width, height } = useWindowDimensions(); const nativeMetrics = useNativeLayoutMetrics(); const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView; - const usesWorkspaceFlowScreens = Platform.OS === "android" || usesSplitView; - const flowPresentations = useRef(new Map()); return ( } - screenListeners={{ - state: ({ data }) => { - const keys = new Set(data.state.routes.map((route) => route.key)); - for (const key of flowPresentations.current.keys()) { - if (!keys.has(key)) flowPresentations.current.delete(key); - } - }, - }} - screenOptions={({ route }) => { - if (route.name !== "SettingsSheet") { - return Platform.OS === "android" - ? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) } - : {}; - } - - // Moving an open flow between a sheet and a split column remounts its nested - // navigator. Keep its presentation until dismissal so folding preserves the settings flow. - let usesCard = flowPresentations.current.get(route.key); - if (usesCard === undefined) { - usesCard = usesWorkspaceFlowScreens; - flowPresentations.current.set(route.key, usesCard); - } - return usesCard - ? { presentation: "card" } - : { - presentation: "formSheet", - sheetAllowedDetents: [1], - sheetGrabberVisible: true, - }; - }} + screenOptions={({ route }) => + Platform.OS === "android" && route.name !== "SettingsSheet" + ? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) } + : {} + } /> ); }); diff --git a/apps/mobile/src/components/ui/ScreenScrollView.tsx b/apps/mobile/src/components/ui/ScreenScrollView.tsx index 9a83807ec55f..684d2e3bdf51 100644 --- a/apps/mobile/src/components/ui/ScreenScrollView.tsx +++ b/apps/mobile/src/components/ui/ScreenScrollView.tsx @@ -1,11 +1,19 @@ -import type { ComponentProps } from "react"; +import { createContext, use, type ComponentProps } from "react"; import { Platform } from "react-native"; import { NativeScrollView as ScrollView } from "../../native/NativeScrollViewMarker"; +export const ScreenScrollViewBottomInset = createContext(0); + export function ScreenScrollView(props: ComponentProps) { + const bottomInset = use(ScreenScrollViewBottomInset); return ( 0 + ? { ...props.contentInset, bottom: (props.contentInset?.bottom ?? 0) + bottomInset } + : props.contentInset + } contentContainerStyle={[ props.contentContainerStyle, Platform.OS === "android" && { diff --git a/apps/mobile/src/features/home/home-thread-navigation.test.ts b/apps/mobile/src/features/home/home-thread-navigation.test.ts index 820a66665054..d02d602767af 100644 --- a/apps/mobile/src/features/home/home-thread-navigation.test.ts +++ b/apps/mobile/src/features/home/home-thread-navigation.test.ts @@ -21,6 +21,10 @@ function loadRouters() { vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } })); +vi.mock("../workspace/useAndroidWorkspaceSelection", () => ({ + useAndroidWorkspaceSelection: vi.fn(), +})); + vi.mock("@react-navigation/native", () => { const { CommonActions, StackActions } = loadRouters(); return { CommonActions, StackActions }; diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index bec83816b585..ffcab2aa3102 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -1,3 +1,4 @@ +import { AdaptiveWorkspaceContext, useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; import { createDeferredNavigation } from "./deferred-navigation"; import { uuidv4 } from "../../lib/uuid"; import type { @@ -15,7 +16,6 @@ import { useNavigation, } from "@react-navigation/native"; import { - createContext, use, useDeferredValue, useEffect, @@ -43,10 +43,7 @@ import { type VerticalHinge, deriveWorkspacePaneLayout, isPrimarySidebarPinned, - type FileInspectorPaneLayout, - type Layout, type WorkspaceAuxiliaryPaneRole, - type WorkspacePaneLayout, } from "../../lib/layout"; import { resolveThreadSelectionNavigationAction, @@ -73,6 +70,7 @@ import { } from "../workspace/AndroidWorkspaceNavigation"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; @@ -94,65 +92,7 @@ import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { AndroidHingeObserver } from "../../native/AndroidHingeObserver"; import { WindowHingeContext } from "../../native/window-hinge-context"; -interface AdaptiveWorkspaceContextValue { - readonly layout: Layout; - readonly panes: WorkspacePaneLayout; - readonly fileInspector: FileInspectorPaneLayout; - readonly primarySidebarSearchQuery: string; - readonly startNewTask: () => void; - readonly deferThreadSelectionUntil: (operation: Promise) => void; - readonly selectThread: (thread: EnvironmentThreadShell) => void; - readonly selectThreadRoute: (params: ReactNavigation.RootParamList["Thread"]) => void; - readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; - readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; - readonly setPrimarySidebarSearchQuery: (query: string) => void; - readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; - readonly toggleAuxiliaryPane: () => void; - readonly togglePrimarySidebar: () => void; - readonly setAuxiliaryPaneWidth: (width: number) => void; - /** - * Android split view while the thread composer has the soft keyboard: the - * sidebar and rail step aside and the thread header drops to one line. - */ - readonly composerKeyboardActive: boolean; - /** The thread composer reports its focus so the layout can make room for typing. */ - readonly setComposerEditing: (editing: boolean) => void; -} - -const compactLayout = deriveLayout({ width: 0, height: 0 }); -const compactPanes = deriveWorkspacePaneLayout({ - layout: compactLayout, - viewportWidth: 0, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: true, -}); -const compactFileInspector = deriveFileInspectorPaneLayout({ - layout: compactLayout, - viewportWidth: 0, -}); -const AdaptiveWorkspaceContext = createContext({ - layout: compactLayout, - panes: compactPanes, - fileInspector: compactFileInspector, - primarySidebarSearchQuery: "", - startNewTask: () => undefined, - deferThreadSelectionUntil: () => undefined, - selectThread: () => undefined, - selectThreadRoute: () => undefined, - activateAuxiliaryPaneRole: () => () => undefined, - registerWorkspaceInspector: () => () => undefined, - setPrimarySidebarSearchQuery: () => undefined, - showAuxiliaryPane: () => undefined, - toggleAuxiliaryPane: () => undefined, - togglePrimarySidebar: () => undefined, - setAuxiliaryPaneWidth: () => undefined, - composerKeyboardActive: false, - setComposerEditing: () => undefined, -}); - -export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { - return use(AdaptiveWorkspaceContext); -} +export { useAdaptiveWorkspaceLayout }; export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); @@ -265,9 +205,11 @@ function AdaptiveWorkspaceLayoutContent( const nativeMetrics = useNativeLayoutMetrics(); const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; const pathname = props.pathname; - // Android's Settings tab is a full workspace page: the rail stays, the sidebar and - // auxiliary panes hide. - const workspacePage = Platform.OS === "android" && pathname.startsWith("/settings"); + const workspace = useWorkspaceTabs(); + // Settings and Usage occupy the workspace rather than a list/detail pair. + const workspacePage = + Platform.OS === "android" && + (pathname.startsWith("/settings") || workspace?.state.selectedTab === "usage"); const navigation = useNavigation(); const activeRoleOwner = useRef(null); const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index d719c85525d1..d58360e7e99c 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -13,6 +13,8 @@ export function WorkspaceEmptyDetail(props: { readonly onAddConnection?: () => void; }) { const pullRequests = props.tab === "pullRequests"; + if (props.tab && props.tab !== "threads" && props.tab !== "pullRequests") + return ; return ( void; + readonly deferThreadSelectionUntil: (operation: Promise) => void; + readonly selectThread: (thread: EnvironmentThreadShell) => void; + readonly selectThreadRoute: (params: ReactNavigation.RootParamList["Thread"]) => void; + readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; + readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; + readonly setPrimarySidebarSearchQuery: (query: string) => void; + readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; + readonly toggleAuxiliaryPane: () => void; + readonly togglePrimarySidebar: () => void; + readonly setAuxiliaryPaneWidth: (width: number) => void; + /** + * Android split view while the thread composer has the soft keyboard: the + * sidebar and rail step aside and the thread header drops to one line. + */ + readonly composerKeyboardActive: boolean; + /** The thread composer reports its focus so the layout can make room for typing. */ + readonly setComposerEditing: (editing: boolean) => void; +} + +const compactLayout = deriveLayout({ width: 0, height: 0 }); +const compactPanes = deriveWorkspacePaneLayout({ + layout: compactLayout, + viewportWidth: 0, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, +}); +const compactFileInspector = deriveFileInspectorPaneLayout({ + layout: compactLayout, + viewportWidth: 0, +}); +export const AdaptiveWorkspaceContext = createContext({ + layout: compactLayout, + panes: compactPanes, + fileInspector: compactFileInspector, + primarySidebarSearchQuery: "", + startNewTask: () => undefined, + deferThreadSelectionUntil: () => undefined, + selectThread: () => undefined, + selectThreadRoute: () => undefined, + activateAuxiliaryPaneRole: () => () => undefined, + registerWorkspaceInspector: () => () => undefined, + setPrimarySidebarSearchQuery: () => undefined, + showAuxiliaryPane: () => undefined, + toggleAuxiliaryPane: () => undefined, + togglePrimarySidebar: () => undefined, + setAuxiliaryPaneWidth: () => undefined, + composerKeyboardActive: false, + setComposerEditing: () => undefined, +}); + +export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { + return use(AdaptiveWorkspaceContext); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 262e0ac71676..6af2cda6dca5 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -26,7 +26,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { WorkspaceTabsProvider, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { WorkspaceNativeTabs } from "../workspace/WorkspaceNativeTabs.ios"; -import { isDuoDevice } from "../../lib/device-type"; +import { workspaceUsesColumns } from "../workspace/workspace-tabs-state"; import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; @@ -178,12 +178,10 @@ function WorkspaceColumns( const selectedTab = useWorkspaceTabs()?.state.selectedTab; const openNewTask = startNewTask; - // The Duo keeps compose in the detail column's bar, which is its vertical bar in landscape - // and folded, so it sits with the column's other actions whichever screen is shown. Pull - // requests leave it out, including one opened from a thread; their bar is already full of - // review actions. + // The cover detail rail needs room for thread tools above the fixed tabs. + // The unfolded Duo keeps compose beside its sidebar control. const detailCompose = - isDuoDevice(deviceType) && selectedTab !== "pullRequests" ? openNewTask : undefined; + deviceType === "duo-unfolded" && selectedTab !== "pullRequests" ? openNewTask : undefined; const inspector = use(NativeWorkspaceInspectorContext); const hostRef = useRef(null); const activeDetailKey = props.detail.at(-1)?.key; @@ -362,7 +360,8 @@ function WorkspaceColumns( } function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean }) { - const { layout, startNewTask } = useAdaptiveWorkspaceLayout(); + const { layout } = useAdaptiveWorkspaceLayout(); + const workspace = useWorkspaceTabs()!; // Native dismiss commits the route pop after the transition. Restore the dock // when the primary screen starts appearing, including interactive back gestures. const [primaryVisible, setPrimaryVisible] = useState(false); @@ -380,6 +379,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean const descriptors = props.descriptors; const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; const visibleRoute = projection.detail.at(-1) ?? primaryRoute; + const workspacePage = !workspaceUsesColumns(workspace.state.selectedTab, visibleRoute.name); const baseDescriptor: Descriptor = { ...primaryDescriptor, navigation: props.descriptors[visibleRoute.key]!.navigation, @@ -389,8 +389,8 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean options: { headerShown: false }, render: () => (