Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/mobile/src/components/AppSymbol.ios.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { SymbolView as ExpoSymbolView, type SymbolViewProps } from "expo-symbols";
import { SymbolView as ExpoSymbolView } from "expo-symbols";
import { withUniwind } from "uniwind";
import type { AppSymbolViewProps } from "./AppSymbol";

export type { SFSymbol } from "expo-symbols";
export type AppSymbolName = SymbolViewProps["name"];
export type { AppSymbolName } from "./AppSymbol";

/**
* Keep the iOS implementation isolated from the Android Tabler fallback so
* Metro does not initialize the icon package when iOS renders SF Symbols.
*/
function AppSymbolView(props: SymbolViewProps) {
function AppSymbolView(props: AppSymbolViewProps) {
return <ExpoSymbolView {...props} />;
}

Expand Down
49 changes: 39 additions & 10 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import type { Icon } from "@tabler/icons-react-native/types";
* the entire Tabler icon set in Metro.
*/
import IconAdjustmentsHorizontal from "@tabler/icons-react-native/IconAdjustmentsHorizontal";
import IconAlignLeft from "@tabler/icons-react-native/IconAlignLeft";
import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle";
import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle";
import IconApps from "@tabler/icons-react-native/IconApps";
import IconArchive from "@tabler/icons-react-native/IconArchive";
import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp";
import IconArrowDown from "@tabler/icons-react-native/IconArrowDown";
import IconArrowForwardUp from "@tabler/icons-react-native/IconArrowForwardUp";
import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft";
import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle";
import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle";
Expand All @@ -30,15 +33,17 @@ import IconChevronDown from "@tabler/icons-react-native/IconChevronDown";
import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft";
import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";
import IconChevronUp from "@tabler/icons-react-native/IconChevronUp";
import IconCircle from "@tabler/icons-react-native/IconCircle";
import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck";
import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled";
import IconTicket from "@tabler/icons-react-native/IconTicket";
import IconClock from "@tabler/icons-react-native/IconClock";
import IconCode from "@tabler/icons-react-native/IconCode";
import IconCopy from "@tabler/icons-react-native/IconCopy";
import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";
import IconDatabase from "@tabler/icons-react-native/IconDatabase";
import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";
import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop";
import IconDeviceMobile from "@tabler/icons-react-native/IconDeviceMobile";
import IconDots from "@tabler/icons-react-native/IconDots";
import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical";
import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";
Expand All @@ -62,13 +67,15 @@ import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns";
import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar";
import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight";
import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing";
import IconMenu2 from "@tabler/icons-react-native/IconMenu2";
import IconMicrophone from "@tabler/icons-react-native/IconMicrophone";
import IconLink from "@tabler/icons-react-native/IconLink";
import IconMenu2 from "@tabler/icons-react-native/IconMenu2";
import IconMessage from "@tabler/icons-react-native/IconMessage";
import IconMinus from "@tabler/icons-react-native/IconMinus";
import IconMoon from "@tabler/icons-react-native/IconMoon";
import IconNetwork from "@tabler/icons-react-native/IconNetwork";
import IconPalette from "@tabler/icons-react-native/IconPalette";
import IconPencil from "@tabler/icons-react-native/IconPencil";
import IconPhoto from "@tabler/icons-react-native/IconPhoto";
import IconPin from "@tabler/icons-react-native/IconPin";
import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff";
Expand Down Expand Up @@ -97,13 +104,14 @@ import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";
import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";
import IconWorld from "@tabler/icons-react-native/IconWorld";
import IconX from "@tabler/icons-react-native/IconX";
import type { SFSymbol, SymbolViewProps } from "expo-symbols";
import type { AndroidSymbol, SFSymbol, SymbolViewProps } from "expo-symbols";
import { withUniwind } from "uniwind";

const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
const ANDROID_ICON_BY_SF_SYMBOL = {
"arrow.branch": IconGitBranch,
"arrow.left": IconArrowLeft,
"arrow.clockwise": IconRefresh,
"arrow.down": IconArrowDown,
"arrow.down.circle": IconArrowDownCircle,
"arrow.right.circle": IconArrowRightCircle,
"arrow.triangle.branch": IconGitBranch,
Expand All @@ -117,6 +125,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"arrow.up.right": IconArrowUpRight,
"arrow.up.right.circle": IconArrowUpRightCircle,
"arrow.uturn.backward": IconArrowBackUp,
"arrow.uturn.forward": IconArrowForwardUp,
archivebox: IconArchive,
"archivebox.fill": IconArchive,
"bell.badge": IconBellRinging,
Expand All @@ -127,6 +136,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"chart.bar.xaxis": IconChartBar,
checkmark: IconCheck,
"checkmark.circle": IconCircleCheck,
circle: IconCircle,
clock: IconClock,
ticket: IconTicket,
cloud: IconCloud,
Expand All @@ -143,11 +153,13 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
moon: IconMoon,
"ellipsis.circle": IconDotsCircleHorizontal,
"exclamationmark.triangle": IconAlertTriangle,
"exclamationmark.circle": IconAlertCircle,
eye: IconEye,
folder: IconFolder,
"folder.badge.plus": IconFolderPlus,
"folder.fill": IconFolder,
gearshape: IconSettings,
globe: IconWorld,
hammer: IconHammer,
"info.circle": IconInfoCircle,
internaldrive: IconDatabase,
Expand All @@ -162,7 +174,9 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
macmini: IconServer,
macstudio: IconDeviceDesktop,
magnifyingglass: IconSearch,
mic: IconMicrophone,
paintbrush: IconPalette,
pencil: IconPencil,
"person.crop.circle": IconUserCircle,
photo: IconPhoto,
pin: IconPin,
Expand All @@ -187,6 +201,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"sun.max": IconSun,
"stop.fill": IconPlayerStopFilled,
terminal: IconTerminal2,
"text.alignleft": IconAlignLeft,
"text.bubble": IconMessage,
"text.word.spacing": IconLetterSpacing,
"textformat.size": IconTypography,
Expand All @@ -197,13 +212,14 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"wifi.slash": IconWifiOff,
xmark: IconX,
"xmark.circle.fill": IconCircleXFilled,
};
} satisfies Partial<Record<SFSymbol, Icon>>;
const SF_ICON_LOOKUP: Partial<Record<SFSymbol, Icon>> = ANDROID_ICON_BY_SF_SYMBOL;

// Callers can pass `{ ios, android }` names where `android` is a Material
// icon name (the raw expo-symbols contract). Resolve those here too so the
// android key keeps working through this wrapper — it wins over the SF map
// when both match (e.g. folder vs folder_open for expanded project groups).
const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = {
const ANDROID_ICON_BY_MATERIAL_NAME = {
auto_awesome: IconSparkles,
bolt: IconBolt,
build: IconTool,
Expand All @@ -222,21 +238,34 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = {
keyboard_arrow_up: IconChevronUp,
keyboard_hide: IconKeyboardHide,
more_vert: IconDotsVertical,
merge: IconGitMerge,
public: IconWorld,
remove: IconMinus,
smartphone: IconDeviceMobile,
terminal: IconTerminal2,
visibility: IconEye,
};
} satisfies Partial<Record<AndroidSymbol, Icon>>;

export type { SFSymbol } from "expo-symbols";
export type AppSymbolName = SymbolViewProps["name"];
export type AppSymbolName =
| keyof typeof ANDROID_ICON_BY_SF_SYMBOL
| {
ios: SFSymbol;
android: keyof typeof ANDROID_ICON_BY_MATERIAL_NAME;
};

export type AppSymbolViewProps = Omit<SymbolViewProps, "name"> & { name: AppSymbolName };

export function isAppSymbolName(name: string): name is Extract<AppSymbolName, string> {
return Object.prototype.hasOwnProperty.call(ANDROID_ICON_BY_SF_SYMBOL, name);
}

function AppSymbolView(props: SymbolViewProps) {
function AppSymbolView(props: AppSymbolViewProps) {
const materialName = typeof props.name === "string" ? undefined : props.name.android;
const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios;
const AndroidIcon =
(materialName ? ANDROID_ICON_BY_MATERIAL_NAME[materialName] : undefined) ??
(sfSymbol ? ANDROID_ICON_BY_SF_SYMBOL[sfSymbol] : undefined);
(sfSymbol ? SF_ICON_LOOKUP[sfSymbol] : undefined);

if (!AndroidIcon) {
return props.fallback ?? null;
Expand Down
6 changes: 2 additions & 4 deletions apps/mobile/src/components/EnvironmentMachineSymbol.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { EnvironmentMachineKind } from "@t3tools/contracts";
import type { SFSymbol } from "expo-symbols";
import { SymbolView, type AppSymbolName } from "./AppSymbol";

import { SymbolView } from "./AppSymbol";

const SYMBOL_BY_KIND: Record<EnvironmentMachineKind, SFSymbol> = {
const SYMBOL_BY_KIND: Record<EnvironmentMachineKind, AppSymbolName> = {
server: "server.rack",
cloud: "cloud",
linux: "terminal",
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/components/MaterialMenuPopup.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { View } from "react-native";

import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import type { MaterialMenuPopupProps } from "./MaterialMenuPopup";
import { SymbolView, type AppSymbolName } from "./AppSymbol";
import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol";

function MenuIcon(props: {
readonly name: AppSymbolName;
Expand Down Expand Up @@ -96,10 +96,10 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
) : null}
</Column>
</DropdownMenuItem.Text>
{action.image ? (
{action.image && isAppSymbolName(action.image) ? (
<DropdownMenuItem.LeadingIcon>
<MenuIcon
name={action.image as AppSymbolName}
name={action.image}
destructive={action.attributes?.destructive}
disabled={action.attributes?.disabled}
/>
Expand Down
116 changes: 112 additions & 4 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import * as NodePath from "node:path";
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
import { mergeUsage } from "@t3tools/shared/usageMerge";
import {
EnvironmentId,
ProviderDriverKind,
ProviderInstanceId,
UsageDay,
Expand Down Expand Up @@ -131,14 +133,15 @@ describe("UsageService", () => {
[
{ type: "session_meta", payload: { id: "codex-account-session" } },
{ type: "turn_context", payload: { model: "gpt-5.6-sol" } },
{
// A-B-A at one timestamp must preserve both equal A events.
...[11, 12, 11].map((outputTokens) => ({
type: "event_msg",
timestamp: "2026-08-01T10:00:00Z",
payload: {
type: "token_count",
info: { last_token_usage: { input_tokens: 10, output_tokens: 11 } },
info: { last_token_usage: { input_tokens: 10, output_tokens: outputTokens } },
},
},
})),
]
.map((line) => encodeUnknownJsonString(line))
.join("\n") + "\n",
Expand Down Expand Up @@ -198,7 +201,21 @@ describe("UsageService", () => {
),
);
const summary = yield* service.readSummary(WINDOW);
assert.strictEqual(totalOutputTokens(summary), 36);
assert.strictEqual(totalOutputTokens(summary), 59);
yield* Effect.promise(() =>
NodeFSP.rename(
NodePath.join(codexHome, "sessions", "rollout.jsonl"),
NodePath.join(codexHome, "sessions", "moved.jsonl"),
),
);
const moved = yield* service.readSummary(WINDOW);
assert.deepStrictEqual(moved.buckets, summary.buckets);
yield* Effect.promise(() =>
NodeFSP.rm(NodePath.join(codexHome, "sessions"), { recursive: true }),
);
const removed = yield* service.readSummary(WINDOW);
assert.deepStrictEqual(removed.buckets, summary.buckets);

const sources = summary.sources.filter((source) => source.status === "ok");
assert.strictEqual(sources.length, 4);
assert.strictEqual(
Expand Down Expand Up @@ -393,6 +410,97 @@ describe("UsageService", () => {
}).pipe(Effect.scoped),
);

it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
const alias = NodePath.join(home, "claude-alias");
yield* Effect.promise(() =>
NodeFSP.symlink(NodePath.join(home, "claude"), alias, "junction"),
);
const content = claudeLine(1, 5);
yield* Effect.promise(() => NodeFSP.writeFile(transcript, content));
yield* Effect.gen(function* () {
const service = yield* UsageService.make;
const first = yield* service.readSummary(WINDOW);
assert.strictEqual(totalOutputTokens(first), 5);
assert.isAbove(first.buckets[0]?.costUsd ?? 0, 0);

yield* Effect.promise(() => NodeFSP.rm(transcript));
const deleted = yield* service.readSummary(WINDOW);
assert.deepStrictEqual(deleted.buckets, first.buckets);
assert.deepStrictEqual(deleted.sources, first.sources);

const restarted = yield* UsageService.make;
const restored = yield* restarted.readSummary(WINDOW);
assert.deepStrictEqual(restored.buckets, first.buckets);
assert.deepStrictEqual(restored.sources, first.sources);

// A moved transcript must not count the saved usage twice.
yield* Effect.promise(() => NodeFSP.writeFile(transcript + ".jsonl", content));
const moved = yield* restarted.readSummary(WINDOW);
assert.deepStrictEqual(moved.buckets, first.buckets);
assert.strictEqual(moved.sources[0]?.distinctSessions, 1);

const replacementProjects = NodePath.join(home, "replacement-projects");
yield* Effect.promise(() => NodeFSP.mkdir(replacementProjects));
yield* Effect.promise(() =>
NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }),
);
const afterRootCleanup = yield* UsageService.make;
const missingRoot = yield* afterRootCleanup.readSummary(WINDOW);
assert.deepStrictEqual(missingRoot.buckets, first.buckets);
assert.strictEqual(missingRoot.sources[0]?.distinctSessions, 1);
assert.strictEqual(missingRoot.sources[0]?.status, "ok");
assert.deepStrictEqual(missingRoot.sources[0]?.fingerprint, first.sources[0]?.fingerprint);
yield* Effect.promise(async () => {
const projects = NodePath.join(home, "claude", "projects");
await NodeFSP.rename(replacementProjects, projects);
await NodeFSP.writeFile(NodePath.join(projects, "new.jsonl"), claudeLine(2, 7));
});
const recreated = yield* afterRootCleanup.readSummary(WINDOW);
assert.strictEqual(totalOutputTokens(recreated), 12);
assert.deepStrictEqual(recreated.sources[0]?.fingerprint, first.sources[0]?.fingerprint);

const merged = mergeUsage(
[
{
environmentId: EnvironmentId.make("cleanup-test"),
label: "test",
summary: recreated,
},
{
environmentId: EnvironmentId.make("other-environment"),
label: "before cleanup",
summary: first,
},
],
missingRoot.contractVersion,
);
assert.strictEqual(merged.outputTokens, 12);
assert.strictEqual(merged.sessions, 1);
assert.strictEqual(merged.costUsd, recreated.buckets[0]?.costUsd);

const outsideWindow = yield* restarted.readSummary({
...WINDOW,
sinceDay: UsageDay.make("2026-08-02"),
});
assert.deepStrictEqual(outsideWindow.buckets, []);
assert.strictEqual(outsideWindow.sources[0]?.distinctSessions, 0);
}).pipe(
Effect.provide(
serviceLayers({
prefix: "usage-service-cleanup-test",
home,
settings: { providers: { ...settings.providers, claudeAgent: { homePath: alias } } },
ratesDocument: {
"claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 },
},
}),
),
);
}).pipe(Effect.scoped),
);

it.live("does not share an in-flight scan after custom prices change", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
Expand Down
Loading
Loading