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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "./components/SettingsEnvironmentFilterHeader";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsControlRow } from "./components/SettingsControlRow";
import { AutoResumeMessageField } from "./components/AutoResumeMessageField";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
Expand Down Expand Up @@ -393,6 +394,44 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ enableAgentBrowserAccess: value })}
/>
</SettingsSection>
<SettingsSection title="Usage limits">
<FanoutSwitchRow
icon="clock"
label="Resume after usage limits"
subtitle={
projectSelected
? "Environment-wide setting. Select All projects to change it."
: "Send the resume message once a provider limit resets."
}
value={uniform("autoResumeAfterUsageLimit")}
disabled={disabledFor("autoResumeAfterUsageLimit")}
onValueChange={(value) => write({ autoResumeAfterUsageLimit: value })}
/>
<View className="border-t border-border-subtle">
<SettingsControlRow
icon="text.bubble"
label="Resume message"
disabled={disabledFor("autoResumeMessage")}
>
<AutoResumeMessageField
value={uniform("autoResumeMessage") ?? ""}
placeholder={isMixed("autoResumeMessage") ? "Mixed" : "go on"}
disabled={disabledFor("autoResumeMessage")}
onCommit={(value) => write({ autoResumeMessage: value })}
/>
</SettingsControlRow>
</View>
<View className="border-t border-border-subtle">
<FanoutSwitchRow
icon="bolt.circle"
label="Resume without fast mode"
subtitle="Nobody is waiting on a resumed thread."
value={uniform("autoResumeDisablesFastMode")}
disabled={disabledFor("autoResumeDisablesFastMode")}
onValueChange={(value) => write({ autoResumeDisablesFastMode: value })}
/>
</View>
</SettingsSection>
</>
) : null}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useState } from "react";

import { AppTextInput } from "../../../components/AppText";

/** Commits on blur or submit so each keystroke does not fan out a settings write. */
export function AutoResumeMessageField(props: {
readonly value: string;
readonly placeholder: string;
readonly disabled: boolean;
readonly onCommit: (value: string) => void;
}) {
const [draft, setDraft] = useState<string | null>(null);
const commit = () => {
const next = draft;
setDraft(null);
if (!props.disabled && next !== null && next !== props.value) props.onCommit(next);
};
return (
<AppTextInput
className="min-h-10 w-40 rounded-xl px-3 py-2 text-base"
returnKeyType="done"
autoCapitalize="none"
value={draft ?? props.value}
placeholder={props.placeholder}
onChangeText={setDraft}
onBlur={commit}
onSubmitEditing={commit}
accessibilityLabel="Resume message"
editable={!props.disabled}
/>
);
}
17 changes: 17 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { ComposerFeedback } from "./ComposerFeedback";
import { ComposerUsageLimits } from "./ComposerUsageLimits";
import { PendingUserInputCard } from "./PendingUserInputCard";
import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";
import { UsageLimitResumeCard } from "./UsageLimitResumeCard";
import {
FLOATING_WORKING_CONTROL_COVERAGE,
FloatingWorkingControl,
Expand Down Expand Up @@ -322,6 +323,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const windowHeight = useWindowDimensions().height;
const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + IOS_NAV_BAR_HEIGHT;
const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
const usageLimit = props.selectedThread.usageLimit;
const usageLimitWithReset =
usageLimit?.resetsAt != null ? { ...usageLimit, resetsAt: usageLimit.resetsAt } : null;
const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
useReadAloudLifecycle({ environmentId: props.environmentId, threadId: props.selectedThread.id });
const composerEditorRef = useRef<ComposerEditorHandle>(null);
Expand Down Expand Up @@ -1007,6 +1011,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
/>
</Animated.View>
) : null}
{usageLimitWithReset && activeUserInputRequestId === null ? (
<Animated.View
className="shrink-0 px-4 pb-3"
entering={FadeInDown.duration(220)}
exiting={FadeOut.duration(140)}
>
<UsageLimitResumeCard
environmentId={props.environmentId}
threadId={props.selectedThread.id}
usageLimit={usageLimitWithReset}
/>
</Animated.View>
) : null}
{props.creationState?.kind === "failed" ? (
<Animated.View
className="shrink-0 px-4"
Expand Down
54 changes: 54 additions & 0 deletions apps/mobile/src/features/threads/UsageLimitResumeCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { EnvironmentId, ThreadId, ThreadUsageLimit } from "@t3tools/contracts";
import { View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { RequestActionButton } from "./RequestActionButton";

const RESET_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
});
const RESET_DAY_FORMATTER = new Intl.DateTimeFormat(undefined, {
weekday: "short",
month: "numeric",
day: "numeric",
});

/** Weekly limits reset days out, so anything past today names the day. */
function formatReset(iso: string): string {
const date = new Date(iso);
const time = RESET_TIME_FORMATTER.format(date);
return date.toDateString() === new Date().toDateString()
? `at ${time}`
: `${RESET_DAY_FORMATTER.format(date)} at ${time}`;
}

/** Offers, or shows, the server-side resume of a thread stopped by a usage limit. */
export function UsageLimitResumeCard(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly usageLimit: ThreadUsageLimit & { readonly resetsAt: string };
}) {
const setAutoResume = useAtomCommand(threadEnvironment.setAutoResume, "auto-resume update");
const resetTime = formatReset(props.usageLimit.resetsAt);
const scheduled = props.usageLimit.resumeScheduled;
return (
<View className="flex-row items-center gap-3 rounded-[20px] border border-border-subtle bg-card-alt p-4">
<Text className="min-w-0 flex-1 font-sans text-sm leading-normal text-foreground-secondary">
{scheduled ? `Resumes automatically ${resetTime}.` : `The usage limit resets ${resetTime}.`}
</Text>
<RequestActionButton
label={scheduled ? "Cancel" : `Resume ${resetTime}`}
tone={scheduled ? "secondary" : "primary"}
onPress={() =>
void setAutoResume({
environmentId: props.environmentId,
input: { threadId: props.threadId, scheduled: !scheduled },
})
}
/>
</View>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
} from "../src/orchestration/Services/OrchestrationEngine.ts";
import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
import * as UsageLimitResumeReactor from "../src/orchestration/UsageLimitResumeReactor.ts";
import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts";
import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";
import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
Expand Down Expand Up @@ -408,6 +409,12 @@ export const makeOrchestrationIntegrationHarness = (
drain: Effect.void,
}),
),
Layer.provideMerge(
Layer.succeed(UsageLimitResumeReactor.UsageLimitResumeReactor, {
start: () => Effect.void,
drain: Effect.void,
}),
),
Layer.provideMerge(
Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, {
start: () => Effect.void,
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
import * as UsageLimitResumeReactor from "../UsageLimitResumeReactor.ts";
import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
Expand Down Expand Up @@ -95,6 +96,15 @@ describe("OrchestrationReactor", () => {
drain: Effect.void,
}),
),
Layer.provideMerge(
Layer.succeed(UsageLimitResumeReactor.UsageLimitResumeReactor, {
start: () => {
started.push("usage-limit-resume-reactor");
return Effect.void;
},
drain: Effect.void,
}),
),
Layer.provideMerge(
Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, {
start: () => {
Expand Down Expand Up @@ -128,6 +138,7 @@ describe("OrchestrationReactor", () => {
"thread-deletion-reactor",
"thread-pull-request-reactor",
"thread-settlement-reactor",
"usage-limit-resume-reactor",
"pull-request-sync-reactor",
"agent-awareness-relay",
"storage-cleanup",
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
import * as UsageLimitResumeReactor from "../UsageLimitResumeReactor.ts";
import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
Expand All @@ -21,6 +22,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
const checkpointReactor = yield* CheckpointReactor;
const threadDeletionReactor = yield* ThreadDeletionReactor;
const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
const usageLimitResumeReactor = yield* UsageLimitResumeReactor.UsageLimitResumeReactor;
const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;
const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
Expand All @@ -33,6 +35,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
yield* threadDeletionReactor.start();
yield* threadPullRequestReactor.start();
yield* threadSettlementReactor.start();
yield* usageLimitResumeReactor.start();
yield* pullRequestSyncReactor.start();
yield* agentAwarenessRelay.start();
yield* storageCleanup.start();
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
unsettledAt: null,
snoozedUntil: null,
snoozedAt: null,
usageLimit: null,
pinnedAt: null,
pinOrderKey: null,
activeOrderKey: null,
Expand Down Expand Up @@ -748,6 +749,35 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
return;
}

case "thread.usage-limit-set": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
usageLimit: event.payload.usageLimit,
});
return;
}

case "thread.auto-resume-set": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
const usageLimit = Option.isSome(existingRow) ? existingRow.value.usageLimit : null;
if (Option.isNone(existingRow) || usageLimit == null) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
usageLimit: { ...usageLimit, resumeScheduled: event.payload.scheduled },
});
return;
}

case "thread.pinned": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
unsettledAt: null,
snoozedUntil: null,
snoozedAt: null,
usageLimit: null,
pinnedAt: "2026-02-24T00:00:01.000Z",
pinOrderKey: "gm",
activeOrderKey: "hq",
Expand Down Expand Up @@ -608,6 +609,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
unsettledAt: null,
snoozedUntil: null,
snoozedAt: null,
usageLimit: null,
pinnedAt: "2026-02-24T00:00:01.000Z",
pinOrderKey: "gm",
activeOrderKey: "hq",
Expand Down Expand Up @@ -744,6 +746,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
projectId: asProjectId("project-1"),
title: "Thread 1",
titleState: null,
usageLimit: null,
session: snapshot.threads[0]?.session ?? null,
});
}
Expand Down
Loading
Loading