Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
89b0d91
feat(auth): separate environment administration scopes
juliusmarminge Sep 4, 2026
1be8e08
fix(web): explain maintenance permissions with tooltips
juliusmarminge Sep 4, 2026
11778e3
fix(auth): respect independent environment grants in settings
juliusmarminge Sep 4, 2026
519dfd5
fix(mobile): wait for grants before selecting shared settings
juliusmarminge Sep 4, 2026
0f6e229
fix(usage): require settings scope for model price edits
juliusmarminge Sep 4, 2026
6ecbb1e
fix(web): brand relay targets before checking scopes
juliusmarminge Sep 4, 2026
c75545a
fix(settings): explain unavailable environment controls
juliusmarminge Sep 4, 2026
55cf215
fix(auth): preserve self-updates across scope migration
juliusmarminge Sep 4, 2026
3d5610b
fix(web): preserve untouched script shortcuts on save
juliusmarminge Sep 4, 2026
d1809b2
fix(web): initialize shortcuts when adding scripts
juliusmarminge Sep 5, 2026
97fc24a
fix(web): allow relay unlink after link-state failure
juliusmarminge Sep 5, 2026
c7c40a0
fix(web): sync shared settings before remote grants load
juliusmarminge Sep 5, 2026
80987e5
test(web): type failed shared settings grants
juliusmarminge Sep 5, 2026
b9e37df
fix(web): resolve script shortcuts in the active environment
juliusmarminge Sep 5, 2026
218cb65
fix(server): choose enabled instances for text generation
juliusmarminge Sep 5, 2026
d950aaa
fix(web): preserve retained shortcuts when re-adding actions
juliusmarminge Sep 5, 2026
3b55f5a
fix(server): preserve provider fallback tuple types
juliusmarminge Sep 5, 2026
0d80cc4
fix(server): keep effective provider fallback entries typed
juliusmarminge Sep 5, 2026
eb95284
fix(web): update settings controls when grants change
juliusmarminge Sep 5, 2026
c54ff76
fix(mobile): respect relay access when changing Live Activities
juliusmarminge Sep 5, 2026
ee34e7d
fix(web): gate project settings by checkout permissions
juliusmarminge Sep 5, 2026
4416ff7
fix(web): recheck thread mutation permissions at dispatch
juliusmarminge Sep 5, 2026
76bfecb
fix(web): guard project settings command dispatch
juliusmarminge Sep 5, 2026
0d6eccb
fix(mobile): gate new task submission by task permissions
juliusmarminge Sep 5, 2026
6c12d5f
fix(web): gate composer actions and uploads by thread permissions
juliusmarminge Sep 5, 2026
8ec1545
fix(mobile): gate thread list mutations by task permissions
juliusmarminge Sep 5, 2026
a6e4f09
fix(web): gate legacy sidebar project and thread actions
juliusmarminge Sep 5, 2026
83b51e4
fix(mobile): gate task controls and queued delivery
juliusmarminge Sep 5, 2026
4b3cdd0
fix(web): respect scopes in editor and markdown actions
juliusmarminge Sep 5, 2026
5d83ddc
fix(mobile): preserve queued tasks after access changes
juliusmarminge Sep 5, 2026
d09ac2b
docs: clarify grouped project permissions
juliusmarminge Sep 5, 2026
9a80e32
test(web): keep task permission fixtures within their layer
juliusmarminge Sep 5, 2026
5e93f3e
test(clients): correct permission fixture types
juliusmarminge Sep 5, 2026
cd9c9d2
fix(web): report revoked settings access accurately
juliusmarminge Sep 5, 2026
d511bd7
test(web): omit unset project patch fields
juliusmarminge Sep 5, 2026
151f387
test(web): provide grants in legacy Markdown fixtures
juliusmarminge Sep 5, 2026
3270705
fix(web): preserve typed authorization failures
juliusmarminge Sep 5, 2026
8a1df24
fix(mobile): preserve typed authorization failures
juliusmarminge Sep 5, 2026
5d802e8
fix(web): gate onboarding imports by task permissions
juliusmarminge Sep 5, 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
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));

vi.mock("../cloud/linkEnvironment", () => ({
vi.mock("expo-constants", () => ({
default: { expoConfig: {} },
}));

vi.mock("expo-device", () => ({}));

vi.mock("../cloud/linkEnvironment", async (importOriginal) => ({
...(await importOriginal<typeof import("../cloud/linkEnvironment")>()),
linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void),
}));

Expand Down Expand Up @@ -78,6 +85,7 @@ describe("liveActivityPreferences", () => {
previousEnabled: true,
clerkToken: "clerk-token",
connections: [connection],
canConfigureEnvironment: () => true,
});

expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
Expand All @@ -98,6 +106,7 @@ describe("liveActivityPreferences", () => {
previousEnabled: false,
clerkToken: "clerk-token",
connections: [connection],
canConfigureEnvironment: () => true,
});

expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
Expand All @@ -118,6 +127,7 @@ describe("liveActivityPreferences", () => {
previousEnabled: true,
clerkToken: null,
connections: [connection],
canConfigureEnvironment: () => true,
});

expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
Expand All @@ -139,6 +149,7 @@ describe("liveActivityPreferences", () => {
previousEnabled: false,
clerkToken: "clerk-token",
connections: [connection, managedConnection],
canConfigureEnvironment: () => true,
});

expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1);
Expand All @@ -162,6 +173,7 @@ describe("liveActivityPreferences", () => {
previousEnabled: true,
clerkToken: "clerk-token",
connections: [connection],
canConfigureEnvironment: () => true,
}),
);

Expand All @@ -184,4 +196,105 @@ describe("liveActivityPreferences", () => {
});
}).pipe(Effect.provide(testLayer));
});

it.effect("only re-links environments with relay access when enabling updates", () => {
const readOnlyConnection: SavedRemoteConnection = {
...connection,
environmentId: "read-only" as EnvironmentId,
};

return Effect.gen(function* () {
yield* setLiveActivityUpdatesEnabled({
enabled: true,
previousEnabled: false,
clerkToken: "clerk-token",
connections: [readOnlyConnection, connection],
canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId,
});

expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
liveActivitiesEnabled: true,
});
expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledExactlyOnceWith({
clerkToken: "clerk-token",
connection,
liveActivitiesEnabled: true,
});
}).pipe(Effect.provide(testLayer));
});

it.effect("keeps device updates independently switchable without relay access", () =>
Effect.gen(function* () {
for (const enabled of [false, true]) {
yield* setLiveActivityUpdatesEnabled({
enabled,
previousEnabled: !enabled,
clerkToken: "clerk-token",
connections: [connection],
canConfigureEnvironment: () => false,
});
}

expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, {
liveActivitiesEnabled: false,
});
expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, {
liveActivitiesEnabled: true,
});
expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled();
}).pipe(Effect.provide(testLayer)),
);

it.effect("checks current relay access after updating the device registration", () => {
let canConfigureEnvironment = true;
vi.mocked(updateAgentAwarenessRegistrationPreferences).mockImplementationOnce(() =>
Effect.sync(() => {
canConfigureEnvironment = false;
}),
);

return Effect.gen(function* () {
yield* setLiveActivityUpdatesEnabled({
enabled: false,
previousEnabled: true,
clerkToken: "clerk-token",
connections: [connection],
canConfigureEnvironment: () => canConfigureEnvironment,
});

expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled();
expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1);
}).pipe(Effect.provide(testLayer));
});

it.effect("does not retry environment changes after relay access is revoked", () => {
let canConfigureEnvironment = true;
vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() =>
Effect.sync(() => {
canConfigureEnvironment = false;
}).pipe(
Effect.andThen(
Effect.fail(new CloudEnvironmentLinkError({ message: "relay access revoked" })),
),
),
);

return Effect.gen(function* () {
const exit = yield* Effect.exit(
setLiveActivityUpdatesEnabled({
enabled: false,
previousEnabled: true,
clerkToken: "clerk-token",
connections: [connection],
canConfigureEnvironment: () => canConfigureEnvironment,
}),
);

expect(exit._tag).toBe("Failure");
expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1);
expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, {
liveActivitiesEnabled: true,
});
}).pipe(Effect.provide(testLayer));
});
});
29 changes: 18 additions & 11 deletions apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Effect from "effect/Effect";
import type { EnvironmentId } from "@t3tools/contracts";

import type { SavedRemoteConnection } from "../../lib/connection";
import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment";
Expand All @@ -10,11 +11,26 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
readonly previousEnabled: boolean;
readonly clerkToken: string | null;
readonly connections: ReadonlyArray<SavedRemoteConnection>;
readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean;
}) {
const linkedConnections = input.connections.filter(
(connection) => connection.bearerToken !== null,
);

const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* (
connection: SavedRemoteConnection,
enabled: boolean,
clerkToken: string,
) {
if (!input.canConfigureEnvironment(connection.environmentId)) return;

yield* linkEnvironmentToCloudWithPreference({
clerkToken,
connection,
liveActivitiesEnabled: enabled,
});
});

const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) {
yield* updateAgentAwarenessRegistrationPreferences({
liveActivitiesEnabled: enabled,
Expand All @@ -25,12 +41,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn

yield* Effect.forEach(
linkedConnections,
(connection) =>
linkEnvironmentToCloudWithPreference({
clerkToken,
connection,
liveActivitiesEnabled: enabled,
}),
(connection) => updateEnvironmentPreference(connection, enabled, clerkToken),
{ concurrency: "unbounded" },
);
});
Expand All @@ -50,11 +61,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
yield* Effect.forEach(
linkedConnections,
(connection) =>
linkEnvironmentToCloudWithPreference({
clerkToken,
connection,
liveActivitiesEnabled: input.previousEnabled,
}).pipe(
updateEnvironmentPreference(connection, input.previousEnabled, clerkToken).pipe(
Effect.catchCause((cause) =>
Effect.logWarning(
`Could not restore Live Activity preference for environment ${connection.environmentId}.`,
Expand Down
115 changes: 62 additions & 53 deletions apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
} from "@t3tools/client-runtime/state/shell";
import { LegendList } from "@legendapp/list/react-native";
import {
AuthOrchestrationOperateScope,
type EnvironmentId,
type EnvironmentMachineKind,
resolveEnvironmentMachineKind,
Expand Down Expand Up @@ -34,6 +35,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { relativeTime } from "../../lib/time";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useServerConfigs } from "../../state/entities";
import { useEnvironmentScope } from "../../state/session";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import {
createNativeMailSearchToolbarItem,
Expand Down Expand Up @@ -416,22 +418,72 @@ function ArchivedThreadRow(props: {
}) {
const { width: windowWidth } = useWindowDimensions();
const cardColor = useUniwindTheme()["--color-card"];
const canOperateThread = useEnvironmentScope(
props.thread.environmentId,
AuthOrchestrationOperateScope,
);
const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
Boolean(part),
);
const rowContent = (
<View
className={`flex-row items-center gap-3 bg-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`}
>
<View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
<SymbolView
name="archivebox.fill"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
/>
</View>

<View className="min-w-0 flex-1 gap-1">
<View className="flex-row items-center gap-2">
<Text
className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground"
numberOfLines={1}
>
{props.thread.title}
</Text>
<Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary">
{timestamp}
</Text>
</View>
{subtitle.length > 0 ? (
<View className="flex-row items-center gap-1.5">
<SymbolView
name="arrow.triangle.branch"
size={10}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
/>
<Text
className="min-w-0 flex-1 font-mono text-2xs text-foreground-tertiary"
numberOfLines={1}
>
{subtitle.join(" · ")}
</Text>
</View>
) : null}
</View>
</View>
);
// Keep the group's rounded corners on both interactive and read-only rows.
const containerStyle = {
borderTopLeftRadius: props.isFirst ? 20 : 0,
borderTopRightRadius: props.isFirst ? 20 : 0,
borderBottomLeftRadius: props.isLast ? 20 : 0,
borderBottomRightRadius: props.isLast ? 20 : 0,
overflow: "hidden" as const,
};
if (!canOperateThread) return <View style={containerStyle}>{rowContent}</View>;

return (
<ThreadSwipeable
backgroundColor={cardColor}
// Round + clip the swipeable container so the group's corners stay
// rounded while rows swipe; the row itself stays square inside.
containerStyle={{
borderTopLeftRadius: props.isFirst ? 20 : 0,
borderTopRightRadius: props.isFirst ? 20 : 0,
borderBottomLeftRadius: props.isLast ? 20 : 0,
borderBottomRightRadius: props.isLast ? 20 : 0,
overflow: "hidden",
}}
containerStyle={containerStyle}
fullSwipeWidth={windowWidth - 32}
onDelete={props.onDelete}
onSwipeableClose={props.onSwipeableClose}
Expand All @@ -445,50 +497,7 @@ function ArchivedThreadRow(props: {
simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
threadTitle={props.thread.title}
>
{() => (
<View
className={`flex-row items-center gap-3 bg-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`}
>
<View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
<SymbolView
name="archivebox.fill"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
/>
</View>

<View className="min-w-0 flex-1 gap-1">
<View className="flex-row items-center gap-2">
<Text
className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground"
numberOfLines={1}
>
{props.thread.title}
</Text>
<Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary">
{timestamp}
</Text>
</View>
{subtitle.length > 0 ? (
<View className="flex-row items-center gap-1.5">
<SymbolView
name="arrow.triangle.branch"
size={10}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
/>
<Text
className="min-w-0 flex-1 font-mono text-2xs text-foreground-tertiary"
numberOfLines={1}
>
{subtitle.join(" · ")}
</Text>
</View>
) : null}
</View>
</View>
)}
{() => rowContent}
</ThreadSwipeable>
);
}
Expand Down
Loading
Loading