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
134 changes: 134 additions & 0 deletions apps/desktop/src/components/runners/chat-move-progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from "react";
import type { QueryClient } from "@tanstack/react-query";
import type { ChatMove, ConversationWithMessages, MoveChatInput } from "@godmode/shared";
import { ArrowRightLeft, X } from "lucide-react";
import { toast } from "sonner";
import { toastApiError } from "@/components/vault/vault-utils";
import { api } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { setChatMove, useChatMoves } from "@/stores/chatMoves";

const MB = 1024 * 1024;

function size(n: number): string {
if (n < MB) return `${Math.max(1, Math.round(n / 1024))} KB`;
return `${(n / MB).toFixed(n < 10 * MB ? 1 : 0)} MB`;
}

function clock(seconds: number): string {
const s = Math.max(0, Math.round(seconds));
return s < 60 ? `${s} s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")} min`;
}

/** Copy speed from the first bytes of the copy on, once there is enough to tell. */
function useRate(move: ChatMove | undefined): number | null {
const first = useRef<{ at: number; bytes: number } | null>(null);
const copying = move?.stage === "copying";
if (!copying) first.current = null;
else if (!first.current && move.bytes > 0) first.current = { at: Date.now(), bytes: move.bytes };
if (!copying || !first.current) return null;
const seconds = (Date.now() - first.current.at) / 1000;
return seconds >= 1.5 ? (move.bytes - first.current.bytes) / seconds : null;
}

function useElapsed(since: number): number {
const [at, setAt] = useState(Date.now());
useEffect(() => {
const t = setInterval(() => setAt(Date.now()), 1000);
return () => clearInterval(t);
}, []);
return (at - since) / 1000;
}

function ChatMoveToast({ toastId, conversationId, dest, agentName }: { toastId: string; conversationId: string; dest: string; agentName: string }) {
const move = useChatMoves((s) => s.moves[conversationId]);
const started = useRef(Date.now());
const seen = useRef(false);
if (move) seen.current = true;
const finishing = !move && seen.current;
const elapsed = useElapsed(started.current);
const rate = useRate(move);

const percent = move?.stage === "copying" && move.total ? Math.min(100, (move.bytes / move.total) * 100) : finishing || move?.stage === "saving" || move?.stage === "continuing" ? 100 : null;
const left = move?.stage === "copying" && move.total && rate ? (move.total - move.bytes) / rate : null;

let detail: string;
if (finishing) detail = "Almost there";
else if (!move) detail = "Starting";
else if (move.stage === "checking") detail = `Checking the chat on ${move.from}`;
else if (move.stage === "copying") detail = move.total ? `Copying the conversation · ${size(move.bytes)} of ${size(move.total)}` : `Copying the conversation · ${size(move.bytes)}`;
else if (move.stage === "sending") detail = `Sending it to ${move.to}`;
else if (move.stage === "saving") detail = `Saving it on ${dest}`;
else detail = `${agentName} picks up where it stopped`;

return (
<div className="w-[356px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg" role="status" aria-live="polite">
<div className="flex items-start gap-3 p-3.5 pb-3">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-brand-soft text-brand-strong">
<ArrowRightLeft className="size-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="flex items-baseline gap-2 text-[13px] font-medium">
<span className="truncate text-shimmer">Moving chat to {dest}</span>
{move?.stage === "copying" && percent !== null && <span className="ml-auto shrink-0 font-mono text-[11px] font-normal text-muted-foreground tabular-nums">{Math.floor(percent)}%</span>}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground tabular-nums">{detail}</p>
</div>
<button
type="button"
onClick={() => toast.dismiss(toastId)}
aria-label="Hide"
className="-mt-0.5 -mr-1 grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
<X className="size-3.5" />
</button>
</div>
<div className="px-3.5 pb-3.5">
<div className="h-1 overflow-hidden rounded-full bg-foreground/[0.07]" aria-hidden>
<div
className={cn("h-full rounded-full bg-brand transition-[width] duration-300 ease-out", percent === null && "w-1/3 animate-indeterminate", percent === 100 && "animate-pulse")}
style={percent === null ? undefined : { width: `${Math.max(2, percent)}%` }}
/>
</div>
<p className="mt-2 flex justify-between gap-3 text-[11px] text-muted-foreground tabular-nums">
<span className="truncate">Keep working, you'll see when it's there.</span>
<span className="shrink-0">{left !== null ? `${clock(left)} left · ${size(rate!)}/s` : clock(elapsed)}</span>
</p>
</div>
</div>
);
}

/**
* Moves a chat without holding anything up: the dialog closes and a card in the corner shows how far the move is
* (from the server's `chat.move` events) until it arrived or failed.
*/
export function startChatMove(
qc: QueryClient,
opts: { conversationId: string; input: MoveChatInput; from: string; dest: string; agentName: string; working: boolean },
) {
const { conversationId, dest, agentName, working } = opts;
const id = `chat-move:${conversationId}`;
setChatMove(conversationId, { conversationId, from: opts.from, to: dest, stage: "checking", bytes: 0, total: null, startedAt: new Date().toISOString() });
toast.custom((t) => <ChatMoveToast toastId={String(t)} conversationId={conversationId} dest={dest} agentName={agentName} />, { id, duration: Infinity, unstyled: true });
api.conversations.move(conversationId, opts.input).then(
(moved) => {
setChatMove(conversationId, null);
qc.setQueryData<ConversationWithMessages>(qk.conversation(conversationId), (old) => (old ? { ...old, ...moved } : old));
void qc.invalidateQueries({ queryKey: qk.conversation(conversationId) });
void qc.invalidateQueries({ queryKey: qk.conversationsAll });
void qc.invalidateQueries({ queryKey: qk.runners });
toast.dismiss(id);
toast.success(`Moved to ${dest}`, {
id: `${id}:done`,
description: working ? `${agentName} continues there where it stopped.` : `${agentName} continues this chat there, with the whole conversation.`,
});
},
(err) => {
setChatMove(conversationId, null);
toast.dismiss(id);
toastApiError(err, "Couldn't move the chat", qc);
},
);
}
42 changes: 16 additions & 26 deletions apps/desktop/src/components/runners/move-chat.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Conversation, ConversationWithMessages } from "@godmode/shared";
import { useQueryClient } from "@tanstack/react-query";
import type { Conversation } from "@godmode/shared";
import { ArrowRight, Laptop, MonitorSmartphone } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
Expand All @@ -15,11 +14,10 @@ import {
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { followupWhen } from "@/components/chat/followup";
import { toastApiError } from "@/components/vault/vault-utils";
import { api } from "@/lib/api";
import { placeName, type Place } from "@/lib/places";
import { qk } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { useChatMoves } from "@/stores/chatMoves";
import { startChatMove } from "./chat-move-progress";
import { RunnerStateDot, RunnerTag, THIS_COMPUTER_INLINE } from "./runner-parts";

/** Why a runner's chat can't move right now (its run works or stands still there), or null. */
Expand Down Expand Up @@ -47,8 +45,8 @@ function PlaceTag({ place, active }: { place: Place | null; active?: boolean })
}

/**
* Asks before a chat moves to another computer, then moves it with its Claude session. While a run works the move
* stops it and the agent continues there (`working`).
* Asks before a chat moves to another computer, then moves it with its Claude session in the background (a card in the
* corner shows how far it is). While a run works the move stops it and the agent continues there (`working`).
*/
export function MoveChatDialog({
conversation,
Expand Down Expand Up @@ -82,23 +80,15 @@ export function MoveChatDialog({
const name = origin ? placeName(origin) : "The other computer";
const away = !!origin && origin.state !== "online";
const dest = to?.here ? THIS_COMPUTER_INLINE : to ? placeName(to) : "the other computer";
const move = useMutation({
mutationFn: () => api.conversations.move(conversation.id, { to: to!.runnerId, force: working || undefined }),
onSuccess: (moved) => {
qc.setQueryData<ConversationWithMessages>(qk.conversation(conversation.id), (old) => (old ? { ...old, ...moved } : old));
void qc.invalidateQueries({ queryKey: qk.conversation(conversation.id) });
void qc.invalidateQueries({ queryKey: qk.conversationsAll });
void qc.invalidateQueries({ queryKey: qk.runners });
onOpenChange(false);
toast.success(`Moved to ${dest}`, {
description: working ? `${agentName} continues there where it stopped.` : `${agentName} continues this chat there, with the whole conversation.`,
});
},
onError: (err) => toastApiError(err, "Couldn't move the chat", qc),
});
const moving = useChatMoves((s) => !!s.moves[conversation.id]);
const move = () => {
if (!to) return;
startChatMove(qc, { conversationId: conversation.id, input: { to: to.runnerId, force: working || undefined }, from: name, dest, agentName, working });
onOpenChange(false);
};

return (
<AlertDialog open={open} onOpenChange={(next) => !move.isPending && onOpenChange(next)}>
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Move this chat to {dest}?</AlertDialogTitle>
Expand Down Expand Up @@ -146,9 +136,9 @@ export function MoveChatDialog({
</ul>

<AlertDialogFooter>
<AlertDialogCancel disabled={move.isPending}>Cancel</AlertDialogCancel>
<Button onClick={() => move.mutate()} disabled={move.isPending || !to}>
{move.isPending ? <Spinner /> : to?.here ? <Laptop /> : <MonitorSmartphone />} {working ? "Move and continue" : "Move"}
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button onClick={move} disabled={moving || !to}>
{moving ? <Spinner /> : to?.here ? <Laptop /> : <MonitorSmartphone />} {moving ? "Moving" : working ? "Move and continue" : "Move"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/lib/realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { InvalidateQueryFilters, QueryClient } from "@tanstack/react-query"
import { browserView, type AgentQuestion, type AutomationEvent, type BrowserProfile, type ClientEvent, type ConversationWithMessages, type DeviceGroup, type EntityName, type Message, type RemoteRunner, type ServerEvent, type Task, type TaskEvent, type Vm } from "@godmode/shared";
import { wsUrl } from "./core";
import { useLive } from "@/stores/live";
import { setChatMove, useChatMoves } from "@/stores/chatMoves";
import { withPending } from "./pending-queue";
import { qk } from "./queryKeys";

Expand Down Expand Up @@ -214,6 +215,8 @@ function handle(qc: QueryClient, event: ServerEvent) {
case "hello":
// A run.started for each active run follows; whatever else still looks live ended while we were away.
if (event.activeRunIds) live.retainRuns(event.activeRunIds);
// Moves still on their way are told again right after.
useChatMoves.setState({ moves: {} });
break;
case "run.started":
live.runStarted(event.run);
Expand Down Expand Up @@ -406,6 +409,9 @@ function handle(qc: QueryClient, event: ServerEvent) {
qc.invalidateQueries({ queryKey: qk.runners });
qc.invalidateQueries({ queryKey: qk.devices });
break;
case "chat.move":
setChatMove(event.conversationId, event.move);
break;
case "devices.updated": {
const before = qc.getQueryData<DeviceGroup>(qk.devices);
qc.setQueryData(qk.devices, event.group);
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/stores/chatMoves.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { create } from "zustand";
import type { ChatMove } from "@godmode/shared";

/** Chats on their way between computers, as the server tells them (`chat.move`). */
export const useChatMoves = create<{ moves: Record<string, ChatMove> }>(() => ({ moves: {} }));

export function setChatMove(conversationId: string, move: ChatMove | null) {
useChatMoves.setState(({ moves }) => {
const next = { ...moves };
if (move) next[conversationId] = move;
else delete next[conversationId];
return { moves: next };
});
}
1 change: 1 addition & 0 deletions packages/core/src/cloud/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ const RULES: [methods: string, path: string, rule: Rule][] = [
["GET|PUT", "/api/link/agents/:id/memory", refused(COMPUTER_ONLY)],
["POST", "/api/link/browser/:profileId/cookies", refused(COMPUTER_ONLY)],
["GET", "/api/link/conversations/:id/handover", refused(COMPUTER_ONLY)],
["GET", "/api/link/conversations/:id/handover/size", refused(COMPUTER_ONLY)],
["POST", "/api/link/exec", refused(COMPUTER_ONLY)],
["POST", "/api/link/forget", refused(COMPUTER_ONLY)],
["PUT", "/api/link/update/chunk", refused(COMPUTER_ONLY)],
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/remote/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,13 @@ export class SecureChannel<R = RunnerInfo | LinkPeer> {
} while (offset < bytes.byteLength);
}

/** Bytes of streams still arriving: how far a large answer is. */
get incoming(): number {
let size = 0;
for (const stream of this.streams.values()) size += stream.size;
return size;
}

/** Ends the link from this side. Also what the owner calls when the transport closed underneath. */
close(): void {
this.shutdown(null);
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/remote/chatMoves.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Chats on their way between computers (owner: remote): how far each one is, told to the windows as `chat.move`
* events so a move shows its progress on a slow connection instead of a dialog that stands still.
*/
import type { ChatMove, ChatMoveStage } from "@godmode/shared";
import { bus } from "../events/bus";
import { addWelcomeEvents } from "../server/ws";
import { now } from "../util";

const EMIT_EVERY_MS = 250;

const moves = new Map<string, ChatMove>();
const lastEmit = new Map<string, number>();

function emit(conversationId: string, force = false): void {
const at = Date.now();
if (!force && at - (lastEmit.get(conversationId) ?? 0) < EMIT_EVERY_MS) return;
lastEmit.set(conversationId, at);
bus.emit({ type: "chat.move", conversationId, move: moves.get(conversationId) ?? null });
}

addWelcomeEvents(() => [...moves.values()].map((move) => ({ type: "chat.move", conversationId: move.conversationId, move })));

/** Runs the move with its progress told; the windows learn it ended (arrived or failed) when `fn` settles. */
export async function trackMove<T>(conversationId: string, from: string, to: string, fn: () => Promise<T>): Promise<T> {
if (moves.has(conversationId)) return fn();
moves.set(conversationId, { conversationId, from, to, stage: "checking", bytes: 0, total: null, startedAt: now() });
emit(conversationId, true);
try {
return await fn();
} finally {
moves.delete(conversationId);
emit(conversationId, true);
lastEmit.delete(conversationId);
}
}

/** A new step of a tracked move; nothing for a chat that isn't moving through `trackMove`. */
export function moveStage(conversationId: string, stage: ChatMoveStage, total: number | null = null): void {
const move = moves.get(conversationId);
if (!move) return;
moves.set(conversationId, { ...move, stage, bytes: 0, total });
emit(conversationId, true);
}

export function moveBytes(conversationId: string, bytes: number): void {
const move = moves.get(conversationId);
if (!move) return;
const next = move.total ? Math.min(bytes, move.total) : bytes;
if (next <= move.bytes) return;
moves.set(conversationId, { ...move, bytes: next });
emit(conversationId);
}
36 changes: 22 additions & 14 deletions packages/core/src/remote/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { HttpError, badRequest, now, parseJson, sleep } from "../util";
import { remoteRuns } from "./activeRuns";
import { canonicalKey } from "./crypto";
import { readHandover, writeSession, writeTranscript } from "./handover";
import { moveStage, trackMove } from "./chatMoves";
import { loadIdentity } from "./identity";
import { forgetController, linkServerPort, runnerInfo, setPhoneInfo, startLinkServer, stopLinkServer } from "./linkServer";
import { adoptChat, catchUp, fetchConversation, importChat } from "./mirror";
Expand Down Expand Up @@ -1338,7 +1339,7 @@ async function pushChat(conversationId: string, targetId: string, extra: Message
handover: readHandover(conversationId),
followup: followup ? { note: followup.note, dueAt: followup.dueAt } : null,
},
{ timeoutMs: 180_000 },
{ timeoutMs: 15 * 60_000 },
);
const state: RunnerChatState = { running: false, paused: null, followup: followup ? { note: followup.note, dueAt: followup.dueAt, createdAt: followup.createdAt } : null };
sql("UPDATE conversations SET runner_id = ?, runner_state = ?, working_directory = NULL, computer_target = NULL, updated_at = ? WHERE id = ?", targetId, JSON.stringify(state), now(), conversationId);
Expand Down Expand Up @@ -1429,20 +1430,27 @@ export async function moveChat(conversationId: string, input: MoveChatInput): Pr
const queued = owner === null ? listQueue(conversationId).map((q) => q.content) : [];
if (queued.length && !input.force) throw new HttpError(409, "Messages are waiting in this chat's queue. Send or remove them first, or move it anyway.", "chat_queued");

if (working) await stopWork(conversationId, owner, dest);
if (queued.length) clearQueue(conversationId);
const resume = working || queued.length > 0;
if (owner !== null) await moveChatHere(conversationId);

const blocks = marker(from, dest, resume);
if (to !== null) {
await pushChat(conversationId, to, resume ? null : blocks);
if (resume) await sendThere(conversationId, to, { content: `Continued on ${dest}`, prompt: continuePrompt(from, dest, queued), marker: blocks });
} else if (resume) {
await sendMessage(conversationId, { content: `Continued on ${dest}`, prompt: continuePrompt(from, dest, queued), marker: blocks, trigger: "chat" });
} else {
addMessage({ conversationId, role: "system", content: "", blocks });
}
await trackMove(conversationId, from, dest, async () => {
if (working) await stopWork(conversationId, owner, dest);
if (queued.length) clearQueue(conversationId);
if (owner !== null) await moveChatHere(conversationId);

const blocks = marker(from, dest, resume);
if (to !== null) {
moveStage(conversationId, "sending");
await pushChat(conversationId, to, resume ? null : blocks);
if (resume) {
moveStage(conversationId, "continuing");
await sendThere(conversationId, to, { content: `Continued on ${dest}`, prompt: continuePrompt(from, dest, queued), marker: blocks });
}
} else if (resume) {
moveStage(conversationId, "continuing");
await sendMessage(conversationId, { content: `Continued on ${dest}`, prompt: continuePrompt(from, dest, queued), marker: blocks, trigger: "chat" });
} else {
addMessage({ conversationId, role: "system", content: "", blocks });
}
});
log.info("moved a chat", { conversationId, from, to: dest, resume });
audit("user", "device.chat.move", conversationId, { from, to: dest, resume });
emitConversationUpdated(conversationId);
Expand Down
Loading
Loading