feat: autopilot progress, composer start and morning summary (#146)

The autopilot's start line counts its steps and folds open to the latest
checkpoints, and its result reads as a card. The composer's plus menu
starts an autopilot with the draft as the order and a choice of until
done, tomorrow morning or a time. Today lists the autopilots that run,
wait or ended within a day, and their step runs no longer show one by
one. Autopilot runs send no completion or failure push; only a pause
notifies.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-05 04:45:49 +02:00 committed by GitHub
parent c69f00e891
commit 54658ee689
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1142 additions and 59 deletions

View File

@ -60,6 +60,8 @@ One bot has one continuous visible thread because continuity is part of its iden
Team Computers share a persisted browser identity and isolate simultaneous live desktops when supported because shared login state and safe concurrency are both required. Team Computers share a persisted browser identity and isolate simultaneous live desktops when supported because shared login state and safe concurrency are both required.
Private Computers isolate the entire working home because some trust boundaries must be stronger than collaboration convenience. Private Computers isolate the entire working home because some trust boundaries must be stronger than collaboration convenience.
Connections belong to the user or space rather than requiring repetitive per-bot setup because bots differ through role and state, not account plumbing. Connections belong to the user or space rather than requiring repetitive per-bot setup because bots differ through role and state, not account plumbing.
Bots answer with cards from a fixed catalog of sections rather than generated interface because the backend must validate every card and apply every tap, and a small catalog stays calm and consistent on every surface.
An autopilot works on an order with no time or cost limit, but pauses when runs stop reporting progress and always shows when it started and ended, because long unattended work must stay visible and must never spin silently.
## Scope ## Scope

View File

@ -8,7 +8,8 @@ import {
resumeAutopilot, resumeAutopilot,
startAutopilot, startAutopilot,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import type { Actor, Autopilot } from "@rakazo/contracts"; import type { Actor, Autopilot, AutopilotRecent } from "@rakazo/contracts";
import { AUTOPILOT_RECENT_MS } from "@rakazo/contracts";
import type { PrismaClient, ThreadEvents } from "@rakazo/db"; import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { IsolationError } from "@rakazo/db"; import { IsolationError } from "@rakazo/db";
import type { ThreadTarget } from "./thread-target.js"; import type { ThreadTarget } from "./thread-target.js";
@ -77,3 +78,29 @@ export async function resumeThreadAutopilot(
} }
return { ok: true }; return { ok: true };
} }
/** Today's view: the user's open autopilots in this space and those that ended within a day. */
export async function listRecentAutopilots(
deps: Pick<AutopilotDeps, "prisma">,
actor: Actor,
now = new Date(),
): Promise<AutopilotRecent[]> {
const rows = await deps.prisma.autopilot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ status: { in: ["active", "paused"] } },
{ endedAt: { gte: new Date(now.getTime() - AUTOPILOT_RECENT_MS) } },
],
},
include: { thread: { select: { groupId: true } } },
orderBy: { startedAt: "desc" },
take: 10,
});
return rows.map((row) => ({
...autopilotView(row),
spaceId: row.spaceId,
groupId: row.thread.groupId,
}));
}

View File

@ -173,6 +173,7 @@ import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
import { import {
getThreadAutopilot, getThreadAutopilot,
listRecentAutopilots,
resumeThreadAutopilot, resumeThreadAutopilot,
startThreadAutopilot, startThreadAutopilot,
stopThreadAutopilot, stopThreadAutopilot,
@ -5173,6 +5174,9 @@ export function createRouter(deps: RouterDeps) {
const target = await resolveThreadTarget(deps.prisma, context.actor, input); const target = await resolveThreadTarget(deps.prisma, context.actor, input);
return resumeThreadAutopilot(deps, context.actor, target); return resumeThreadAutopilot(deps, context.actor, target);
}), }),
recent: authed.autopilot.recent.handler(async ({ context }) => ({
items: await listRecentAutopilots(deps, context.actor),
})),
}, },
voice: { voice: {
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()), catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),

View File

@ -20,6 +20,16 @@ describe("run activity copy", () => {
).toBe("Maya asked: Please check the release workflow."); ).toBe("Maya asked: Please check the release workflow.");
}); });
it("names an autopilot step by the user's order", () => {
expect(
activityPromptSnippet({
trigger: "autopilot",
prompt:
"Autopilot: continue the user's order on your own.\nOrder: Tidy the tickets\nUntil: done",
}),
).toBe("Tidy the tickets");
});
it("names a delegated task by its title instead of its wake prompt", () => { it("names a delegated task by its title instead of its wake prompt", () => {
expect( expect(
activityPromptSnippet({ activityPromptSnippet({

View File

@ -26,6 +26,10 @@ export function activityPromptSnippet(
const task = parsed.success ? taskBlockOf(parsed.data) : undefined; const task = parsed.success ? taskBlockOf(parsed.data) : undefined;
return promptSnippet(task?.title ?? input.prompt, max); return promptSnippet(task?.title ?? input.prompt, max);
} }
if (input.trigger === "autopilot") {
// The continuation prompt is for the model; people know the run by its order.
return promptSnippet(/^Order: (.*)$/m.exec(input.prompt)?.[1] ?? input.prompt, max);
}
if (input.trigger !== "bot_message") return promptSnippet(input.prompt, max); if (input.trigger !== "bot_message") return promptSnippet(input.prompt, max);
const parsed = MessageBlock.array().safeParse(input.sourceBlocks); const parsed = MessageBlock.array().safeParse(input.sourceBlocks);
const message = parsed.success ? botMessageContext(parsed.data) : undefined; const message = parsed.success ? botMessageContext(parsed.data) : undefined;

View File

@ -9,13 +9,16 @@ import type {
Routine, Routine,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { import {
AUTOPILOT_LIMITS,
canReactToThreadMessage, canReactToThreadMessage,
MESSAGE_REACTIONS, MESSAGE_REACTIONS,
type MessageReaction, type MessageReaction,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import type { AutopilotUntilChoice } from "@rakazo/core";
import { import {
abortableDelay, abortableDelay,
attachmentsForThread, attachmentsForThread,
autopilotUntil,
buildComposerMentionOptions, buildComposerMentionOptions,
type ComposerMention, type ComposerMention,
cloudAgentHttpsUrl, cloudAgentHttpsUrl,
@ -1277,6 +1280,8 @@ function Thread() {
); );
function showAttachMenu() { function showAttachMenu() {
// The autopilot takes the typed order, so it is offered once there is one.
const order = draft.trim();
Alert.alert(t("Attach"), undefined, [ Alert.alert(t("Attach"), undefined, [
{ {
text: t("Photo library"), text: t("Photo library"),
@ -1284,6 +1289,34 @@ function Thread() {
}, },
{ text: t("Camera"), onPress: () => void addAttachments(takePhoto) }, { text: t("Camera"), onPress: () => void addAttachments(takePhoto) },
{ text: t("File"), onPress: () => void addAttachments(pickDocuments) }, { text: t("File"), onPress: () => void addAttachments(pickDocuments) },
...(order ? [{ text: t("Autopilot"), onPress: () => askAutopilotUntil(order) }] : []),
{ text: t("Cancel"), style: "cancel" as const },
]);
}
function askAutopilotUntil(order: string) {
const start = (choice: AutopilotUntilChoice) => {
const targetBotId = botId;
const targetGroupId = groupId;
if (!targetBotId && !targetGroupId) return;
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
const until = autopilotUntil(choice, new Date());
void rpc("autopilot/start", {
...target,
goal: order.slice(0, AUTOPILOT_LIMITS.goal),
...(until ? { until: until.toISOString() } : {}),
})
.then(async () => {
setDraft("");
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
})
.catch((err) =>
Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")),
);
};
Alert.alert(t("Until when?"), order, [
{ text: t("Until done"), onPress: () => start({ kind: "done" }) },
{ text: t("Until tomorrow morning"), onPress: () => start({ kind: "morning" }) },
{ text: t("Cancel"), style: "cancel" }, { text: t("Cancel"), style: "cancel" },
]); ]);
} }

View File

@ -15,7 +15,10 @@ const ICONS = {
"autopilot.finished": { ios: "checkmark.circle", android: "checkmark-circle-outline" }, "autopilot.finished": { ios: "checkmark.circle", android: "checkmark-circle-outline" },
} as const; } as const;
/** One quiet line when the autopilot starts, pauses, stops or finishes. */ /**
* One quiet line when the autopilot starts, pauses, stops or finishes. The start line counts the
* steps and folds open to the latest checkpoints; a finished autopilot's result reads as a card.
*/
export function AutopilotLine({ export function AutopilotLine({
block, block,
onAction, onAction,
@ -27,6 +30,7 @@ export function AutopilotLine({
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [expanded, setExpanded] = useState(false);
const open = !block.ended && Boolean(onAction); const open = !block.ended && Boolean(onAction);
const muted = { color: tokens.mutedForeground, fontSize: 13.5 }; const muted = { color: tokens.mutedForeground, fontSize: 13.5 };
@ -66,6 +70,12 @@ export function AutopilotLine({
if (block.durationMs !== undefined) parts.push(formatAutopilotDuration(block.durationMs)); if (block.durationMs !== undefined) parts.push(formatAutopilotDuration(block.durationMs));
} }
const icon = ICONS[block.event]; const icon = ICONS[block.event];
const checkpoints = block.event === "autopilot.started" ? (block.checkpoints ?? []) : [];
const latest = checkpoints.at(-1)?.text;
const time = new Intl.DateTimeFormat(dateLocaleForUi(locale), {
hour: "numeric",
minute: "2-digit",
});
const link = (text: string, action: AutopilotAction) => ( const link = (text: string, action: AutopilotAction) => (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
@ -102,6 +112,26 @@ export function AutopilotLine({
· {part} · {part}
</Text> </Text>
))} ))}
{block.steps && block.event === "autopilot.started" ? (
<>
<Text style={muted}>·</Text>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
hitSlop={10}
onPress={() => setExpanded((value) => !value)}
>
<Text style={[muted, { fontWeight: "600" }]}>
{block.steps === 1 ? t("1 step") : t("{count} steps", { count: block.steps })}
</Text>
</Pressable>
{latest && !expanded ? (
<Text style={muted} numberOfLines={1}>
· {t("last: {latest}", { latest })}
</Text>
) : null}
</>
) : null}
{open && block.event === "autopilot.paused" ? ( {open && block.event === "autopilot.paused" ? (
<> <>
<Text style={muted}>·</Text> <Text style={muted}>·</Text>
@ -115,10 +145,38 @@ export function AutopilotLine({
</> </>
) : null} ) : null}
</View> </View>
{block.event === "autopilot.finished" && block.text ? ( {expanded && checkpoints.length > 0 ? (
<Text style={{ color: tokens.foreground, fontSize: 14, textAlign: "center" }}> <View style={{ width: "100%", maxWidth: 420, gap: 4, paddingHorizontal: 16 }}>
{block.text} {checkpoints.map((checkpoint, index) => (
<View key={`${checkpoint.at}-${index}`} style={{ flexDirection: "row", gap: 8 }}>
<Text
style={[muted, { width: 64, textAlign: "right", fontVariant: ["tabular-nums"] }]}
>
{time.format(new Date(checkpoint.at))}
</Text> </Text>
<Text style={{ flex: 1, color: tokens.foreground, fontSize: 13.5 }}>
{checkpoint.text}
</Text>
</View>
))}
</View>
) : null}
{block.event === "autopilot.finished" && block.text ? (
<View
style={{
width: "100%",
maxWidth: 520,
marginTop: 4,
borderRadius: 12,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 14,
paddingVertical: 10,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 14 }}>{block.text}</Text>
</View>
) : null} ) : null}
</View> </View>
); );

View File

@ -691,6 +691,12 @@ export const RU_MESSAGES: Record<string, string> = {
Expired: "Истекло", Expired: "Истекло",
Autopilot: "Автопилот", Autopilot: "Автопилот",
"Autopilot finished": "Автопилот завершён", "Autopilot finished": "Автопилот завершён",
"Until when?": "До какого времени?",
"Until done": "До готовности",
"Until tomorrow morning": "До завтрашнего утра",
"1 step": "1 шаг",
"{count} steps": "Шагов: {count}",
"last: {latest}": "последний: {latest}",
"Autopilot paused": "Автопилот на паузе", "Autopilot paused": "Автопилот на паузе",
"Autopilot stopped": "Автопилот остановлен", "Autopilot stopped": "Автопилот остановлен",
"allowed: {allowed}": "разрешено: {allowed}", "allowed: {allowed}": "разрешено: {allowed}",

View File

@ -675,6 +675,12 @@ export const ZH_MESSAGES: Record<string, string> = {
Expired: "已过期", Expired: "已过期",
Autopilot: "自动驾驶", Autopilot: "自动驾驶",
"Autopilot finished": "自动驾驶已完成", "Autopilot finished": "自动驾驶已完成",
"Until when?": "持续到什么时候?",
"Until done": "直到完成",
"Until tomorrow morning": "直到明天早上",
"1 step": "1 步",
"{count} steps": "{count} 步",
"last: {latest}": "最近:{latest}",
"Autopilot paused": "自动驾驶已暂停", "Autopilot paused": "自动驾驶已暂停",
"Autopilot stopped": "自动驾驶已停止", "Autopilot stopped": "自动驾驶已停止",
"allowed: {allowed}": "已允许:{allowed}", "allowed: {allowed}": "已允许:{allowed}",

View File

@ -60,3 +60,51 @@ test("an autopilot without progress pauses and stops on request", async ({ page
await stopped.scrollIntoViewIfNeeded(); await stopped.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "autopilot-paused-light"); await captureScreenshot(page, testInfo, "autopilot-paused-light");
}); });
test("an autopilot started from the composer works overnight and reports in Today", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `autopilot-night-${stamp}@rakazo.test`, "password12", "Autopilot Night");
await completeOnboarding(page);
await page.getByPlaceholder(/Message/).fill("Tidy the tickets");
await page.getByRole("button", { name: "Add" }).click();
await page.getByRole("menuitem", { name: "Autopilot" }).click();
const dialog = page.getByTestId("autopilot-dialog");
await expect(dialog.getByRole("textbox", { name: "Order" })).toHaveValue("Tidy the tickets");
await dialog.getByRole("radio", { name: "Until tomorrow morning" }).click();
await dialog.getByRole("button", { name: "Start" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByPlaceholder(/Message/)).toHaveValue("");
const start = page.locator('[data-testid="autopilot-line"][data-event="autopilot.started"]');
await expect(start).toContainText("Tidy the tickets", { timeout: 60_000 });
await expect(start).toContainText(/until 8:00/);
const finished = page.locator('[data-testid="autopilot-line"][data-event="autopilot.finished"]');
await expect(finished).toBeVisible({ timeout: 120_000 });
await expect(finished.getByTestId("autopilot-result")).toHaveText(
"Tidied 5 tickets; nothing waits on you.",
);
await start.getByRole("button", { name: "5 steps" }).click();
await expect(start.getByTestId("autopilot-steps").locator("li")).toHaveCount(5);
await expect(start.getByTestId("autopilot-steps")).toContainText("Ticket 5 tidied");
await useAppearance(page, "light");
await start.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "autopilot-night-light");
await useAppearance(page, "dark");
await start.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "autopilot-night-dark");
// The morning summary: what the autopilot did, in Today.
await page.getByRole("button", { name: "Today", exact: true }).click();
const row = page.getByTestId("heute-autopilot-row");
await expect(row).toHaveAttribute("data-status", "finished", { timeout: 30_000 });
await expect(row).toContainText("Tidy the tickets");
await expect(row).toContainText("Tidied 5 tickets; nothing waits on you.");
await expect(row).toContainText("5 steps");
await captureScreenshot(page, testInfo, "autopilot-today-dark");
await row.click();
await expect(start).toBeVisible();
});

View File

@ -196,7 +196,7 @@ test("chat composer controls are vertically centered", async ({ page }) => {
await completeOnboarding(page); await completeOnboarding(page);
const centers = await page.getByTestId("composer-bar").evaluate((composer) => const centers = await page.getByTestId("composer-bar").evaluate((composer) =>
["Attach file", "Message", "Voice", "Send"].map((label) => { ["Add", "Message", "Voice", "Send"].map((label) => {
const element = composer.querySelector<HTMLElement>(`[aria-label="${label}"]`); const element = composer.querySelector<HTMLElement>(`[aria-label="${label}"]`);
if (!element) throw new Error(`Missing composer control: ${label}`); if (!element) throw new Error(`Missing composer control: ${label}`);
const box = element.getBoundingClientRect(); const box = element.getBoundingClientRect();

View File

@ -645,5 +645,13 @@
"Autopilot stopped": "Autopilot gestoppt", "Autopilot stopped": "Autopilot gestoppt",
"Autopilot finished": "Autopilot beendet", "Autopilot finished": "Autopilot beendet",
"until {until}": "bis {until}", "until {until}": "bis {until}",
"allowed: {allowed}": "erlaubt: {allowed}" "allowed: {allowed}": "erlaubt: {allowed}",
"{steps, plural, one {# step} other {# steps}}": "{steps, plural, one {# Schritt} other {# Schritte}}",
"last: {latest}": "zuletzt: {latest}",
"Order": "Auftrag",
"Time": "Uhrzeit",
"Until a time": "Bis zu einer Uhrzeit",
"Until done": "Bis fertig",
"Until tomorrow morning": "Bis morgen früh",
"Until when?": "Bis wann?"
} }

View File

@ -134,6 +134,25 @@ describe("buildHeuteBrief", () => {
expect(brief.done[0]?.run.runId).toBe("run-4"); expect(brief.done[0]?.run.runId).toBe("run-4");
}); });
it("leaves an autopilot's step runs to its own row", () => {
const brief = buildHeuteBrief({
bots: [],
groups: [],
activeRuns: [run({ runId: "step-2", status: "running", trigger: "autopilot" })],
recentRuns: [
run({
runId: "step-1",
status: "completed",
trigger: "autopilot",
updatedAt: "2026-09-22T09:00:00.000Z",
}),
],
now,
});
expect(brief.doing).toHaveLength(0);
expect(brief.done).toHaveLength(0);
});
it("caps needs-you at three items", () => { it("caps needs-you at three items", () => {
const bots = Array.from({ length: 5 }, (_, index) => const bots = Array.from({ length: 5 }, (_, index) =>
bot({ id: `bot-${index}`, unread: true, name: `Bot ${index}` }), bot({ id: `bot-${index}`, unread: true, name: `Bot ${index}` }),

View File

@ -117,12 +117,16 @@ export function buildHeuteBrief(params: {
coveredTargets.add(key); coveredTargets.add(key);
} }
// An autopilot's runs show as its one row in Today, not one row per step.
const doing: DoingItem[] = activeRuns const doing: DoingItem[] = activeRuns
.filter((run) => isDoingStatus(run.status)) .filter((run) => isDoingStatus(run.status) && run.trigger !== "autopilot")
.map((run) => ({ kind: "run" as const, run })); .map((run) => ({ kind: "run" as const, run }));
const done: DoneItem[] = recentRuns const done: DoneItem[] = recentRuns
.filter((run) => run.status === "completed" && isToday(run.updatedAt, now)) .filter(
(run) =>
run.status === "completed" && run.trigger !== "autopilot" && isToday(run.updatedAt, now),
)
.slice(0, 5) .slice(0, 5)
.map((run) => ({ kind: "run" as const, run })); .map((run) => ({ kind: "run" as const, run }));

View File

@ -264,6 +264,11 @@ msgstr "Noch {remaining} · Bot beobachtet nur"
msgid "{size} B" msgid "{size} B"
msgstr "{size} B" msgstr "{size} B"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "{steps, plural, one {# step} other {# steps}}"
msgstr "{steps, plural, one {# Schritt} other {# Schritte}}"
#. placeholder {0}: runActivityLabel(run.status) #. placeholder {0}: runActivityLabel(run.status)
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
msgid "{title}, {0}" msgid "{title}, {0}"
@ -366,6 +371,7 @@ msgstr "Aktivität"
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx #: src/pages/ScratchpadSection.tsx
#: src/pages/shell/composer.tsx
msgid "Add" msgid "Add"
msgstr "Hinzufügen" msgstr "Hinzufügen"
@ -764,7 +770,9 @@ msgstr "Automatische Absender"
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "Automatische Absender erscheinen, sobald sie hier etwas posten." msgstr "Automatische Absender erscheinen, sobald sie hier etwas posten."
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
msgid "Autopilot" msgid "Autopilot"
msgstr "Autopilot" msgstr "Autopilot"
@ -2898,6 +2906,11 @@ msgstr "Sprache"
msgid "Lashes" msgid "Lashes"
msgstr "Wimpern" msgstr "Wimpern"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "last: {latest}"
msgstr "zuletzt: {latest}"
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
msgid "Later" msgid "Later"
msgstr "Später" msgstr "Später"
@ -3743,6 +3756,10 @@ msgstr "Optionaler Header-Wert"
msgid "Or connect an API key" msgid "Or connect an API key"
msgstr "Oder einen API-Schlüssel verbinden" msgstr "Oder einen API-Schlüssel verbinden"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Order"
msgstr "Auftrag"
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Organization API key" msgid "Organization API key"
msgstr "Organisations-API-Schlüssel" msgstr "Organisations-API-Schlüssel"
@ -3803,6 +3820,7 @@ msgstr "Füge die OpenAI-kompatible Adresse deines Servers ein. Rakazo ergänzt
msgid "Paste your API key" msgid "Paste your API key"
msgstr "Füge deinen API-Schlüssel ein" msgstr "Füge deinen API-Schlüssel ein"
#: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
msgstr "Pausiert" msgstr "Pausiert"
@ -4760,6 +4778,7 @@ msgid "Star"
msgstr "Stern" msgstr "Stern"
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/autopilot-dialog.tsx
msgid "Start" msgid "Start"
msgstr "Starten" msgstr "Starten"
@ -5042,6 +5061,10 @@ msgstr "Stufen"
msgid "Tightening the copy…" msgid "Tightening the copy…"
msgstr "Straffe den Text…" msgstr "Straffe den Text…"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Time"
msgstr "Uhrzeit"
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Time of day" msgid "Time of day"
msgstr "Uhrzeit" msgstr "Uhrzeit"
@ -5109,6 +5132,7 @@ msgid "Triage in progress…"
msgstr "Triage läuft…" msgstr "Triage läuft…"
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-dialog.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
@ -5163,6 +5187,22 @@ msgstr "Nicht unterstützter Dateityp: {0}"
msgid "until {until}" msgid "until {until}"
msgstr "bis {until}" msgstr "bis {until}"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until a time"
msgstr "Bis zu einer Uhrzeit"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until done"
msgstr "Bis fertig"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until tomorrow morning"
msgstr "Bis morgen früh"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until when?"
msgstr "Bis wann?"
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -264,6 +264,11 @@ msgstr "{remaining} left · bot is watching, not acting"
msgid "{size} B" msgid "{size} B"
msgstr "{size} B" msgstr "{size} B"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "{steps, plural, one {# step} other {# steps}}"
msgstr "{steps, plural, one {# step} other {# steps}}"
#. placeholder {0}: runActivityLabel(run.status) #. placeholder {0}: runActivityLabel(run.status)
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
msgid "{title}, {0}" msgid "{title}, {0}"
@ -366,6 +371,7 @@ msgstr "Activity"
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx #: src/pages/ScratchpadSection.tsx
#: src/pages/shell/composer.tsx
msgid "Add" msgid "Add"
msgstr "Add" msgstr "Add"
@ -764,7 +770,9 @@ msgstr "Automated senders"
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "Automated senders will appear after they post here." msgstr "Automated senders will appear after they post here."
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
msgid "Autopilot" msgid "Autopilot"
msgstr "Autopilot" msgstr "Autopilot"
@ -2898,6 +2906,11 @@ msgstr "Language"
msgid "Lashes" msgid "Lashes"
msgstr "Lashes" msgstr "Lashes"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "last: {latest}"
msgstr "last: {latest}"
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
msgid "Later" msgid "Later"
msgstr "Later" msgstr "Later"
@ -3743,6 +3756,10 @@ msgstr "Optional header value"
msgid "Or connect an API key" msgid "Or connect an API key"
msgstr "Or connect an API key" msgstr "Or connect an API key"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Order"
msgstr "Order"
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Organization API key" msgid "Organization API key"
msgstr "Organization API key" msgstr "Organization API key"
@ -3803,6 +3820,7 @@ msgstr "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if
msgid "Paste your API key" msgid "Paste your API key"
msgstr "Paste your API key" msgstr "Paste your API key"
#: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
msgstr "Paused" msgstr "Paused"
@ -4760,6 +4778,7 @@ msgid "Star"
msgstr "Star" msgstr "Star"
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/autopilot-dialog.tsx
msgid "Start" msgid "Start"
msgstr "Start" msgstr "Start"
@ -5042,6 +5061,10 @@ msgstr "Tiers"
msgid "Tightening the copy…" msgid "Tightening the copy…"
msgstr "Tightening the copy…" msgstr "Tightening the copy…"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Time"
msgstr "Time"
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Time of day" msgid "Time of day"
msgstr "Time of day" msgstr "Time of day"
@ -5109,6 +5132,7 @@ msgid "Triage in progress…"
msgstr "Triage in progress…" msgstr "Triage in progress…"
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-dialog.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
@ -5163,6 +5187,22 @@ msgstr "Unsupported file type: {0}"
msgid "until {until}" msgid "until {until}"
msgstr "until {until}" msgstr "until {until}"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until a time"
msgstr "Until a time"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until done"
msgstr "Until done"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until tomorrow morning"
msgstr "Until tomorrow morning"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until when?"
msgstr "Until when?"
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -264,6 +264,11 @@ msgstr "Quedan {remaining} · el bot observa, no actúa"
msgid "{size} B" msgid "{size} B"
msgstr "{size} B" msgstr "{size} B"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "{steps, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: runActivityLabel(run.status) #. placeholder {0}: runActivityLabel(run.status)
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
msgid "{title}, {0}" msgid "{title}, {0}"
@ -366,6 +371,7 @@ msgstr "Actividad"
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx #: src/pages/ScratchpadSection.tsx
#: src/pages/shell/composer.tsx
msgid "Add" msgid "Add"
msgstr "Agregar" msgstr "Agregar"
@ -764,7 +770,9 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
msgid "Autopilot" msgid "Autopilot"
msgstr "" msgstr ""
@ -2898,6 +2906,11 @@ msgstr "Idioma"
msgid "Lashes" msgid "Lashes"
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "last: {latest}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
msgid "Later" msgid "Later"
msgstr "" msgstr ""
@ -3743,6 +3756,10 @@ msgstr "Valor de encabezado opcional"
msgid "Or connect an API key" msgid "Or connect an API key"
msgstr "O conecta una clave de API" msgstr "O conecta una clave de API"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Order"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Organization API key" msgid "Organization API key"
msgstr "Clave de API de organización" msgstr "Clave de API de organización"
@ -3803,6 +3820,7 @@ msgstr "Pega la dirección compatible con OpenAI de tu servidor. Rakazo agrega /
msgid "Paste your API key" msgid "Paste your API key"
msgstr "Pega tu clave de API" msgstr "Pega tu clave de API"
#: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
msgstr "En pausa" msgstr "En pausa"
@ -4760,6 +4778,7 @@ msgid "Star"
msgstr "" msgstr ""
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/autopilot-dialog.tsx
msgid "Start" msgid "Start"
msgstr "" msgstr ""
@ -5042,6 +5061,10 @@ msgstr ""
msgid "Tightening the copy…" msgid "Tightening the copy…"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Time"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Time of day" msgid "Time of day"
msgstr "Hora del día" msgstr "Hora del día"
@ -5109,6 +5132,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-dialog.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
@ -5163,6 +5187,22 @@ msgstr "Tipo de archivo no compatible: {0}"
msgid "until {until}" msgid "until {until}"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until a time"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until done"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until tomorrow morning"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until when?"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -264,6 +264,11 @@ msgstr "{remaining} शेष · बॉट देख रहा है, कार
msgid "{size} B" msgid "{size} B"
msgstr "{size} B" msgstr "{size} B"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "{steps, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: runActivityLabel(run.status) #. placeholder {0}: runActivityLabel(run.status)
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
msgid "{title}, {0}" msgid "{title}, {0}"
@ -366,6 +371,7 @@ msgstr "गतिविधि"
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx #: src/pages/ScratchpadSection.tsx
#: src/pages/shell/composer.tsx
msgid "Add" msgid "Add"
msgstr "जोड़ें" msgstr "जोड़ें"
@ -764,7 +770,9 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
msgid "Autopilot" msgid "Autopilot"
msgstr "" msgstr ""
@ -2898,6 +2906,11 @@ msgstr "भाषा"
msgid "Lashes" msgid "Lashes"
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "last: {latest}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
msgid "Later" msgid "Later"
msgstr "" msgstr ""
@ -3743,6 +3756,10 @@ msgstr "वैकल्पिक हेडर मान"
msgid "Or connect an API key" msgid "Or connect an API key"
msgstr "या एक API कुंजी कनेक्ट करें" msgstr "या एक API कुंजी कनेक्ट करें"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Order"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Organization API key" msgid "Organization API key"
msgstr "संगठन API कुंजी" msgstr "संगठन API कुंजी"
@ -3803,6 +3820,7 @@ msgstr "अपने सर्वर से OpenAI-संगत पता पे
msgid "Paste your API key" msgid "Paste your API key"
msgstr "अपनी API कुंजी पेस्ट करें" msgstr "अपनी API कुंजी पेस्ट करें"
#: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
msgstr "" msgstr ""
@ -4760,6 +4778,7 @@ msgid "Star"
msgstr "" msgstr ""
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/autopilot-dialog.tsx
msgid "Start" msgid "Start"
msgstr "" msgstr ""
@ -5042,6 +5061,10 @@ msgstr ""
msgid "Tightening the copy…" msgid "Tightening the copy…"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Time"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Time of day" msgid "Time of day"
msgstr "दिन का समय" msgstr "दिन का समय"
@ -5109,6 +5132,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-dialog.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
@ -5163,6 +5187,22 @@ msgstr "असमर्थित फ़ाइल प्रकार: {0}"
msgid "until {until}" msgid "until {until}"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until a time"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until done"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until tomorrow morning"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until when?"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -264,6 +264,11 @@ msgstr "남은 시간 {remaining} · Bot은 화면만 보고 직접 조작하지
msgid "{size} B" msgid "{size} B"
msgstr "{size} B" msgstr "{size} B"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "{steps, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: runActivityLabel(run.status) #. placeholder {0}: runActivityLabel(run.status)
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
msgid "{title}, {0}" msgid "{title}, {0}"
@ -366,6 +371,7 @@ msgstr "활동"
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx #: src/pages/ScratchpadSection.tsx
#: src/pages/shell/composer.tsx
msgid "Add" msgid "Add"
msgstr "추가" msgstr "추가"
@ -764,7 +770,9 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
msgid "Autopilot" msgid "Autopilot"
msgstr "" msgstr ""
@ -2898,6 +2906,11 @@ msgstr "언어"
msgid "Lashes" msgid "Lashes"
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "last: {latest}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
msgid "Later" msgid "Later"
msgstr "" msgstr ""
@ -3743,6 +3756,10 @@ msgstr "선택 사항인 헤더 값"
msgid "Or connect an API key" msgid "Or connect an API key"
msgstr "또는 API 키 연결" msgstr "또는 API 키 연결"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Order"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Organization API key" msgid "Organization API key"
msgstr "조직 API 키" msgstr "조직 API 키"
@ -3803,6 +3820,7 @@ msgstr "서버의 OpenAI 호환 주소를 붙여넣으세요. 필요하면 Rakaz
msgid "Paste your API key" msgid "Paste your API key"
msgstr "API 키를 붙여넣으세요" msgstr "API 키를 붙여넣으세요"
#: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
msgstr "일시 중지됨" msgstr "일시 중지됨"
@ -4760,6 +4778,7 @@ msgid "Star"
msgstr "" msgstr ""
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/autopilot-dialog.tsx
msgid "Start" msgid "Start"
msgstr "" msgstr ""
@ -5042,6 +5061,10 @@ msgstr ""
msgid "Tightening the copy…" msgid "Tightening the copy…"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Time"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Time of day" msgid "Time of day"
msgstr "실행 시각" msgstr "실행 시각"
@ -5109,6 +5132,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-dialog.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
@ -5163,6 +5187,22 @@ msgstr "지원하지 않는 파일 형식: {0}"
msgid "until {until}" msgid "until {until}"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until a time"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until done"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until tomorrow morning"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until when?"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -264,6 +264,11 @@ msgstr "{remaining} restantes · o bot está observando, não agindo"
msgid "{size} B" msgid "{size} B"
msgstr "{size} B" msgstr "{size} B"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "{steps, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: runActivityLabel(run.status) #. placeholder {0}: runActivityLabel(run.status)
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
msgid "{title}, {0}" msgid "{title}, {0}"
@ -366,6 +371,7 @@ msgstr "Atividade"
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx #: src/pages/ScratchpadSection.tsx
#: src/pages/shell/composer.tsx
msgid "Add" msgid "Add"
msgstr "Adicionar" msgstr "Adicionar"
@ -764,7 +770,9 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
msgid "Autopilot" msgid "Autopilot"
msgstr "" msgstr ""
@ -2898,6 +2906,11 @@ msgstr "Idioma"
msgid "Lashes" msgid "Lashes"
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "last: {latest}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
msgid "Later" msgid "Later"
msgstr "" msgstr ""
@ -3743,6 +3756,10 @@ msgstr "Valor de cabeçalho opcional"
msgid "Or connect an API key" msgid "Or connect an API key"
msgstr "Ou conecte uma chave de API" msgstr "Ou conecte uma chave de API"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Order"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Organization API key" msgid "Organization API key"
msgstr "Chave da API da organização" msgstr "Chave da API da organização"
@ -3803,6 +3820,7 @@ msgstr "Cole o endereço compatível com OpenAI do seu servidor. Rakazo adiciona
msgid "Paste your API key" msgid "Paste your API key"
msgstr "Cole sua chave API" msgstr "Cole sua chave API"
#: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
msgstr "" msgstr ""
@ -4760,6 +4778,7 @@ msgid "Star"
msgstr "" msgstr ""
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/autopilot-dialog.tsx
msgid "Start" msgid "Start"
msgstr "" msgstr ""
@ -5042,6 +5061,10 @@ msgstr ""
msgid "Tightening the copy…" msgid "Tightening the copy…"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Time"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Time of day" msgid "Time of day"
msgstr "Hora do dia" msgstr "Hora do dia"
@ -5109,6 +5132,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-dialog.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
@ -5163,6 +5187,22 @@ msgstr "Tipo de arquivo não suportado: {0}"
msgid "until {until}" msgid "until {until}"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until a time"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until done"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until tomorrow morning"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until when?"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -264,6 +264,11 @@ msgstr "Осталось: {remaining} · бот наблюдает, но не д
msgid "{size} B" msgid "{size} B"
msgstr "{size} Б" msgstr "{size} Б"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "{steps, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: runActivityLabel(run.status) #. placeholder {0}: runActivityLabel(run.status)
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
msgid "{title}, {0}" msgid "{title}, {0}"
@ -366,6 +371,7 @@ msgstr "Активность"
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx #: src/pages/ScratchpadSection.tsx
#: src/pages/shell/composer.tsx
msgid "Add" msgid "Add"
msgstr "Добавить" msgstr "Добавить"
@ -764,7 +770,9 @@ msgstr "Автоматические отправители"
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "Автоматические отправители появятся после публикации здесь." msgstr "Автоматические отправители появятся после публикации здесь."
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
msgid "Autopilot" msgid "Autopilot"
msgstr "" msgstr ""
@ -2898,6 +2906,11 @@ msgstr "Язык"
msgid "Lashes" msgid "Lashes"
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "last: {latest}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
msgid "Later" msgid "Later"
msgstr "Позже" msgstr "Позже"
@ -3743,6 +3756,10 @@ msgstr "Необязательное значение заголовка"
msgid "Or connect an API key" msgid "Or connect an API key"
msgstr "Или подключить API-ключ" msgstr "Или подключить API-ключ"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Order"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Organization API key" msgid "Organization API key"
msgstr "Ключ API организации" msgstr "Ключ API организации"
@ -3803,6 +3820,7 @@ msgstr "Вставьте OpenAI-совместимый адрес со свое
msgid "Paste your API key" msgid "Paste your API key"
msgstr "Вставьте свой ключ API" msgstr "Вставьте свой ключ API"
#: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
msgstr "Приостановлено" msgstr "Приостановлено"
@ -4760,6 +4778,7 @@ msgid "Star"
msgstr "" msgstr ""
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/autopilot-dialog.tsx
msgid "Start" msgid "Start"
msgstr "" msgstr ""
@ -5042,6 +5061,10 @@ msgstr ""
msgid "Tightening the copy…" msgid "Tightening the copy…"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Time"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Time of day" msgid "Time of day"
msgstr "Время суток" msgstr "Время суток"
@ -5109,6 +5132,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-dialog.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
@ -5163,6 +5187,22 @@ msgstr "Неподдерживаемый тип файла: {0}."
msgid "until {until}" msgid "until {until}"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until a time"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until done"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until tomorrow morning"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until when?"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -264,6 +264,11 @@ msgstr "{remaining} kaldı · bot yalnızca izliyor"
msgid "{size} B" msgid "{size} B"
msgstr "{size} B" msgstr "{size} B"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "{steps, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: runActivityLabel(run.status) #. placeholder {0}: runActivityLabel(run.status)
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
msgid "{title}, {0}" msgid "{title}, {0}"
@ -366,6 +371,7 @@ msgstr "Etkinlik"
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx #: src/pages/ScratchpadSection.tsx
#: src/pages/shell/composer.tsx
msgid "Add" msgid "Add"
msgstr "Ekle" msgstr "Ekle"
@ -764,7 +770,9 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
msgid "Autopilot" msgid "Autopilot"
msgstr "" msgstr ""
@ -2898,6 +2906,11 @@ msgstr "Dil"
msgid "Lashes" msgid "Lashes"
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "last: {latest}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
msgid "Later" msgid "Later"
msgstr "" msgstr ""
@ -3743,6 +3756,10 @@ msgstr "İsteğe bağlı üstbilgi değeri"
msgid "Or connect an API key" msgid "Or connect an API key"
msgstr "Veya bir API anahtarı bağla" msgstr "Veya bir API anahtarı bağla"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Order"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Organization API key" msgid "Organization API key"
msgstr "Kuruluş API anahtarı" msgstr "Kuruluş API anahtarı"
@ -3803,6 +3820,7 @@ msgstr "Sunucundaki OpenAI uyumlu adresi yapıştır. Gerekirse Rakazo /v1 ekler
msgid "Paste your API key" msgid "Paste your API key"
msgstr "API anahtarını yapıştır" msgstr "API anahtarını yapıştır"
#: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
msgstr "" msgstr ""
@ -4760,6 +4778,7 @@ msgid "Star"
msgstr "" msgstr ""
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/autopilot-dialog.tsx
msgid "Start" msgid "Start"
msgstr "" msgstr ""
@ -5042,6 +5061,10 @@ msgstr ""
msgid "Tightening the copy…" msgid "Tightening the copy…"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Time"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Time of day" msgid "Time of day"
msgstr "Günün saati" msgstr "Günün saati"
@ -5109,6 +5132,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-dialog.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
@ -5163,6 +5187,22 @@ msgstr "Desteklenmeyen dosya türü: {0}"
msgid "until {until}" msgid "until {until}"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until a time"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until done"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until tomorrow morning"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until when?"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -264,6 +264,11 @@ msgstr "剩余 {remaining} · Bot 只看不操作"
msgid "{size} B" msgid "{size} B"
msgstr "{size} B" msgstr "{size} B"
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "{steps, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: runActivityLabel(run.status) #. placeholder {0}: runActivityLabel(run.status)
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
msgid "{title}, {0}" msgid "{title}, {0}"
@ -366,6 +371,7 @@ msgstr "动态"
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx #: src/pages/ScratchpadSection.tsx
#: src/pages/shell/composer.tsx
msgid "Add" msgid "Add"
msgstr "添加" msgstr "添加"
@ -764,7 +770,9 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx
msgid "Autopilot" msgid "Autopilot"
msgstr "" msgstr ""
@ -2898,6 +2906,11 @@ msgstr "语言"
msgid "Lashes" msgid "Lashes"
msgstr "" msgstr ""
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "last: {latest}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
msgid "Later" msgid "Later"
msgstr "" msgstr ""
@ -3743,6 +3756,10 @@ msgstr "可选请求头值"
msgid "Or connect an API key" msgid "Or connect an API key"
msgstr "或连接 API 密钥" msgstr "或连接 API 密钥"
#: src/pages/shell/autopilot-dialog.tsx
msgid "Order"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Organization API key" msgid "Organization API key"
msgstr "组织 API 密钥" msgstr "组织 API 密钥"
@ -3803,6 +3820,7 @@ msgstr "粘贴服务器的 OpenAI 兼容地址。需要时 Rakazo 会补上 /v1
msgid "Paste your API key" msgid "Paste your API key"
msgstr "粘贴你的 API 密钥" msgstr "粘贴你的 API 密钥"
#: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
msgstr "已暂停" msgstr "已暂停"
@ -4760,6 +4778,7 @@ msgid "Star"
msgstr "" msgstr ""
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/autopilot-dialog.tsx
msgid "Start" msgid "Start"
msgstr "" msgstr ""
@ -5042,6 +5061,10 @@ msgstr ""
msgid "Tightening the copy…" msgid "Tightening the copy…"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Time"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Time of day" msgid "Time of day"
msgstr "时间" msgstr "时间"
@ -5109,6 +5132,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-dialog.tsx
#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
@ -5163,6 +5187,22 @@ msgstr "不支持的文件类型:{0}"
msgid "until {until}" msgid "until {until}"
msgstr "" msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until a time"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until done"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until tomorrow morning"
msgstr ""
#: src/pages/shell/autopilot-dialog.tsx
msgid "Until when?"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -1,8 +1,16 @@
import { i18n } from "@lingui/core"; import { i18n } from "@lingui/core";
import { Plural, Trans, useLingui } from "@lingui/react/macro"; import { Plural, Trans, useLingui } from "@lingui/react/macro";
import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts"; import type {
AutopilotRecent,
Bot,
CoachToday,
Group,
HabitToday,
RunActivityRow,
} from "@rakazo/contracts";
import { botColors } from "@rakazo/ui-tokens"; import { botColors } from "@rakazo/ui-tokens";
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web"; import { BotAvatar, Button, EvooMascot, GroupAvatar, Icon } from "@rakazo/ui-web";
import { CircleCheck, Pause, Play, Square } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CoachRhythmControl } from "../components/heute/CoachRhythmControl"; import { CoachRhythmControl } from "../components/heute/CoachRhythmControl";
import { HeuteCoach } from "../components/heute/HeuteCoach"; import { HeuteCoach } from "../components/heute/HeuteCoach";
@ -39,6 +47,7 @@ export function HeuteBrief({
}: HeuteBriefProps) { }: HeuteBriefProps) {
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]); const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]); const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
const [autopilots, setAutopilots] = useState<AutopilotRecent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [coach, setCoach] = useState<CoachToday | null>(null); const [coach, setCoach] = useState<CoachToday | null>(null);
const [coachSettled, setCoachSettled] = useState(false); const [coachSettled, setCoachSettled] = useState(false);
@ -50,13 +59,15 @@ export function HeuteBrief({
const tick = async () => { const tick = async () => {
try { try {
const [active, recent] = await Promise.all([ const [active, recent, autopilot] = await Promise.all([
rpc.runs.list({ filter: "active" }), rpc.runs.list({ filter: "active" }),
rpc.runs.list({ filter: "recent" }), rpc.runs.list({ filter: "recent" }),
rpc.autopilot.recent().catch(() => ({ items: [] })),
]); ]);
if (cancelled) return; if (cancelled) return;
setActiveRuns(active.runs); setActiveRuns(active.runs);
setRecentRuns(recent.runs); setRecentRuns(recent.runs);
setAutopilots(autopilot.items);
} catch { } catch {
if (cancelled) return; if (cancelled) return;
} finally { } finally {
@ -139,7 +150,11 @@ export function HeuteBrief({
coach && (coach.brief || coach.habits.length > 0 || coach.goals.length > 0), coach && (coach.brief || coach.habits.length > 0 || coach.goals.length > 0),
); );
const hasContent = const hasContent =
hasCoachContent || brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; hasCoachContent ||
autopilots.length > 0 ||
brief.needsYou.length > 0 ||
brief.doing.length > 0 ||
brief.done.length > 0;
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
@ -170,6 +185,27 @@ export function HeuteBrief({
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8 pb-4"> <div className="mx-auto flex w-full max-w-2xl flex-col gap-8 pb-4">
{coach ? <HeuteCoach today={coach} onHabitDone={setHabitDone} /> : null} {coach ? <HeuteCoach today={coach} onHabitDone={setHabitDone} /> : null}
{autopilots.length > 0 ? (
<section data-testid="heute-autopilot">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Autopilot</Trans>
</h2>
<div className="flex flex-col gap-1">
{autopilots.map((autopilot) => (
<AutopilotRow
key={autopilot.id}
autopilot={autopilot}
onOpen={() =>
autopilot.groupId
? onOpenGroup(autopilot.groupId, autopilot.spaceId)
: onOpenBot(autopilot.botId, autopilot.spaceId)
}
/>
))}
</div>
</section>
) : null}
{brief.needsYou.length > 0 ? ( {brief.needsYou.length > 0 ? (
<section data-testid="heute-needs-you"> <section data-testid="heute-needs-you">
<h2 className="rk-shell-section-label mb-2 px-0.5"> <h2 className="rk-shell-section-label mb-2 px-0.5">
@ -446,6 +482,55 @@ function DoneRow({
); );
} }
/** One autopilot: what it works on, how far it got, and how it ended; opens its thread. */
function AutopilotRow({ autopilot, onOpen }: { autopilot: AutopilotRecent; onOpen: () => void }) {
const { t } = useLingui();
const steps = autopilot.stepCount;
const latest = autopilot.lastCheckpoint;
const icon = { active: Play, paused: Pause, finished: CircleCheck, stopped: Square }[
autopilot.status
];
const detail =
autopilot.status === "finished"
? autopilot.result
: autopilot.status === "paused"
? autopilot.pauseReason
: latest
? t`last: ${latest}`
: null;
return (
<button
type="button"
data-testid="heute-autopilot-row"
data-status={autopilot.status}
onClick={onOpen}
className="flex w-full items-start gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
>
<span className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon icon={icon} size="sm" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-ui-body font-medium text-foreground" dir="auto">
{autopilot.goal}
</div>
{detail ? (
<div className="mt-0.5 line-clamp-3 text-ui-body-sm text-muted-foreground" dir="auto">
{detail}
</div>
) : null}
</div>
{autopilot.status === "paused" ? (
<ActionableChip label={t`Paused`} />
) : (
<span className="shrink-0 text-ui-label text-muted-foreground">
<Plural value={steps} one="# step" other="# steps" />
</span>
)}
</button>
);
}
function jetztRowKey(item: JetztItem): string { function jetztRowKey(item: JetztItem): string {
if (item.kind === "run") return `run:${item.run.runId}`; if (item.kind === "run") return `run:${item.run.runId}`;
return `unread:${item.chatKind}:${item.chat.id}`; return `unread:${item.chatKind}:${item.chat.id}`;

View File

@ -1993,6 +1993,18 @@ export function ShellPage() {
await refreshThreadRef.current(botId); await refreshThreadRef.current(botId);
} }
}, []); }, []);
const startAutopilot = useCallback(async (goal: string, until?: string) => {
const botId = activeBotId.current;
const groupId = activeGroupId.current;
if (!botId && !groupId) return;
const target = groupId ? { groupId } : { botId: botId! };
await rpc.autopilot.start({ ...target, goal, ...(until ? { until } : {}) });
if (groupId && activeGroupId.current === groupId) {
await refreshGroupThreadRef.current(groupId);
} else if (botId && activeBotId.current === botId) {
await refreshThreadRef.current(botId);
}
}, []);
const reactToMessage = useCallback( const reactToMessage = useCallback(
async (message: ThreadMessage, reaction: MessageReaction) => { async (message: ThreadMessage, reaction: MessageReaction) => {
const botId = activeBotId.current; const botId = activeBotId.current;
@ -3418,6 +3430,7 @@ export function ShellPage() {
agentSkills={agentSkills} agentSkills={agentSkills}
onSlashOpen={refreshAgentSkills} onSlashOpen={refreshAgentSkills}
onConnectModel={bootstrapMe?.needsModel ? () => openSettings("models") : undefined} onConnectModel={bootstrapMe?.needsModel ? () => openSettings("models") : undefined}
onAutopilot={startAutopilot}
onSlashAction={(action) => { onSlashAction={(action) => {
if (action === "chat-settings") { if (action === "chat-settings") {
setPanel(inGroup ? "group-settings" : "settings"); setPanel(inGroup ? "group-settings" : "settings");

View File

@ -0,0 +1,106 @@
import { useLingui } from "@lingui/react/macro";
import type { AutopilotUntilChoice } from "@rakazo/core";
import { autopilotUntil } from "@rakazo/core";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
Input,
Textarea,
} from "@rakazo/ui-web";
import { useState } from "react";
type Choice = AutopilotUntilChoice["kind"];
/**
* Start the autopilot from the composer: the order (the draft so far) and until when it may
* work. Mounted only while open, so it always starts from the current draft.
*/
export function AutopilotDialog({
draft,
onClose,
onStart,
}: {
draft: string;
onClose: () => void;
onStart: (goal: string, until?: string) => Promise<void>;
}) {
const { t } = useLingui();
const [goal, setGoal] = useState(draft);
const [choice, setChoice] = useState<Choice>("done");
const [time, setTime] = useState("06:00");
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const options: { kind: Choice; label: string }[] = [
{ kind: "done", label: t`Until done` },
{ kind: "morning", label: t`Until tomorrow morning` },
{ kind: "time", label: t`Until a time` },
];
async function start() {
const order = goal.trim();
if (!order) return;
setBusy(true);
setFailed(false);
try {
const until = autopilotUntil(
choice === "time" ? { kind: "time", time } : { kind: choice },
new Date(),
);
await onStart(order, until?.toISOString());
onClose();
} catch {
setFailed(true);
} finally {
setBusy(false);
}
}
return (
<Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent data-testid="autopilot-dialog" className="sm:max-w-md">
<DialogTitle>{t`Until when?`}</DialogTitle>
<Textarea
aria-label={t`Order`}
value={goal}
maxLength={500}
rows={3}
onChange={(event) => setGoal(event.target.value)}
/>
<div role="radiogroup" aria-label={t`Until when?`} className="flex flex-wrap gap-2">
{options.map((option) => (
<Button
key={option.kind}
size="sm"
variant={choice === option.kind ? "default" : "outline"}
role="radio"
aria-checked={choice === option.kind}
onClick={() => setChoice(option.kind)}
>
{option.label}
</Button>
))}
{choice === "time" ? (
<Input
type="time"
aria-label={t`Time`}
value={time}
onChange={(event) => {
if (event.target.value) setTime(event.target.value.slice(0, 5));
}}
className="h-control-sm w-32 px-2 tabular-nums"
/>
) : null}
</div>
<DialogFooter>
<Button disabled={busy || !goal.trim()} onClick={() => void start()}>
{failed ? t`Try again` : t`Start`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,8 +1,8 @@
import { useLingui } from "@lingui/react/macro"; import { Plural, useLingui } from "@lingui/react/macro";
import type { AutopilotBlock } from "@rakazo/contracts"; import type { AutopilotBlock } from "@rakazo/contracts";
import { formatAutopilotDuration } from "@rakazo/core"; import { formatAutopilotDuration } from "@rakazo/core";
import { Icon } from "@rakazo/ui-web"; import { Icon } from "@rakazo/ui-web";
import { CircleCheck, Pause, Play, Square } from "lucide-react"; import { ChevronDown, CircleCheck, Pause, Play, Square } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
export type AutopilotAction = "stop" | "resume"; export type AutopilotAction = "stop" | "resume";
@ -11,8 +11,9 @@ const actionClass =
"rounded-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"; "rounded-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50";
/** /**
* One quiet line when the autopilot starts, pauses, stops or finishes. Stop stays on the start * One quiet line when the autopilot starts, pauses, stops or finishes. The start line counts the
* line while it runs; a pause offers Continue. * steps and folds open to the latest checkpoints; Stop stays on it while it runs, a pause offers
* Continue, and the result of a finished autopilot reads as a card.
*/ */
export function AutopilotLine({ export function AutopilotLine({
block, block,
@ -24,6 +25,7 @@ export function AutopilotLine({
const { i18n, t } = useLingui(); const { i18n, t } = useLingui();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [expanded, setExpanded] = useState(false);
const open = !block.ended && Boolean(onAction); const open = !block.ended && Boolean(onAction);
async function act(action: AutopilotAction) { async function act(action: AutopilotAction) {
@ -44,6 +46,13 @@ export function AutopilotLine({
new Date(block.until), new Date(block.until),
) )
: null; : null;
const time = new Intl.DateTimeFormat(i18n.locale || "en", {
hour: "numeric",
minute: "2-digit",
});
const checkpoints = block.event === "autopilot.started" ? (block.checkpoints ?? []) : [];
const latest = checkpoints.at(-1)?.text;
const steps = block.steps ?? 0;
const duration = const duration =
block.durationMs !== undefined ? formatAutopilotDuration(block.durationMs) : null; block.durationMs !== undefined ? formatAutopilotDuration(block.durationMs) : null;
const parts: string[] = []; const parts: string[] = [];
@ -86,6 +95,30 @@ export function AutopilotLine({
<span dir="auto">{part}</span> <span dir="auto">{part}</span>
</span> </span>
))} ))}
{steps > 0 && block.event === "autopilot.started" ? (
<>
<span aria-hidden>·</span>
<button
type="button"
className={`${actionClass} inline-flex items-center gap-0.5`}
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
>
<Plural value={steps} one="# step" other="# steps" />
<Icon
icon={ChevronDown}
size="sm"
className={expanded ? "rotate-180 transition-transform" : "transition-transform"}
/>
</button>
{latest && !expanded ? (
<>
<span aria-hidden>·</span>
<span dir="auto" className="max-w-xs truncate">{t`last: ${latest}`}</span>
</>
) : null}
</>
) : null}
{open && block.event === "autopilot.paused" ? ( {open && block.event === "autopilot.paused" ? (
<> <>
<span aria-hidden>·</span> <span aria-hidden>·</span>
@ -113,8 +146,29 @@ export function AutopilotLine({
</> </>
) : null} ) : null}
</p> </p>
{expanded && checkpoints.length > 0 ? (
<ol
data-testid="autopilot-steps"
className="flex w-full max-w-md flex-col gap-1 text-left text-ui-body-sm"
>
{checkpoints.map((checkpoint, index) => (
<li key={`${checkpoint.at}-${index}`} className="flex gap-2">
<span className="w-16 shrink-0 text-right tabular-nums">
{time.format(new Date(checkpoint.at))}
</span>
<span dir="auto" className="min-w-0 text-foreground/80">
{checkpoint.text}
</span>
</li>
))}
</ol>
) : null}
{block.event === "autopilot.finished" && block.text ? ( {block.event === "autopilot.finished" && block.text ? (
<p dir="auto" className="max-w-xl text-ui-body-sm text-foreground/80"> <p
dir="auto"
data-testid="autopilot-result"
className="mt-1 w-full max-w-xl whitespace-pre-wrap rounded-lg border border-border bg-card px-4 py-3 text-left text-ui-body-sm text-card-foreground"
>
{block.text} {block.text}
</p> </p>
) : null} ) : null}

View File

@ -11,13 +11,21 @@ import {
serializeComposerPrompt, serializeComposerPrompt,
truncateSlashDescription, truncateSlashDescription,
} from "@rakazo/core"; } from "@rakazo/core";
import { BotAvatar, Button } from "@rakazo/ui-web"; import {
BotAvatar,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@rakazo/ui-web";
import { import {
ArrowUp, ArrowUp,
Box, Box,
Clock, Clock,
Mic, Mic,
Paperclip, Paperclip,
Play,
Plus, Plus,
Puzzle, Puzzle,
Settings, Settings,
@ -28,12 +36,16 @@ import type { ClipboardEvent, DragEvent, RefObject } from "react";
import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { VoiceAuroraRim } from "../../components/voice/VoiceAurora"; import { VoiceAuroraRim } from "../../components/voice/VoiceAurora";
import { isFileDrag, isFilePaste } from "../../lib/pending-attachments"; import { isFileDrag, isFilePaste } from "../../lib/pending-attachments";
import { AutopilotDialog } from "./autopilot-dialog";
import { previewMessageText } from "./message-preview"; import { previewMessageText } from "./message-preview";
import type { PendingAttachment } from "./shell-types"; import type { PendingAttachment } from "./shell-types";
import { FALLBACK_BOT_COLOR } from "./shell-types"; import { FALLBACK_BOT_COLOR } from "./shell-types";
const ATTACHMENT_ACCEPT = ATTACHMENT_ALLOWED_MIME_TYPES.join(","); const ATTACHMENT_ACCEPT = ATTACHMENT_ALLOWED_MIME_TYPES.join(",");
const addButtonClass =
"tactile-press size-10 shrink-0 rounded-full border border-border bg-foreground/5 text-foreground/80 transition-all hover:bg-foreground/10 hover:text-foreground flex items-center justify-center cursor-pointer";
export const Composer = memo(function Composer({ export const Composer = memo(function Composer({
running, running,
disabled, disabled,
@ -62,6 +74,7 @@ export const Composer = memo(function Composer({
onSlashOpen, onSlashOpen,
onSlashAction, onSlashAction,
onConnectModel, onConnectModel,
onAutopilot,
}: { }: {
running: boolean; running: boolean;
disabled?: boolean; disabled?: boolean;
@ -91,9 +104,12 @@ export const Composer = memo(function Composer({
onSlashAction?: (action: SlashActionId) => void; onSlashAction?: (action: SlashActionId) => void;
/** Set while no model is connected: nothing can run until one is. */ /** Set while no model is connected: nothing can run until one is. */
onConnectModel?: () => void; onConnectModel?: () => void;
/** Hand the order to the autopilot; the composer menu offers it only when set. */
onAutopilot?: (goal: string, until?: string) => Promise<void>;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [autopilotOpen, setAutopilotOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0); const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
const [slashQuery, setSlashQuery] = useState<string | null>(null); const [slashQuery, setSlashQuery] = useState<string | null>(null);
@ -571,16 +587,54 @@ export const Composer = memo(function Composer({
className="hidden" className="hidden"
onChange={(event) => void onAttachmentPick(event.target.files)} onChange={(event) => void onAttachmentPick(event.target.files)}
/> />
{onAutopilot ? (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled}
render={
<Button
variant="ghost"
size="icon"
aria-label={t`Add`}
className={addButtonClass}
/>
}
>
<Plus strokeWidth={2.2} className="size-icon-lg" />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="w-auto min-w-40">
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
<Paperclip />
{t`Attach file`}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAutopilotOpen(true)}>
<Play />
{t`Autopilot`}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={t`Attach file`} aria-label={t`Attach file`}
disabled={disabled} disabled={disabled}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="tactile-press size-10 shrink-0 rounded-full border border-border bg-foreground/5 text-foreground/80 transition-all hover:bg-foreground/10 hover:text-foreground flex items-center justify-center cursor-pointer" className={addButtonClass}
> >
<Plus strokeWidth={2.2} className="size-icon-lg" /> <Plus strokeWidth={2.2} className="size-icon-lg" />
</Button> </Button>
)}
{autopilotOpen && onAutopilot ? (
<AutopilotDialog
draft={draft}
onClose={() => setAutopilotOpen(false)}
onStart={async (goal, until) => {
await onAutopilot(goal, until);
setDraft("");
}}
/>
) : null}
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-1.5"> <div className="flex min-w-0 flex-1 flex-wrap items-end gap-1.5">
{selectedSkill ? ( {selectedSkill ? (
<span <span

View File

@ -100,7 +100,7 @@ describePostgres("autopilot (PostgreSQL)", () => {
expect(rules.map((rule) => rule.matchValue)).toEqual(["github_commit"]); expect(rules.map((rule) => rule.matchValue)).toEqual(["github_commit"]);
for (let step = 1; step <= 4; step += 1) { for (let step = 1; step <= 4; step += 1) {
await checkpointAutopilot(prisma, owner, { summary: `Ticket ${step} tidied` }); await checkpointAutopilot(deps, owner, { summary: `Ticket ${step} tidied` });
await continueAutopilotAfterRun(deps, owner, { checkpointed: true, failed: false }); await continueAutopilotAfterRun(deps, owner, { checkpointed: true, failed: false });
await finishQueuedRuns(owner.threadId); await finishQueuedRuns(owner.threadId);
} }
@ -129,6 +129,11 @@ describePostgres("autopilot (PostgreSQL)", () => {
["autopilot.started", true], ["autopilot.started", true],
["autopilot.finished", false], ["autopilot.finished", false],
]); ]);
// The start line carries the live progress.
expect(timeline[0]?.steps).toBe(4);
expect(timeline[0]?.checkpoints?.map((checkpoint) => checkpoint.text)).toEqual(
[1, 2, 3, 4].map((step) => `Ticket ${step} tidied`),
);
// Nothing runs after the end. // Nothing runs after the end.
await continueAutopilotAfterRun(deps, owner, { checkpointed: false, failed: false }); await continueAutopilotAfterRun(deps, owner, { checkpointed: false, failed: false });
expect(enqueue).toHaveBeenCalledTimes(4); expect(enqueue).toHaveBeenCalledTimes(4);

View File

@ -14,6 +14,7 @@ import {
appendEventInTransaction, appendEventInTransaction,
endAutopilotLinesInTransaction, endAutopilotLinesInTransaction,
postAutopilotLineInTransaction, postAutopilotLineInTransaction,
recordAutopilotCheckpointInTransaction,
} from "@rakazo/db"; } from "@rakazo/db";
import { getLogger } from "@rakazo/logging"; import { getLogger } from "@rakazo/logging";
@ -165,22 +166,40 @@ export async function startAutopilot(
/** A step is done: remember where the work stands. */ /** A step is done: remember where the work stands. */
export async function checkpointAutopilot( export async function checkpointAutopilot(
prisma: PrismaClient, deps: Pick<AutopilotDeps, "prisma" | "events">,
owner: Pick<AutopilotOwner, "userId" | "threadId">, owner: Pick<AutopilotOwner, "userId" | "threadId">,
input: { summary: string; next?: string }, input: { summary: string; next?: string },
): Promise<{ ok: true; steps: number } | { error: string }> { ): Promise<{ ok: true; steps: number } | { error: string }> {
const open = await findOpenAutopilot(prisma, owner); const open = await findOpenAutopilot(deps.prisma, owner);
if (open?.status !== "active") return { error: "No autopilot is running in this thread." }; if (open?.status !== "active") return { error: "No autopilot is running in this thread." };
const updated = await prisma.autopilot.update({ const summary = clip(input.summary, AUTOPILOT_LIMITS.checkpoint);
where: { id: open.id }, const recorded = await deps.prisma.$transaction(async (tx) => {
const autopilot = await lockAutopilot(tx, open.id);
if (autopilot?.status !== "active") return null;
const updated = await tx.autopilot.update({
where: { id: autopilot.id },
data: { data: {
stepCount: { increment: 1 }, stepCount: { increment: 1 },
idleCount: 0, idleCount: 0,
lastCheckpoint: clip(input.summary, AUTOPILOT_LIMITS.checkpoint), lastCheckpoint: summary,
nextStep: input.next?.trim() ? clip(input.next, AUTOPILOT_LIMITS.checkpoint) : null, nextStep: input.next?.trim() ? clip(input.next, AUTOPILOT_LIMITS.checkpoint) : null,
}, },
}); });
return { ok: true, steps: updated.stepCount }; const seq = autopilot.startMessageId
? await recordAutopilotCheckpointInTransaction(tx, {
spaceId: autopilot.spaceId,
threadId: autopilot.threadId,
messageId: autopilot.startMessageId,
autopilotId: autopilot.id,
checkpoint: { text: summary, at: new Date().toISOString() },
steps: updated.stepCount,
})
: null;
return { steps: updated.stepCount, threadId: autopilot.threadId, seq };
});
if (!recorded) return { error: "No autopilot is running in this thread." };
await notify(deps.events, recorded.threadId, recorded.seq);
return { ok: true, steps: recorded.steps };
} }
/** End the autopilot: finished with a result, or stopped by the user. */ /** End the autopilot: finished with a result, or stopped by the user. */
@ -366,7 +385,7 @@ export async function continueAutopilotAfterRun(
deps: AutopilotDeps, deps: AutopilotDeps,
run: { userId: string; threadId: string; botId: string }, run: { userId: string; threadId: string; botId: string },
outcome: { checkpointed: boolean; failed: boolean; error?: string }, outcome: { checkpointed: boolean; failed: boolean; error?: string },
): Promise<void> { ): Promise<{ paused: string } | null> {
const open = await deps.prisma.autopilot.findFirst({ const open = await deps.prisma.autopilot.findFirst({
where: { where: {
threadId: run.threadId, threadId: run.threadId,
@ -376,7 +395,7 @@ export async function continueAutopilotAfterRun(
}, },
select: { id: true }, select: { id: true },
}); });
if (!open) return; if (!open) return null;
const next = await deps.prisma.$transaction(async (tx) => { const next = await deps.prisma.$transaction(async (tx) => {
const autopilot = await lockAutopilot(tx, open.id); const autopilot = await lockAutopilot(tx, open.id);
if (autopilot?.status !== "active") return null; if (autopilot?.status !== "active") return null;
@ -390,7 +409,7 @@ export async function continueAutopilotAfterRun(
? `Runs keep failing${outcome.error ? `: ${clip(outcome.error, 200)}` : "."}` ? `Runs keep failing${outcome.error ? `: ${clip(outcome.error, 200)}` : "."}`
: `No progress in ${guard.idleCount} runs${autopilot.lastCheckpoint ? `. Last: ${autopilot.lastCheckpoint}` : "."}`; : `No progress in ${guard.idleCount} runs${autopilot.lastCheckpoint ? `. Last: ${autopilot.lastCheckpoint}` : "."}`;
const line = await pauseAutopilotInTransaction(tx, autopilot, reason); const line = await pauseAutopilotInTransaction(tx, autopilot, reason);
return { threadId: autopilot.threadId, seq: line.seq, runId: null }; return { threadId: autopilot.threadId, seq: line.seq, runId: null, paused: reason };
} }
const updated = await tx.autopilot.update({ const updated = await tx.autopilot.update({
where: { id: autopilot.id }, where: { id: autopilot.id },
@ -400,17 +419,19 @@ export async function continueAutopilotAfterRun(
threadId: autopilot.threadId, threadId: autopilot.threadId,
seq: null, seq: null,
runId: await createContinuationRun(tx, updated), runId: await createContinuationRun(tx, updated),
paused: null,
}; };
}); });
if (!next) return; if (!next) return null;
if ("expired" in next && next.expired) { if ("expired" in next && next.expired) {
const autopilot = next.expired; const autopilot = next.expired;
await endAutopilot(deps, autopilot.id, { await endAutopilot(deps, autopilot.id, {
status: "finished", status: "finished",
result: autopilot.lastCheckpoint ?? "Time is up.", result: autopilot.lastCheckpoint ?? "Time is up.",
}); });
return; return null;
} }
await notify(deps.events, next.threadId, next.seq); await notify(deps.events, next.threadId, next.seq);
await enqueue(deps, next.runId); await enqueue(deps, next.runId);
return next.paused ? { paused: next.paused } : null;
} }

View File

@ -467,6 +467,31 @@ function isFailedToolResult(value: unknown): value is { error: unknown } {
return error !== undefined && error !== null; return error !== undefined && error !== null;
} }
/**
* Hand a finished run to its thread's autopilot. A pause is the one autopilot event that pushes
* a notification: the user decides whether it goes on.
*/
async function continueAutopilot(
deps: ExecutorDeps,
run: { spaceId: string; userId: string; botId: string; threadId: string },
bot: { id: string; name: string },
threadId: string,
outcome: { checkpointed: boolean; failed: boolean; error?: string },
) {
const result = await continueAutopilotAfterRun(deps, run, outcome).catch((error) => {
getLogger().error("autopilot continuation", error);
return null;
});
if (!result) return;
await notifyRun(deps, run, {
kind: "help",
title: `${bot.name} paused the autopilot`,
body: result.paused.slice(0, 180),
botId: bot.id,
threadId,
});
}
export function toolCompletionFromResult( export function toolCompletionFromResult(
base: Pick<AgentToolCompletion, "name" | "executionId" | "durationMs">, base: Pick<AgentToolCompletion, "name" | "executionId" | "durationMs">,
result: unknown, result: unknown,
@ -1373,11 +1398,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
.enqueue(runContinueJob(failed.continuationRunId)) .enqueue(runContinueJob(failed.continuationRunId))
.catch((error) => getLogger().error("steering continuation enqueue", error)); .catch((error) => getLogger().error("steering continuation enqueue", error));
} else { } else {
await continueAutopilotAfterRun(deps, run, { await continueAutopilot(deps, run, bot, thread.id, {
checkpointed: false, checkpointed: false,
failed: true, failed: true,
error: MISSING_MODEL_MESSAGE, error: MISSING_MODEL_MESSAGE,
}).catch((continueError) => getLogger().error("autopilot continuation", continueError)); });
} }
if (run.trigger === "bot_message") { if (run.trigger === "bot_message") {
await returnBotMessageOutcome( await returnBotMessageOutcome(
@ -1388,7 +1413,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
"status", "status",
).catch((error) => getLogger().error("bot message failure return", error)); ).catch((error) => getLogger().error("bot message failure return", error));
} }
if (!failed.continuationRunId) { // An autopilot keeps the night quiet: only a pause, a real blocker, reaches the user.
if (!failed.continuationRunId && run.trigger !== "autopilot") {
await notifyRun(deps, run, { await notifyRun(deps, run, {
kind: "failure", kind: "failure",
title: `${bot.name} failed`, title: `${bot.name} failed`,
@ -3594,7 +3620,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
); );
} }
if (name === "autopilot_checkpoint") { if (name === "autopilot_checkpoint") {
const checkpoint = await checkpointAutopilot(deps.prisma, owner, { const checkpoint = await checkpointAutopilot(deps, owner, {
summary: text("summary"), summary: text("summary"),
next: text("next") || undefined, next: text("next") || undefined,
}); });
@ -4656,10 +4682,10 @@ export function createRunExecutor(deps: ExecutorDeps) {
.enqueue(runContinueJob(completed.continuationRunId)) .enqueue(runContinueJob(completed.continuationRunId))
.catch((error) => getLogger().error("steering continuation enqueue", error)); .catch((error) => getLogger().error("steering continuation enqueue", error));
} else { } else {
await continueAutopilotAfterRun(deps, run, { await continueAutopilot(deps, run, bot, thread.id, {
checkpointed: autopilotCheckpointed, checkpointed: autopilotCheckpointed,
failed: false, failed: false,
}).catch((error) => getLogger().error("autopilot continuation", error)); });
} }
if (botMessageOutcome) { if (botMessageOutcome) {
// Prefer the final reply. If the turn only posted mid-turn progress, return that // Prefer the final reply. If the turn only posted mid-turn progress, return that
@ -4674,7 +4700,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
botMessageOutcome.intent, botMessageOutcome.intent,
).catch((error) => getLogger().error("bot message result return", error)); ).catch((error) => getLogger().error("bot message result return", error));
} }
if (text && !completed.continuationRunId) { if (text && !completed.continuationRunId && run.trigger !== "autopilot") {
await notifyRun(deps, run, { await notifyRun(deps, run, {
kind: "completion", kind: "completion",
title: `${bot.name} finished`, title: `${bot.name} finished`,
@ -4733,11 +4759,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
.enqueue(runContinueJob(failed.continuationRunId)) .enqueue(runContinueJob(failed.continuationRunId))
.catch((error) => getLogger().error("steering continuation enqueue", error)); .catch((error) => getLogger().error("steering continuation enqueue", error));
} else { } else {
await continueAutopilotAfterRun(deps, run, { await continueAutopilot(deps, run, bot, thread.id, {
checkpointed: autopilotCheckpointed, checkpointed: autopilotCheckpointed,
failed: true, failed: true,
error: message, error: message,
}).catch((continueError) => getLogger().error("autopilot continuation", continueError)); });
} }
if (run.trigger === "bot_message") { if (run.trigger === "bot_message") {
await returnBotMessageOutcome( await returnBotMessageOutcome(
@ -4748,7 +4774,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
"status", "status",
).catch((returnError) => getLogger().error("bot message failure return", returnError)); ).catch((returnError) => getLogger().error("bot message failure return", returnError));
} }
if (!failed.continuationRunId) { // An autopilot keeps the night quiet: only a pause, a real blocker, reaches the user.
if (!failed.continuationRunId && run.trigger !== "autopilot") {
await notifyRun(deps, run, { await notifyRun(deps, run, {
kind: "failure", kind: "failure",
title: `${bot.name} failed`, title: `${bot.name} failed`,

View File

@ -6,6 +6,8 @@ export const AUTOPILOT_IDLE_LIMIT = 3;
export const AUTOPILOT_FAIL_LIMIT = 3; export const AUTOPILOT_FAIL_LIMIT = 3;
export const AUTOPILOT_LIMITS = { goal: 500, focus: 200, checkpoint: 300, result: 2000 } as const; export const AUTOPILOT_LIMITS = { goal: 500, focus: 200, checkpoint: 300, result: 2000 } as const;
/** Checkpoints kept on the start line; older ones only count toward the step total. */
export const AUTOPILOT_CHECKPOINT_LOG = 50;
export const AutopilotStatusSchema = z.enum(["active", "paused", "finished", "stopped"]); export const AutopilotStatusSchema = z.enum(["active", "paused", "finished", "stopped"]);
export type AutopilotStatus = z.infer<typeof AutopilotStatusSchema>; export type AutopilotStatus = z.infer<typeof AutopilotStatusSchema>;
@ -30,6 +32,11 @@ export const AutopilotBlockSchema = z.object({
/** Tools or apps the bot may use without asking while this autopilot runs. */ /** Tools or apps the bot may use without asking while this autopilot runs. */
allowed: z.array(z.string()).optional(), allowed: z.array(z.string()).optional(),
steps: z.number().int().nonnegative().optional(), steps: z.number().int().nonnegative().optional(),
/** The latest checkpoints, newest last, on the start line. */
checkpoints: z
.array(z.object({ text: z.string(), at: z.string() }))
.max(AUTOPILOT_CHECKPOINT_LOG)
.optional(),
durationMs: z.number().int().nonnegative().optional(), durationMs: z.number().int().nonnegative().optional(),
/** Why it paused, or what it achieved. */ /** Why it paused, or what it achieved. */
text: z.string().optional(), text: z.string().optional(),
@ -55,6 +62,16 @@ export const AutopilotSchema = z.object({
}); });
export type Autopilot = z.infer<typeof AutopilotSchema>; export type Autopilot = z.infer<typeof AutopilotSchema>;
/** How long a finished or stopped autopilot stays in Today, so the night's work is there at breakfast. */
export const AUTOPILOT_RECENT_MS = 24 * 60 * 60 * 1000;
/** An autopilot in Today: open ones, and those that ended within AUTOPILOT_RECENT_MS. */
export const AutopilotRecentSchema = AutopilotSchema.extend({
spaceId: Id,
groupId: Id.nullable(),
});
export type AutopilotRecent = z.infer<typeof AutopilotRecentSchema>;
export const AutopilotStartInputSchema = z.object({ export const AutopilotStartInputSchema = z.object({
goal: z.string().trim().min(1).max(AUTOPILOT_LIMITS.goal), goal: z.string().trim().min(1).max(AUTOPILOT_LIMITS.goal),
focus: z.string().trim().max(AUTOPILOT_LIMITS.focus).optional(), focus: z.string().trim().max(AUTOPILOT_LIMITS.focus).optional(),

View File

@ -2,7 +2,7 @@ import { eventIterator, oc } from "@orpc/contract";
import * as z from "zod"; import * as z from "zod";
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js"; import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
import { AutopilotSchema, AutopilotStartInputSchema } from "./autopilot.js"; import { AutopilotRecentSchema, AutopilotSchema, AutopilotStartInputSchema } from "./autopilot.js";
import { CardActSchema } from "./cards.js"; import { CardActSchema } from "./cards.js";
import { import {
CheckInSchema, CheckInSchema,
@ -948,6 +948,8 @@ export const appContract = {
.output(AutopilotSchema), .output(AutopilotSchema),
stop: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })), stop: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
resume: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })), resume: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
/** The user's autopilots in this space that run, wait, or ended within the last day. */
recent: oc.output(z.object({ items: z.array(AutopilotRecentSchema) })),
}, },
voice: { voice: {
catalog: oc.output(z.array(VoiceCatalogEntrySchema)), catalog: oc.output(z.array(VoiceCatalogEntrySchema)),

View File

@ -1,10 +1,12 @@
import type { AutopilotBlock } from "@rakazo/contracts"; import type { AutopilotBlock } from "@rakazo/contracts";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
addAutopilotCheckpoint,
advanceAutopilotGuard, advanceAutopilotGuard,
autopilotContinuationPrompt, autopilotContinuationPrompt,
autopilotExpired, autopilotExpired,
autopilotHistoryText, autopilotHistoryText,
autopilotUntil,
formatAutopilotDuration, formatAutopilotDuration,
markAutopilotLinesEnded, markAutopilotLinesEnded,
} from "./autopilot.js"; } from "./autopilot.js";
@ -82,4 +84,31 @@ describe("autopilot", () => {
expect(formatAutopilotDuration(222 * 60_000)).toBe("3 h 42 min"); expect(formatAutopilotDuration(222 * 60_000)).toBe("3 h 42 min");
expect(formatAutopilotDuration(120 * 60_000)).toBe("2 h"); expect(formatAutopilotDuration(120 * 60_000)).toBe("2 h");
}); });
it("counts checkpoints on the start line and keeps the latest fifty", () => {
let blocks: AutopilotBlock[] = [started];
for (let step = 1; step <= 52; step += 1) {
blocks =
addAutopilotCheckpoint(blocks, "a1", { text: `Step ${step}`, at: "t" }, step) ?? blocks;
}
expect(blocks[0]?.steps).toBe(52);
expect(blocks[0]?.checkpoints).toHaveLength(50);
expect(blocks[0]?.checkpoints?.at(-1)?.text).toBe("Step 52");
expect(addAutopilotCheckpoint(blocks, "other", { text: "x", at: "t" }, 1)).toBeNull();
});
it("picks the next morning or the next given time", () => {
const evening = new Date(2026, 9, 4, 22, 30);
expect(autopilotUntil({ kind: "done" }, evening)).toBeUndefined();
expect(autopilotUntil({ kind: "morning" }, evening)).toEqual(new Date(2026, 9, 5, 8, 0));
expect(autopilotUntil({ kind: "morning" }, new Date(2026, 9, 5, 2, 0))).toEqual(
new Date(2026, 9, 5, 8, 0),
);
expect(autopilotUntil({ kind: "time", time: "23:15" }, evening)).toEqual(
new Date(2026, 9, 4, 23, 15),
);
expect(autopilotUntil({ kind: "time", time: "06:00" }, evening)).toEqual(
new Date(2026, 9, 5, 6, 0),
);
});
}); });

View File

@ -1,5 +1,9 @@
import type { AutopilotBlock } from "@rakazo/contracts"; import type { AutopilotBlock } from "@rakazo/contracts";
import { AUTOPILOT_FAIL_LIMIT, AUTOPILOT_IDLE_LIMIT } from "@rakazo/contracts"; import {
AUTOPILOT_CHECKPOINT_LOG,
AUTOPILOT_FAIL_LIMIT,
AUTOPILOT_IDLE_LIMIT,
} from "@rakazo/contracts";
/** What the autopilot remembers between runs. */ /** What the autopilot remembers between runs. */
export interface AutopilotState { export interface AutopilotState {
@ -86,6 +90,45 @@ export function markAutopilotLinesEnded(
return changed ? next : null; return changed ? next : null;
} }
/** Count a checkpoint on the autopilot's start line and keep the latest few. */
export function addAutopilotCheckpoint(
blocks: readonly { kind: string }[],
autopilotId: string,
checkpoint: { text: string; at: string },
steps: number,
): AutopilotBlock[] | null {
let changed = false;
const next = blocks.map((block) => {
const line = block as AutopilotBlock;
if (line.kind !== "autopilot" || line.autopilotId !== autopilotId) return line;
if (line.event !== "autopilot.started") return line;
changed = true;
return {
...line,
steps,
checkpoints: [...(line.checkpoints ?? []), checkpoint].slice(-AUTOPILOT_CHECKPOINT_LOG),
};
});
return changed ? next : null;
}
/** When the autopilot should stop: never on its own, at the next 8:00, or at the next given time. */
export type AutopilotUntilChoice =
| { kind: "done" }
| { kind: "morning" }
| { kind: "time"; time: string };
export function autopilotUntil(choice: AutopilotUntilChoice, now: Date): Date | undefined {
if (choice.kind === "done") return undefined;
const [hours, minutes] =
choice.kind === "morning" ? [8, 0] : choice.time.split(":").map((part) => Number(part));
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return undefined;
const until = new Date(now);
until.setHours(hours ?? 0, minutes ?? 0, 0, 0);
if (until.getTime() <= now.getTime()) until.setDate(until.getDate() + 1);
return until;
}
/** "3 h 42 min", "12 min", or "1 min" for anything shorter. */ /** "3 h 42 min", "12 min", or "1 min" for anything shorter. */
export function formatAutopilotDuration(ms: number): string { export function formatAutopilotDuration(ms: number): string {
const minutes = Math.max(1, Math.round(ms / 60_000)); const minutes = Math.max(1, Math.round(ms / 60_000));

View File

@ -1,5 +1,5 @@
import type { AutopilotBlock, MessageBlock } from "@rakazo/contracts"; import type { AutopilotBlock, MessageBlock } from "@rakazo/contracts";
import { markAutopilotLinesEnded } from "@rakazo/core"; import { addAutopilotCheckpoint, markAutopilotLinesEnded } from "@rakazo/core";
import type { Prisma } from "./client.js"; import type { Prisma } from "./client.js";
import { appendEventInTransaction } from "./events.js"; import { appendEventInTransaction } from "./events.js";
import { createThreadMessageInTransaction } from "./messages.js"; import { createThreadMessageInTransaction } from "./messages.js";
@ -57,3 +57,41 @@ export async function endAutopilotLinesInTransaction(
} }
return seq; return seq;
} }
/** Count a checkpoint on the autopilot's start line, so the line shows live progress. */
export async function recordAutopilotCheckpointInTransaction(
tx: Prisma.TransactionClient,
input: {
spaceId: string;
threadId: string;
messageId: string;
autopilotId: string;
checkpoint: { text: string; at: string };
steps: number;
},
): Promise<number | null> {
const message = await tx.message.findUnique({
where: { id: input.messageId },
select: { id: true, role: true, botId: true, blocks: true, threadId: true },
});
if (!message?.botId || message.threadId !== input.threadId) return null;
const blocks = addAutopilotCheckpoint(
message.blocks as MessageBlock[],
input.autopilotId,
input.checkpoint,
input.steps,
);
if (!blocks) return null;
await tx.message.update({
where: { id: message.id },
data: { blocks: blocks as Prisma.InputJsonValue },
});
const event = await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: message.botId,
type: "thread.message.updated",
payload: { messageId: message.id, role: message.role, blocks },
});
return event.seq;
}