Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/mobile/src/connection/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const layerProvidedClientConnection = layerSnapshotLoader.pipe(
Connection.layerWithOptions({
usageLimitSources: true,
memoryPressure: true,
quietRuns: true,
usageLimitsCommand: true,
}),
),
Expand Down
72 changes: 71 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,11 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { orchestrationEnvironment } from "../../state/orchestration";
import { useDelayedStatus } from "../../lib/useDelayedStatus";
import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input";
import type { RunStopPresentation } from "@t3tools/client-runtime/state/thread-execution";
import {
quietRunId,
quietRunSince,
type RunStopPresentation,
} from "@t3tools/client-runtime/state/thread-execution";
import type {
PendingApproval,
PendingUserInput,
Expand Down Expand Up @@ -550,6 +554,12 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
const canForceStop =
useAtomValue(orchestrationEnvironment.forceStopThread.permissionAtom(props.environmentId)) &&
props.serverConfig?.environment.capabilities.threadForceStop === true;
// The run the server saw go quiet, not the latest one: a queued follow-up can be newer.
const quietRunTarget = quietRunId(props.serverConfig, props.selectedThread) ?? undefined;
const quietSince =
props.activeWorkStartedAt === null
? null
: quietRunSince(props.serverConfig, props.selectedThread);
const [forceStoppingKey, setForceStoppingKey] = useState<string | null>(null);
// The run that ignored Stop, so a confirmation left open cannot hit a newer run.
const forceStop = async (runId: RunId | undefined) => {
Expand Down Expand Up @@ -580,6 +590,26 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
};
const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null);
const statusPending = statusRequest?.threadKey === selectedThreadKey;
const stopQuietRun = async () => {
if (statusPending || !props.canOperateThread) return;
const request = { threadKey: selectedThreadKey };
setStatusRequest(request);
const result = await interruptThreadTurn({
environmentId: props.environmentId,
input:
quietRunTarget === undefined
? { threadId: props.selectedThread.id }
: { threadId: props.selectedThread.id, runId: quietRunTarget },
});
setStatusRequest((current) => (current === request ? null : current));
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Dialog.alert(
"Could not stop the turn",
error instanceof Error ? error.message : "Try again.",
);
}
};
const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString());
useFocusEffect(
useCallback(() => {
Expand Down Expand Up @@ -723,6 +753,46 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
},
};
}
if (quietSince !== null && contentPresentationKind === "ready") {
return {
kind: "quiet",
quietSince,
action: {
label:
forceStoppingKey === selectedThreadKey
? "Force stopping..."
: statusPending
? "Stopping..."
: "Stop",
accessibilityLabel: "Stop the quiet turn",
disabled:
!props.canOperateThread || statusPending || forceStoppingKey === selectedThreadKey,
onPress: () =>
Dialog.alert(
"Stop this turn?",
canForceStop
? "The agent has sent nothing for a while. A long tool may still be running. Force stop ends the turn and kills the agent's processes, unless other working threads share them."
: "The agent has sent nothing for a while. A long tool may still be running.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Stop",
onPress: () => void stopQuietRun(),
},
...(canForceStop
? [
{
text: "Force stop",
style: "destructive" as const,
onPress: () => void forceStop(quietRunTarget),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
]
: []),
],
),
},
};
}
if (props.isCompacting && contentPresentationKind === "ready") {
return { kind: "compacting" };
}
Expand Down
29 changes: 25 additions & 4 deletions apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ export function FloatingWorkingControl(props: {
const action =
props.status?.kind === "background" ||
props.status?.kind === "lifecycle" ||
props.status?.kind === "stop"
props.status?.kind === "stop" ||
props.status?.kind === "quiet"
? props.status.action
: null;
const [agentsWidth, setAgentsWidth] = useState(0);
Expand Down Expand Up @@ -179,7 +180,9 @@ export function FloatingWorkingControl(props: {
>
<FloatingStatusLabel
key={
props.status.kind === "working" || props.status.kind === "compacting"
props.status.kind === "working" ||
props.status.kind === "compacting" ||
props.status.kind === "quiet"
? props.status.kind
: `${props.status.kind}:${props.status.label}`
}
Expand Down Expand Up @@ -473,6 +476,24 @@ function FloatingStatusLabel(props: {
</StatusLabelRow>
);
}
if (props.status.kind === "quiet") {
return (
<StatusLabelRow
key="quiet"
accessibilityLabel="No activity from the agent"
className="gap-2"
onLayout={props.onLayout}
>
<SymbolView
name="timer"
size={13}
tintColorClassName="accent-warning-foreground"
type="monochrome"
/>
<WorkingTimer startedAt={props.status.quietSince} prefix="No activity for" />
</StatusLabelRow>
);
}
if (props.status.kind === "stop") {
return (
<StatusLabelRow
Expand Down Expand Up @@ -588,15 +609,15 @@ function WorkingDuration(props: {
);
}

function WorkingTimer(props: { readonly startedAt: string }) {
function WorkingTimer(props: { readonly startedAt: string; readonly prefix?: string }) {
const nowMs = useVisibleSecondClock(true);
return (
<SystemText
className="shrink text-xs text-foreground"
numberOfLines={1}
style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }}
>
Working {formatWorkingDuration(props.startedAt, nowMs)}
{props.prefix ?? "Working"} {formatWorkingDuration(props.startedAt, nowMs)}
</SystemText>
);
}
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export type FloatingWorkingStatus =
readonly label: string;
readonly action: FloatingStatusAction;
}
| {
readonly kind: "quiet";
/** When the provider last sent anything for the running turn. */
readonly quietSince: string;
readonly action: FloatingStatusAction;
}
| {
readonly kind: "stop";
readonly label: string;
Expand Down
39 changes: 30 additions & 9 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
import { appAtomRegistry } from "../../state/atom-registry";
import { threadArrangementOpenAtom } from "../../state/thread-order";
import { threadEnvironment } from "../../state/threads";
import { serverEnvironment } from "../../state/server";
import { useAtomValue } from "@effect/atom-react";
import { quietRunSince } from "@t3tools/client-runtime/state/thread-execution";
import { useAtomCommand } from "../../state/use-atom-command";
import { useVisibleSecondClock } from "./use-visible-second-clock";
import type { ThreadMoveDestination } from "./threadOrder";
Expand Down Expand Up @@ -132,6 +135,12 @@ const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> =
iconTintClassName: "accent-warning-foreground",
},
};
const QUIET_STATUS_LABEL: StatusLabel = {
label: "No activity",
icon: "timer",
className: "text-warning-foreground",
iconTintClassName: "accent-warning-foreground",
};
const DONE_STATUS_LABEL: StatusLabel = {
label: "Done",
icon: "checkmark.circle",
Expand Down Expand Up @@ -688,10 +697,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {

const status = resolveThreadListV2Status(thread);
const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
const workingLabel = STATUS_LABEL_BY_STATUS[status];
const quietSince = useAtomValue(
serverEnvironment.configValueAtom(thread.environmentId),
(config) => (status === "working" ? quietRunSince(config, thread) : null),
);
const workingLabel = quietSince !== null ? QUIET_STATUS_LABEL : STATUS_LABEL_BY_STATUS[status];
const rowAccessibilityLabel = [
thread.title,
quietSince !== null ? QUIET_STATUS_LABEL.label : null,
props.hasQueuedMessages ? "messages queued to send" : null,
]
.filter((part) => part !== null)
.join(", ");
const showsWoke = status === "ready" && props.wokeAt !== null;
const statusLabel =
(status === "working" && workingLabel !== undefined && thread.goal?.status === "active"
(status === "working" &&
quietSince === null &&
workingLabel !== undefined &&
thread.goal?.status === "active"
? { ...workingLabel, label: "Goal" }
: workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined);
const recede = shouldRecedeThreadRow({ status, selected });
Expand Down Expand Up @@ -1145,7 +1168,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
>
{statusLabel.label}
{status === "working" ? (
<ThreadListV2Duration startedAt={resolveThreadWorkingStartedAt(thread)} />
<ThreadListV2Duration
startedAt={quietSince ?? resolveThreadWorkingStartedAt(thread)}
/>
) : status === "waiting" ? (
<ThreadListV2Duration startedAt={thread.latestRun?.completedAt ?? null} />
) : null}
Expand Down Expand Up @@ -1299,9 +1324,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
interactionOpacity={rowAppearance.interactionOpacity}
className={rowAppearance.className}
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={
props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
}
accessibilityLabel={rowAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityActions={rowAccessibilityActions}
Expand Down Expand Up @@ -1332,9 +1355,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
interactionClassName={rowAppearance.interactionClassName}
interactionOpacity={rowAppearance.interactionOpacity}
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={
props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
}
accessibilityLabel={rowAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ selected }}
className={rowAppearance.className}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt
initialConfigValueAtom: environmentSession.initialConfigValueAtom,
usageLimitSources: true,
memoryPressure: true,
quietRuns: true,
usageLimitsCommand: true,
});
export const describeOutdatedServer = createOutdatedServerDescriptionCommand(connectionAtomRuntime);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export const make = Effect.gen(function* () {
threadSnooze: true,
threadForceStop: true,
memoryPressure: true,
quietRuns: true,
environmentThemes: true,
usageLimitSources: true,
providerWorkspaceSnapshotsOnDemand: true,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/EffectWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Stream from "effect/Stream";

import * as CheckpointRollbackService from "./CheckpointRollbackService.ts";
import * as CheckpointService from "./CheckpointService.ts";
Expand Down Expand Up @@ -113,6 +114,7 @@ function layerExecutorFor(input: {
detach: () => record("detach"),
otherWorkingThreads: () => Effect.succeed([]),
releaseUnlessBusy: () => Effect.succeed(true),
quietRuns: Stream.empty,
}),
),
Layer.succeed(
Expand Down
Loading
Loading