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
45 changes: 36 additions & 9 deletions apps/desktop/src/components/chat/turn-end.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Agent, ConversationWithMessages, Message, RetryMode } from "@godmode/shared";
import { needsFix, retryHelps, retryModeOf, runEndOf, type RunEnd } from "@godmode/shared";
import { ArrowUpRight, MessageSquarePlus, Play, RotateCcw } from "lucide-react";
import { ArrowUpRight, 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 { api, ApiRequestError, errorMessage } from "@/lib/api";
import { useTasks } from "@/lib/hooks";
import { useRoutines, useTasks } from "@/lib/hooks";
import { qk } from "@/lib/queryKeys";

const PLATFORM_ORIGINS = new Set(["slack", "telegram", "teams"]);
Expand All @@ -22,7 +23,9 @@ function fixFor(end: RunEnd, agentName: string): { text: string; to?: string; la
case "cli":
return { text: "Install Claude Code, then try again.", to: "/settings/system", label: "Open System" };
case "vm":
return { text: "Turn virtual machines on, or pick no VM below, then try again.", to: "/settings/vms", label: "VM settings" };
return end.off
? { text: "Turn virtual machines on, or pick no VM below, then try again.", to: "/settings/vms", label: "VM settings" }
: { text: "The VM couldn't be started — check it, or pick no VM below, then try again.", to: "/vms", label: "Open VMs" };
case "folder":
return { text: `Pick another folder for ${agentName} below, then try again.` };
default:
Expand All @@ -44,7 +47,9 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver
retry: false,
});
const { data: tasks = [] } = useTasks("all");
const { data: routines = [] } = useRoutines();
const chat = useStartAgentChat();
const runRoutine = useRunRoutine();
const name = agent?.name ?? "The agent";
const retry = useMutation({
mutationFn: () => api.conversations.retry(conversation.id, message.runId!),
Expand All @@ -70,20 +75,42 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver
if (!run || (run.status !== "failed" && run.status !== "cancelled")) return null;
if (run.trigger === "dream" || run.trigger === "check" || PLATFORM_ORIGINS.has(conversation.origin)) return null;

const task = tasks.find((t) => t.conversationId === conversation.id);
if (task) {
// A ticket's chat (also one whose ticket is archived) goes on from the ticket.
if (conversation.origin === "task") {
const task = tasks.find((t) => t.conversationId === conversation.id);
return (
<Row>
<span className="min-w-0">This chat works on ticket #{task.number} — continue it from there.</span>
<span className="min-w-0">{task ? `This chat works on ticket #${task.number} — continue it from there.` : "This chat works on a ticket — continue it from there."}</span>
<Button size="xs" variant="outline" asChild>
<Link to={`/tasks?task=${task.id}`}>
<Link to={task ? `/tasks?task=${task.id}` : "/tasks?view=archived"}>
Open ticket <ArrowUpRight />
</Link>
</Button>
</Row>
);
}

// An automation's run is run again by the automation, so its own checks and history follow it.
if (run.trigger === "routine") {
const routine = routines.find((r) => r.id === run.routineId);
if (!routine) return null;
return (
<Row>
<span className="min-w-0">This was “{routine.name}” — run the automation again.</span>
<span className="flex shrink-0 items-center gap-1">
<Button size="xs" variant="outline" disabled={runRoutine.isPending || !routine.enabled} aria-busy={runRoutine.isPending} onClick={() => runRoutine.mutate(routine)}>
{runRoutine.isPending ? <Spinner /> : <Play className="fill-current" />} Run now
</Button>
<Button size="xs" variant="ghost" asChild>
<Link to={`/automations?edit=${routine.id}`}>
<Workflow /> Open automation
</Link>
</Button>
</span>
</Row>
);
}

const end = runEndOf(run.error ?? "");
if (!retryHelps(end)) {
return (
Expand All @@ -101,7 +128,7 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver
const mode: RetryMode = retryModeOf(message.blocks) === "continue" && conversation.claudeSessionId ? "continue" : "again";
const label = mode === "continue" ? "Continue" : "Try again";
const action = (variant: "outline" | "ghost") => (
<Button size="xs" variant={variant} disabled={retry.isPending} onClick={() => retry.mutate()} title={mode === "continue" ? `${name} picks up where it stopped` : `Sends the message to ${name} again`}>
<Button size="xs" variant={variant} disabled={retry.isPending} aria-busy={retry.isPending} onClick={() => retry.mutate()} title={mode === "continue" ? `${name} picks up where it stopped` : `Sends the message to ${name} again`}>
{retry.isPending ? <Spinner /> : mode === "continue" ? <Play className="fill-current" /> : <RotateCcw />} {label}
</Button>
);
Expand Down Expand Up @@ -143,7 +170,7 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver

function Row({ children }: { children: ReactNode }) {
return (
<div role="group" aria-label="This turn ended early" className="-mt-3 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 pl-11 text-xs text-muted-foreground">
<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">
{children}
</div>
);
Expand Down
40 changes: 29 additions & 11 deletions apps/mobile/src/app/chat/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FlashList, type FlashListRef } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { router, Stack, useFocusEffect, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Pressable, StyleSheet, View } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand All @@ -13,7 +13,7 @@ import { LiveStrip } from "@/components/live-strip";
import { AssistantMessage, UserMessage } from "@/components/message";
import { Icon } from "@/components/icon";
import { EmptyState, T, tap } from "@/components/ui";
import { api, errorText } from "@/lib/api";
import { ApiError, api, errorText } from "@/lib/api";
import { encodeFiles, type PendingFile } from "@/lib/attachments";
import { useAgents } from "@/lib/hooks";
import { useConversationRun, useLive } from "@/lib/live";
Expand Down Expand Up @@ -95,11 +95,13 @@ export default function Chat() {
.catch((err) => Alert.alert("Couldn't continue", errorText(err)));
};

// The latest turn ended early (failed, stopped, cut off): one tap picks it up.
const ended = !run && !paused && conversation.data ? endedTurn(conversation.data) : null;
// The latest turn ended early (failed, stopped, cut off): one tap picks it up — when nothing else would happen first.
const [retrying, setRetrying] = useState(false);
const ended = !run && !paused && conversation.data && agent?.enabled !== false ? endedTurn(conversation.data) : null;
const retry = () => {
if (!ended) return;
if (!ended || retrying) return;
tap();
setRetrying(true);
api.conversations
.retry(id, ended.runId)
.then((result) => {
Expand All @@ -108,7 +110,12 @@ export default function Chat() {
);
useLive.getState().runStarted(result.run);
})
.catch((err) => Alert.alert("Couldn't pick this up", errorText(err)));
.catch((err) => {
// Something else moved the chat on (another tap, the computer): show that instead of an error.
if (err instanceof ApiError && err.code === "stale") void queryClient.invalidateQueries({ queryKey: qk.conversation(id) });
else Alert.alert("Couldn't pick this up", errorText(err));
})
.finally(() => setRetrying(false));
};

const title = conversation.data?.title || "Chat";
Expand Down Expand Up @@ -151,7 +158,7 @@ export default function Chat() {
onContinue={resume}
/>
) : ended ? (
<EndedStrip mode={ended.mode} onRetry={retry} />
<EndedStrip mode={ended.mode} onRetry={retry} busy={retrying} />
) : null}
<ComposerDock>
<Composer
Expand Down Expand Up @@ -204,9 +211,12 @@ function PausedStrip({ limit, held, auto, onContinue }: { limit: string | null;
);
}

/** The chat's last turn, when it ended early and trying again can help (not in a ticket's or a chat platform's chat). */
/**
* The chat's last turn, when it ended early and trying again can help: not in a ticket's, an automation's or a chat
* platform's chat (those go on from there), nor while messages wait in the chat (they'd go along unasked).
*/
function endedTurn(conv: ConversationWithMessages): { runId: string; mode: RetryMode } | null {
if (conv.origin === "task" || conv.origin === "dream" || conv.origin === "slack" || conv.origin === "telegram" || conv.origin === "teams") return null;
if (["task", "routine", "dream", "slack", "telegram", "teams"].includes(conv.origin) || conv.queue.length > 0) return null;
const last = conv.messages[conv.messages.length - 1];
if (last?.role !== "assistant" || !last.runId) return null;
const end = last.blocks[last.blocks.length - 1];
Expand All @@ -216,15 +226,23 @@ function endedTurn(conv: ConversationWithMessages): { runId: string; mode: Retry
}

/** Above the composer when the last turn ended early. */
function EndedStrip({ mode, onRetry }: { mode: RetryMode; onRetry: () => void }) {
function EndedStrip({ mode, onRetry, busy }: { mode: RetryMode; onRetry: () => void; busy: boolean }) {
const c = useColors();
return (
<View style={[styles.paused, { backgroundColor: c.surface, borderColor: c.border }]}>
<Icon name="warning" size={15} color={c.textMuted} />
<T variant="footnote" muted style={{ flex: 1 }} numberOfLines={2}>
{mode === "continue" ? "Stopped before it was done" : "Didn't get through"}
</T>
<Pressable onPress={onRetry} hitSlop={10} accessibilityRole="button" accessibilityLabel={mode === "continue" ? "Continue" : "Try again"}>
<Pressable
onPress={onRetry}
disabled={busy}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={mode === "continue" ? "Continue" : "Try again"}
accessibilityState={{ busy, disabled: busy }}
style={{ opacity: busy ? 0.5 : 1 }}
>
<T variant="footnote" color={c.primary} style={{ fontWeight: "600" }}>
{mode === "continue" ? "Continue" : "Try again"}
</T>
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { memo, useEffect, useState } from "react";
import { LayoutAnimation, Pressable, StyleSheet, View } from "react-native";
import Animated, { FadeIn, useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming } from "react-native-reanimated";
import type { Attachment, Message, MessageBlock } from "@godmode/shared";
import { toolActivity } from "@godmode/shared";
import { FileChip } from "./attachments";
import { Icon } from "./icon";
import { Markdown } from "./markdown";
import { Row, T, tap } from "./ui";
import { activityText, toolLabel } from "@/lib/format";
import { toolLabel } from "@/lib/format";
import { radius, space, useColors } from "@/lib/theme";

type Step = Extract<MessageBlock, { type: "tool_use" | "thinking" }>;
Expand Down Expand Up @@ -209,7 +210,7 @@ function Steps({ steps, live }: { steps: Step[]; live?: boolean }) {
const label = latest ? toolLabel(latest) : null;
const failed = tools.some((t) => t.isError);
const pending = live && latest && latest.result === undefined;
const summary = pending ? activityText(`Using ${latest.name.replace(/^mcp__.+?__/, "")}`) : label ? label.title : "Thought it through";
const summary = pending ? toolActivity(latest.name, latest.input).replace(/…$/, "") : label ? label.title : "Thought it through";
const count = tools.length;

return (
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ const DOING: [RegExp, string][] = [
/** The core says "Using browser_navigate"; people read "Opening a page". */
export function activityText(label: string | null | undefined): string {
if (!label) return "Thinking";
// An older core sends "Using <tool id>"; a current one sends plain words ("Using Linear…" among them).
// An older core sends "Using <tool id>" (no "…"); a current one sends plain words ending in "…" ("Using Linear…").
const tool = /^Using ([\w.-]+)$/.exec(label)?.[1];
if (!tool || !/[_.]/.test(tool)) return label.replace(/…$/, "");
if (!tool) return label.replace(/…$/, "");
return DOING.find(([re]) => re.test(tool))?.[1] ?? "Working";
}
7 changes: 5 additions & 2 deletions apps/mobile/src/lib/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ interface LiveState {
/** Why it's offline in the cloud gateway's words (computer not connected, plan limit), when it said so. */
offlineReason: string | null;
runs: Record<string, LiveRun>;
/** Runs seen ending lately: an answer that arrives after their end doesn't bring them back as live. */
ended: string[];
/** Latest activity per run; may arrive before the run itself is known. */
labels: Record<string, string>;
drafts: Record<string, Draft>;
Expand All @@ -49,6 +51,7 @@ export const useLive = create<LiveState>((set) => ({
status: "connecting",
offlineReason: null,
runs: {},
ended: [],
labels: {},
drafts: {},
frames: {},
Expand All @@ -62,7 +65,7 @@ export const useLive = create<LiveState>((set) => ({
drafts: Object.fromEntries(Object.entries(s.drafts).filter(([, d]) => ids.has(d.runId))),
};
}),
runStarted: (run) => set((s) => ({ runs: { ...s.runs, [run.id]: { run, activity: s.labels[run.id] ?? null } } })),
runStarted: (run) => set((s) => (s.ended.includes(run.id) ? s : { runs: { ...s.runs, [run.id]: { run, activity: s.labels[run.id] ?? null } } })),
runActivity: (runId, label) =>
set((s) => ({
labels: { ...s.labels, [runId]: label },
Expand All @@ -74,7 +77,7 @@ export const useLive = create<LiveState>((set) => ({
const { [run.id]: _label, ...labels } = s.labels;
const drafts = { ...s.drafts };
if (drafts[run.conversationId]?.runId === run.id) delete drafts[run.conversationId];
return { runs, labels, drafts };
return { runs, labels, drafts, ended: [...s.ended.slice(-49), run.id] };
}),
delta: (conversationId, draft) => set((s) => ({ drafts: { ...s.drafts, [conversationId]: draft } })),
frame: (key, frame) => set((s) => ({ frames: { ...s.frames, [key]: frame } })),
Expand Down
8 changes: 5 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,11 @@ queued, budgeted and reported like a message from the human:
starting over. **Try again** otherwise: the run's own prompt is sent again (saved secrets stay masked; the marker
says so). A turn an automation, follow-up or another agent started is answered to the human in this chat.
* The chat gets a `retry` marker (a system message, `content` "Continue where you stopped" / "Try again").
* Refused: a run that isn't the chat's latest (`stale`), one that didn't end early, a chat that works, stands still or
belongs to a ticket (`task_chat` — continued from the ticket) or a chat platform (`platform_chat` — the person asks
there), dreams and condition checks, and a chat too long to go on (`context`). Godmode's own end-of-turn sentences
* Refused: a run that isn't the chat's latest (`stale`), one that didn't end early, a chat that works, stands still,
has messages waiting (`queued` — they would go along unasked) or belongs to a ticket (`task_chat` — continued from the
ticket) or a chat platform (`platform_chat` — the person asks there), an automation's run (`automation` — the desktop
offers the automation's *Run now*, so its busy check, events and "Needs you" follow), dreams and condition checks, and
a chat too long to go on (`context`). A chat on a runner is picked up there (the request is forwarded). Godmode's own end-of-turn sentences
live in `@godmode/shared` (`runEndOf`); for a sign-in, CLI, VM, folder or model error the desktop links to the fix
and offers *Try again* next to it.

Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/remote/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const RUN = /^\/api\/runs\/([A-Za-z0-9_-]{1,100})(\/cancel|\/log)?$/;
const BROWSER = /^\/api\/browser\/profiles\/[A-Za-z0-9_-]{1,100}\/(input|navigate)$/;
const PROXY = /^\/api\/runners\/([A-Za-z0-9_-]{1,100})\/proxy(\/.*)$/;
/** Chat sub-routes the runner answers. `files` resolves the paths in its messages on its own disk. */
const FORWARDED = /^\/(messages|queue\/send|queue\/[A-Za-z0-9_-]{1,100}|pause|continue|followup|followup\/run|files)$/;
const FORWARDED = /^\/(messages|queue\/send|queue\/[A-Za-z0-9_-]{1,100}|pause|continue|retry|followup|followup\/run|files)$/;
const MAX_BODY = 64 * 1024 * 1024;

function runnerOfRun(runId: string): string | null {
Expand Down Expand Up @@ -133,7 +133,8 @@ async function conversationRequest(c: Context, runnerId: string, id: string, res
return null;
}
if (!FORWARDED.test(rest)) return null;
if (rest === "/messages" && method === "POST") await prepareRemoteMessage(id);
// Both start a run there: the runner gets what it needs for it first.
if ((rest === "/messages" || rest === "/retry") && method === "POST") await prepareRemoteMessage(id);
return forward(c, runnerId, pathAndQuery(c));
}

Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/runner/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,13 @@ export interface ContinueAnswer {
const NOTE_TAGS = /<\/?(?:godmode[\w-]*|answer-from-human|message-from-human|your-question)\b[^>]*>/gi;

export function stripNoteTags(text: string): string {
return text.replace(NOTE_TAGS, "");
// Until nothing is left: "<</godmode-x>/godmode-continue>" would leave a tag behind after one pass.
let out = text;
for (let prev = ""; prev !== out; ) {
prev = out;
out = out.replace(NOTE_TAGS, "");
}
return out;
}

/**
Expand Down Expand Up @@ -630,8 +636,8 @@ export function retryWhy(end: RunEnd | null, error: string, userName: string): s
case "budget":
return "it reached its cost limit";
default:
// The error may carry text the model or a page wrote: it is quoted as data, never as instructions.
return `it failed with this error: “${line(error.split("\n")[0] ?? "", 300)}”`;
// The error may carry text the model or a page wrote: it is quoted as data, never as instructions (no tags at all).
return `it failed with this error: “${line(error.split("\n")[0] ?? "", 300).replace(/[<>]/g, (c) => (c === "<" ? "‹" : "›"))}”`;
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
RUN_INTERRUPTED,
RUN_MAX_TURNS,
RUN_SHUT_DOWN,
RUN_STOPPED_BY_USER,
TASK_PRIORITY_RANK,
WORKFLOW_TOOL,
isModelId,
Expand Down Expand Up @@ -787,7 +788,7 @@ function closePaused(row: RunRow, reason: string, byHuman = false): void {
if (p && convAlive) {
safely("close the paused message", () => {
const message = getMessage(p.message_id);
const blocks = p.reason === "question" ? withdrawOpenBlocks(message.blocks, reason === "Cancelled" || reason === "Cancelled by user" ? null : reason) : message.blocks;
const blocks = p.reason === "question" ? withdrawOpenBlocks(message.blocks, reason === "Cancelled" || reason === RUN_STOPPED_BY_USER ? null : reason) : message.blocks;
assistant = updateMessage(p.message_id, { blocks: [...blocks, { type: "notice", level: "info", text: reason }] });
});
}
Expand Down
Loading
Loading