Restore managed agents on launch and recover chat after relay restarts (#93)

This commit is contained in:
Wes
2026-03-17 13:48:02 -07:00
committed by GitHub
parent 6ecd04819e
commit 0d73fe7632
22 changed files with 1131 additions and 493 deletions
+19
View File
@@ -18,6 +18,7 @@ import {
startManagedAgent,
stopManagedAgent,
} from "@/shared/api/tauri";
import { setManagedAgentStartOnAppLaunch } from "@/shared/api/tauriManagedAgents";
import type {
CreateManagedAgentInput,
ManagedAgent,
@@ -155,6 +156,24 @@ export function useStopManagedAgentMutation() {
});
}
export function useSetManagedAgentStartOnAppLaunchMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
pubkey,
startOnAppLaunch,
}: {
pubkey: string;
startOnAppLaunch: boolean;
}) => setManagedAgentStartOnAppLaunch(pubkey, startOnAppLaunch),
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
},
});
}
export function useDeleteManagedAgentMutation() {
const queryClient = useQueryClient();
@@ -7,6 +7,7 @@ import {
useManagedAgentsQuery,
useMintManagedAgentTokenMutation,
useRelayAgentsQuery,
useSetManagedAgentStartOnAppLaunchMutation,
useStartManagedAgentMutation,
useStopManagedAgentMutation,
} from "@/features/agents/hooks";
@@ -28,6 +29,7 @@ export function AgentsView() {
const managedAgentsQuery = useManagedAgentsQuery();
const startMutation = useStartManagedAgentMutation();
const stopMutation = useStopManagedAgentMutation();
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
const deleteMutation = useDeleteManagedAgentMutation();
const mintTokenMutation = useMintManagedAgentTokenMutation();
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
@@ -124,6 +126,32 @@ export function AgentsView() {
}
}
async function handleToggleStartOnAppLaunch(
pubkey: string,
startOnAppLaunch: boolean,
) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
try {
const updated = await startOnLaunchMutation.mutateAsync({
pubkey,
startOnAppLaunch,
});
setActionNoticeMessage(
updated.startOnAppLaunch
? `Will start ${updated.name} automatically when the desktop app opens.`
: `${updated.name} will stay manual-start only.`,
);
} catch (error) {
setActionErrorMessage(
error instanceof Error
? error.message
: "Failed to update startup preference.",
);
}
}
async function handleMintToken(pubkey: string, name: string) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
@@ -177,6 +205,7 @@ export function AgentsView() {
const isActionPending =
startMutation.isPending ||
stopMutation.isPending ||
startOnLaunchMutation.isPending ||
deleteMutation.isPending ||
mintTokenMutation.isPending;
@@ -218,6 +247,9 @@ export function AgentsView() {
onStop={(pubkey) => {
void handleStop(pubkey);
}}
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
void handleToggleStartOnAppLaunch(pubkey, startOnAppLaunch);
}}
selectedAgentPubkey={selectedAgent?.pubkey ?? null}
/>
@@ -48,6 +48,7 @@ export function CreateAgentDialog({
const [relayUrl, setRelayUrl] = React.useState("");
const [mintToken, setMintToken] = React.useState(true);
const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true);
const [startOnAppLaunch, setStartOnAppLaunch] = React.useState(true);
const [tokenName, setTokenName] = React.useState("");
const [selectedScopes, setSelectedScopes] = React.useState<Set<TokenScope>>(
() => new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES),
@@ -128,6 +129,7 @@ export function CreateAgentDialog({
setRelayUrl("");
setMintToken(true);
setSpawnAfterCreate(true);
setStartOnAppLaunch(true);
setTokenName("");
setSelectedScopes(new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES));
setAcpCommand("sprout-acp");
@@ -215,6 +217,7 @@ export function CreateAgentDialog({
tokenName: tokenName.trim() || undefined,
tokenScopes: [...selectedScopes],
spawnAfterCreate,
startOnAppLaunch,
};
const created = await createMutation.mutateAsync(input);
handleOpenChange(false);
@@ -258,12 +261,16 @@ export function CreateAgentDialog({
setMintToken((current) => !current);
}
}}
onToggleStartOnAppLaunch={() => {
setStartOnAppLaunch((current) => !current);
}}
onToggleSpawnAfterCreate={() => {
if (!spawnToggleDisabled) {
setSpawnAfterCreate((current) => !current);
}
}}
prereqs={prereqs}
startOnAppLaunch={startOnAppLaunch}
spawnAfterCreate={spawnAfterCreate}
spawnToggleDisabled={spawnToggleDisabled}
/>
@@ -261,9 +261,11 @@ export function CreateAgentOptionToggles({
mintToken,
mintToggleDisabled,
prereqs,
startOnAppLaunch,
spawnAfterCreate,
spawnToggleDisabled,
onToggleMintToken,
onToggleStartOnAppLaunch,
onToggleSpawnAfterCreate,
}: {
isMintSupported: boolean;
@@ -271,13 +273,15 @@ export function CreateAgentOptionToggles({
mintToken: boolean;
mintToggleDisabled: boolean;
prereqs: ManagedAgentPrereqs | null;
startOnAppLaunch: boolean;
spawnAfterCreate: boolean;
spawnToggleDisabled: boolean;
onToggleMintToken: () => void;
onToggleStartOnAppLaunch: () => void;
onToggleSpawnAfterCreate: () => void;
}) {
return (
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<button
aria-pressed={mintToken}
className={cn(
@@ -299,6 +303,26 @@ export function CreateAgentOptionToggles({
</p>
</button>
<button
aria-pressed={startOnAppLaunch}
className={cn(
"rounded-2xl border px-4 py-3 text-left transition-colors",
startOnAppLaunch
? "border-primary bg-primary/10"
: "border-border/70 bg-background/70",
)}
onClick={onToggleStartOnAppLaunch}
type="button"
>
<p className="text-sm font-semibold tracking-tight">
Start on app launch
</p>
<p className="mt-1 text-sm text-muted-foreground">
Reopen this local ACP harness automatically when the desktop app
starts.
</p>
</button>
<button
aria-pressed={spawnAfterCreate}
className={cn(
@@ -15,6 +15,7 @@ import { formatTimestamp, truncatePubkey } from "./agentUi";
export function ManagedAgentCard({
agent,
onToggleStartOnAppLaunch,
onAddToChannel,
isSelected,
onDelete,
@@ -24,6 +25,7 @@ export function ManagedAgentCard({
onStop,
}: {
agent: ManagedAgent;
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
onAddToChannel: (agent: ManagedAgent) => void;
isSelected: boolean;
onDelete: (pubkey: string) => void;
@@ -137,6 +139,19 @@ export function ManagedAgentCard({
Mint token
</Button>
<Button
onClick={() =>
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
}
size="sm"
type="button"
variant="outline"
>
{agent.startOnAppLaunch
? "Launch on open: on"
: "Launch on open: off"}
</Button>
<Button
onClick={() => onDelete(agent.pubkey)}
size="sm"
@@ -21,6 +21,7 @@ export function ManagedAgentsSection({
onSelect,
onStart,
onStop,
onToggleStartOnAppLaunch,
}: {
actionErrorMessage: string | null;
actionNoticeMessage: string | null;
@@ -37,6 +38,7 @@ export function ManagedAgentsSection({
onSelect: (pubkey: string) => void;
onStart: (pubkey: string) => void;
onStop: (pubkey: string) => void;
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
}) {
return (
<section className="space-y-4">
@@ -119,6 +121,11 @@ export function ManagedAgentsSection({
onStop(pubkey);
}
}}
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
if (!isActionPending) {
onToggleStartOnAppLaunch(pubkey, startOnAppLaunch);
}
}}
/>
))}
+12
View File
@@ -178,6 +178,17 @@ export function useChannelSubscription(channel: Channel | null) {
let isDisposed = false;
let cleanup: (() => Promise<void>) | undefined;
const disposeReconnectListener = relayClient.subscribeToReconnects(() => {
void syncLatestHistory().catch((error) => {
if (!isDisposed) {
console.error(
"Failed to refresh channel history after reconnecting",
channelId,
error,
);
}
});
});
relayClient
.subscribeToChannel(channelId, (event) => {
@@ -209,6 +220,7 @@ export function useChannelSubscription(channel: Channel | null) {
return () => {
isDisposed = true;
disposeReconnectListener();
if (cleanup) {
void cleanup();
}
@@ -6,8 +6,6 @@ import type { Channel, RelayEvent } from "@/shared/api/types";
import {
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_V2,
KIND_TYPING_INDICATOR,
} from "@/shared/constants/kinds";
@@ -40,8 +38,6 @@ function isTypingCompletionEvent(event: RelayEvent | null | undefined) {
return (
event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_V2 ||
event.kind === KIND_STREAM_MESSAGE_EDIT ||
event.kind === KIND_STREAM_MESSAGE_DIFF
);
}