Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1db9915
feat: sticky project access mode with machine default
JustMarkDev Sep 8, 2026
7bcd1c9
Merge branch 'main' into project-default-runtime-mode
JustMarkDev Sep 8, 2026
2cf0041
fix(web): stop sticky from shadowing Default access
JustMarkDev Sep 8, 2026
e6ca378
refactor: rename sticky runtime mode to last-used
JustMarkDev Sep 8, 2026
cd322f2
Merge branch 'main' into project-default-runtime-mode
JustMarkDev Sep 8, 2026
dc470f7
fix: honor draft runtime mode and logical project last-used keys
JustMarkDev Sep 8, 2026
9c3e7a3
Merge branch 'main' into project-default-runtime-mode
JustMarkDev Sep 8, 2026
c8d3ef9
fix(web): widen last-used runtime mode mock return type
JustMarkDev Sep 8, 2026
8be0faa
Merge branch 'main' into project-default-runtime-mode
JustMarkDev Sep 8, 2026
921fed6
Merge branch 'main' into project-default-runtime-mode
JustMarkDev Sep 8, 2026
8f8bf59
fix(mobile): allow runtime mode draft updates without project metadata
JustMarkDev Sep 8, 2026
b29d2f9
fix(web): carry viewed access mode into new pull-request drafts
JustMarkDev Sep 8, 2026
d41c637
Merge branch 'main' into project-default-runtime-mode
JustMarkDev Sep 8, 2026
f91e14a
fix(web): satisfy exactOptionalPropertyTypes for PR draft mode
JustMarkDev Sep 8, 2026
a9f729b
fix(web): cancel pending rAF stubs before unstubbing globals
JustMarkDev Sep 9, 2026
02a2456
Revert "fix(web): cancel pending rAF stubs before unstubbing globals"
JustMarkDev Sep 9, 2026
5b799db
refactor: restore sticky naming for remembered access mode
JustMarkDev Sep 9, 2026
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
26 changes: 22 additions & 4 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {
import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
MessageId,
T3_PROJECT_FILE_NAME,
ThreadId,
Expand All @@ -36,6 +35,7 @@ import {
resolveNewTaskModelSelection,
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import { deriveLogicalProjectKeyFromSettings } from "@t3tools/client-runtime/state/project-grouping";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { appAtomRegistry } from "../../state/atom-registry";
import { projectEnvironment } from "../../state/projects";
Expand All @@ -54,10 +54,13 @@ import {
scheduleUnusedComposerAttachmentCleanup,
setComposerDraftText,
setStickyComposerModelSelection,
setStickyComposerRuntimeMode,
updateComposerDraftSettings,
useComposerDraft,
useStickyComposerModelSelection,
useStickyComposerRuntimeMode,
} from "../../state/use-composer-drafts";
import { resolveNewThreadRuntimeMode } from "@t3tools/shared/runtimeMode";
import {
capturePendingTaskEditorWriteBaseline,
flushPendingTaskEditorWrite,
Expand Down Expand Up @@ -452,7 +455,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
draftStartFromOrigin ??
selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
true;
const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;

// Antigravity keeps unavailable selections so sign-out or a catalog change
// cannot switch the user's model. Other providers retain their fallback
Expand All @@ -472,6 +474,17 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedEnvironmentServerConfig,
storedStickyModelSelection,
);
// Sticky access mode is keyed by logical project identity (same as web),
// so equivalent repo instances share the preference across grouping modes.
const selectedLogicalProjectKey = selectedProject
? deriveLogicalProjectKeyFromSettings(selectedProject, groupingSettings)
: null;
const stickyRuntimeMode = useStickyComposerRuntimeMode(selectedLogicalProjectKey);
const runtimeMode = resolveNewThreadRuntimeMode({
draftRuntimeMode: selectedProjectDraft.runtimeMode,
stickyRuntimeMode,
configuredRuntimeMode: selectedEnvironmentServerConfig?.settings.defaultRuntimeMode,
});
const modelOptions = useMemo(
() =>
buildModelOptions(
Expand Down Expand Up @@ -875,8 +888,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (selectedProjectDraftKey) {
updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value });
}
if (selectedLogicalProjectKey) {
setStickyComposerRuntimeMode(selectedLogicalProjectKey, value);
}
},
[selectedProjectDraftKey],
[selectedLogicalProjectKey, selectedProjectDraftKey],
);
const setInteractionMode = useCallback(
(value: ProviderInteractionMode) => {
Expand Down Expand Up @@ -965,7 +981,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
text,
attachments: draft.attachments,
modelSelection: draftModelSelection,
runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,
runtimeMode,
interactionMode: resolvePendingTaskInteractionMode({
preferenceLoaded: planModePreferenceLoaded,
planModeEnabled: legacyPlanModeEnabled,
Expand Down Expand Up @@ -1007,8 +1023,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedModel,
selectedProject,
selectedProjectDraftKey,
selectedProjectKey,
legacyPlanModeEnabled,
planModePreferenceLoaded,
runtimeMode,
startFromOrigin,
workspaceMode,
],
Expand Down
44 changes: 44 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,9 @@ import {
setComposerDraftAttachmentUpload,
waitForComposerDraftsLoaded,
setStickyComposerModelSelection,
setStickyComposerRuntimeMode,
stickyComposerModelSelectionAtom,
stickyComposerRuntimeModeByProjectKeyAtom,
undoComposerDraftMerge,
undoComposerDraftMergeState,
} from "./use-composer-drafts";
Expand All @@ -202,6 +204,7 @@ afterEach(() => {
appAtomRegistry.set(composerDraftsAtom, {});
appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, {});
appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
composerAttachmentCleanupMocks.remove.mockClear();
composerAttachmentCleanupMocks.releaseUploads.mockReset();
Expand All @@ -210,6 +213,47 @@ afterEach(() => {
incomingShareStorageMocks.load.mockResolvedValue([]);
});

describe("mobile sticky runtime mode", () => {
it("round-trips sticky runtime modes per project", () => {
expect(
decodePersistedComposerState({
schemaVersion: 1,
drafts: {},
stickyRuntimeModeByProjectKey: {
"env:project-a": "approval-required",
"env:project-b": "auto-accept-edits",
},
}).stickyRuntimeModeByProjectKey,
).toEqual({
"env:project-a": "approval-required",
"env:project-b": "auto-accept-edits",
});
});

it("reads an intermediate lastUsedRuntimeModeByProjectKey as sticky", () => {
expect(
decodePersistedComposerState({
schemaVersion: 1,
drafts: {},
lastUsedRuntimeModeByProjectKey: {
"env:project-a": "approval-required",
},
}).stickyRuntimeModeByProjectKey,
).toEqual({
"env:project-a": "approval-required",
});
});

it("stores sticky runtime mode updates in memory", () => {
setStickyComposerRuntimeMode("env:project-a", "approval-required");
expect(appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)).toEqual({
"env:project-a": "approval-required",
});
setStickyComposerRuntimeMode("env:project-a", null);
expect(appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)).toEqual({});
});
});

describe("mobile composer drafts", () => {
// Hydration is one-shot per module instance and the attachment sweep now
// triggers it too, so this test must observe it before any sweep test runs.
Expand Down
89 changes: 89 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ const PersistedComposerDraftsSchema = Schema.Struct({
schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),
drafts: Schema.Record(Schema.String, ComposerDraftSchema),
stickyModelSelection: Schema.optional(ModelSelectionSchema),
stickyRuntimeModeByProjectKey: Schema.optional(Schema.Record(Schema.String, RuntimeModeSchema)),
lastUsedRuntimeModeByProjectKey: Schema.optional(Schema.Record(Schema.String, RuntimeModeSchema)),
cloudAccountId: Schema.optional(Schema.String),
signedOutDrafts: Schema.optional(
Schema.Record(
Expand Down Expand Up @@ -158,6 +160,10 @@ export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null>
Atom.withLabel("mobile:sticky-composer-model-selection"),
);

export const stickyComposerRuntimeModeByProjectKeyAtom = Atom.make<
Partial<Record<string, RuntimeMode>>
>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-runtime-mode-by-project"));

interface SignedOutDrafts {
readonly drafts: Record<string, ComposerDraft>;
readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
Expand Down Expand Up @@ -273,6 +279,7 @@ export function migrateLegacyNewTaskDraft(
export function decodePersistedComposerState(value: unknown): {
readonly drafts: Record<string, ComposerDraft>;
readonly stickyModelSelection: ModelSelection | null;
readonly stickyRuntimeModeByProjectKey: Partial<Record<string, RuntimeMode>>;
readonly cloudDrafts: ComposerCloudDraftState;
} {
const parsed = decodePersistedComposerDraftsDocument(value);
Expand Down Expand Up @@ -307,6 +314,9 @@ export function decodePersistedComposerState(value: unknown): {
.filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0),
),
stickyModelSelection: parsed.stickyModelSelection ?? null,
stickyRuntimeModeByProjectKey: normalizeStickyRuntimeModeByProjectKey(
parsed.stickyRuntimeModeByProjectKey ?? parsed.lastUsedRuntimeModeByProjectKey,
),
cloudDrafts: {
accountId: parsed.cloudAccountId ?? null,
signedOut: Object.fromEntries(
Expand All @@ -328,6 +338,23 @@ export function decodePersistedComposerState(value: unknown): {
};
}

function normalizeStickyRuntimeModeByProjectKey(
value: Partial<Record<string, RuntimeMode>> | undefined,
): Partial<Record<string, RuntimeMode>> {
if (!value) {
return {};
}
const next: Partial<Record<string, RuntimeMode>> = {};
for (const [projectKey, runtimeMode] of Object.entries(value)) {
const key = projectKey.trim();
if (key.length === 0 || runtimeMode === undefined) {
continue;
}
next[key] = runtimeMode;
}
return next;
}

async function getComposerDraftsFile() {
const { Directory, File, Paths } = await import("expo-file-system");
const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY);
Expand All @@ -345,6 +372,7 @@ async function loadPersistedComposerState(): Promise<
return {
drafts: {},
stickyModelSelection: null,
stickyRuntimeModeByProjectKey: {},
cloudDrafts: { accountId: null, signedOut: {} },
};
}
Expand All @@ -365,6 +393,9 @@ async function loadPersistedComposerState(): Promise<
async function writePersistedComposerState(
drafts: Record<string, ComposerDraft>,
stickyModelSelection: ModelSelection | null,
stickyRuntimeModeByProjectKey: Partial<Record<string, RuntimeMode>> = appAtomRegistry.get(
stickyComposerRuntimeModeByProjectKeyAtom,
),
cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom),
): Promise<void> {
let operation: ComposerDraftPersistenceError["operation"] = "open";
Expand All @@ -374,10 +405,18 @@ async function writePersistedComposerState(
const nonEmptyDrafts = Object.fromEntries(
Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)),
);
const stickyRuntimeModes = Object.fromEntries(
Object.entries(stickyRuntimeModeByProjectKey).filter(
(entry): entry is [string, RuntimeMode] => entry[1] !== undefined,
),
);
const document = {
schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
drafts: nonEmptyDrafts,
...(stickyModelSelection ? { stickyModelSelection } : {}),
...(Object.keys(stickyRuntimeModes).length > 0
? { stickyRuntimeModeByProjectKey: stickyRuntimeModes }
: {}),
...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}),
...(Object.keys(cloudDrafts.signedOut).length > 0
? {
Expand Down Expand Up @@ -629,6 +668,7 @@ function schedulePersistComposerState(): void {
await writePersistedComposerState(
appAtomRegistry.get(composerDraftsAtom),
appAtomRegistry.get(stickyComposerModelSelectionAtom),
appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom),
);
persistRetryNeeded = false;
} catch (error) {
Expand Down Expand Up @@ -661,6 +701,13 @@ export function ensureComposerDraftsLoaded(): void {
) {
appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection);
}
if (Object.keys(persisted.stickyRuntimeModeByProjectKey).length > 0) {
const current = appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom);
appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, {
...persisted.stickyRuntimeModeByProjectKey,
...current,
});
}
});
loadPromise = loading;
// Handle fire-and-forget hook loads without swallowing failures from the
Expand Down Expand Up @@ -892,6 +939,37 @@ export function setStickyComposerModelSelection(modelSelection: ModelSelection):
schedulePersistComposerState();
}

export function getStickyComposerRuntimeMode(projectKey: string): RuntimeMode | null {
const key = projectKey.trim();
if (key.length === 0) {
return null;
}
return appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)[key] ?? null;
}

export function setStickyComposerRuntimeMode(
projectKey: string,
runtimeMode: RuntimeMode | null | undefined,
): void {
const key = projectKey.trim();
if (key.length === 0) {
return;
}
const current = appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom);
const nextMode = runtimeMode ?? null;
if ((current[key] ?? null) === nextMode) {
return;
}
const next = { ...current };
if (nextMode === null) {
delete next[key];
} else {
next[key] = nextMode;
}
appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, next);
schedulePersistComposerState();
}

export function setComposerDraftText(draftKey: string, value: string): void {
updateComposerDrafts((current) => {
const draft = {
Expand Down Expand Up @@ -1491,3 +1569,14 @@ export function useStickyComposerModelSelection(): ModelSelection | null {
}, []);
return selection;
}

export function useStickyComposerRuntimeMode(projectKey: string | null): RuntimeMode | null {
const stickyByProject = useAtomValue(stickyComposerRuntimeModeByProjectKeyAtom);
useEffect(() => {
ensureComposerDraftsLoaded();
}, []);
if (!projectKey) {
return null;
}
return stickyByProject[projectKey] ?? null;
}
14 changes: 13 additions & 1 deletion apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
pickComposerMedia,
} from "../lib/composerImages";
import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { deriveLogicalProjectKeyFromSettings } from "@t3tools/client-runtime/state/project-grouping";
import { scopedThreadKey } from "../lib/scopedEntities";
import { buildThreadFeed } from "../lib/threadActivity";
import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";
Expand All @@ -48,6 +49,7 @@ import {
removeComposerDraftAttachment,
scheduleUnusedComposerAttachmentCleanup,
setComposerDraftText,
setStickyComposerRuntimeMode,
updateComposerDraftSettings,
useComposerDraft,
} from "./use-composer-drafts";
Expand All @@ -62,6 +64,7 @@ import {
composerAttachmentUploadBlockReason,
composerAttachmentUploadsAtom,
} from "./composer-attachment-uploads";
import { useMobileProjectGroupingSettings } from "./project-grouping";

export function appendReviewCommentToDraft(input: {
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -105,8 +108,10 @@ export function useThreadComposerState() {
const {
selectedThread: selectedThreadShell,
selectedThreadCreation,
selectedThreadProject,
selectedEnvironmentRuntime,
} = useThreadSelection();
const projectGroupingSettings = useMobileProjectGroupingSettings();
const selectedThreadDetail = useSelectedThreadDetail();
const composerDrafts = useAtomValue(composerDraftsAtom);
const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom);
Expand Down Expand Up @@ -590,8 +595,15 @@ export function useThreadComposerState() {
return;
}
updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value });
if (!selectedThreadProject) {
return;
}
setStickyComposerRuntimeMode(
deriveLogicalProjectKeyFromSettings(selectedThreadProject, projectGroupingSettings),
value,
);
},
[selectedThreadKey],
[projectGroupingSettings, selectedThreadKey, selectedThreadProject],
);

const onUpdateInteractionMode = useCallback(
Expand Down
Loading
Loading