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
91 changes: 86 additions & 5 deletions apps/desktop/src/components/chat/turn-end.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import { Link } from "react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Agent, ConversationWithMessages, Message, RetryMode } from "@godmode/shared";
import type { Agent, ConversationWithMessages, Message, RetryMode, Run } from "@godmode/shared";
import { needsFix, retryHelps, retryModeOf, runEndOf, type RunEnd } from "@godmode/shared";
import { ArrowUpRight, MessageSquarePlus, Play, RotateCcw, Workflow } from "lucide-react";
import { ArrowUpRight, LogIn, MessageSquarePlus, Play, RotateCcw, Workflow } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useStartAgentChat } from "@/components/agents/agent-actions";
import { useRunRoutine } from "@/components/agents/routine-item";
import { ClaudeSignInSteps, maySignIn, useClaudeLogin } from "@/components/onboarding/claude-sign-in";
import { api, ApiRequestError, errorMessage, isLicenseRequired } from "@/lib/api";
import { useRoutines, useTasks } from "@/lib/hooks";
import { qk } from "@/lib/queryKeys";
Expand All @@ -18,8 +19,6 @@ const PLATFORM_ORIGINS = new Set(["slack", "telegram", "teams"]);
/** Where the fix for a turn that can't just be tried again lives. */
function fixFor(end: RunEnd, agentName: string): { text: string; to?: string; label?: string } {
switch (end.kind) {
case "auth":
return { text: "Sign Claude in again, then try again.", to: "/settings/system", label: "Open System" };
case "cli":
return { text: "Install Claude Code, then try again.", to: "/settings/system", label: "Open System" };
case "vm":
Expand Down Expand Up @@ -141,6 +140,8 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver
</Button>
);

if (end?.kind === "auth") return <SignInAgain run={run} name={name} action={action} onSignedIn={() => retry.mutate()} />;

if (end && needsFix(end)) {
const fix = fixFor(end, name);
return (
Expand Down Expand Up @@ -178,6 +179,86 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver
);
}

/**
* Claude Code is signed out: sign in again from the chat (or the banner), and the turn goes on by itself once Claude
* works again while the chat is open.
*/
function SignInAgain({ run, name, action, onSignedIn }: { run: Run; name: string; action: (variant: "outline" | "ghost") => ReactNode; onSignedIn: () => void }) {
const continued = useRef<string | null>(null);
const goOn = useCallback(() => {
if (continued.current === run.id) return;
continued.current = run.id;
onSignedIn();
}, [run.id, onSignedIn]);
const login = useClaudeLogin({ onSignedIn: goOn });
const { status, state, start } = login;
// Cleared some other way while the chat is open (a sign-in in a terminal, another run that got an answer).
const wasOut = useRef(false);
useEffect(() => {
if (status?.signedOut) wasOut.current = true;
else if (wasOut.current && status?.signedOut === null) {
wasOut.current = false;
goOn();
}
}, [status?.signedOut, goOn]);
// A core that knows the state says it works again (older ones leave it out).
const worksAgain = status?.signedOut === null && state !== "failed";

if (status?.signedOut?.apiKey)
return (
<Row>
<span className="min-w-0">Claude refused the Anthropic API key. Check it, then try again.</span>
<span className="flex shrink-0 items-center gap-1">
<Button size="xs" variant="outline" asChild>
<Link to="/integrations?tab=api-keys">
API keys <ArrowUpRight />
</Link>
</Button>
{action("ghost")}
</span>
</Row>
);
if (state === "waiting" && !login.mine)
return (
<Row>
<Spinner className="size-3.5" />
<span className="min-w-0">Signing in to Claude… {name} picks up where it stopped once you're done.</span>
</Row>
);
if (state === "waiting")
return (
<div className="-mt-3 space-y-1.5 pl-11">
<ClaudeSignInSteps login={login} className="max-w-xl" />
<p className="text-xs text-muted-foreground">{name} goes on by itself once you're signed in.</p>
</div>
);
if (worksAgain)
return (
<Row>
{action("outline")}
<span className="min-w-0">Claude works again. {name} picks up where it stopped.</span>
</Row>
);
if (!maySignIn)
return (
<Row>
<span className="min-w-0">Claude is signed out on this computer. Once its owner signs in again, try again.</span>
{action("ghost")}
</Row>
);
return (
<Row>
<span className="min-w-0">{state === "failed" && status?.message ? `The sign-in didn't finish: ${status.message}` : `Claude is signed out. Sign in again and ${name} goes on by itself.`}</span>
<span className="flex shrink-0 items-center gap-1">
<Button size="xs" disabled={start.isPending} aria-busy={start.isPending} onClick={() => start.mutate()}>
{start.isPending ? <Spinner /> : <LogIn />} {state === "failed" ? "Try again" : "Sign in again"}
</Button>
{action("ghost")}
</span>
</Row>
);
}

function Row({ children }: { children: ReactNode }) {
return (
<div role="group" aria-label="This turn ended early" aria-live="polite" className="-mt-3 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 pl-11 text-xs text-muted-foreground">
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { CommandPalette } from "@/components/layout/command-palette";
import { UpdateButton } from "@/components/layout/update-button";
import { ClaudeUpdateButton } from "@/components/layout/claude-update-button";
import { MacPermissionsButton } from "@/components/permissions/mac-permissions";
import { ClaudeSignedOutBanner } from "@/components/layout/claude-signed-out";
import { DeviceIndicator, PrimaryOfflineBanner } from "@/components/layout/device-status";
import { LidModeButton } from "@/components/layout/lid-mode";
import { ThemeToggle } from "@/components/layout/theme-toggle";
Expand Down Expand Up @@ -319,6 +320,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<DesktopDragStrip />
{cloudContext && boot && <CloudVersionNote coreVersion={boot.version} uiVersion={cloudContext.uiVersion} />}
<PrimaryOfflineBanner />
<ClaudeSignedOutBanner />
<LicenseBanner />
<div ref={setScrollEl} className="@container min-h-0 flex-1 overflow-y-auto">
<PageScrollContext value={scrollEl}>{children}</PageScrollContext>
Expand Down
91 changes: 91 additions & 0 deletions apps/desktop/src/components/layout/claude-signed-out.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router";
import { AnimatePresence, motion } from "motion/react";
import { KeyRound, LogIn } from "lucide-react";
import { ClaudeSignInSteps, maySignIn, signsInFromAfar, useClaudeLogin } from "@/components/onboarding/claude-sign-in";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";

/**
* Above every page while runs find Claude Code signed out: one click signs it in again, right here. Gone by itself once
* it works (a sign-in, or a run that got an answer).
*/
export function ClaudeSignedOutBanner() {
const { pathname } = useLocation();
const login = useClaudeLogin();
const { status, state, start } = login;
const out = status?.signedOut ?? null;
// Settings → System shows the same sign-in in its checklist.
const shown = !!out && !pathname.startsWith("/settings/system");
// A sign-in started elsewhere (the chat, before a reload) shows its steps here on request.
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (state !== "waiting") setExpanded(false);
}, [state]);

return (
<AnimatePresence initial={false}>
{shown && (
<motion.div
key="claude-signed-out"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="shrink-0 overflow-hidden"
>
<div className="px-3 pt-3">
<div role="alert" className="rounded-lg border border-warning/30 bg-warning/[0.07] py-1.5 pr-1.5 pl-3 text-[13px] shadow-card">
<div className="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="shrink-0 text-warning [&_svg]:size-4">{out.apiKey ? <KeyRound /> : state === "waiting" ? <Spinner /> : <LogIn />}</span>
<p className="min-w-0 flex-1 leading-snug">
{out.apiKey ? (
<>
<span className="font-medium">Claude refused your Anthropic API key.</span>
<span className="text-muted-foreground"> Your agents can't work until it works again.</span>
</>
) : state === "waiting" ? (
<>
<span className="font-medium">Signing in to Claude…</span>
{!login.mine && !signsInFromAfar && <span className="text-muted-foreground"> Finish it in your browser.</span>}
</>
) : state === "failed" && status?.message ? (
<>
<span className="font-medium">The sign-in didn't finish.</span>
<span className="text-muted-foreground"> {status.message}</span>
</>
) : (
<>
<span className="font-medium">Claude is signed out.</span>
<span className="text-muted-foreground">
{maySignIn ? " Your agents can't work until you sign in again." : " Your agents can't work until the owner of this computer signs in again."}
</span>
</>
)}
</p>
{out.apiKey ? (
<Button size="sm" variant="outline" className="h-7 shrink-0 bg-card" asChild>
<Link to="/integrations?tab=api-keys">Check the key</Link>
</Button>
) : !maySignIn ? null : state !== "waiting" ? (
<Button size="sm" className="h-7 shrink-0" onClick={() => start.mutate()} disabled={start.isPending}>
{start.isPending ? <Spinner /> : <LogIn />}
{state === "failed" ? "Try again" : "Sign in again"}
</Button>
) : (
!login.mine &&
!expanded && (
<Button size="sm" variant="ghost" className="h-7 shrink-0" onClick={() => setExpanded(true)}>
Show steps
</Button>
)
)}
</div>
{!out.apiKey && state === "waiting" && (login.mine || expanded) && <ClaudeSignInSteps login={login} className="my-1.5 mr-1.5 bg-card" />}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
152 changes: 152 additions & 0 deletions apps/desktop/src/components/onboarding/claude-sign-in.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink } from "lucide-react";
import { toast } from "sonner";
import type { ClaudeLoginStatus } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { api, errorMessage } from "@/lib/api";
import { cloudContext, isTauri } from "@/lib/core";
import { openExternal } from "@/lib/desktop";
import { qk } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";

const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);

/** The dashboard runs away from the computer (a phone, Godmode Cloud): the browser the sign-in opens there is out of reach. */
export const signsInFromAfar = !!cloudContext || (!isTauri && typeof window !== "undefined" && !LOCAL_HOSTS.has(window.location.hostname));

/** Who may sign Claude in: on Godmode Cloud only the computer's owner. */
export const maySignIn = !cloudContext || cloudContext.role === "owner";

/**
* Claude Code's sign-in on this computer: its state (also whether runs found it signed out) and the steps to sign in.
* `onSignedIn` runs when a sign-in finishes while the component is on screen, wherever it was started.
*/
export function useClaudeLogin({ onSignedIn }: { onSignedIn?: () => void } = {}) {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: qk.claudeLogin,
queryFn: api.doctor.claudeLogin,
refetchInterval: (q) => {
const s = q.state.data as ClaudeLoginStatus | undefined;
return s?.state === "waiting" ? 1500 : s?.signedOut ? 30_000 : false;
},
});
// A reply may arrive after the event it raced with: it doesn't take away the sign-in link the event brought.
const set = (s: ClaudeLoginStatus) =>
qc.setQueryData<ClaudeLoginStatus>(qk.claudeLogin, (old) => (old?.state === "waiting" && s.state === "waiting" && old.url && !s.url ? { ...s, url: old.url } : s));
// The sign-in this component started (the others only mention it).
const [mine, setMine] = useState(false);
const start = useMutation({
mutationFn: api.doctor.startClaudeLogin,
onMutate: () => setMine(true),
onSuccess: (s) => {
set(s);
if (s.state !== "waiting") setMine(false);
},
onError: (e) => {
setMine(false);
toast.error("Could not start the sign-in", { description: errorMessage(e) });
},
});
const cancel = useMutation({ mutationFn: api.doctor.cancelClaudeLogin, onSuccess: set });
// What the code did arrives as an event (or the next poll); the reply is only "still waiting".
const submit = useMutation({
mutationFn: (code: string) => api.doctor.submitClaudeLoginCode(code),
onError: (e) => toast.error("Could not send the code", { description: errorMessage(e) }),
});

const state = data?.state ?? "idle";
const prev = useRef(state);
useEffect(() => {
if (prev.current === "waiting" && state !== "waiting") {
if (state === "done") {
toast.success("Signed in to Claude", { id: "claude-signed-in", description: "Your agents can work again." });
onSignedIn?.();
}
setMine(false);
}
prev.current = state;
}, [state, onSignedIn]);

return { status: data, state, mine: mine && state === "waiting", start, cancel, submit };
}

export type ClaudeLogin = ReturnType<typeof useClaudeLogin>;

/** While the sign-in runs: finish it in the browser, or (no browser window, a phone) open the page and paste its code. */
export function ClaudeSignInSteps({ login, className }: { login: ClaudeLogin; className?: string }) {
const [code, setCode] = useState("");
const [manual, setManual] = useState(signsInFromAfar);
const url = login.status?.url ?? null;
const send = () => {
if (!code.trim()) return;
login.submit.mutate(code.trim(), { onSuccess: () => setCode("") });
};
const openPage = () => {
if (!url) return;
setManual(true);
void openExternal(url);
};

return (
<div className={cn("rounded-md border bg-paper-2 p-3 text-xs", className)} aria-live="polite">
<div className="flex items-center gap-2 font-medium">
<Spinner className="size-3.5" />
<span className="min-w-0 flex-1">{signsInFromAfar ? "Sign in on this device" : "Finish signing in in your browser"}</span>
<Button size="xs" variant="ghost" className="-my-1 -mr-1.5 text-muted-foreground" onClick={() => login.cancel.mutate()} disabled={login.cancel.isPending}>
Cancel
</Button>
</div>
{signsInFromAfar ? (
<ol className="mt-2 list-decimal space-y-1.5 pl-4 leading-relaxed text-foreground/85">
<li>
<button type="button" disabled={!url} onClick={openPage} className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline disabled:opacity-60">
{url ? "Open the Claude sign-in page" : "Getting the sign-in page…"} {url ? <ExternalLink className="size-3" /> : <Spinner className="size-3" />}
</button>
</li>
<li>
Sign in with your Claude account and click <span className="font-medium">Authorize</span>.
</li>
<li>Paste the code the page shows:</li>
</ol>
) : (
<p className="mt-1.5 leading-relaxed text-muted-foreground">
Sign in with your Claude account and click <span className="font-medium text-foreground">Authorize</span>. This updates by itself.
{url && !manual && (
<>
{" "}
<button type="button" className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline" onClick={openPage}>
No browser window? <ExternalLink className="size-3" />
</button>
</>
)}
</p>
)}
{manual && (
<form
className="mt-2 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
send();
}}
>
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Paste the code"
aria-label="Code from the Claude sign-in page"
className="h-8 bg-card font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<Button type="submit" size="sm" variant="outline" disabled={!code.trim() || login.submit.isPending}>
{login.submit.isPending && <Spinner />} Continue
</Button>
</form>
)}
</div>
);
}
Loading
Loading