Skip to content
Closed
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
13 changes: 13 additions & 0 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "muse") {
// Meta mark: https://research.meta.ai/navigation/meta-logo-primary-dark_standardsize.svg
return (
<Svg width={size} height={size} viewBox="0 0 150.4828 100" fill="none">
<Path
d="M107.9999847,0.0000013c-12.3391113,0-21.984787,9.2935991-30.7164001,21.0993118 C65.2847366,5.821527,55.2499847,0,43.2413788,0C18.7586193,0,0,31.8620682,0,65.5862122 C0,86.6896515,10.2095623,100,27.3103466,100c12.3080502,0,21.1600361-5.8025894,36.8965454-33.3103409 c0,0,6.5597382-11.5841255,11.0725021-19.563858c1.5813522,2.5531845,3.2431107,5.3001404,4.9964676,8.2535095 l7.379303,12.4137955C102.0298843,91.8478851,110.039032,100,124.5517273,100 c16.6595764,0,25.9310303-13.4923325,25.9310303-35.0344849 C150.4827576,29.6551723,131.301178,0.0000013,107.9999847,0.0000013z M52.2068977,59.2413788 c-12.7586212,20-17.1724167,24.4827576-24.2758656,24.4827576c-7.3103428,0-11.6551704-6.4178314-11.6551704-17.8620682 c0-24.4827576,12.2068977-49.5172424,26.7586231-49.5172424c7.880127,0,14.4653816,4.5510426,24.5522308,18.9913158 C58.0087967,50.0272293,52.2068977,59.2413788,52.2068977,59.2413788z M100.3597336,56.7235756L91.5367355,42.00877 c-2.3876724-3.8832092-4.6757278-7.451458-6.8838348-10.7230263 c7.951973-12.273531,14.5113449-18.3891907,22.3126144-18.3891907c16.2069016,0,29.1724243,23.8620682,29.1724243,53.1724091 c0,11.1724167-3.6593475,17.6551743-11.2413864,17.6551743 C117.6294937,83.7241364,114.1578674,78.924736,100.3597336,56.7235756z"
fill={mono}
fillRule="evenodd"
/>
</Svg>
);
}

if (props.provider === "opencode") {
return (
<Svg width={size} height={size} viewBox="0 0 32 40" fill="none">
Expand Down
86 changes: 85 additions & 1 deletion apps/mobile/src/connection/environment-cache-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";
import {
EnvironmentId,
ProjectId,
ProviderInstanceId,
ThreadId,
type OrchestrationThreadDetailSnapshot,
type VcsListRefsResult,
} from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
Expand All @@ -7,6 +14,33 @@ import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-data
import { make } from "./environment-cache-store";

const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
const THREAD_ID = ThreadId.make("thread-1");
const THREAD_SNAPSHOT: OrchestrationThreadDetailSnapshot = {
snapshotSequence: 7,
thread: {
id: THREAD_ID,
projectId: ProjectId.make("project-1"),
title: "Cached thread",
modelSelection: { instanceId: ProviderInstanceId.make("muse"), model: "muse-spark-1.3" },
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
latestTurn: null,
createdAt: "2026-09-11T00:00:00.000Z",
updatedAt: "2026-09-11T00:00:00.000Z",
archivedAt: null,
settledOverride: null,
settledAt: null,
pullRequests: [],
deletedAt: null,
messages: [],
proposedPlans: [],
activities: [],
checkpoints: [],
session: null,
},
};
const REFS: VcsListRefsResult = {
refs: [
{
Expand Down Expand Up @@ -69,6 +103,56 @@ function makeDatabase() {
}

describe("mobile SQLite environment cache store", () => {
it.effect("discards v3 thread snapshots so historical activities are refetched", () =>
Effect.gen(function* () {
const memory = makeDatabase();
const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));
const id = cacheId(ENVIRONMENT_ID, "thread", THREAD_ID);
memory.values.set(
id,
JSON.stringify({
schemaVersion: 3,
environmentId: ENVIRONMENT_ID,
threadId: THREAD_ID,
snapshot: THREAD_SNAPSHOT,
}),
);

expect(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID)).toEqual(Option.none());
expect(memory.removed).toEqual([id]);
expect(memory.values.has(id)).toBe(false);
}),
);

it.effect("accepts v4 thread snapshots and round-trips refreshed history", () =>
Effect.gen(function* () {
const memory = makeDatabase();
const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));
memory.values.set(
cacheId(ENVIRONMENT_ID, "thread", THREAD_ID),
JSON.stringify({
schemaVersion: 4,
environmentId: ENVIRONMENT_ID,
threadId: THREAD_ID,
snapshot: THREAD_SNAPSHOT,
}),
);
expect(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID)).toEqual(
Option.some(THREAD_SNAPSHOT),
);

const refreshed = {
...THREAD_SNAPSHOT,
snapshotSequence: 8,
thread: { ...THREAD_SNAPSHOT.thread, title: "Refreshed thread" },
};
yield* store.saveThread(ENVIRONMENT_ID, refreshed);

expect(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID)).toEqual(Option.some(refreshed));
expect(memory.removed).toEqual([]);
}),
);

it.effect("round-trips schema-validated VCS refs", () =>
Effect.gen(function* () {
const memory = makeDatabase();
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
// makes pre-pagination clients discard the record instead of decoding a
// partial thread as complete (rollback safety).
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3;
// v4 refetches activities that previously lost Muse native item kinds during
// projection. Cursor-based resume cannot repair already cached historical rows.
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 4;
const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
const VCS_REFS_CACHE_SCHEMA_VERSION = 1;

Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set([
"claudeAgent",
"codex",
"antigravity",
"muse",
]);
/**
* Keep measured row changes stable, but let catalog mutations use the list's
Expand Down Expand Up @@ -178,6 +179,11 @@ function ProviderHeader(props: {
<>
<ProviderIcon provider={props.driver} size={15} />
<Text className="text-sm font-t3-medium text-foreground-muted">{props.label}</Text>
{props.driver === "muse" ? (
<View className="rounded-md bg-subtle-strong px-1.5 py-0.5">
<Text className="text-3xs font-t3-bold text-foreground-muted">Beta</Text>
</View>
) : null}
{props.collapsible ? (
<>
<View className="flex-1" />
Expand All @@ -200,7 +206,7 @@ function ProviderHeader(props: {
if (props.collapsible) {
return (
<Pressable
accessibilityLabel={`${props.label}, ${props.modelCount} models`}
accessibilityLabel={`${props.label}${props.driver === "muse" ? ", Beta" : ""}, ${props.modelCount} models`}
accessibilityRole="button"
accessibilityState={{ expanded: !props.collapsed }}
className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60"
Expand Down
92 changes: 92 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,98 @@ describe("mobile model options", () => {
expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled);
});

describe("Muse catalog changes", () => {
const selection: ModelSelection = {
instanceId: ProviderInstanceId.make("muse_work"),
model: "muse-spark-1.3-contributor",
options: [{ id: "reasoningEffort", value: "high" }],
};
const config = {
providers: [
{
instanceId: selection.instanceId,
driver: "muse",
displayName: "Muse Work",
enabled: true,
installed: true,
auth: { status: "unknown" },
models: [
{
slug: selection.model,
name: "Muse Spark 1.3 Contributor",
isCustom: false,
isDefault: true,
capabilities: {
optionDescriptors: [
{
id: "reasoningEffort",
label: "Reasoning",
type: "select",
options: [
{ id: "medium", label: "Medium", isDefault: true },
{ id: "high", label: "High" },
],
currentValue: "medium",
},
],
},
},
],
},
],
} as unknown as ServerConfig;

it.each([
["disabled", { enabled: false }],
["uninstalled", { installed: false }],
["unavailable", { availability: "unavailable" }],
["empty catalog", { models: [] }],
] as const)(
"keeps a saved model unavailable during %s and restores it on recovery",
(_state, update) => {
const changedConfig = {
...config,
providers: config.providers.map((provider) => ({ ...provider, ...update })),
};
expect(resolveSelectableModelSelection(changedConfig, selection)).toBe(selection);
expect(isModelSelectionUnavailable(changedConfig, selection)).toBe(true);
expect(buildModelOptions(changedConfig, null)).toEqual([]);
expect(buildModelOptions(changedConfig, selection)).toMatchObject([
{
providerKey: "muse_work",
providerDriver: "muse",
isUnavailable: true,
selection,
},
]);

const [restored] = buildModelOptions(config, selection);
expect(isModelSelectionUnavailable(config, selection)).toBe(false);
expect(restored?.isUnavailable).not.toBe(true);
expect(restored?.selection).toEqual(selection);
},
);

it("marks a model removed by refresh unavailable without changing the instance", () => {
const changedConfig = {
...config,
providers: config.providers.map((provider) => ({
...provider,
models: provider.models.map((model) => ({ ...model, slug: "muse-other-model" })),
})),
};
const options = buildModelOptions(changedConfig, selection);
expect(options.find((option) => option.selection.model === selection.model)).toMatchObject({
providerKey: "muse_work",
isUnavailable: true,
selection,
});
expect(
options.find((option) => option.selection.model === "muse-other-model")?.isUnavailable,
).not.toBe(true);
});
});

describe("Antigravity selections", () => {
const selection = {
instanceId: ProviderInstanceId.make("google_work"),
Expand Down
11 changes: 6 additions & 5 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function normalizeSelectionOptions(
};
}

/** Whether a known Antigravity selection needs setup or a different model. */
/** Whether a saved dynamic catalog selection needs setup or a different model. */
export function isModelSelectionUnavailable(
config: T3ServerConfig | null | undefined,
selection: ModelSelection | null | undefined,
Expand All @@ -75,7 +75,7 @@ export function isModelSelectionUnavailable(
const driver =
provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
return (
driver === "antigravity" &&
(driver === "antigravity" || driver === "muse") &&
(!provider ||
!provider.enabled ||
!provider.installed ||
Expand All @@ -86,7 +86,7 @@ export function isModelSelectionUnavailable(
}

/**
* Keep Antigravity selections when setup or catalog changes make them
* Keep Antigravity and Muse selections when setup or catalog changes make them
* unavailable. Other providers fall through to the server default when they
* are disabled, missing, or signed out. Without config, keep stored selections.
*/
Expand All @@ -102,7 +102,7 @@ export function resolveSelectableModelSelection(
);
const driver =
provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
if (driver === "antigravity") {
if (driver === "antigravity" || driver === "muse") {
return selection;
}
return provider &&
Expand Down Expand Up @@ -158,7 +158,8 @@ export function buildModelOptions(
!provider.enabled ||
!provider.installed ||
provider.auth.status === "unauthenticated" ||
(provider.driver === "antigravity" && provider.availability === "unavailable")
((provider.driver === "antigravity" || provider.driver === "muse") &&
provider.availability === "unavailable")
) {
continue;
}
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
import {
summarizeToolGroup,
workLogEntryIsToolLike,
} from "@t3tools/client-runtime/work-log/presentation";
import { beforeEach, describe, expect, it } from "vite-plus/test";

import {
Expand Down Expand Up @@ -291,6 +295,44 @@ function makeThread(
}

describe("buildThreadFeed", () => {
it("renders projected Muse reminder children as updates instead of tool use", () => {
const thread = makeThread({
id: ThreadId.make("muse-reminders"),
projectId: ProjectId.make("project-1"),
title: "Muse reminders",
activities: ["first", "second"].flatMap((itemId) =>
["started", "completed"].map((phase) =>
makeActivity({
id: EventId.make(`${itemId}-${phase}`),
kind: `tool.${phase}`,
tone: "tool",
summary: phase === "started" ? "reminderChild started" : "reminderChild",
createdAt: "2026-09-11T00:00:00.000Z",
payload: {
itemType: "dynamic_tool_call",
toolCallId: itemId,
status: phase === "started" ? "inProgress" : "completed",
title: "reminderChild",
detail: "Reminder child session",
data: { item: { kind: "reminderChild" } },
},
}),
),
),
});
const [group] = buildThreadFeed(thread);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
const entries = group.activities.map((activity) => activity.workEntry);
expect(entries).toHaveLength(2);
expect(entries.map((entry) => entry.detail)).toEqual([
"Reminder child session",
"Reminder child session",
]);
expect(entries.every((entry) => !workLogEntryIsToolLike(entry))).toBe(true);
expect(summarizeToolGroup(entries)).toBe("Received 2 updates");
});

it("reuses unchanged feed and presentation rows during an assistant text update", () => {
const completedTurnId = TurnId.make("completed-turn");
const activeTurnId = TurnId.make("active-turn");
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
if (toolPresentation.toolSource) {
entry.toolSource = toolPresentation.toolSource;
}
if (itemType === "mcp_tool_call") {
if (itemType === "mcp_tool_call" || itemType === "dynamic_tool_call") {
const data = asRecord(payload?.data);
const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item;
if (toolData !== undefined) {
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@ff-labs/fff-node": "0.9.4",
"@muse-code/sdk": "0.1.1",
"@opencode-ai/sdk": "^1.3.15",
"effect": "catalog:",
"msgpackr-extract": "3.0.4",
Expand Down
Loading
Loading