Merge branch 'claude/phase2-coach' into claude/phase3-media
# Conflicts: # apps/mobile/lib/api.test.ts # apps/web/src/locales/de/messages.po # apps/web/src/locales/en/messages.po # apps/web/src/locales/es/messages.po # apps/web/src/locales/hi/messages.po # apps/web/src/locales/ko/messages.po # apps/web/src/locales/pt-BR/messages.po # apps/web/src/locales/ru/messages.po # apps/web/src/locales/tr/messages.po # apps/web/src/locales/zh-CN/messages.po # apps/web/src/pages/SettingsOverlay.tsx # apps/web/src/pages/shell/message-cards.tsx # packages/adapter-kit/src/index.ts # packages/adapters/src/builtin-tools.ts # packages/adapters/src/executor-tools.ts # packages/adapters/src/index.ts
This commit is contained in:
commit
08cff7b26a
10
.env.example
10
.env.example
@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY=
|
||||
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
SIGNUPS_ENABLED=true
|
||||
SIGNUP_ALLOWLIST=
|
||||
# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem.
|
||||
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
|
||||
# deployment owner's own onboarding discover local projects. Other accounts never scan the host.
|
||||
# First-run discovery. Leave unset and onboarding never looks at the host filesystem.
|
||||
# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the
|
||||
# git projects in it, reading file and folder names only. Other accounts never see the step.
|
||||
# ONBOARDING_SCAN_HOME=
|
||||
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
||||
DATA_DIR=./data
|
||||
@ -166,8 +166,12 @@ SMTP_URL=
|
||||
EMAIL_FROM=
|
||||
# Local development only: capture password-reset email and print its link to the API console.
|
||||
EMAIL_EMULATOR=
|
||||
# Optional web push keys (set both). Unset: generated once and stored encrypted in the database.
|
||||
# Browsers only allow push on https (or localhost).
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https.
|
||||
VAPID_SUBJECT=
|
||||
# Structured logs to stdout (json in production, pretty in development unless overridden).
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { ORPCError, onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import type {
|
||||
@ -38,13 +37,14 @@ import {
|
||||
destroyBot,
|
||||
EmailEmulator,
|
||||
EncryptedSecretStore,
|
||||
ExpoPushProvider,
|
||||
ExpoPushSender,
|
||||
GraphileJobPublisher,
|
||||
GraphileJobWorkerHost,
|
||||
InMemoryJobQueue,
|
||||
InMemoryRealtimeFanout,
|
||||
InstalledConnectorProvider,
|
||||
IntegrationProviderSettings,
|
||||
importLegacyPushTokens,
|
||||
isComposioEnabled,
|
||||
isMessagingSurfaceEnabled,
|
||||
isPipedreamEnabled,
|
||||
@ -58,9 +58,9 @@ import {
|
||||
PiOAuthLogins,
|
||||
PipedreamConnector,
|
||||
PostgresRealtimeFanout,
|
||||
PushNotificationProvider,
|
||||
pipedreamConfigFromEnv,
|
||||
piSessionsRoot,
|
||||
pushTokenPath,
|
||||
reconcileCloudAgents,
|
||||
reconcileComputerUpdates,
|
||||
reconcileDelegatedTasks,
|
||||
@ -69,6 +69,9 @@ import {
|
||||
SmtpEmailProvider,
|
||||
SpaceMemoryProviderResolver,
|
||||
toTeamChatInbound,
|
||||
VapidKeys,
|
||||
vapidEnvFromProcess,
|
||||
WebPushSender,
|
||||
} from "@rakazo/adapters";
|
||||
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
|
||||
import { signupPolicyFromEnv } from "@rakazo/core";
|
||||
@ -316,7 +319,14 @@ export async function createApp(
|
||||
: new PiAgentRuntime({
|
||||
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
|
||||
});
|
||||
const notifications = new ExpoPushProvider(env.dataDir);
|
||||
const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env));
|
||||
const notifications = new PushNotificationProvider(prisma, {
|
||||
webpush: new WebPushSender(vapid),
|
||||
expo: new ExpoPushSender(),
|
||||
});
|
||||
void importLegacyPushTokens(prisma, env.dataDir).catch((error) =>
|
||||
getLogger().error("legacy push token import failed", error),
|
||||
);
|
||||
const auth = createAuth(prisma, {
|
||||
secret: env.authSecret,
|
||||
baseURL: env.authUrl,
|
||||
@ -372,7 +382,6 @@ export async function createApp(
|
||||
),
|
||||
);
|
||||
await removePiUserSessions(env.dataDir, userId);
|
||||
await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
|
||||
},
|
||||
});
|
||||
// One provider instance so emulator launches and polls share the same Map.
|
||||
@ -483,6 +492,7 @@ export async function createApp(
|
||||
secrets,
|
||||
oauthLogins,
|
||||
integrationSettings,
|
||||
vapid,
|
||||
mcpOAuth,
|
||||
composio: stack.composio,
|
||||
connectors: stack.connector,
|
||||
@ -580,7 +590,7 @@ export async function createApp(
|
||||
}
|
||||
const { matched, response } = await rpc.handle(c.req.raw, {
|
||||
prefix: "/rpc",
|
||||
context: { actor, signal: c.req.raw.signal },
|
||||
context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") },
|
||||
});
|
||||
if (matched) return c.newResponse(response.body, response);
|
||||
await next();
|
||||
|
||||
96
apps/api/src/coach.ts
Normal file
96
apps/api/src/coach.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { getCoachRhythm, setCoachRhythm } from "@rakazo/adapters";
|
||||
import type {
|
||||
Actor,
|
||||
CheckIn,
|
||||
CoachRhythm,
|
||||
CoachToday,
|
||||
Goal,
|
||||
Habit,
|
||||
LogCheckIn,
|
||||
} from "@rakazo/contracts";
|
||||
import type { CheckInFilter, GoalPatch, HabitPatch, PrismaClient } from "@rakazo/db";
|
||||
import {
|
||||
CoachNotFoundError,
|
||||
createGoal,
|
||||
createHabit,
|
||||
InvalidCoachInputError,
|
||||
listCheckIns,
|
||||
listGoals,
|
||||
listHabits,
|
||||
loadCoachToday,
|
||||
logCheckIn,
|
||||
removeGoal,
|
||||
removeHabit,
|
||||
updateGoal,
|
||||
updateHabit,
|
||||
} from "@rakazo/db";
|
||||
|
||||
export interface CoachDeps {
|
||||
prisma: PrismaClient;
|
||||
jobs: JobPublisher;
|
||||
}
|
||||
|
||||
/** Another user's ids read as missing, so a caller cannot probe them. */
|
||||
async function mapErrors<T>(work: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (error instanceof CoachNotFoundError) {
|
||||
throw new ORPCError("NOT_FOUND", { message: error.message });
|
||||
}
|
||||
if (error instanceof InvalidCoachInputError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Coach procedures for the signed-in user only; nothing here is space-scoped. */
|
||||
export function createCoachService(deps: CoachDeps) {
|
||||
const { prisma } = deps;
|
||||
return {
|
||||
today: (actor: Actor): Promise<CoachToday> => loadCoachToday(prisma, actor.userId),
|
||||
|
||||
listGoals: (actor: Actor, input: { status?: Goal["status"] }) =>
|
||||
listGoals(prisma, actor.userId, input),
|
||||
createGoal: (
|
||||
actor: Actor,
|
||||
input: {
|
||||
title: string;
|
||||
detail?: string;
|
||||
targetDate?: string | null;
|
||||
spaceId?: string | null;
|
||||
},
|
||||
) => mapErrors(() => createGoal(prisma, actor.userId, { ...input, source: "user" })),
|
||||
updateGoal: (actor: Actor, { goalId, ...patch }: GoalPatch & { goalId: string }) =>
|
||||
mapErrors(() => updateGoal(prisma, actor.userId, goalId, patch)),
|
||||
removeGoal: async (actor: Actor, input: { goalId: string }) => {
|
||||
await mapErrors(() => removeGoal(prisma, actor.userId, input.goalId));
|
||||
return { ok: true as const };
|
||||
},
|
||||
|
||||
listHabits: (actor: Actor, input: { includeInactive?: boolean }): Promise<Habit[]> =>
|
||||
listHabits(prisma, actor.userId, input),
|
||||
createHabit: (
|
||||
actor: Actor,
|
||||
input: { title: string; cadence: Habit["cadence"]; goalId?: string | null },
|
||||
) => mapErrors(() => createHabit(prisma, actor.userId, { ...input, source: "user" })),
|
||||
updateHabit: (actor: Actor, { habitId, ...patch }: HabitPatch & { habitId: string }) =>
|
||||
mapErrors(() => updateHabit(prisma, actor.userId, habitId, patch)),
|
||||
removeHabit: async (actor: Actor, input: { habitId: string }) => {
|
||||
await mapErrors(() => removeHabit(prisma, actor.userId, input.habitId));
|
||||
return { ok: true as const };
|
||||
},
|
||||
|
||||
logCheckIn: (actor: Actor, input: LogCheckIn): Promise<CheckIn> =>
|
||||
mapErrors(() => logCheckIn(prisma, actor.userId, input)),
|
||||
listCheckIns: (actor: Actor, input: CheckInFilter) =>
|
||||
mapErrors(() => listCheckIns(prisma, actor.userId, input)),
|
||||
|
||||
getRhythm: (actor: Actor): Promise<CoachRhythm> => getCoachRhythm(prisma, actor.userId),
|
||||
setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise<CoachRhythm> =>
|
||||
setCoachRhythm(deps, { userId: actor.userId, rhythm }),
|
||||
};
|
||||
}
|
||||
50
apps/api/src/connected-app-facts.ts
Normal file
50
apps/api/src/connected-app-facts.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { updateUserProfile } from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
|
||||
/**
|
||||
* The app's own name: a featured app's label, otherwise the label of the user's first account for
|
||||
* it (later accounts may be named "Gmail 2" or renamed).
|
||||
*/
|
||||
async function connectedAppName(
|
||||
prisma: PrismaClient,
|
||||
connection: { userId: string; connectorId: string; provider: string },
|
||||
): Promise<string | null> {
|
||||
const featured = matchFeaturedConnectorId(connection.provider);
|
||||
if (featured) return FEATURED_CONNECTOR_LABELS[featured];
|
||||
const first = await prisma.connection.findFirst({
|
||||
where: {
|
||||
userId: connection.userId,
|
||||
connectorId: connection.connectorId,
|
||||
provider: connection.provider,
|
||||
},
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { displayName: true },
|
||||
});
|
||||
const name = first?.displayName.trim().slice(0, 80);
|
||||
return name || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers "Uses <App>" in the user's profile when one of their connections becomes connected.
|
||||
* Revoking the connection keeps the fact; the user removes it in About you. Best effort: the
|
||||
* connection itself has already succeeded, so a full profile or a database error only logs.
|
||||
*/
|
||||
export async function recordConnectedApp(
|
||||
prisma: PrismaClient,
|
||||
connection: { userId: string; connectorId: string; provider: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const name = await connectedAppName(prisma, connection);
|
||||
if (!name) return;
|
||||
await updateUserProfile(prisma, connection.userId, {
|
||||
addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }],
|
||||
});
|
||||
} catch (error) {
|
||||
getLogger().error("connected app fact was not recorded", error, {
|
||||
connectorId: connection.connectorId,
|
||||
provider: connection.provider,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -18,9 +18,11 @@ function run(
|
||||
status: "waiting_input" | "waiting_takeover",
|
||||
thread: Partial<InboxRunRow["thread"]> = {},
|
||||
bot = scout,
|
||||
spaceId = "space-home",
|
||||
): InboxRunRow {
|
||||
return {
|
||||
id,
|
||||
spaceId,
|
||||
status,
|
||||
updatedAt: at(0),
|
||||
bot,
|
||||
@ -48,8 +50,10 @@ const approval = {
|
||||
function unreadThread(
|
||||
bot: InboxUnreadThreadRow["bot"],
|
||||
latest?: { id: string; text: string; minute: number },
|
||||
spaceId = "space-home",
|
||||
): InboxUnreadThreadRow {
|
||||
return {
|
||||
spaceId,
|
||||
createdAt: at(0),
|
||||
bot,
|
||||
messages: latest
|
||||
@ -227,4 +231,64 @@ describe("buildInboxItems", () => {
|
||||
});
|
||||
expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() });
|
||||
});
|
||||
|
||||
it("lists asks from another space's main conversation, but not the open space's own", () => {
|
||||
const project = { id: "space-launch", name: "Launch" };
|
||||
const main = (groupId: string) => ({
|
||||
botId: null,
|
||||
groupId,
|
||||
group: { kind: "main", archivedAt: null },
|
||||
});
|
||||
const items = buildInboxItems({
|
||||
runs: [
|
||||
run("home-main", "waiting_input", main("group-main-home")),
|
||||
run("project-main", "waiting_input", main("group-main-launch"), writer, project.id),
|
||||
],
|
||||
messages: [
|
||||
message("ask-home", "home-main", [question], 1),
|
||||
message("ask-project", "project-main", [question], 2),
|
||||
],
|
||||
unreadThreads: [],
|
||||
otherSpaces: new Map([[project.id, project]]),
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
runId: "project-main",
|
||||
space: project,
|
||||
target: { groupId: "group-main-launch" },
|
||||
});
|
||||
});
|
||||
|
||||
it("names the space of items from the user's other spaces", () => {
|
||||
const side = { id: "space-side", name: "Side project" };
|
||||
const items = buildInboxItems({
|
||||
runs: [
|
||||
run("run-home", "waiting_input"),
|
||||
run("run-side", "waiting_input", {}, writer, side.id),
|
||||
run("takeover-side", "waiting_takeover", {}, writer, side.id),
|
||||
],
|
||||
messages: [
|
||||
message("ask-home", "run-home", [question], 1),
|
||||
message("ask-side", "run-side", [question], 2),
|
||||
],
|
||||
unreadThreads: [
|
||||
unreadThread(
|
||||
{ id: "bot-quiet", name: "Quiet", color: "#3EC5A8" },
|
||||
{ id: "quiet-latest", text: "Done", minute: 3 },
|
||||
side.id,
|
||||
),
|
||||
],
|
||||
otherSpaces: new Map([[side.id, side]]),
|
||||
});
|
||||
|
||||
expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([
|
||||
["takeover", "Side project"],
|
||||
["ask", null],
|
||||
["ask", "Side project"],
|
||||
["unread", "Side project"],
|
||||
]);
|
||||
expect(items[1]).not.toHaveProperty("space");
|
||||
expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } });
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { promptSnippet } from "./runs.js";
|
||||
|
||||
/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */
|
||||
/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */
|
||||
const INBOX_RUN_LIMIT = 50;
|
||||
const INBOX_UNREAD_LIMIT = 50;
|
||||
const INBOX_PREVIEW_LENGTH = 280;
|
||||
@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280;
|
||||
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
|
||||
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
|
||||
type InboxBot = { id: string; name: string; color: string };
|
||||
type InboxSpace = NonNullable<InboxItem["space"]>;
|
||||
|
||||
export interface InboxRunRow {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
status: string;
|
||||
updatedAt: Date;
|
||||
bot: InboxBot;
|
||||
@ -33,6 +35,7 @@ export interface InboxMessageRow {
|
||||
}
|
||||
|
||||
export interface InboxUnreadThreadRow {
|
||||
spaceId: string;
|
||||
createdAt: Date;
|
||||
bot: InboxBot | null;
|
||||
/** The newest bot message, if any. */
|
||||
@ -73,12 +76,14 @@ function previewText(blocks: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an ask can be answered from. The main conversation already shows its own asks, and an
|
||||
* archived group or an external conversation has no thread target to answer through.
|
||||
* Where an ask can be answered from. The open space's main conversation already shows its own
|
||||
* asks (a project's main conversation does not), and an archived group or an external
|
||||
* conversation has no thread target to answer through.
|
||||
*/
|
||||
function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null {
|
||||
function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null {
|
||||
if (thread.groupId) {
|
||||
if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null;
|
||||
if (!thread.group || thread.group.archivedAt) return null;
|
||||
if (thread.group.kind === "main" && currentSpace) return null;
|
||||
return { groupId: thread.groupId };
|
||||
}
|
||||
return thread.botId ? { botId: thread.botId } : null;
|
||||
@ -95,14 +100,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number {
|
||||
/**
|
||||
* Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
|
||||
* everything that waits on the user first, then unread threads not already covered, each
|
||||
* oldest first.
|
||||
* oldest first. Items from another space name it.
|
||||
*/
|
||||
export function buildInboxItems(input: {
|
||||
runs: InboxRunRow[];
|
||||
/** Ask and takeover messages of those runs, newest first. */
|
||||
messages: InboxMessageRow[];
|
||||
unreadThreads: InboxUnreadThreadRow[];
|
||||
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
|
||||
otherSpaces?: ReadonlyMap<string, InboxSpace>;
|
||||
}): InboxItem[] {
|
||||
const spaceOf = (spaceId: string) => {
|
||||
const space = input.otherSpaces?.get(spaceId);
|
||||
return space ? { space } : {};
|
||||
};
|
||||
const messagesByRun = new Map<string, InboxMessageRow[]>();
|
||||
for (const message of input.messages) {
|
||||
if (!message.runId) continue;
|
||||
@ -115,13 +126,14 @@ export function buildInboxItems(input: {
|
||||
for (const run of input.runs) {
|
||||
const messages = messagesByRun.get(run.id) ?? [];
|
||||
if (run.status === "waiting_input") {
|
||||
const target = askTarget(run.thread);
|
||||
const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId));
|
||||
const pending = target ? newest(messages, pendingAsk) : undefined;
|
||||
if (!target || !pending) continue;
|
||||
waiting.push({
|
||||
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
|
||||
bot: run.bot,
|
||||
target,
|
||||
...spaceOf(run.spaceId),
|
||||
runId: run.id,
|
||||
messageId: pending.message.id,
|
||||
ask: pending.block,
|
||||
@ -135,6 +147,7 @@ export function buildInboxItems(input: {
|
||||
kind: "takeover",
|
||||
bot: run.bot,
|
||||
target: { botId: run.bot.id },
|
||||
...spaceOf(run.spaceId),
|
||||
runId: run.id,
|
||||
messageId: request?.message.id ?? null,
|
||||
text: request?.block.text ?? "",
|
||||
@ -154,6 +167,7 @@ export function buildInboxItems(input: {
|
||||
kind: "unread",
|
||||
bot: thread.bot,
|
||||
target,
|
||||
...spaceOf(thread.spaceId),
|
||||
messageId: latest?.id ?? null,
|
||||
text: previewText(latest?.blocks),
|
||||
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
|
||||
@ -165,13 +179,34 @@ export function buildInboxItems(input: {
|
||||
|
||||
const botSelect = { id: true, name: true, color: true } as const;
|
||||
|
||||
/** The current space's inbox: only the actor's own bots, runs and threads. */
|
||||
/**
|
||||
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
|
||||
* being deleted.
|
||||
*/
|
||||
async function otherInboxSpaces(
|
||||
prisma: PrismaClient,
|
||||
actor: Actor,
|
||||
): Promise<Map<string, InboxSpace>> {
|
||||
const memberships = await prisma.spaceMember.findMany({
|
||||
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
|
||||
select: { space: { select: { id: true, name: true } } },
|
||||
});
|
||||
return new Map(memberships.map(({ space }) => [space.id, space]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are
|
||||
* answered and opened in their own space, where the thread procedures check membership and
|
||||
* ownership again.
|
||||
*/
|
||||
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
|
||||
const otherSpaces = await otherInboxSpaces(prisma, actor);
|
||||
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
|
||||
const ownBot = { userId: actor.userId, archivedAt: null };
|
||||
const [runs, unreadThreads] = await Promise.all([
|
||||
prisma.run.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
spaceId: { in: spaceIds },
|
||||
userId: actor.userId,
|
||||
status: { in: ["waiting_input", "waiting_takeover"] },
|
||||
bot: ownBot,
|
||||
@ -179,6 +214,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
spaceId: true,
|
||||
status: true,
|
||||
threadId: true,
|
||||
updatedAt: true,
|
||||
@ -197,12 +233,13 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
||||
// Only specialists' own threads; group threads, the main conversation included, stay out.
|
||||
prisma.thread.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
spaceId: { in: spaceIds },
|
||||
userId: actor.userId,
|
||||
unread: true,
|
||||
bot: { ...ownBot, kind: "specialist" },
|
||||
},
|
||||
select: {
|
||||
spaceId: true,
|
||||
createdAt: true,
|
||||
bot: { select: botSelect },
|
||||
messages: {
|
||||
@ -232,5 +269,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
||||
orderBy: { seq: "desc" },
|
||||
select: { id: true, runId: true, blocks: true, createdAt: true },
|
||||
});
|
||||
return buildInboxItems({ runs, messages, unreadThreads });
|
||||
return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
|
||||
}
|
||||
|
||||
108
apps/api/src/onboarding-intake.test.ts
Normal file
108
apps/api/src/onboarding-intake.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
closingNote,
|
||||
focusCard,
|
||||
intakeContext,
|
||||
intakeStep,
|
||||
nextIntakeStep,
|
||||
readIntakeAnswer,
|
||||
secondaryOption,
|
||||
} from "./onboarding-intake.js";
|
||||
|
||||
// Intl may separate the time from AM/PM with a narrow no-break space.
|
||||
const plain = (text: string) => text.replace(/\s/g, " ");
|
||||
const off = { morning: null, evening: null, weekly: null };
|
||||
|
||||
describe("onboarding steps", () => {
|
||||
it("asks in order and offers notifications only to a device that can still turn push on", () => {
|
||||
expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal");
|
||||
expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm");
|
||||
expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours");
|
||||
expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications");
|
||||
expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull();
|
||||
expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("gives every onboarding card a quiet way out, and another bot's focus card none", () => {
|
||||
const context = intakeContext(null, new Date());
|
||||
for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) {
|
||||
const card = intakeStep(id).card(context);
|
||||
expect(card.step).toBe(id);
|
||||
expect(secondaryOption(card)).toBeDefined();
|
||||
}
|
||||
expect(focusCard().step).toBeUndefined();
|
||||
expect(secondaryOption(focusCard())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes quiet hours in the user's locale", () => {
|
||||
const labels = (locale: string | null) =>
|
||||
intakeStep("quiet_hours")
|
||||
.card(intakeContext({ locale, timeZone: null }, new Date()))
|
||||
.options.map((option) => plain(option.label));
|
||||
expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]);
|
||||
expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]);
|
||||
expect(labels("not a tag")).toEqual(labels("en"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("readIntakeAnswer", () => {
|
||||
const goal = intakeStep("goal").card(intakeContext(null, new Date()));
|
||||
const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date()));
|
||||
|
||||
it("accepts the card's own options and words only where the card takes them", () => {
|
||||
expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({
|
||||
option: { id: "weekly", letter: "C", label: "Once a week" },
|
||||
});
|
||||
expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull();
|
||||
expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull();
|
||||
expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" });
|
||||
expect(readIntakeAnswer(goal, "_text", " ")).toBeNull();
|
||||
expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("closingNote", () => {
|
||||
const vienna = (iso: string, locale = "de-AT") =>
|
||||
intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso));
|
||||
const note = (rhythm: Parameters<typeof closingNote>[0], context: ReturnType<typeof vienna>) => {
|
||||
const { nextCheckInAt, text } = closingNote(rhythm, context);
|
||||
return { nextCheckInAt, text: plain(text) };
|
||||
};
|
||||
|
||||
it("names the first check-in in the user's time zone", () => {
|
||||
const morning = { ...off, morning: "08:00" };
|
||||
expect(note(morning, vienna("2026-10-07T04:00:00Z"))).toEqual({
|
||||
nextCheckInAt: "2026-10-07T06:00:00.000Z",
|
||||
text: "All set. I’ll check in today at 8:00.",
|
||||
});
|
||||
expect(note(morning, vienna("2026-10-07T09:00:00Z"))).toEqual({
|
||||
nextCheckInAt: "2026-10-08T06:00:00.000Z",
|
||||
text: "All set. I’ll check in tomorrow at 8:00.",
|
||||
});
|
||||
expect(note({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z")).text).toBe(
|
||||
"All set. I’ll check in today at 18:00.",
|
||||
);
|
||||
// Wednesday in Vienna; the weekly review is on Sunday.
|
||||
const weekly = { ...off, weekly: { day: 0, time: "18:00" } };
|
||||
expect(note(weekly, vienna("2026-10-07T09:00:00Z", "en-US"))).toEqual({
|
||||
nextCheckInAt: "2026-10-11T16:00:00.000Z",
|
||||
text: "All set. I’ll check in on Sunday at 6:00 PM.",
|
||||
});
|
||||
});
|
||||
|
||||
it("says the user can just write when no check-in is set", () => {
|
||||
expect(closingNote(off, vienna("2026-10-07T09:00:00Z"))).toEqual({
|
||||
kind: "intake_note",
|
||||
note: "done",
|
||||
nextCheckInAt: null,
|
||||
text: "All set. I’m here whenever you need me.",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to English and UTC for an unusable profile", () => {
|
||||
expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({
|
||||
locale: "en",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
});
|
||||
});
|
||||
297
apps/api/src/onboarding-intake.ts
Normal file
297
apps/api/src/onboarding-intake.ts
Normal file
@ -0,0 +1,297 @@
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters";
|
||||
import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts";
|
||||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||
import { knownTimeZone, nextCronDateAcross } from "@rakazo/core";
|
||||
import type { Prisma, PrismaClient } from "@rakazo/db";
|
||||
import { createGoal, updateNotificationSettings, updateUserProfile } from "@rakazo/db";
|
||||
|
||||
/**
|
||||
* The questions the main bot asks a new user in the main conversation, one card at a time.
|
||||
* Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order
|
||||
* the user sees. Everything here is deterministic, so no model tokens are spent.
|
||||
*/
|
||||
|
||||
export type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
|
||||
type ChoiceOption = ChoiceBlock["options"][number];
|
||||
|
||||
/** A validated answer: an option of the card, or the user's own words. */
|
||||
export type IntakeAnswer =
|
||||
| { option: ChoiceOption; text?: undefined }
|
||||
| { option?: undefined; text: string };
|
||||
|
||||
/** What a card may adapt to: times are written in the user's locale. */
|
||||
export interface IntakeContext {
|
||||
locale: string;
|
||||
timeZone: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface IntakeEffects {
|
||||
prisma: PrismaClient;
|
||||
jobs: JobPublisher;
|
||||
tx: Prisma.TransactionClient;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface IntakeStep {
|
||||
id: OnboardingStep;
|
||||
/** Asked only when this holds; otherwise onboarding moves past the step. */
|
||||
asks?: (device: { canEnablePush: boolean }) => boolean;
|
||||
card: (context: IntakeContext) => ChoiceBlock;
|
||||
/** Records a validated answer. Runs while the main conversation is locked. */
|
||||
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FocusOption {
|
||||
id: string;
|
||||
letter: string;
|
||||
label: string;
|
||||
apps: string[];
|
||||
/** Stored as a `goals` fact, so it stays a plain English statement. */
|
||||
fact: string;
|
||||
}
|
||||
|
||||
export const FOCUS_OPTIONS: FocusOption[] = [
|
||||
{
|
||||
id: "day",
|
||||
letter: "A",
|
||||
label: "Day-to-day work",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
fact: "Wants help with day-to-day work first",
|
||||
},
|
||||
{
|
||||
id: "inbox",
|
||||
letter: "B",
|
||||
label: "Inbox & email",
|
||||
apps: ["gmail", "googlecalendar", "slack"],
|
||||
fact: "Wants help with inbox and email first",
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
letter: "C",
|
||||
label: "Research & writing",
|
||||
apps: ["hackernews", "notion", "googledocs"],
|
||||
fact: "Wants help with research and writing first",
|
||||
},
|
||||
{
|
||||
id: "crew",
|
||||
letter: "D",
|
||||
label: "Specialist crew",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
fact: "Wants a coordinated crew of specialist bots",
|
||||
},
|
||||
];
|
||||
|
||||
const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" };
|
||||
|
||||
/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */
|
||||
export function focusCard(step?: "focus"): ChoiceBlock {
|
||||
return {
|
||||
kind: "choice",
|
||||
question: "What do you want me on first?",
|
||||
options: [
|
||||
...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
||||
...(step ? [SKIP] : []),
|
||||
],
|
||||
...(step ? { step } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const RHYTHMS: Record<string, CoachRhythm> = {
|
||||
morning: { morning: "08:00", evening: null, weekly: null },
|
||||
morning_evening: { morning: "08:00", evening: "18:00", weekly: null },
|
||||
weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } },
|
||||
none: { morning: null, evening: null, weekly: null },
|
||||
};
|
||||
|
||||
const QUIET_HOURS: Record<string, QuietHours> = {
|
||||
"22-07": { start: "22:00", end: "07:00" },
|
||||
"23-08": { start: "23:00", end: "08:00" },
|
||||
};
|
||||
|
||||
export const INTAKE_STEPS: IntakeStep[] = [
|
||||
{
|
||||
id: "focus",
|
||||
card: () => focusCard("focus"),
|
||||
save: async ({ prisma, tx, userId }, answer) => {
|
||||
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
|
||||
if (!option) return;
|
||||
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
|
||||
await updateUserProfile(prisma, userId, {
|
||||
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "goal",
|
||||
card: () => ({
|
||||
kind: "choice",
|
||||
step: "goal",
|
||||
question: "What’s one thing you want to get done in the next few weeks?",
|
||||
textInput: { placeholder: "Your goal" },
|
||||
options: [SKIP],
|
||||
}),
|
||||
save: async ({ prisma, userId }, answer) => {
|
||||
if (answer.text)
|
||||
await createGoal(prisma, userId, { title: answer.text, source: "onboarding" });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rhythm",
|
||||
card: () => ({
|
||||
kind: "choice",
|
||||
step: "rhythm",
|
||||
question: "When should I check in with you?",
|
||||
options: [
|
||||
{ id: "morning", letter: "A", label: "Every morning" },
|
||||
{ id: "morning_evening", letter: "B", label: "Morning and evening" },
|
||||
{ id: "weekly", letter: "C", label: "Once a week" },
|
||||
{ id: "none", letter: "", label: "Not now" },
|
||||
],
|
||||
}),
|
||||
save: async ({ prisma, jobs, userId }, answer) => {
|
||||
const rhythm = answer.option && RHYTHMS[answer.option.id];
|
||||
if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "quiet_hours",
|
||||
card: (context) => ({
|
||||
kind: "choice",
|
||||
step: "quiet_hours",
|
||||
question: "When should I stay quiet?",
|
||||
options: [
|
||||
...Object.entries(QUIET_HOURS).map(([id, range], index) => ({
|
||||
id,
|
||||
letter: String.fromCharCode(65 + index),
|
||||
label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`,
|
||||
})),
|
||||
SKIP,
|
||||
],
|
||||
}),
|
||||
save: async ({ prisma, userId }, answer) => {
|
||||
const quietHours = answer.option && QUIET_HOURS[answer.option.id];
|
||||
if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours });
|
||||
},
|
||||
},
|
||||
{
|
||||
// The browser subscribes when the user taps "Turn on"; the answer only moves on.
|
||||
id: "notifications",
|
||||
asks: ({ canEnablePush }) => canEnablePush,
|
||||
card: () => ({
|
||||
kind: "choice",
|
||||
step: "notifications",
|
||||
question: "Can I send you notifications?",
|
||||
options: [
|
||||
{ id: "on", letter: "A", label: "Turn on" },
|
||||
{ id: "later", letter: "", label: "Not now" },
|
||||
],
|
||||
}),
|
||||
save: async () => {},
|
||||
},
|
||||
];
|
||||
|
||||
export function intakeStep(id: OnboardingStep): IntakeStep {
|
||||
const step = INTAKE_STEPS.find((entry) => entry.id === id);
|
||||
if (!step) throw new Error(`Unknown onboarding step: ${id}`);
|
||||
return step;
|
||||
}
|
||||
|
||||
/** The step after `id` that applies to this device, or null when onboarding is done. */
|
||||
export function nextIntakeStep(
|
||||
id: OnboardingStep,
|
||||
device: { canEnablePush: boolean },
|
||||
): IntakeStep | null {
|
||||
const index = INTAKE_STEPS.findIndex((entry) => entry.id === id);
|
||||
return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null;
|
||||
}
|
||||
|
||||
/** Checks an answer against the card it answers; null when the card does not offer it. */
|
||||
export function readIntakeAnswer(
|
||||
card: ChoiceBlock,
|
||||
optionId: string,
|
||||
text: string | undefined,
|
||||
): IntakeAnswer | null {
|
||||
if (optionId === CHOICE_TEXT_ANSWER_ID) {
|
||||
const words = text?.trim();
|
||||
return card.textInput && words ? { text: words } : null;
|
||||
}
|
||||
const option = card.options.find((entry) => entry.id === optionId);
|
||||
return option ? { option } : null;
|
||||
}
|
||||
|
||||
/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */
|
||||
export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined {
|
||||
return card.options.find((option) => !option.letter);
|
||||
}
|
||||
|
||||
/** Card times and the closing line follow the user's profile, falling back to English and UTC. */
|
||||
export function intakeContext(
|
||||
profile: { locale: string | null; timeZone: string | null } | null,
|
||||
now: Date,
|
||||
): IntakeContext {
|
||||
let locale = "en";
|
||||
try {
|
||||
if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en";
|
||||
} catch {
|
||||
// Not a language tag; keep English.
|
||||
}
|
||||
return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now };
|
||||
}
|
||||
|
||||
function formatClock(time: string, locale: string): string {
|
||||
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(Date.UTC(2000, 0, 1, hour, minute)));
|
||||
}
|
||||
|
||||
function localDay(date: Date, timeZone: string): number {
|
||||
const [year, month, day] = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
.format(date)
|
||||
.split("-")
|
||||
.map(Number);
|
||||
return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000;
|
||||
}
|
||||
|
||||
export type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
|
||||
|
||||
/**
|
||||
* The closing note: what happens next, i.e. the first check-in when there is one. The web says
|
||||
* it in the user's language from `nextCheckInAt`; `text` is the English line for everything else.
|
||||
*/
|
||||
export function closingNote(rhythm: CoachRhythm, context: IntakeContext): IntakeNote {
|
||||
const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []);
|
||||
const next = nextCronDateAcross(crons, context.now, context.timeZone);
|
||||
return {
|
||||
kind: "intake_note",
|
||||
note: "done",
|
||||
nextCheckInAt: next?.toISOString() ?? null,
|
||||
text: closingText(next, context),
|
||||
};
|
||||
}
|
||||
|
||||
function closingText(next: Date | null, context: IntakeContext): string {
|
||||
if (!next) return "All set. I’m here whenever you need me.";
|
||||
const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone);
|
||||
const day =
|
||||
days === 0
|
||||
? "today"
|
||||
: days === 1
|
||||
? "tomorrow"
|
||||
: `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`;
|
||||
const time = new Intl.DateTimeFormat(context.locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: context.timeZone,
|
||||
}).format(next);
|
||||
return `All set. I’ll check in ${day} at ${time}.`;
|
||||
}
|
||||
@ -1,166 +1,192 @@
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type * as db from "@rakazo/db";
|
||||
import { SpaceLimitError } from "@rakazo/db";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||
|
||||
const createSpaceForMember = vi.hoisted(() => vi.fn());
|
||||
const ensureMainConversation = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@rakazo/db", async (original) => ({
|
||||
...(await original<typeof db>()),
|
||||
createSpaceForMember,
|
||||
ensureMainConversation,
|
||||
}));
|
||||
import path from "node:path";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyOnboardingScan,
|
||||
discoverOnboardingScan,
|
||||
onboardingScanFolder,
|
||||
onboardingScanStatus,
|
||||
startOnboardingScan,
|
||||
} from "./onboarding-scan.js";
|
||||
|
||||
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
||||
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
||||
const roots: string[] = [];
|
||||
|
||||
type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } };
|
||||
|
||||
/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */
|
||||
function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) {
|
||||
const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = [];
|
||||
createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => {
|
||||
if (spaces.length >= spaceLimit) throw new SpaceLimitError();
|
||||
const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null };
|
||||
spaces.push(space);
|
||||
return { id: space.id, name: space.name };
|
||||
});
|
||||
const prisma = {
|
||||
space: {
|
||||
findMany: async () => spaces.filter((space) => space.workspacePath),
|
||||
update: async ({ where, data }: SpaceUpdate) => {
|
||||
const space = spaces.find((candidate) => candidate.id === where.id);
|
||||
if (space) space.workspacePath = data.workspacePath;
|
||||
},
|
||||
/** Fails the test if the scan writes anything before its checks pass. */
|
||||
const untouchedPrisma = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("the database must not be touched");
|
||||
},
|
||||
} as unknown as db.PrismaClient;
|
||||
return { prisma, spaces };
|
||||
},
|
||||
) as PrismaClient;
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
|
||||
roots.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function scanHomeWithOneFile(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||
await mkdir(join(home, "Projects"));
|
||||
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
|
||||
return home;
|
||||
async function files(root: string, entries: string[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
const file = path.join(root, entry);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, "x\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function scanHomeWithRepos(...repos: string[]): Promise<string> {
|
||||
const home = await scanHomeWithOneFile();
|
||||
for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true });
|
||||
return home;
|
||||
async function project(root: string, relative: string, entries: string[]): Promise<void> {
|
||||
await mkdir(path.join(root, relative, ".git"), { recursive: true });
|
||||
await files(path.join(root, relative), entries);
|
||||
}
|
||||
|
||||
async function completed(scanId: string, userId: string) {
|
||||
return await vi.waitFor(
|
||||
async () => {
|
||||
const status = await getScanStatus(scanId, userId);
|
||||
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
|
||||
() => {
|
||||
const status = onboardingScanStatus(scanId, userId);
|
||||
if (status.status === "scanning") throw new Error("pending");
|
||||
return status;
|
||||
},
|
||||
{ timeout: 10_000, interval: 50 },
|
||||
{ timeout: 10_000, interval: 20 },
|
||||
);
|
||||
}
|
||||
|
||||
/** The emulated runtime that the shared test setup pins keeps the scan simulated. */
|
||||
function allowHostScan() {
|
||||
vi.stubEnv("AGENT_RUNTIME", "");
|
||||
vi.stubEnv("SANDBOX_PROVIDER", "");
|
||||
}
|
||||
afterAll(async () => {
|
||||
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("onboarding scan", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
createSpaceForMember.mockReset();
|
||||
ensureMainConversation.mockReset();
|
||||
it("lists git projects with their languages and tools and never follows a symlink", async () => {
|
||||
const home = await tempDir();
|
||||
const outside = await tempDir();
|
||||
// A home folder tracked by git is not itself a project.
|
||||
await mkdir(path.join(home, ".git"));
|
||||
await project(home, "code/atlas", [
|
||||
"src/a.ts",
|
||||
"src/b.ts",
|
||||
"src/c.ts",
|
||||
"src/d.tsx",
|
||||
"scripts/build.js",
|
||||
"Dockerfile",
|
||||
".github/workflows/ci.yml",
|
||||
"infra/main.tf",
|
||||
"src/odd.constructor",
|
||||
"node_modules/dep/index.js",
|
||||
"node_modules/dep/more.js",
|
||||
]);
|
||||
await project(home, "code/garden", ["main.py"]);
|
||||
await project(home, "deep/a/b/edge", ["tool.py"]);
|
||||
await project(home, "deep/a/b/c/too-deep", ["main.go"]);
|
||||
await project(home, ".hidden/secret", ["main.go"]);
|
||||
await project(home, "notes/node_modules/vendored", ["main.go"]);
|
||||
await files(home, ["notes/todo.md"]);
|
||||
await project(outside, "escaped", ["main.rs"]);
|
||||
await symlink(path.join(outside, "escaped"), path.join(home, "linked"));
|
||||
await symlink(outside, path.join(home, "code/atlas/src/outside"));
|
||||
|
||||
const findings = await discoverOnboardingScan(home);
|
||||
|
||||
expect(findings.projects).toEqual([
|
||||
{
|
||||
id: "project-0",
|
||||
name: "atlas",
|
||||
path: "code/atlas",
|
||||
languages: ["TypeScript", "JavaScript"],
|
||||
tools: ["Docker", "GitHub Actions", "Terraform"],
|
||||
},
|
||||
{ id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] },
|
||||
{ id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] },
|
||||
]);
|
||||
expect(findings.facts).toEqual([
|
||||
{ id: "language:Python", label: "Python", category: "work", value: "Codes in Python" },
|
||||
{
|
||||
id: "language:JavaScript",
|
||||
label: "JavaScript",
|
||||
category: "work",
|
||||
value: "Codes in JavaScript",
|
||||
},
|
||||
{
|
||||
id: "language:TypeScript",
|
||||
label: "TypeScript",
|
||||
category: "work",
|
||||
value: "Codes in TypeScript",
|
||||
},
|
||||
{ id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" },
|
||||
{
|
||||
id: "tool:GitHub Actions",
|
||||
label: "GitHub Actions",
|
||||
category: "tools",
|
||||
value: "Uses GitHub Actions",
|
||||
},
|
||||
{ id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides a scan from every account except the one that started it", async () => {
|
||||
const { prisma } = fakeDatabase();
|
||||
const { scanId } = await startOnboardingScan({ actor: member }, prisma);
|
||||
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
||||
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
||||
status: expect.any(String),
|
||||
});
|
||||
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
|
||||
it("offers a scan folder only to the deployment owner and only when it exists", async () => {
|
||||
const home = await tempDir();
|
||||
await files(home, ["file.txt"]);
|
||||
await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, home)).resolves.toBe(home);
|
||||
await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("never reads the host without a configured scan home", async () => {
|
||||
const { prisma } = fakeDatabase();
|
||||
const { scanId } = await startOnboardingScan({ actor: owner }, prisma);
|
||||
it("keeps a scan with the account that started it and returns only relative paths", async () => {
|
||||
const home = await tempDir();
|
||||
await project(home, "code/atlas", ["index.ts"]);
|
||||
const { scanId } = await startOnboardingScan(owner, home);
|
||||
|
||||
const status = await completed(scanId, owner.userId);
|
||||
expect(status.status).toBe("complete");
|
||||
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
||||
});
|
||||
|
||||
it("lets only the deployment owner discover the configured scan home", async () => {
|
||||
allowHostScan();
|
||||
const scanHome = await scanHomeWithRepos("Projects/app");
|
||||
const { prisma } = fakeDatabase();
|
||||
|
||||
const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma);
|
||||
const memberStatus = await completed(asMember.scanId, member.userId);
|
||||
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
||||
expect(createSpaceForMember).not.toHaveBeenCalled();
|
||||
|
||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
||||
expect(ownerStatus.status).toBe("complete");
|
||||
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
||||
});
|
||||
|
||||
it("links each repository of the deployment owner to one project of its own", async () => {
|
||||
allowHostScan();
|
||||
const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim");
|
||||
const app = await realpath(join(scanHome, "Projects", "app"));
|
||||
const { prisma, spaces } = fakeDatabase();
|
||||
|
||||
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const firstStatus = await completed(first.scanId, owner.userId);
|
||||
expect(firstStatus.status).toBe("complete");
|
||||
expect(firstStatus.result).toMatchObject({
|
||||
localRepos: [{ name: "app" }],
|
||||
createdProjects: [{ spaceId: "space-1", name: "app", path: app }],
|
||||
expect(status).toEqual({
|
||||
status: "complete",
|
||||
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
|
||||
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
|
||||
});
|
||||
expect(createSpaceForMember).toHaveBeenCalledWith(prisma, {
|
||||
currentSpaceId: owner.spaceId,
|
||||
userId: owner.userId,
|
||||
name: "app",
|
||||
expect(JSON.stringify(status)).not.toContain(home);
|
||||
expect(() => onboardingScanStatus(scanId, member.userId)).toThrow(
|
||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
||||
);
|
||||
expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow(
|
||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
||||
);
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, member, home, {
|
||||
scanId,
|
||||
projectIds: ["project-0"],
|
||||
factIds: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("checks ownership, the folder and every chosen project again before writing", async () => {
|
||||
const home = await tempDir();
|
||||
const outside = await tempDir();
|
||||
await project(home, "code/atlas", ["index.ts"]);
|
||||
await project(home, "code/garden", ["main.py"]);
|
||||
await project(outside, "elsewhere", ["main.rs"]);
|
||||
const { scanId } = await startOnboardingScan(owner, home);
|
||||
await completed(scanId, owner.userId);
|
||||
const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] };
|
||||
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
// The chosen project became a symlink out of the scan folder after the scan.
|
||||
await rm(path.join(home, "code/garden"), { recursive: true });
|
||||
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
||||
await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "That folder does not exist or is not readable.",
|
||||
});
|
||||
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
|
||||
// Each new project starts with its own main agent.
|
||||
expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1");
|
||||
|
||||
// Scanning again finds the linked project instead of creating a second one.
|
||||
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const againStatus = await completed(again.scanId, owner.userId);
|
||||
expect(againStatus.result).toMatchObject({ createdProjects: [] });
|
||||
expect(createSpaceForMember).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("joins a running scan instead of linking its repositories twice", async () => {
|
||||
allowHostScan();
|
||||
const scanHome = await scanHomeWithRepos("Projects/app");
|
||||
const { prisma, spaces } = fakeDatabase();
|
||||
|
||||
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const second = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
expect(second.scanId).toBe(first.scanId);
|
||||
await completed(first.scanId, owner.userId);
|
||||
expect(spaces).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stops creating projects at the space limit and still completes", async () => {
|
||||
allowHostScan();
|
||||
const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c");
|
||||
const { prisma } = fakeDatabase({ spaceLimit: 1 });
|
||||
|
||||
const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const status = await completed(scanId, owner.userId);
|
||||
expect(status.status).toBe("complete");
|
||||
expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] });
|
||||
expect(createSpaceForMember).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import type * as db from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { chooseFocus, markAppConnected } from "./onboarding.js";
|
||||
import { focusCard } from "./onboarding-intake.js";
|
||||
|
||||
const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>);
|
||||
vi.mock("@rakazo/db", async (original) => ({
|
||||
@ -13,16 +14,16 @@ vi.mock("@rakazo/db", async (original) => ({
|
||||
}));
|
||||
function fixture(catalog: unknown[]) {
|
||||
posted.length = 0;
|
||||
// A specialist's focus card, waiting for an answer.
|
||||
const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]);
|
||||
const tx = {
|
||||
$executeRaw: vi.fn(),
|
||||
message: {
|
||||
findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]),
|
||||
update: vi.fn(),
|
||||
},
|
||||
message: { findMany: pending, update: vi.fn() },
|
||||
};
|
||||
const deps = {
|
||||
prisma: {
|
||||
bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) },
|
||||
message: { findMany: pending },
|
||||
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)),
|
||||
},
|
||||
events: { notify: vi.fn() },
|
||||
@ -39,17 +40,16 @@ function fixture(catalog: unknown[]) {
|
||||
describe("onboarding connection suggestions", () => {
|
||||
it("does not invent authorization cards when no connector has an app catalog", async () => {
|
||||
const { deps, actor } = fixture([]);
|
||||
await chooseFocus(deps, actor, "bot", "day");
|
||||
expect(posted.flatMap((message) => message.blocks)).not.toContainEqual(
|
||||
expect.objectContaining({ kind: "app_connect" }),
|
||||
);
|
||||
expect(posted.length).toBeGreaterThan(0);
|
||||
await chooseFocus(deps, actor, "bot", { optionId: "day" });
|
||||
// The answered card is all that changes; nothing is said around it.
|
||||
expect(posted).toEqual([]);
|
||||
expect(deps.events.notify).toHaveBeenCalled();
|
||||
});
|
||||
it("uses the available connector and omits unavailable apps", async () => {
|
||||
const { deps, actor } = fixture([
|
||||
{ connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null },
|
||||
]);
|
||||
await chooseFocus(deps, actor, "bot", "day");
|
||||
await chooseFocus(deps, actor, "bot", { optionId: "day" });
|
||||
expect(
|
||||
posted
|
||||
.flatMap((message) => message.blocks)
|
||||
@ -57,6 +57,8 @@ describe("onboarding connection suggestions", () => {
|
||||
).toEqual([
|
||||
expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }),
|
||||
]);
|
||||
// The cards speak for themselves.
|
||||
expect(posted).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,67 +1,47 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import type { ConnectorRegistry } from "@rakazo/adapters";
|
||||
import type { Actor, MessageBlock } from "@rakazo/contracts";
|
||||
import { getCoachRhythm } from "@rakazo/adapters";
|
||||
import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts";
|
||||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||
import { featuredConnectorProvidersMatch } from "@rakazo/core";
|
||||
import {
|
||||
appendEventInTransaction,
|
||||
createThreadMessageInTransaction,
|
||||
ensureMainConversation,
|
||||
IsolationError,
|
||||
isPersonalMainBot,
|
||||
type Prisma,
|
||||
type PrismaClient,
|
||||
type ThreadEvents,
|
||||
} from "@rakazo/db";
|
||||
import type { ChoiceBlock, FocusOption, IntakeAnswer, IntakeContext } from "./onboarding-intake.js";
|
||||
import {
|
||||
closingNote,
|
||||
FOCUS_OPTIONS,
|
||||
focusCard,
|
||||
intakeContext,
|
||||
intakeStep,
|
||||
nextIntakeStep,
|
||||
readIntakeAnswer,
|
||||
secondaryOption,
|
||||
} from "./onboarding-intake.js";
|
||||
|
||||
/**
|
||||
* First-run conversational onboarding, seeded deterministically into the bot's
|
||||
* thread: greeting, a focus choice, and available app cards the user authorizes
|
||||
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
|
||||
* talks in the main conversation, where onboarding lands.
|
||||
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are
|
||||
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main
|
||||
* conversation, one card after each answer. Any other bot gets one focus card that suggests
|
||||
* apps the user authorizes inline. Focus never renames a bot.
|
||||
*/
|
||||
|
||||
type OnboardingDeps = {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
connectors: ConnectorRegistry;
|
||||
jobs: JobPublisher;
|
||||
};
|
||||
|
||||
type FocusOption = {
|
||||
id: string;
|
||||
letter: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
apps: string[];
|
||||
};
|
||||
|
||||
const FOCUS_OPTIONS: FocusOption[] = [
|
||||
{
|
||||
id: "day",
|
||||
letter: "A",
|
||||
label: "Day-to-day work",
|
||||
summary: "Slack, calendar, and email",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
},
|
||||
{
|
||||
id: "inbox",
|
||||
letter: "B",
|
||||
label: "Inbox & email",
|
||||
summary: "email and calendar",
|
||||
apps: ["gmail", "googlecalendar", "slack"],
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
letter: "C",
|
||||
label: "Research & writing",
|
||||
summary: "the web, notes, and docs",
|
||||
apps: ["hackernews", "notion", "googledocs"],
|
||||
},
|
||||
{
|
||||
id: "crew",
|
||||
letter: "D",
|
||||
label: "Specialist crew",
|
||||
summary: "a coordinated team of focused Oos",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
},
|
||||
];
|
||||
type Target = { spaceId: string; botId: string; threadId: string };
|
||||
|
||||
const APP_DESCRIPTIONS: Record<string, string> = {
|
||||
slack: "Search, read, and send messages.",
|
||||
@ -85,74 +65,102 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
|
||||
return { bot, thread: bot.thread };
|
||||
}
|
||||
|
||||
async function post(
|
||||
deps: OnboardingDeps,
|
||||
target: { spaceId: string; botId: string; threadId: string },
|
||||
/**
|
||||
* Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows
|
||||
* that reference the thread from another connection (the coach routines do).
|
||||
*/
|
||||
async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise<void> {
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`;
|
||||
}
|
||||
|
||||
async function postInTransaction(
|
||||
tx: Prisma.TransactionClient,
|
||||
target: Target,
|
||||
blocks: MessageBlock[],
|
||||
): Promise<string> {
|
||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: target.threadId,
|
||||
role: "bot",
|
||||
blocks,
|
||||
botId: target.botId,
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
payload: { messageId: message.id, role: "bot", blocks },
|
||||
});
|
||||
return { message, event };
|
||||
): Promise<{ seq: number }> {
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: target.threadId,
|
||||
role: "bot",
|
||||
blocks,
|
||||
botId: target.botId,
|
||||
});
|
||||
return appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
payload: { messageId: message.id, role: "bot", blocks },
|
||||
});
|
||||
}
|
||||
|
||||
async function updateBlocksInTransaction(
|
||||
tx: Prisma.TransactionClient,
|
||||
target: Target,
|
||||
messageId: string,
|
||||
blocks: MessageBlock[],
|
||||
): Promise<{ seq: number }> {
|
||||
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
||||
return appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId, role: "bot", blocks },
|
||||
});
|
||||
await deps.events.notify(target.threadId, committed.event.seq);
|
||||
return committed.message.id;
|
||||
}
|
||||
|
||||
async function updateBlocks(
|
||||
deps: OnboardingDeps,
|
||||
target: { spaceId: string; botId: string; threadId: string },
|
||||
target: Target,
|
||||
messageId: string,
|
||||
blocks: MessageBlock[],
|
||||
): Promise<void> {
|
||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
||||
return appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId, role: "bot", blocks },
|
||||
});
|
||||
});
|
||||
const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) =>
|
||||
updateBlocksInTransaction(tx, target, messageId, blocks),
|
||||
);
|
||||
await deps.events.notify(target.threadId, event.seq);
|
||||
}
|
||||
|
||||
/** Sentinel answerId for a focus card the user dismissed without choosing. */
|
||||
export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed";
|
||||
|
||||
/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */
|
||||
export async function startOnboarding(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
): Promise<void> {
|
||||
const { thread } = await requireBotThread(deps, actor, botId);
|
||||
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
|
||||
if (existing > 0) return;
|
||||
// Fresh chats start empty (same as web). The focus card is posted later via
|
||||
// promptFocus so non-first bots can wait ~10s for free typing, or skip if the
|
||||
// user already engaged.
|
||||
await requireBotThread(deps, actor, botId);
|
||||
}
|
||||
|
||||
function messageHasChoice(blocks: MessageBlock[]): boolean {
|
||||
return blocks.some((block) => block.kind === "choice");
|
||||
function isChoice(block: MessageBlock): block is ChoiceBlock {
|
||||
return block.kind === "choice";
|
||||
}
|
||||
|
||||
function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
|
||||
return blocks.some((block) => block.kind === "choice" && !block.answerId);
|
||||
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined {
|
||||
return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId);
|
||||
}
|
||||
|
||||
/** The newest card in the thread that still waits for an answer, however long ago it came. */
|
||||
async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) {
|
||||
const messages = await client.message.findMany({
|
||||
where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } },
|
||||
select: { id: true, blocks: true },
|
||||
orderBy: { seq: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
for (const message of messages) {
|
||||
const blocks = message.blocks as MessageBlock[];
|
||||
const card = pendingChoice(blocks);
|
||||
if (card) return { messageId: message.id, blocks, card };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the first card once. The main bot starts the onboarding questions, one set per user;
|
||||
* any other bot posts its focus card only while its thread is still idle.
|
||||
*/
|
||||
export async function promptFocus(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
@ -160,44 +168,32 @@ export async function promptFocus(
|
||||
): Promise<void> {
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
const blocks: MessageBlock[] = [
|
||||
{
|
||||
kind: "choice",
|
||||
question: "What do you want me on first?",
|
||||
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
||||
},
|
||||
];
|
||||
// Check + insert + event in one transaction so concurrent promptFocus calls
|
||||
// cannot duplicate cards, and a concurrent user send cannot publish first.
|
||||
// The intake runs with the personal main agent; a project's main bot starts like any other.
|
||||
const main = await isPersonalMainBot(deps.prisma, bot);
|
||||
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
|
||||
// and a concurrent user send cannot publish first.
|
||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
// Serialize concurrent promptFocus callers on this thread before the gate check.
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||
await lockThread(tx, thread.id);
|
||||
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) {
|
||||
return null;
|
||||
}
|
||||
const recent = await tx.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
select: { role: true, blocks: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (recent.some((message) => message.role === "user")) return null;
|
||||
if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: target.threadId,
|
||||
role: "bot",
|
||||
blocks,
|
||||
botId: target.botId,
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
payload: { messageId: message.id, role: "bot", blocks },
|
||||
});
|
||||
return { message, event };
|
||||
if (!main && recent.some((message) => message.role === "user")) return null;
|
||||
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null;
|
||||
if (!main) return postInTransaction(tx, target, [focusCard()]);
|
||||
const first = intakeStep("focus");
|
||||
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } });
|
||||
return postInTransaction(tx, target, [
|
||||
first.card(await loadIntakeContext(tx, actor.userId, new Date())),
|
||||
]);
|
||||
});
|
||||
if (!committed) return;
|
||||
await deps.events.notify(target.threadId, committed.event.seq);
|
||||
if (committed) await deps.events.notify(target.threadId, committed.seq);
|
||||
}
|
||||
|
||||
/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */
|
||||
export async function dismissFocus(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
@ -205,81 +201,128 @@ export async function dismissFocus(
|
||||
): Promise<void> {
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||
const recent = await tx.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const pending = recent.find((message) =>
|
||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
||||
const pending = await findPendingCard(deps.prisma, thread.id);
|
||||
const skip = pending?.card.step ? secondaryOption(pending.card) : undefined;
|
||||
if (pending?.card.step && skip) {
|
||||
await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id });
|
||||
return;
|
||||
}
|
||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await lockThread(tx, thread.id);
|
||||
const current = await findPendingCard(tx, thread.id);
|
||||
if (!current || current.card.step) return null;
|
||||
const blocks = current.blocks.map((block) =>
|
||||
block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block,
|
||||
);
|
||||
if (!pending) return null;
|
||||
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
||||
block.kind === "choice" && !block.answerId
|
||||
? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID }
|
||||
: block,
|
||||
);
|
||||
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId: pending.id, role: "bot", blocks },
|
||||
});
|
||||
return { messageId: pending.id, blocks, event };
|
||||
return updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||
});
|
||||
if (!claimed) return;
|
||||
await deps.events.notify(target.threadId, claimed.event.seq);
|
||||
if (event) await deps.events.notify(target.threadId, event.seq);
|
||||
}
|
||||
|
||||
export interface ChooseInput {
|
||||
step?: OnboardingStep;
|
||||
optionId: string;
|
||||
text?: string;
|
||||
canEnablePush?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers the pending card. The answer is checked against that card, a stale answer (another
|
||||
* card is pending by now) is ignored, and everything that follows is committed together with
|
||||
* it: the focus card's app suggestions, then the next onboarding question or the closing line.
|
||||
*/
|
||||
export async function chooseFocus(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
optionId: string,
|
||||
input: ChooseInput,
|
||||
): Promise<void> {
|
||||
const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId);
|
||||
if (!option) throw new IsolationError();
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
const pending = await findPendingCard(deps.prisma, thread.id);
|
||||
if (!pending || pending.card.step !== input.step) return;
|
||||
const answer = readIntakeAnswer(pending.card, input.optionId, input.text);
|
||||
if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" });
|
||||
const focus =
|
||||
!pending.card.step || pending.card.step === "focus"
|
||||
? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id)
|
||||
: undefined;
|
||||
// Fetched before the transaction: the connector catalog can take seconds.
|
||||
const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus) : [];
|
||||
|
||||
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||
const recent = await tx.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const pending = recent.find((message) =>
|
||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
||||
);
|
||||
if (!pending) return null;
|
||||
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
||||
block.kind === "choice" ? { ...block, answerId: option.id } : block,
|
||||
);
|
||||
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId: pending.id, role: "bot", blocks },
|
||||
});
|
||||
return { messageId: pending.id, blocks, event };
|
||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await lockThread(tx, thread.id);
|
||||
const current = await findPendingCard(tx, thread.id);
|
||||
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null;
|
||||
const onboarding = current.card.step
|
||||
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } })
|
||||
: null;
|
||||
if (current.card.step) {
|
||||
if (onboarding?.step !== current.card.step) return null;
|
||||
await intakeStep(current.card.step).save(
|
||||
{ prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId },
|
||||
answer,
|
||||
);
|
||||
}
|
||||
const answered = answeredCard(current.card, answer);
|
||||
const blocks = current.blocks.map((block) => (block === current.card ? answered : block));
|
||||
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||
for (const message of followUp) last = await postInTransaction(tx, target, message);
|
||||
if (current.card.step) {
|
||||
last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush);
|
||||
}
|
||||
return last;
|
||||
});
|
||||
if (!claimed) return;
|
||||
await deps.events.notify(target.threadId, claimed.event.seq);
|
||||
if (event) await deps.events.notify(target.threadId, event.seq);
|
||||
}
|
||||
|
||||
// Keep the name and title the user chose when creating the bot; the focus
|
||||
// step only suggests apps, it must not rename the bot.
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`,
|
||||
},
|
||||
]);
|
||||
function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock {
|
||||
return answer.option
|
||||
? { ...card, answerId: answer.option.id }
|
||||
: { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text };
|
||||
}
|
||||
|
||||
async function loadIntakeContext(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
now: Date,
|
||||
): Promise<IntakeContext> {
|
||||
const profile = await tx.userProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { locale: true, timeZone: true },
|
||||
});
|
||||
return intakeContext(profile, now);
|
||||
}
|
||||
|
||||
/** Posts the next onboarding question, or the closing line once every step is answered. */
|
||||
async function askNext(
|
||||
deps: OnboardingDeps,
|
||||
tx: Prisma.TransactionClient,
|
||||
target: Target,
|
||||
userId: string,
|
||||
answeredStep: OnboardingStep,
|
||||
canEnablePush = false,
|
||||
): Promise<{ seq: number }> {
|
||||
const now = new Date();
|
||||
const context = await loadIntakeContext(tx, userId, now);
|
||||
const next = nextIntakeStep(answeredStep, { canEnablePush });
|
||||
if (next) {
|
||||
await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } });
|
||||
return postInTransaction(tx, target, [next.card(context)]);
|
||||
}
|
||||
// The rhythm step saved its answer outside this transaction, so the routines are readable.
|
||||
const rhythm = await getCoachRhythm(deps.prisma, userId);
|
||||
await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } });
|
||||
return postInTransaction(tx, target, [closingNote(rhythm, context)]);
|
||||
}
|
||||
|
||||
/** The apps worth connecting for a focus, as cards; they need no words around them. */
|
||||
async function focusFollowUp(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
option: FocusOption,
|
||||
): Promise<MessageBlock[][]> {
|
||||
const providers = deps.connectors.managedProviders();
|
||||
const catalog = (
|
||||
await Promise.all(
|
||||
@ -290,14 +333,14 @@ export async function chooseFocus(
|
||||
traceId: "onboarding.choose",
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId: bot.id,
|
||||
botId,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
.catch(() => []),
|
||||
),
|
||||
)
|
||||
).flat();
|
||||
const cards: MessageBlock[] = option.apps.flatMap((slug) => {
|
||||
const cards = option.apps.flatMap((slug) => {
|
||||
const entry = catalog.find(
|
||||
(item) =>
|
||||
item.slug.toLowerCase() === slug.toLowerCase() ||
|
||||
@ -316,27 +359,7 @@ export async function chooseFocus(
|
||||
},
|
||||
];
|
||||
});
|
||||
if (!cards.length) {
|
||||
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
|
||||
return;
|
||||
}
|
||||
const cardNames = cards
|
||||
.map((card) => (card.kind === "app_connect" ? card.name : ""))
|
||||
.filter(Boolean);
|
||||
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`,
|
||||
},
|
||||
]);
|
||||
await post(deps, target, cards);
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`,
|
||||
},
|
||||
]);
|
||||
return cards.length > 0 ? [cards] : [];
|
||||
}
|
||||
|
||||
export async function markAppConnected(
|
||||
@ -376,7 +399,3 @@ export async function markAppConnected(
|
||||
await updateBlocks(deps, target, message.id, next);
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
} from "@rakazo/adapter-kit";
|
||||
import {
|
||||
computerControlExpireJobKey,
|
||||
MemoryDocumentTooLargeError,
|
||||
messagingDeliverJob,
|
||||
routineJobKey,
|
||||
routineWakeupJob,
|
||||
@ -28,6 +29,7 @@ import type {
|
||||
MemoryProviderResolver,
|
||||
PiOAuthLogins,
|
||||
RemoteConnectorDependencies,
|
||||
VapidKeys,
|
||||
} from "@rakazo/adapters";
|
||||
import {
|
||||
acquireComputerExecutionLease,
|
||||
@ -44,7 +46,6 @@ import {
|
||||
computerSupportsUpdate,
|
||||
computerUpdateView,
|
||||
createVoiceProvider,
|
||||
deletePushToken,
|
||||
deploymentAutoReviewDefault,
|
||||
destroyBot,
|
||||
displayBotWorkspacePath,
|
||||
@ -53,6 +54,7 @@ import {
|
||||
filterTeamWorkspaceEntries,
|
||||
hasActiveComputerControl,
|
||||
isAutoReviewCheckerConfigured,
|
||||
isBlockedHostname,
|
||||
isComputerScreenUnavailable,
|
||||
isProjectWorkspacePath,
|
||||
isSandboxGoneError,
|
||||
@ -75,7 +77,6 @@ import {
|
||||
resolveAutoReviewChecker,
|
||||
resolveBotWorkspacePath,
|
||||
sanitizeComposioError,
|
||||
savePushToken,
|
||||
scheduleComputerControlExpiry,
|
||||
scheduleComputerSleep,
|
||||
screenLeaseIdForRun,
|
||||
@ -100,6 +101,7 @@ import type {
|
||||
} from "@rakazo/contracts";
|
||||
import {
|
||||
appContract,
|
||||
CoachRoutineKeySchema,
|
||||
IntegrationProviderIdSchema,
|
||||
OPENAI_COMPATIBLE_PROVIDER_ID,
|
||||
} from "@rakazo/contracts";
|
||||
@ -132,6 +134,7 @@ import {
|
||||
findModelCredential,
|
||||
findSpaceModelTier,
|
||||
formatMessagingLinkCode,
|
||||
getNotificationSettings,
|
||||
InvalidProjectWorkspacePathError,
|
||||
InvalidSpaceNameError,
|
||||
IsolationError,
|
||||
@ -145,6 +148,8 @@ import {
|
||||
parseComputerMode,
|
||||
projectWorkspaceDto,
|
||||
releaseSpaceDeletionClaim,
|
||||
removePushSubscription,
|
||||
removePushSubscriptionsOfKind,
|
||||
renewSpaceDeletionClaim,
|
||||
requireMembership,
|
||||
resolveProjectWorkspaceDirectory,
|
||||
@ -153,8 +158,10 @@ import {
|
||||
SpaceLimitError,
|
||||
SpaceNotEmptyError,
|
||||
SpaceNotFoundError,
|
||||
savePushSubscription,
|
||||
selectSpaceModelPreference,
|
||||
selectSpaceVoicePreference,
|
||||
updateNotificationSettings,
|
||||
wireProjectWorkspace,
|
||||
} from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
@ -163,11 +170,13 @@ import { createAgentSkillsService } from "./agent-skills.js";
|
||||
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
||||
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
||||
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
||||
import { createCoachService } from "./coach.js";
|
||||
import {
|
||||
executionBlocksUserTakeover,
|
||||
resolveBusyBotName,
|
||||
toComputerStatus,
|
||||
} from "./computer-status.js";
|
||||
import { recordConnectedApp } from "./connected-app-facts.js";
|
||||
import { listInbox } from "./inbox.js";
|
||||
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
||||
import {
|
||||
@ -199,7 +208,12 @@ import {
|
||||
promptFocus,
|
||||
startOnboarding,
|
||||
} from "./onboarding.js";
|
||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||
import {
|
||||
applyOnboardingScan,
|
||||
onboardingScanFolder,
|
||||
onboardingScanStatus,
|
||||
startOnboardingScan,
|
||||
} from "./onboarding-scan.js";
|
||||
import { listSpaceRuns } from "./runs.js";
|
||||
import { addScreenProxyCapability } from "./screen-proxy.js";
|
||||
import { querySpaceSearch } from "./search.js";
|
||||
@ -305,6 +319,11 @@ async function reconcilePendingConnections(
|
||||
: []),
|
||||
];
|
||||
if (updates.length > 0) await prisma.$transaction(updates);
|
||||
const connected = new Set(sync.connectIds);
|
||||
for (const row of rows) {
|
||||
if (row.status !== "pending" || !connected.has(row.id)) continue;
|
||||
await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider });
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */
|
||||
@ -478,6 +497,8 @@ export interface RouterDeps {
|
||||
secrets: EncryptedSecretStore;
|
||||
oauthLogins: PiOAuthLogins;
|
||||
integrationSettings?: IntegrationProviderSettings;
|
||||
/** The deployment's web push key pair; without it browsers cannot subscribe. */
|
||||
vapid?: Pick<VapidKeys, "publicKey">;
|
||||
composio?: ComposioProvider;
|
||||
mcpOAuth?: McpOAuthBroker;
|
||||
connectors: ConnectorRegistry;
|
||||
@ -545,9 +566,18 @@ function mapSpaceLifecycleError(error: unknown): unknown {
|
||||
}
|
||||
|
||||
export function createRouter(deps: RouterDeps) {
|
||||
const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>();
|
||||
const os = implement(appContract).$context<{
|
||||
actor: Actor | null;
|
||||
signal?: AbortSignal;
|
||||
userAgent?: string;
|
||||
}>();
|
||||
const repos = createRepos(deps.prisma);
|
||||
const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors };
|
||||
const onboardingDeps = {
|
||||
prisma: deps.prisma,
|
||||
events: deps.events,
|
||||
connectors: deps.connectors,
|
||||
jobs: deps.jobs,
|
||||
};
|
||||
const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets);
|
||||
const groupRepos = createGroupRepos(deps.prisma);
|
||||
const projectGoalRepos = createProjectGoalRepos(deps.prisma);
|
||||
@ -560,6 +590,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
dataDir: deps.dataDir,
|
||||
});
|
||||
const agentSkills = createAgentSkillsService(deps.prisma);
|
||||
const coach = createCoachService(deps);
|
||||
|
||||
const authed = os.use(async ({ context, next }) => {
|
||||
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
|
||||
@ -599,12 +630,57 @@ export function createRouter(deps: RouterDeps) {
|
||||
profile: {
|
||||
get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)),
|
||||
update: authed.profile.update.handler(({ context, input }) =>
|
||||
updateProfile(deps.prisma, context.actor, input),
|
||||
updateProfile(deps, context.actor, input),
|
||||
),
|
||||
forget: authed.profile.forget.handler(({ context, input }) =>
|
||||
forgetProfile(deps.prisma, context.actor, input),
|
||||
forgetProfile(deps, context.actor, input),
|
||||
),
|
||||
},
|
||||
coach: {
|
||||
today: authed.coach.today.handler(({ context }) => coach.today(context.actor)),
|
||||
goals: {
|
||||
list: authed.coach.goals.list.handler(({ context, input }) =>
|
||||
coach.listGoals(context.actor, input),
|
||||
),
|
||||
create: authed.coach.goals.create.handler(({ context, input }) =>
|
||||
coach.createGoal(context.actor, input),
|
||||
),
|
||||
update: authed.coach.goals.update.handler(({ context, input }) =>
|
||||
coach.updateGoal(context.actor, input),
|
||||
),
|
||||
remove: authed.coach.goals.remove.handler(({ context, input }) =>
|
||||
coach.removeGoal(context.actor, input),
|
||||
),
|
||||
},
|
||||
habits: {
|
||||
list: authed.coach.habits.list.handler(({ context, input }) =>
|
||||
coach.listHabits(context.actor, input),
|
||||
),
|
||||
create: authed.coach.habits.create.handler(({ context, input }) =>
|
||||
coach.createHabit(context.actor, input),
|
||||
),
|
||||
update: authed.coach.habits.update.handler(({ context, input }) =>
|
||||
coach.updateHabit(context.actor, input),
|
||||
),
|
||||
remove: authed.coach.habits.remove.handler(({ context, input }) =>
|
||||
coach.removeHabit(context.actor, input),
|
||||
),
|
||||
},
|
||||
checkIns: {
|
||||
log: authed.coach.checkIns.log.handler(({ context, input }) =>
|
||||
coach.logCheckIn(context.actor, input),
|
||||
),
|
||||
list: authed.coach.checkIns.list.handler(({ context, input }) =>
|
||||
coach.listCheckIns(context.actor, input),
|
||||
),
|
||||
},
|
||||
rhythm: {
|
||||
get: authed.coach.rhythm.get.handler(({ context }) => coach.getRhythm(context.actor)),
|
||||
set: authed.coach.rhythm.set.handler(({ context, input }) =>
|
||||
coach.setRhythm(context.actor, input),
|
||||
),
|
||||
},
|
||||
},
|
||||
spaces: {
|
||||
list: authed.spaces.list.handler(async ({ context }) =>
|
||||
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
||||
@ -2515,21 +2591,28 @@ export function createRouter(deps: RouterDeps) {
|
||||
},
|
||||
});
|
||||
if (!doc) throw new IsolationError();
|
||||
const updated = await deps.memory.commit(
|
||||
{
|
||||
scope: doc.scope as "bot" | "user",
|
||||
botId: doc.botId ?? undefined,
|
||||
path: doc.path,
|
||||
content: input.content,
|
||||
},
|
||||
{
|
||||
operationId: "mem",
|
||||
traceId: "mem",
|
||||
spaceId: context.actor.spaceId,
|
||||
userId: context.actor.userId,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
);
|
||||
const updated = await deps.memory
|
||||
.commit(
|
||||
{
|
||||
scope: doc.scope as "bot" | "user",
|
||||
botId: doc.botId ?? undefined,
|
||||
path: doc.path,
|
||||
content: input.content,
|
||||
},
|
||||
{
|
||||
operationId: "mem",
|
||||
traceId: "mem",
|
||||
spaceId: context.actor.spaceId,
|
||||
userId: context.actor.userId,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof MemoryDocumentTooLargeError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
id: updated.id,
|
||||
scope: doc.scope as "bot" | "user",
|
||||
@ -3505,7 +3588,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
return { ok: true as const };
|
||||
}),
|
||||
choose: authed.onboarding.choose.handler(async ({ context, input }) => {
|
||||
await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId);
|
||||
await chooseFocus(onboardingDeps, context.actor, input.botId, input);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
|
||||
@ -3522,16 +3605,18 @@ export function createRouter(deps: RouterDeps) {
|
||||
);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
scan: authed.onboarding.scan.handler(async ({ context, input }) => {
|
||||
const { scanId } = await startOnboardingScan(
|
||||
{ actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome },
|
||||
deps.prisma,
|
||||
);
|
||||
return { scanId };
|
||||
}),
|
||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => {
|
||||
return await getScanStatus(input.scanId, context.actor.userId);
|
||||
}),
|
||||
scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({
|
||||
folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome),
|
||||
})),
|
||||
scan: authed.onboarding.scan.handler(({ context }) =>
|
||||
startOnboardingScan(context.actor, deps.env.onboardingScanHome),
|
||||
),
|
||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) =>
|
||||
onboardingScanStatus(input.scanId, context.actor.userId),
|
||||
),
|
||||
applyScan: authed.onboarding.applyScan.handler(({ context, input }) =>
|
||||
applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input),
|
||||
),
|
||||
},
|
||||
integrationSetup: {
|
||||
get: authed.integrationSetup.get.handler(async ({ context }) => {
|
||||
@ -3804,6 +3889,13 @@ export function createRouter(deps: RouterDeps) {
|
||||
}
|
||||
throw new IsolationError();
|
||||
}
|
||||
if (!auth.authorizationUrl) {
|
||||
await recordConnectedApp(deps.prisma, {
|
||||
userId: context.actor.userId,
|
||||
connectorId: input.connectorId,
|
||||
provider: input.provider,
|
||||
});
|
||||
}
|
||||
return { connectionId: row.id, authorizationUrl: auth.authorizationUrl };
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationError) throw error;
|
||||
@ -4109,6 +4201,13 @@ export function createRouter(deps: RouterDeps) {
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
if (row.status === "connected") {
|
||||
await recordConnectedApp(deps.prisma, {
|
||||
userId: context.actor.userId,
|
||||
connectorId: row.connectorId,
|
||||
provider: row.provider,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
@ -4865,13 +4964,53 @@ export function createRouter(deps: RouterDeps) {
|
||||
},
|
||||
notifications: {
|
||||
registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => {
|
||||
await savePushToken(deps.dataDir, context.actor.userId, input.token);
|
||||
await savePushSubscription(deps.prisma, context.actor.userId, {
|
||||
kind: "expo",
|
||||
endpoint: input.token,
|
||||
userAgent: context.userAgent,
|
||||
});
|
||||
return { ok: true as const };
|
||||
}),
|
||||
// The mobile app signs out without naming its token, so all of its tokens go.
|
||||
unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => {
|
||||
await deletePushToken(deps.dataDir, context.actor.userId);
|
||||
await removePushSubscriptionsOfKind(deps.prisma, context.actor.userId, "expo");
|
||||
return { ok: true as const };
|
||||
}),
|
||||
settings: {
|
||||
get: authed.notifications.settings.get.handler(({ context }) =>
|
||||
getNotificationSettings(deps.prisma, context.actor.userId),
|
||||
),
|
||||
update: authed.notifications.settings.update.handler(({ context, input }) =>
|
||||
updateNotificationSettings(deps.prisma, context.actor.userId, input),
|
||||
),
|
||||
},
|
||||
webPush: {
|
||||
config: authed.notifications.webPush.config.handler(async () => {
|
||||
const publicKey = (await deps.vapid?.publicKey()) ?? null;
|
||||
return { available: publicKey !== null, publicKey };
|
||||
}),
|
||||
subscribe: authed.notifications.webPush.subscribe.handler(async ({ context, input }) => {
|
||||
if (isBlockedHostname(new URL(input.endpoint).hostname.replace(/^\[|\]$/g, ""))) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Endpoint must be a public push service",
|
||||
});
|
||||
}
|
||||
await savePushSubscription(deps.prisma, context.actor.userId, {
|
||||
kind: "webpush",
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.keys.p256dh,
|
||||
auth: input.keys.auth,
|
||||
userAgent: context.userAgent,
|
||||
});
|
||||
return { ok: true as const };
|
||||
}),
|
||||
unsubscribe: authed.notifications.webPush.unsubscribe.handler(
|
||||
async ({ context, input }) => {
|
||||
await removePushSubscription(deps.prisma, context.actor.userId, input.endpoint);
|
||||
return { ok: true as const };
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
search: {
|
||||
query: authed.search.query.handler(async ({ context, input }) => ({
|
||||
@ -5179,6 +5318,7 @@ function spaceGroupDto(group: SpaceGroup): SpaceGroup {
|
||||
sectionId: group.sectionId,
|
||||
members: group.members,
|
||||
preview: group.preview,
|
||||
...(group.previewBlock ? { previewBlock: group.previewBlock } : {}),
|
||||
unread: group.unread,
|
||||
updatedAt: group.updatedAt,
|
||||
};
|
||||
@ -5562,6 +5702,7 @@ function mapRoutine(row: {
|
||||
lastRunAt: Date | null;
|
||||
nextRunAt: Date | null;
|
||||
createdAt: Date;
|
||||
coachKey: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
@ -5578,6 +5719,7 @@ function mapRoutine(row: {
|
||||
lastRunAt: row.lastRunAt?.toISOString() ?? null,
|
||||
nextRunAt: row.nextRunAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
coachKey: CoachRoutineKeySchema.safeParse(row.coachKey).data ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
143
apps/api/src/thread-message-pages.postgres.test.ts
Normal file
143
apps/api/src/thread-message-pages.postgres.test.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import type { MessageBlock } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadMessagePage } from "./thread-message-pages.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const describePostgres =
|
||||
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||
|
||||
describePostgres("loadMessagePage (PostgreSQL)", () => {
|
||||
let prisma: PrismaClient;
|
||||
let close: () => Promise<void>;
|
||||
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
|
||||
let userRunId: string;
|
||||
let peerRunId: string;
|
||||
let seq = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const db = createDb(databaseUrl!);
|
||||
prisma = db.prisma;
|
||||
close = async () => {
|
||||
await db.prisma.$disconnect();
|
||||
await db.pool.end();
|
||||
};
|
||||
identity = await provisionMessagingIdentity(
|
||||
prisma,
|
||||
{ provider: "sendblue", address: `+1555${Date.now().toString().slice(-7)}` },
|
||||
{ signupsEnabled: undefined, signupAllowlist: undefined },
|
||||
);
|
||||
const { spaceId, botId, threadId, userId } = identity;
|
||||
const owner = { spaceId, botId, threadId, userId };
|
||||
const run = async (trigger: string) => {
|
||||
const task = await prisma.task.create({
|
||||
data: { ...owner, prompt: "fixture", status: "completed" },
|
||||
});
|
||||
const created = await prisma.run.create({
|
||||
data: { ...owner, taskId: task.id, status: "completed", trigger },
|
||||
});
|
||||
return created.id;
|
||||
};
|
||||
userRunId = await run("user_message");
|
||||
peerRunId = await run("bot_message");
|
||||
seq =
|
||||
(
|
||||
await prisma.message.aggregate({
|
||||
where: { threadId: identity.threadId },
|
||||
_max: { seq: true },
|
||||
})
|
||||
)._max.seq ?? 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (identity) {
|
||||
await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } });
|
||||
await prisma.organization.deleteMany({ where: { id: identity.spaceId } });
|
||||
await prisma.user.deleteMany({ where: { id: identity.userId } });
|
||||
}
|
||||
await close?.();
|
||||
});
|
||||
|
||||
async function message(id: string, runId: string | null, blocks: MessageBlock[]) {
|
||||
seq += 1;
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
id: `${identity.threadId}-${id}`,
|
||||
threadId: identity.threadId,
|
||||
seq,
|
||||
role: "bot",
|
||||
runId,
|
||||
blocks,
|
||||
},
|
||||
});
|
||||
return seq;
|
||||
}
|
||||
|
||||
const ids = (page: Awaited<ReturnType<typeof loadMessagePage>>) =>
|
||||
page.messages.map((row) => row.id.slice(identity.threadId.length + 1));
|
||||
|
||||
it("fills a page with visible rows across a long stretch of hidden peer activity", async () => {
|
||||
await message("older", userRunId, [{ kind: "text", text: "Older answer" }]);
|
||||
await message("newer", userRunId, [{ kind: "text", text: "Newer answer" }]);
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
await message(`peer-steps-${index}`, peerRunId, [
|
||||
{ kind: "steps", steps: [{ label: "Working", count: 1 }] },
|
||||
]);
|
||||
}
|
||||
await message("peer-receipt", peerRunId, [
|
||||
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
|
||||
]);
|
||||
await message("peer-reply", peerRunId, [{ kind: "text", text: "Peer summary" }]);
|
||||
await message("orphan-run", "run-that-no-longer-exists", [{ kind: "text", text: "Kept" }]);
|
||||
|
||||
const first = await loadMessagePage(prisma, identity.threadId, undefined, 3);
|
||||
expect(ids(first)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
|
||||
|
||||
const second = await loadMessagePage(prisma, identity.threadId, first.olderCursor!, 2);
|
||||
expect(ids(second)).toEqual(["older", "newer"]);
|
||||
|
||||
const peerView = await loadMessagePage(
|
||||
prisma,
|
||||
identity.threadId,
|
||||
undefined,
|
||||
3,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(ids(peerView)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
|
||||
const peerOlder = await loadMessagePage(
|
||||
prisma,
|
||||
identity.threadId,
|
||||
peerView.olderCursor!,
|
||||
1,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(ids(peerOlder)).toEqual(["peer-steps-29"]);
|
||||
});
|
||||
|
||||
it("skips a receipt-only page for web but returns it when receipts are displayed", async () => {
|
||||
await message("before-receipts", userRunId, [{ kind: "text", text: "Answer" }]);
|
||||
await message("sent", userRunId, [
|
||||
{ kind: "bot_message_sent", toBotId: "bot-2", toBotName: "Coder", text: "Check this." },
|
||||
]);
|
||||
await message("received", peerRunId, [
|
||||
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
|
||||
]);
|
||||
|
||||
const web = await loadMessagePage(prisma, identity.threadId, undefined, 2);
|
||||
expect(ids(web)).toEqual(["orphan-run", "before-receipts"]);
|
||||
|
||||
const mobile = await loadMessagePage(
|
||||
prisma,
|
||||
identity.threadId,
|
||||
undefined,
|
||||
2,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
expect(ids(mobile)).toEqual(["sent", "received"]);
|
||||
});
|
||||
});
|
||||
@ -43,98 +43,6 @@ describe("thread message pages", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps peer receipt rows when filtering peer-run output from pages", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "message-reply",
|
||||
threadId: "thread-1",
|
||||
seq: 3,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Echoed peer reply" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:03.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-received",
|
||||
threadId: "thread-1",
|
||||
seq: 2,
|
||||
role: "user",
|
||||
blocks: [
|
||||
{
|
||||
kind: "bot_message_received",
|
||||
fromBotId: "bot-2",
|
||||
fromBotName: "Coder",
|
||||
text: "Done.",
|
||||
},
|
||||
],
|
||||
botId: null,
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:02.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-user",
|
||||
threadId: "thread-1",
|
||||
seq: 1,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Visible answer" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-user",
|
||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||
},
|
||||
]);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 3);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual([
|
||||
"message-user",
|
||||
"message-received",
|
||||
"message-reply",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters peer-run activity when its receipt is outside the loaded page", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "message-peer",
|
||||
threadId: "thread-1",
|
||||
seq: 2,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "steps", steps: [{ label: "Echoed peer reply", count: 1 }] }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:02.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-user",
|
||||
threadId: "thread-1",
|
||||
seq: 1,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Visible answer" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-user",
|
||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||
},
|
||||
]);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
|
||||
});
|
||||
|
||||
it("keeps peer text around-page targets and omits peer activity", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
@ -281,45 +189,6 @@ describe("thread message pages", () => {
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
|
||||
});
|
||||
|
||||
it("scans past a page containing only hidden peer-run activity", async () => {
|
||||
const row = (seq: number, runId: string, kind: "text" | "steps" = "text") => ({
|
||||
id: `message-${seq}`,
|
||||
threadId: "thread-1",
|
||||
seq,
|
||||
role: "bot",
|
||||
blocks:
|
||||
kind === "steps"
|
||||
? [{ kind: "steps", steps: [{ label: String(seq), count: 1 }] }]
|
||||
: [{ kind: "text", text: String(seq) }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId,
|
||||
createdAt: new Date("2026-08-16T00:00:00.000Z"),
|
||||
});
|
||||
const findMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
row(4, "run-peer", "steps"),
|
||||
row(3, "run-peer", "steps"),
|
||||
row(2, "run-peer", "steps"),
|
||||
])
|
||||
.mockResolvedValueOnce([row(1, "run-user")]);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "run-peer" }])
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-1"]);
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("scans past a receipt-only page so web can reach older user-visible rows", async () => {
|
||||
const receiptRows = [
|
||||
{
|
||||
@ -383,21 +252,13 @@ describe("thread message pages", () => {
|
||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||
},
|
||||
];
|
||||
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "run-peer" }])
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
|
||||
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns a receipt-only page when the client displays peer receipts", async () => {
|
||||
@ -419,11 +280,8 @@ describe("thread message pages", () => {
|
||||
runId: "run-peer",
|
||||
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
|
||||
});
|
||||
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
|
||||
} as unknown as PrismaClient;
|
||||
const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
|
||||
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
|
||||
|
||||
@ -432,10 +290,10 @@ describe("thread message pages", () => {
|
||||
"message-receipt-3",
|
||||
]);
|
||||
expect(page.olderCursor).toBe(2);
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("queries before the cursor and returns an ascending bounded page", async () => {
|
||||
it("queries the bot messages view before the cursor as an ascending bounded page", async () => {
|
||||
const findMany = vi.fn(async () =>
|
||||
[5, 4, 3].map((seq) => ({
|
||||
id: `message-${seq}`,
|
||||
@ -449,7 +307,7 @@ describe("thread message pages", () => {
|
||||
);
|
||||
const prisma = { message: { findMany } } as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", 6, 2);
|
||||
const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true);
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: { threadId: "thread-1", seq: { lt: 6 } },
|
||||
@ -461,7 +319,7 @@ describe("thread message pages", () => {
|
||||
});
|
||||
|
||||
it("ends pagination when the database returns no lookahead row", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
const queryRaw = vi.fn(async () => [
|
||||
{
|
||||
id: "message-0",
|
||||
threadId: "thread-1",
|
||||
@ -472,7 +330,7 @@ describe("thread message pages", () => {
|
||||
createdAt: new Date("2026-08-16T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
const prisma = { message: { findMany } } as unknown as PrismaClient;
|
||||
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", 1, 2);
|
||||
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
|
||||
import { isPeerReceiptBlocks } from "@rakazo/core";
|
||||
import type { Prisma, PrismaClient } from "@rakazo/db";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { Prisma } from "@rakazo/db";
|
||||
|
||||
type MessageDb = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
/** Peer-run rows that stay in the normal transcript: receipts, ask cards and the bot's own reply. */
|
||||
const PEER_VISIBLE_BLOCK_KINDS: readonly string[] = [
|
||||
"bot_message_sent",
|
||||
"bot_message_received",
|
||||
"ask",
|
||||
"text",
|
||||
];
|
||||
|
||||
export async function loadMessagePage(
|
||||
prisma: MessageDb,
|
||||
threadId: string,
|
||||
@ -47,37 +56,69 @@ export async function loadMessagePage(
|
||||
}
|
||||
}
|
||||
|
||||
let cursor = before;
|
||||
while (true) {
|
||||
if (includePeerRuns) {
|
||||
const rows = await prisma.message.findMany({
|
||||
where: {
|
||||
threadId,
|
||||
...(cursor === undefined ? {} : { seq: { lt: cursor } }),
|
||||
...(before === undefined ? {} : { seq: { lt: before } }),
|
||||
},
|
||||
orderBy: { seq: "desc" },
|
||||
take: pageSize + 1,
|
||||
});
|
||||
const hasOlder = rows.length > pageSize;
|
||||
const pageRows = rows.slice(0, pageSize).reverse();
|
||||
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
|
||||
return toPage(threadId, rows, pageSize);
|
||||
}
|
||||
|
||||
let cursor = before;
|
||||
while (true) {
|
||||
const rows = await visibleMessageRows(prisma, threadId, cursor, pageSize + 1);
|
||||
const page = toPage(threadId, rows, pageSize);
|
||||
// Web hides receipts client-side, so its receipt-only pages keep scanning.
|
||||
// Mobile explicitly retains them and must receive each page for pagination.
|
||||
const hasSubstantive = visibleRows.some(
|
||||
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]),
|
||||
);
|
||||
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) {
|
||||
return {
|
||||
threadId,
|
||||
messages: visibleRows.map(toThreadMessage),
|
||||
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null,
|
||||
};
|
||||
}
|
||||
// TODO: only rescan when a raw page is entirely peer output. Consider a run relation if
|
||||
// long peer-only histories make this path hot.
|
||||
cursor = pageRows[0]?.seq;
|
||||
const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks));
|
||||
if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page;
|
||||
cursor = page.olderCursor;
|
||||
}
|
||||
}
|
||||
|
||||
type MessageRow = Parameters<typeof toThreadMessage>[0];
|
||||
|
||||
function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage {
|
||||
const pageRows = rows.slice(0, pageSize).reverse();
|
||||
return {
|
||||
threadId,
|
||||
messages: pageRows.map(toThreadMessage),
|
||||
olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest-first transcript rows with peer-run activity excluded in the database, so a long
|
||||
* peer-only stretch costs one query instead of a rescan per hidden page.
|
||||
*/
|
||||
function visibleMessageRows(
|
||||
prisma: MessageDb,
|
||||
threadId: string,
|
||||
before: number | undefined,
|
||||
limit: number,
|
||||
): Promise<MessageRow[]> {
|
||||
return prisma.$queryRaw<MessageRow[]>`
|
||||
SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId",
|
||||
m."replyQuote", m."runId", m."createdAt"
|
||||
FROM messages m
|
||||
LEFT JOIN runs r ON r.id = m."runId"
|
||||
WHERE m."threadId" = ${threadId}
|
||||
${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`}
|
||||
AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join(
|
||||
PEER_VISIBLE_BLOCK_KINDS.map(
|
||||
(kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`,
|
||||
),
|
||||
" OR ",
|
||||
)})
|
||||
ORDER BY m.seq DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function loadAllMessages(
|
||||
prisma: PrismaClient,
|
||||
threadId: string,
|
||||
@ -106,15 +147,8 @@ async function withoutPeerRunMessages<T extends { runId: string | null; blocks:
|
||||
const peerRunIds = new Set(peerRuns.map((run) => run.id));
|
||||
return rows.filter((row) => {
|
||||
if (!row.runId || !peerRunIds.has(row.runId)) return true;
|
||||
// Keep peer receipts (chips), ask cards, and the bot's own text reply.
|
||||
const blocks = row.blocks as MessageBlock[];
|
||||
return blocks.some(
|
||||
(block) =>
|
||||
block.kind === "bot_message_sent" ||
|
||||
block.kind === "bot_message_received" ||
|
||||
block.kind === "ask" ||
|
||||
block.kind === "text",
|
||||
);
|
||||
return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind));
|
||||
});
|
||||
}
|
||||
|
||||
@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: {
|
||||
!!block &&
|
||||
typeof block === "object" &&
|
||||
"kind" in block &&
|
||||
(block.kind === "bot_message_received" ||
|
||||
block.kind === "bot_message_sent" ||
|
||||
block.kind === "ask" ||
|
||||
block.kind === "text"),
|
||||
typeof block.kind === "string" &&
|
||||
PEER_VISIBLE_BLOCK_KINDS.includes(block.kind),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -183,7 +183,7 @@ describe("threadSnapshot", () => {
|
||||
},
|
||||
]);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
|
||||
@ -203,7 +203,10 @@ describe("threadSnapshot", () => {
|
||||
|
||||
const snapshot = await threadSnapshot({ prisma }, target);
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledOnce();
|
||||
expect(tx.$queryRaw).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.stringContaining("FOR SHARE")]),
|
||||
"thread-1",
|
||||
);
|
||||
expect(findManyEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
@ -245,7 +248,7 @@ describe("threadSnapshot", () => {
|
||||
const findManyEvents = vi.fn();
|
||||
const findFirstRun = botRunFindFirst([run]);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
@ -330,7 +333,7 @@ describe("threadSnapshot", () => {
|
||||
prisma: {
|
||||
$transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
|
||||
callback({
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
@ -385,7 +388,7 @@ describe("threadSnapshot", () => {
|
||||
};
|
||||
const findFirstRun = botRunFindFirst([failed, completed]);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
@ -421,7 +424,7 @@ describe("threadSnapshot", () => {
|
||||
const findManyEvents = vi.fn();
|
||||
const findFirstRun = botRunFindFirst([]);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
@ -925,9 +928,16 @@ function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] })
|
||||
);
|
||||
}
|
||||
|
||||
/** The snapshot's thread lock finds the thread; its message page query finds no rows. */
|
||||
function threadQueries() {
|
||||
return vi.fn(async (sql: TemplateStringsArray) =>
|
||||
sql.join("").includes("FOR SHARE") ? [{ id: "thread-1" }] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { CoachRhythmDeps } from "@rakazo/adapters";
|
||||
import { syncCoachTimeZone } from "@rakazo/adapters";
|
||||
import type {
|
||||
Actor,
|
||||
ForgetUserFacts,
|
||||
@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProf
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
prisma: PrismaClient,
|
||||
deps: CoachRhythmDeps,
|
||||
actor: Actor,
|
||||
input: UpdateUserProfile,
|
||||
): Promise<UserProfileState> {
|
||||
try {
|
||||
await updateUserProfile(prisma, actor.userId, input);
|
||||
await updateUserProfile(deps.prisma, actor.userId, input);
|
||||
} catch (error) {
|
||||
throw toRpcError(error);
|
||||
}
|
||||
return getUserProfileState(prisma, actor.userId);
|
||||
if (input.timeZone !== undefined) await syncCoachTimeZone(deps, actor.userId);
|
||||
return getUserProfileState(deps.prisma, actor.userId);
|
||||
}
|
||||
|
||||
/** Unknown ids are ignored, so a caller cannot probe another user's facts. */
|
||||
export async function forgetProfile(
|
||||
prisma: PrismaClient,
|
||||
deps: CoachRhythmDeps,
|
||||
actor: Actor,
|
||||
input: ForgetUserFacts,
|
||||
): Promise<UserProfileState> {
|
||||
await forgetUserFacts(
|
||||
prisma,
|
||||
actor.userId,
|
||||
input.factIds
|
||||
? { factIds: input.factIds }
|
||||
: input.category
|
||||
? { category: input.category }
|
||||
: { all: true },
|
||||
);
|
||||
return getUserProfileState(prisma, actor.userId);
|
||||
const target = input.factIds
|
||||
? { factIds: input.factIds }
|
||||
: input.category
|
||||
? { category: input.category }
|
||||
: { all: true as const };
|
||||
await forgetUserFacts(deps.prisma, actor.userId, target);
|
||||
// Forgetting everything clears the time zone too.
|
||||
if ("all" in target) await syncCoachTimeZone(deps, actor.userId);
|
||||
return getUserProfileState(deps.prisma, actor.userId);
|
||||
}
|
||||
|
||||
@ -1903,6 +1903,32 @@ describe("mobile thread event reduction", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a choice card as its question and answer", () => {
|
||||
const card = {
|
||||
kind: "choice" as const,
|
||||
step: "goal" as const,
|
||||
question: "What’s one thing you want to get done in the next few weeks?",
|
||||
textInput: { placeholder: "Your goal" },
|
||||
options: [{ id: "skip", letter: "", label: "Skip" }],
|
||||
};
|
||||
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
|
||||
expect(
|
||||
blockText(
|
||||
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
|
||||
),
|
||||
).toBe(`${card.question}\nRun a 10K`);
|
||||
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
|
||||
`${card.question}\nSkip`,
|
||||
);
|
||||
const note = {
|
||||
kind: "intake_note" as const,
|
||||
note: "done" as const,
|
||||
nextCheckInAt: null,
|
||||
text: "All set. I’m here whenever you need me.",
|
||||
};
|
||||
expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text);
|
||||
});
|
||||
|
||||
it("deduplicates durable messages and replaces matching transient subagent state", () => {
|
||||
const initial = snapshot([
|
||||
mobileMessage("message-1", [{ kind: "text", text: "old" }]),
|
||||
|
||||
@ -894,6 +894,12 @@ export function blockText(message: MobileMessage) {
|
||||
if (block.kind === "child_bot") {
|
||||
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
|
||||
}
|
||||
// Choice cards are answered on the web; here they read as the question and its answer.
|
||||
if (block.kind === "choice") {
|
||||
const answer =
|
||||
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
|
||||
return answer ? `${block.question}\n${answer}` : block.question;
|
||||
}
|
||||
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
|
||||
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
|
||||
if (block.kind === "file") {
|
||||
|
||||
@ -4,6 +4,10 @@ import { captureScreenshot } from "./helpers";
|
||||
const fixture = "/e2e/fixtures/chart-links.html";
|
||||
|
||||
test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
// ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below.
|
||||
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`);
|
||||
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
|
||||
for (const inspect of ["0", "1"]) {
|
||||
for (const href of [
|
||||
'javascript:void(document.body.dataset.plotExecuted="yes")',
|
||||
|
||||
@ -255,7 +255,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
|
||||
|
||||
await sidebar.getByRole("button", { name: "Today", exact: true }).click();
|
||||
const heute = page.getByTestId("heute-brief");
|
||||
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
|
||||
await expect(heute).toBeVisible();
|
||||
await inEachTheme(page, async (theme) => {
|
||||
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
|
||||
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
|
||||
|
||||
@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation`
|
||||
* is set, it then opens the main bot's own thread, where most specs start.
|
||||
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
|
||||
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
|
||||
* set, it then opens the main bot's own thread, where most specs start.
|
||||
*/
|
||||
export async function completeOnboarding(
|
||||
page: Page,
|
||||
@ -101,13 +102,12 @@ export async function completeOnboarding(
|
||||
if (page.url().includes("/onboarding")) {
|
||||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||
"data-onboarding-step",
|
||||
/^(emoji|preferences|language|voice|integrations|scan|bot)$/,
|
||||
/^(emoji|language|voice|integrations|scan|bot)$/,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
const design = page.getByRole("heading", { name: /Design your Oo/i });
|
||||
const preferences = page.getByRole("heading", { name: /Tell us about yourself/i });
|
||||
const language = page.getByRole("heading", { name: /Choose your language/i });
|
||||
const voice = page.getByRole("heading", { name: /Pick a voice/i });
|
||||
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
|
||||
@ -119,8 +119,6 @@ export async function completeOnboarding(
|
||||
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
if (await design.isVisible()) {
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(preferences).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(language).toBeVisible();
|
||||
await page.getByRole("button", { name: /Skip/i }).click();
|
||||
@ -133,8 +131,15 @@ export async function completeOnboarding(
|
||||
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||
}
|
||||
|
||||
// Scan step auto-advances to the main bot, then onboarding lands in the main conversation.
|
||||
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
|
||||
// then lands in the main conversation.
|
||||
const skipScan = page
|
||||
.locator('[data-onboarding-step="scan"]')
|
||||
.getByRole("button", { name: "Skip", exact: true });
|
||||
await expect(async () => {
|
||||
if (await skipScan.isVisible()) await skipScan.click();
|
||||
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
|
||||
}).toPass({ timeout: 30_000 });
|
||||
await expect(chief).toBeVisible({ timeout: 15_000 });
|
||||
await waitForChatComposer(page);
|
||||
|
||||
@ -242,6 +247,7 @@ export async function openUserSettings(
|
||||
| "memory"
|
||||
| "profile"
|
||||
| "voice"
|
||||
| "notifications"
|
||||
| "usage"
|
||||
| "computer"
|
||||
| "updates",
|
||||
|
||||
@ -1,20 +1,136 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
||||
import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts";
|
||||
import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers";
|
||||
|
||||
/** The nav button reads "Today" or "Today, N need you". */
|
||||
function openHeute(page: Page) {
|
||||
return page.getByRole("button", { name: /^Today(,|$)/ }).click();
|
||||
}
|
||||
|
||||
function shiftDate(date: string, days: number) {
|
||||
return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
|
||||
await completeOnboarding(page);
|
||||
|
||||
await page.getByRole("button", { name: "Today", exact: true }).click();
|
||||
await openHeute(page);
|
||||
await expect(page).toHaveURL(/\/app\/[^/]+$/);
|
||||
|
||||
const brief = page.getByTestId("heute-brief");
|
||||
await expect(brief).toBeVisible();
|
||||
await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0);
|
||||
await expect(page.getByText("evOo Mission Control")).toHaveCount(0);
|
||||
await expect(page.getByTestId("heute-mission-control-link")).toHaveCount(0);
|
||||
await expect(page.getByTestId("heute-status")).toBeVisible();
|
||||
await expect(page.getByTestId("heute-mission-control-link")).toBeVisible();
|
||||
// Nothing tracked yet: no coach sections.
|
||||
await expect(page.getByTestId("heute-coach-brief")).toHaveCount(0);
|
||||
await expect(page.getByTestId("heute-habits")).toHaveCount(0);
|
||||
|
||||
await captureScreenshot(page, testInfo, "heute-native-brief");
|
||||
});
|
||||
|
||||
test("Heute shows the coach's brief, habits and goals, checks habits off and sets check-ins", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `heute-coach-${stamp}@rakazo.test`, "password12", "Heute Coach");
|
||||
await completeOnboarding(page);
|
||||
|
||||
// The main bot writes today's brief through its tool.
|
||||
const composer = page.getByRole("combobox", { name: "Message" });
|
||||
await composer.fill("write today's brief: Two calm hours on the beta, then a walk.");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("today's brief is ready.").first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const { date } = await rpc<CoachToday>(page, "coach/today", {});
|
||||
const goal = await rpc<Goal>(page, "coach/goals/create", {
|
||||
title: "Ship the beta",
|
||||
targetDate: shiftDate(date, 30),
|
||||
});
|
||||
const habit = await rpc<Habit>(page, "coach/habits/create", { title: "Walk 20 minutes" });
|
||||
for (const day of [1, 2]) {
|
||||
await rpc(page, "coach/checkIns/log", {
|
||||
kind: "habit",
|
||||
habitId: habit.id,
|
||||
date: shiftDate(date, -day),
|
||||
done: true,
|
||||
});
|
||||
}
|
||||
|
||||
await openHeute(page);
|
||||
await expect(page.getByTestId("heute-coach-brief")).toHaveText(
|
||||
"Two calm hours on the beta, then a walk.",
|
||||
);
|
||||
await expect(page.getByTestId(`heute-goal-${goal.id}`)).toContainText("Ship the beta");
|
||||
const habitRow = page.getByTestId(`heute-habit-${habit.id}`);
|
||||
await expect(habitRow).toContainText("Walk 20 minutes");
|
||||
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("2 days");
|
||||
|
||||
const checkbox = habitRow.getByRole("checkbox");
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
const logged = page.waitForResponse(
|
||||
(response) => response.url().includes("/rpc/coach/checkIns/log") && response.ok(),
|
||||
);
|
||||
await checkbox.click();
|
||||
await logged;
|
||||
await expect(checkbox).toBeChecked();
|
||||
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days");
|
||||
await captureScreenshot(page, testInfo, "heute-coach");
|
||||
|
||||
await page.reload();
|
||||
await openHeute(page);
|
||||
await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked();
|
||||
|
||||
// Check-in times live behind one compact control.
|
||||
await page.getByTestId("heute-rhythm").click();
|
||||
const panel = page.getByTestId("heute-rhythm-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
const morning = panel.getByTestId("heute-rhythm-morning");
|
||||
await expect(morning.getByRole("switch")).not.toBeChecked();
|
||||
const enabled = page.waitForResponse(
|
||||
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
|
||||
);
|
||||
await morning.getByRole("switch").click();
|
||||
await enabled;
|
||||
const time = morning.locator('input[type="time"]');
|
||||
await expect(time).toHaveValue("08:00");
|
||||
const moved = page.waitForResponse(
|
||||
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
|
||||
);
|
||||
await time.fill("07:30");
|
||||
await moved;
|
||||
await expect(time).toHaveValue("07:30");
|
||||
const weekly = panel.getByTestId("heute-rhythm-weekly");
|
||||
const weeklyOn = page.waitForResponse(
|
||||
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
|
||||
);
|
||||
await weekly.getByRole("switch").click();
|
||||
await weeklyOn;
|
||||
await expect(weekly.getByRole("combobox")).toHaveValue("0");
|
||||
await captureScreenshot(page, testInfo, "heute-rhythm");
|
||||
|
||||
expect(await rpc<CoachRhythm>(page, "coach/rhythm/get", {})).toEqual({
|
||||
morning: "07:30",
|
||||
evening: null,
|
||||
weekly: { day: 0, time: "18:00" },
|
||||
});
|
||||
const main = await rpc<{ id: string }>(page, "bots/ensureMain", {});
|
||||
const routines = await rpc<Routine[]>(page, "routines/list", { botId: main.id });
|
||||
expect(routines.map(({ name, coachKey }) => ({ name, coachKey }))).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: "Morning brief", coachKey: "morning" },
|
||||
{ name: "Weekly review", coachKey: "weekly" },
|
||||
]),
|
||||
);
|
||||
expect(routines).toHaveLength(2);
|
||||
|
||||
// The main bot's routine list names the check-ins by their key, in the UI language.
|
||||
await openMainBotThread(page);
|
||||
await page.getByTitle("Agent computer").click();
|
||||
await expect(page.getByText("Morning brief", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Weekly review", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@ -39,6 +39,75 @@ test("answers a specialist's question from the main conversation", async ({ page
|
||||
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("answers a question from another space in the main conversation", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross");
|
||||
await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 });
|
||||
const helper = await createBot(page, "Helper");
|
||||
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
|
||||
const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", {
|
||||
name: "Scout",
|
||||
title: "",
|
||||
description: "",
|
||||
notifyOnFinish: true,
|
||||
computerMode: "team",
|
||||
});
|
||||
await rpcInSpace(page, side.id, "threads/send", {
|
||||
botId: scout.id,
|
||||
text: "ask me which city to use",
|
||||
});
|
||||
const runInSide = async () =>
|
||||
(
|
||||
await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", {
|
||||
botId: scout.id,
|
||||
})
|
||||
).run ?? null;
|
||||
await expect
|
||||
.poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 })
|
||||
.toBe("waiting_input");
|
||||
|
||||
await openMainConversation(page, helper.id);
|
||||
const inbox = page.getByRole("region", { name: "Needs you" });
|
||||
const ask = inbox.locator("[data-kind='ask']");
|
||||
await expect(ask).toContainText("Scout", { timeout: 30_000 });
|
||||
await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project");
|
||||
await captureScreenshot(page, testInfo, "inbox-cross-space");
|
||||
|
||||
await ask.getByRole("button", { name: "Edit first" }).click();
|
||||
await ask.getByRole("textbox", { name: "Answer" }).fill("Paris");
|
||||
await ask.getByRole("button", { name: "Send answer" }).click();
|
||||
await expect(ask).toHaveCount(0);
|
||||
await expect.poll(runInSide, { timeout: 60_000 }).toBeNull();
|
||||
|
||||
// The reply arrives as unread from the other space and opens there.
|
||||
const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" });
|
||||
await expect(reply).toContainText("Paris", { timeout: 45_000 });
|
||||
await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project");
|
||||
await reply.getByRole("button").click();
|
||||
await page.waitForURL(new RegExp(`/app/${scout.id}$`));
|
||||
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id);
|
||||
});
|
||||
|
||||
async function rpcInSpace<T = unknown>(
|
||||
page: Page,
|
||||
spaceId: string,
|
||||
procedure: string,
|
||||
body: unknown,
|
||||
): Promise<T> {
|
||||
const response = await page.request.post(`/rpc/${procedure}`, {
|
||||
data: { json: body },
|
||||
headers: { "x-rakazo-space-id": spaceId },
|
||||
});
|
||||
const parsed = (await response.json()) as { json?: T; error?: { message?: string } };
|
||||
if (!response.ok() || parsed.error) {
|
||||
throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`);
|
||||
}
|
||||
return parsed.json as T;
|
||||
}
|
||||
|
||||
async function createBot(page: Page, name: string) {
|
||||
return rpc<{ id: string }>(page, "bots/create", {
|
||||
name,
|
||||
|
||||
@ -114,6 +114,36 @@ test("memory and skills are readable and editable in the app", async ({ page },
|
||||
await expect(botDocEditor).toHaveValue(new RegExp(botMarker));
|
||||
await botMemory.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
|
||||
// Remembering appends a dated note and keeps the edit above.
|
||||
await rpc(page, "threads/send", {
|
||||
botId: activeBotId(page),
|
||||
text: `remember that the tester ${stamp} prefers tea`,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await rpc<MemoryDocument[]>(page, "memory/list", {
|
||||
botId: activeBotId(page),
|
||||
scope: "bot",
|
||||
})
|
||||
).find((doc) => doc.path === "MEMORY.md")?.content,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toMatch(
|
||||
new RegExp(
|
||||
`^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`,
|
||||
),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ??
|
||||
null,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBeNull();
|
||||
|
||||
// Skills: create one through the editor, reopen it, edit, then delete it.
|
||||
// Builtin catalog is currently empty; user skills still cover create/edit/delete.
|
||||
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();
|
||||
|
||||
@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr
|
||||
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
|
||||
async function sendComposerMessage(page: Page, composer: Locator, text: string) {
|
||||
await composer.fill(text);
|
||||
const send = page.getByRole("button", { name: "Send", exact: true });
|
||||
// An open onboarding card in the transcript has its own Send.
|
||||
const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true });
|
||||
await expect(send).toBeEnabled();
|
||||
await send.click();
|
||||
await expect(composer).toHaveValue("");
|
||||
@ -72,14 +73,25 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
|
||||
|
||||
const transcript = page.getByTestId("transcript");
|
||||
|
||||
// Empty start: no Fresh-start greeting. Answer the focus card so a plain
|
||||
// bot text bubble exists for hover layout checks.
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
||||
// Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot
|
||||
// row's rail sits at the row's end, so the measured reply must wrap and fill its row.
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await page.getByTestId("focus-choice-day").click();
|
||||
const botText = page.getByText(/Got it\./);
|
||||
await expect(botText).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
const composer = page.getByRole("combobox", { name: /^Message/ });
|
||||
await sendComposerMessage(
|
||||
page,
|
||||
composer,
|
||||
`hover-bot-${stamp} ${"please look into this ".repeat(7)}`,
|
||||
);
|
||||
const botText = page.getByText(/i handled: hover-bot-/);
|
||||
await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 });
|
||||
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
|
||||
await expect(botRow).toBeVisible();
|
||||
await expectRailAtRest(page, botRow);
|
||||
@ -121,7 +133,6 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
|
||||
|
||||
const parentText = `hover-parent-${stamp}`;
|
||||
const replyText = `hover-reply-${stamp}`;
|
||||
const composer = page.getByRole("combobox", { name: /^Message/ });
|
||||
await expect(composer).toBeVisible();
|
||||
await sendComposerMessage(page, composer, parentText);
|
||||
|
||||
@ -387,12 +398,19 @@ test.describe("touch message actions", () => {
|
||||
expect(
|
||||
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
|
||||
).toBe(false);
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await page.getByTestId("focus-choice-day").click();
|
||||
const botText = page.getByText(/Got it\./);
|
||||
await expect(botText).toBeVisible({ timeout: 20_000 });
|
||||
// Onboarding writes no text of its own, so ask for a plain reply to act on.
|
||||
const composer = page.getByRole("combobox", { name: /^Message/ });
|
||||
await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`);
|
||||
const botText = page.getByText(/i handled: touch-bot-/);
|
||||
await expect(page.getByTestId("transcript").getByText(/i handled: touch-bot-/)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
const row = page
|
||||
.getByTestId("transcript")
|
||||
.locator("[data-message-id]")
|
||||
|
||||
@ -42,7 +42,9 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses",
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("Start the conversation — New Bot is on stage above."),
|
||||
).toBeVisible();
|
||||
@ -117,20 +119,30 @@ test("later bot waits before showing the focus card; sending cancels it", async
|
||||
await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay");
|
||||
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||
// The main bot shows the focus card immediately in the main conversation.
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.clock.install();
|
||||
await createBotFromPicker(page);
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.clock.fastForward(9_000);
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.clock.fastForward(1_500);
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await createBotFromPicker(page, { name: "Later Bot" });
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
const composer = page.getByPlaceholder(/Message/);
|
||||
await composer.fill("I'll set this up myself");
|
||||
// Send must finish before the delay is advanced: cancel runs after a successful
|
||||
@ -144,7 +156,9 @@ test("later bot waits before showing the focus card; sending cancels it", async
|
||||
page.getByTestId("transcript").getByText("I'll set this up myself").first(),
|
||||
).toBeVisible();
|
||||
await page.clock.fastForward(12_000);
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("plus picker can create a Private computer bot", async ({ page }, testInfo) => {
|
||||
|
||||
130
apps/web/e2e/notification-settings.spec.ts
Normal file
130
apps/web/e2e/notification-settings.spec.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* E2E: Settings → Notifications turns push on for this browser (the Push API is stubbed: headless
|
||||
* Chromium has no push service), sets quiet hours and switches nudges off; all of it survives a
|
||||
* reload.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
|
||||
|
||||
const ENDPOINT = "https://push.example.test/e2e-device";
|
||||
|
||||
/**
|
||||
* A fake Notification permission and service worker registration, kept in sessionStorage so they
|
||||
* survive a reload. Headless Chromium reports notifications as denied even when granted.
|
||||
*/
|
||||
async function stubPushApi(page: Page, endpoint: string) {
|
||||
await page.addInitScript((pushEndpoint) => {
|
||||
const permissionKey = "e2e:notification-permission";
|
||||
const notification = Object.defineProperties(function Notification() {}, {
|
||||
permission: { get: () => sessionStorage.getItem(permissionKey) ?? "default" },
|
||||
requestPermission: {
|
||||
value: async () => {
|
||||
sessionStorage.setItem(permissionKey, "granted");
|
||||
return "granted";
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, "Notification", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: notification,
|
||||
});
|
||||
const storageKey = "e2e:push-subscription";
|
||||
const current = () => {
|
||||
const stored = sessionStorage.getItem(storageKey);
|
||||
if (!stored) return null;
|
||||
const saved = JSON.parse(stored) as { serverKey: number[] };
|
||||
return {
|
||||
endpoint: pushEndpoint,
|
||||
options: { applicationServerKey: new Uint8Array(saved.serverKey).buffer },
|
||||
toJSON: () => ({
|
||||
endpoint: pushEndpoint,
|
||||
keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) },
|
||||
}),
|
||||
unsubscribe: async () => {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
const registration = {
|
||||
scope: `${location.origin}/`,
|
||||
pushManager: {
|
||||
getSubscription: async () => current(),
|
||||
subscribe: async (options: { applicationServerKey: Uint8Array }) => {
|
||||
sessionStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ serverKey: [...new Uint8Array(options.applicationServerKey)] }),
|
||||
);
|
||||
return current();
|
||||
},
|
||||
},
|
||||
};
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: {
|
||||
register: async () => registration,
|
||||
getRegistration: async () => registration,
|
||||
ready: Promise.resolve(registration),
|
||||
},
|
||||
});
|
||||
if (!("PushManager" in window)) {
|
||||
Object.defineProperty(window, "PushManager", { configurable: true, value: class {} });
|
||||
}
|
||||
}, endpoint);
|
||||
}
|
||||
|
||||
test("notification settings: push on this device, quiet hours and nudges", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await stubPushApi(page, ENDPOINT);
|
||||
await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester");
|
||||
await completeOnboarding(page);
|
||||
|
||||
let settings = await openUserSettings(page, "notifications");
|
||||
const panel = settings.getByTestId("notification-settings");
|
||||
const push = panel.getByTestId("notification-push-switch");
|
||||
await expect(push).not.toBeChecked();
|
||||
|
||||
const subscribed = page.waitForRequest("**/rpc/notifications/webPush/subscribe");
|
||||
await push.click();
|
||||
expect((await subscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
|
||||
await expect(push).toBeChecked();
|
||||
|
||||
const quiet = panel.getByTestId("notification-quiet-hours-switch");
|
||||
await expect(quiet).not.toBeChecked();
|
||||
await quiet.click();
|
||||
await expect(quiet).toBeChecked();
|
||||
const start = panel.getByTestId("notification-quiet-hours-start");
|
||||
await expect(start).toHaveValue("22:00");
|
||||
await expect(panel.getByTestId("notification-quiet-hours-end")).toHaveValue("07:00");
|
||||
const saved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/rpc/notifications/settings/update") &&
|
||||
response.request().postData()?.includes("23:30") === true,
|
||||
);
|
||||
await start.fill("23:30");
|
||||
await saved;
|
||||
|
||||
const nudges = panel.getByTestId("notification-nudges-switch");
|
||||
await expect(nudges).toBeChecked();
|
||||
await nudges.click();
|
||||
await expect(nudges).not.toBeChecked();
|
||||
await expect
|
||||
.poll(() => rpc<{ nudges: boolean }>(page, "notifications/settings/get", null))
|
||||
.toMatchObject({ nudges: false, quietHours: { start: "23:30", end: "07:00" } });
|
||||
|
||||
await captureScreenshot(page, testInfo, "notification-settings");
|
||||
|
||||
await page.reload();
|
||||
settings = await openUserSettings(page, "notifications");
|
||||
await expect(settings.getByTestId("notification-push-switch")).toBeChecked();
|
||||
await expect(settings.getByTestId("notification-quiet-hours-start")).toHaveValue("23:30");
|
||||
await expect(settings.getByTestId("notification-nudges-switch")).not.toBeChecked();
|
||||
|
||||
const unsubscribed = page.waitForRequest("**/rpc/notifications/webPush/unsubscribe");
|
||||
await settings.getByTestId("notification-push-switch").click();
|
||||
expect((await unsubscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
|
||||
await expect(settings.getByTestId("notification-push-switch")).not.toBeChecked();
|
||||
});
|
||||
@ -16,11 +16,16 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
||||
await expect(
|
||||
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("focus-choice-day")).toBeVisible();
|
||||
await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work");
|
||||
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
|
||||
await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew");
|
||||
// Onboarding questions offer a quiet way out instead of a dismiss button.
|
||||
await expect(page.getByTestId("focus-choice-skip")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
await page.mouse.move(1, 1);
|
||||
await captureScreenshot(page, testInfo, "01-focus-choice");
|
||||
await captureScreenshot(page, testInfo, "choice-card-onboarding");
|
||||
@ -33,6 +38,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
||||
).toBeVisible();
|
||||
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
// The app cards speak for themselves; onboarding adds no text around them.
|
||||
await expect(page.getByText(/^Got it/)).toHaveCount(0);
|
||||
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
|
||||
const connectionCards = page.getByRole("group", { name: / connection$/ });
|
||||
@ -45,10 +52,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
||||
);
|
||||
expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8);
|
||||
expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8);
|
||||
await page
|
||||
.getByTestId("transcript")
|
||||
.getByText("Hit those three and I’ll start pulling the picture.")
|
||||
.scrollIntoViewIfNeeded();
|
||||
// The next onboarding question follows the suggestions.
|
||||
const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
|
||||
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
|
||||
await slackCard(page).scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(1, 1);
|
||||
await captureScreenshot(page, testInfo, "02-app-suggestions");
|
||||
const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" });
|
||||
|
||||
77
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
77
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* E2E: the deployment owner's onboarding asks before scanning the fixture home folder the
|
||||
* harness configures, then adds only the projects and facts the user picks.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts";
|
||||
import { captureScreenshot, rpc, signup } from "./helpers";
|
||||
|
||||
const api = process.env.API_URL ?? "http://127.0.0.1:3110";
|
||||
|
||||
/** Advances whichever wizard step is showing until the scan step is reached. */
|
||||
async function walkToScanStep(page: Page) {
|
||||
const surface = page.locator('[data-rakazo-surface="onboarding"]');
|
||||
await expect(async () => {
|
||||
if ((await surface.getAttribute("data-onboarding-step")) === "scan") return;
|
||||
const skip = page.getByRole("button", { name: /^Skip/ }).first();
|
||||
const next = (await skip.isVisible())
|
||||
? skip
|
||||
: page.getByRole("button", { name: "Continue", exact: true });
|
||||
await next.click({ timeout: 2_000 });
|
||||
await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 });
|
||||
}).toPass({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
test("asks before looking for projects and adds only what the user picks", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const email = `discovery-${Date.now()}@rakazo.test`;
|
||||
await signup(page, email, "password12", "Discovery");
|
||||
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||
// The harness starts without a deployment owner; this spec takes the seat for its run.
|
||||
const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } });
|
||||
expect(owner.ok()).toBe(true);
|
||||
const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null };
|
||||
try {
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
await walkToScanStep(page);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible();
|
||||
await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "onboarding-discovery-consent");
|
||||
await page.getByRole("button", { name: "Look", exact: true }).click();
|
||||
|
||||
const projects = page.getByRole("group", { name: "Add as projects" });
|
||||
await expect(projects.getByRole("checkbox")).toHaveCount(2);
|
||||
const atlas = projects.getByRole("checkbox", { name: /atlas/ });
|
||||
await expect(atlas).not.toBeChecked();
|
||||
await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked();
|
||||
await atlas.check();
|
||||
const facts = page.getByRole("group", { name: "Add to About you" });
|
||||
await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked();
|
||||
await facts.getByRole("checkbox", { name: "Python" }).uncheck();
|
||||
await captureScreenshot(page, testInfo, "onboarding-discovery-results");
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||
|
||||
const navigation = await rpc<SpaceNavigation>(page, "spaces/list", {});
|
||||
const names = navigation.spaces.map((space) => space.name);
|
||||
expect(names).toContain("atlas");
|
||||
expect(names).not.toContain("garden");
|
||||
expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch(
|
||||
/code\/atlas$/,
|
||||
);
|
||||
const values = (await rpc<UserProfileState>(page, "profile/get", {})).facts.map(
|
||||
(fact) => fact.value,
|
||||
);
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]),
|
||||
);
|
||||
expect(values).not.toContain("Codes in Python");
|
||||
} finally {
|
||||
await page.request.post(`${api}/__e2e/deployment-owner`, {
|
||||
data: { userId: previousOwnerUserId },
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* E2E test for the new emoji-first onboarding flow:
|
||||
* E2E test for the emoji-first onboarding flow:
|
||||
* 1. Sign up → lands on onboarding
|
||||
* 2. Design Oo: pick shape + color → live preview
|
||||
* 3. Tell us about yourself: name, timezone, hobbies, work style, communication
|
||||
* 4. Choose language
|
||||
* 5. Skip integrations → bot created → land in chat
|
||||
* 3. Choose language, skip the voice
|
||||
* 4. Skip integrations → bot created → land in chat, where the main bot asks the rest
|
||||
*/
|
||||
|
||||
test("emoji-first onboarding: shape + color selection with live preview", async ({
|
||||
@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
||||
|
||||
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
|
||||
|
||||
// Click Continue → goes to preferences step
|
||||
// Click Continue → goes to the language step
|
||||
await page
|
||||
.getByRole("button", { name: /Continue/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Step 2: Tell us about yourself
|
||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||
|
||||
if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences");
|
||||
|
||||
// Fill in name
|
||||
await page.getByPlaceholder(/Your name/i).fill("Alice");
|
||||
|
||||
// Fill in timezone
|
||||
await page.getByLabel(/Timezone/i).fill("Europe/Vienna");
|
||||
|
||||
// Select a hobby
|
||||
const hobbyButtons = page.locator("button[aria-pressed]");
|
||||
if (await hobbyButtons.count()) {
|
||||
await hobbyButtons.first().click(); // toggle first hobby
|
||||
}
|
||||
|
||||
// Select work style
|
||||
const workStyleButtons = page.locator("button[aria-pressed]:visible");
|
||||
if ((await workStyleButtons.count()) >= 2) {
|
||||
await workStyleButtons.nth(0).click();
|
||||
}
|
||||
|
||||
// Select communication style
|
||||
const commButtons = page.locator("button[aria-pressed]:visible");
|
||||
if ((await commButtons.count()) >= 2) {
|
||||
await commButtons.nth(0).click();
|
||||
}
|
||||
|
||||
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
|
||||
|
||||
// Click Continue → goes to language step
|
||||
await page
|
||||
.getByRole("button", { name: /Continue/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Step 3: Language selection
|
||||
// Step 2: Language selection
|
||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||
|
||||
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
|
||||
@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => {
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Preferences step — just continue
|
||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: /Continue/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Language step — click Skip, then skip voice.
|
||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Skip/i }).click();
|
||||
|
||||
91
apps/web/e2e/onboarding-intake.spec.ts
Normal file
91
apps/web/e2e/onboarding-intake.spec.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* E2E: after the wizard the main bot gets to know the user in the main conversation, one card
|
||||
* after each answer, without ever blocking the composer.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
|
||||
|
||||
function card(page: Page, step: string) {
|
||||
return page.locator(`[data-testid="choice-card"][data-step="${step}"]`);
|
||||
}
|
||||
|
||||
test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester");
|
||||
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||
|
||||
await page.getByTestId("focus-choice-day").click();
|
||||
const goal = card(page, "goal");
|
||||
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
|
||||
|
||||
// Typing instead of answering goes to the main bot, and the card stays answerable.
|
||||
const composer = page.getByRole("combobox", { name: "Message" });
|
||||
await composer.fill("Quick question first");
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
const goalInput = goal.getByRole("textbox", {
|
||||
name: "What’s one thing you want to get done in the next few weeks?",
|
||||
});
|
||||
await goalInput.fill("Run a 10K");
|
||||
await goal.scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(1, 1);
|
||||
await captureScreenshot(page, testInfo, "onboarding-intake-goal");
|
||||
await goalInput.press("Enter");
|
||||
await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K");
|
||||
|
||||
const rhythm = card(page, "rhythm");
|
||||
await expect(rhythm).toContainText("When should I check in with you?");
|
||||
// The sidebar previews the main conversation as the web words it: the pending question.
|
||||
const mainEntry = page.locator("aside").first().locator("[data-main-conversation]");
|
||||
await expect(mainEntry).toContainText("When should I check in with you?");
|
||||
await rhythm.scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(1, 1);
|
||||
await captureScreenshot(page, testInfo, "onboarding-intake-rhythm");
|
||||
await rhythm.getByTestId("rhythm-choice-morning").click();
|
||||
await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning");
|
||||
|
||||
const quiet = card(page, "quiet_hours");
|
||||
await expect(quiet).toContainText("When should I stay quiet?");
|
||||
await quiet.getByTestId("quiet_hours-choice-22-07").click();
|
||||
|
||||
// Only a browser that can still turn push on is asked about notifications.
|
||||
// The web words the closing note itself, from the time of the first check-in.
|
||||
const closing = page
|
||||
.getByTestId("transcript")
|
||||
.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/);
|
||||
const notifications = card(page, "notifications");
|
||||
await expect(notifications.or(closing).first()).toBeVisible();
|
||||
if (await notifications.isVisible()) {
|
||||
await notifications.getByTestId("notifications-choice-later").click();
|
||||
}
|
||||
await expect(closing).toBeVisible();
|
||||
await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./);
|
||||
await expect(
|
||||
page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'),
|
||||
).toHaveCount(0);
|
||||
await closing.scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(1, 1);
|
||||
await captureScreenshot(page, testInfo, "onboarding-intake-done");
|
||||
|
||||
const settings = await rpc<{ quietHours: { start: string; end: string } | null }>(
|
||||
page,
|
||||
"notifications/settings/get",
|
||||
null,
|
||||
);
|
||||
expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" });
|
||||
const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>(
|
||||
page,
|
||||
"profile/get",
|
||||
null,
|
||||
);
|
||||
expect(profile.facts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
value: "Wants help with day-to-day work first",
|
||||
source: "onboarding",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({
|
||||
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||
"data-onboarding-step",
|
||||
/^(emoji|preferences|language|voice|integrations)$/,
|
||||
/^(emoji|language|voice|integrations)$/,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||
@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) {
|
||||
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||
"data-onboarding-step",
|
||||
/^(emoji|preferences|language|voice|integrations)$/,
|
||||
/^(emoji|language|voice|integrations)$/,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||
|
||||
@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page }
|
||||
.getByRole("button", { name: /Continue/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||
});
|
||||
|
||||
@ -67,6 +67,9 @@ test("account settings appearance control switches to light mode", async ({ page
|
||||
await settings.getByRole("button", { name: "Close user settings" }).click();
|
||||
await expect(settings).toBeHidden();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
// Light surfaces stay light: no dark panel or white-on-paper text survives the switch.
|
||||
await expect(page.locator("main").first()).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await expect(page.locator("aside").first()).toHaveCSS("background-color", "rgb(245, 245, 242)");
|
||||
await captureScreenshot(page, testInfo, "ui-appearance-light-shell");
|
||||
await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light");
|
||||
|
||||
|
||||
74
apps/web/e2e/user-profile-edit.spec.ts
Normal file
74
apps/web/e2e/user-profile-edit.spec.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* E2E: Settings → About you edits a fact in place: its wording, category and the spaces it
|
||||
* shows in.
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { UserProfileState } from "@rakazo/contracts";
|
||||
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
|
||||
|
||||
test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester");
|
||||
await completeOnboarding(page);
|
||||
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
|
||||
await rpc(page, "profile/update", {
|
||||
addFacts: [{ category: "preferences", value: "Prefers tea" }],
|
||||
});
|
||||
|
||||
const settings = await openUserSettings(page, "profile");
|
||||
const profile = settings.getByTestId("user-profile-settings");
|
||||
const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" });
|
||||
await tea.getByRole("button", { name: "Edit" }).click();
|
||||
|
||||
const editor = profile.getByTestId("user-fact-editor");
|
||||
const wording = editor.getByRole("textbox", { name: "Fact" });
|
||||
await expect(wording).toBeFocused();
|
||||
await wording.fill("Prefers green tea");
|
||||
await editor.getByRole("combobox", { name: "Category" }).selectOption("habits");
|
||||
await editor.getByRole("button", { name: "All spaces" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Side project" }).click();
|
||||
await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "user-profile-edit");
|
||||
|
||||
await editor.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
const edited = profile
|
||||
.getByTestId("user-facts-habits")
|
||||
.getByTestId("user-fact")
|
||||
.filter({ hasText: "Prefers green tea" });
|
||||
await expect(edited).toContainText("Side project");
|
||||
await expect(tea).toHaveCount(0);
|
||||
const teaFact = async () =>
|
||||
(await rpc<UserProfileState>(page, "profile/get", null)).facts.find((fact) =>
|
||||
fact.value.includes("tea"),
|
||||
);
|
||||
expect(await teaFact()).toMatchObject({
|
||||
value: "Prefers green tea",
|
||||
category: "habits",
|
||||
allSpaces: false,
|
||||
spaceIds: [side.id],
|
||||
});
|
||||
|
||||
// Escape leaves the fact as it was; showing it everywhere again clears the limit.
|
||||
await edited.getByRole("button", { name: "Edit" }).click();
|
||||
await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(editor).toHaveCount(0);
|
||||
await expect(edited).toContainText("Prefers green tea");
|
||||
await expect(settings).toBeVisible();
|
||||
|
||||
await edited.getByRole("button", { name: "Edit" }).click();
|
||||
await editor.getByRole("button", { name: "Side project" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await editor.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
await expect(edited).not.toContainText("Side project");
|
||||
expect(await teaFact()).toMatchObject({
|
||||
value: "Prefers green tea",
|
||||
allSpaces: true,
|
||||
spaceIds: [],
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* E2E: onboarding answers land in the user profile, and Settings → About you shows every
|
||||
* fact with its origin, confirms a pending one, and forgets one or all of them.
|
||||
* E2E: onboarding fills the user profile without asking (time zone, locale and language from
|
||||
* the browser) and records the focus answer as a fact; Settings → About you shows every fact
|
||||
* with its origin, confirms a pending one, and forgets one or all of them.
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
|
||||
@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer }
|
||||
test.use({ timezoneId: "Europe/Vienna" });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Walk the full emoji-first flow, including the preferences step.
|
||||
// Walk the full emoji-first flow.
|
||||
await page.route("**/rpc/integrationSetup/get", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
||||
});
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||
await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna");
|
||||
await page.getByPlaceholder("Your name").fill("Pat Example");
|
||||
await page.getByRole("button", { name: "Music", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Deep-dive focused" }).click();
|
||||
await page.getByRole("button", { name: "Direct & concise" }).click();
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||
await page.getByRole("button", { name: "English", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||
|
||||
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 });
|
||||
await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
|
||||
await waitForChatComposer(page);
|
||||
// The main bot asks the rest in the main conversation; the focus answer becomes a fact.
|
||||
await page.getByTestId("focus-choice-day").click();
|
||||
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible();
|
||||
|
||||
// The answers no longer overwrite a bot's instructions.
|
||||
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
|
||||
expect(bots.length).toBeGreaterThan(0);
|
||||
for (const bot of bots) expect(bot.instructions).not.toContain("User Context");
|
||||
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example");
|
||||
// The account name is used as is; onboarding no longer asks for it again.
|
||||
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester");
|
||||
|
||||
// Sensitive facts from anywhere but the user wait for confirmation.
|
||||
await rpc(page, "profile/update", {
|
||||
@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
||||
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
|
||||
await expect(profile.getByTestId("profile-field-language")).toContainText("English");
|
||||
await expect(profile.getByTestId("profile-field-locale")).toContainText("United States");
|
||||
await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText(
|
||||
"Direct and concise",
|
||||
);
|
||||
|
||||
const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" });
|
||||
await expect(music).toContainText("Onboarding");
|
||||
await expect(profile.getByTestId("user-facts-habits")).toContainText(
|
||||
"Prefers focused deep dives",
|
||||
const focus = profile
|
||||
.getByTestId("user-fact")
|
||||
.filter({ hasText: "Wants help with day-to-day work first" });
|
||||
await expect(focus).toContainText("Onboarding");
|
||||
await expect(profile.getByTestId("user-facts-goals")).toContainText(
|
||||
"Wants help with day-to-day work first",
|
||||
);
|
||||
|
||||
const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" });
|
||||
@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
||||
await expect(allergy).toHaveAttribute("data-status", "active");
|
||||
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
|
||||
|
||||
await music.getByRole("button", { name: "Delete" }).click();
|
||||
await expect(music).toHaveCount(0);
|
||||
await focus.getByRole("button", { name: "Delete" }).click();
|
||||
await expect(focus).toHaveCount(0);
|
||||
await captureScreenshot(page, testInfo, "user-profile-settings");
|
||||
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
|
||||
page,
|
||||
@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
||||
null,
|
||||
);
|
||||
expect(afterDelete.facts).toEqual(
|
||||
expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]),
|
||||
expect.not.arrayContaining([
|
||||
expect.objectContaining({ value: "Wants help with day-to-day work first" }),
|
||||
]),
|
||||
);
|
||||
expect(afterDelete.facts).toContainEqual(
|
||||
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<!-- Fonts loaded locally via @fontsource-variable (Inter + Geist) — no external CDN -->
|
||||
<link rel="stylesheet" href="/evoo-brand.css" />
|
||||
<title>evOo</title>
|
||||
@ -26,7 +27,7 @@
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
|
||||
if (meta) meta.setAttribute("content", theme === "light" ? "#ffffff" : "#07080a");
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "EvOo",
|
||||
"short_name": "EvOo",
|
||||
"name": "evOo",
|
||||
"short_name": "evOo",
|
||||
"description": "Deine Oos — immer da, immer wach.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
|
||||
61
apps/web/public/sw.js
Normal file
61
apps/web/public/sw.js
Normal file
@ -0,0 +1,61 @@
|
||||
// Shows the server's web push notifications and opens the conversation they belong to.
|
||||
// No fetch handler: the app always loads from the network.
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
function appPath(value) {
|
||||
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
|
||||
? value
|
||||
: "/app";
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
let data = {};
|
||||
try {
|
||||
data = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
// The app in front of the user already shows what happened.
|
||||
if (windows.some((client) => client.focused && client.visibilityState === "visible")) return;
|
||||
await self.registration.showNotification(
|
||||
typeof data.title === "string" && data.title ? data.title : "evOo",
|
||||
{
|
||||
body: typeof data.body === "string" ? data.body : "",
|
||||
// Same tag as the in-app notification, so one replaces the other.
|
||||
tag: typeof data.tag === "string" && data.tag ? data.tag : undefined,
|
||||
icon: "/icon-192.png",
|
||||
data: { url: appPath(data.url) },
|
||||
},
|
||||
);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const target = new URL(appPath(event.notification.data?.url), self.location.origin).href;
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
const open =
|
||||
windows.find((client) => client.url === target) ??
|
||||
windows.find((client) => new URL(client.url).origin === self.location.origin);
|
||||
if (!open) {
|
||||
await self.clients.openWindow(target);
|
||||
return;
|
||||
}
|
||||
const focused = await open.focus();
|
||||
if (focused.url !== target) await focused.navigate(target).catch(() => undefined);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@ -79,6 +79,22 @@ const unread: InboxItem = {
|
||||
text: "Draft ready for review",
|
||||
createdAt,
|
||||
};
|
||||
const side = { id: "space-side", name: "Side project" };
|
||||
const askInOtherSpace: InboxItem = {
|
||||
kind: "ask",
|
||||
bot: writer,
|
||||
target: { botId: writer.id },
|
||||
space: side,
|
||||
runId: "run-side",
|
||||
messageId: "msg-side",
|
||||
ask: { kind: "ask", text: "Which title fits?", status: "pending" },
|
||||
createdAt,
|
||||
};
|
||||
const groupAskInOtherSpace: InboxItem = {
|
||||
...approvalInGroup,
|
||||
space: side,
|
||||
messageId: "msg-side-group",
|
||||
};
|
||||
|
||||
const idleBots = [
|
||||
{ id: scout.id, status: "idle", unread: false, preview: "" },
|
||||
@ -89,10 +105,18 @@ let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const onOpenBot = vi.fn();
|
||||
const onOpenGroup = vi.fn();
|
||||
const onOpenInSpace = vi.fn();
|
||||
|
||||
function render(bots = idleBots) {
|
||||
return act(async () =>
|
||||
root.render(<MainInbox bots={bots} onOpenBot={onOpenBot} onOpenGroup={onOpenGroup} />),
|
||||
root.render(
|
||||
<MainInbox
|
||||
bots={bots}
|
||||
onOpenBot={onOpenBot}
|
||||
onOpenGroup={onOpenGroup}
|
||||
onOpenInSpace={onOpenInSpace}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -111,6 +135,7 @@ beforeEach(() => {
|
||||
api.markRead.mockReset().mockResolvedValue({ ok: true });
|
||||
onOpenBot.mockReset();
|
||||
onOpenGroup.mockReset();
|
||||
onOpenInSpace.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
@ -154,12 +179,10 @@ it("answers through the thread the ask came from and drops only that item", asyn
|
||||
itemOf("approval").querySelector<HTMLButtonElement>("[data-answer]")?.click(),
|
||||
);
|
||||
|
||||
expect(api.answer).toHaveBeenCalledWith({
|
||||
groupId: "group-ops",
|
||||
runId: "run-approval",
|
||||
messageId: "msg-approval",
|
||||
answer: "Paris",
|
||||
});
|
||||
expect(api.answer).toHaveBeenCalledWith(
|
||||
{ groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris" },
|
||||
{ context: {} },
|
||||
);
|
||||
expect(api.markRead).not.toHaveBeenCalled();
|
||||
expect(api.list).toHaveBeenCalledTimes(2);
|
||||
expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1);
|
||||
@ -173,7 +196,7 @@ it("marks the bot's own thread read before answering, so the question does not r
|
||||
|
||||
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
|
||||
|
||||
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id });
|
||||
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }, { context: {} });
|
||||
expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
api.answer.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
@ -187,12 +210,10 @@ it("refreshes after a failed answer so a stale ask disappears", async () => {
|
||||
|
||||
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
|
||||
|
||||
expect(api.answer).toHaveBeenCalledWith({
|
||||
botId: scout.id,
|
||||
runId: "run-ask",
|
||||
messageId: "msg-ask",
|
||||
answer: "Paris",
|
||||
});
|
||||
expect(api.answer).toHaveBeenCalledWith(
|
||||
{ botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris" },
|
||||
{ context: {} },
|
||||
);
|
||||
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
|
||||
});
|
||||
|
||||
@ -208,6 +229,36 @@ it("opens the bot's thread for takeovers and unread threads, and the source thre
|
||||
expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]);
|
||||
});
|
||||
|
||||
it("names another space's items and answers and opens them in that space", async () => {
|
||||
api.list.mockResolvedValueOnce({ items: [askInOtherSpace, askInOwnThread] });
|
||||
api.list.mockResolvedValue({ items: [askInOwnThread, groupAskInOtherSpace] });
|
||||
api.answer.mockResolvedValue({ ok: true });
|
||||
await render();
|
||||
|
||||
const items = container.querySelectorAll<HTMLElement>("[data-testid='main-inbox-item']");
|
||||
expect(items[0]?.querySelector("[data-testid='main-inbox-space']")?.textContent).toBe(
|
||||
"· Side project",
|
||||
);
|
||||
expect(items[1]?.querySelector("[data-testid='main-inbox-space']")).toBeNull();
|
||||
|
||||
await act(async () => items[0]?.querySelector<HTMLButtonElement>("[data-answer]")?.click());
|
||||
expect(api.markRead).toHaveBeenCalledWith(
|
||||
{ botId: writer.id },
|
||||
{ context: { spaceId: side.id } },
|
||||
);
|
||||
expect(api.answer).toHaveBeenCalledWith(
|
||||
{ botId: writer.id, runId: "run-side", messageId: "msg-side", answer: "Paris" },
|
||||
{ context: { spaceId: side.id } },
|
||||
);
|
||||
|
||||
const [own, group] = container.querySelectorAll<HTMLElement>("[data-testid='main-inbox-item']");
|
||||
await act(async () => group?.querySelector("button")?.click());
|
||||
await act(async () => own?.querySelector("button")?.click());
|
||||
expect(onOpenInSpace.mock.calls).toEqual([[side.id, "/app/g/group-ops"]]);
|
||||
expect(onOpenBot.mock.calls).toEqual([[scout.id]]);
|
||||
expect(onOpenGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes when a bot's state changes, not on every render", async () => {
|
||||
api.list.mockResolvedValue({ items: [] });
|
||||
await render();
|
||||
|
||||
@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000;
|
||||
|
||||
type PendingAskItem = Extract<InboxItem, { kind: "ask" | "approval" }>;
|
||||
|
||||
/** Thread procedures for an item run in the item's own space. */
|
||||
function itemContext(item: InboxItem) {
|
||||
return { context: item.space ? { spaceId: item.space.id } : {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user's bots need from them outside the main conversation, with each bot as the
|
||||
* sender: asks and approvals are answered in place, takeovers and unread threads open the
|
||||
* bot's thread.
|
||||
* What the user's bots in all of their spaces need from them outside the main conversation, with
|
||||
* each bot as the sender: asks and approvals are answered in place, takeovers and unread threads
|
||||
* open the bot's thread.
|
||||
*/
|
||||
export function MainInbox({
|
||||
bots,
|
||||
onOpenBot,
|
||||
onOpenGroup,
|
||||
onOpenInSpace,
|
||||
}: {
|
||||
/** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */
|
||||
bots: readonly Pick<Bot, "id" | "status" | "unread" | "preview">[];
|
||||
onOpenBot: (botId: string) => void;
|
||||
onOpenGroup: (groupId: string) => void;
|
||||
/** Opens an app path, such as a thread, in another of the user's spaces. */
|
||||
onOpenInSpace: (spaceId: string, path: string) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const [items, setItems] = useState<InboxItem[]>([]);
|
||||
@ -59,11 +67,16 @@ export function MainInbox({
|
||||
}, [refresh]);
|
||||
|
||||
const open = useCallback(
|
||||
(target: InboxThreadTarget) => {
|
||||
if ("botId" in target) onOpenBot(target.botId);
|
||||
({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => {
|
||||
if (space) {
|
||||
onOpenInSpace(
|
||||
space.id,
|
||||
"botId" in target ? `/app/${target.botId}` : `/app/g/${target.groupId}`,
|
||||
);
|
||||
} else if ("botId" in target) onOpenBot(target.botId);
|
||||
else onOpenGroup(target.groupId);
|
||||
},
|
||||
[onOpenBot, onOpenGroup],
|
||||
[onOpenBot, onOpenGroup, onOpenInSpace],
|
||||
);
|
||||
|
||||
const answer = useCallback(
|
||||
@ -72,14 +85,12 @@ export function MainInbox({
|
||||
// Answering reads the ask, so the bot's thread should not come back as unread with the
|
||||
// same question. Mark it first: the reply to this answer marks it unread again.
|
||||
if ("botId" in item.target) {
|
||||
await rpc.threads.markRead(item.target).catch(() => undefined);
|
||||
await rpc.threads.markRead(item.target, itemContext(item)).catch(() => undefined);
|
||||
}
|
||||
await rpc.threads.answer({
|
||||
...item.target,
|
||||
runId: item.runId,
|
||||
messageId: item.messageId,
|
||||
answer: text,
|
||||
});
|
||||
await rpc.threads.answer(
|
||||
{ ...item.target, runId: item.runId, messageId: item.messageId, answer: text },
|
||||
itemContext(item),
|
||||
);
|
||||
const key = inboxItemKey(item);
|
||||
setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key));
|
||||
} finally {
|
||||
@ -105,10 +116,10 @@ export function MainInbox({
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(item.target)}
|
||||
onClick={() => open(item)}
|
||||
className="w-fit rounded-md hover:underline"
|
||||
>
|
||||
<Sender bot={item.bot} />
|
||||
<Sender bot={item.bot} space={item.space} />
|
||||
</button>
|
||||
<AskCard
|
||||
block={item.ask}
|
||||
@ -120,11 +131,11 @@ export function MainInbox({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(item.target)}
|
||||
onClick={() => open(item)}
|
||||
className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<Sender bot={item.bot} />
|
||||
<Sender bot={item.bot} space={item.space} />
|
||||
{item.text ? (
|
||||
<span
|
||||
className="mt-0.5 line-clamp-2 text-ui-body-sm text-muted-foreground"
|
||||
@ -154,16 +165,27 @@ export function MainInbox({
|
||||
);
|
||||
}
|
||||
|
||||
function Sender({ bot }: { bot: InboxItem["bot"] }) {
|
||||
/** The bot, and the space it works in when that is not the current one. */
|
||||
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
|
||||
const color = resolvePersonaColorDef(bot.id, parseBotAvatar(bot.color).color).light;
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
|
||||
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
|
||||
style={{ color }}
|
||||
dir="auto"
|
||||
>
|
||||
<BotAvatar color={bot.color} identity={bot.id} size={22} className="shrink-0" />
|
||||
<span className="truncate">{bot.name}</span>
|
||||
{space ? (
|
||||
<span
|
||||
data-testid="main-inbox-space"
|
||||
className="truncate font-normal text-muted-foreground"
|
||||
dir="auto"
|
||||
>
|
||||
<span aria-hidden="true">· </span>
|
||||
{space.name}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
197
apps/web/src/components/NotificationSettingsPanel.tsx
Normal file
197
apps/web/src/components/NotificationSettingsPanel.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { NotificationSettings, NotificationSettingsPatch } from "@rakazo/contracts";
|
||||
import { Input, Switch } from "@rakazo/ui-web";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { disableWebPush, enableWebPush, webPushEnabled, webPushSupported } from "../lib/web-push";
|
||||
|
||||
const DEFAULT_QUIET_HOURS = { start: "22:00", end: "07:00" };
|
||||
const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
type PushState = "loading" | "on" | "off" | "blocked";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
testId,
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
testId: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<li className="py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span id={labelId} className="text-ui-body text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<Switch
|
||||
data-testid={testId}
|
||||
aria-labelledby={labelId}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Push on this browser, quiet hours and nudges. Quiet hours and nudges apply to every device. */
|
||||
export function NotificationSettingsPanel() {
|
||||
const { t } = useLingui();
|
||||
const pushSupported = webPushSupported();
|
||||
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
||||
const [push, setPush] = useState<PushState>("loading");
|
||||
const [pushBusy, setPushBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [quietDraft, setQuietDraft] = useState(DEFAULT_QUIET_HOURS);
|
||||
const saves = useRef(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
rpc.notifications.settings
|
||||
.get()
|
||||
.then((next) => {
|
||||
if (cancelled) return;
|
||||
setSettings(next);
|
||||
if (next.quietHours) setQuietDraft(next.quietHours);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(t`Couldn't load`);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pushSupported) return;
|
||||
let cancelled = false;
|
||||
void webPushEnabled().then((enabled) => {
|
||||
if (cancelled) return;
|
||||
setPush(enabled ? "on" : Notification.permission === "denied" ? "blocked" : "off");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pushSupported]);
|
||||
|
||||
/** Saves in order, so the last change the user made is the one that sticks. */
|
||||
function save(patch: NotificationSettingsPatch) {
|
||||
setError(null);
|
||||
setSettings((current) => (current ? { ...current, ...patch } : current));
|
||||
saves.current = saves.current.then(async () => {
|
||||
try {
|
||||
setSettings(await rpc.notifications.settings.update(patch));
|
||||
} catch {
|
||||
setError(t`Couldn't save`);
|
||||
setSettings(await rpc.notifications.settings.get().catch(() => null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function changePush(next: boolean) {
|
||||
setPushBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!next) {
|
||||
await disableWebPush();
|
||||
setPush("off");
|
||||
return;
|
||||
}
|
||||
const result = await enableWebPush();
|
||||
if (result === "enabled") setPush("on");
|
||||
else if (result === "denied") setPush("blocked");
|
||||
else setError(t`Couldn't save`);
|
||||
} catch {
|
||||
setError(t`Couldn't save`);
|
||||
} finally {
|
||||
setPushBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function changeQuietTime(field: "start" | "end", value: string) {
|
||||
const next = { ...quietDraft, [field]: value };
|
||||
setQuietDraft(next);
|
||||
if (CLOCK_TIME.test(next.start) && CLOCK_TIME.test(next.end) && next.start !== next.end) {
|
||||
save({ quietHours: next });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="notification-settings" className="space-y-5">
|
||||
{error ? (
|
||||
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{settings ? (
|
||||
<section className="rounded-xl border border-border px-4 py-4">
|
||||
<ul className="divide-y divide-border">
|
||||
{pushSupported ? (
|
||||
<SettingRow
|
||||
label={<Trans>Push on this device</Trans>}
|
||||
testId="notification-push-switch"
|
||||
checked={push === "on"}
|
||||
disabled={push === "loading" || push === "blocked" || pushBusy}
|
||||
onCheckedChange={(next) => void changePush(next)}
|
||||
>
|
||||
{push === "blocked" ? (
|
||||
<p className="mt-1 text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Blocked in your browser settings</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
</SettingRow>
|
||||
) : null}
|
||||
<SettingRow
|
||||
label={<Trans>Quiet hours</Trans>}
|
||||
testId="notification-quiet-hours-switch"
|
||||
checked={settings.quietHours !== null}
|
||||
onCheckedChange={(next) => save({ quietHours: next ? quietDraft : null })}
|
||||
>
|
||||
{settings.quietHours ? (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Input
|
||||
type="time"
|
||||
data-testid="notification-quiet-hours-start"
|
||||
aria-label={t`Quiet hours start`}
|
||||
value={quietDraft.start}
|
||||
onChange={(event) => changeQuietTime("start", event.target.value)}
|
||||
className="w-36"
|
||||
/>
|
||||
<span aria-hidden="true" className="text-muted-foreground">
|
||||
–
|
||||
</span>
|
||||
<Input
|
||||
type="time"
|
||||
data-testid="notification-quiet-hours-end"
|
||||
aria-label={t`Quiet hours end`}
|
||||
value={quietDraft.end}
|
||||
onChange={(event) => changeQuietTime("end", event.target.value)}
|
||||
className="w-36"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={<Trans>Nudges from your Oo</Trans>}
|
||||
testId="notification-nudges-switch"
|
||||
checked={settings.nudges}
|
||||
onCheckedChange={(next) => save({ nudges: next })}
|
||||
/>
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/components/UserFactEditor.tsx
Normal file
129
apps/web/src/components/UserFactEditor.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { UserFact, UserFactCategory } from "@rakazo/contracts";
|
||||
import { USER_FACT_CATEGORIES, USER_FACT_VALUE_MAX_LENGTH } from "@rakazo/contracts";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
} from "@rakazo/ui-web";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { UserFactDraft } from "../lib/user-profile-view";
|
||||
import { factSpaceNames, toggleFactSpace, userFactDraft } from "../lib/user-profile-view";
|
||||
|
||||
/** Edits one fact in place: its wording, category and, with more than one space, where it shows. */
|
||||
export function UserFactEditor({
|
||||
fact,
|
||||
spaces,
|
||||
categoryLabels,
|
||||
busy,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
fact: UserFact;
|
||||
/** The user's spaces; the visibility control appears with more than one. */
|
||||
spaces: ReadonlyArray<{ id: string; name: string }>;
|
||||
categoryLabels: Record<UserFactCategory, string>;
|
||||
busy: boolean;
|
||||
onSave: (draft: UserFactDraft) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const [draft, setDraft] = useState(() => userFactDraft(fact));
|
||||
const allSpaces = t`All spaces`;
|
||||
const showSpaces = spaces.length > 1 || draft.spaceIds !== null;
|
||||
|
||||
return (
|
||||
<li data-testid="user-fact-editor">
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (draft.value.trim()) onSave(draft);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// The spaces menu renders in a portal; its Escape only closes the menu.
|
||||
if (event.key === "Escape" && event.currentTarget.contains(event.target as Node)) {
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label={t`Fact`}
|
||||
value={draft.value}
|
||||
maxLength={USER_FACT_VALUE_MAX_LENGTH}
|
||||
onChange={(event) => setDraft({ ...draft, value: event.target.value })}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
className="*:data-[slot=native-select]:h-control-md"
|
||||
aria-label={t`Category`}
|
||||
value={draft.category}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, category: event.target.value as UserFactCategory })
|
||||
}
|
||||
>
|
||||
{USER_FACT_CATEGORIES.map((category) => (
|
||||
<NativeSelectOption key={category} value={category}>
|
||||
{categoryLabels[category]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{showSpaces ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button type="button" variant="outline" className="max-w-56" />}
|
||||
>
|
||||
<span className="truncate">
|
||||
{factSpaceNames(draft.spaceIds, spaces).join(", ") || allSpaces}
|
||||
</span>
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto min-w-44">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={draft.spaceIds === null}
|
||||
onCheckedChange={() => setDraft({ ...draft, spaceIds: null })}
|
||||
>
|
||||
{allSpaces}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
{spaces.map((space) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={space.id}
|
||||
checked={draft.spaceIds?.includes(space.id) ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
spaceIds: toggleFactSpace(draft.spaceIds, space.id, checked),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="truncate" dir="auto">
|
||||
{space.name}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<div className="ms-auto flex items-center gap-1">
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy || !draft.value.trim()}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@ -10,7 +10,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
} from "@rakazo/ui-web";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { PencilIcon, XIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { getActiveUiLocale } from "../lib/i18n";
|
||||
@ -20,9 +20,12 @@ import {
|
||||
CLEARED_PROFILE_FIELD,
|
||||
displayLanguageTag,
|
||||
displayRegionTag,
|
||||
factSpaceNames,
|
||||
filledProfileFields,
|
||||
groupUserFacts,
|
||||
userFactEdit,
|
||||
} from "../lib/user-profile-view";
|
||||
import { UserFactEditor } from "./UserFactEditor";
|
||||
|
||||
function Row({
|
||||
value,
|
||||
@ -51,14 +54,30 @@ function Row({
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything the user's bots know about the user, with consent and forget controls. */
|
||||
/** Everything the user's bots know about the user, with edit, consent and forget controls. */
|
||||
export function UserProfileSettings() {
|
||||
const { t } = useLingui();
|
||||
const [state, setState] = useState<UserProfileState | null>(null);
|
||||
const [spaces, setSpaces] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [forgetOpen, setForgetOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Only names a fact's spaces; without them the profile still works.
|
||||
rpc.spaces
|
||||
.list()
|
||||
.then((navigation) => {
|
||||
if (!cancelled) setSpaces(navigation.spaces.map(({ id, name }) => ({ id, name })));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
rpc.profile
|
||||
@ -74,14 +93,16 @@ export function UserProfileSettings() {
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
async function apply(change: () => Promise<UserProfileState>) {
|
||||
if (busy) return;
|
||||
async function apply(change: () => Promise<UserProfileState>): Promise<boolean> {
|
||||
if (busy) return false;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setState(await change());
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Couldn't save`);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -178,36 +199,75 @@ export function UserProfileSettings() {
|
||||
{categoryLabels[group.category]}
|
||||
</h3>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{group.facts.map((fact) => (
|
||||
<Row
|
||||
key={fact.id}
|
||||
testId="user-fact"
|
||||
status={fact.status}
|
||||
value={fact.value}
|
||||
detail={sourceLabels[fact.source]}
|
||||
actions={(describedBy) => (
|
||||
<>
|
||||
{fact.status === "pending" ? (
|
||||
{group.facts.map((fact) =>
|
||||
fact.id === editingId ? (
|
||||
<UserFactEditor
|
||||
key={fact.id}
|
||||
fact={fact}
|
||||
spaces={spaces}
|
||||
categoryLabels={categoryLabels}
|
||||
busy={busy}
|
||||
onCancel={() => setEditingId(null)}
|
||||
onSave={(draft) => {
|
||||
const edit = userFactEdit(fact, draft);
|
||||
if (!edit) {
|
||||
setEditingId(null);
|
||||
return;
|
||||
}
|
||||
void apply(() => rpc.profile.update({ editFacts: [edit] })).then((saved) => {
|
||||
if (saved) setEditingId(null);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
key={fact.id}
|
||||
testId="user-fact"
|
||||
status={fact.status}
|
||||
value={fact.value}
|
||||
detail={[
|
||||
sourceLabels[fact.source],
|
||||
factSpaceNames(fact.allSpaces ? null : fact.spaceIds, spaces).join(", "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
actions={(describedBy) => (
|
||||
<>
|
||||
{fact.status === "pending" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-describedby={describedBy}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
|
||||
}
|
||||
>
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t`Edit`}
|
||||
aria-describedby={describedBy}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
|
||||
}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setEditingId(fact.id);
|
||||
}}
|
||||
>
|
||||
<Trans>Confirm</Trans>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
{deleteButton(
|
||||
describedBy,
|
||||
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{deleteButton(
|
||||
describedBy,
|
||||
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
214
apps/web/src/components/heute/CoachRhythmControl.tsx
Normal file
214
apps/web/src/components/heute/CoachRhythmControl.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { CoachRhythm } from "@rakazo/contracts";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Spinner,
|
||||
Switch,
|
||||
} from "@rakazo/ui-web";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useId, useRef, useState } from "react";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
|
||||
/** Times a check-in starts at when it is switched on. */
|
||||
const DEFAULTS = { morning: "08:00", evening: "20:00", weekly: { day: 0, time: "18:00" } };
|
||||
const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
function weekdayName(day: number): string {
|
||||
// 2026-10-04 was a Sunday, so day 0 maps to Sunday.
|
||||
return new Date(Date.UTC(2026, 9, 4 + day)).toLocaleDateString(i18n.locale || "en", {
|
||||
weekday: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
/** When the main bot checks in: loaded when opened, saved on every change. */
|
||||
export function CoachRhythmControl() {
|
||||
const { t } = useLingui();
|
||||
const [rhythm, setRhythm] = useState<CoachRhythm | null>(null);
|
||||
const latestRequest = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const request = ++latestRequest.current;
|
||||
try {
|
||||
const loaded = await rpc.coach.rhythm.get();
|
||||
if (request === latestRequest.current) setRhythm(loaded);
|
||||
} catch {
|
||||
// Opening the control again retries.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const save = useCallback(
|
||||
async (next: CoachRhythm) => {
|
||||
setRhythm(next);
|
||||
const request = ++latestRequest.current;
|
||||
try {
|
||||
const saved = await rpc.coach.rhythm.set(next);
|
||||
if (request === latestRequest.current) setRhythm(saved);
|
||||
} catch {
|
||||
if (request === latestRequest.current) void load();
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (open) void load();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
data-testid="heute-rhythm"
|
||||
aria-label={t`Check-ins`}
|
||||
title={t`Check-ins`}
|
||||
render={<Button variant="ghost" size="icon" />}
|
||||
>
|
||||
<CalendarClock strokeWidth={1.7} aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
data-testid="heute-rhythm-panel"
|
||||
className="w-[min(340px,calc(100vw-2rem))] gap-3 p-3"
|
||||
>
|
||||
{rhythm ? (
|
||||
<>
|
||||
<RhythmRow
|
||||
label={t`Morning`}
|
||||
testId="heute-rhythm-morning"
|
||||
on={rhythm.morning !== null}
|
||||
onToggle={(on) => void save({ ...rhythm, morning: on ? DEFAULTS.morning : null })}
|
||||
>
|
||||
{(labelId) =>
|
||||
rhythm.morning ? (
|
||||
<TimeInput
|
||||
labelId={labelId}
|
||||
value={rhythm.morning}
|
||||
onChange={(morning) => void save({ ...rhythm, morning })}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</RhythmRow>
|
||||
<RhythmRow
|
||||
label={t`Evening`}
|
||||
testId="heute-rhythm-evening"
|
||||
on={rhythm.evening !== null}
|
||||
onToggle={(on) => void save({ ...rhythm, evening: on ? DEFAULTS.evening : null })}
|
||||
>
|
||||
{(labelId) =>
|
||||
rhythm.evening ? (
|
||||
<TimeInput
|
||||
labelId={labelId}
|
||||
value={rhythm.evening}
|
||||
onChange={(evening) => void save({ ...rhythm, evening })}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</RhythmRow>
|
||||
<RhythmRow
|
||||
label={t`Weekly`}
|
||||
testId="heute-rhythm-weekly"
|
||||
on={rhythm.weekly !== null}
|
||||
onToggle={(on) => void save({ ...rhythm, weekly: on ? DEFAULTS.weekly : null })}
|
||||
>
|
||||
{(labelId) => {
|
||||
const weekly = rhythm.weekly;
|
||||
if (!weekly) return null;
|
||||
return (
|
||||
<>
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
aria-labelledby={labelId}
|
||||
value={String(weekly.day)}
|
||||
onChange={(event) =>
|
||||
void save({
|
||||
...rhythm,
|
||||
weekly: { ...weekly, day: Number(event.target.value) },
|
||||
})
|
||||
}
|
||||
>
|
||||
{WEEKDAYS.map((day) => (
|
||||
<NativeSelectOption key={day} value={day}>
|
||||
{weekdayName(day)}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<TimeInput
|
||||
labelId={labelId}
|
||||
value={weekly.time}
|
||||
onChange={(time) => void save({ ...rhythm, weekly: { ...weekly, time } })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</RhythmRow>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-center py-3">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function RhythmRow({
|
||||
label,
|
||||
testId,
|
||||
on,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
testId: string;
|
||||
on: boolean;
|
||||
onToggle: (on: boolean) => void;
|
||||
children: (labelId: string) => ReactNode;
|
||||
}) {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<div data-testid={testId} className="flex min-h-control-sm flex-wrap items-center gap-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={on}
|
||||
aria-labelledby={labelId}
|
||||
onCheckedChange={(checked) => onToggle(checked)}
|
||||
/>
|
||||
<span id={labelId} className="flex-1 text-ui-body text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">{children(labelId)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeInput({
|
||||
labelId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
labelId: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
type="time"
|
||||
aria-labelledby={labelId}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onChange(event.target.value.slice(0, 5));
|
||||
}}
|
||||
className="h-control-sm w-32 px-2 tabular-nums"
|
||||
/>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/components/heute/HeuteCoach.tsx
Normal file
105
apps/web/src/components/heute/HeuteCoach.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { CoachToday, HabitToday } from "@rakazo/contracts";
|
||||
import { Checkbox, cn } from "@rakazo/ui-web";
|
||||
import { Flame } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { formatLocalDate } from "../../lib/heute-brief";
|
||||
|
||||
/** Shown once a habit has been kept at least twice in a row. */
|
||||
const STREAK_MIN = 2;
|
||||
|
||||
/** Today's brief, habits to check off and active goals; each part only when it has content. */
|
||||
export function HeuteCoach({
|
||||
today,
|
||||
onHabitDone,
|
||||
}: {
|
||||
today: CoachToday;
|
||||
onHabitDone: (habit: HabitToday, done: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{today.brief ? (
|
||||
<section data-testid="heute-coach-brief" className="rounded-2xl bg-muted/60 px-4 py-3.5">
|
||||
<p className="whitespace-pre-line text-ui-body-lg text-foreground">{today.brief.text}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{today.habits.length > 0 ? (
|
||||
<section data-testid="heute-habits">
|
||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||
<Trans>Habits</Trans>
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{today.habits.map((habit) => (
|
||||
<li key={habit.id}>
|
||||
<HabitRow habit={habit} onDone={(done) => onHabitDone(habit, done)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{today.goals.length > 0 ? (
|
||||
<section data-testid="heute-goals">
|
||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||
<Trans>Goals</Trans>
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{today.goals.map((goal) => (
|
||||
<li
|
||||
key={goal.id}
|
||||
data-testid={`heute-goal-${goal.id}`}
|
||||
className="flex items-baseline justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 truncate text-ui-body text-foreground">{goal.title}</span>
|
||||
{goal.targetDate ? (
|
||||
<span className="shrink-0 text-ui-label text-muted-foreground tabular-nums">
|
||||
{formatLocalDate(goal.targetDate, i18n.locale || "en", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HabitRow({ habit, onDone }: { habit: HabitToday; onDone: (done: boolean) => void }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
data-testid={`heute-habit-${habit.id}`}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-2xl px-3 py-2.5 transition-colors hover:bg-accent"
|
||||
>
|
||||
<Checkbox id={id} checked={habit.doneToday} onCheckedChange={(checked) => onDone(checked)} />
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-ui-body",
|
||||
habit.doneToday ? "text-muted-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{habit.title}
|
||||
</span>
|
||||
{habit.streak >= STREAK_MIN ? (
|
||||
<span
|
||||
data-testid={`heute-habit-streak-${habit.id}`}
|
||||
className="flex shrink-0 items-center gap-1 text-ui-label text-muted-foreground tabular-nums"
|
||||
>
|
||||
<Flame strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
|
||||
{habit.cadence === "weekly" ? (
|
||||
<Plural value={habit.streak} one="# week" other="# weeks" />
|
||||
) : (
|
||||
<Plural value={habit.streak} one="# day" other="# days" />
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@ -1,186 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
|
||||
interface ScanStatus {
|
||||
status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed";
|
||||
progress: number;
|
||||
message: string;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
const PHASES = [
|
||||
{
|
||||
key: "scanning" as const,
|
||||
label: "Scanning your computer...",
|
||||
sublabel: "Files, folders, git repositories",
|
||||
},
|
||||
{
|
||||
key: "connecting" as const,
|
||||
label: "Connecting to your services...",
|
||||
sublabel: "GitHub, email, calendar",
|
||||
},
|
||||
{
|
||||
key: "building" as const,
|
||||
label: "Building your profile...",
|
||||
sublabel: "Interests, skills, preferences",
|
||||
},
|
||||
{
|
||||
key: "briefing" as const,
|
||||
label: "Setting up your agent team...",
|
||||
sublabel: "Project folders, role assignments",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function OnboardingScanProgress({
|
||||
onComplete,
|
||||
botId,
|
||||
}: {
|
||||
onComplete: () => void;
|
||||
botId?: string;
|
||||
}) {
|
||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, setScanId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const scanResult = await rpc.onboarding.scan(botId ? { botId } : {});
|
||||
if (cancelled) return;
|
||||
setScanId(scanResult.scanId);
|
||||
|
||||
// Poll for status
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await rpc.onboarding.scanStatus({ scanId: scanResult.scanId });
|
||||
if (cancelled) return;
|
||||
setStatus(s);
|
||||
if (s.status === "complete") {
|
||||
onComplete();
|
||||
} else if (s.status === "failed") {
|
||||
setError(s.message);
|
||||
}
|
||||
} catch {
|
||||
// poll error — keep trying
|
||||
}
|
||||
};
|
||||
|
||||
// First poll immediately, then every 2s
|
||||
await poll();
|
||||
pollingInterval = setInterval(poll, 2000);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Scan failed");
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
};
|
||||
}, [botId, onComplete]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button type="button" className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
|
||||
Continue anyway
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<div className="mt-6 animate-spin rounded-full border-4 border-border border-t-primary h-8 w-8" />
|
||||
<p className="mt-4 text-muted-foreground">Preparing your profile...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status);
|
||||
const isComplete = status.status === "complete";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground text-center">
|
||||
{isComplete ? "All set!" : "Getting to know you..."}
|
||||
</h2>
|
||||
<p className="mt-1 text-center text-sm text-muted-foreground">
|
||||
{isComplete
|
||||
? "Your profile and agent team are ready."
|
||||
: "We're scanning your computer and services to personalize your experience."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{PHASES.map((phase, idx) => {
|
||||
const isActive = idx === currentPhaseIndex && !isComplete;
|
||||
const isDone = idx < currentPhaseIndex || isComplete;
|
||||
return (
|
||||
<div
|
||||
key={phase.key}
|
||||
className={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all ${
|
||||
isActive
|
||||
? "border-primary bg-primary/5"
|
||||
: isDone
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{isDone ? (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">
|
||||
✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">
|
||||
●
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">
|
||||
○
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{phase.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">{phase.sublabel}</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted/30">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${status.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isComplete && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white"
|
||||
onClick={onComplete}
|
||||
>
|
||||
Continue to your workspace →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
apps/web/src/components/onboarding/OnboardingScanStep.tsx
Normal file
250
apps/web/src/components/onboarding/OnboardingScanStep.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { OnboardingScanFact, OnboardingScanProject } from "@rakazo/contracts";
|
||||
import { Button, Checkbox } from "@rakazo/ui-web";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { LoadingState } from "../ai/primitives";
|
||||
|
||||
const POLL_MS = 1_000;
|
||||
|
||||
interface Found {
|
||||
projects: OnboardingScanProject[];
|
||||
facts: OnboardingScanFact[];
|
||||
}
|
||||
|
||||
function toggled(ids: string[], id: string, on: boolean): string[] {
|
||||
return on ? [...ids, id] : ids.filter((entry) => entry !== id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks before looking at the server's scan folder, then lets the user choose which found projects
|
||||
* become project spaces and which languages and tools go to About you. Nothing is created without
|
||||
* a choice. Skips itself when this account cannot scan.
|
||||
*/
|
||||
export function OnboardingScanStep({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useLingui();
|
||||
const ids = useId();
|
||||
const done = useRef(onDone);
|
||||
done.current = onDone;
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
const [looking, setLooking] = useState(false);
|
||||
const [scanId, setScanId] = useState<string | null>(null);
|
||||
const [found, setFound] = useState<Found | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [projectIds, setProjectIds] = useState<string[]>([]);
|
||||
const [factIds, setFactIds] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
rpc.onboarding
|
||||
.scanTarget()
|
||||
.then((target) => {
|
||||
if (cancelled) return;
|
||||
if (target.folder) setFolder(target.folder);
|
||||
else done.current();
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) done.current();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scanId) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const poll = () => {
|
||||
rpc.onboarding
|
||||
.scanStatus({ scanId })
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
if (status.status === "scanning") {
|
||||
timer = setTimeout(poll, POLL_MS);
|
||||
} else if (status.status === "failed") {
|
||||
setFailed(true);
|
||||
} else {
|
||||
setFound({ projects: status.projects, facts: status.facts });
|
||||
setFactIds(status.facts.map((fact) => fact.id));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [scanId]);
|
||||
|
||||
async function look() {
|
||||
setLooking(true);
|
||||
try {
|
||||
setScanId((await rpc.onboarding.scan()).scanId);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!scanId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await rpc.onboarding.applyScan({ scanId, projectIds, factIds });
|
||||
done.current();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const continueButton = (
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button onClick={() => done.current()}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<p className="text-sm text-destructive">
|
||||
<Trans>Could not look in this folder.</Trans>
|
||||
</p>
|
||||
{continueButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (found && found.projects.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>No projects found</Trans>
|
||||
</h2>
|
||||
{continueButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (found) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<fieldset className="border-0 p-0">
|
||||
<legend>
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>Add as projects</Trans>
|
||||
</h2>
|
||||
</legend>
|
||||
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||
{found.projects.map((project) => (
|
||||
<label
|
||||
key={project.id}
|
||||
htmlFor={`${ids}-${project.id}`}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-lg px-2 py-2 hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${ids}-${project.id}`}
|
||||
checked={projectIds.includes(project.id)}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) =>
|
||||
setProjectIds((current) => toggled(current, project.id, checked))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">{project.name}</span>
|
||||
{project.path !== project.name ? (
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{project.path}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{found.facts.length > 0 ? (
|
||||
<fieldset className="mt-6 border-0 p-0">
|
||||
<legend className="text-sm font-medium text-foreground">
|
||||
<Trans>Add to About you</Trans>
|
||||
</legend>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{found.facts.map((fact) => (
|
||||
<label
|
||||
key={fact.id}
|
||||
htmlFor={`${ids}-${fact.id}`}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${ids}-${fact.id}`}
|
||||
checked={factIds.includes(fact.id)}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) =>
|
||||
setFactIds((current) => toggled(current, fact.id, checked))
|
||||
}
|
||||
/>
|
||||
{fact.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="mt-4 text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" disabled={saving} onClick={() => done.current()}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving || (projectIds.length === 0 && factIds.length === 0)}
|
||||
onClick={() => void add()}
|
||||
>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (looking) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<LoadingState label={t`Looking`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>Look for projects here?</Trans>
|
||||
</h2>
|
||||
<p className="mt-3 break-all rounded-lg bg-muted px-3 py-2 font-mono text-sm text-foreground">
|
||||
{folder}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>Only file and folder names are read.</Trans>
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => done.current()}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => void look()}>
|
||||
<Trans>Look</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -55,22 +55,22 @@ export function StageHero({
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-xl`}
|
||||
style={
|
||||
reducedMotion
|
||||
? undefined
|
||||
: {
|
||||
boxShadow: `0 16px 48px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.12), 0 0 ${Math.round(18 + energy * 32)}px ${glowRgb}`,
|
||||
}
|
||||
}
|
||||
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_12px_32px_-12px_rgba(24,24,22,0.18)] backdrop-blur-xl dark:shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)]`}
|
||||
>
|
||||
{reducedMotion ? null : (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-full"
|
||||
style={{ boxShadow: `0 0 ${Math.round(18 + energy * 32)}px ${glowRgb}` }}
|
||||
/>
|
||||
)}
|
||||
<BotAvatar
|
||||
color={botColor}
|
||||
identity={botId}
|
||||
status={botStatus}
|
||||
pose={pose}
|
||||
size={avatarSize}
|
||||
className="drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
|
||||
className="drop-shadow-[0_6px_16px_rgba(0,0,0,0.12)] dark:drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ export function TextStageCanvas({
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${phaseTint} 0%, ${personaTint} 42%, transparent 72%)`,
|
||||
opacity: 0.55 + energy * 0.2,
|
||||
transform: `translateX(-50%) scale(${bloomScale})`,
|
||||
transform: `scale(${bloomScale})`,
|
||||
transition: reducedMotion
|
||||
? undefined
|
||||
: "transform 140ms ease-out, opacity 140ms ease-out",
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHeuteBrief, HEUTE_NEEDS_YOU_CAP, isToday } from "./heute-brief";
|
||||
import {
|
||||
buildHeuteBrief,
|
||||
coachRefreshSignal,
|
||||
formatLocalDate,
|
||||
HEUTE_NEEDS_YOU_CAP,
|
||||
isToday,
|
||||
withHabitDone,
|
||||
} from "./heute-brief";
|
||||
|
||||
const now = new Date("2026-09-22T12:00:00.000Z");
|
||||
|
||||
@ -142,3 +149,59 @@ describe("buildHeuteBrief", () => {
|
||||
expect(brief.actionCount).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
function habit(overrides: Partial<HabitToday> = {}): HabitToday {
|
||||
return {
|
||||
id: "habit-1",
|
||||
title: "Walk",
|
||||
cadence: "daily",
|
||||
goalId: null,
|
||||
active: true,
|
||||
source: "user",
|
||||
createdByBotId: null,
|
||||
createdAt: "2026-09-01T00:00:00.000Z",
|
||||
updatedAt: "2026-09-01T00:00:00.000Z",
|
||||
doneToday: false,
|
||||
streak: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("coach on the Heute page", () => {
|
||||
it("reloads the coach when a run finishes, not while runs are only working", () => {
|
||||
const working = [run({ runId: "run-1", status: "running" })];
|
||||
expect(coachRefreshSignal(working)).toBe("");
|
||||
expect(coachRefreshSignal([...working, run({ runId: "run-2", status: "completed" })])).toBe(
|
||||
"run-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks a habit off before the server confirms, moving the streak by one", () => {
|
||||
const today: CoachToday = {
|
||||
date: "2026-10-03",
|
||||
timeZone: "Europe/Vienna",
|
||||
brief: null,
|
||||
goals: [],
|
||||
habits: [habit(), habit({ id: "habit-2", doneToday: true, streak: 1 })],
|
||||
checkIn: null,
|
||||
};
|
||||
const checked = withHabitDone(today, "habit-1", true);
|
||||
expect(checked.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([
|
||||
{ doneToday: true, streak: 4 },
|
||||
{ doneToday: true, streak: 1 },
|
||||
]);
|
||||
expect(withHabitDone(checked, "habit-2", false).habits[1]).toMatchObject({
|
||||
doneToday: false,
|
||||
streak: 0,
|
||||
});
|
||||
// Repeating the same state changes nothing.
|
||||
expect(withHabitDone(checked, "habit-1", true)).toEqual(checked);
|
||||
});
|
||||
|
||||
it("labels the user's calendar date without shifting it into the browser's zone", () => {
|
||||
expect(formatLocalDate("2026-10-03", "en-US", { weekday: "long" })).toBe("Saturday");
|
||||
expect(formatLocalDate("2026-11-01", "en-US", { month: "short", day: "numeric" })).toBe(
|
||||
"Nov 1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
import type { Bot, CoachToday, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity";
|
||||
|
||||
export type JetztRunItem = {
|
||||
@ -137,3 +137,38 @@ export function buildHeuteBrief(params: {
|
||||
weiter: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finished runs are when the coach learns something (a check-in, today's brief), so a change in
|
||||
* them is the cue to reload the coach's view of the day.
|
||||
*/
|
||||
export function coachRefreshSignal(recentRuns: RunActivityRow[]): string {
|
||||
return recentRuns
|
||||
.filter((run) => run.status === "completed")
|
||||
.map((run) => run.runId)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* A habit checked or unchecked before the server confirms. The current period only counts
|
||||
* toward the streak once it is done, so the streak moves by one.
|
||||
*/
|
||||
export function withHabitDone(today: CoachToday, habitId: string, done: boolean): CoachToday {
|
||||
return {
|
||||
...today,
|
||||
habits: today.habits.map((habit) =>
|
||||
habit.id !== habitId || habit.doneToday === done
|
||||
? habit
|
||||
: { ...habit, doneToday: done, streak: Math.max(0, habit.streak + (done ? 1 : -1)) },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** A calendar date ("YYYY-MM-DD") as a label, independent of the browser's time zone. */
|
||||
export function formatLocalDate(
|
||||
date: string,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
): string {
|
||||
return new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, { ...options, timeZone: "UTC" });
|
||||
}
|
||||
|
||||
203
apps/web/src/lib/onboarding-copy.test.ts
Normal file
203
apps/web/src/lib/onboarding-copy.test.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import type { MessageBlock, ThreadMessage } from "@rakazo/contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "./i18n.js";
|
||||
import {
|
||||
intakeNoteText,
|
||||
localizedChoice,
|
||||
localizedPreview,
|
||||
withIntakeNoteText,
|
||||
} from "./onboarding-copy.js";
|
||||
|
||||
// The macros compile at build time; here the English source is the id, placeholders numbered.
|
||||
vi.mock("@lingui/core/macro", () => {
|
||||
const descriptor = (parts: TemplateStringsArray, values: unknown[]) => {
|
||||
const message = parts.reduce((out, part, index) => `${out}{${index - 1}}${part}`);
|
||||
return { id: message, message, values: { ...values } };
|
||||
};
|
||||
return {
|
||||
t: (parts: TemplateStringsArray, ...values: unknown[]) => i18n._(descriptor(parts, values)),
|
||||
msg: (parts: TemplateStringsArray, ...values: unknown[]) => descriptor(parts, values),
|
||||
};
|
||||
});
|
||||
|
||||
type Choice = Extract<MessageBlock, { kind: "choice" }>;
|
||||
type Note = Extract<MessageBlock, { kind: "intake_note" }>;
|
||||
|
||||
// Intl may separate the time from AM/PM with a narrow no-break space.
|
||||
const plain = (text: string) => text.replace(/\s/g, " ");
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.load("en", {});
|
||||
i18n.activate("en");
|
||||
});
|
||||
|
||||
describe("localizedChoice", () => {
|
||||
it("says a known card from its step and option ids, whatever the server wrote", () => {
|
||||
const card: Choice = {
|
||||
kind: "choice",
|
||||
step: "rhythm",
|
||||
question: "server question",
|
||||
options: [
|
||||
{ id: "morning", letter: "A", label: "server morning" },
|
||||
{ id: "fortnightly", letter: "B", label: "Every other week" },
|
||||
{ id: "none", letter: "", label: "server none" },
|
||||
],
|
||||
};
|
||||
expect(localizedChoice(card)).toEqual({
|
||||
...card,
|
||||
question: "When should I check in with you?",
|
||||
options: [
|
||||
{ id: "morning", letter: "A", label: "Every morning" },
|
||||
// An option the web does not know keeps the server text.
|
||||
{ id: "fortnightly", letter: "B", label: "Every other week" },
|
||||
{ id: "none", letter: "", label: "Not now" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("words the goal placeholder and keeps the server's quiet-hour ranges", () => {
|
||||
const goal = localizedChoice({
|
||||
kind: "choice",
|
||||
step: "goal",
|
||||
question: "?",
|
||||
textInput: { placeholder: "server placeholder" },
|
||||
options: [{ id: "skip", letter: "", label: "server skip" }],
|
||||
});
|
||||
expect(goal.textInput).toEqual({ placeholder: "Your goal" });
|
||||
expect(goal.options[0]?.label).toBe("Skip");
|
||||
const quiet = localizedChoice({
|
||||
kind: "choice",
|
||||
step: "quiet_hours",
|
||||
question: "?",
|
||||
options: [{ id: "22-07", letter: "A", label: "22:00 – 7:00" }],
|
||||
});
|
||||
expect(quiet.options[0]?.label).toBe("22:00 – 7:00");
|
||||
});
|
||||
|
||||
it("recognizes another bot's focus card and leaves other cards alone", () => {
|
||||
const focus: Choice = {
|
||||
kind: "choice",
|
||||
question: "server question",
|
||||
options: [
|
||||
{ id: "day", letter: "A", label: "x" },
|
||||
{ id: "crew", letter: "D", label: "y" },
|
||||
],
|
||||
};
|
||||
expect(localizedChoice(focus).question).toBe("What do you want me on first?");
|
||||
expect(localizedChoice(focus).options.map((option) => option.label)).toEqual([
|
||||
"Day-to-day work",
|
||||
"Specialist crew",
|
||||
]);
|
||||
const other: Choice = {
|
||||
kind: "choice",
|
||||
question: "Pick one",
|
||||
options: [{ id: "a", letter: "A", label: "Apples" }],
|
||||
};
|
||||
expect(localizedChoice(other)).toBe(other);
|
||||
});
|
||||
});
|
||||
|
||||
describe("intakeNoteText", () => {
|
||||
const now = new Date(2026, 9, 7, 10, 0);
|
||||
const note = (nextCheckInAt: Date | null): Note => ({
|
||||
kind: "intake_note",
|
||||
note: "done",
|
||||
nextCheckInAt: nextCheckInAt?.toISOString() ?? null,
|
||||
text: "server text",
|
||||
});
|
||||
|
||||
it("names the first check-in relative to today, in the user's clock", () => {
|
||||
expect(plain(intakeNoteText(note(new Date(2026, 9, 7, 18, 0)), now))).toBe(
|
||||
"All set. I’ll check in today at 6:00 PM.",
|
||||
);
|
||||
expect(plain(intakeNoteText(note(new Date(2026, 9, 8, 8, 0)), now))).toBe(
|
||||
"All set. I’ll check in tomorrow at 8:00 AM.",
|
||||
);
|
||||
expect(plain(intakeNoteText(note(new Date(2026, 9, 11, 18, 0)), now))).toBe(
|
||||
"All set. I’ll check in on Sunday at 6:00 PM.",
|
||||
);
|
||||
});
|
||||
|
||||
it("has a line for no check-in and falls back to the server text when unreadable", () => {
|
||||
expect(intakeNoteText(note(null), now)).toBe("All set. I’m here whenever you need me.");
|
||||
expect(intakeNoteText({ ...note(null), nextCheckInAt: "soon" }, now)).toBe("server text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withIntakeNoteText", () => {
|
||||
const message = (blocks: MessageBlock[]) =>
|
||||
({ id: "m", role: "bot", blocks, createdAt: "2026-10-07T08:00:00.000Z" }) as ThreadMessage;
|
||||
|
||||
it("turns notes into text and leaves other messages untouched", () => {
|
||||
const plainMessage = message([{ kind: "text", text: "hi" }]);
|
||||
expect(withIntakeNoteText(plainMessage)).toBe(plainMessage);
|
||||
const withNote = message([
|
||||
{ kind: "intake_note", note: "done", nextCheckInAt: null, text: "server text" },
|
||||
]);
|
||||
expect(withIntakeNoteText(withNote).blocks).toEqual([
|
||||
{ kind: "text", text: "All set. I’m here whenever you need me." },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizedPreview", () => {
|
||||
const now = new Date(2026, 9, 7, 10, 0);
|
||||
const rhythm: Choice = {
|
||||
kind: "choice",
|
||||
step: "rhythm",
|
||||
question: "server question",
|
||||
options: [
|
||||
{ id: "morning", letter: "A", label: "server morning" },
|
||||
{ id: "none", letter: "", label: "server none" },
|
||||
],
|
||||
};
|
||||
const goal: Choice = {
|
||||
kind: "choice",
|
||||
step: "goal",
|
||||
question: "server goal",
|
||||
textInput: { placeholder: "server placeholder" },
|
||||
options: [{ id: "skip", letter: "", label: "server skip" }],
|
||||
};
|
||||
const done: Note = {
|
||||
kind: "intake_note",
|
||||
note: "done",
|
||||
nextCheckInAt: new Date(2026, 9, 8, 8, 0).toISOString(),
|
||||
text: "server text",
|
||||
};
|
||||
const preview = (previewBlock?: Choice | Note) => localizedPreview({ preview: "", previewBlock });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now });
|
||||
return () => vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps the server preview when onboarding did not write the latest message", () => {
|
||||
expect(localizedPreview({ preview: "See you at 8" })).toBe("See you at 8");
|
||||
});
|
||||
|
||||
it("reads like the conversation: the question, the answer once given, the closing note", () => {
|
||||
expect(preview(rhythm)).toBe("When should I check in with you?");
|
||||
expect(preview({ ...rhythm, answerId: "morning" })).toBe("Every morning");
|
||||
expect(preview({ ...goal, answerId: "_text", answerText: "Run a 10K" })).toBe("Run a 10K");
|
||||
expect(plain(preview(done))).toBe("All set. I’ll check in tomorrow at 8:00 AM.");
|
||||
// A dismissed card is gone from the conversation, so the preview is too.
|
||||
expect(preview({ ...rhythm, answerId: "_dismissed" })).toBe("");
|
||||
});
|
||||
|
||||
it("speaks the user's language", () => {
|
||||
i18n.load("de", {
|
||||
"When should I check in with you?": "Wann soll ich mich bei dir melden?",
|
||||
"Every morning": "Jeden Morgen",
|
||||
"All set. I’ll check in tomorrow at {0}.": "Alles klar. Ich melde mich morgen um {0}.",
|
||||
"All set. I’m here whenever you need me.":
|
||||
"Alles klar. Ich bin da, wann immer du mich brauchst.",
|
||||
});
|
||||
i18n.activate("de");
|
||||
expect(preview(rhythm)).toBe("Wann soll ich mich bei dir melden?");
|
||||
expect(preview({ ...rhythm, answerId: "morning" })).toBe("Jeden Morgen");
|
||||
expect(preview(done)).toBe("Alles klar. Ich melde mich morgen um 8:00.");
|
||||
expect(preview({ ...done, nextCheckInAt: null })).toBe(
|
||||
"Alles klar. Ich bin da, wann immer du mich brauchst.",
|
||||
);
|
||||
});
|
||||
});
|
||||
130
apps/web/src/lib/onboarding-copy.ts
Normal file
130
apps/web/src/lib/onboarding-copy.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import type { MessageDescriptor } from "@lingui/core";
|
||||
import { msg, t } from "@lingui/core/macro";
|
||||
import type { Group, MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts";
|
||||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||
import { i18n } from "./i18n";
|
||||
|
||||
/**
|
||||
* The onboarding cards in the user's language. The server stores each card with its step and
|
||||
* option ids plus English text; the web says what it knows from the ids and keeps the server
|
||||
* text for anything new (mobile and agent history always use the server text).
|
||||
*/
|
||||
|
||||
type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
|
||||
type ChoiceOption = ChoiceBlock["options"][number];
|
||||
type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
|
||||
|
||||
const QUESTIONS: Record<OnboardingStep, MessageDescriptor> = {
|
||||
focus: msg`What do you want me on first?`,
|
||||
goal: msg`What’s one thing you want to get done in the next few weeks?`,
|
||||
rhythm: msg`When should I check in with you?`,
|
||||
quiet_hours: msg`When should I stay quiet?`,
|
||||
notifications: msg`Can I send you notifications?`,
|
||||
};
|
||||
|
||||
const SKIP = msg`Skip`;
|
||||
const NOT_NOW = msg`Not now`;
|
||||
|
||||
// Quiet-hour ranges are left out: the server already writes them in the user's locale.
|
||||
const OPTIONS: Record<OnboardingStep, Record<string, MessageDescriptor>> = {
|
||||
focus: {
|
||||
day: msg`Day-to-day work`,
|
||||
inbox: msg`Inbox & email`,
|
||||
research: msg`Research & writing`,
|
||||
crew: msg`Specialist crew`,
|
||||
skip: SKIP,
|
||||
},
|
||||
goal: { skip: SKIP },
|
||||
rhythm: {
|
||||
morning: msg`Every morning`,
|
||||
morning_evening: msg`Morning and evening`,
|
||||
weekly: msg`Once a week`,
|
||||
none: NOT_NOW,
|
||||
},
|
||||
quiet_hours: { skip: SKIP },
|
||||
notifications: { on: msg`Turn on`, later: NOT_NOW },
|
||||
};
|
||||
|
||||
const PLACEHOLDERS: Partial<Record<OnboardingStep, MessageDescriptor>> = {
|
||||
goal: msg`Your goal`,
|
||||
};
|
||||
|
||||
/** Another bot's focus card carries no step; its option ids give it away. */
|
||||
function cardStep(card: ChoiceBlock): OnboardingStep | undefined {
|
||||
if (card.step) return card.step;
|
||||
const focus = OPTIONS.focus;
|
||||
return card.options.length > 0 && card.options.every((option) => focus[option.id])
|
||||
? "focus"
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** The card's question, options and placeholder in the user's language. */
|
||||
export function localizedChoice(card: ChoiceBlock): ChoiceBlock {
|
||||
const step = cardStep(card);
|
||||
if (!step) return card;
|
||||
const options = OPTIONS[step];
|
||||
const placeholder = PLACEHOLDERS[step];
|
||||
return {
|
||||
...card,
|
||||
question: i18n._(QUESTIONS[step]),
|
||||
options: card.options.map((option) => {
|
||||
const label = options[option.id];
|
||||
return label ? { ...option, label: i18n._(label) } : option;
|
||||
}),
|
||||
...(card.textInput && placeholder ? { textInput: { placeholder: i18n._(placeholder) } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** What the user answered: the option they picked or their own words. */
|
||||
export function chosenOption(card: ChoiceBlock): ChoiceOption | undefined {
|
||||
return card.answerText
|
||||
? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: card.answerText }
|
||||
: card.options.find((option) => option.id === card.answerId);
|
||||
}
|
||||
|
||||
function sameDay(left: Date, right: Date): boolean {
|
||||
return left.toDateString() === right.toDateString();
|
||||
}
|
||||
|
||||
/** The closing note in the user's language, with the first check-in in their time zone. */
|
||||
export function intakeNoteText(note: IntakeNote, now = new Date()): string {
|
||||
if (note.note !== "done") return note.text;
|
||||
if (!note.nextCheckInAt) return t`All set. I’m here whenever you need me.`;
|
||||
const at = new Date(note.nextCheckInAt);
|
||||
if (!Number.isFinite(at.getTime())) return note.text;
|
||||
const time = new Intl.DateTimeFormat(i18n.locale || "en", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(at);
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
if (sameDay(at, now)) return t`All set. I’ll check in today at ${time}.`;
|
||||
if (sameDay(at, tomorrow)) return t`All set. I’ll check in tomorrow at ${time}.`;
|
||||
const weekday = new Intl.DateTimeFormat(i18n.locale || "en", { weekday: "long" }).format(at);
|
||||
return t`All set. I’ll check in on ${weekday} at ${time}.`;
|
||||
}
|
||||
|
||||
/** The message with onboarding notes as plain text, so bubbles, copy and speech read the same. */
|
||||
export function withIntakeNoteText(message: ThreadMessage): ThreadMessage {
|
||||
if (!message.blocks.some((block) => block.kind === "intake_note")) return message;
|
||||
return {
|
||||
...message,
|
||||
blocks: message.blocks.map((block) =>
|
||||
block.kind === "intake_note" ? { kind: "text", text: intakeNoteText(block) } : block,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation's preview in the user's language. An onboarding card reads as its question, or
|
||||
* as the answer once given; the closing note as the note. A dismissed card shows nothing in the
|
||||
* conversation, so it keeps the server preview.
|
||||
*/
|
||||
export function localizedPreview(chat: Pick<Group, "preview" | "previewBlock">): string {
|
||||
const block = chat.previewBlock;
|
||||
if (!block) return chat.preview;
|
||||
if (block.kind === "intake_note") return intakeNoteText(block);
|
||||
const card = localizedChoice(block);
|
||||
if (!card.answerId) return card.question;
|
||||
return chosenOption(card)?.label ?? chat.preview;
|
||||
}
|
||||
@ -1,56 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { onboardingProfileUpdate } from "./onboarding-profile";
|
||||
|
||||
const blank = { name: "", timezone: "", hobbies: [], workStyle: "", communication: "" };
|
||||
|
||||
describe("onboardingProfileUpdate", () => {
|
||||
it("turns answers into profile fields and onboarding facts", () => {
|
||||
it("fills the time zone, locale and chosen language from the browser", () => {
|
||||
expect(
|
||||
onboardingProfileUpdate({
|
||||
answers: {
|
||||
name: " Alice ",
|
||||
timezone: "Europe/Vienna",
|
||||
hobbies: ["music", "tech", "none"],
|
||||
workStyle: "deep-dive",
|
||||
communication: "direct",
|
||||
},
|
||||
accountName: "Alice Example",
|
||||
browserTimeZone: "UTC",
|
||||
browserTimeZone: "Europe/Vienna",
|
||||
locale: "de-AT",
|
||||
language: "de",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "Alice",
|
||||
timeZone: "Europe/Vienna",
|
||||
locale: "de-AT",
|
||||
language: "de",
|
||||
communicationStyle: "Direct and concise",
|
||||
addFacts: [
|
||||
{ category: "preferences", value: "Interested in music", source: "onboarding" },
|
||||
{ category: "preferences", value: "Interested in tech and gadgets", source: "onboarding" },
|
||||
{ category: "habits", value: "Prefers focused deep dives", source: "onboarding" },
|
||||
],
|
||||
});
|
||||
).toEqual({ timeZone: "Europe/Vienna", locale: "de-AT", language: "de" });
|
||||
});
|
||||
|
||||
it("keeps the account name and falls back to the browser time zone", () => {
|
||||
it("leaves out a language the user did not pick and malformed values", () => {
|
||||
expect(
|
||||
onboardingProfileUpdate({
|
||||
answers: { ...blank, name: "Alice", timezone: "Not/AZone" },
|
||||
accountName: "Alice",
|
||||
browserTimeZone: "America/Toronto",
|
||||
}),
|
||||
).toEqual({ timeZone: "America/Toronto" });
|
||||
});
|
||||
|
||||
it("leaves out a language the user did not pick and malformed tags", () => {
|
||||
expect(
|
||||
onboardingProfileUpdate({
|
||||
answers: blank,
|
||||
browserTimeZone: "UTC",
|
||||
browserTimeZone: "Not/AZone",
|
||||
locale: "not a tag",
|
||||
language: null,
|
||||
}),
|
||||
).toEqual({ timeZone: "UTC" });
|
||||
).toEqual({});
|
||||
expect(onboardingProfileUpdate({ browserTimeZone: "UTC" })).toEqual({ timeZone: "UTC" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,73 +1,19 @@
|
||||
import type { UserFactCategory } from "@rakazo/contracts";
|
||||
import { isLanguageTag, isTimeZone } from "@rakazo/contracts";
|
||||
|
||||
export interface OnboardingAnswers {
|
||||
name: string;
|
||||
timezone: string;
|
||||
hobbies: string[];
|
||||
workStyle: string;
|
||||
communication: string;
|
||||
}
|
||||
|
||||
// Stored as data for the user's bots, so these stay plain English statements.
|
||||
const HOBBY_FACTS: Record<string, string> = {
|
||||
coding: "Interested in coding",
|
||||
design: "Interested in design",
|
||||
writing: "Interested in writing",
|
||||
music: "Interested in music",
|
||||
gaming: "Interested in gaming",
|
||||
reading: "Interested in reading",
|
||||
sports: "Interested in sports",
|
||||
cooking: "Interested in cooking",
|
||||
tech: "Interested in tech and gadgets",
|
||||
};
|
||||
|
||||
const WORK_STYLE_FACTS: Record<string, string> = {
|
||||
structured: "Works in a structured, planned way",
|
||||
flexible: "Works flexibly and spontaneously",
|
||||
"deep-dive": "Prefers focused deep dives",
|
||||
quick: "Works quickly and pragmatically",
|
||||
};
|
||||
|
||||
const COMMUNICATION_STYLES: Record<string, string> = {
|
||||
direct: "Direct and concise",
|
||||
detailed: "Detailed and explanatory",
|
||||
balanced: "Balanced",
|
||||
casual: "Casual and friendly",
|
||||
};
|
||||
|
||||
/** Turns onboarding answers into one profile update; empty or unknown answers are left out. */
|
||||
/**
|
||||
* The profile fields onboarding fills in without asking: the browser's time zone and locale,
|
||||
* and the language the user picked for their Oos. The name comes from the account.
|
||||
*/
|
||||
export function onboardingProfileUpdate(input: {
|
||||
answers: OnboardingAnswers;
|
||||
/** The account name, which only changes when the user typed a different one. */
|
||||
accountName?: string;
|
||||
/** Used when the typed time zone is empty or unknown. */
|
||||
browserTimeZone: string;
|
||||
/** Browser locale, e.g. "de-AT". */
|
||||
locale?: string;
|
||||
/** Language the user picked for their Oos, when they picked one. */
|
||||
language?: string | null;
|
||||
}) {
|
||||
const { answers } = input;
|
||||
const name = answers.name.trim();
|
||||
const typedZone = answers.timezone.trim();
|
||||
const workStyle = WORK_STYLE_FACTS[answers.workStyle];
|
||||
const facts: Array<{ category: UserFactCategory; value: string }> = [
|
||||
...answers.hobbies.flatMap((hobby) => {
|
||||
const value = HOBBY_FACTS[hobby];
|
||||
return value ? [{ category: "preferences" as const, value }] : [];
|
||||
}),
|
||||
...(workStyle ? [{ category: "habits" as const, value: workStyle }] : []),
|
||||
];
|
||||
const communicationStyle = COMMUNICATION_STYLES[answers.communication];
|
||||
return {
|
||||
...(name && name !== input.accountName?.trim() ? { name } : {}),
|
||||
timeZone: typedZone && isTimeZone(typedZone) ? typedZone : input.browserTimeZone,
|
||||
...(isTimeZone(input.browserTimeZone) ? { timeZone: input.browserTimeZone } : {}),
|
||||
...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}),
|
||||
...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}),
|
||||
...(communicationStyle ? { communicationStyle } : {}),
|
||||
...(facts.length > 0
|
||||
? { addFacts: facts.map((fact) => ({ ...fact, source: "onboarding" as const })) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
28
apps/web/src/lib/routine-name.test.ts
Normal file
28
apps/web/src/lib/routine-name.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { routineName, routineSaveName } from "./routine-name.js";
|
||||
|
||||
const labels = vi.hoisted(() => ({ locale: "en" }));
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
// Stands in for a German catalog when the test switches the locale.
|
||||
t: (parts: TemplateStringsArray) =>
|
||||
labels.locale === "de" && parts.join("") === "Evening check-in"
|
||||
? "Abend-Check-in"
|
||||
: parts.join(""),
|
||||
}));
|
||||
|
||||
describe("check-in routine names", () => {
|
||||
it("shows a check-in under its own label until the user renames it", () => {
|
||||
labels.locale = "de";
|
||||
expect(routineName({ name: "Evening check-in", coachKey: "evening" })).toBe("Abend-Check-in");
|
||||
expect(routineName({ name: "Wind down", coachKey: "evening" })).toBe("Wind down");
|
||||
expect(routineName({ name: "Evening check-in", coachKey: null })).toBe("Evening check-in");
|
||||
});
|
||||
|
||||
it("saves the stored name when the shown one was left as it is", () => {
|
||||
labels.locale = "de";
|
||||
const routine = { name: "Evening check-in", coachKey: "evening" as const };
|
||||
expect(routineSaveName(routine, "Abend-Check-in")).toBe("Evening check-in");
|
||||
expect(routineSaveName(routine, "Wind down")).toBe("Wind down");
|
||||
expect(routineSaveName(null, "New")).toBe("New");
|
||||
});
|
||||
});
|
||||
27
apps/web/src/lib/routine-name.ts
Normal file
27
apps/web/src/lib/routine-name.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { CoachRoutineKey, Routine } from "@rakazo/contracts";
|
||||
import { COACH_ROUTINE_NAMES } from "@rakazo/contracts";
|
||||
|
||||
const COACH_ROUTINE_LABELS: Record<CoachRoutineKey, () => string> = {
|
||||
morning: () => t`Morning brief`,
|
||||
evening: () => t`Evening check-in`,
|
||||
weekly: () => t`Weekly review`,
|
||||
};
|
||||
|
||||
/**
|
||||
* A check-in routine is stored under its English name; the user sees it in their language
|
||||
* until they rename it.
|
||||
*/
|
||||
export function routineName(routine: Pick<Routine, "name" | "coachKey">): string {
|
||||
return routine.coachKey && routine.name === COACH_ROUTINE_NAMES[routine.coachKey]
|
||||
? COACH_ROUTINE_LABELS[routine.coachKey]()
|
||||
: routine.name;
|
||||
}
|
||||
|
||||
/** The name to save: a check-in name the user left as shown keeps its stored form. */
|
||||
export function routineSaveName(
|
||||
routine: Pick<Routine, "name" | "coachKey"> | null,
|
||||
draftName: string,
|
||||
): string {
|
||||
return routine && draftName === routineName(routine) ? routine.name : draftName;
|
||||
}
|
||||
@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
displayLanguageTag,
|
||||
displayRegionTag,
|
||||
factSpaceNames,
|
||||
filledProfileFields,
|
||||
groupUserFacts,
|
||||
toggleFactSpace,
|
||||
userFactDraft,
|
||||
userFactEdit,
|
||||
} from "./user-profile-view";
|
||||
|
||||
function fact(overrides: Partial<UserFact> & Pick<UserFact, "id">): UserFact {
|
||||
@ -74,3 +78,43 @@ describe("user profile view", () => {
|
||||
expect(displayRegionTag("not a tag", "en")).toBe("not a tag");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user fact editing", () => {
|
||||
const tea = fact({ id: "tea", value: "Prefers tea", category: "preferences" });
|
||||
const limited = fact({ id: "lin", allSpaces: false, spaceIds: ["space-b", "space-a"] });
|
||||
|
||||
it("sends only what changed, or nothing", () => {
|
||||
expect(userFactEdit(tea, userFactDraft(tea))).toBeNull();
|
||||
expect(userFactEdit(tea, { ...userFactDraft(tea), value: " Prefers tea " })).toBeNull();
|
||||
expect(
|
||||
userFactEdit(limited, { ...userFactDraft(limited), spaceIds: ["space-a", "space-b"] }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
userFactEdit(tea, { value: " Prefers green tea ", category: "habits", spaceIds: null }),
|
||||
).toEqual({ id: "tea", value: "Prefers green tea", category: "habits" });
|
||||
expect(userFactEdit(tea, { ...userFactDraft(tea), spaceIds: ["space-a"] })).toEqual({
|
||||
id: "tea",
|
||||
spaceIds: ["space-a"],
|
||||
});
|
||||
expect(userFactEdit(limited, { ...userFactDraft(limited), spaceIds: null })).toEqual({
|
||||
id: "lin",
|
||||
spaceIds: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("limits a fact to checked spaces and shows it everywhere when none is left", () => {
|
||||
expect(toggleFactSpace(null, "space-a", true)).toEqual(["space-a"]);
|
||||
expect(toggleFactSpace(["space-a"], "space-b", true)).toEqual(["space-a", "space-b"]);
|
||||
expect(toggleFactSpace(["space-a", "space-b"], "space-a", false)).toEqual(["space-b"]);
|
||||
expect(toggleFactSpace(["space-a"], "space-a", false)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the spaces of a limited fact", () => {
|
||||
const spaces = [
|
||||
{ id: "space-a", name: "Home" },
|
||||
{ id: "space-b", name: "Side project" },
|
||||
];
|
||||
expect(factSpaceNames(["space-b", "gone"], spaces)).toEqual(["Side project"]);
|
||||
expect(factSpaceNames(null, spaces)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import type { UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts";
|
||||
import type { UpdateUserProfile, UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts";
|
||||
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
|
||||
|
||||
/** What the fact editor holds: null spaces means every space. */
|
||||
export interface UserFactDraft {
|
||||
value: string;
|
||||
category: UserFactCategory;
|
||||
spaceIds: string[] | null;
|
||||
}
|
||||
|
||||
type UserFactEdit = NonNullable<UpdateUserProfile["editFacts"]>[number];
|
||||
|
||||
export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary";
|
||||
|
||||
const PROFILE_FIELDS: ProfileField[] = [
|
||||
@ -75,3 +84,49 @@ export function displayRegionTag(tag: string, uiLocale: string): string {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
export function userFactDraft(fact: UserFact): UserFactDraft {
|
||||
return {
|
||||
value: fact.value,
|
||||
category: fact.category,
|
||||
spaceIds: fact.allSpaces ? null : fact.spaceIds,
|
||||
};
|
||||
}
|
||||
|
||||
/** The `editFacts` entry for what changed in the draft, or null when nothing did. */
|
||||
export function userFactEdit(fact: UserFact, draft: UserFactDraft): UserFactEdit | null {
|
||||
const edit: UserFactEdit = { id: fact.id };
|
||||
const value = draft.value.trim();
|
||||
if (value !== fact.value) edit.value = value;
|
||||
if (draft.category !== fact.category) edit.category = draft.category;
|
||||
const before = userFactDraft(fact).spaceIds;
|
||||
const sameSpaces =
|
||||
before === null || draft.spaceIds === null
|
||||
? before === draft.spaceIds
|
||||
: before.length === draft.spaceIds.length &&
|
||||
draft.spaceIds.every((spaceId) => before.includes(spaceId));
|
||||
if (!sameSpaces) edit.spaceIds = draft.spaceIds;
|
||||
return Object.keys(edit).length > 1 ? edit : null;
|
||||
}
|
||||
|
||||
/** Checks or unchecks one space; unchecking the last one shows the fact everywhere again. */
|
||||
export function toggleFactSpace(
|
||||
spaceIds: string[] | null,
|
||||
spaceId: string,
|
||||
checked: boolean,
|
||||
): string[] | null {
|
||||
const current = spaceIds ?? [];
|
||||
const next = checked
|
||||
? [...current.filter((id) => id !== spaceId), spaceId]
|
||||
: current.filter((id) => id !== spaceId);
|
||||
return next.length > 0 ? next : null;
|
||||
}
|
||||
|
||||
/** Names of the spaces a limited fact shows in, in the order of `spaces`; unknown ids are skipped. */
|
||||
export function factSpaceNames(
|
||||
spaceIds: string[] | null,
|
||||
spaces: ReadonlyArray<{ id: string; name: string }>,
|
||||
): string[] {
|
||||
if (!spaceIds) return [];
|
||||
return spaces.filter((space) => spaceIds.includes(space.id)).map((space) => space.name);
|
||||
}
|
||||
|
||||
102
apps/web/src/lib/web-push.ts
Normal file
102
apps/web/src/lib/web-push.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { desktopBridge } from "./desktop";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
const SERVICE_WORKER_URL = "/sw.js";
|
||||
const SERVICE_WORKER_SCOPE = "/";
|
||||
|
||||
/**
|
||||
* Web push needs the Push API, a service worker and a secure context (https or localhost). The
|
||||
* desktop app keeps its in-app notifications instead.
|
||||
*/
|
||||
export function webPushSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.isSecureContext &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window &&
|
||||
!desktopBridge()
|
||||
);
|
||||
}
|
||||
|
||||
/** Registers the push service worker where web push works; safe to call on every start. */
|
||||
export async function registerPushServiceWorker(): Promise<void> {
|
||||
if (!webPushSupported()) return;
|
||||
await navigator.serviceWorker
|
||||
.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async function currentSubscription(): Promise<PushSubscription | null> {
|
||||
const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE);
|
||||
return (await registration?.pushManager.getSubscription()) ?? null;
|
||||
}
|
||||
|
||||
/** True when this browser has granted notifications and holds a push subscription. */
|
||||
export async function webPushEnabled(): Promise<boolean> {
|
||||
if (!webPushSupported() || Notification.permission !== "granted") return false;
|
||||
return Boolean(await currentSubscription().catch(() => null));
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> {
|
||||
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "="));
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function sameKey(current: ArrayBuffer | null, expected: Uint8Array): boolean {
|
||||
if (!current || current.byteLength !== expected.byteLength) return false;
|
||||
const bytes = new Uint8Array(current);
|
||||
return bytes.every((byte, index) => byte === expected[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on push for this browser and registers it with the server. Call it straight from a click:
|
||||
* browsers only show the permission prompt during a user gesture. Throws when the server cannot
|
||||
* be reached.
|
||||
*/
|
||||
export async function enableWebPush(): Promise<"enabled" | "denied" | "unsupported"> {
|
||||
if (!webPushSupported()) return "unsupported";
|
||||
const permission =
|
||||
Notification.permission === "default"
|
||||
? await Notification.requestPermission()
|
||||
: Notification.permission;
|
||||
if (permission !== "granted") return "denied";
|
||||
const config = await rpc.notifications.webPush.config();
|
||||
if (!config.available || !config.publicKey) return "unsupported";
|
||||
const applicationServerKey = base64UrlToBytes(config.publicKey);
|
||||
let subscription: PushSubscription;
|
||||
try {
|
||||
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE });
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
// A subscription made for other server keys cannot receive this server's pushes.
|
||||
if (existing && !sameKey(existing.options.applicationServerKey, applicationServerKey)) {
|
||||
await existing.unsubscribe();
|
||||
}
|
||||
subscription =
|
||||
existing && sameKey(existing.options.applicationServerKey, applicationServerKey)
|
||||
? existing
|
||||
: await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });
|
||||
} catch {
|
||||
// Browsers without a push service (or with it switched off) reject the subscription.
|
||||
return "unsupported";
|
||||
}
|
||||
const keys = subscription.toJSON().keys ?? {};
|
||||
await rpc.notifications.webPush.subscribe({
|
||||
endpoint: subscription.endpoint,
|
||||
keys: { p256dh: keys.p256dh ?? "", auth: keys.auth ?? "" },
|
||||
});
|
||||
return "enabled";
|
||||
}
|
||||
|
||||
/** Stops push for this browser, on the server first so no push arrives after it returns. */
|
||||
export async function disableWebPush(): Promise<void> {
|
||||
if (!webPushSupported()) return;
|
||||
const subscription = await currentSubscription();
|
||||
if (!subscription) return;
|
||||
await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint });
|
||||
await subscription.unsubscribe().catch(() => undefined);
|
||||
}
|
||||
@ -1,17 +1,18 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: de\n"
|
||||
"Project-Id-Version: rakazo-web\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: de\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: \n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid " (unread)"
|
||||
@ -21,6 +22,11 @@ msgstr " (ungelesen)"
|
||||
msgid "(optional)"
|
||||
msgstr "(optional)"
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# day} other {# days}}"
|
||||
msgstr "{0, plural, one {# Tag} other {# Tage}}"
|
||||
|
||||
#. placeholder {0}: group.entries.length
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "{0, plural, one {# model} other {# models}}"
|
||||
@ -31,6 +37,11 @@ msgstr "{0, plural, one {# Modell} other {# Modelle}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr "{0, plural, one {# weiteres wartet} other {# weitere warten}}"
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
msgstr "{0, plural, one {# Woche} other {# Wochen}}"
|
||||
|
||||
#. placeholder {0}: file.name
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
|
||||
@ -282,10 +293,6 @@ msgstr "1:1-Unterhaltung"
|
||||
msgid "1:1 conversation with {title}"
|
||||
msgstr "1:1-Unterhaltung mit {title}"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "A few quick preferences so your Oo can be better tailored to you."
|
||||
msgstr "Ein paar kurze Vorlieben, damit dein Oo besser zu dir passt."
|
||||
|
||||
#: src/pages/shell/bot-picker.tsx
|
||||
msgid "About groups"
|
||||
msgstr "Über Gruppen"
|
||||
@ -351,6 +358,7 @@ msgstr "Aktives Modell"
|
||||
msgid "Activity"
|
||||
msgstr "Aktivität"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
msgid "Add"
|
||||
@ -388,6 +396,10 @@ msgstr "Gib eine HTTPS-Server-URL ein."
|
||||
msgid "Add another"
|
||||
msgstr "Weiteres hinzufügen"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add as projects"
|
||||
msgstr "Als Projekte hinzufügen"
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Add Executor"
|
||||
msgstr "Executor hinzufügen"
|
||||
@ -429,6 +441,10 @@ msgstr "Server hinzufügen"
|
||||
msgid "Add server URL"
|
||||
msgstr "Server-URL hinzufügen"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add to About you"
|
||||
msgstr "Zu „Über dich“ hinzufügen"
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Add to routine"
|
||||
msgstr "Zur Routine hinzufügen"
|
||||
@ -496,6 +512,26 @@ msgstr "Alle Daten lokal gespeichert — keine externen Server"
|
||||
msgid "All quiet"
|
||||
msgstr "Alles ruhig"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in on {weekday} at {time}."
|
||||
msgstr "Alles klar. Ich melde mich am {weekday} um {time}."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in today at {time}."
|
||||
msgstr "Alles klar. Ich melde mich heute um {time}."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in tomorrow at {time}."
|
||||
msgstr "Alles klar. Ich melde mich morgen um {time}."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’m here whenever you need me."
|
||||
msgstr "Alles klar. Ich bin da, wann immer du mich brauchst."
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "All spaces"
|
||||
msgstr "Alle Spaces"
|
||||
|
||||
#. placeholder {0}: rule.matchValue
|
||||
#: src/components/ApprovalRulesSettings.tsx
|
||||
msgid "Allow {0} actions without asking"
|
||||
@ -703,10 +739,6 @@ msgstr "Autorisieren"
|
||||
msgid "Authorize this server so agents can use its tools. A popup opens."
|
||||
msgstr "Autorisiere diesen Server, damit Agents seine Tools nutzen können. Ein Pop-up öffnet sich."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Auto-detected or enter manually"
|
||||
msgstr "Automatisch erkannt oder manuell eingeben"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Automated senders"
|
||||
msgstr "Automatische Absender"
|
||||
@ -743,10 +775,6 @@ msgstr "Zurück zur Anmeldung"
|
||||
msgid "Back to timeline"
|
||||
msgstr "Zurück zur Zeitleiste"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Balanced"
|
||||
msgstr "Ausgewogen"
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Base URL"
|
||||
msgstr "Basis-URL"
|
||||
@ -756,6 +784,10 @@ msgstr "Basis-URL"
|
||||
msgid "Bearer token"
|
||||
msgstr "Bearer-Token"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr "In deinen Browser-Einstellungen blockiert"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Booting live desktop…"
|
||||
msgstr "Live-Desktop wird gestartet…"
|
||||
@ -824,6 +856,10 @@ msgstr "Ruhige Hinweistöne für erledigte Arbeit, Sprachphasen und Übergaben.
|
||||
msgid "Calm helper"
|
||||
msgstr "Ruhiger Helfer"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Can I send you notifications?"
|
||||
msgstr "Darf ich dir Benachrichtigungen schicken?"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Can speak"
|
||||
msgstr "Kann sprechen"
|
||||
@ -837,6 +873,7 @@ msgstr "Server nicht erreichbar."
|
||||
#: src/components/ComputerUpdateProgress.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/components/teach/TeachComputerOverlay.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -872,9 +909,9 @@ msgstr "Abgebrochen"
|
||||
msgid "Cancelled"
|
||||
msgstr "Abgebrochen"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Casual & friendly"
|
||||
msgstr "Locker & freundlich"
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Category"
|
||||
msgstr "Kategorie"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Change password"
|
||||
@ -930,6 +967,10 @@ msgstr "Melde dich."
|
||||
msgid "Check your email"
|
||||
msgstr "Prüfe deine E-Mails"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Check-ins"
|
||||
msgstr "Check-ins"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Checking dependencies…"
|
||||
msgstr "Prüfe Abhängigkeiten…"
|
||||
@ -1139,7 +1180,6 @@ msgid "Command"
|
||||
msgstr "Befehl"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Communication style"
|
||||
msgstr "Kommunikationsstil"
|
||||
|
||||
@ -1300,8 +1340,8 @@ msgstr "Kontextlimit"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Weiter"
|
||||
|
||||
@ -1519,6 +1559,10 @@ msgstr "Update-Status konnte nicht geladen werden"
|
||||
msgid "Could not load voice settings"
|
||||
msgstr "Stimmeinstellungen konnten nicht geladen werden"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Could not look in this folder."
|
||||
msgstr "In diesem Ordner konnte nicht gesucht werden."
|
||||
|
||||
#: src/pages/LocalSettings.tsx
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr "Lokale Einstellungen konnten nicht geöffnet werden. Starte den lokalen Server, erstelle sein Besitzerkonto und versuche es dann erneut."
|
||||
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
|
||||
msgstr "{name} konnte nicht hochgeladen werden. Versuche es erneut."
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't load"
|
||||
msgstr "Konnte nicht geladen werden"
|
||||
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
|
||||
msgstr "Messaging-Einstellungen konnten nicht geladen werden"
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't save"
|
||||
msgstr "Konnte nicht gespeichert werden"
|
||||
@ -1826,6 +1872,10 @@ msgstr "Datenbank"
|
||||
msgid "day"
|
||||
msgstr "Tag"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Day-to-day work"
|
||||
msgstr "Tagesgeschäft"
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "days"
|
||||
msgstr "Tage"
|
||||
@ -1834,10 +1884,6 @@ msgstr "Tage"
|
||||
msgid "Decline"
|
||||
msgstr "Ablehnen"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Deep-dive focused"
|
||||
msgstr "Fokussiert in die Tiefe"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Default"
|
||||
msgstr "Standard"
|
||||
@ -1938,18 +1984,10 @@ msgstr "Desktop-App"
|
||||
msgid "Desktop update"
|
||||
msgstr "Desktop-Update"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Detailed & explanatory"
|
||||
msgstr "Ausführlich & erklärend"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Developer tools"
|
||||
msgstr "Entwicklertools"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Direct & concise"
|
||||
msgstr "Direkt & knapp"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
msgid "Direct MCP"
|
||||
msgstr "Direktes MCP"
|
||||
@ -2073,6 +2111,10 @@ msgstr "z. B. Hivenet Agent, Presales, Zeiterfassungs-Bot"
|
||||
msgid "Earlier message"
|
||||
msgstr "Frühere Nachricht"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Edit"
|
||||
msgstr "Bearbeiten"
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Edit first"
|
||||
msgstr "Zuerst bearbeiten"
|
||||
@ -2122,6 +2164,14 @@ msgstr "Gib einen absoluten Ordnerpfad auf dem Server ein. Git-Remotes werden au
|
||||
msgid "Enter this code at <0>{0}</0>"
|
||||
msgstr "Gib diesen Code unter <0>{0}</0> ein"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Evening"
|
||||
msgstr "Abends"
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Evening check-in"
|
||||
msgstr "Abend-Check-in"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every"
|
||||
@ -2150,6 +2200,10 @@ msgstr "Jeden Montag"
|
||||
msgid "Every month"
|
||||
msgstr "Jeden Monat"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Every morning"
|
||||
msgstr "Jeden Morgen"
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every week"
|
||||
@ -2191,6 +2245,10 @@ msgstr "Externe Unterhaltung"
|
||||
msgid "Extra high"
|
||||
msgstr "Sehr hoch"
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Fact"
|
||||
msgstr "Fakt"
|
||||
|
||||
#: src/components/CloudAgentCard.tsx
|
||||
msgid "failed"
|
||||
msgstr "fehlgeschlagen"
|
||||
@ -2265,10 +2323,6 @@ msgstr "Erste {shownRows} Zeilen"
|
||||
msgid "Flag unexpected actions"
|
||||
msgstr "Unerwartete Aktionen markieren"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Flexible & spontaneous"
|
||||
msgstr "Flexibel & spontan"
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Fonts loaded locally"
|
||||
msgstr "Schriften lokal geladen"
|
||||
@ -2333,6 +2387,7 @@ msgstr "Git-Ereignis"
|
||||
msgid "Goal"
|
||||
msgstr "Ziel"
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
msgid "Goals"
|
||||
@ -2369,6 +2424,7 @@ msgstr "Updates bündeln"
|
||||
msgid "Groups"
|
||||
msgstr "Gruppen"
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Habits"
|
||||
msgstr "Gewohnheiten"
|
||||
@ -2443,10 +2499,6 @@ msgstr "Passwort verbergen"
|
||||
msgid "High"
|
||||
msgstr "Hoch"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Hobbies & interests"
|
||||
msgstr "Hobbys & Interessen"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "Hobby"
|
||||
msgstr "Hobby"
|
||||
@ -2460,10 +2512,6 @@ msgstr "Stunde"
|
||||
msgid "hours"
|
||||
msgstr "Stunden"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How do you like to work?"
|
||||
msgstr "Wie arbeitest du am liebsten?"
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "How often"
|
||||
msgstr "Wie oft"
|
||||
@ -2472,10 +2520,6 @@ msgstr "Wie oft"
|
||||
msgid "How should your Oo sound when reading replies aloud?"
|
||||
msgstr "Wie soll dein Oo klingen, wenn es Antworten vorliest?"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How should your Oo talk to you?"
|
||||
msgstr "Wie soll dein Oo mit dir sprechen?"
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "How to check"
|
||||
msgstr "So wird geprüft"
|
||||
@ -2524,6 +2568,10 @@ msgstr "In meinem Fachgebiet — ich gehe gern ins Detail oder setze einfach um,
|
||||
msgid "In progress"
|
||||
msgstr "In Arbeit"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Inbox & email"
|
||||
msgstr "Posteingang & E-Mail"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Inherit default"
|
||||
msgstr "Standard übernehmen"
|
||||
@ -2747,6 +2795,10 @@ msgstr "Einstellungen des lokalen Servers"
|
||||
msgid "Log out"
|
||||
msgstr "Abmelden"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look"
|
||||
msgstr "Suchen"
|
||||
|
||||
#. placeholder {0}: index + 1
|
||||
#. placeholder {1}: looks.length
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
@ -2757,10 +2809,18 @@ msgstr "Look {0} von {1}"
|
||||
msgid "Look families"
|
||||
msgstr "Look-Familien"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look for projects here?"
|
||||
msgstr "Hier nach Projekten suchen?"
|
||||
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
msgid "Look variants"
|
||||
msgstr "Look-Varianten"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Looking"
|
||||
msgstr "Suche läuft"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Low"
|
||||
@ -2925,10 +2985,6 @@ msgstr "Minute"
|
||||
msgid "minutes"
|
||||
msgstr "Minuten"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Mission Control"
|
||||
msgstr "Mission Control"
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -2986,6 +3042,18 @@ msgstr "Weitere Computeraktionen"
|
||||
msgid "More conversations ({hiddenSessionCount})"
|
||||
msgstr "Mehr Unterhaltungen ({hiddenSessionCount})"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Morning"
|
||||
msgstr "Morgens"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Morning and evening"
|
||||
msgstr "Morgens und abends"
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Morning brief"
|
||||
msgstr "Morgenbriefing"
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "Move them to your shared memory."
|
||||
msgstr "Verschiebe sie in deine geteilten Erinnerungen."
|
||||
@ -2997,7 +3065,6 @@ msgstr "In Ordner verschieben"
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
@ -3176,6 +3243,10 @@ msgstr "Noch keine Nachrichten mit {peerBotName}."
|
||||
msgid "No model catalog is available."
|
||||
msgstr "Kein Modellkatalog verfügbar."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "No projects found"
|
||||
msgstr "Keine Projekte gefunden"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "No projects yet."
|
||||
msgstr "Noch keine Projekte."
|
||||
@ -3233,6 +3304,7 @@ msgstr "Nicht verbunden"
|
||||
msgid "Not in the plugin catalog"
|
||||
msgstr "Nicht im Plugin-Katalog"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Not now"
|
||||
msgstr "Jetzt nicht"
|
||||
@ -3245,14 +3317,11 @@ msgstr "Nichts läuft mehr"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Noch nichts gemerkt"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Nothing specific"
|
||||
msgstr "Nichts Bestimmtes"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr "Noch nichts"
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
msgstr "Benachrichtigungen"
|
||||
@ -3266,6 +3335,10 @@ msgstr "Jetzt"
|
||||
msgid "Now using {0}."
|
||||
msgstr "Jetzt wird {0} verwendet."
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Nudges from your Oo"
|
||||
msgstr "Anstöße von deinem Oo"
|
||||
|
||||
#: src/pages/McpOAuthCallback.tsx
|
||||
msgid "OAuth authorization was cancelled."
|
||||
msgstr "OAuth-Autorisierung wurde abgebrochen."
|
||||
@ -3299,6 +3372,10 @@ msgstr "am 1. um {0}"
|
||||
msgid "Onboarding"
|
||||
msgstr "Onboarding"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Once a week"
|
||||
msgstr "Einmal pro Woche"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "One more look before I answer…"
|
||||
msgstr "Noch ein Blick, bevor ich antworte…"
|
||||
@ -3307,6 +3384,10 @@ msgstr "Noch ein Blick, bevor ich antworte…"
|
||||
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
msgstr "Nur leere Spaces können gelöscht werden. Lösche zuerst die Bots und Gruppen darin."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Only file and folder names are read."
|
||||
msgstr "Gelesen werden nur Datei- und Ordnernamen."
|
||||
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Open"
|
||||
@ -3559,6 +3640,10 @@ msgstr "Anbieter"
|
||||
msgid "Pull request"
|
||||
msgstr "Pull Request"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Push on this device"
|
||||
msgstr "Push auf diesem Gerät"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -3570,14 +3655,22 @@ msgstr "In Warteschlange"
|
||||
msgid "Queued…"
|
||||
msgstr "Wartet…"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Quick & pragmatic"
|
||||
msgstr "Schnell & pragmatisch"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Quiet day so far"
|
||||
msgstr "Bisher ein ruhiger Tag"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours"
|
||||
msgstr "Ruhezeiten"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours end"
|
||||
msgstr "Ende der Ruhezeiten"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours start"
|
||||
msgstr "Beginn der Ruhezeiten"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Quote"
|
||||
msgstr "Zitieren"
|
||||
@ -3798,6 +3891,10 @@ msgstr "Antwort an {replyName}: „{replyQuote}“"
|
||||
msgid "Reporting from home…"
|
||||
msgstr "Melde mich von zu Hause…"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Research & writing"
|
||||
msgstr "Recherche & Schreiben"
|
||||
|
||||
#: src/pages/shell/bot-capabilities.tsx
|
||||
msgid "research, writing"
|
||||
msgstr "Recherche, Schreiben"
|
||||
@ -3938,6 +4035,7 @@ msgstr "Sandbox"
|
||||
#: src/components/AskCard.tsx
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
@ -4040,15 +4138,12 @@ msgstr "Suche…"
|
||||
msgid "Select {0}"
|
||||
msgstr "{0} auswählen"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Select all that apply"
|
||||
msgstr "Wähle alles Zutreffende aus"
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Self-hosted (better-auth)"
|
||||
msgstr "Selbst gehostet (better-auth)"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Send"
|
||||
msgstr "Senden"
|
||||
|
||||
@ -4213,7 +4308,9 @@ msgid "Skills"
|
||||
msgstr "Skills"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Skip"
|
||||
msgstr "Überspringen"
|
||||
@ -4298,6 +4395,10 @@ msgstr "Vorlesen an oder aus"
|
||||
msgid "Speaking with intent…"
|
||||
msgstr "Spreche mit Bedacht…"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Specialist crew"
|
||||
msgstr "Spezialisten-Team"
|
||||
|
||||
#: src/lib/model-tier-label.ts
|
||||
msgid "Standard"
|
||||
msgstr "Standard"
|
||||
@ -4391,10 +4492,6 @@ msgstr "Stresstest fürs Portfolio…"
|
||||
msgid "Stronger together — {0} specialists"
|
||||
msgstr "Stärker zusammen — {0} Spezialisten"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Structured & planned"
|
||||
msgstr "Strukturiert & geplant"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "subagent"
|
||||
msgstr "Subagent"
|
||||
@ -4480,10 +4577,6 @@ msgstr "Teams nicht aktiviert"
|
||||
msgid "Tell me what matters. I'll move."
|
||||
msgstr "Sag mir, was zählt. Ich lege los."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Tell us about yourself"
|
||||
msgstr "Erzähl uns von dir"
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Test"
|
||||
msgstr "Test"
|
||||
@ -4574,10 +4667,6 @@ msgstr "Uhrzeit"
|
||||
msgid "Time zone"
|
||||
msgstr "Zeitzone"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Timezone"
|
||||
msgstr "Zeitzone"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Title"
|
||||
msgstr "Titel"
|
||||
@ -4640,6 +4729,10 @@ msgstr "Triage läuft…"
|
||||
msgid "Try again"
|
||||
msgstr "Erneut versuchen"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Turn on"
|
||||
msgstr "Einschalten"
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Type your answer"
|
||||
msgstr "Gib deine Antwort ein"
|
||||
@ -4841,6 +4934,14 @@ msgstr "Webhook"
|
||||
msgid "Weekdays"
|
||||
msgstr "Wochentage"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Weekly"
|
||||
msgstr "Wöchentlich"
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Weekly review"
|
||||
msgstr "Wochenrückblick"
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "What about its memories?"
|
||||
msgstr "Was soll mit seinen Erinnerungen geschehen?"
|
||||
@ -4849,6 +4950,10 @@ msgstr "Was soll mit seinen Erinnerungen geschehen?"
|
||||
msgid "What do you think — react or answer in a sentence."
|
||||
msgstr "Was meinst du — reagiere oder antworte in einem Satz."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What do you want me on first?"
|
||||
msgstr "Wobei soll ich dir zuerst helfen?"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "What feels like you?"
|
||||
msgstr "Was passt zu dir?"
|
||||
@ -4889,6 +4994,10 @@ msgstr "Was zurückgegeben werden soll"
|
||||
msgid "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
msgstr "Was beschäftigt dich? Ich leite es weiter — und sage dir, wenn ich anderer Meinung bin."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What’s one thing you want to get done in the next few weeks?"
|
||||
msgstr "Was möchtest du in den nächsten Wochen erledigt bekommen?"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "What's personal today?"
|
||||
msgstr "Was ist heute privat dran?"
|
||||
@ -4901,6 +5010,14 @@ msgstr "Um welche Aufgabe geht es?"
|
||||
msgid "When a webhook fires"
|
||||
msgstr "Wenn ein Webhook auslöst"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I check in with you?"
|
||||
msgstr "Wann soll ich mich bei dir melden?"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I stay quiet?"
|
||||
msgstr "Wann soll ich still sein?"
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "When to run"
|
||||
msgstr "Ausführungszeit"
|
||||
@ -4926,10 +5043,6 @@ msgstr "Arbeit"
|
||||
msgid "Work channel open — go ahead."
|
||||
msgstr "Arbeitskanal offen — leg los."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Work style"
|
||||
msgstr "Arbeitsstil"
|
||||
|
||||
#: src/components/stage/OoStage.tsx
|
||||
msgid "Working"
|
||||
msgstr "Arbeitet"
|
||||
@ -4987,8 +5100,11 @@ msgstr "Deine Unterhaltungen und Einstellungen verlassen nie deinen Rechner"
|
||||
msgid "Your email address"
|
||||
msgstr "Deine E-Mail-Adresse"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Your goal"
|
||||
msgstr "Dein Ziel"
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Your name"
|
||||
msgstr "Dein Name"
|
||||
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: en\n"
|
||||
"Project-Id-Version: rakazo-web\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: \n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid " (unread)"
|
||||
@ -21,6 +22,11 @@ msgstr " (unread)"
|
||||
msgid "(optional)"
|
||||
msgstr "(optional)"
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# day} other {# days}}"
|
||||
msgstr "{0, plural, one {# day} other {# days}}"
|
||||
|
||||
#. placeholder {0}: group.entries.length
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "{0, plural, one {# model} other {# models}}"
|
||||
@ -31,6 +37,11 @@ msgstr "{0, plural, one {# model} other {# models}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
msgstr "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
#. placeholder {0}: file.name
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
|
||||
@ -282,10 +293,6 @@ msgstr "1:1 conversation"
|
||||
msgid "1:1 conversation with {title}"
|
||||
msgstr "1:1 conversation with {title}"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "A few quick preferences so your Oo can be better tailored to you."
|
||||
msgstr "A few quick preferences so your Oo can be better tailored to you."
|
||||
|
||||
#: src/pages/shell/bot-picker.tsx
|
||||
msgid "About groups"
|
||||
msgstr "About groups"
|
||||
@ -351,6 +358,7 @@ msgstr "Active model"
|
||||
msgid "Activity"
|
||||
msgstr "Activity"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
msgid "Add"
|
||||
@ -388,6 +396,10 @@ msgstr "Add an HTTPS server URL."
|
||||
msgid "Add another"
|
||||
msgstr "Add another"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add as projects"
|
||||
msgstr "Add as projects"
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Add Executor"
|
||||
msgstr "Add Executor"
|
||||
@ -429,6 +441,10 @@ msgstr "Add server"
|
||||
msgid "Add server URL"
|
||||
msgstr "Add server URL"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add to About you"
|
||||
msgstr "Add to About you"
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Add to routine"
|
||||
msgstr "Add to routine"
|
||||
@ -496,6 +512,26 @@ msgstr "All data stored locally — no external servers"
|
||||
msgid "All quiet"
|
||||
msgstr "All quiet"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in on {weekday} at {time}."
|
||||
msgstr "All set. I’ll check in on {weekday} at {time}."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in today at {time}."
|
||||
msgstr "All set. I’ll check in today at {time}."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in tomorrow at {time}."
|
||||
msgstr "All set. I’ll check in tomorrow at {time}."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’m here whenever you need me."
|
||||
msgstr "All set. I’m here whenever you need me."
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "All spaces"
|
||||
msgstr "All spaces"
|
||||
|
||||
#. placeholder {0}: rule.matchValue
|
||||
#: src/components/ApprovalRulesSettings.tsx
|
||||
msgid "Allow {0} actions without asking"
|
||||
@ -703,10 +739,6 @@ msgstr "Authorize"
|
||||
msgid "Authorize this server so agents can use its tools. A popup opens."
|
||||
msgstr "Authorize this server so agents can use its tools. A popup opens."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Auto-detected or enter manually"
|
||||
msgstr "Auto-detected or enter manually"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Automated senders"
|
||||
msgstr "Automated senders"
|
||||
@ -743,10 +775,6 @@ msgstr "Back to sign in"
|
||||
msgid "Back to timeline"
|
||||
msgstr "Back to timeline"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Balanced"
|
||||
msgstr "Balanced"
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Base URL"
|
||||
msgstr "Base URL"
|
||||
@ -756,6 +784,10 @@ msgstr "Base URL"
|
||||
msgid "Bearer token"
|
||||
msgstr "Bearer token"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr "Blocked in your browser settings"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Booting live desktop…"
|
||||
msgstr "Booting live desktop…"
|
||||
@ -824,6 +856,10 @@ msgstr "Calm audio cues for finished work, voice phases, and handoffs. Respects
|
||||
msgid "Calm helper"
|
||||
msgstr "Calm helper"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Can I send you notifications?"
|
||||
msgstr "Can I send you notifications?"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Can speak"
|
||||
msgstr "Can speak"
|
||||
@ -837,6 +873,7 @@ msgstr "Can't reach the server."
|
||||
#: src/components/ComputerUpdateProgress.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/components/teach/TeachComputerOverlay.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -872,9 +909,9 @@ msgstr "Cancelled"
|
||||
msgid "Cancelled"
|
||||
msgstr "Cancelled"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Casual & friendly"
|
||||
msgstr "Casual & friendly"
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Category"
|
||||
msgstr "Category"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Change password"
|
||||
@ -930,6 +967,10 @@ msgstr "Check in."
|
||||
msgid "Check your email"
|
||||
msgstr "Check your email"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Check-ins"
|
||||
msgstr "Check-ins"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Checking dependencies…"
|
||||
msgstr "Checking dependencies…"
|
||||
@ -1139,7 +1180,6 @@ msgid "Command"
|
||||
msgstr "Command"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Communication style"
|
||||
msgstr "Communication style"
|
||||
|
||||
@ -1300,8 +1340,8 @@ msgstr "Context limit"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Continue"
|
||||
|
||||
@ -1519,6 +1559,10 @@ msgstr "Could not load update status"
|
||||
msgid "Could not load voice settings"
|
||||
msgstr "Could not load voice settings"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Could not look in this folder."
|
||||
msgstr "Could not look in this folder."
|
||||
|
||||
#: src/pages/LocalSettings.tsx
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
|
||||
msgstr "Could not upload {name}. Try again."
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't load"
|
||||
msgstr "Couldn't load"
|
||||
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
|
||||
msgstr "Couldn't load messaging settings"
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't save"
|
||||
msgstr "Couldn't save"
|
||||
@ -1826,6 +1872,10 @@ msgstr "Database"
|
||||
msgid "day"
|
||||
msgstr "day"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Day-to-day work"
|
||||
msgstr "Day-to-day work"
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "days"
|
||||
msgstr "days"
|
||||
@ -1834,10 +1884,6 @@ msgstr "days"
|
||||
msgid "Decline"
|
||||
msgstr "Decline"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Deep-dive focused"
|
||||
msgstr "Deep-dive focused"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Default"
|
||||
msgstr "Default"
|
||||
@ -1938,18 +1984,10 @@ msgstr "Desktop app"
|
||||
msgid "Desktop update"
|
||||
msgstr "Desktop update"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Detailed & explanatory"
|
||||
msgstr "Detailed & explanatory"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Developer tools"
|
||||
msgstr "Developer tools"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Direct & concise"
|
||||
msgstr "Direct & concise"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
msgid "Direct MCP"
|
||||
msgstr "Direct MCP"
|
||||
@ -2073,6 +2111,10 @@ msgstr "e.g. Hivenet Agent, Presales, Timesheets bot"
|
||||
msgid "Earlier message"
|
||||
msgstr "Earlier message"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Edit"
|
||||
msgstr "Edit"
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Edit first"
|
||||
msgstr "Edit first"
|
||||
@ -2122,6 +2164,14 @@ msgstr "Enter an absolute folder path on the server. Git remotes are linked auto
|
||||
msgid "Enter this code at <0>{0}</0>"
|
||||
msgstr "Enter this code at <0>{0}</0>"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Evening"
|
||||
msgstr "Evening"
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Evening check-in"
|
||||
msgstr "Evening check-in"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every"
|
||||
@ -2150,6 +2200,10 @@ msgstr "Every Monday"
|
||||
msgid "Every month"
|
||||
msgstr "Every month"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Every morning"
|
||||
msgstr "Every morning"
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every week"
|
||||
@ -2191,6 +2245,10 @@ msgstr "External conversation"
|
||||
msgid "Extra high"
|
||||
msgstr "Extra high"
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Fact"
|
||||
msgstr "Fact"
|
||||
|
||||
#: src/components/CloudAgentCard.tsx
|
||||
msgid "failed"
|
||||
msgstr "failed"
|
||||
@ -2265,10 +2323,6 @@ msgstr "First {shownRows} rows"
|
||||
msgid "Flag unexpected actions"
|
||||
msgstr "Flag unexpected actions"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Flexible & spontaneous"
|
||||
msgstr "Flexible & spontaneous"
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Fonts loaded locally"
|
||||
msgstr "Fonts loaded locally"
|
||||
@ -2333,6 +2387,7 @@ msgstr "Git event"
|
||||
msgid "Goal"
|
||||
msgstr "Goal"
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
msgid "Goals"
|
||||
@ -2369,6 +2424,7 @@ msgstr "Group updates"
|
||||
msgid "Groups"
|
||||
msgstr "Groups"
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Habits"
|
||||
msgstr "Habits"
|
||||
@ -2443,10 +2499,6 @@ msgstr "Hide password"
|
||||
msgid "High"
|
||||
msgstr "High"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Hobbies & interests"
|
||||
msgstr "Hobbies & interests"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "Hobby"
|
||||
msgstr "Hobby"
|
||||
@ -2460,10 +2512,6 @@ msgstr "hour"
|
||||
msgid "hours"
|
||||
msgstr "hours"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How do you like to work?"
|
||||
msgstr "How do you like to work?"
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "How often"
|
||||
msgstr "How often"
|
||||
@ -2472,10 +2520,6 @@ msgstr "How often"
|
||||
msgid "How should your Oo sound when reading replies aloud?"
|
||||
msgstr "How should your Oo sound when reading replies aloud?"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How should your Oo talk to you?"
|
||||
msgstr "How should your Oo talk to you?"
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "How to check"
|
||||
msgstr "How to check"
|
||||
@ -2524,6 +2568,10 @@ msgstr "In my lane — happy to geek out or just execute, your call."
|
||||
msgid "In progress"
|
||||
msgstr "In progress"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Inbox & email"
|
||||
msgstr "Inbox & email"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Inherit default"
|
||||
msgstr "Inherit default"
|
||||
@ -2747,6 +2795,10 @@ msgstr "Local Server Settings"
|
||||
msgid "Log out"
|
||||
msgstr "Log out"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look"
|
||||
msgstr "Look"
|
||||
|
||||
#. placeholder {0}: index + 1
|
||||
#. placeholder {1}: looks.length
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
@ -2757,10 +2809,18 @@ msgstr "Look {0} of {1}"
|
||||
msgid "Look families"
|
||||
msgstr "Look families"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look for projects here?"
|
||||
msgstr "Look for projects here?"
|
||||
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
msgid "Look variants"
|
||||
msgstr "Look variants"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Looking"
|
||||
msgstr "Looking"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Low"
|
||||
@ -2925,10 +2985,6 @@ msgstr "minute"
|
||||
msgid "minutes"
|
||||
msgstr "minutes"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Mission Control"
|
||||
msgstr "Mission Control"
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -2986,6 +3042,18 @@ msgstr "More computer actions"
|
||||
msgid "More conversations ({hiddenSessionCount})"
|
||||
msgstr "More conversations ({hiddenSessionCount})"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Morning"
|
||||
msgstr "Morning"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Morning and evening"
|
||||
msgstr "Morning and evening"
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Morning brief"
|
||||
msgstr "Morning brief"
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "Move them to your shared memory."
|
||||
msgstr "Move them to your shared memory."
|
||||
@ -2997,7 +3065,6 @@ msgstr "Move to folder"
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
@ -3176,6 +3243,10 @@ msgstr "No messages with {peerBotName} yet."
|
||||
msgid "No model catalog is available."
|
||||
msgstr "No model catalog is available."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "No projects found"
|
||||
msgstr "No projects found"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "No projects yet."
|
||||
msgstr "No projects yet."
|
||||
@ -3233,6 +3304,7 @@ msgstr "Not connected"
|
||||
msgid "Not in the plugin catalog"
|
||||
msgstr "Not in the plugin catalog"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Not now"
|
||||
msgstr "Not now"
|
||||
@ -3245,14 +3317,11 @@ msgstr "Nothing is still running"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Nothing remembered yet"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Nothing specific"
|
||||
msgstr "Nothing specific"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr "Nothing yet"
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
msgstr "Notifications"
|
||||
@ -3266,6 +3335,10 @@ msgstr "Now"
|
||||
msgid "Now using {0}."
|
||||
msgstr "Now using {0}."
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Nudges from your Oo"
|
||||
msgstr "Nudges from your Oo"
|
||||
|
||||
#: src/pages/McpOAuthCallback.tsx
|
||||
msgid "OAuth authorization was cancelled."
|
||||
msgstr "OAuth authorization was cancelled."
|
||||
@ -3299,6 +3372,10 @@ msgstr "on the 1st at {0}"
|
||||
msgid "Onboarding"
|
||||
msgstr "Onboarding"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Once a week"
|
||||
msgstr "Once a week"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "One more look before I answer…"
|
||||
msgstr "One more look before I answer…"
|
||||
@ -3307,6 +3384,10 @@ msgstr "One more look before I answer…"
|
||||
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
msgstr "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Only file and folder names are read."
|
||||
msgstr "Only file and folder names are read."
|
||||
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Open"
|
||||
@ -3559,6 +3640,10 @@ msgstr "Providers"
|
||||
msgid "Pull request"
|
||||
msgstr "Pull request"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Push on this device"
|
||||
msgstr "Push on this device"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -3570,14 +3655,22 @@ msgstr "Queued"
|
||||
msgid "Queued…"
|
||||
msgstr "Queued…"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Quick & pragmatic"
|
||||
msgstr "Quick & pragmatic"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Quiet day so far"
|
||||
msgstr "Quiet day so far"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours"
|
||||
msgstr "Quiet hours"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours end"
|
||||
msgstr "Quiet hours end"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours start"
|
||||
msgstr "Quiet hours start"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Quote"
|
||||
msgstr "Quote"
|
||||
@ -3798,6 +3891,10 @@ msgstr "Replying to {replyName}: “{replyQuote}”"
|
||||
msgid "Reporting from home…"
|
||||
msgstr "Reporting from home…"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Research & writing"
|
||||
msgstr "Research & writing"
|
||||
|
||||
#: src/pages/shell/bot-capabilities.tsx
|
||||
msgid "research, writing"
|
||||
msgstr "research, writing"
|
||||
@ -3938,6 +4035,7 @@ msgstr "Sandbox"
|
||||
#: src/components/AskCard.tsx
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
@ -4040,15 +4138,12 @@ msgstr "Searching…"
|
||||
msgid "Select {0}"
|
||||
msgstr "Select {0}"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Select all that apply"
|
||||
msgstr "Select all that apply"
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Self-hosted (better-auth)"
|
||||
msgstr "Self-hosted (better-auth)"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Send"
|
||||
msgstr "Send"
|
||||
|
||||
@ -4213,7 +4308,9 @@ msgid "Skills"
|
||||
msgstr "Skills"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Skip"
|
||||
msgstr "Skip"
|
||||
@ -4298,6 +4395,10 @@ msgstr "Speaking on or off"
|
||||
msgid "Speaking with intent…"
|
||||
msgstr "Speaking with intent…"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Specialist crew"
|
||||
msgstr "Specialist crew"
|
||||
|
||||
#: src/lib/model-tier-label.ts
|
||||
msgid "Standard"
|
||||
msgstr "Standard"
|
||||
@ -4391,10 +4492,6 @@ msgstr "Stress-testing the book…"
|
||||
msgid "Stronger together — {0} specialists"
|
||||
msgstr "Stronger together — {0} specialists"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Structured & planned"
|
||||
msgstr "Structured & planned"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "subagent"
|
||||
msgstr "subagent"
|
||||
@ -4480,10 +4577,6 @@ msgstr "Teams not enabled"
|
||||
msgid "Tell me what matters. I'll move."
|
||||
msgstr "Tell me what matters. I'll move."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Tell us about yourself"
|
||||
msgstr "Tell us about yourself"
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Test"
|
||||
msgstr "Test"
|
||||
@ -4574,10 +4667,6 @@ msgstr "Time of day"
|
||||
msgid "Time zone"
|
||||
msgstr "Time zone"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Timezone"
|
||||
msgstr "Timezone"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Title"
|
||||
msgstr "Title"
|
||||
@ -4640,6 +4729,10 @@ msgstr "Triage in progress…"
|
||||
msgid "Try again"
|
||||
msgstr "Try again"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Turn on"
|
||||
msgstr "Turn on"
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Type your answer"
|
||||
msgstr "Type your answer"
|
||||
@ -4841,6 +4934,14 @@ msgstr "Webhook"
|
||||
msgid "Weekdays"
|
||||
msgstr "Weekdays"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Weekly"
|
||||
msgstr "Weekly"
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Weekly review"
|
||||
msgstr "Weekly review"
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "What about its memories?"
|
||||
msgstr "What about its memories?"
|
||||
@ -4849,6 +4950,10 @@ msgstr "What about its memories?"
|
||||
msgid "What do you think — react or answer in a sentence."
|
||||
msgstr "What do you think — react or answer in a sentence."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What do you want me on first?"
|
||||
msgstr "What do you want me on first?"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "What feels like you?"
|
||||
msgstr "What feels like you?"
|
||||
@ -4889,6 +4994,10 @@ msgstr "What to return"
|
||||
msgid "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
msgstr "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What’s one thing you want to get done in the next few weeks?"
|
||||
msgstr "What’s one thing you want to get done in the next few weeks?"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "What's personal today?"
|
||||
msgstr "What's personal today?"
|
||||
@ -4901,6 +5010,14 @@ msgstr "What's the work item?"
|
||||
msgid "When a webhook fires"
|
||||
msgstr "When a webhook fires"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I check in with you?"
|
||||
msgstr "When should I check in with you?"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I stay quiet?"
|
||||
msgstr "When should I stay quiet?"
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "When to run"
|
||||
msgstr "When to run"
|
||||
@ -4926,10 +5043,6 @@ msgstr "Work"
|
||||
msgid "Work channel open — go ahead."
|
||||
msgstr "Work channel open — go ahead."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Work style"
|
||||
msgstr "Work style"
|
||||
|
||||
#: src/components/stage/OoStage.tsx
|
||||
msgid "Working"
|
||||
msgstr "Working"
|
||||
@ -4987,8 +5100,11 @@ msgstr "Your conversations and settings never leave your machine"
|
||||
msgid "Your email address"
|
||||
msgstr "Your email address"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Your goal"
|
||||
msgstr "Your goal"
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Your name"
|
||||
msgstr "Your name"
|
||||
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: es\n"
|
||||
"Project-Id-Version: rakazo-web\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: es\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid " (unread)"
|
||||
@ -21,6 +22,11 @@ msgstr " (sin leer)"
|
||||
msgid "(optional)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.entries.length
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "{0, plural, one {# model} other {# models}}"
|
||||
@ -31,6 +37,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: file.name
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
|
||||
@ -282,10 +293,6 @@ msgstr ""
|
||||
msgid "1:1 conversation with {title}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "A few quick preferences so your Oo can be better tailored to you."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-picker.tsx
|
||||
msgid "About groups"
|
||||
msgstr ""
|
||||
@ -351,6 +358,7 @@ msgstr "Modelo activo"
|
||||
msgid "Activity"
|
||||
msgstr "Actividad"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
msgid "Add"
|
||||
@ -388,6 +396,10 @@ msgstr "Agrega una URL de servidor HTTPS."
|
||||
msgid "Add another"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add as projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Add Executor"
|
||||
msgstr "Agregar Executor"
|
||||
@ -429,6 +441,10 @@ msgstr "Agregar servidor"
|
||||
msgid "Add server URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add to About you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Add to routine"
|
||||
msgstr "Agregar a la rutina"
|
||||
@ -496,6 +512,26 @@ msgstr ""
|
||||
msgid "All quiet"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in on {weekday} at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in today at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in tomorrow at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’m here whenever you need me."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "All spaces"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: rule.matchValue
|
||||
#: src/components/ApprovalRulesSettings.tsx
|
||||
msgid "Allow {0} actions without asking"
|
||||
@ -703,10 +739,6 @@ msgstr "Autorizar"
|
||||
msgid "Authorize this server so agents can use its tools. A popup opens."
|
||||
msgstr "Autoriza este servidor para que tus agentes usen sus herramientas. Se abre una ventana emergente."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Auto-detected or enter manually"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Automated senders"
|
||||
msgstr ""
|
||||
@ -743,10 +775,6 @@ msgstr "Volver a iniciar sesión"
|
||||
msgid "Back to timeline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Balanced"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Base URL"
|
||||
msgstr "URL base"
|
||||
@ -756,6 +784,10 @@ msgstr "URL base"
|
||||
msgid "Bearer token"
|
||||
msgstr "Token Bearer"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Booting live desktop…"
|
||||
msgstr "Iniciando escritorio en vivo…"
|
||||
@ -824,6 +856,10 @@ msgstr ""
|
||||
msgid "Calm helper"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Can I send you notifications?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Can speak"
|
||||
msgstr ""
|
||||
@ -837,6 +873,7 @@ msgstr "No se puede alcanzar el servidor."
|
||||
#: src/components/ComputerUpdateProgress.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/components/teach/TeachComputerOverlay.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -863,7 +900,7 @@ msgstr "cancelado"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
msgstr "Cancelado"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/AskCard.tsx
|
||||
@ -872,8 +909,8 @@ msgstr ""
|
||||
msgid "Cancelled"
|
||||
msgstr "Cancelado"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Casual & friendly"
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
@ -930,6 +967,10 @@ msgstr "Regístrate."
|
||||
msgid "Check your email"
|
||||
msgstr "Revisa tu correo"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Check-ins"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Checking dependencies…"
|
||||
msgstr ""
|
||||
@ -1139,7 +1180,6 @@ msgid "Command"
|
||||
msgstr "Comando"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Communication style"
|
||||
msgstr ""
|
||||
|
||||
@ -1300,8 +1340,8 @@ msgstr "Límite de contexto"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Continuar"
|
||||
|
||||
@ -1519,6 +1559,10 @@ msgstr "No se pudo cargar el estado de actualización"
|
||||
msgid "Could not load voice settings"
|
||||
msgstr "No se pudo cargar la configuración de voz"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Could not look in this folder."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/LocalSettings.tsx
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't load"
|
||||
msgstr ""
|
||||
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
|
||||
msgstr "No se pudo cargar la configuración de mensajería"
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
@ -1826,6 +1872,10 @@ msgstr ""
|
||||
msgid "day"
|
||||
msgstr "día"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Day-to-day work"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "days"
|
||||
msgstr "días"
|
||||
@ -1834,10 +1884,6 @@ msgstr "días"
|
||||
msgid "Decline"
|
||||
msgstr "Rechazar"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Deep-dive focused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Default"
|
||||
msgstr "Predeterminado"
|
||||
@ -1938,18 +1984,10 @@ msgstr ""
|
||||
msgid "Desktop update"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Detailed & explanatory"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Developer tools"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Direct & concise"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
msgid "Direct MCP"
|
||||
msgstr ""
|
||||
@ -2073,6 +2111,10 @@ msgstr "p. ej. Hivenet Agent, Presales, bot de hojas de horas"
|
||||
msgid "Earlier message"
|
||||
msgstr "Mensaje anterior"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Edit first"
|
||||
msgstr "Editar primero"
|
||||
@ -2122,6 +2164,14 @@ msgstr ""
|
||||
msgid "Enter this code at <0>{0}</0>"
|
||||
msgstr "Ingresa este código en <0>{0}</0>"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Evening check-in"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every"
|
||||
@ -2150,6 +2200,10 @@ msgstr "Cada lunes"
|
||||
msgid "Every month"
|
||||
msgstr "Cada mes"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Every morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every week"
|
||||
@ -2191,6 +2245,10 @@ msgstr ""
|
||||
msgid "Extra high"
|
||||
msgstr "Extra alto"
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Fact"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CloudAgentCard.tsx
|
||||
msgid "failed"
|
||||
msgstr "fallido"
|
||||
@ -2265,10 +2323,6 @@ msgstr ""
|
||||
msgid "Flag unexpected actions"
|
||||
msgstr "Marcar acciones inesperadas"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Flexible & spontaneous"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Fonts loaded locally"
|
||||
msgstr ""
|
||||
@ -2333,6 +2387,7 @@ msgstr "Evento de Git"
|
||||
msgid "Goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
msgid "Goals"
|
||||
@ -2369,6 +2424,7 @@ msgstr ""
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Habits"
|
||||
msgstr ""
|
||||
@ -2443,10 +2499,6 @@ msgstr "Ocultar contraseña"
|
||||
msgid "High"
|
||||
msgstr "Alto"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Hobbies & interests"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "Hobby"
|
||||
msgstr ""
|
||||
@ -2460,10 +2512,6 @@ msgstr "hora"
|
||||
msgid "hours"
|
||||
msgstr "horas"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How do you like to work?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "How often"
|
||||
msgstr "Con qué frecuencia"
|
||||
@ -2472,10 +2520,6 @@ msgstr "Con qué frecuencia"
|
||||
msgid "How should your Oo sound when reading replies aloud?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How should your Oo talk to you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "How to check"
|
||||
msgstr "Cómo comprobar"
|
||||
@ -2524,6 +2568,10 @@ msgstr ""
|
||||
msgid "In progress"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Inbox & email"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Inherit default"
|
||||
msgstr "Heredar predeterminado"
|
||||
@ -2747,6 +2795,10 @@ msgstr ""
|
||||
msgid "Log out"
|
||||
msgstr "Cerrar sesión"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: index + 1
|
||||
#. placeholder {1}: looks.length
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
@ -2757,10 +2809,18 @@ msgstr ""
|
||||
msgid "Look families"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look for projects here?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
msgid "Look variants"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Looking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Low"
|
||||
@ -2925,10 +2985,6 @@ msgstr "minuto"
|
||||
msgid "minutes"
|
||||
msgstr "minutos"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Mission Control"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -2986,6 +3042,18 @@ msgstr "Más acciones de computadora"
|
||||
msgid "More conversations ({hiddenSessionCount})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Morning and evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Morning brief"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "Move them to your shared memory."
|
||||
msgstr "Muévelas a tu memoria compartida."
|
||||
@ -2997,7 +3065,6 @@ msgstr ""
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
@ -3176,6 +3243,10 @@ msgstr "Aún no hay mensajes con {peerBotName}."
|
||||
msgid "No model catalog is available."
|
||||
msgstr "No hay un catálogo de modelos disponible."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "No projects found"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "No projects yet."
|
||||
msgstr ""
|
||||
@ -3233,6 +3304,7 @@ msgstr "No conectado"
|
||||
msgid "Not in the plugin catalog"
|
||||
msgstr "No está en el catálogo de plugins"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Not now"
|
||||
msgstr "Ahora no"
|
||||
@ -3245,14 +3317,11 @@ msgstr "No queda nada en ejecución"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Aún no hay recuerdos"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Nothing specific"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
msgstr "Notificaciones"
|
||||
@ -3266,6 +3335,10 @@ msgstr "Ahora"
|
||||
msgid "Now using {0}."
|
||||
msgstr "Ahora usando {0}."
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Nudges from your Oo"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/McpOAuthCallback.tsx
|
||||
msgid "OAuth authorization was cancelled."
|
||||
msgstr "Se canceló la autorización OAuth."
|
||||
@ -3299,6 +3372,10 @@ msgstr "el día 1 a las {0}"
|
||||
msgid "Onboarding"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Once a week"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "One more look before I answer…"
|
||||
msgstr ""
|
||||
@ -3307,6 +3384,10 @@ msgstr ""
|
||||
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Only file and folder names are read."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Open"
|
||||
@ -3559,6 +3640,10 @@ msgstr "Proveedores"
|
||||
msgid "Pull request"
|
||||
msgstr "Solicitud de incorporación"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Push on this device"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -3570,14 +3655,22 @@ msgstr "En cola"
|
||||
msgid "Queued…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Quick & pragmatic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Quiet day so far"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours end"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours start"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Quote"
|
||||
msgstr ""
|
||||
@ -3798,6 +3891,10 @@ msgstr ""
|
||||
msgid "Reporting from home…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Research & writing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-capabilities.tsx
|
||||
msgid "research, writing"
|
||||
msgstr ""
|
||||
@ -3938,6 +4035,7 @@ msgstr ""
|
||||
#: src/components/AskCard.tsx
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
@ -4040,15 +4138,12 @@ msgstr "Buscando…"
|
||||
msgid "Select {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Select all that apply"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Self-hosted (better-auth)"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Send"
|
||||
msgstr "Enviar"
|
||||
|
||||
@ -4213,7 +4308,9 @@ msgid "Skills"
|
||||
msgstr "Habilidades"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Skip"
|
||||
msgstr "Omitir"
|
||||
@ -4298,6 +4395,10 @@ msgstr ""
|
||||
msgid "Speaking with intent…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Specialist crew"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/model-tier-label.ts
|
||||
msgid "Standard"
|
||||
msgstr ""
|
||||
@ -4391,10 +4492,6 @@ msgstr ""
|
||||
msgid "Stronger together — {0} specialists"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Structured & planned"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "subagent"
|
||||
msgstr "subagente"
|
||||
@ -4480,10 +4577,6 @@ msgstr ""
|
||||
msgid "Tell me what matters. I'll move."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Tell us about yourself"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Test"
|
||||
msgstr "Probar"
|
||||
@ -4574,10 +4667,6 @@ msgstr "Hora del día"
|
||||
msgid "Time zone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Timezone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Title"
|
||||
msgstr "Título"
|
||||
@ -4640,6 +4729,10 @@ msgstr ""
|
||||
msgid "Try again"
|
||||
msgstr "Intentarlo de nuevo"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Turn on"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Type your answer"
|
||||
msgstr "Escribe tu respuesta"
|
||||
@ -4841,6 +4934,14 @@ msgstr "Webhook"
|
||||
msgid "Weekdays"
|
||||
msgstr "Días laborables"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Weekly"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Weekly review"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "What about its memories?"
|
||||
msgstr "¿Qué hay de sus memorias?"
|
||||
@ -4849,6 +4950,10 @@ msgstr "¿Qué hay de sus memorias?"
|
||||
msgid "What do you think — react or answer in a sentence."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What do you want me on first?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "What feels like you?"
|
||||
msgstr ""
|
||||
@ -4889,6 +4994,10 @@ msgstr "Qué devolver"
|
||||
msgid "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What’s one thing you want to get done in the next few weeks?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "What's personal today?"
|
||||
msgstr ""
|
||||
@ -4901,6 +5010,14 @@ msgstr ""
|
||||
msgid "When a webhook fires"
|
||||
msgstr "Cuando se dispara un webhook"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I check in with you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I stay quiet?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "When to run"
|
||||
msgstr "Cuándo ejecutar"
|
||||
@ -4926,10 +5043,6 @@ msgstr ""
|
||||
msgid "Work channel open — go ahead."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Work style"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/stage/OoStage.tsx
|
||||
msgid "Working"
|
||||
msgstr ""
|
||||
@ -4945,7 +5058,7 @@ msgstr ""
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Working…"
|
||||
msgstr ""
|
||||
msgstr "Trabajando…"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
@ -4987,8 +5100,11 @@ msgstr ""
|
||||
msgid "Your email address"
|
||||
msgstr "Tu dirección de correo"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Your goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Your name"
|
||||
msgstr "Tu nombre"
|
||||
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: hi\n"
|
||||
"Project-Id-Version: rakazo-web\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: hi\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: \n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid " (unread)"
|
||||
@ -21,6 +22,11 @@ msgstr " (अपठित)"
|
||||
msgid "(optional)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.entries.length
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "{0, plural, one {# model} other {# models}}"
|
||||
@ -31,6 +37,11 @@ msgstr "{0, plural, one {# मॉडल} other {# मॉडल}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: file.name
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
|
||||
@ -282,10 +293,6 @@ msgstr ""
|
||||
msgid "1:1 conversation with {title}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "A few quick preferences so your Oo can be better tailored to you."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-picker.tsx
|
||||
msgid "About groups"
|
||||
msgstr ""
|
||||
@ -351,6 +358,7 @@ msgstr "सक्रिय मॉडल"
|
||||
msgid "Activity"
|
||||
msgstr "गतिविधि"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
msgid "Add"
|
||||
@ -388,6 +396,10 @@ msgstr "एक HTTPS सर्वर URL दर्ज करें।"
|
||||
msgid "Add another"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add as projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Add Executor"
|
||||
msgstr "Executor जोड़ें"
|
||||
@ -429,6 +441,10 @@ msgstr "सर्वर जोड़ें"
|
||||
msgid "Add server URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add to About you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Add to routine"
|
||||
msgstr "रूटीन में जोड़ें"
|
||||
@ -496,6 +512,26 @@ msgstr ""
|
||||
msgid "All quiet"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in on {weekday} at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in today at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in tomorrow at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’m here whenever you need me."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "All spaces"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: rule.matchValue
|
||||
#: src/components/ApprovalRulesSettings.tsx
|
||||
msgid "Allow {0} actions without asking"
|
||||
@ -703,10 +739,6 @@ msgstr "ऑथराइज़ करें"
|
||||
msgid "Authorize this server so agents can use its tools. A popup opens."
|
||||
msgstr "एजेंटों को इसके टूल का उपयोग करने देने के लिए इस सर्वर को ऑथराइज़ करें। एक पॉपअप खुलता है।"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Auto-detected or enter manually"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Automated senders"
|
||||
msgstr ""
|
||||
@ -743,10 +775,6 @@ msgstr "साइन इन पर वापस जाएँ"
|
||||
msgid "Back to timeline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Balanced"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Base URL"
|
||||
msgstr "बेस URL"
|
||||
@ -756,6 +784,10 @@ msgstr "बेस URL"
|
||||
msgid "Bearer token"
|
||||
msgstr "बियरर टोकन"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Booting live desktop…"
|
||||
msgstr "लाइव डेस्कटॉप शुरू हो रहा है…"
|
||||
@ -824,6 +856,10 @@ msgstr ""
|
||||
msgid "Calm helper"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Can I send you notifications?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Can speak"
|
||||
msgstr ""
|
||||
@ -837,6 +873,7 @@ msgstr "सर्वर तक नहीं पहुंचा जा सका
|
||||
#: src/components/ComputerUpdateProgress.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/components/teach/TeachComputerOverlay.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -863,7 +900,7 @@ msgstr "रद्द"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
msgstr "रद्द किया गया"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/AskCard.tsx
|
||||
@ -872,8 +909,8 @@ msgstr ""
|
||||
msgid "Cancelled"
|
||||
msgstr "रद्द किया गया"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Casual & friendly"
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
@ -930,6 +967,10 @@ msgstr "जांच करें।"
|
||||
msgid "Check your email"
|
||||
msgstr "अपना ईमेल देखें"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Check-ins"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Checking dependencies…"
|
||||
msgstr ""
|
||||
@ -1139,7 +1180,6 @@ msgid "Command"
|
||||
msgstr "कमांड"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Communication style"
|
||||
msgstr ""
|
||||
|
||||
@ -1300,8 +1340,8 @@ msgstr "कॉन्टेक्स्ट सीमा"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Continue"
|
||||
msgstr "जारी रखें"
|
||||
|
||||
@ -1519,6 +1559,10 @@ msgstr ""
|
||||
msgid "Could not load voice settings"
|
||||
msgstr "आवाज़ सेटिंग्स लोड नहीं हो सकीं"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Could not look in this folder."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/LocalSettings.tsx
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't load"
|
||||
msgstr ""
|
||||
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
@ -1826,6 +1872,10 @@ msgstr ""
|
||||
msgid "day"
|
||||
msgstr "दिन"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Day-to-day work"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "days"
|
||||
msgstr "दिन"
|
||||
@ -1834,10 +1884,6 @@ msgstr "दिन"
|
||||
msgid "Decline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Deep-dive focused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Default"
|
||||
msgstr "डिफ़ॉल्ट"
|
||||
@ -1938,18 +1984,10 @@ msgstr ""
|
||||
msgid "Desktop update"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Detailed & explanatory"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Developer tools"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Direct & concise"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
msgid "Direct MCP"
|
||||
msgstr ""
|
||||
@ -2073,6 +2111,10 @@ msgstr "उदा. Hivenet Agent, Presales, टाइमशीट बॉट"
|
||||
msgid "Earlier message"
|
||||
msgstr "पुराना संदेश"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Edit first"
|
||||
msgstr "पहले संपादित करें"
|
||||
@ -2122,6 +2164,14 @@ msgstr ""
|
||||
msgid "Enter this code at <0>{0}</0>"
|
||||
msgstr "यह कोड <0>{0}</0> पर दर्ज करें"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Evening check-in"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every"
|
||||
@ -2150,6 +2200,10 @@ msgstr "हर सोमवार"
|
||||
msgid "Every month"
|
||||
msgstr "हर महीने"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Every morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every week"
|
||||
@ -2191,6 +2245,10 @@ msgstr ""
|
||||
msgid "Extra high"
|
||||
msgstr "अतिरिक्त उच्च"
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Fact"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CloudAgentCard.tsx
|
||||
msgid "failed"
|
||||
msgstr "असफल"
|
||||
@ -2265,10 +2323,6 @@ msgstr ""
|
||||
msgid "Flag unexpected actions"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Flexible & spontaneous"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Fonts loaded locally"
|
||||
msgstr ""
|
||||
@ -2333,6 +2387,7 @@ msgstr ""
|
||||
msgid "Goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
msgid "Goals"
|
||||
@ -2369,6 +2424,7 @@ msgstr ""
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Habits"
|
||||
msgstr ""
|
||||
@ -2443,10 +2499,6 @@ msgstr ""
|
||||
msgid "High"
|
||||
msgstr "उच्च"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Hobbies & interests"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "Hobby"
|
||||
msgstr ""
|
||||
@ -2460,10 +2512,6 @@ msgstr "घंटा"
|
||||
msgid "hours"
|
||||
msgstr "घंटे"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How do you like to work?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "How often"
|
||||
msgstr "कितनी बार"
|
||||
@ -2472,10 +2520,6 @@ msgstr "कितनी बार"
|
||||
msgid "How should your Oo sound when reading replies aloud?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How should your Oo talk to you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "How to check"
|
||||
msgstr "जांच कैसे करें"
|
||||
@ -2524,6 +2568,10 @@ msgstr ""
|
||||
msgid "In progress"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Inbox & email"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Inherit default"
|
||||
msgstr "डिफ़ॉल्ट का पालन करें"
|
||||
@ -2747,6 +2795,10 @@ msgstr ""
|
||||
msgid "Log out"
|
||||
msgstr "लॉग आउट"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: index + 1
|
||||
#. placeholder {1}: looks.length
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
@ -2757,10 +2809,18 @@ msgstr ""
|
||||
msgid "Look families"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look for projects here?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
msgid "Look variants"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Looking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Low"
|
||||
@ -2925,10 +2985,6 @@ msgstr "मिनट"
|
||||
msgid "minutes"
|
||||
msgstr "मिनट"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Mission Control"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -2986,6 +3042,18 @@ msgstr ""
|
||||
msgid "More conversations ({hiddenSessionCount})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Morning and evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Morning brief"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "Move them to your shared memory."
|
||||
msgstr "उन्हें अपनी साझा मेमोरी में ले जाएं।"
|
||||
@ -2997,7 +3065,6 @@ msgstr ""
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
@ -3176,6 +3243,10 @@ msgstr "{peerBotName} के साथ अभी कोई संदेश न
|
||||
msgid "No model catalog is available."
|
||||
msgstr "कोई मॉडल कैटलॉग उपलब्ध नहीं है।"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "No projects found"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "No projects yet."
|
||||
msgstr ""
|
||||
@ -3233,6 +3304,7 @@ msgstr "कनेक्टेड नहीं"
|
||||
msgid "Not in the plugin catalog"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Not now"
|
||||
msgstr "अभी नहीं"
|
||||
@ -3245,14 +3317,11 @@ msgstr "कुछ भी नहीं चल रहा"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "अभी तक कुछ याद नहीं किया गया"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Nothing specific"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
msgstr "सूचनाएं"
|
||||
@ -3266,6 +3335,10 @@ msgstr "अभी"
|
||||
msgid "Now using {0}."
|
||||
msgstr "अब {0} उपयोग में है।"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Nudges from your Oo"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/McpOAuthCallback.tsx
|
||||
msgid "OAuth authorization was cancelled."
|
||||
msgstr "OAuth ऑथराइज़ेशन रद्द कर दिया गया।"
|
||||
@ -3299,6 +3372,10 @@ msgstr "1 तारीख को {0} बजे"
|
||||
msgid "Onboarding"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Once a week"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "One more look before I answer…"
|
||||
msgstr ""
|
||||
@ -3307,6 +3384,10 @@ msgstr ""
|
||||
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Only file and folder names are read."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Open"
|
||||
@ -3559,6 +3640,10 @@ msgstr "प्रोवाइडर्स"
|
||||
msgid "Pull request"
|
||||
msgstr "पुल अनुरोध"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Push on this device"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -3570,14 +3655,22 @@ msgstr "कतार में"
|
||||
msgid "Queued…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Quick & pragmatic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Quiet day so far"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours end"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours start"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Quote"
|
||||
msgstr ""
|
||||
@ -3798,6 +3891,10 @@ msgstr ""
|
||||
msgid "Reporting from home…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Research & writing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-capabilities.tsx
|
||||
msgid "research, writing"
|
||||
msgstr ""
|
||||
@ -3938,6 +4035,7 @@ msgstr ""
|
||||
#: src/components/AskCard.tsx
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
@ -4040,15 +4138,12 @@ msgstr "खोजा जा रहा है…"
|
||||
msgid "Select {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Select all that apply"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Self-hosted (better-auth)"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Send"
|
||||
msgstr "भेजें"
|
||||
|
||||
@ -4213,7 +4308,9 @@ msgid "Skills"
|
||||
msgstr "स्किल्स"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Skip"
|
||||
msgstr "छोड़ें"
|
||||
@ -4298,6 +4395,10 @@ msgstr ""
|
||||
msgid "Speaking with intent…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Specialist crew"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/model-tier-label.ts
|
||||
msgid "Standard"
|
||||
msgstr ""
|
||||
@ -4391,10 +4492,6 @@ msgstr ""
|
||||
msgid "Stronger together — {0} specialists"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Structured & planned"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "subagent"
|
||||
msgstr "सबएजेंट"
|
||||
@ -4480,10 +4577,6 @@ msgstr ""
|
||||
msgid "Tell me what matters. I'll move."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Tell us about yourself"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Test"
|
||||
msgstr "टेस्ट करें"
|
||||
@ -4574,10 +4667,6 @@ msgstr "दिन का समय"
|
||||
msgid "Time zone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Timezone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Title"
|
||||
msgstr "शीर्षक"
|
||||
@ -4640,6 +4729,10 @@ msgstr ""
|
||||
msgid "Try again"
|
||||
msgstr "फिर से कोशिश करें"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Turn on"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Type your answer"
|
||||
msgstr "अपना उत्तर लिखें"
|
||||
@ -4841,6 +4934,14 @@ msgstr ""
|
||||
msgid "Weekdays"
|
||||
msgstr "कार्यदिवस"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Weekly"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Weekly review"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "What about its memories?"
|
||||
msgstr "इसकी मेमोरी का क्या करें?"
|
||||
@ -4849,6 +4950,10 @@ msgstr "इसकी मेमोरी का क्या करें?"
|
||||
msgid "What do you think — react or answer in a sentence."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What do you want me on first?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "What feels like you?"
|
||||
msgstr ""
|
||||
@ -4889,6 +4994,10 @@ msgstr "क्या लौटाना है"
|
||||
msgid "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What’s one thing you want to get done in the next few weeks?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "What's personal today?"
|
||||
msgstr ""
|
||||
@ -4901,6 +5010,14 @@ msgstr ""
|
||||
msgid "When a webhook fires"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I check in with you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I stay quiet?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "When to run"
|
||||
msgstr "कब चलाएं"
|
||||
@ -4926,10 +5043,6 @@ msgstr ""
|
||||
msgid "Work channel open — go ahead."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Work style"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/stage/OoStage.tsx
|
||||
msgid "Working"
|
||||
msgstr ""
|
||||
@ -4945,7 +5058,7 @@ msgstr ""
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Working…"
|
||||
msgstr ""
|
||||
msgstr "काम चल रहा है…"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
@ -4987,8 +5100,11 @@ msgstr ""
|
||||
msgid "Your email address"
|
||||
msgstr "आपका ईमेल पता"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Your goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Your name"
|
||||
msgstr "आपका नाम"
|
||||
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: ko\n"
|
||||
"Project-Id-Version: rakazo-web\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: ko\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: \n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid " (unread)"
|
||||
@ -21,6 +22,11 @@ msgstr " (읽지 않음)"
|
||||
msgid "(optional)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.entries.length
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "{0, plural, one {# model} other {# models}}"
|
||||
@ -31,6 +37,11 @@ msgstr "{0, plural, one {모델 #개} other {모델 #개}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: file.name
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
|
||||
@ -282,10 +293,6 @@ msgstr ""
|
||||
msgid "1:1 conversation with {title}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "A few quick preferences so your Oo can be better tailored to you."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-picker.tsx
|
||||
msgid "About groups"
|
||||
msgstr ""
|
||||
@ -351,6 +358,7 @@ msgstr "현재 모델"
|
||||
msgid "Activity"
|
||||
msgstr "활동"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
msgid "Add"
|
||||
@ -388,6 +396,10 @@ msgstr "HTTPS 서버 URL을 입력하세요."
|
||||
msgid "Add another"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add as projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Add Executor"
|
||||
msgstr "Executor 추가"
|
||||
@ -429,6 +441,10 @@ msgstr "서버 추가"
|
||||
msgid "Add server URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add to About you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Add to routine"
|
||||
msgstr "자동 실행에 추가"
|
||||
@ -496,6 +512,26 @@ msgstr ""
|
||||
msgid "All quiet"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in on {weekday} at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in today at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in tomorrow at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’m here whenever you need me."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "All spaces"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: rule.matchValue
|
||||
#: src/components/ApprovalRulesSettings.tsx
|
||||
msgid "Allow {0} actions without asking"
|
||||
@ -703,10 +739,6 @@ msgstr "인증하기"
|
||||
msgid "Authorize this server so agents can use its tools. A popup opens."
|
||||
msgstr "에이전트가 도구를 사용할 수 있도록 이 서버에 권한을 부여하세요. 팝업이 열립니다."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Auto-detected or enter manually"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Automated senders"
|
||||
msgstr ""
|
||||
@ -743,10 +775,6 @@ msgstr "로그인으로 돌아가기"
|
||||
msgid "Back to timeline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Balanced"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Base URL"
|
||||
msgstr "서버 주소"
|
||||
@ -756,6 +784,10 @@ msgstr "서버 주소"
|
||||
msgid "Bearer token"
|
||||
msgstr "Bearer 토큰"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Booting live desktop…"
|
||||
msgstr "라이브 데스크톱 시작 중…"
|
||||
@ -824,6 +856,10 @@ msgstr ""
|
||||
msgid "Calm helper"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Can I send you notifications?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Can speak"
|
||||
msgstr ""
|
||||
@ -837,6 +873,7 @@ msgstr "서버에 연결할 수 없습니다."
|
||||
#: src/components/ComputerUpdateProgress.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/components/teach/TeachComputerOverlay.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -863,7 +900,7 @@ msgstr "취소됨"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
msgstr "취소됨"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/AskCard.tsx
|
||||
@ -872,8 +909,8 @@ msgstr ""
|
||||
msgid "Cancelled"
|
||||
msgstr "취소됨"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Casual & friendly"
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
@ -930,6 +967,10 @@ msgstr "확인해 주세요."
|
||||
msgid "Check your email"
|
||||
msgstr "이메일을 확인하세요"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Check-ins"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Checking dependencies…"
|
||||
msgstr ""
|
||||
@ -1139,7 +1180,6 @@ msgid "Command"
|
||||
msgstr "실행 명령"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Communication style"
|
||||
msgstr ""
|
||||
|
||||
@ -1300,8 +1340,8 @@ msgstr "컨텍스트 한도"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Continue"
|
||||
msgstr "계속"
|
||||
|
||||
@ -1519,6 +1559,10 @@ msgstr "업데이트 상태를 불러올 수 없습니다"
|
||||
msgid "Could not load voice settings"
|
||||
msgstr "음성 설정을 불러오지 못했습니다."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Could not look in this folder."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/LocalSettings.tsx
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't load"
|
||||
msgstr ""
|
||||
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
|
||||
msgstr "메시징 설정을 불러올 수 없습니다"
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
@ -1826,6 +1872,10 @@ msgstr ""
|
||||
msgid "day"
|
||||
msgstr "일"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Day-to-day work"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "days"
|
||||
msgstr "일"
|
||||
@ -1834,10 +1884,6 @@ msgstr "일"
|
||||
msgid "Decline"
|
||||
msgstr "거절"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Deep-dive focused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Default"
|
||||
msgstr "기본값"
|
||||
@ -1938,18 +1984,10 @@ msgstr ""
|
||||
msgid "Desktop update"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Detailed & explanatory"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Developer tools"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Direct & concise"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
msgid "Direct MCP"
|
||||
msgstr ""
|
||||
@ -2073,6 +2111,10 @@ msgstr "예: Hivenet Agent, Presales, 근무표 봇"
|
||||
msgid "Earlier message"
|
||||
msgstr "이전 메시지"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Edit first"
|
||||
msgstr "수정 후 보내기"
|
||||
@ -2122,6 +2164,14 @@ msgstr ""
|
||||
msgid "Enter this code at <0>{0}</0>"
|
||||
msgstr "<0>{0}</0>에서 이 코드를 입력하세요"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Evening check-in"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every"
|
||||
@ -2150,6 +2200,10 @@ msgstr "매주 월요일"
|
||||
msgid "Every month"
|
||||
msgstr "매월"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Every morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every week"
|
||||
@ -2191,6 +2245,10 @@ msgstr ""
|
||||
msgid "Extra high"
|
||||
msgstr "매우 높음"
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Fact"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CloudAgentCard.tsx
|
||||
msgid "failed"
|
||||
msgstr "실패"
|
||||
@ -2265,10 +2323,6 @@ msgstr ""
|
||||
msgid "Flag unexpected actions"
|
||||
msgstr "예상하지 못한 작업 표시"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Flexible & spontaneous"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Fonts loaded locally"
|
||||
msgstr ""
|
||||
@ -2333,6 +2387,7 @@ msgstr "Git 이벤트"
|
||||
msgid "Goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
msgid "Goals"
|
||||
@ -2369,6 +2424,7 @@ msgstr ""
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Habits"
|
||||
msgstr ""
|
||||
@ -2443,10 +2499,6 @@ msgstr "비밀번호 숨기기"
|
||||
msgid "High"
|
||||
msgstr "높음"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Hobbies & interests"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "Hobby"
|
||||
msgstr ""
|
||||
@ -2460,10 +2512,6 @@ msgstr "시간"
|
||||
msgid "hours"
|
||||
msgstr "시간"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How do you like to work?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "How often"
|
||||
msgstr "실행 주기"
|
||||
@ -2472,10 +2520,6 @@ msgstr "실행 주기"
|
||||
msgid "How should your Oo sound when reading replies aloud?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How should your Oo talk to you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "How to check"
|
||||
msgstr "완료 확인 방법"
|
||||
@ -2524,6 +2568,10 @@ msgstr ""
|
||||
msgid "In progress"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Inbox & email"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Inherit default"
|
||||
msgstr "기본 설정 사용"
|
||||
@ -2747,6 +2795,10 @@ msgstr ""
|
||||
msgid "Log out"
|
||||
msgstr "로그아웃"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: index + 1
|
||||
#. placeholder {1}: looks.length
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
@ -2757,10 +2809,18 @@ msgstr ""
|
||||
msgid "Look families"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look for projects here?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
msgid "Look variants"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Looking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Low"
|
||||
@ -2925,10 +2985,6 @@ msgstr "분"
|
||||
msgid "minutes"
|
||||
msgstr "분"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Mission Control"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -2986,6 +3042,18 @@ msgstr "컴퓨터 작업 더보기"
|
||||
msgid "More conversations ({hiddenSessionCount})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Morning and evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Morning brief"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "Move them to your shared memory."
|
||||
msgstr "공용 기억으로 옮깁니다."
|
||||
@ -2997,7 +3065,6 @@ msgstr ""
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
@ -3176,6 +3243,10 @@ msgstr "{peerBotName}와의 메시지가 아직 없습니다."
|
||||
msgid "No model catalog is available."
|
||||
msgstr "사용 가능한 모델 목록이 없습니다."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "No projects found"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "No projects yet."
|
||||
msgstr ""
|
||||
@ -3233,6 +3304,7 @@ msgstr "연결되지 않음"
|
||||
msgid "Not in the plugin catalog"
|
||||
msgstr "플러그인 카탈로그에 없음"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Not now"
|
||||
msgstr "나중에"
|
||||
@ -3245,14 +3317,11 @@ msgstr "실행 중인 작업 없음"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "아직 기억한 내용이 없습니다"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Nothing specific"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
msgstr "알림"
|
||||
@ -3266,6 +3335,10 @@ msgstr "지금"
|
||||
msgid "Now using {0}."
|
||||
msgstr "이제 {0} 모델을 사용합니다."
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Nudges from your Oo"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/McpOAuthCallback.tsx
|
||||
msgid "OAuth authorization was cancelled."
|
||||
msgstr "OAuth 인증이 취소되었습니다."
|
||||
@ -3299,6 +3372,10 @@ msgstr "매월 1일 {0}에"
|
||||
msgid "Onboarding"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Once a week"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "One more look before I answer…"
|
||||
msgstr ""
|
||||
@ -3307,6 +3384,10 @@ msgstr ""
|
||||
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Only file and folder names are read."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Open"
|
||||
@ -3559,6 +3640,10 @@ msgstr "서비스"
|
||||
msgid "Pull request"
|
||||
msgstr "풀 리퀘스트"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Push on this device"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -3570,14 +3655,22 @@ msgstr "대기 중"
|
||||
msgid "Queued…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Quick & pragmatic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Quiet day so far"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours end"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours start"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Quote"
|
||||
msgstr ""
|
||||
@ -3798,6 +3891,10 @@ msgstr ""
|
||||
msgid "Reporting from home…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Research & writing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-capabilities.tsx
|
||||
msgid "research, writing"
|
||||
msgstr ""
|
||||
@ -3938,6 +4035,7 @@ msgstr ""
|
||||
#: src/components/AskCard.tsx
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
@ -4040,15 +4138,12 @@ msgstr "검색 중…"
|
||||
msgid "Select {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Select all that apply"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Self-hosted (better-auth)"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Send"
|
||||
msgstr "보내기"
|
||||
|
||||
@ -4213,7 +4308,9 @@ msgid "Skills"
|
||||
msgstr "스킬"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Skip"
|
||||
msgstr "건너뛰기"
|
||||
@ -4298,6 +4395,10 @@ msgstr ""
|
||||
msgid "Speaking with intent…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Specialist crew"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/model-tier-label.ts
|
||||
msgid "Standard"
|
||||
msgstr ""
|
||||
@ -4391,10 +4492,6 @@ msgstr ""
|
||||
msgid "Stronger together — {0} specialists"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Structured & planned"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "subagent"
|
||||
msgstr "보조 에이전트"
|
||||
@ -4480,10 +4577,6 @@ msgstr ""
|
||||
msgid "Tell me what matters. I'll move."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Tell us about yourself"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Test"
|
||||
msgstr "시험 실행"
|
||||
@ -4574,10 +4667,6 @@ msgstr "실행 시각"
|
||||
msgid "Time zone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Timezone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Title"
|
||||
msgstr "역할"
|
||||
@ -4640,6 +4729,10 @@ msgstr ""
|
||||
msgid "Try again"
|
||||
msgstr "다시 시도"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Turn on"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Type your answer"
|
||||
msgstr "답변을 입력하세요"
|
||||
@ -4841,6 +4934,14 @@ msgstr "웹훅"
|
||||
msgid "Weekdays"
|
||||
msgstr "주중"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Weekly"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Weekly review"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "What about its memories?"
|
||||
msgstr "이 Bot의 기억은 어떻게 할까요?"
|
||||
@ -4849,6 +4950,10 @@ msgstr "이 Bot의 기억은 어떻게 할까요?"
|
||||
msgid "What do you think — react or answer in a sentence."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What do you want me on first?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "What feels like you?"
|
||||
msgstr ""
|
||||
@ -4889,6 +4994,10 @@ msgstr "완료 후 전달할 결과"
|
||||
msgid "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What’s one thing you want to get done in the next few weeks?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "What's personal today?"
|
||||
msgstr ""
|
||||
@ -4901,6 +5010,14 @@ msgstr ""
|
||||
msgid "When a webhook fires"
|
||||
msgstr "웹훅이 실행될 때"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I check in with you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I stay quiet?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "When to run"
|
||||
msgstr "실행 시간"
|
||||
@ -4926,10 +5043,6 @@ msgstr ""
|
||||
msgid "Work channel open — go ahead."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Work style"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/stage/OoStage.tsx
|
||||
msgid "Working"
|
||||
msgstr ""
|
||||
@ -4945,7 +5058,7 @@ msgstr ""
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Working…"
|
||||
msgstr ""
|
||||
msgstr "처리 중…"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
@ -4987,8 +5100,11 @@ msgstr ""
|
||||
msgid "Your email address"
|
||||
msgstr "이메일 주소"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Your goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Your name"
|
||||
msgstr "이름"
|
||||
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: pt-BR\n"
|
||||
"Project-Id-Version: rakazo-web\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: pt-BR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid " (unread)"
|
||||
@ -21,6 +22,11 @@ msgstr " (não lido)"
|
||||
msgid "(optional)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.entries.length
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "{0, plural, one {# model} other {# models}}"
|
||||
@ -31,6 +37,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: file.name
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
|
||||
@ -282,10 +293,6 @@ msgstr ""
|
||||
msgid "1:1 conversation with {title}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "A few quick preferences so your Oo can be better tailored to you."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-picker.tsx
|
||||
msgid "About groups"
|
||||
msgstr ""
|
||||
@ -351,6 +358,7 @@ msgstr "Modelo ativo"
|
||||
msgid "Activity"
|
||||
msgstr "Atividade"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
msgid "Add"
|
||||
@ -388,6 +396,10 @@ msgstr "Adicione um URL do servidor HTTPS."
|
||||
msgid "Add another"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add as projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Add Executor"
|
||||
msgstr "Adicionar Executor"
|
||||
@ -429,6 +441,10 @@ msgstr "Adicionar servidor"
|
||||
msgid "Add server URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add to About you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Add to routine"
|
||||
msgstr "Adicionar à rotina"
|
||||
@ -496,6 +512,26 @@ msgstr ""
|
||||
msgid "All quiet"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in on {weekday} at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in today at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in tomorrow at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’m here whenever you need me."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "All spaces"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: rule.matchValue
|
||||
#: src/components/ApprovalRulesSettings.tsx
|
||||
msgid "Allow {0} actions without asking"
|
||||
@ -703,10 +739,6 @@ msgstr "Autorizar"
|
||||
msgid "Authorize this server so agents can use its tools. A popup opens."
|
||||
msgstr "Autorize este servidor para que os agentes usem suas ferramentas. Uma janela pop-up será aberta."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Auto-detected or enter manually"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Automated senders"
|
||||
msgstr ""
|
||||
@ -743,10 +775,6 @@ msgstr "Voltar para o login"
|
||||
msgid "Back to timeline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Balanced"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Base URL"
|
||||
msgstr "URL Base"
|
||||
@ -756,6 +784,10 @@ msgstr "URL Base"
|
||||
msgid "Bearer token"
|
||||
msgstr "Token portador"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Booting live desktop…"
|
||||
msgstr "Iniciando a área de trabalho ao vivo..."
|
||||
@ -824,6 +856,10 @@ msgstr ""
|
||||
msgid "Calm helper"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Can I send you notifications?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Can speak"
|
||||
msgstr ""
|
||||
@ -837,6 +873,7 @@ msgstr "Não é possível acessar o servidor."
|
||||
#: src/components/ComputerUpdateProgress.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/components/teach/TeachComputerOverlay.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -863,7 +900,7 @@ msgstr "cancelado"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
msgstr "Cancelado"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/AskCard.tsx
|
||||
@ -872,8 +909,8 @@ msgstr ""
|
||||
msgid "Cancelled"
|
||||
msgstr "Cancelado"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Casual & friendly"
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
@ -930,6 +967,10 @@ msgstr "Acompanhamento"
|
||||
msgid "Check your email"
|
||||
msgstr "Confira seu e-mail"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Check-ins"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Checking dependencies…"
|
||||
msgstr ""
|
||||
@ -1139,7 +1180,6 @@ msgid "Command"
|
||||
msgstr "Comando"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Communication style"
|
||||
msgstr ""
|
||||
|
||||
@ -1300,8 +1340,8 @@ msgstr "Limite de contexto"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Continuar"
|
||||
|
||||
@ -1519,6 +1559,10 @@ msgstr ""
|
||||
msgid "Could not load voice settings"
|
||||
msgstr "Não foi possível carregar as configurações de voz"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Could not look in this folder."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/LocalSettings.tsx
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't load"
|
||||
msgstr ""
|
||||
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
@ -1826,6 +1872,10 @@ msgstr ""
|
||||
msgid "day"
|
||||
msgstr "dia"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Day-to-day work"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "days"
|
||||
msgstr "dias"
|
||||
@ -1834,10 +1884,6 @@ msgstr "dias"
|
||||
msgid "Decline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Deep-dive focused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Default"
|
||||
msgstr "Padrão"
|
||||
@ -1938,18 +1984,10 @@ msgstr ""
|
||||
msgid "Desktop update"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Detailed & explanatory"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Developer tools"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Direct & concise"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
msgid "Direct MCP"
|
||||
msgstr ""
|
||||
@ -2073,6 +2111,10 @@ msgstr "ex.: Hivenet Agent, Presales, bot de planilhas"
|
||||
msgid "Earlier message"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Edit first"
|
||||
msgstr "Editar primeiro"
|
||||
@ -2122,6 +2164,14 @@ msgstr ""
|
||||
msgid "Enter this code at <0>{0}</0>"
|
||||
msgstr "Digite este código em <0>{0}</0>"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Evening check-in"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every"
|
||||
@ -2150,6 +2200,10 @@ msgstr "Toda segunda-feira"
|
||||
msgid "Every month"
|
||||
msgstr "A cada mês"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Every morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every week"
|
||||
@ -2191,6 +2245,10 @@ msgstr ""
|
||||
msgid "Extra high"
|
||||
msgstr "Extra alta"
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Fact"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CloudAgentCard.tsx
|
||||
msgid "failed"
|
||||
msgstr "falhou"
|
||||
@ -2265,10 +2323,6 @@ msgstr ""
|
||||
msgid "Flag unexpected actions"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Flexible & spontaneous"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Fonts loaded locally"
|
||||
msgstr ""
|
||||
@ -2333,6 +2387,7 @@ msgstr ""
|
||||
msgid "Goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
msgid "Goals"
|
||||
@ -2369,6 +2424,7 @@ msgstr ""
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Habits"
|
||||
msgstr ""
|
||||
@ -2443,10 +2499,6 @@ msgstr ""
|
||||
msgid "High"
|
||||
msgstr "Alto"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Hobbies & interests"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "Hobby"
|
||||
msgstr ""
|
||||
@ -2460,10 +2512,6 @@ msgstr "hora"
|
||||
msgid "hours"
|
||||
msgstr "horas"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How do you like to work?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "How often"
|
||||
msgstr "Com que frequência"
|
||||
@ -2472,10 +2520,6 @@ msgstr "Com que frequência"
|
||||
msgid "How should your Oo sound when reading replies aloud?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How should your Oo talk to you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "How to check"
|
||||
msgstr "Como verificar"
|
||||
@ -2524,6 +2568,10 @@ msgstr ""
|
||||
msgid "In progress"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Inbox & email"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Inherit default"
|
||||
msgstr "Herdar (padrão)"
|
||||
@ -2747,6 +2795,10 @@ msgstr ""
|
||||
msgid "Log out"
|
||||
msgstr "Sair"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: index + 1
|
||||
#. placeholder {1}: looks.length
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
@ -2757,10 +2809,18 @@ msgstr ""
|
||||
msgid "Look families"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look for projects here?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
msgid "Look variants"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Looking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Low"
|
||||
@ -2925,10 +2985,6 @@ msgstr "minuto"
|
||||
msgid "minutes"
|
||||
msgstr "minutos"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Mission Control"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -2986,6 +3042,18 @@ msgstr ""
|
||||
msgid "More conversations ({hiddenSessionCount})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Morning and evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Morning brief"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "Move them to your shared memory."
|
||||
msgstr "Mova-os para a sua memória compartilhada."
|
||||
@ -2997,7 +3065,6 @@ msgstr ""
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
@ -3176,6 +3243,10 @@ msgstr "Ainda sem mensagens com {peerBotName}."
|
||||
msgid "No model catalog is available."
|
||||
msgstr "Nenhum catálogo de modelos está disponível."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "No projects found"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "No projects yet."
|
||||
msgstr ""
|
||||
@ -3233,6 +3304,7 @@ msgstr "Não Conectado"
|
||||
msgid "Not in the plugin catalog"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Not now"
|
||||
msgstr "Agora não"
|
||||
@ -3245,14 +3317,11 @@ msgstr "Nada ainda está em execução"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Nada memorizado ainda"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Nothing specific"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
msgstr "Notificações"
|
||||
@ -3266,6 +3335,10 @@ msgstr "Agora"
|
||||
msgid "Now using {0}."
|
||||
msgstr "Agora usando {0}."
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Nudges from your Oo"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/McpOAuthCallback.tsx
|
||||
msgid "OAuth authorization was cancelled."
|
||||
msgstr "A autorização OAuth foi cancelada."
|
||||
@ -3299,6 +3372,10 @@ msgstr "no dia 1º às {0}"
|
||||
msgid "Onboarding"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Once a week"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "One more look before I answer…"
|
||||
msgstr ""
|
||||
@ -3307,6 +3384,10 @@ msgstr ""
|
||||
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Only file and folder names are read."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Open"
|
||||
@ -3559,6 +3640,10 @@ msgstr "Provedores"
|
||||
msgid "Pull request"
|
||||
msgstr "Solicitação de pull"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Push on this device"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -3570,14 +3655,22 @@ msgstr "Na fila"
|
||||
msgid "Queued…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Quick & pragmatic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Quiet day so far"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours end"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours start"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Quote"
|
||||
msgstr ""
|
||||
@ -3798,6 +3891,10 @@ msgstr ""
|
||||
msgid "Reporting from home…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Research & writing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-capabilities.tsx
|
||||
msgid "research, writing"
|
||||
msgstr ""
|
||||
@ -3938,6 +4035,7 @@ msgstr ""
|
||||
#: src/components/AskCard.tsx
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
@ -4040,15 +4138,12 @@ msgstr "Pesquisando…"
|
||||
msgid "Select {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Select all that apply"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Self-hosted (better-auth)"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Send"
|
||||
msgstr "Enviar"
|
||||
|
||||
@ -4213,7 +4308,9 @@ msgid "Skills"
|
||||
msgstr "Habilidades"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Skip"
|
||||
msgstr "Pular"
|
||||
@ -4298,6 +4395,10 @@ msgstr ""
|
||||
msgid "Speaking with intent…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Specialist crew"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/model-tier-label.ts
|
||||
msgid "Standard"
|
||||
msgstr ""
|
||||
@ -4391,10 +4492,6 @@ msgstr ""
|
||||
msgid "Stronger together — {0} specialists"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Structured & planned"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "subagent"
|
||||
msgstr "subagente"
|
||||
@ -4480,10 +4577,6 @@ msgstr ""
|
||||
msgid "Tell me what matters. I'll move."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Tell us about yourself"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Test"
|
||||
msgstr "Teste"
|
||||
@ -4574,10 +4667,6 @@ msgstr "Hora do dia"
|
||||
msgid "Time zone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Timezone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Title"
|
||||
msgstr "Título"
|
||||
@ -4640,6 +4729,10 @@ msgstr ""
|
||||
msgid "Try again"
|
||||
msgstr "Tentar novamente"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Turn on"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Type your answer"
|
||||
msgstr "Digite sua resposta"
|
||||
@ -4841,6 +4934,14 @@ msgstr ""
|
||||
msgid "Weekdays"
|
||||
msgstr "Dias da semana"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Weekly"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Weekly review"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "What about its memories?"
|
||||
msgstr "E suas memórias?"
|
||||
@ -4849,6 +4950,10 @@ msgstr "E suas memórias?"
|
||||
msgid "What do you think — react or answer in a sentence."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What do you want me on first?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "What feels like you?"
|
||||
msgstr ""
|
||||
@ -4889,6 +4994,10 @@ msgstr "O que devolver"
|
||||
msgid "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What’s one thing you want to get done in the next few weeks?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "What's personal today?"
|
||||
msgstr ""
|
||||
@ -4901,6 +5010,14 @@ msgstr ""
|
||||
msgid "When a webhook fires"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I check in with you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I stay quiet?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "When to run"
|
||||
msgstr "Quando executar"
|
||||
@ -4926,10 +5043,6 @@ msgstr ""
|
||||
msgid "Work channel open — go ahead."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Work style"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/stage/OoStage.tsx
|
||||
msgid "Working"
|
||||
msgstr ""
|
||||
@ -4945,7 +5058,7 @@ msgstr ""
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Working…"
|
||||
msgstr ""
|
||||
msgstr "Funcionando"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
@ -4987,8 +5100,11 @@ msgstr ""
|
||||
msgid "Your email address"
|
||||
msgstr "Seu endereço de e-mail"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Your goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Your name"
|
||||
msgstr "O seu nome"
|
||||
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-09-07 14:00+0700\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: ru\n"
|
||||
"Project-Id-Version: rakazo-web\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-07 14:00+0700\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: ru\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid " (unread)"
|
||||
@ -21,6 +22,11 @@ msgstr " (непрочитано)"
|
||||
msgid "(optional)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.entries.length
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "{0, plural, one {# model} other {# models}}"
|
||||
@ -31,6 +37,11 @@ msgstr "{0, plural, one {# модель} few {# модели} many {# модел
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: file.name
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
|
||||
@ -282,10 +293,6 @@ msgstr ""
|
||||
msgid "1:1 conversation with {title}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "A few quick preferences so your Oo can be better tailored to you."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-picker.tsx
|
||||
msgid "About groups"
|
||||
msgstr "О группах"
|
||||
@ -351,6 +358,7 @@ msgstr "Активная модель"
|
||||
msgid "Activity"
|
||||
msgstr "Активность"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
msgid "Add"
|
||||
@ -388,6 +396,10 @@ msgstr "Добавьте URL-адрес HTTPS-сервера."
|
||||
msgid "Add another"
|
||||
msgstr "Добавить еще"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add as projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Add Executor"
|
||||
msgstr "Добавить Executor"
|
||||
@ -429,6 +441,10 @@ msgstr "Добавить сервер"
|
||||
msgid "Add server URL"
|
||||
msgstr "Добавьте URL сервера"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add to About you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Add to routine"
|
||||
msgstr "Добавить в задачу"
|
||||
@ -496,6 +512,26 @@ msgstr ""
|
||||
msgid "All quiet"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in on {weekday} at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in today at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in tomorrow at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’m here whenever you need me."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "All spaces"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: rule.matchValue
|
||||
#: src/components/ApprovalRulesSettings.tsx
|
||||
msgid "Allow {0} actions without asking"
|
||||
@ -703,10 +739,6 @@ msgstr "Авторизовать"
|
||||
msgid "Authorize this server so agents can use its tools. A popup opens."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Auto-detected or enter manually"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Automated senders"
|
||||
msgstr "Автоматические отправители"
|
||||
@ -743,10 +775,6 @@ msgstr "Вернуться ко входу"
|
||||
msgid "Back to timeline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Balanced"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Base URL"
|
||||
msgstr "Базовый URL"
|
||||
@ -756,6 +784,10 @@ msgstr "Базовый URL"
|
||||
msgid "Bearer token"
|
||||
msgstr "Bearer-токен"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Booting live desktop…"
|
||||
msgstr "Загрузка рабочего стола…"
|
||||
@ -824,6 +856,10 @@ msgstr ""
|
||||
msgid "Calm helper"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Can I send you notifications?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Can speak"
|
||||
msgstr ""
|
||||
@ -837,6 +873,7 @@ msgstr "Не удаётся связаться с сервером."
|
||||
#: src/components/ComputerUpdateProgress.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/components/teach/TeachComputerOverlay.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -863,7 +900,7 @@ msgstr "отменен"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
msgstr "Отменено"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/AskCard.tsx
|
||||
@ -872,8 +909,8 @@ msgstr ""
|
||||
msgid "Cancelled"
|
||||
msgstr "Отменено"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Casual & friendly"
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
@ -930,6 +967,10 @@ msgstr "Проверить."
|
||||
msgid "Check your email"
|
||||
msgstr "Проверьте свою электронную почту"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Check-ins"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Checking dependencies…"
|
||||
msgstr ""
|
||||
@ -1139,7 +1180,6 @@ msgid "Command"
|
||||
msgstr "Команда"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Communication style"
|
||||
msgstr ""
|
||||
|
||||
@ -1300,8 +1340,8 @@ msgstr ""
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Продолжить"
|
||||
|
||||
@ -1519,6 +1559,10 @@ msgstr "Не удалось загрузить статус обновления
|
||||
msgid "Could not load voice settings"
|
||||
msgstr "Не удалось загрузить голосовые настройки."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Could not look in this folder."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/LocalSettings.tsx
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr "Не удалось открыть локальные настройки. Запустите локальный сервер и создайте учетную запись владельца, затем повторите попытку."
|
||||
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't load"
|
||||
msgstr ""
|
||||
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
|
||||
msgstr "Не удалось загрузить настройки обмена сообщениями."
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
@ -1826,6 +1872,10 @@ msgstr ""
|
||||
msgid "day"
|
||||
msgstr "день"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Day-to-day work"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "days"
|
||||
msgstr "дни"
|
||||
@ -1834,10 +1884,6 @@ msgstr "дни"
|
||||
msgid "Decline"
|
||||
msgstr "Отклонить"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Deep-dive focused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
@ -1938,18 +1984,10 @@ msgstr "Настольное приложение"
|
||||
msgid "Desktop update"
|
||||
msgstr "Обновление настольного приложения"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Detailed & explanatory"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Developer tools"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Direct & concise"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
msgid "Direct MCP"
|
||||
msgstr "Прямой MCP"
|
||||
@ -2073,6 +2111,10 @@ msgstr "напр. Hivenet Agent, Presales, бот табелей"
|
||||
msgid "Earlier message"
|
||||
msgstr "Предыдущее сообщение"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Edit first"
|
||||
msgstr "Сначала отредактируйте"
|
||||
@ -2122,6 +2164,14 @@ msgstr ""
|
||||
msgid "Enter this code at <0>{0}</0>"
|
||||
msgstr "Введите этот код по адресу <0>{0}</0>."
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Evening check-in"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every"
|
||||
@ -2150,6 +2200,10 @@ msgstr "Каждый понедельник"
|
||||
msgid "Every month"
|
||||
msgstr "Каждый месяц"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Every morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every week"
|
||||
@ -2191,6 +2245,10 @@ msgstr "Внешний диалог"
|
||||
msgid "Extra high"
|
||||
msgstr "Очень высокий"
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Fact"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CloudAgentCard.tsx
|
||||
msgid "failed"
|
||||
msgstr "неуспешный"
|
||||
@ -2265,10 +2323,6 @@ msgstr ""
|
||||
msgid "Flag unexpected actions"
|
||||
msgstr "Пометить неожиданные действия"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Flexible & spontaneous"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Fonts loaded locally"
|
||||
msgstr ""
|
||||
@ -2333,6 +2387,7 @@ msgstr "Git-событие"
|
||||
msgid "Goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
msgid "Goals"
|
||||
@ -2369,6 +2424,7 @@ msgstr "Обновления группы"
|
||||
msgid "Groups"
|
||||
msgstr "Группы"
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Habits"
|
||||
msgstr ""
|
||||
@ -2443,10 +2499,6 @@ msgstr "Скрыть пароль"
|
||||
msgid "High"
|
||||
msgstr "Высокий"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Hobbies & interests"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "Hobby"
|
||||
msgstr ""
|
||||
@ -2460,10 +2512,6 @@ msgstr "час"
|
||||
msgid "hours"
|
||||
msgstr "часы"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How do you like to work?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "How often"
|
||||
msgstr "Как часто"
|
||||
@ -2472,10 +2520,6 @@ msgstr "Как часто"
|
||||
msgid "How should your Oo sound when reading replies aloud?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How should your Oo talk to you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "How to check"
|
||||
msgstr "Как проверить"
|
||||
@ -2524,6 +2568,10 @@ msgstr ""
|
||||
msgid "In progress"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Inbox & email"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Inherit default"
|
||||
msgstr "Наследовать значение по умолчанию"
|
||||
@ -2747,6 +2795,10 @@ msgstr "Настройки локального сервера"
|
||||
msgid "Log out"
|
||||
msgstr "Выйти"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: index + 1
|
||||
#. placeholder {1}: looks.length
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
@ -2757,10 +2809,18 @@ msgstr ""
|
||||
msgid "Look families"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look for projects here?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
msgid "Look variants"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Looking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Low"
|
||||
@ -2925,10 +2985,6 @@ msgstr "минута"
|
||||
msgid "minutes"
|
||||
msgstr "минуты"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Mission Control"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -2986,6 +3042,18 @@ msgstr "Больше действий с компьютером"
|
||||
msgid "More conversations ({hiddenSessionCount})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Morning and evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Morning brief"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "Move them to your shared memory."
|
||||
msgstr "Переместите их в свою общую память."
|
||||
@ -2997,7 +3065,6 @@ msgstr ""
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
@ -3023,7 +3090,7 @@ msgstr ""
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Needs you"
|
||||
msgstr ""
|
||||
msgstr "Требуется ваше участие"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/MainInbox.tsx
|
||||
@ -3176,6 +3243,10 @@ msgstr "Сообщений с {peerBotName} пока нет."
|
||||
msgid "No model catalog is available."
|
||||
msgstr "Каталога моделей нет."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "No projects found"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "No projects yet."
|
||||
msgstr ""
|
||||
@ -3233,6 +3304,7 @@ msgstr "Не подключено"
|
||||
msgid "Not in the plugin catalog"
|
||||
msgstr "Нет в каталоге плагинов"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Not now"
|
||||
msgstr "Не сейчас"
|
||||
@ -3245,14 +3317,11 @@ msgstr ""
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Пока ничего не запомнено"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Nothing specific"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
msgstr "Уведомления"
|
||||
@ -3266,6 +3335,10 @@ msgstr "Сейчас"
|
||||
msgid "Now using {0}."
|
||||
msgstr "Теперь используется {0}."
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Nudges from your Oo"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/McpOAuthCallback.tsx
|
||||
msgid "OAuth authorization was cancelled."
|
||||
msgstr "Авторизация OAuth отменена."
|
||||
@ -3299,6 +3372,10 @@ msgstr "1-го числа в {0}"
|
||||
msgid "Onboarding"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Once a week"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "One more look before I answer…"
|
||||
msgstr ""
|
||||
@ -3307,6 +3384,10 @@ msgstr ""
|
||||
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
msgstr "Удалить можно только пустые пространства. Сначала удалите их ботов и группы."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Only file and folder names are read."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Open"
|
||||
@ -3559,6 +3640,10 @@ msgstr "Провайдеры"
|
||||
msgid "Pull request"
|
||||
msgstr "Запрос на слияние"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Push on this device"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -3570,14 +3655,22 @@ msgstr "В очереди"
|
||||
msgid "Queued…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Quick & pragmatic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Quiet day so far"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours end"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours start"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Quote"
|
||||
msgstr ""
|
||||
@ -3798,6 +3891,10 @@ msgstr ""
|
||||
msgid "Reporting from home…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Research & writing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-capabilities.tsx
|
||||
msgid "research, writing"
|
||||
msgstr ""
|
||||
@ -3938,6 +4035,7 @@ msgstr ""
|
||||
#: src/components/AskCard.tsx
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
@ -4040,15 +4138,12 @@ msgstr "Поиск…"
|
||||
msgid "Select {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Select all that apply"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Self-hosted (better-auth)"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Send"
|
||||
msgstr "Отправить"
|
||||
|
||||
@ -4213,7 +4308,9 @@ msgid "Skills"
|
||||
msgstr "Навыки"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Skip"
|
||||
msgstr "Пропустить"
|
||||
@ -4298,6 +4395,10 @@ msgstr ""
|
||||
msgid "Speaking with intent…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Specialist crew"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/model-tier-label.ts
|
||||
msgid "Standard"
|
||||
msgstr ""
|
||||
@ -4391,10 +4492,6 @@ msgstr ""
|
||||
msgid "Stronger together — {0} specialists"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Structured & planned"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "subagent"
|
||||
msgstr "субагент"
|
||||
@ -4480,10 +4577,6 @@ msgstr "Teams не настроен"
|
||||
msgid "Tell me what matters. I'll move."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Tell us about yourself"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Test"
|
||||
msgstr "Тест"
|
||||
@ -4574,10 +4667,6 @@ msgstr "Время суток"
|
||||
msgid "Time zone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Timezone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Title"
|
||||
msgstr "Заголовок"
|
||||
@ -4640,6 +4729,10 @@ msgstr ""
|
||||
msgid "Try again"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Turn on"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Type your answer"
|
||||
msgstr "Введите ответ"
|
||||
@ -4841,6 +4934,14 @@ msgstr "Вебхук"
|
||||
msgid "Weekdays"
|
||||
msgstr "Будни"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Weekly"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Weekly review"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "What about its memories?"
|
||||
msgstr "Что делать с его памятью?"
|
||||
@ -4849,6 +4950,10 @@ msgstr "Что делать с его памятью?"
|
||||
msgid "What do you think — react or answer in a sentence."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What do you want me on first?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "What feels like you?"
|
||||
msgstr ""
|
||||
@ -4889,6 +4994,10 @@ msgstr "Что вернуть"
|
||||
msgid "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What’s one thing you want to get done in the next few weeks?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "What's personal today?"
|
||||
msgstr ""
|
||||
@ -4901,6 +5010,14 @@ msgstr ""
|
||||
msgid "When a webhook fires"
|
||||
msgstr "Когда срабатывает вебхук"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I check in with you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I stay quiet?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "When to run"
|
||||
msgstr "Когда запускать"
|
||||
@ -4926,10 +5043,6 @@ msgstr ""
|
||||
msgid "Work channel open — go ahead."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Work style"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/stage/OoStage.tsx
|
||||
msgid "Working"
|
||||
msgstr ""
|
||||
@ -4945,7 +5058,7 @@ msgstr ""
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Working…"
|
||||
msgstr ""
|
||||
msgstr "Выполняется…"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
@ -4987,8 +5100,11 @@ msgstr ""
|
||||
msgid "Your email address"
|
||||
msgstr "Ваш адрес электронной почты"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Your goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Your name"
|
||||
msgstr "Ваше имя"
|
||||
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-08-26 21:16+0300\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: tr\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-26 21:16+0300\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: tr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: \n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid " (unread)"
|
||||
@ -21,6 +22,11 @@ msgstr " (okunmadı)"
|
||||
msgid "(optional)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.entries.length
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "{0, plural, one {# model} other {# models}}"
|
||||
@ -31,6 +37,11 @@ msgstr "{0, plural, one {# model} other {# model}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: file.name
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
|
||||
@ -282,10 +293,6 @@ msgstr ""
|
||||
msgid "1:1 conversation with {title}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "A few quick preferences so your Oo can be better tailored to you."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-picker.tsx
|
||||
msgid "About groups"
|
||||
msgstr ""
|
||||
@ -351,6 +358,7 @@ msgstr "Etkin model"
|
||||
msgid "Activity"
|
||||
msgstr "Etkinlik"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
msgid "Add"
|
||||
@ -388,6 +396,10 @@ msgstr "Bir HTTPS sunucu URL'si gir."
|
||||
msgid "Add another"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add as projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Add Executor"
|
||||
msgstr "Executor ekle"
|
||||
@ -429,6 +441,10 @@ msgstr "Sunucu ekle"
|
||||
msgid "Add server URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add to About you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Add to routine"
|
||||
msgstr "Rutine ekle"
|
||||
@ -496,6 +512,26 @@ msgstr ""
|
||||
msgid "All quiet"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in on {weekday} at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in today at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in tomorrow at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’m here whenever you need me."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "All spaces"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: rule.matchValue
|
||||
#: src/components/ApprovalRulesSettings.tsx
|
||||
msgid "Allow {0} actions without asking"
|
||||
@ -703,10 +739,6 @@ msgstr "Yetkilendir"
|
||||
msgid "Authorize this server so agents can use its tools. A popup opens."
|
||||
msgstr "Agent'ların araçlarını kullanabilmesi için bu sunucuyu yetkilendir. Bir açılır pencere açılır."
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Auto-detected or enter manually"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Automated senders"
|
||||
msgstr ""
|
||||
@ -743,10 +775,6 @@ msgstr "Oturum açmaya geri dön"
|
||||
msgid "Back to timeline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Balanced"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Base URL"
|
||||
msgstr "Temel URL"
|
||||
@ -756,6 +784,10 @@ msgstr "Temel URL"
|
||||
msgid "Bearer token"
|
||||
msgstr "Bearer token"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Booting live desktop…"
|
||||
msgstr "Canlı masaüstü başlatılıyor…"
|
||||
@ -824,6 +856,10 @@ msgstr ""
|
||||
msgid "Calm helper"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Can I send you notifications?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Can speak"
|
||||
msgstr ""
|
||||
@ -837,6 +873,7 @@ msgstr "Sunucuya ulaşılamıyor."
|
||||
#: src/components/ComputerUpdateProgress.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/components/teach/TeachComputerOverlay.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -863,7 +900,7 @@ msgstr "iptal edildi"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
msgstr "İptal edildi"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/AskCard.tsx
|
||||
@ -872,8 +909,8 @@ msgstr ""
|
||||
msgid "Cancelled"
|
||||
msgstr "İptal edildi"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Casual & friendly"
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
@ -930,6 +967,10 @@ msgstr "Durum bildir."
|
||||
msgid "Check your email"
|
||||
msgstr "E-postanızı kontrol edin"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Check-ins"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Checking dependencies…"
|
||||
msgstr ""
|
||||
@ -1139,7 +1180,6 @@ msgid "Command"
|
||||
msgstr "Komut"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Communication style"
|
||||
msgstr ""
|
||||
|
||||
@ -1300,8 +1340,8 @@ msgstr "Bağlam sınırı"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Devam"
|
||||
|
||||
@ -1519,6 +1559,10 @@ msgstr ""
|
||||
msgid "Could not load voice settings"
|
||||
msgstr "Ses ayarları yüklenemedi"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Could not look in this folder."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/LocalSettings.tsx
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't load"
|
||||
msgstr ""
|
||||
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
@ -1826,6 +1872,10 @@ msgstr ""
|
||||
msgid "day"
|
||||
msgstr "gün"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Day-to-day work"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "days"
|
||||
msgstr "gün"
|
||||
@ -1834,10 +1884,6 @@ msgstr "gün"
|
||||
msgid "Decline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Deep-dive focused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Default"
|
||||
msgstr "Varsayılan"
|
||||
@ -1938,18 +1984,10 @@ msgstr ""
|
||||
msgid "Desktop update"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Detailed & explanatory"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Developer tools"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Direct & concise"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
msgid "Direct MCP"
|
||||
msgstr ""
|
||||
@ -2073,6 +2111,10 @@ msgstr "örn. Hivenet Agent, Presales, puantaj botu"
|
||||
msgid "Earlier message"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Edit first"
|
||||
msgstr "Önce düzenle"
|
||||
@ -2122,6 +2164,14 @@ msgstr ""
|
||||
msgid "Enter this code at <0>{0}</0>"
|
||||
msgstr "Bu kodu <0>{0}</0> adresinde gir"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Evening check-in"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every"
|
||||
@ -2150,6 +2200,10 @@ msgstr "Her pazartesi"
|
||||
msgid "Every month"
|
||||
msgstr "Her ay"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Every morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every week"
|
||||
@ -2191,6 +2245,10 @@ msgstr ""
|
||||
msgid "Extra high"
|
||||
msgstr "Çok yüksek"
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Fact"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CloudAgentCard.tsx
|
||||
msgid "failed"
|
||||
msgstr "başarısız"
|
||||
@ -2265,10 +2323,6 @@ msgstr ""
|
||||
msgid "Flag unexpected actions"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Flexible & spontaneous"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Fonts loaded locally"
|
||||
msgstr ""
|
||||
@ -2333,6 +2387,7 @@ msgstr ""
|
||||
msgid "Goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
msgid "Goals"
|
||||
@ -2369,6 +2424,7 @@ msgstr ""
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Habits"
|
||||
msgstr ""
|
||||
@ -2443,10 +2499,6 @@ msgstr ""
|
||||
msgid "High"
|
||||
msgstr "Yüksek"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Hobbies & interests"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "Hobby"
|
||||
msgstr ""
|
||||
@ -2460,10 +2512,6 @@ msgstr "saat"
|
||||
msgid "hours"
|
||||
msgstr "saat"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How do you like to work?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "How often"
|
||||
msgstr "Ne sıklıkla"
|
||||
@ -2472,10 +2520,6 @@ msgstr "Ne sıklıkla"
|
||||
msgid "How should your Oo sound when reading replies aloud?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How should your Oo talk to you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "How to check"
|
||||
msgstr "Nasıl kontrol edilir"
|
||||
@ -2524,6 +2568,10 @@ msgstr ""
|
||||
msgid "In progress"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Inbox & email"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Inherit default"
|
||||
msgstr "Varsayılanı devral"
|
||||
@ -2747,6 +2795,10 @@ msgstr ""
|
||||
msgid "Log out"
|
||||
msgstr "Çıkış yap"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: index + 1
|
||||
#. placeholder {1}: looks.length
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
@ -2757,10 +2809,18 @@ msgstr ""
|
||||
msgid "Look families"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look for projects here?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
msgid "Look variants"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Looking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Low"
|
||||
@ -2925,10 +2985,6 @@ msgstr "dakika"
|
||||
msgid "minutes"
|
||||
msgstr "dakika"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Mission Control"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -2986,6 +3042,18 @@ msgstr ""
|
||||
msgid "More conversations ({hiddenSessionCount})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Morning and evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Morning brief"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "Move them to your shared memory."
|
||||
msgstr "Bunları ortak hafızana taşı."
|
||||
@ -2997,7 +3065,6 @@ msgstr ""
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
@ -3176,6 +3243,10 @@ msgstr "{peerBotName} ile henüz mesaj yok."
|
||||
msgid "No model catalog is available."
|
||||
msgstr "Kullanılabilir model kataloğu yok."
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "No projects found"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "No projects yet."
|
||||
msgstr ""
|
||||
@ -3233,6 +3304,7 @@ msgstr "Bağlı değil"
|
||||
msgid "Not in the plugin catalog"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Not now"
|
||||
msgstr "Şimdi değil"
|
||||
@ -3245,14 +3317,11 @@ msgstr "Hâlâ çalışan bir şey yok"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "Henüz hatırlanan bir şey yok"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Nothing specific"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
msgstr "Bildirimler"
|
||||
@ -3266,6 +3335,10 @@ msgstr "Şimdi"
|
||||
msgid "Now using {0}."
|
||||
msgstr "Artık {0} kullanılıyor."
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Nudges from your Oo"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/McpOAuthCallback.tsx
|
||||
msgid "OAuth authorization was cancelled."
|
||||
msgstr "OAuth yetkilendirmesi iptal edildi."
|
||||
@ -3299,6 +3372,10 @@ msgstr "her ayın 1'inde saat {0}"
|
||||
msgid "Onboarding"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Once a week"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "One more look before I answer…"
|
||||
msgstr ""
|
||||
@ -3307,6 +3384,10 @@ msgstr ""
|
||||
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Only file and folder names are read."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Open"
|
||||
@ -3559,6 +3640,10 @@ msgstr "Sağlayıcılar"
|
||||
msgid "Pull request"
|
||||
msgstr "Çekme isteği"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Push on this device"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -3570,14 +3655,22 @@ msgstr "Kuyrukta"
|
||||
msgid "Queued…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Quick & pragmatic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Quiet day so far"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours end"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours start"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Quote"
|
||||
msgstr ""
|
||||
@ -3798,6 +3891,10 @@ msgstr ""
|
||||
msgid "Reporting from home…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Research & writing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-capabilities.tsx
|
||||
msgid "research, writing"
|
||||
msgstr ""
|
||||
@ -3938,6 +4035,7 @@ msgstr ""
|
||||
#: src/components/AskCard.tsx
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
@ -4040,15 +4138,12 @@ msgstr "Aranıyor…"
|
||||
msgid "Select {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Select all that apply"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Self-hosted (better-auth)"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Send"
|
||||
msgstr "Gönder"
|
||||
|
||||
@ -4213,7 +4308,9 @@ msgid "Skills"
|
||||
msgstr "Beceriler"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Skip"
|
||||
msgstr "Atla"
|
||||
@ -4298,6 +4395,10 @@ msgstr ""
|
||||
msgid "Speaking with intent…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Specialist crew"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/model-tier-label.ts
|
||||
msgid "Standard"
|
||||
msgstr ""
|
||||
@ -4391,10 +4492,6 @@ msgstr ""
|
||||
msgid "Stronger together — {0} specialists"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Structured & planned"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "subagent"
|
||||
msgstr "alt ajan"
|
||||
@ -4480,10 +4577,6 @@ msgstr ""
|
||||
msgid "Tell me what matters. I'll move."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Tell us about yourself"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Test"
|
||||
msgstr "Dene"
|
||||
@ -4574,10 +4667,6 @@ msgstr "Günün saati"
|
||||
msgid "Time zone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Timezone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Title"
|
||||
msgstr "Başlık"
|
||||
@ -4640,6 +4729,10 @@ msgstr ""
|
||||
msgid "Try again"
|
||||
msgstr "Tekrar dene"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Turn on"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Type your answer"
|
||||
msgstr "Yanıtını yaz"
|
||||
@ -4841,6 +4934,14 @@ msgstr ""
|
||||
msgid "Weekdays"
|
||||
msgstr "Hafta içi"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Weekly"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Weekly review"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "What about its memories?"
|
||||
msgstr "Hafızası ne olsun?"
|
||||
@ -4849,6 +4950,10 @@ msgstr "Hafızası ne olsun?"
|
||||
msgid "What do you think — react or answer in a sentence."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What do you want me on first?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "What feels like you?"
|
||||
msgstr ""
|
||||
@ -4889,6 +4994,10 @@ msgstr "Ne döndürülecek"
|
||||
msgid "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What’s one thing you want to get done in the next few weeks?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "What's personal today?"
|
||||
msgstr ""
|
||||
@ -4901,6 +5010,14 @@ msgstr ""
|
||||
msgid "When a webhook fires"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I check in with you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I stay quiet?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "When to run"
|
||||
msgstr "Ne zaman çalışacak"
|
||||
@ -4926,10 +5043,6 @@ msgstr ""
|
||||
msgid "Work channel open — go ahead."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Work style"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/stage/OoStage.tsx
|
||||
msgid "Working"
|
||||
msgstr ""
|
||||
@ -4945,7 +5058,7 @@ msgstr ""
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Working…"
|
||||
msgstr ""
|
||||
msgstr "Çalışıyor…"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
@ -4987,8 +5100,11 @@ msgstr ""
|
||||
msgid "Your email address"
|
||||
msgstr "E-posta adresin"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Your goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Your name"
|
||||
msgstr "Adın"
|
||||
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: zh-CN\n"
|
||||
"Project-Id-Version: rakazo-web\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: zh-CN\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: \n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid " (unread)"
|
||||
@ -21,6 +22,11 @@ msgstr "(未读)"
|
||||
msgid "(optional)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.entries.length
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "{0, plural, one {# model} other {# models}}"
|
||||
@ -31,6 +37,11 @@ msgstr "{0, plural, one {# 个模型} other {# 个模型}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: file.name
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
|
||||
@ -282,10 +293,6 @@ msgstr ""
|
||||
msgid "1:1 conversation with {title}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "A few quick preferences so your Oo can be better tailored to you."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-picker.tsx
|
||||
msgid "About groups"
|
||||
msgstr ""
|
||||
@ -351,6 +358,7 @@ msgstr "当前模型"
|
||||
msgid "Activity"
|
||||
msgstr "动态"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
msgid "Add"
|
||||
@ -388,6 +396,10 @@ msgstr "请填写 HTTPS 服务器 URL。"
|
||||
msgid "Add another"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add as projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Add Executor"
|
||||
msgstr "添加 Executor"
|
||||
@ -429,6 +441,10 @@ msgstr "添加服务器"
|
||||
msgid "Add server URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Add to About you"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Add to routine"
|
||||
msgstr "加入例行任务"
|
||||
@ -496,6 +512,26 @@ msgstr ""
|
||||
msgid "All quiet"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in on {weekday} at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in today at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’ll check in tomorrow at {time}."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "All set. I’m here whenever you need me."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "All spaces"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: rule.matchValue
|
||||
#: src/components/ApprovalRulesSettings.tsx
|
||||
msgid "Allow {0} actions without asking"
|
||||
@ -703,10 +739,6 @@ msgstr "授权"
|
||||
msgid "Authorize this server so agents can use its tools. A popup opens."
|
||||
msgstr "授权此服务器后,Agent 才能使用其工具。将打开弹窗。"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Auto-detected or enter manually"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Automated senders"
|
||||
msgstr ""
|
||||
@ -743,10 +775,6 @@ msgstr "返回登录"
|
||||
msgid "Back to timeline"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Balanced"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Base URL"
|
||||
msgstr "Base URL"
|
||||
@ -756,6 +784,10 @@ msgstr "Base URL"
|
||||
msgid "Bearer token"
|
||||
msgstr "Bearer token"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Blocked in your browser settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Booting live desktop…"
|
||||
msgstr "正在启动实时桌面…"
|
||||
@ -824,6 +856,10 @@ msgstr ""
|
||||
msgid "Calm helper"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Can I send you notifications?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Can speak"
|
||||
msgstr ""
|
||||
@ -837,6 +873,7 @@ msgstr "无法连接服务器。"
|
||||
#: src/components/ComputerUpdateProgress.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/components/teach/TeachComputerOverlay.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -863,7 +900,7 @@ msgstr "已取消"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
msgstr "已取消"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/AskCard.tsx
|
||||
@ -872,8 +909,8 @@ msgstr ""
|
||||
msgid "Cancelled"
|
||||
msgstr "已取消"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Casual & friendly"
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
@ -930,6 +967,10 @@ msgstr "报到。"
|
||||
msgid "Check your email"
|
||||
msgstr "请查看邮箱"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Check-ins"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Checking dependencies…"
|
||||
msgstr ""
|
||||
@ -1139,7 +1180,6 @@ msgid "Command"
|
||||
msgstr "命令"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Communication style"
|
||||
msgstr ""
|
||||
|
||||
@ -1300,8 +1340,8 @@ msgstr "上下文限制"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Continue"
|
||||
msgstr "继续"
|
||||
|
||||
@ -1519,6 +1559,10 @@ msgstr "无法加载更新状态"
|
||||
msgid "Could not load voice settings"
|
||||
msgstr "无法加载语音设置"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Could not look in this folder."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/LocalSettings.tsx
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't load"
|
||||
msgstr ""
|
||||
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
|
||||
msgstr "无法加载消息设置"
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
@ -1826,6 +1872,10 @@ msgstr ""
|
||||
msgid "day"
|
||||
msgstr "天"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Day-to-day work"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "days"
|
||||
msgstr "天"
|
||||
@ -1834,10 +1884,6 @@ msgstr "天"
|
||||
msgid "Decline"
|
||||
msgstr "拒绝"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Deep-dive focused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Default"
|
||||
msgstr "默认"
|
||||
@ -1938,18 +1984,10 @@ msgstr ""
|
||||
msgid "Desktop update"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Detailed & explanatory"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Developer tools"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Direct & concise"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
msgid "Direct MCP"
|
||||
msgstr ""
|
||||
@ -2073,6 +2111,10 @@ msgstr "例如 Hivenet Agent、Presales、工时表机器人"
|
||||
msgid "Earlier message"
|
||||
msgstr "更早的消息"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Edit first"
|
||||
msgstr "先编辑"
|
||||
@ -2122,6 +2164,14 @@ msgstr ""
|
||||
msgid "Enter this code at <0>{0}</0>"
|
||||
msgstr "在 <0>{0}</0> 输入此代码"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Evening check-in"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every"
|
||||
@ -2150,6 +2200,10 @@ msgstr "每周一"
|
||||
msgid "Every month"
|
||||
msgstr "每月"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Every morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "Every week"
|
||||
@ -2191,6 +2245,10 @@ msgstr ""
|
||||
msgid "Extra high"
|
||||
msgstr "极高"
|
||||
|
||||
#: src/components/UserFactEditor.tsx
|
||||
msgid "Fact"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CloudAgentCard.tsx
|
||||
msgid "failed"
|
||||
msgstr "失败"
|
||||
@ -2265,10 +2323,6 @@ msgstr ""
|
||||
msgid "Flag unexpected actions"
|
||||
msgstr "标记异常操作"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Flexible & spontaneous"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Fonts loaded locally"
|
||||
msgstr ""
|
||||
@ -2333,6 +2387,7 @@ msgstr "Git 事件"
|
||||
msgid "Goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
msgid "Goals"
|
||||
@ -2369,6 +2424,7 @@ msgstr ""
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Habits"
|
||||
msgstr ""
|
||||
@ -2443,10 +2499,6 @@ msgstr "隐藏密码"
|
||||
msgid "High"
|
||||
msgstr "高"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Hobbies & interests"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "Hobby"
|
||||
msgstr ""
|
||||
@ -2460,10 +2512,6 @@ msgstr "小时"
|
||||
msgid "hours"
|
||||
msgstr "小时"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How do you like to work?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineSchedule.tsx
|
||||
msgid "How often"
|
||||
msgstr "频率"
|
||||
@ -2472,10 +2520,6 @@ msgstr "频率"
|
||||
msgid "How should your Oo sound when reading replies aloud?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "How should your Oo talk to you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "How to check"
|
||||
msgstr "如何检查"
|
||||
@ -2524,6 +2568,10 @@ msgstr ""
|
||||
msgid "In progress"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Inbox & email"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Inherit default"
|
||||
msgstr "沿用默认"
|
||||
@ -2747,6 +2795,10 @@ msgstr ""
|
||||
msgid "Log out"
|
||||
msgstr "退出登录"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: index + 1
|
||||
#. placeholder {1}: looks.length
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
@ -2757,10 +2809,18 @@ msgstr ""
|
||||
msgid "Look families"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Look for projects here?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||
msgid "Look variants"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Looking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Low"
|
||||
@ -2925,10 +2985,6 @@ msgstr "分钟"
|
||||
msgid "minutes"
|
||||
msgstr "分钟"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Mission Control"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -2986,6 +3042,18 @@ msgstr ""
|
||||
msgid "More conversations ({hiddenSessionCount})"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Morning"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Morning and evening"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Morning brief"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "Move them to your shared memory."
|
||||
msgstr "把它们移到共享记忆。"
|
||||
@ -2997,7 +3065,6 @@ msgstr ""
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
@ -3176,6 +3243,10 @@ msgstr "还没有与 {peerBotName} 的消息。"
|
||||
msgid "No model catalog is available."
|
||||
msgstr "没有可用的模型目录。"
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "No projects found"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "No projects yet."
|
||||
msgstr ""
|
||||
@ -3233,6 +3304,7 @@ msgstr "未连接"
|
||||
msgid "Not in the plugin catalog"
|
||||
msgstr "不在插件目录中"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Not now"
|
||||
msgstr "暂不"
|
||||
@ -3245,14 +3317,11 @@ msgstr "没有仍在运行的任务"
|
||||
msgid "Nothing remembered yet"
|
||||
msgstr "暂无记忆"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Nothing specific"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Nothing yet"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/SettingsOverlay.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Notifications"
|
||||
msgstr "通知"
|
||||
@ -3266,6 +3335,10 @@ msgstr "现在"
|
||||
msgid "Now using {0}."
|
||||
msgstr "正在使用 {0}。"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Nudges from your Oo"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/McpOAuthCallback.tsx
|
||||
msgid "OAuth authorization was cancelled."
|
||||
msgstr "OAuth 授权已取消。"
|
||||
@ -3299,6 +3372,10 @@ msgstr "每月 1 日 {0}"
|
||||
msgid "Onboarding"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Once a week"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "One more look before I answer…"
|
||||
msgstr ""
|
||||
@ -3307,6 +3384,10 @@ msgstr ""
|
||||
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
msgid "Only file and folder names are read."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ScratchpadSection.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Open"
|
||||
@ -3559,6 +3640,10 @@ msgstr "提供方"
|
||||
msgid "Pull request"
|
||||
msgstr "拉取请求"
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Push on this device"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -3570,14 +3655,22 @@ msgstr "排队中"
|
||||
msgid "Queued…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Quick & pragmatic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
msgid "Quiet day so far"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours end"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/NotificationSettingsPanel.tsx
|
||||
msgid "Quiet hours start"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Quote"
|
||||
msgstr ""
|
||||
@ -3798,6 +3891,10 @@ msgstr ""
|
||||
msgid "Reporting from home…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Research & writing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-capabilities.tsx
|
||||
msgid "research, writing"
|
||||
msgstr ""
|
||||
@ -3938,6 +4035,7 @@ msgstr ""
|
||||
#: src/components/AskCard.tsx
|
||||
#: src/components/MediaSettings.tsx
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
#: src/components/UserFactEditor.tsx
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
#: src/pages/GroupPanel.tsx
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
@ -4040,15 +4138,12 @@ msgstr "正在搜索…"
|
||||
msgid "Select {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Select all that apply"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
msgid "Self-hosted (better-auth)"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Send"
|
||||
msgstr "发送"
|
||||
|
||||
@ -4213,7 +4308,9 @@ msgid "Skills"
|
||||
msgstr "技能"
|
||||
|
||||
#: src/components/integrations/IntegrationSetup.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/lib/onboarding-copy.ts
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Skip"
|
||||
msgstr "跳过"
|
||||
@ -4298,6 +4395,10 @@ msgstr ""
|
||||
msgid "Speaking with intent…"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Specialist crew"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/model-tier-label.ts
|
||||
msgid "Standard"
|
||||
msgstr ""
|
||||
@ -4391,10 +4492,6 @@ msgstr ""
|
||||
msgid "Stronger together — {0} specialists"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Structured & planned"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "subagent"
|
||||
msgstr "子 Agent"
|
||||
@ -4480,10 +4577,6 @@ msgstr ""
|
||||
msgid "Tell me what matters. I'll move."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Tell us about yourself"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Test"
|
||||
msgstr "测试"
|
||||
@ -4574,10 +4667,6 @@ msgstr "时间"
|
||||
msgid "Time zone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Timezone"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Title"
|
||||
msgstr "标题"
|
||||
@ -4640,6 +4729,10 @@ msgstr ""
|
||||
msgid "Try again"
|
||||
msgstr "重试"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Turn on"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/AskCard.tsx
|
||||
msgid "Type your answer"
|
||||
msgstr "输入你的回答"
|
||||
@ -4841,6 +4934,14 @@ msgstr "Webhook"
|
||||
msgid "Weekdays"
|
||||
msgstr "工作日"
|
||||
|
||||
#: src/components/heute/CoachRhythmControl.tsx
|
||||
msgid "Weekly"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-name.ts
|
||||
msgid "Weekly review"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
msgid "What about its memories?"
|
||||
msgstr "它的记忆怎么处理?"
|
||||
@ -4849,6 +4950,10 @@ msgstr "它的记忆怎么处理?"
|
||||
msgid "What do you think — react or answer in a sentence."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What do you want me on first?"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
msgid "What feels like you?"
|
||||
msgstr ""
|
||||
@ -4889,6 +4994,10 @@ msgstr "返回什么"
|
||||
msgid "What's on your mind? I'll route it — and tell you when I disagree."
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "What’s one thing you want to get done in the next few weeks?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "What's personal today?"
|
||||
msgstr ""
|
||||
@ -4901,6 +5010,14 @@ msgstr ""
|
||||
msgid "When a webhook fires"
|
||||
msgstr "当 Webhook 触发时"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I check in with you?"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "When should I stay quiet?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "When to run"
|
||||
msgstr "何时运行"
|
||||
@ -4926,10 +5043,6 @@ msgstr ""
|
||||
msgid "Work channel open — go ahead."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Work style"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/stage/OoStage.tsx
|
||||
msgid "Working"
|
||||
msgstr ""
|
||||
@ -4945,7 +5058,7 @@ msgstr ""
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/agent-activity.ts
|
||||
msgid "Working…"
|
||||
msgstr ""
|
||||
msgstr "处理中…"
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
@ -4987,8 +5100,11 @@ msgstr ""
|
||||
msgid "Your email address"
|
||||
msgstr "你的邮箱"
|
||||
|
||||
#: src/lib/onboarding-copy.ts
|
||||
msgid "Your goal"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
msgid "Your name"
|
||||
msgstr "你的名字"
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import { markAfterPaint, markOnce } from "./lib/performance";
|
||||
import { installPreloadRecovery } from "./lib/preload-recovery";
|
||||
import { applyUiAppearance, watchSystemAppearance } from "./lib/ui-appearance";
|
||||
import { resolveUiLocale } from "./lib/ui-locale";
|
||||
import { registerPushServiceWorker } from "./lib/web-push";
|
||||
import "./styles.css";
|
||||
|
||||
markOnce("rk:renderer:module-evaluated");
|
||||
@ -19,6 +20,7 @@ try {
|
||||
installPreloadRecovery();
|
||||
applyUiDirection(resolveUiLocale());
|
||||
applyUiAppearance();
|
||||
void registerPushServiceWorker();
|
||||
|
||||
function PerformanceProbe() {
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@ -1,22 +1,23 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts";
|
||||
import { botColors } from "@rakazo/ui-tokens";
|
||||
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CoachRhythmControl } from "../components/heute/CoachRhythmControl";
|
||||
import { HeuteCoach } from "../components/heute/HeuteCoach";
|
||||
import { runActivityLabel } from "../lib/agent-activity";
|
||||
import type { DoingItem, DoneItem, JetztItem } from "../lib/heute-brief";
|
||||
import {
|
||||
buildHeuteBrief,
|
||||
cleanBriefPreview,
|
||||
type DoingItem,
|
||||
type DoneItem,
|
||||
type JetztItem,
|
||||
coachRefreshSignal,
|
||||
formatLocalDate,
|
||||
withHabitDone,
|
||||
} from "../lib/heute-brief";
|
||||
import { localizedPreview } from "../lib/onboarding-copy";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
const MISSION_CONTROL_URL = "http://127.0.0.1:3088/";
|
||||
|
||||
type HeuteBriefProps = {
|
||||
bots: Bot[];
|
||||
groups: Group[];
|
||||
@ -26,6 +27,8 @@ type HeuteBriefProps = {
|
||||
onStartChat: () => void;
|
||||
};
|
||||
|
||||
const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" };
|
||||
|
||||
export function HeuteBrief({
|
||||
bots,
|
||||
groups,
|
||||
@ -37,6 +40,9 @@ export function HeuteBrief({
|
||||
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
||||
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [coach, setCoach] = useState<CoachToday | null>(null);
|
||||
const [coachSettled, setCoachSettled] = useState(false);
|
||||
const latestCoachRequest = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -68,6 +74,50 @@ export function HeuteBrief({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshCoach = useCallback(async () => {
|
||||
const request = ++latestCoachRequest.current;
|
||||
try {
|
||||
const today = await rpc.coach.today();
|
||||
if (request === latestCoachRequest.current) setCoach(today);
|
||||
} catch {
|
||||
// Keep the last view; the next refresh retries.
|
||||
} finally {
|
||||
setCoachSettled(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// `coachSignal` is the trigger: a finished run may have logged a check-in or written the brief.
|
||||
const coachSignal = useMemo(() => coachRefreshSignal(recentRuns), [recentRuns]);
|
||||
useEffect(() => {
|
||||
void refreshCoach();
|
||||
}, [refreshCoach, coachSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") void refreshCoach();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
// Ignore responses that arrive after unmount.
|
||||
latestCoachRequest.current += 1;
|
||||
};
|
||||
}, [refreshCoach]);
|
||||
|
||||
const setHabitDone = useCallback(
|
||||
async (habit: HabitToday, done: boolean) => {
|
||||
if (!coach) return;
|
||||
setCoach((current) => (current ? withHabitDone(current, habit.id, done) : current));
|
||||
latestCoachRequest.current += 1;
|
||||
try {
|
||||
await rpc.coach.checkIns.log({ kind: "habit", habitId: habit.id, date: coach.date, done });
|
||||
} finally {
|
||||
void refreshCoach();
|
||||
}
|
||||
},
|
||||
[coach, refreshCoach],
|
||||
);
|
||||
|
||||
const brief = useMemo(
|
||||
() =>
|
||||
buildHeuteBrief({
|
||||
@ -81,31 +131,45 @@ export function HeuteBrief({
|
||||
|
||||
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
|
||||
|
||||
const dateLabel = formatHeuteDate(new Date());
|
||||
const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
|
||||
const locale = i18n.locale || "de";
|
||||
const dateLabel = coach
|
||||
? formatLocalDate(coach.date, locale, DATE_LABEL)
|
||||
: new Date().toLocaleDateString(locale, DATE_LABEL);
|
||||
const hasCoachContent = Boolean(
|
||||
coach && (coach.brief || coach.habits.length > 0 || coach.goals.length > 0),
|
||||
);
|
||||
const hasContent =
|
||||
hasCoachContent || brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
|
||||
<div className="border-b border-border px-4 py-5 md:px-8 lg:px-10">
|
||||
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">{dateLabel}</h1>
|
||||
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
|
||||
<StatusLine
|
||||
actionCount={brief.actionCount}
|
||||
loading={loading}
|
||||
doingCount={brief.doing.length}
|
||||
/>
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-5 md:px-8 lg:px-10">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">
|
||||
{dateLabel}
|
||||
</h1>
|
||||
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
|
||||
<StatusLine
|
||||
actionCount={brief.actionCount}
|
||||
loading={loading}
|
||||
doingCount={brief.doing.length}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<CoachRhythmControl />
|
||||
</div>
|
||||
|
||||
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
|
||||
{loading && !hasContent ? (
|
||||
{(loading || !coachSettled) && !hasContent ? (
|
||||
<p className="text-ui-body text-muted-foreground">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
) : !hasContent ? (
|
||||
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
|
||||
) : (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8 pb-4">
|
||||
{coach ? <HeuteCoach today={coach} onHabitDone={setHabitDone} /> : null}
|
||||
|
||||
{brief.needsYou.length > 0 ? (
|
||||
<section data-testid="heute-needs-you">
|
||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||
@ -172,19 +236,6 @@ export function HeuteBrief({
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mx-auto mt-10 flex w-full max-w-2xl justify-center pb-4">
|
||||
<a
|
||||
href={MISSION_CONTROL_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground transition-colors hover:text-foreground"
|
||||
data-testid="heute-mission-control-link"
|
||||
>
|
||||
<Trans>Mission Control</Trans>
|
||||
<ExternalLink strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -286,7 +337,7 @@ function NeedsYouRow({
|
||||
|
||||
const { chatKind, chat } = item;
|
||||
const title = chat.name;
|
||||
const preview = cleanBriefPreview(chat.preview) || t`Unread reply`;
|
||||
const preview = cleanBriefPreview(localizedPreview(chat)) || t`Unread reply`;
|
||||
const label =
|
||||
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
|
||||
return (
|
||||
@ -399,12 +450,3 @@ function jetztRowKey(item: JetztItem): string {
|
||||
if (item.kind === "run") return `run:${item.run.runId}`;
|
||||
return `unread:${item.chatKind}:${item.chat.id}`;
|
||||
}
|
||||
|
||||
function formatHeuteDate(date: Date): string {
|
||||
const locale = i18n.locale || "de";
|
||||
return date.toLocaleDateString(locale, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
|
||||
import { Button, Input } from "@rakazo/ui-web";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@rakazo/ui-web";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
|
||||
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
|
||||
@ -9,7 +9,7 @@ import {
|
||||
type OnboardingEmojiConfig,
|
||||
OnboardingOoDesigner,
|
||||
} from "../components/onboarding/OnboardingOoDesigner";
|
||||
import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress";
|
||||
import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep";
|
||||
import {
|
||||
KOKORO_LOCAL_TOKEN,
|
||||
OnboardingVoiceStep,
|
||||
@ -24,7 +24,6 @@ import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-loo
|
||||
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
||||
import { rpc, selectSpace } from "../lib/rpc";
|
||||
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
|
||||
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
|
||||
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
|
||||
|
||||
/** Selects the main conversation's space and returns its route. */
|
||||
@ -53,57 +52,11 @@ function defaultEmojiConfig(): OnboardingEmojiConfig {
|
||||
};
|
||||
}
|
||||
|
||||
interface UserPreferences {
|
||||
name: string;
|
||||
timezone: string;
|
||||
hobbies: string[];
|
||||
workStyle: string;
|
||||
communication: string;
|
||||
}
|
||||
|
||||
const HOBBY_OPTIONS = [
|
||||
{ key: "coding", label: "Coding" },
|
||||
{ key: "design", label: "Design" },
|
||||
{ key: "writing", label: "Writing" },
|
||||
{ key: "music", label: "Music" },
|
||||
{ key: "gaming", label: "Gaming" },
|
||||
{ key: "reading", label: "Reading" },
|
||||
{ key: "sports", label: "Sports" },
|
||||
{ key: "cooking", label: "Cooking" },
|
||||
{ key: "tech", label: "Tech & Gadgets" },
|
||||
{ key: "none", label: <Trans>Nothing specific</Trans> },
|
||||
];
|
||||
|
||||
const WORK_STYLE_OPTIONS = [
|
||||
{ key: "structured", label: <Trans>Structured & planned</Trans> },
|
||||
{ key: "flexible", label: <Trans>Flexible & spontaneous</Trans> },
|
||||
{ key: "deep-dive", label: <Trans>Deep-dive focused</Trans> },
|
||||
{ key: "quick", label: <Trans>Quick & pragmatic</Trans> },
|
||||
];
|
||||
|
||||
const COMMUNICATION_OPTIONS = [
|
||||
{ key: "direct", label: <Trans>Direct & concise</Trans> },
|
||||
{ key: "detailed", label: <Trans>Detailed & explanatory</Trans> },
|
||||
{ key: "balanced", label: <Trans>Balanced</Trans> },
|
||||
{ key: "casual", label: <Trans>Casual & friendly</Trans> },
|
||||
];
|
||||
|
||||
function defaultPreferences(): UserPreferences {
|
||||
return {
|
||||
name: "",
|
||||
timezone: localTimezone(),
|
||||
hobbies: [],
|
||||
workStyle: "",
|
||||
communication: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function OnboardingPage() {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const fieldId = useId();
|
||||
const [step, setStep] = useState<
|
||||
"loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot"
|
||||
"loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot"
|
||||
>("emoji");
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState("");
|
||||
const [voiceSkipped, setVoiceSkipped] = useState(false);
|
||||
@ -114,11 +67,8 @@ export function OnboardingPage() {
|
||||
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||
const [crewReady, setCrewReady] = useState(false);
|
||||
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
||||
const [scanBotId, setScanBotId] = useState<string | null>(null);
|
||||
|
||||
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
||||
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem("rakazo.uiLocale") ?? "en";
|
||||
@ -140,15 +90,6 @@ export function OnboardingPage() {
|
||||
.then(async ([me, integrations]) => {
|
||||
if (cancelled) return;
|
||||
setBootstrapMe(me);
|
||||
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
||||
// Get bot ID for scan phase
|
||||
try {
|
||||
const botList = await rpc.bots.list();
|
||||
const mainBot = botList.find((b) => b.kind === "main");
|
||||
if (mainBot && !cancelled) setScanBotId(mainBot.id);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
setIntegrationSetup(integrations);
|
||||
// The first step renders before this resolves; never pull a user back who moved on.
|
||||
if (!me.needsModel && integrations?.needsSetup) {
|
||||
@ -178,17 +119,10 @@ export function OnboardingPage() {
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
function toggleHobby(key: string) {
|
||||
const hobbies = prefs.hobbies.includes(key)
|
||||
? prefs.hobbies.filter((h) => h !== key)
|
||||
: [...prefs.hobbies, key];
|
||||
setPrefs({ ...prefs, hobbies });
|
||||
}
|
||||
|
||||
const discoveryPhase: OnboardingDiscoveryPhase =
|
||||
step === "loading"
|
||||
? "loading"
|
||||
: step === "emoji" || step === "preferences" || step === "language" || step === "voice"
|
||||
: step === "emoji" || step === "language" || step === "voice"
|
||||
? "emoji"
|
||||
: step === "integrations"
|
||||
? "integrations"
|
||||
@ -229,8 +163,6 @@ export function OnboardingPage() {
|
||||
await rpc.profile
|
||||
.update(
|
||||
onboardingProfileUpdate({
|
||||
answers: prefs,
|
||||
accountName: bootstrapMe?.name,
|
||||
browserTimeZone: localTimezone(),
|
||||
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
|
||||
language: languageChoice,
|
||||
@ -252,22 +184,15 @@ export function OnboardingPage() {
|
||||
|
||||
const me = await rpc.me().catch(() => null);
|
||||
if (me) setBootstrapMe(me);
|
||||
const harvested = await persistUserProfileHarvest(
|
||||
rpc,
|
||||
collectLocalProfileSignals(me ?? undefined),
|
||||
).catch(() => null);
|
||||
if (harvested) setHelpVibe(harvested);
|
||||
await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch(
|
||||
() => null,
|
||||
);
|
||||
setProfilePersisted(true);
|
||||
for (const serverId of integrationServers) {
|
||||
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
|
||||
}
|
||||
const started = await rpc.onboarding
|
||||
.start({ botId: bot.id })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (started) {
|
||||
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
||||
}
|
||||
// The main bot asks the rest in the main conversation.
|
||||
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
||||
const main = await rpc.spaces
|
||||
.list()
|
||||
.then((navigation) => navigation.main)
|
||||
@ -300,11 +225,7 @@ export function OnboardingPage() {
|
||||
<OnboardingDiscoveryShell
|
||||
items={discoveryItems}
|
||||
compact={
|
||||
step === "emoji" ||
|
||||
step === "preferences" ||
|
||||
step === "language" ||
|
||||
step === "voice" ||
|
||||
step === "integrations"
|
||||
step === "emoji" || step === "language" || step === "voice" || step === "integrations"
|
||||
}
|
||||
>
|
||||
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
||||
@ -313,140 +234,10 @@ export function OnboardingPage() {
|
||||
<OnboardingOoDesigner
|
||||
config={emojiConfig}
|
||||
onChange={setEmojiConfig}
|
||||
onContinue={() => setStep("preferences")}
|
||||
onContinue={() => setStep("language")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "preferences" ? (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>Tell us about yourself</Trans>
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>A few quick preferences so your Oo can be better tailored to you.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<label
|
||||
htmlFor={`${fieldId}-name`}
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
<Trans>Name</Trans>
|
||||
</label>
|
||||
<Input
|
||||
id={`${fieldId}-name`}
|
||||
value={prefs.name}
|
||||
onChange={(e) => setPrefs({ ...prefs, name: e.target.value })}
|
||||
placeholder={t`Your name`}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label
|
||||
htmlFor={`${fieldId}-timezone`}
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
<Trans>Timezone</Trans>
|
||||
</label>
|
||||
<Input
|
||||
id={`${fieldId}-timezone`}
|
||||
value={prefs.timezone}
|
||||
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
|
||||
placeholder={t`Auto-detected or enter manually`}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset className="mt-6 border-0 p-0">
|
||||
<legend className="block text-sm font-medium text-foreground">
|
||||
<Trans>Hobbies & interests</Trans>
|
||||
</legend>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<Trans>Select all that apply</Trans>
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{HOBBY_OPTIONS.map((hobby) => (
|
||||
<button
|
||||
key={hobby.key}
|
||||
type="button"
|
||||
onClick={() => toggleHobby(hobby.key)}
|
||||
aria-pressed={prefs.hobbies.includes(hobby.key)}
|
||||
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
|
||||
prefs.hobbies.includes(hobby.key)
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{hobby.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="mt-6 border-0 p-0">
|
||||
<legend className="block text-sm font-medium text-foreground">
|
||||
<Trans>Work style</Trans>
|
||||
</legend>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<Trans>How do you like to work?</Trans>
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{WORK_STYLE_OPTIONS.map((ws) => (
|
||||
<button
|
||||
key={ws.key}
|
||||
type="button"
|
||||
onClick={() => setPrefs({ ...prefs, workStyle: ws.key })}
|
||||
aria-pressed={prefs.workStyle === ws.key}
|
||||
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
|
||||
prefs.workStyle === ws.key
|
||||
? "border-primary bg-primary/10 text-foreground shadow-sm"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{ws.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="mt-6 border-0 p-0">
|
||||
<legend className="block text-sm font-medium text-foreground">
|
||||
<Trans>Communication style</Trans>
|
||||
</legend>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<Trans>How should your Oo talk to you?</Trans>
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{COMMUNICATION_OPTIONS.map((comm) => (
|
||||
<button
|
||||
key={comm.key}
|
||||
type="button"
|
||||
onClick={() => setPrefs({ ...prefs, communication: comm.key })}
|
||||
aria-pressed={prefs.communication === comm.key}
|
||||
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
|
||||
prefs.communication === comm.key
|
||||
? "border-primary bg-primary/10 text-foreground shadow-sm"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{comm.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => setStep("language")}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "language" ? (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
@ -477,7 +268,7 @@ export function OnboardingPage() {
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => setStep("voice")}>
|
||||
@ -513,12 +304,7 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "scan" ? (
|
||||
<OnboardingScanProgress
|
||||
onComplete={() => setStep("bot")}
|
||||
botId={scanBotId ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
{step === "scan" ? <OnboardingScanStep onDone={() => setStep("bot")} /> : null}
|
||||
|
||||
{step === "bot" && error ? (
|
||||
<div className="text-center">
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
} from "@rakazo/ui-web";
|
||||
import { ChevronLeft, Clock, GitBranch, Globe, MessageSquare, Pause, Plus, X } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { routineName } from "../lib/routine-name";
|
||||
import { RoutineSchedule } from "./RoutineSchedule";
|
||||
|
||||
function toDatetimeLocalValue(date: Date): string {
|
||||
@ -85,7 +86,7 @@ export function emptyRoutineDraft(): RoutineDraftState {
|
||||
|
||||
export function draftFromRoutine(routine: Routine): RoutineDraftState {
|
||||
return {
|
||||
name: routine.name,
|
||||
name: routineName(routine),
|
||||
prompt: routine.prompt,
|
||||
schedules: routine.crons.map(presetFromCron),
|
||||
webhookEnabled: routine.webhookEnabled,
|
||||
@ -156,7 +157,7 @@ export function RoutineListRow({
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[14.5px] font-medium text-foreground" dir="auto">
|
||||
{routine.name}
|
||||
{routineName(routine)}
|
||||
</span>
|
||||
<span className="block truncate text-ui-body-sm text-muted-foreground/80">
|
||||
{routineTriggerSummary(routine)}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
|
||||
import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
||||
import {
|
||||
Bell,
|
||||
Brain,
|
||||
Clapperboard,
|
||||
CloudDownload,
|
||||
@ -18,6 +19,7 @@ import {
|
||||
import { type ComponentType, useEffect, useRef, useState } from "react";
|
||||
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
||||
import { MediaSettings } from "../components/MediaSettings";
|
||||
import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel";
|
||||
import { UserProfileSettings } from "../components/UserProfileSettings";
|
||||
import {
|
||||
ComputerSettingsPanel,
|
||||
@ -39,6 +41,7 @@ export type SettingsSection =
|
||||
| "profile"
|
||||
| "voice"
|
||||
| "media"
|
||||
| "notifications"
|
||||
| "usage"
|
||||
| "computer"
|
||||
| "updates"
|
||||
@ -112,6 +115,7 @@ export function SettingsOverlay({
|
||||
{ id: "profile", label: t`About you`, icon: UserRound },
|
||||
{ id: "voice", label: t`Voice`, icon: Volume2 },
|
||||
...(isDeploymentOwner ? [{ id: "media" as const, label: t`Media`, icon: Clapperboard }] : []),
|
||||
{ id: "notifications", label: t`Notifications`, icon: Bell },
|
||||
{ id: "usage", label: t`Usage`, icon: Gauge },
|
||||
...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []),
|
||||
{ id: "updates", label: t`Updates`, icon: CloudDownload },
|
||||
@ -251,6 +255,7 @@ export function SettingsOverlay({
|
||||
) : null}
|
||||
{section === "profile" ? <UserProfileSettings /> : null}
|
||||
{section === "media" && isDeploymentOwner ? <MediaSettings /> : null}
|
||||
{section === "notifications" ? <NotificationSettingsPanel /> : null}
|
||||
{section === "usage" ? (
|
||||
<UsageSettingsPanel usage={usage} panelRef={usageRef} />
|
||||
) : null}
|
||||
|
||||
@ -219,6 +219,7 @@ import { lazyWithRecovery } from "../lib/lazy-recovery";
|
||||
import { localTimezone } from "../lib/local-timezone";
|
||||
import { copyableMessageText } from "../lib/message-text";
|
||||
import { messageProviderLabel } from "../lib/messaging";
|
||||
import { localizedPreview, withIntakeNoteText } from "../lib/onboarding-copy";
|
||||
import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire";
|
||||
import { ooSound } from "../lib/oo-sound";
|
||||
import { ooVocals } from "../lib/oo-vocals";
|
||||
@ -232,6 +233,7 @@ import { markAfterPaint, markOnce } from "../lib/performance";
|
||||
import { isEmptyProjectSpace } from "../lib/project-empty-state";
|
||||
import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order";
|
||||
import { quoteDraftForSelection } from "../lib/quote-selection";
|
||||
import { routineSaveName } from "../lib/routine-name";
|
||||
import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc";
|
||||
import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage";
|
||||
import { sharedInflight } from "../lib/shared-inflight";
|
||||
@ -258,6 +260,7 @@ import {
|
||||
transcriptMovedDown,
|
||||
} from "../lib/transcript-scroll";
|
||||
import { speaker } from "../lib/tts";
|
||||
import { disableWebPush } from "../lib/web-push";
|
||||
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
|
||||
import { ActivityList } from "./ActivityList";
|
||||
import type { ContextMenuPosition } from "./BotContextMenu";
|
||||
@ -3655,7 +3658,7 @@ export function ShellPage() {
|
||||
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
|
||||
subtitle={rosterActivitySubtitle(
|
||||
mainConversationEntry.bot.status,
|
||||
cleanRosterPreview(mainConversationEntry.group.preview),
|
||||
cleanRosterPreview(localizedPreview(mainConversationEntry.group)),
|
||||
)}
|
||||
active={inGroup && groupId === mainConversationEntry.groupId}
|
||||
onOpen={openMainConversation}
|
||||
@ -4169,7 +4172,7 @@ export function ShellPage() {
|
||||
item.chat.unread,
|
||||
)}`}
|
||||
>
|
||||
{cleanRosterPreview(item.chat.preview)}
|
||||
{cleanRosterPreview(localizedPreview(item.chat))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@ -4317,10 +4320,14 @@ export function ShellPage() {
|
||||
variant="ghost"
|
||||
className="w-full justify-start font-normal"
|
||||
onClick={() =>
|
||||
void authClient.signOut().then(() => {
|
||||
clearSpaceSelection();
|
||||
navigate("/");
|
||||
})
|
||||
// This browser stops receiving the account's pushes once it signs out.
|
||||
void disableWebPush()
|
||||
.catch(() => undefined)
|
||||
.then(() => authClient.signOut())
|
||||
.then(() => {
|
||||
clearSpaceSelection();
|
||||
navigate("/");
|
||||
})
|
||||
}
|
||||
>
|
||||
<LogOut className="text-muted-foreground" strokeWidth={1.75} />
|
||||
@ -4665,7 +4672,12 @@ export function ShellPage() {
|
||||
</>
|
||||
)}
|
||||
{activeGroup?.kind === "main" && !showProjectTimeline ? (
|
||||
<MainInbox bots={bots} onOpenBot={openBot} onOpenGroup={openGroup} />
|
||||
<MainInbox
|
||||
bots={bots}
|
||||
onOpenBot={openBot}
|
||||
onOpenGroup={openGroup}
|
||||
onOpenInSpace={openSpaceChat}
|
||||
/>
|
||||
) : null}
|
||||
{recordingSkill ? (
|
||||
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
|
||||
@ -5089,7 +5101,7 @@ export function ShellPage() {
|
||||
}
|
||||
saved = await rpc.routines.update({
|
||||
routineId: targetRoutine.id,
|
||||
name: routineDraft.name || t`Routine`,
|
||||
name: routineSaveName(targetRoutine, routineDraft.name) || t`Routine`,
|
||||
prompt: routineDraft.prompt || t`Check in.`,
|
||||
crons,
|
||||
active: armOneShot ? true : routineDraft.active,
|
||||
@ -5843,7 +5855,11 @@ const Transcript = memo(function Transcript({
|
||||
() => new Map(messages.map((message) => [message.id, message])),
|
||||
[messages],
|
||||
);
|
||||
const reactionView = useMemo(() => projectMessageReactions(messages), [messages]);
|
||||
const reactionView = useMemo(
|
||||
() => projectMessageReactions(messages.map(withIntakeNoteText)),
|
||||
// Onboarding notes are worded in the UI language.
|
||||
[messages, i18n.locale],
|
||||
);
|
||||
const transcriptSubordinate = encounterMode && voiceEncounterActive;
|
||||
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
|
||||
const workingLabel =
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { MessageBlock } from "@rakazo/contracts";
|
||||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||
import { abortableDelay } from "@rakazo/core";
|
||||
import {
|
||||
Button,
|
||||
@ -8,15 +9,28 @@ import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@rakazo/ui-web";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { ArrowUp, Check, Download, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BuiCard, SuccessPop } from "../../components/ai/primitives";
|
||||
import { artifactUrl } from "../../lib/artifact-url";
|
||||
import { chartViewport } from "../../lib/chart-viewport";
|
||||
import { connectMcpOauth } from "../../lib/mcp-connect";
|
||||
import { chosenOption, localizedChoice } from "../../lib/onboarding-copy";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
|
||||
|
||||
type ChoiceOption = Extract<MessageBlock, { kind: "choice" }>["options"][number];
|
||||
|
||||
/**
|
||||
* A question card. Lettered options are the answers; an option without a letter ("Skip",
|
||||
* "Not now") is the quiet way out, and a card without one can be dismissed instead. Onboarding
|
||||
* cards may also take an answer in the user's own words.
|
||||
*/
|
||||
export function ChoiceCard({
|
||||
botId,
|
||||
block,
|
||||
@ -29,14 +43,33 @@ export function ChoiceCard({
|
||||
const { t } = useLingui();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [locallyDismissed, setLocallyDismissed] = useState(false);
|
||||
const dismissed = locallyDismissed || block.answerId === "_dismissed";
|
||||
const answered = Boolean(block.answerId);
|
||||
// What the card says, in the user's language; answers still go out by step and option id.
|
||||
const card = localizedChoice(block);
|
||||
const answers = card.options.filter((option) => option.letter);
|
||||
const waysOut = card.options.filter((option) => !option.letter);
|
||||
const testId = (option: ChoiceOption) => `${block.step ?? "focus"}-choice-${option.id}`;
|
||||
|
||||
async function choose(optionId: string) {
|
||||
async function choose(optionId: string, words?: string) {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await rpc.onboarding.choose({ botId, optionId });
|
||||
// Tapping "Turn on" is the gesture the browser needs before it asks for permission.
|
||||
const answer =
|
||||
block.step === "notifications" && optionId === "on" && (await enableWebPush()) !== "enabled"
|
||||
? "later"
|
||||
: optionId;
|
||||
await rpc.onboarding.choose({
|
||||
botId,
|
||||
step: block.step,
|
||||
optionId: answer,
|
||||
...(words ? { text: words } : {}),
|
||||
// The server offers the notifications card only to a browser that can still turn push on.
|
||||
canEnablePush: webPushSupported() && !(await webPushEnabled()),
|
||||
});
|
||||
await onBotChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not save this choice`);
|
||||
@ -59,10 +92,16 @@ export function ChoiceCard({
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const chosen = chosenOption(card);
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="apple-glass-card relative w-[440px] max-w-full rounded-[24px] px-5 py-4.5 transition-all">
|
||||
{!block.answerId ? (
|
||||
<BuiCard
|
||||
data-testid="choice-card"
|
||||
data-step={block.step}
|
||||
className="relative w-[440px] max-w-full px-5 py-4"
|
||||
>
|
||||
{!answered && waysOut.length === 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@ -75,54 +114,116 @@ export function ChoiceCard({
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="pe-8 text-ui-body font-medium text-foreground tracking-tight">
|
||||
{block.question}
|
||||
{card.question}
|
||||
</div>
|
||||
{block.subtitle ? (
|
||||
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
|
||||
) : null}
|
||||
<div className="mt-3.5 space-y-2.5">
|
||||
{block.options
|
||||
.filter((option) => !block.answerId || option.id === block.answerId)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-testid={`focus-choice-${option.id}`}
|
||||
aria-label={option.label}
|
||||
disabled={Boolean(block.answerId) || pending}
|
||||
onClick={() => void choose(option.id)}
|
||||
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-white/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
|
||||
block.answerId === option.id
|
||||
? "bg-blue-500/20 border-blue-500/40 shadow-sm"
|
||||
: "bg-white/[0.04] hover:bg-white/[0.08] hover:border-white/10"
|
||||
}`}
|
||||
{answered ? (
|
||||
chosen ? (
|
||||
<div
|
||||
data-testid="choice-answer"
|
||||
className="mt-3 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3 text-ui-body font-medium text-foreground"
|
||||
>
|
||||
{chosen.letter ? <ChoiceLetter letter={chosen.letter} selected /> : null}
|
||||
<span className="flex-1 leading-snug" dir="auto">
|
||||
{chosen.label}
|
||||
</span>
|
||||
<Check aria-hidden strokeWidth={2.2} className="mt-0.5 size-icon-md text-primary" />
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{answers.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{answers.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-testid={testId(option)}
|
||||
aria-label={option.label}
|
||||
disabled={pending}
|
||||
onClick={() => void choose(option.id)}
|
||||
className="tactile-press flex w-full items-start gap-3 rounded-xl border border-border bg-background p-3 text-start text-ui-body leading-snug text-foreground transition-colors hover:bg-muted disabled:opacity-60"
|
||||
>
|
||||
<ChoiceLetter letter={option.letter} />
|
||||
<span className="flex-1">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{card.textInput ? (
|
||||
<form
|
||||
className="mt-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const words = text.trim();
|
||||
if (words) void choose(CHOICE_TEXT_ANSWER_ID, words);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-[8px] text-ui-body-sm font-semibold ${
|
||||
block.answerId === option.id
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-white/10 text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{option.letter}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-1 text-ui-body leading-snug ${block.answerId ? "text-foreground font-medium" : "text-foreground"}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
{block.answerId === option.id ? (
|
||||
<span className="mt-0.5 text-blue-400 font-bold">✓</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InputGroup className="h-control-lg rounded-xl bg-background">
|
||||
<InputGroupInput
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder={card.textInput.placeholder}
|
||||
aria-label={card.question}
|
||||
maxLength={200}
|
||||
disabled={pending}
|
||||
dir="auto"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
variant="default"
|
||||
aria-label={t`Send`}
|
||||
disabled={pending || !text.trim()}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ArrowUp aria-hidden />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</form>
|
||||
) : null}
|
||||
{waysOut.length > 0 ? (
|
||||
<div className="mt-2 flex justify-end gap-1">
|
||||
{waysOut.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={testId(option)}
|
||||
disabled={pending}
|
||||
onClick={() => void choose(option.id)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
</BuiCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`mt-0.5 grid size-6 shrink-0 place-items-center rounded-lg text-ui-body-sm font-semibold ${
|
||||
selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppConnectCard({
|
||||
botId,
|
||||
block,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
export function rosterRowClass(selected: boolean): string {
|
||||
return `flex w-full items-center gap-3 rounded-2xl border px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
||||
selected
|
||||
? "border-sidebar-border bg-sidebar-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"
|
||||
? "border-sidebar-border bg-sidebar-accent shadow-xs dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"
|
||||
: "border-transparent hover:bg-sidebar-accent/50"
|
||||
}`;
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
createWebProvider,
|
||||
databaseCapacityBackoffMs,
|
||||
EncryptedSecretStore,
|
||||
ExpoPushProvider,
|
||||
ExpoPushSender,
|
||||
GraphileJobPublisher,
|
||||
GraphileJobWorkerHost,
|
||||
InMemoryJobQueue,
|
||||
@ -39,6 +39,7 @@ import {
|
||||
PiAgentRuntime,
|
||||
PipedreamConnector,
|
||||
PostgresRealtimeFanout,
|
||||
PushNotificationProvider,
|
||||
pipedreamConfigFromEnv,
|
||||
reconcileCloudAgents,
|
||||
reconcileComputerUpdates,
|
||||
@ -48,6 +49,9 @@ import {
|
||||
resolveSandboxProvider,
|
||||
ScriptedAgentRuntime,
|
||||
SpaceMemoryProviderResolver,
|
||||
VapidKeys,
|
||||
vapidEnvFromProcess,
|
||||
WebPushSender,
|
||||
} from "@rakazo/adapters";
|
||||
import { resolveEncryptionKey, resolveSupervisorToken } from "@rakazo/core";
|
||||
import {
|
||||
@ -205,7 +209,10 @@ async function main() {
|
||||
secretStore: secrets,
|
||||
deploymentModelKey,
|
||||
dataDir,
|
||||
notifications: new ExpoPushProvider(dataDir),
|
||||
notifications: new PushNotificationProvider(prisma, {
|
||||
webpush: new WebPushSender(new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env))),
|
||||
expo: new ExpoPushSender(),
|
||||
}),
|
||||
jobs,
|
||||
events,
|
||||
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
||||
|
||||
@ -127,6 +127,19 @@ Called from `Onboarding.tsx` after first bot ensure.
|
||||
|
||||
Seeded in user `MEMORY.md` (`user-relationship-contract.ts`). Persona copy and prompts follow. Details: `docs/OO-RELATIONSHIP.md`.
|
||||
|
||||
## Memory and the user model
|
||||
|
||||
| Store | Holds | Written by |
|
||||
|-------|-------|------------|
|
||||
| `UserProfile` + `UserFact` (Settings → About you) | The user model: who the user is, across spaces; a fact can be limited to chosen spaces | `update_user_profile`, onboarding, chat consolidation, the user |
|
||||
| User-scoped `MEMORY.md` (Settings → Memory) | Space notes shared by the bots of one space | The user, onboarding harvest |
|
||||
| Bot-scoped `MEMORY.md` (bot → Knowledge) | One bot's notes in one space | `remember` appends dated notes; `mode: "replace"` rewrites |
|
||||
|
||||
Space notes are not migrated into facts: facts are short, consented statements about the user,
|
||||
space notes are free-form context for one space. A memory document holds at most 16 KB, so a
|
||||
bot's notes and the space notes both reach the prompt; when it is full, `remember` asks the bot
|
||||
to condense it.
|
||||
|
||||
## Capability vision — ambient & video (future roadmap)
|
||||
|
||||
**This PR does not build surveillance.** Current Gesamterfahrung stays **Stage + sound + calm onboarding** (`VOICE.md`). The following is Ingo's long-term direction — optional layers only.
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
# Evo auto-deploy (evOo-local)
|
||||
|
||||
Idempotent deploy helper for the **source checkout** on the Evo host (`~/Projects/evOo-local`, user `cynar`). Pulls `main` (or a pinned SHA), runs migrations, restarts the existing stack, health-checks API `:3100` and web `:5173`, and rolls back on failure.
|
||||
Idempotent deploy helper for the **source checkout** on the Evo host, run as `<deploy-user>`. Pulls `main` (or a pinned SHA), installs dependencies, builds the web app, runs migrations, restarts the stack, health-checks API `:3100` and web `:5173`, and rolls back on failure.
|
||||
|
||||
This is separate from production Docker deploys under `/srv/rakazo` (see `docs/self-host.md` and `.github/workflows/ci.yml` `deploy-production`).
|
||||
This is separate from production Docker deploys (see `docs/self-host.md` and `.github/workflows/ci.yml` `deploy-production`).
|
||||
|
||||
## Prerequisites on Evo
|
||||
|
||||
1. Git checkout at `~/Projects/evOo-local` (or set `EVO_DEPLOY_DIR`).
|
||||
1. Git checkout at `$HOME/Projects/evOo-local` (or set `EVO_DEPLOY_DIR`).
|
||||
2. `.env` with a working `DATABASE_URL` (Postgres reachable for `pnpm db:migrate`).
|
||||
3. A restart path the script can use (pick one):
|
||||
- **systemd (recommended for unattended deploy):** user unit, e.g. `evoo-local.service`, that starts `pnpm dev` or your stack wrapper. Set repo variable `EVO_SYSTEMD_UNIT`.
|
||||
- **tmux:** session `evoo` running `pnpm dev` (matches `scripts/start-tmux.sh` on Mac; on Evo set `EVO_TMUX_SESSION` if different).
|
||||
- **systemd (recommended for unattended deploy):** the user unit from [Stack unit](#stack-unit). Set repo variable `EVO_SYSTEMD_UNIT`.
|
||||
- **tmux:** session `evoo` running `pnpm dev` (set `EVO_TMUX_SESSION` if different).
|
||||
- **compose:** `infra/compose/docker-compose.yml` with `api` / `web` / `worker` (dev stack).
|
||||
- **script:** executable hook at `EVO_RESTART_SCRIPT`.
|
||||
4. Self-hosted GitHub Actions runner on Evo with labels `[self-hosted, evoo-ci, Linux]` (already used by CI).
|
||||
@ -20,23 +20,88 @@ No SSH keys or deploy secrets are stored in this repo. The workflow runs **on**
|
||||
## Enable the workflow
|
||||
|
||||
1. Merge this PR (or copy `ops/evo-deploy/` and `.github/workflows/evo-deploy.yml` to `main`).
|
||||
2. In GitHub → **Settings → Secrets and variables → Actions → Variables** for `cynarAI/evOo-local`:
|
||||
2. In GitHub → **Settings → Secrets and variables → Actions → Variables** for this repository:
|
||||
- `EVO_DEPLOY_ENABLED` = `true`
|
||||
- Optional: `EVO_DEPLOY_DIR` (default `~/Projects/evOo-local` on the runner user)
|
||||
- Optional: `EVO_DEPLOY_DIR` (default `$HOME/Projects/evOo-local` of the runner user)
|
||||
- Optional: `EVO_RESTART_MODE` (`auto`, `systemd`, `tmux`, `compose`, `script`, `none`)
|
||||
- Optional: `EVO_SYSTEMD_UNIT`, `EVO_TMUX_SESSION`, health URLs, timeouts
|
||||
3. Confirm the runner user can read/write `EVO_DEPLOY_DIR` and restart services (`systemctl --user` or tmux).
|
||||
|
||||
Until `EVO_DEPLOY_ENABLED` is `true`, pushes to `main` do not deploy (workflow job is skipped).
|
||||
|
||||
## Stack unit
|
||||
|
||||
`start-stack.sh` starts api, worker and sandbox supervisor exactly as `pnpm dev` does. The web app runs in one of two modes, chosen by `EVO_WEB_MODE`:
|
||||
|
||||
- `dev` (default): the Vite dev server, same as `pnpm dev`.
|
||||
- `preview`: `vite preview` serves the production build from `apps/web/dist` on the same port. `deploy.sh` rebuilds it on every deploy.
|
||||
|
||||
`evoo-local.service` is a systemd user unit template that runs the script. Install it once, as `<deploy-user>` in the checkout:
|
||||
|
||||
```bash
|
||||
systemctl --user cat evoo-local.service # note what the current unit runs; keep a copy if needed
|
||||
install -D -m 0644 ops/evo-deploy/evoo-local.service ~/.config/systemd/user/evoo-local.service
|
||||
# Edit WorkingDirectory in the installed copy if the checkout is not at ~/Projects/evOo-local.
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable evoo-local.service
|
||||
systemctl --user restart evoo-local.service
|
||||
loginctl enable-linger "$USER" # keep user services running without a login session
|
||||
```
|
||||
|
||||
The unit starts the script through a login shell, so node and pnpm come from the same `PATH` that `deploy.sh` uses. It stays in dev mode, so nothing visible changes. Keep the repo variables `EVO_RESTART_MODE=systemd` and `EVO_SYSTEMD_UNIT=evoo-local.service`. Logs: `journalctl --user -u evoo-local.service -f`.
|
||||
|
||||
### Switch to preview
|
||||
|
||||
The production build drops message source text, so a message missing from the Lingui catalogs shows as a hash id instead of English. Switch only once the catalogs are complete (`pnpm --filter @rakazo/web intl:extract` adds no new entries).
|
||||
|
||||
1. In the root `.env`, set `RAKAZO_HOST` to the hostname browsers use (see [HTTPS with Caddy](#https-with-caddy)) and `SCREEN_PROXY_SECRET` to a dedicated random value of at least 32 characters (`openssl rand -hex 32`), different from `BETTER_AUTH_SECRET` and `SANDBOX_SUPERVISOR_TOKEN`. `vite preview` runs with `NODE_ENV=production` and refuses development placeholders; the API reads the same secret.
|
||||
2. Check the configuration without restarting anything:
|
||||
```bash
|
||||
EVO_WEB_MODE=preview bash ops/evo-deploy/start-stack.sh --check
|
||||
```
|
||||
3. Switch the unit and restart:
|
||||
```bash
|
||||
systemctl --user edit evoo-local.service
|
||||
# add:
|
||||
# [Service]
|
||||
# Environment=EVO_WEB_MODE=preview
|
||||
systemctl --user restart evoo-local.service
|
||||
```
|
||||
4. Verify: `curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5173/`.
|
||||
|
||||
To switch back, remove the override (`systemctl --user revert evoo-local.service`) and restart.
|
||||
|
||||
## HTTPS with Caddy
|
||||
|
||||
Service workers and web push need a secure context, so phones and other devices need HTTPS; `http://<host-ip>:5173` is not one. Run Caddy on the host and proxy everything to the web port. Both the dev server and `vite preview` forward `/api`, `/rpc` and the screen proxy to the API, so `:3100` stays private:
|
||||
|
||||
```Caddyfile
|
||||
app.example.com {
|
||||
reverse_proxy 127.0.0.1:5173
|
||||
}
|
||||
```
|
||||
|
||||
This is the host proxy from `docs/self-host.md`; `infra/compose/Caddyfile.prod` routes the same paths to Compose containers and is not used here. Caddy obtains the certificate itself when the hostname resolves to the host and ports 80 and 443 are reachable; otherwise configure a DNS challenge.
|
||||
|
||||
Root `.env`, then restart the unit:
|
||||
|
||||
```dotenv
|
||||
RAKAZO_HOST=app.example.com
|
||||
EXTRA_TRUSTED_ORIGINS=https://app.example.com
|
||||
```
|
||||
|
||||
- `RAKAZO_HOST` is the hostname `vite preview` accepts. Caddy forwards the original `Host` header; the dev server accepts any host.
|
||||
- `EXTRA_TRUSTED_ORIGINS` (comma-separated) lets the HTTPS origin sign in. Keep `BETTER_AUTH_URL` on the plain-http origin and set it explicitly, because it falls back to `WEB_ORIGIN`: an `https://` value makes Better Auth use `__Secure-` cookies, which browsers drop over plain http, so signing in via `http://…:5173` would stop working.
|
||||
- `WEB_ORIGIN` is the origin of links the API builds, such as the agent computer screen. Set it to the HTTPS origin if that is the address you use; an `http://` screen link is blocked as mixed content on the HTTPS page.
|
||||
|
||||
## Manual / dry-run
|
||||
|
||||
On Evo (or any machine with the checkout and stack):
|
||||
|
||||
```bash
|
||||
cd ~/Projects/evOo-local
|
||||
cd <checkout>
|
||||
|
||||
# Show what would happen (no git reset, migrate, restart, or health wait)
|
||||
# Show what would happen (no git reset, install, build, migrate, restart, or health wait)
|
||||
bash ops/evo-deploy/deploy.sh --dry-run
|
||||
|
||||
# Deploy main tip
|
||||
@ -51,30 +116,28 @@ EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service bash ops/evo-deploy
|
||||
|
||||
GitHub: **Actions → evo-deploy → Run workflow** (optional `ref`, `dry_run`, `skip_migrate`).
|
||||
|
||||
## Rollback behavior
|
||||
## Deploy and rollback behavior
|
||||
|
||||
### Regression: SHA capture vs. git stdout
|
||||
|
||||
`deploy.sh` captures `previousSha` and `targetSha` via process substitution (`read … < <(git_fetch_and_checkout)`). Git commands such as `reset --hard` print progress to **stdout** (`HEAD is now at …`), which previously polluted that capture and produced invalid `.evo-deploy/state.json` (e.g. `deployedSha: "is now at 84bc45e …"`, `previousSha: "HEAD"`). Rollback then became a no-op.
|
||||
|
||||
**Fix:** all git invocations inside `git_fetch_and_checkout` (and rollback) redirect stdout to stderr; only the final `printf '%s %s\n'` emits SHAs on stdout. `write_state` and rollback reject values that are not 7–40 hex characters.
|
||||
|
||||
1. Before `git fetch`, the script records `previousSha` (current `HEAD`).
|
||||
2. After pull, migrate, and restart, it waits up to `EVO_HEALTH_TIMEOUT_SEC` (default 120s) for:
|
||||
1. Before `git fetch`, the script records `previousSha` (current `HEAD`), then resets the checkout to the target.
|
||||
2. `pnpm install --frozen-lockfile`, then the web build into `.evo-deploy/web-dist`. Its output goes to `.evo-deploy/web-build.log` and is printed only when the build fails.
|
||||
3. `pnpm db:generate && pnpm db:migrate`.
|
||||
4. The new build replaces `apps/web/dist`, the stack restarts, and the script waits up to `EVO_HEALTH_TIMEOUT_SEC` (default 120s) for:
|
||||
- `EVO_API_HEALTH_URL` (default `http://127.0.0.1:3100/health`)
|
||||
- `EVO_WEB_HEALTH_URL` (default `http://127.0.0.1:5173/`)
|
||||
3. On failure: `git reset --hard` to `previousSha`, restart services again, exit non-zero.
|
||||
4. Last outcome is written to `.evo-deploy/state.json` under the checkout.
|
||||
5. Any failure rolls back: `git reset --hard` to `previousSha`, reinstall, regenerate the client, rebuild the web app if this deploy had already replaced it, restart, health-check, exit non-zero. A failing install, build or migration therefore never restarts the stack on the new commit, and `apps/web/dist` keeps the build that was being served. Migrations only move forward and stay applied.
|
||||
6. The outcome (`success`, `rolled-back` or `rollback-failed`) is written to `.evo-deploy/state.json` under the checkout.
|
||||
|
||||
`deploy.sh` captures `previousSha` and `targetSha` via process substitution, so every git command inside `git_fetch_and_checkout` and rollback redirects stdout to stderr (`reset --hard` prints `HEAD is now at …`). `write_state` and rollback reject values that are not 7–40 hex characters. `packages/testkit/src/evo-deploy.test.ts` runs the script against a fixture checkout and covers this, the build gate and the rollback.
|
||||
|
||||
Manual rollback to a known good SHA:
|
||||
|
||||
```bash
|
||||
cd ~/Projects/evOo-local
|
||||
git reset --hard <good-sha>
|
||||
EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service bash ops/evo-deploy/deploy.sh --skip-migrate --ref HEAD
|
||||
cd <checkout>
|
||||
EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service EVO_DEPLOY_REF=<good-sha> \
|
||||
bash ops/evo-deploy/deploy.sh --skip-migrate
|
||||
```
|
||||
|
||||
(or restart your unit/tmux session by hand, then verify health URLs).
|
||||
(or `git reset --hard <good-sha>`, `pnpm install --frozen-lockfile`, `pnpm --filter @rakazo/web build`, restart the unit, then verify the health URLs).
|
||||
|
||||
## Autopilot / Grok Bot after merge
|
||||
|
||||
@ -83,7 +146,7 @@ After a PR merges to `main`:
|
||||
1. **Preferred:** rely on the `evo-deploy` workflow (once `EVO_DEPLOY_ENABLED=true`). No SSH step required.
|
||||
2. **Fallback (manual):** SSH to Evo and run:
|
||||
```bash
|
||||
cd ~/Projects/evOo-local && bash ops/evo-deploy/deploy.sh
|
||||
cd <checkout> && bash ops/evo-deploy/deploy.sh
|
||||
```
|
||||
3. **Verify:** API `curl -fsS http://127.0.0.1:3100/health`, web `curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5173/`.
|
||||
|
||||
@ -91,6 +154,8 @@ Do not commit host passwords, tokens, or SSH private keys. Configure restart tar
|
||||
|
||||
## Environment reference
|
||||
|
||||
`deploy.sh`:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `EVO_DEPLOY_DIR` | `$HOME/Projects/evOo-local` | Git checkout root |
|
||||
@ -100,10 +165,17 @@ Do not commit host passwords, tokens, or SSH private keys. Configure restart tar
|
||||
| `EVO_SYSTEMD_SCOPE` | `user` | `user` or `system` |
|
||||
| `EVO_TMUX_SESSION` | `evoo` | tmux session name |
|
||||
| `EVO_TMUX_COMMAND` | `pnpm dev` | Command started in new tmux session |
|
||||
| `EVO_MIGRATE` | `true` | Run `pnpm db:generate` + `pnpm db:migrate` |
|
||||
| `EVO_INSTALL` | `true` | Run `pnpm install --frozen-lockfile` after checkout |
|
||||
| `EVO_MIGRATE` | `true` | Run `pnpm db:generate` + `pnpm db:migrate` (`--skip-migrate`) |
|
||||
| `EVO_INSTALL` | `true` | Run `pnpm install --frozen-lockfile` after checkout (`--skip-install`) |
|
||||
| `EVO_BUILD` | `true` | Build the web app before the restart (`--skip-build`) |
|
||||
| `EVO_API_HEALTH_URL` | `http://127.0.0.1:3100/health` | API health probe |
|
||||
| `EVO_WEB_HEALTH_URL` | `http://127.0.0.1:5173/` | Web health probe |
|
||||
| `EVO_HEALTH_TIMEOUT_SEC` | `120` | Per-check timeout (seconds) |
|
||||
|
||||
If `EVO_RESTART_MODE=auto` cannot detect systemd, tmux, compose, or a restart script, deploy fails with a clear error — set `EVO_RESTART_MODE` and the matching unit/session explicitly.
|
||||
`start-stack.sh`:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `EVO_WEB_MODE` | `dev` | `dev` (Vite dev server) or `preview` (production build) |
|
||||
|
||||
If `EVO_RESTART_MODE=auto` cannot detect systemd, tmux, compose, or a restart script, it falls back to `none` and only health-checks — set `EVO_RESTART_MODE` and the matching unit/session explicitly.
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression: git_fetch_and_checkout must emit only two SHAs on stdout.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
git init -q "$TMP/repo"
|
||||
git -C "$TMP/repo" config user.email "test@example.com"
|
||||
git -C "$TMP/repo" config user.name "Test"
|
||||
echo first >"$TMP/repo/file.txt"
|
||||
git -C "$TMP/repo" add file.txt
|
||||
git -C "$TMP/repo" commit -q -m "first"
|
||||
FIRST_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
|
||||
echo second >"$TMP/repo/file.txt"
|
||||
git -C "$TMP/repo" commit -q -am "second"
|
||||
SECOND_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
|
||||
|
||||
# Minimal reimplementation of the capture contract under test.
|
||||
git_fetch_and_checkout() {
|
||||
local previous_sha target_sha deploy_dir="$1" ref="$2"
|
||||
previous_sha="$(git -C "$deploy_dir" rev-parse HEAD)"
|
||||
git -C "$deploy_dir" fetch origin --prune >&2 2>/dev/null || true
|
||||
target_sha="$(git -C "$deploy_dir" rev-parse "${ref}^{commit}")"
|
||||
git -C "$deploy_dir" reset --hard "$target_sha" >&2
|
||||
printf '%s %s\n' "$previous_sha" "$target_sha"
|
||||
}
|
||||
|
||||
git -C "$TMP/repo" remote add origin "$TMP/repo"
|
||||
git -C "$TMP/repo" checkout -q "$FIRST_SHA"
|
||||
|
||||
capture="$(git_fetch_and_checkout "$TMP/repo" "$SECOND_SHA")"
|
||||
read -r got_previous got_target <<<"$capture"
|
||||
|
||||
[[ "$got_previous" == "$FIRST_SHA" ]] || { echo "expected previous $FIRST_SHA, got $got_previous"; exit 1; }
|
||||
[[ "$got_target" == "$SECOND_SHA" ]] || { echo "expected target $SECOND_SHA, got $got_target"; exit 1; }
|
||||
[[ "$capture" != *"HEAD is now at"* ]] || { echo "git reset output leaked into capture: $capture"; exit 1; }
|
||||
|
||||
echo "ok: deploy-sha-capture regression passed"
|
||||
@ -1,10 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# evOo-local — idempotent deploy helper for the Evo host checkout.
|
||||
# Pull (or pin) a revision, migrate, restart services, health-check, rollback on failure.
|
||||
# Pull (or pin) a revision, install, build the web app, migrate, restart services, health-check,
|
||||
# roll back on failure.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --- defaults (override via env; see ops/evo-deploy/README.md) ---
|
||||
EVO_DEPLOY_DIR="${EVO_DEPLOY_DIR:-${HOME}/Projects/evOo-local}"
|
||||
EVO_DEPLOY_REF="${EVO_DEPLOY_REF:-main}"
|
||||
@ -23,6 +22,7 @@ EVO_WEB_HEALTH_URL="${EVO_WEB_HEALTH_URL:-http://127.0.0.1:5173/}"
|
||||
EVO_HEALTH_TIMEOUT_SEC="${EVO_HEALTH_TIMEOUT_SEC:-120}"
|
||||
EVO_MIGRATE="${EVO_MIGRATE:-true}"
|
||||
EVO_INSTALL="${EVO_INSTALL:-true}"
|
||||
EVO_BUILD="${EVO_BUILD:-true}"
|
||||
EVO_DRY_RUN="${EVO_DRY_RUN:-false}"
|
||||
EVO_STATE_FILE="${EVO_STATE_FILE:-${EVO_DEPLOY_DIR}/.evo-deploy/state.json}"
|
||||
|
||||
@ -37,7 +37,7 @@ is_valid_git_sha() {
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install]
|
||||
Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install] [--skip-build]
|
||||
|
||||
Environment: EVO_DEPLOY_DIR, EVO_DEPLOY_REF, EVO_RESTART_MODE, EVO_SYSTEMD_UNIT,
|
||||
EVO_TMUX_SESSION, EVO_API_HEALTH_URL, EVO_WEB_HEALTH_URL, EVO_HEALTH_TIMEOUT_SEC, …
|
||||
@ -66,6 +66,10 @@ parse_args() {
|
||||
EVO_INSTALL=false
|
||||
shift
|
||||
;;
|
||||
--skip-build)
|
||||
EVO_BUILD=false
|
||||
shift
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
@ -212,8 +216,7 @@ wait_for_url() {
|
||||
}
|
||||
|
||||
health_check() {
|
||||
wait_for_url "$EVO_API_HEALTH_URL" "API"
|
||||
wait_for_url "$EVO_WEB_HEALTH_URL" "web"
|
||||
wait_for_url "$EVO_API_HEALTH_URL" "API" && wait_for_url "$EVO_WEB_HEALTH_URL" "web"
|
||||
}
|
||||
|
||||
write_state() {
|
||||
@ -267,19 +270,78 @@ run_migrate() {
|
||||
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate && pnpm db:migrate"
|
||||
}
|
||||
|
||||
# Rollback regenerates the client for the previous schema; applied migrations stay applied.
|
||||
run_generate() {
|
||||
[[ "$EVO_MIGRATE" == "true" ]] || return 0
|
||||
log "running pnpm db:generate"
|
||||
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate"
|
||||
}
|
||||
|
||||
run_install() {
|
||||
[[ "$EVO_INSTALL" == "true" ]] || { log "skipping install (EVO_INSTALL=false)"; return 0; }
|
||||
log "running pnpm install --frozen-lockfile"
|
||||
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm install --frozen-lockfile"
|
||||
}
|
||||
|
||||
# The build goes to a staging directory, so a failing build never touches the one a running
|
||||
# `vite preview` serves; activate_web_build swaps it in right before the restart.
|
||||
WEB_STAGING_DIR="$EVO_DEPLOY_DIR/.evo-deploy/web-dist"
|
||||
WEB_BUILD_LOG="$EVO_DEPLOY_DIR/.evo-deploy/web-build.log"
|
||||
WEB_DIST_DIR="$EVO_DEPLOY_DIR/apps/web/dist"
|
||||
WEB_BUILD_REPLACED=false
|
||||
|
||||
build_web() {
|
||||
[[ "$EVO_BUILD" == "true" ]] || { log "skipping web build (EVO_BUILD=false)"; return 0; }
|
||||
log "building the web app"
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
log "[dry-run] pnpm --filter @rakazo/web build --outDir $WEB_STAGING_DIR"
|
||||
return 0
|
||||
fi
|
||||
rm -rf "$WEB_STAGING_DIR" && mkdir -p "$(dirname "$WEB_BUILD_LOG")" || return 1
|
||||
if run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm --filter @rakazo/web build --outDir $(printf '%q' "$WEB_STAGING_DIR") --emptyOutDir" >"$WEB_BUILD_LOG" 2>&1 &&
|
||||
[[ -f "$WEB_STAGING_DIR/index.html" ]]; then
|
||||
return 0
|
||||
fi
|
||||
# Stack frames bury the message that names the file and line.
|
||||
grep -v -E '^[[:space:]]+at ' "$WEB_BUILD_LOG" | tail -n 60 >&2 || true
|
||||
log "ERROR: web build failed (full log: $WEB_BUILD_LOG)"
|
||||
return 1
|
||||
}
|
||||
|
||||
activate_web_build() {
|
||||
[[ "$EVO_BUILD" == "true" ]] || return 0
|
||||
local outgoing="$WEB_STAGING_DIR.outgoing"
|
||||
log "activating the web build"
|
||||
WEB_BUILD_REPLACED=true
|
||||
run rm -rf "$outgoing" || return 1
|
||||
if [[ -d "$WEB_DIST_DIR" ]]; then
|
||||
run mv "$WEB_DIST_DIR" "$outgoing" || return 1
|
||||
fi
|
||||
run mv "$WEB_STAGING_DIR" "$WEB_DIST_DIR" || return 1
|
||||
run rm -rf "$outgoing"
|
||||
}
|
||||
|
||||
# Restores the previous commit with its dependencies, client and web build, restarts and
|
||||
# health-checks it. Every step is attempted so the service comes back even if one of them fails.
|
||||
rollback() {
|
||||
local previous_sha="$1"
|
||||
local previous_sha="$1" status="rolled-back"
|
||||
is_valid_git_sha "$previous_sha" || die "rollback aborted: invalid previous_sha: $previous_sha"
|
||||
log "ROLLBACK: resetting to $previous_sha and restarting services"
|
||||
run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" >&2
|
||||
restart_services
|
||||
write_state "$previous_sha" "$previous_sha" "rolled-back"
|
||||
log "ROLLBACK: resetting to $previous_sha"
|
||||
if ! run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" >&2; then
|
||||
write_state "$previous_sha" "$previous_sha" "rollback-failed"
|
||||
die "deploy failed and the checkout could not be reset to $previous_sha"
|
||||
fi
|
||||
run_install || status="rollback-failed"
|
||||
run_generate || status="rollback-failed"
|
||||
# Until activation the served build is still the previous one; rebuild only once it was replaced.
|
||||
if [[ "$WEB_BUILD_REPLACED" == true ]] && ! { build_web && activate_web_build; }; then
|
||||
status="rollback-failed"
|
||||
fi
|
||||
if ! { restart_services && health_check; }; then
|
||||
status="rollback-failed"
|
||||
fi
|
||||
write_state "$previous_sha" "$previous_sha" "$status"
|
||||
[[ "$status" == "rolled-back" ]] || die "deploy failed; rollback to $previous_sha did not complete"
|
||||
die "deploy failed; rolled back to $previous_sha"
|
||||
}
|
||||
|
||||
@ -300,16 +362,12 @@ main() {
|
||||
is_valid_git_sha "$target_sha" || die "invalid target_sha captured from checkout: $target_sha"
|
||||
|
||||
if [[ "$previous_sha" == "$target_sha" && "$DRY_RUN" != true ]]; then
|
||||
log "already at $target_sha — continuing with migrate/restart/health"
|
||||
log "already at $target_sha — continuing with install/build/migrate/restart/health"
|
||||
fi
|
||||
|
||||
run_install
|
||||
run_migrate
|
||||
restart_services
|
||||
|
||||
if ! health_check; then
|
||||
# Any failure rolls back; a failing install, build or migration stops before the restart.
|
||||
if ! { run_install && build_web && run_migrate && activate_web_build && restart_services && health_check; }; then
|
||||
rollback "$previous_sha"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_state "$previous_sha" "$target_sha" "success"
|
||||
|
||||
19
ops/evo-deploy/evoo-local.service
Normal file
19
ops/evo-deploy/evoo-local.service
Normal file
@ -0,0 +1,19 @@
|
||||
# systemd user unit for the evOo stack in this checkout. Template: install and switch modes as
|
||||
# described in ops/evo-deploy/README.md.
|
||||
[Unit]
|
||||
Description=evOo stack (api, worker, sandbox supervisor, web)
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Same default checkout as deploy.sh (EVO_DEPLOY_DIR); change it if the checkout lives elsewhere.
|
||||
WorkingDirectory=%h/Projects/evOo-local
|
||||
# dev (Vite dev server) or preview (production build); see the README before switching.
|
||||
Environment=EVO_WEB_MODE=dev
|
||||
# A login shell puts node and pnpm on PATH the same way deploy.sh finds them.
|
||||
ExecStart=/bin/bash -lc 'exec ./ops/evo-deploy/start-stack.sh'
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
79
ops/evo-deploy/start-stack.sh
Executable file
79
ops/evo-deploy/start-stack.sh
Executable file
@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# evOo-local — start the stack from this checkout (used by evoo-local.service).
|
||||
# api, worker and sandbox supervisor run as `pnpm dev` runs them. The web app runs as the Vite dev
|
||||
# server (EVO_WEB_MODE=dev, the default) or serves the production build with `vite preview` on the
|
||||
# same port (EVO_WEB_MODE=preview). `--check` validates the configuration and exits.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
EVO_WEB_MODE="${EVO_WEB_MODE:-dev}"
|
||||
|
||||
log() { printf '[evo-stack] %s\n' "$*" >&2; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
# `vite preview` runs with NODE_ENV=production, so apps/web/vite.config.ts refuses development
|
||||
# secrets. Resolve the env the way that config does (process env first, then the root .env files)
|
||||
# and run the same guard, so a missing value fails here instead of inside a restart loop.
|
||||
check_preview() {
|
||||
[[ -f "$ROOT/apps/web/dist/index.html" ]] ||
|
||||
die "apps/web/dist has no build; run: pnpm --filter @rakazo/web build"
|
||||
(cd "$ROOT/apps/web" && NODE_ENV=production "$ROOT/node_modules/.bin/tsx" --input-type=module -e '
|
||||
import { loadEnv } from "vite";
|
||||
import { resolveScreenProxySecret } from "../../packages/core/src/secrets-guard.ts";
|
||||
|
||||
const files = loadEnv("production", "../..", "");
|
||||
const env = { ...process.env };
|
||||
for (const key of ["RAKAZO_HOST", "SCREEN_PROXY_SECRET", "SANDBOX_SUPERVISOR_TOKEN", "BETTER_AUTH_SECRET"]) {
|
||||
env[key] = files[key];
|
||||
}
|
||||
const problems = [];
|
||||
if (!env.RAKAZO_HOST?.trim()) problems.push("Set RAKAZO_HOST to the hostname the web app is served on.");
|
||||
try {
|
||||
resolveScreenProxySecret(env);
|
||||
} catch (error) {
|
||||
problems.push(error.message);
|
||||
}
|
||||
for (const problem of problems) console.error("[evo-stack] ERROR: " + problem);
|
||||
process.exitCode = problems.length > 0 ? 1 : 0;
|
||||
')
|
||||
}
|
||||
|
||||
start_preview() {
|
||||
pnpm dev '--filter=!@rakazo/web' &
|
||||
SERVICES_PID=$!
|
||||
NODE_ENV=production pnpm --filter @rakazo/web preview --strictPort &
|
||||
WEB_PID=$!
|
||||
trap 'kill "$SERVICES_PID" "$WEB_PID" 2>/dev/null || true' EXIT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 130' INT
|
||||
# Polling instead of `wait -n` keeps this working on bash 3.2.
|
||||
while kill -0 "$SERVICES_PID" 2>/dev/null && kill -0 "$WEB_PID" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
die "a service exited; stopping the stack"
|
||||
}
|
||||
|
||||
main() {
|
||||
case "${1:-}" in
|
||||
"" | --check) ;;
|
||||
*) die "usage: start-stack.sh [--check]" ;;
|
||||
esac
|
||||
case "$EVO_WEB_MODE" in
|
||||
dev | preview) ;;
|
||||
*) die "EVO_WEB_MODE must be dev or preview (got: $EVO_WEB_MODE)" ;;
|
||||
esac
|
||||
cd "$ROOT"
|
||||
if [[ "$EVO_WEB_MODE" == preview ]]; then
|
||||
check_preview
|
||||
fi
|
||||
if [[ "${1:-}" == --check ]]; then
|
||||
log "ok ($EVO_WEB_MODE)"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$EVO_WEB_MODE" == dev ]]; then
|
||||
exec pnpm dev
|
||||
fi
|
||||
start_preview
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -2,5 +2,6 @@ export * from "./background-jobs.js";
|
||||
export * from "./cloud-agents.js";
|
||||
export * from "./interfaces.js";
|
||||
export * from "./media.js";
|
||||
export * from "./memory.js";
|
||||
export * from "./registry.js";
|
||||
export * from "./types.js";
|
||||
|
||||
13
packages/adapter-kit/src/memory.ts
Normal file
13
packages/adapter-kit/src/memory.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Largest Markdown memory document a commit may store, in UTF-8 bytes: half of what a run reads,
|
||||
* so a bot's document and the space's shared one both reach the prompt.
|
||||
*/
|
||||
export const MEMORY_DOCUMENT_MAX_BYTES = 16 * 1024;
|
||||
|
||||
/** A commit would grow a memory document past MEMORY_DOCUMENT_MAX_BYTES. */
|
||||
export class MemoryDocumentTooLargeError extends Error {
|
||||
constructor(readonly path: string) {
|
||||
super(`${path} would exceed ${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB`);
|
||||
this.name = "MemoryDocumentTooLargeError";
|
||||
}
|
||||
}
|
||||
@ -257,6 +257,8 @@ export interface MemoryCommitRequest {
|
||||
botId?: string;
|
||||
path: string;
|
||||
content: string;
|
||||
/** "replace" (default) stores `content` as the whole document; "append" adds it at the end. */
|
||||
mode?: "replace" | "append";
|
||||
sourceRunId?: string;
|
||||
sourceThreadId?: string;
|
||||
}
|
||||
@ -442,7 +444,7 @@ export interface ScriptedTurn {
|
||||
ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> };
|
||||
takeover?: { reason: string };
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
memory?: Array<{ scope: "bot" | "user"; path: string; content: string }>;
|
||||
memory?: Array<Pick<MemoryCommitRequest, "scope" | "path" | "content" | "mode">>;
|
||||
complete?: boolean;
|
||||
}
|
||||
|
||||
@ -592,7 +594,7 @@ export class ArtifactTooLargeError extends Error {
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
kind: "completion" | "failure" | "help" | "takeover";
|
||||
kind: "completion" | "failure" | "help" | "takeover" | "nudge";
|
||||
title: string;
|
||||
body: string;
|
||||
botId: string;
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
"sharp": "0.35.4",
|
||||
"shell-quote": "1.10.0",
|
||||
"undici": "8.10.2",
|
||||
"web-push": "3.6.7",
|
||||
"zod": "4.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -50,6 +51,7 @@
|
||||
"@types/jsdom": "30.0.0",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/web-push": "3.6.4",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
|
||||
@ -14,7 +14,9 @@ import {
|
||||
USER_FACT_CATEGORIES,
|
||||
} from "@rakazo/contracts";
|
||||
import { z } from "zod";
|
||||
import { coachTools } from "./coach-tools.js";
|
||||
import { mediaToolInputSchema } from "./media-tools.js";
|
||||
import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js";
|
||||
|
||||
/** A media tool's parameters from its operation's input schema, plus `continue_after`. */
|
||||
function mediaParameters(schema: z.ZodType) {
|
||||
@ -438,12 +440,18 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
},
|
||||
{
|
||||
name: "remember",
|
||||
description: "Store a durable fact in this bot's explicit memory.",
|
||||
description:
|
||||
"Add a durable note to this bot's memory document; it is appended with today's date. Use mode replace only to rewrite the whole document, e.g. to condense it when it is full.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "string" },
|
||||
path: { type: "string" },
|
||||
path: { type: "string", description: "Memory document, default MEMORY.md." },
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["append", "replace"],
|
||||
description: "append (default) adds a note; replace sets the whole document to content.",
|
||||
},
|
||||
},
|
||||
required: ["content"],
|
||||
},
|
||||
@ -482,6 +490,28 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
...coachTools,
|
||||
{
|
||||
name: "notify_user",
|
||||
description:
|
||||
"Send the user a short push notification that opens the main conversation. Use it sparingly, for a check-in or reminder that matters now, and say the same in the conversation. The user's quiet hours, their nudge setting and a daily limit apply; the result says whether it was sent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
body: {
|
||||
type: "string",
|
||||
maxLength: NOTIFY_USER_BODY_MAX_LENGTH,
|
||||
description: "One or two short sentences.",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
maxLength: NOTIFY_USER_TITLE_MAX_LENGTH,
|
||||
description: "Optional; defaults to your name.",
|
||||
},
|
||||
},
|
||||
required: ["body"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web_search",
|
||||
description:
|
||||
|
||||
294
packages/adapters/src/coach-rhythm.postgres.test.ts
Normal file
294
packages/adapters/src/coach-rhythm.postgres.test.ts
Normal file
@ -0,0 +1,294 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
|
||||
import { nextCronDate } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js";
|
||||
import { updateUserProfileFromTool } from "./user-profile-tools.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const describePostgres =
|
||||
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||
|
||||
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
|
||||
|
||||
describePostgres("coach rhythm (PostgreSQL)", () => {
|
||||
let prisma: PrismaClient;
|
||||
let close: () => Promise<void>;
|
||||
const userIds: string[] = [];
|
||||
|
||||
async function newUser(timeZone: string | null = "Europe/Vienna") {
|
||||
const id = `cr${randomUUID().replaceAll("-", "")}`;
|
||||
userIds.push(id);
|
||||
await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
|
||||
await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false });
|
||||
if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } });
|
||||
return id;
|
||||
}
|
||||
|
||||
function fakeJobs() {
|
||||
return { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) };
|
||||
}
|
||||
|
||||
function coachRoutines(userId: string) {
|
||||
return prisma.routine.findMany({
|
||||
where: { userId, coachKey: { not: null } },
|
||||
orderBy: { coachKey: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
const db = createDb(databaseUrl!);
|
||||
prisma = db.prisma;
|
||||
close = async () => {
|
||||
await db.prisma.$disconnect();
|
||||
await db.pool.end();
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!prisma) return;
|
||||
const memberships = await prisma.member.findMany({
|
||||
where: { userId: { in: userIds } },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
await prisma.organization.deleteMany({
|
||||
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
||||
});
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await close();
|
||||
});
|
||||
|
||||
it("keeps one main-bot routine per check-in in the main conversation and user's time zone", async () => {
|
||||
const userId = await newUser("Europe/Vienna");
|
||||
const jobs = fakeJobs();
|
||||
const rhythm = { morning: "07:30", evening: null, weekly: { day: 0, time: "18:00" } };
|
||||
|
||||
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm);
|
||||
const conversation = await ensureMainConversation(prisma, userId);
|
||||
const created = await coachRoutines(userId);
|
||||
expect(
|
||||
created.map(
|
||||
({ coachKey, botId, threadId, spaceId, crons, timezone, active, notify, name }) => ({
|
||||
coachKey,
|
||||
botId,
|
||||
threadId,
|
||||
spaceId,
|
||||
crons,
|
||||
timezone,
|
||||
active,
|
||||
notify,
|
||||
name,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
coachKey: "morning",
|
||||
botId: conversation.leadBotId,
|
||||
threadId: conversation.threadId,
|
||||
spaceId: conversation.spaceId,
|
||||
crons: ["30 7 * * *"],
|
||||
timezone: "Europe/Vienna",
|
||||
active: true,
|
||||
notify: true,
|
||||
name: COACH_ROUTINES.morning.name,
|
||||
},
|
||||
{
|
||||
coachKey: "weekly",
|
||||
botId: conversation.leadBotId,
|
||||
threadId: conversation.threadId,
|
||||
spaceId: conversation.spaceId,
|
||||
crons: ["0 18 * * 0"],
|
||||
timezone: "Europe/Vienna",
|
||||
active: true,
|
||||
notify: true,
|
||||
name: COACH_ROUTINES.weekly.name,
|
||||
},
|
||||
]);
|
||||
for (const routine of created) {
|
||||
expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(jobs.enqueue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "routine.wakeup",
|
||||
replaceKey: routineJobKey(routine.id),
|
||||
availableAt: routine.nextRunAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(created[0]?.prompt).toBe(COACH_ROUTINES.morning.prompt);
|
||||
|
||||
// The same rhythm again changes nothing.
|
||||
jobs.enqueue.mockClear();
|
||||
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm);
|
||||
expect(jobs.enqueue).not.toHaveBeenCalled();
|
||||
expect(await coachRoutines(userId)).toEqual(created);
|
||||
|
||||
// Moving, adding and turning off check-ins.
|
||||
const next = { morning: "06:45", evening: "21:00", weekly: null };
|
||||
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: next })).toEqual(next);
|
||||
const updated = await coachRoutines(userId);
|
||||
expect(updated.map((routine) => [routine.coachKey, routine.crons[0]])).toEqual([
|
||||
["evening", "0 21 * * *"],
|
||||
["morning", "45 6 * * *"],
|
||||
]);
|
||||
expect(updated.find((routine) => routine.coachKey === "morning")?.id).toBe(created[0]?.id);
|
||||
expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(created[1]!.id));
|
||||
expect(await getCoachRhythm(prisma, userId)).toEqual(next);
|
||||
});
|
||||
|
||||
it("reads paused or reshaped routines as off and turns them back on", async () => {
|
||||
const userId = await newUser("UTC");
|
||||
const jobs = fakeJobs();
|
||||
await setCoachRhythm(
|
||||
{ prisma, jobs },
|
||||
{ userId, rhythm: { morning: "08:00", evening: "20:00", weekly: null } },
|
||||
);
|
||||
const routines = await coachRoutines(userId);
|
||||
await prisma.routine.update({ where: { id: routines[0]!.id }, data: { active: false } });
|
||||
await prisma.routine.update({
|
||||
where: { id: routines[1]!.id },
|
||||
data: { crons: ["0 8 * * 1-5"] },
|
||||
});
|
||||
expect(await getCoachRhythm(prisma, userId)).toEqual({
|
||||
morning: null,
|
||||
evening: null,
|
||||
weekly: null,
|
||||
});
|
||||
|
||||
const rhythm = { morning: "08:00", evening: "20:00", weekly: null };
|
||||
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm);
|
||||
expect((await coachRoutines(userId)).map((routine) => routine.id)).toEqual(
|
||||
routines.map((routine) => routine.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("turning everything off removes the routines and never creates a main bot", async () => {
|
||||
const userId = await newUser(null);
|
||||
const jobs = fakeJobs();
|
||||
const off = { morning: null, evening: null, weekly: null };
|
||||
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off);
|
||||
expect(await prisma.bot.count({ where: { userId, kind: "main" } })).toBe(0);
|
||||
expect(await getCoachRhythm(prisma, userId)).toEqual(off);
|
||||
|
||||
await setCoachRhythm(
|
||||
{ prisma, jobs },
|
||||
{ userId, rhythm: { morning: "09:15", evening: null, weekly: null } },
|
||||
);
|
||||
const [routine] = await coachRoutines(userId);
|
||||
expect(routine).toMatchObject({ timezone: "UTC", crons: ["15 9 * * *"] });
|
||||
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off);
|
||||
expect(await coachRoutines(userId)).toEqual([]);
|
||||
expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(routine!.id));
|
||||
});
|
||||
|
||||
it("moves the check-ins, and only them, when the profile time zone changes", async () => {
|
||||
const userId = await newUser("Europe/Vienna");
|
||||
const jobs = fakeJobs();
|
||||
const deps = { prisma, jobs };
|
||||
await setCoachRhythm(deps, {
|
||||
userId,
|
||||
rhythm: { morning: "07:30", evening: "21:00", weekly: { day: 0, time: "18:00" } },
|
||||
});
|
||||
const conversation = await ensureMainConversation(prisma, userId);
|
||||
const evening = await prisma.routine.findFirstOrThrow({
|
||||
where: { userId, coachKey: "evening" },
|
||||
});
|
||||
await prisma.routine.update({
|
||||
where: { id: evening.id },
|
||||
data: { active: false, nextRunAt: null },
|
||||
});
|
||||
const own = await prisma.routine.create({
|
||||
data: {
|
||||
botId: conversation.leadBotId,
|
||||
userId,
|
||||
spaceId: conversation.spaceId,
|
||||
name: "Water the plants",
|
||||
prompt: "Remind me to water the plants.",
|
||||
crons: ["0 9 * * *"],
|
||||
timezone: "Europe/Vienna",
|
||||
active: true,
|
||||
nextRunAt: nextCronDate("0 9 * * *", new Date(), "Europe/Vienna"),
|
||||
},
|
||||
});
|
||||
const setZone = (time_zone: string) =>
|
||||
updateUserProfileFromTool(
|
||||
deps,
|
||||
{
|
||||
userId,
|
||||
bot: { id: conversation.leadBotId, kind: "main" },
|
||||
threadId: conversation.threadId,
|
||||
sourceMessageId: null,
|
||||
},
|
||||
{ time_zone },
|
||||
);
|
||||
const before = await coachRoutines(userId);
|
||||
|
||||
// Saving the zone the check-ins already use changes nothing.
|
||||
jobs.enqueue.mockClear();
|
||||
expect(await setZone("Europe/Vienna")).toMatchObject({ ok: true });
|
||||
expect(await coachRoutines(userId)).toEqual(before);
|
||||
expect(jobs.enqueue).not.toHaveBeenCalled();
|
||||
|
||||
expect(await setZone("America/New_York")).toMatchObject({ ok: true });
|
||||
const moved = await coachRoutines(userId);
|
||||
expect(moved.map((routine) => [routine.coachKey, routine.timezone])).toEqual([
|
||||
["evening", "America/New_York"],
|
||||
["morning", "America/New_York"],
|
||||
["weekly", "America/New_York"],
|
||||
]);
|
||||
const localTime = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "America/New_York",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).format(date);
|
||||
const [pausedEvening, morning, weekly] = moved;
|
||||
expect(localTime(morning!.nextRunAt!)).toMatch(/ 07:30$/);
|
||||
expect(localTime(weekly!.nextRunAt!)).toBe("Sun 18:00");
|
||||
for (const routine of [morning!, weekly!]) {
|
||||
expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(routine.nextRunAt).not.toEqual(before.find((old) => old.id === routine.id)?.nextRunAt);
|
||||
}
|
||||
expect(jobs.enqueue).toHaveBeenCalledTimes(2);
|
||||
expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(morning!.id, morning!.nextRunAt!));
|
||||
expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(weekly!.id, weekly!.nextRunAt!));
|
||||
// A paused check-in moves but stays paused; the user's own routine keeps its zone.
|
||||
expect(pausedEvening).toMatchObject({ active: false, nextRunAt: null });
|
||||
expect(await prisma.routine.findUniqueOrThrow({ where: { id: own.id } })).toEqual(own);
|
||||
expect(await getCoachRhythm(prisma, userId)).toEqual({
|
||||
morning: "07:30",
|
||||
evening: null,
|
||||
weekly: { day: 0, time: "18:00" },
|
||||
});
|
||||
|
||||
// Saving the new zone again changes nothing.
|
||||
jobs.enqueue.mockClear();
|
||||
await setZone("America/New_York");
|
||||
expect(await coachRoutines(userId)).toEqual(moved);
|
||||
expect(jobs.enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates no check-ins for a user without any when the time zone changes", async () => {
|
||||
const userId = await newUser("Europe/Vienna");
|
||||
const jobs = fakeJobs();
|
||||
const main = await ensureMainConversation(prisma, userId);
|
||||
await updateUserProfileFromTool(
|
||||
{ prisma, jobs },
|
||||
{
|
||||
userId,
|
||||
bot: { id: main.leadBotId, kind: "main" },
|
||||
threadId: main.threadId,
|
||||
sourceMessageId: null,
|
||||
},
|
||||
{ time_zone: "Asia/Tokyo" },
|
||||
);
|
||||
expect(await prisma.userProfile.findUnique({ where: { userId } })).toMatchObject({
|
||||
timeZone: "Asia/Tokyo",
|
||||
});
|
||||
expect(await prisma.routine.count({ where: { userId } })).toBe(0);
|
||||
expect(jobs.enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
177
packages/adapters/src/coach-rhythm.ts
Normal file
177
packages/adapters/src/coach-rhythm.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
|
||||
import type { CoachRhythm, CoachRoutineKey } from "@rakazo/contracts";
|
||||
import {
|
||||
COACH_RHYTHM_OFF,
|
||||
COACH_ROUTINE_KEYS,
|
||||
COACH_ROUTINE_NAMES,
|
||||
clockTimeToMinutes,
|
||||
minutesToClockTime,
|
||||
} from "@rakazo/contracts";
|
||||
import { nextCronDate, nextCronDateAcross } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db";
|
||||
|
||||
export const COACH_KEYS = COACH_ROUTINE_KEYS;
|
||||
export type CoachKey = CoachRoutineKey;
|
||||
|
||||
/**
|
||||
* The main bot's check-ins. Names show in the main bot's routine list; prompts are its task when
|
||||
* a check-in fires. Product text in English: the main bot answers in the user's language, and
|
||||
* its instructions name the coach tools it has.
|
||||
*/
|
||||
export const COACH_ROUTINES: Record<CoachKey, { name: string; prompt: string }> = {
|
||||
morning: {
|
||||
name: COACH_ROUTINE_NAMES.morning,
|
||||
prompt:
|
||||
"Morning check-in. Review the user's active goals and habits, and today's calendar if a calendar app is connected. Write today's brief: two to five short lines on what matters most today, and save it. Then post one short, friendly message with the gist. Leave out what is not relevant today.",
|
||||
},
|
||||
evening: {
|
||||
name: COACH_ROUTINE_NAMES.evening,
|
||||
prompt:
|
||||
"Evening check-in. Log as check-ins what the user already told you about today. Then ask in one or two sentences how the day went and about habits not yet checked off. Keep it light, and do not ask when everything is already logged.",
|
||||
},
|
||||
weekly: {
|
||||
name: COACH_ROUTINE_NAMES.weekly,
|
||||
prompt:
|
||||
"Weekly review. Look at the past week's check-ins and the progress on the user's active goals. Post a short summary: what moved, what stalled, and one concrete suggestion for next week. Offer to adjust, complete or drop goals, and change them only when the user agrees.",
|
||||
},
|
||||
};
|
||||
|
||||
export interface CoachRhythmDeps {
|
||||
prisma: PrismaClient;
|
||||
jobs: Pick<JobPublisher, "enqueue" | "cancel">;
|
||||
}
|
||||
|
||||
/** The cron for one check-in, or null when it is off. */
|
||||
export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null {
|
||||
const slot = key === "weekly" ? rhythm.weekly : rhythm[key];
|
||||
if (!slot) return null;
|
||||
const minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time);
|
||||
return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`;
|
||||
}
|
||||
|
||||
/** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */
|
||||
function rhythmSlot(key: CoachKey, crons: readonly string[]) {
|
||||
const match =
|
||||
crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null;
|
||||
if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return null;
|
||||
const time = minutesToClockTime(Number(match[2]) * 60 + Number(match[1]));
|
||||
if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time };
|
||||
return match[3] === "*" ? time : null;
|
||||
}
|
||||
|
||||
function isCoachKey(value: string | null): value is CoachKey {
|
||||
return (COACH_KEYS as readonly string[]).includes(value ?? "");
|
||||
}
|
||||
|
||||
/** The user's check-in times, read from the main bot's active coach routines. */
|
||||
export async function getCoachRhythm(prisma: PrismaClient, userId: string): Promise<CoachRhythm> {
|
||||
const botId = await findMainBotId(prisma, userId);
|
||||
if (!botId) return { ...COACH_RHYTHM_OFF };
|
||||
const routines = await prisma.routine.findMany({
|
||||
where: { botId, userId, active: true, coachKey: { not: null } },
|
||||
select: { coachKey: true, crons: true },
|
||||
});
|
||||
const rhythm: CoachRhythm = { ...COACH_RHYTHM_OFF };
|
||||
for (const routine of routines) {
|
||||
if (!isCoachKey(routine.coachKey)) continue;
|
||||
const slot = rhythmSlot(routine.coachKey, routine.crons);
|
||||
if (routine.coachKey === "weekly") rhythm.weekly = slot as CoachRhythm["weekly"];
|
||||
else rhythm[routine.coachKey] = slot as string | null;
|
||||
}
|
||||
return rhythm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the main bot's coach routines match `rhythm`: one routine per check-in that is on,
|
||||
* waking in the main conversation in the user's time zone. Check-ins that are off are deleted.
|
||||
* Calling it again with the same rhythm changes nothing.
|
||||
*/
|
||||
export async function setCoachRhythm(
|
||||
deps: CoachRhythmDeps,
|
||||
input: { userId: string; rhythm: CoachRhythm },
|
||||
): Promise<CoachRhythm> {
|
||||
const { prisma, jobs } = deps;
|
||||
const { userId, rhythm } = input;
|
||||
const anyOn = COACH_KEYS.some((key) => coachCron(key, rhythm) !== null);
|
||||
const conversation = anyOn ? await ensureMainConversation(prisma, userId) : null;
|
||||
const botId = conversation?.leadBotId ?? (await findMainBotId(prisma, userId));
|
||||
if (!botId) return { ...COACH_RHYTHM_OFF };
|
||||
const existing = await prisma.routine.findMany({
|
||||
where: { botId, userId, coachKey: { not: null } },
|
||||
});
|
||||
const timezone = anyOn ? await coachTimeZone(prisma, userId) : "UTC";
|
||||
for (const key of COACH_KEYS) {
|
||||
const cron = coachCron(key, rhythm);
|
||||
const current = existing.find((routine) => routine.coachKey === key);
|
||||
if (!cron || !conversation) {
|
||||
if (current) {
|
||||
await prisma.routine.deleteMany({ where: { id: current.id } });
|
||||
await jobs.cancel(routineJobKey(current.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const { name, prompt } = COACH_ROUTINES[key];
|
||||
const unchanged =
|
||||
current?.active &&
|
||||
current.nextRunAt &&
|
||||
current.crons.length === 1 &&
|
||||
current.crons[0] === cron &&
|
||||
current.timezone === timezone &&
|
||||
current.threadId === conversation.threadId &&
|
||||
current.spaceId === conversation.spaceId &&
|
||||
current.name === name &&
|
||||
current.prompt === prompt;
|
||||
if (unchanged) continue;
|
||||
const nextRunAt = nextCronDate(cron, new Date(), timezone);
|
||||
const fields = {
|
||||
spaceId: conversation.spaceId,
|
||||
threadId: conversation.threadId,
|
||||
name,
|
||||
prompt,
|
||||
crons: [cron],
|
||||
timezone,
|
||||
active: true,
|
||||
notify: true,
|
||||
nextRunAt,
|
||||
};
|
||||
const saved = await prisma.routine.upsert({
|
||||
where: { botId_coachKey: { botId, coachKey: key } },
|
||||
create: { ...fields, botId, userId, coachKey: key },
|
||||
update: fields,
|
||||
});
|
||||
// The job reconciler re-arms an active routine whose wakeup could not be enqueued.
|
||||
await jobs.enqueue(routineWakeupJob(saved.id, nextRunAt));
|
||||
}
|
||||
return getCoachRhythm(prisma, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the main bot's check-ins into the user's current profile time zone and re-arms each
|
||||
* armed one for its next local run. Check-ins already in that zone and routines the user made
|
||||
* stay as they are, so calling it after every time zone save is safe.
|
||||
*/
|
||||
export async function syncCoachTimeZone(deps: CoachRhythmDeps, userId: string): Promise<void> {
|
||||
const { prisma, jobs } = deps;
|
||||
const botId = await findMainBotId(prisma, userId);
|
||||
if (!botId) return;
|
||||
const timezone = await coachTimeZone(prisma, userId);
|
||||
const moved = await prisma.routine.findMany({
|
||||
where: { botId, userId, coachKey: { not: null }, timezone: { not: timezone } },
|
||||
});
|
||||
for (const routine of moved) {
|
||||
const armed = routine.active && routine.nextRunAt !== null;
|
||||
const nextRunAt = armed
|
||||
? (nextCronDateAcross(routine.crons, new Date(), timezone) ?? routine.nextRunAt)
|
||||
: routine.nextRunAt;
|
||||
// Skipped when another save moved it meanwhile.
|
||||
const { count } = await prisma.routine.updateMany({
|
||||
where: { id: routine.id, timezone: routine.timezone },
|
||||
data: { timezone, nextRunAt },
|
||||
});
|
||||
if (count === 1 && armed && nextRunAt) {
|
||||
await jobs.enqueue(routineWakeupJob(routine.id, nextRunAt));
|
||||
}
|
||||
}
|
||||
}
|
||||
102
packages/adapters/src/coach-tools.test.ts
Normal file
102
packages/adapters/src/coach-tools.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COACH_TOOL_NAMES, executeCoachTool } from "./coach-tools.js";
|
||||
import { selectBuiltinToolsForRun } from "./executor-tools.js";
|
||||
|
||||
function toolNames(options: {
|
||||
mainBot?: boolean;
|
||||
/** A project's main bot rather than the user's personal agent. */
|
||||
projectLead?: boolean;
|
||||
messagingChannelRun?: boolean;
|
||||
foreignAgentRun?: boolean;
|
||||
trigger?: string;
|
||||
}) {
|
||||
return selectBuiltinToolsForRun({
|
||||
graphicalToolsAllowed: false,
|
||||
groupId: null,
|
||||
trigger: options.trigger ?? "user",
|
||||
semanticMemoryEnabled: false,
|
||||
messagingChannelRun: options.messagingChannelRun ?? false,
|
||||
foreignAgentRun: options.foreignAgentRun,
|
||||
bot: {
|
||||
kind: options.mainBot ? "main" : "specialist",
|
||||
restrictBuiltinTools: false,
|
||||
allowedBuiltinTools: [],
|
||||
personalAgent: options.mainBot && !options.projectLead,
|
||||
},
|
||||
}).map((tool) => tool.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the space lookup that tells the personal agent from a project lead may reach the
|
||||
* database: refusals and invalid input must not write anything.
|
||||
*/
|
||||
const lookupOnlyPrisma = new Proxy(
|
||||
{
|
||||
space: {
|
||||
findUnique: async ({ where }: { where: { id: string } }) => ({
|
||||
isDefault: where.id === "space-home",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
get(target, property) {
|
||||
if (property === "space") return target.space;
|
||||
throw new Error(`unexpected database access: ${String(property)}`);
|
||||
},
|
||||
},
|
||||
) as unknown as PrismaClient;
|
||||
|
||||
const context = (kind: string, spaceId = "space-home") => ({
|
||||
userId: "user-1",
|
||||
spaceId,
|
||||
bot: { id: "bot-1", kind },
|
||||
});
|
||||
|
||||
describe("coach tools", () => {
|
||||
it("are the personal main agent's alone, also in its check-in routines, and never for other people", () => {
|
||||
for (const name of COACH_TOOL_NAMES) {
|
||||
expect(toolNames({ mainBot: true })).toContain(name);
|
||||
expect(toolNames({ mainBot: true, trigger: "routine" })).toContain(name);
|
||||
expect(toolNames({ mainBot: false })).not.toContain(name);
|
||||
expect(toolNames({ mainBot: true, projectLead: true })).not.toContain(name);
|
||||
expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain(name);
|
||||
expect(toolNames({ mainBot: true, foreignAgentRun: true })).not.toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuse specialists and project leads even when they call a tool anyway", async () => {
|
||||
for (const name of COACH_TOOL_NAMES) {
|
||||
for (const caller of [context("specialist"), context("main", "space-project")]) {
|
||||
await expect(
|
||||
executeCoachTool(lookupOnlyPrisma, caller, name, { title: "Run" }),
|
||||
).resolves.toEqual({ error: "Only the user's main bot can coach the user." });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reject invalid or incomplete arguments before writing", async () => {
|
||||
const main = context("main");
|
||||
await expect(executeCoachTool(lookupOnlyPrisma, main, "update_goal", {})).resolves.toEqual({
|
||||
error: "A new goal needs a title.",
|
||||
});
|
||||
await expect(
|
||||
executeCoachTool(lookupOnlyPrisma, main, "update_goal", { title: "x", target_date: "soon" }),
|
||||
).resolves.toEqual({ error: expect.stringContaining("target_date") });
|
||||
await expect(
|
||||
executeCoachTool(lookupOnlyPrisma, main, "update_habit", {
|
||||
title: "Walk",
|
||||
cadence: "hourly",
|
||||
}),
|
||||
).resolves.toEqual({ error: expect.stringContaining("cadence") });
|
||||
await expect(
|
||||
executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { habit: "Walk", goal: "10k" }),
|
||||
).resolves.toEqual({ error: "Log a habit and a goal in separate calls." });
|
||||
await expect(
|
||||
executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { mood: 9 }),
|
||||
).resolves.toEqual({ error: expect.stringContaining("mood") });
|
||||
await expect(
|
||||
executeCoachTool(lookupOnlyPrisma, main, "write_daily_brief", { text: " " }),
|
||||
).resolves.toEqual({ error: expect.stringContaining("text") });
|
||||
});
|
||||
});
|
||||
357
packages/adapters/src/coach-tools.ts
Normal file
357
packages/adapters/src/coach-tools.ts
Normal file
@ -0,0 +1,357 @@
|
||||
import type { ConnectorTool } from "@rakazo/adapter-kit";
|
||||
import {
|
||||
CHECK_IN_NOTE_MAX_LENGTH,
|
||||
COACH_TITLE_MAX_LENGTH,
|
||||
DAILY_BRIEF_MAX_LENGTH,
|
||||
GOAL_DETAIL_MAX_LENGTH,
|
||||
GOAL_STATUSES,
|
||||
GoalStatusSchema,
|
||||
HABIT_CADENCES,
|
||||
HabitCadenceSchema,
|
||||
LocalDateSchema,
|
||||
} from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import {
|
||||
CoachNotFoundError,
|
||||
createGoal,
|
||||
createHabit,
|
||||
InvalidCoachInputError,
|
||||
isPersonalMainBot,
|
||||
logCheckIn,
|
||||
updateGoal,
|
||||
updateHabit,
|
||||
upsertDailyBrief,
|
||||
} from "@rakazo/db";
|
||||
import { z } from "zod";
|
||||
|
||||
/** The main bot's coach tools, in the order they appear in its tool list. */
|
||||
export const COACH_TOOL_NAMES = [
|
||||
"update_goal",
|
||||
"update_habit",
|
||||
"log_check_in",
|
||||
"write_daily_brief",
|
||||
] as const;
|
||||
export type CoachToolName = (typeof COACH_TOOL_NAMES)[number];
|
||||
|
||||
export function isCoachToolName(name: string): name is CoachToolName {
|
||||
return (COACH_TOOL_NAMES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
const DATE_HINT = "The user's local date, YYYY-MM-DD.";
|
||||
|
||||
export const coachTools: ConnectorTool[] = [
|
||||
{
|
||||
name: "update_goal",
|
||||
description:
|
||||
"Track a goal the user committed to. Without id it creates one; with id it changes it, and status done or dropped closes it. Track only what the user stated or agreed to.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Id or exact title of the goal to change." },
|
||||
title: { type: "string", description: 'A short outcome, e.g. "Run a 10k in November".' },
|
||||
detail: { type: "string", description: "Optional context, such as why it matters." },
|
||||
status: { type: "string", enum: [...GOAL_STATUSES] },
|
||||
target_date: { type: "string", description: `${DATE_HINT} An empty string clears it.` },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_habit",
|
||||
description:
|
||||
"Track a habit the user wants to keep. Without id it creates one; with id it changes it, and active false stops tracking it.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Id or exact title of the habit to change." },
|
||||
title: { type: "string", description: 'A short practice, e.g. "Walk 20 minutes".' },
|
||||
cadence: { type: "string", enum: [...HABIT_CADENCES] },
|
||||
goal: {
|
||||
type: "string",
|
||||
description: "Id or exact title of the goal it serves. An empty string unlinks it.",
|
||||
},
|
||||
active: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "log_check_in",
|
||||
description:
|
||||
"Record what the user reported for a day: a habit kept or missed, progress on a goal, or, with neither, how the day went. Logging the same day again updates it.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
habit: { type: "string", description: "Id or exact title of the habit." },
|
||||
goal: { type: "string", description: "Id or exact title of the goal." },
|
||||
date: { type: "string", description: `${DATE_HINT} Defaults to today.` },
|
||||
done: { type: "boolean", description: "Whether the habit was kept." },
|
||||
mood: { type: "integer", minimum: 1, maximum: 5 },
|
||||
energy: { type: "integer", minimum: 1, maximum: 5 },
|
||||
note: { type: "string", description: "The user's words, briefly." },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "write_daily_brief",
|
||||
description:
|
||||
"Save the user's brief for the day, shown at the top of their Today page. Plain text, two to five short lines. Writing again replaces it.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
date: { type: "string", description: `${DATE_HINT} Defaults to today.` },
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const Ref = z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH);
|
||||
const ClearableDate = z.union([LocalDateSchema, z.literal("")]);
|
||||
|
||||
const UpdateGoalArgs = z.object({
|
||||
id: Ref.optional(),
|
||||
title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(),
|
||||
detail: z.string().trim().max(GOAL_DETAIL_MAX_LENGTH).optional(),
|
||||
status: GoalStatusSchema.optional(),
|
||||
target_date: ClearableDate.optional(),
|
||||
});
|
||||
|
||||
const UpdateHabitArgs = z.object({
|
||||
id: Ref.optional(),
|
||||
title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(),
|
||||
cadence: HabitCadenceSchema.optional(),
|
||||
goal: z.union([Ref, z.literal("")]).optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const Rating = z.number().int().min(1).max(5);
|
||||
|
||||
const LogCheckInArgs = z.object({
|
||||
habit: Ref.optional(),
|
||||
goal: Ref.optional(),
|
||||
date: LocalDateSchema.optional(),
|
||||
done: z.boolean().optional(),
|
||||
mood: Rating.optional(),
|
||||
energy: Rating.optional(),
|
||||
note: z.string().trim().max(CHECK_IN_NOTE_MAX_LENGTH).optional(),
|
||||
});
|
||||
|
||||
const WriteDailyBriefArgs = z.object({
|
||||
text: z.string().trim().min(1).max(DAILY_BRIEF_MAX_LENGTH),
|
||||
date: LocalDateSchema.optional(),
|
||||
});
|
||||
|
||||
export interface CoachToolContext {
|
||||
userId: string;
|
||||
/** The space the run happens in. */
|
||||
spaceId: string;
|
||||
bot: { id: string; kind: string };
|
||||
}
|
||||
|
||||
type ToolResult = Record<string, unknown>;
|
||||
|
||||
function invalid(name: string, error: z.ZodError): ToolResult {
|
||||
const issues = error.issues
|
||||
.map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`)
|
||||
.join("; ");
|
||||
return { error: `Invalid ${name} arguments: ${issues}` };
|
||||
}
|
||||
|
||||
/** An id first, then an exact title (any case) among the user's items. */
|
||||
async function resolveRef(
|
||||
find: (where: object) => Promise<Array<{ id: string }>>,
|
||||
ref: string,
|
||||
what: "goal" | "habit",
|
||||
): Promise<string | ToolResult> {
|
||||
const byId = await find({ id: ref });
|
||||
if (byId[0]) return byId[0].id;
|
||||
const byTitle = await find({ title: { equals: ref, mode: "insensitive" } });
|
||||
if (byTitle.length === 1) return byTitle[0]!.id;
|
||||
return byTitle.length > 1
|
||||
? { error: `Several ${what}s are titled "${ref}". Use the id.` }
|
||||
: { error: `No ${what} "${ref}".` };
|
||||
}
|
||||
|
||||
function goalRef(prisma: PrismaClient, userId: string, ref: string) {
|
||||
return resolveRef(
|
||||
(where) =>
|
||||
prisma.userGoal.findMany({
|
||||
where: { userId, ...where },
|
||||
orderBy: [{ status: "asc" }, { createdAt: "desc" }],
|
||||
select: { id: true },
|
||||
take: 2,
|
||||
}),
|
||||
ref,
|
||||
"goal",
|
||||
);
|
||||
}
|
||||
|
||||
function habitRef(prisma: PrismaClient, userId: string, ref: string) {
|
||||
return resolveRef(
|
||||
(where) =>
|
||||
prisma.habit.findMany({
|
||||
where: { userId, ...where },
|
||||
orderBy: [{ active: "desc" }, { createdAt: "desc" }],
|
||||
select: { id: true },
|
||||
take: 2,
|
||||
}),
|
||||
ref,
|
||||
"habit",
|
||||
);
|
||||
}
|
||||
|
||||
async function updateGoalFromTool(
|
||||
prisma: PrismaClient,
|
||||
context: CoachToolContext,
|
||||
rawArgs: Record<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
const parsed = UpdateGoalArgs.safeParse(rawArgs);
|
||||
if (!parsed.success) return invalid("update_goal", parsed.error);
|
||||
const args = parsed.data;
|
||||
const targetDate = args.target_date === undefined ? undefined : args.target_date || null;
|
||||
if (!args.id) {
|
||||
if (!args.title) return { error: "A new goal needs a title." };
|
||||
const goal = await createGoal(prisma, context.userId, {
|
||||
title: args.title,
|
||||
detail: args.detail,
|
||||
targetDate,
|
||||
source: "chat",
|
||||
createdByBotId: context.bot.id,
|
||||
});
|
||||
return { ok: true, goal: { id: goal.id, title: goal.title, status: goal.status } };
|
||||
}
|
||||
const goalId = await goalRef(prisma, context.userId, args.id);
|
||||
if (typeof goalId !== "string") return goalId;
|
||||
const goal = await updateGoal(prisma, context.userId, goalId, {
|
||||
title: args.title,
|
||||
detail: args.detail,
|
||||
status: args.status,
|
||||
targetDate,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
goal: { id: goal.id, title: goal.title, status: goal.status, targetDate: goal.targetDate },
|
||||
};
|
||||
}
|
||||
|
||||
async function updateHabitFromTool(
|
||||
prisma: PrismaClient,
|
||||
context: CoachToolContext,
|
||||
rawArgs: Record<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
const parsed = UpdateHabitArgs.safeParse(rawArgs);
|
||||
if (!parsed.success) return invalid("update_habit", parsed.error);
|
||||
const args = parsed.data;
|
||||
let goalId: string | null | undefined;
|
||||
if (args.goal) {
|
||||
const resolved = await goalRef(prisma, context.userId, args.goal);
|
||||
if (typeof resolved !== "string") return resolved;
|
||||
goalId = resolved;
|
||||
} else if (args.goal === "") {
|
||||
goalId = null;
|
||||
}
|
||||
if (!args.id) {
|
||||
if (!args.title) return { error: "A new habit needs a title." };
|
||||
const habit = await createHabit(prisma, context.userId, {
|
||||
title: args.title,
|
||||
cadence: args.cadence,
|
||||
goalId,
|
||||
source: "chat",
|
||||
createdByBotId: context.bot.id,
|
||||
});
|
||||
return { ok: true, habit: { id: habit.id, title: habit.title, cadence: habit.cadence } };
|
||||
}
|
||||
const habitId = await habitRef(prisma, context.userId, args.id);
|
||||
if (typeof habitId !== "string") return habitId;
|
||||
const habit = await updateHabit(prisma, context.userId, habitId, {
|
||||
title: args.title,
|
||||
cadence: args.cadence,
|
||||
goalId,
|
||||
active: args.active,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
habit: { id: habit.id, title: habit.title, cadence: habit.cadence, active: habit.active },
|
||||
};
|
||||
}
|
||||
|
||||
async function logCheckInFromTool(
|
||||
prisma: PrismaClient,
|
||||
context: CoachToolContext,
|
||||
rawArgs: Record<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
const parsed = LogCheckInArgs.safeParse(rawArgs);
|
||||
if (!parsed.success) return invalid("log_check_in", parsed.error);
|
||||
const { habit, goal, ...fields } = parsed.data;
|
||||
if (habit && goal) return { error: "Log a habit and a goal in separate calls." };
|
||||
const habitId = habit ? await habitRef(prisma, context.userId, habit) : undefined;
|
||||
if (habitId !== undefined && typeof habitId !== "string") return habitId;
|
||||
const goalId = goal ? await goalRef(prisma, context.userId, goal) : undefined;
|
||||
if (goalId !== undefined && typeof goalId !== "string") return goalId;
|
||||
const checkIn = await logCheckIn(prisma, context.userId, {
|
||||
kind: habitId ? "habit" : goalId ? "goal" : "daily",
|
||||
habitId,
|
||||
goalId,
|
||||
...fields,
|
||||
createdByBotId: context.bot.id,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
checkIn: {
|
||||
kind: checkIn.kind,
|
||||
date: checkIn.date,
|
||||
done: checkIn.done,
|
||||
mood: checkIn.mood,
|
||||
energy: checkIn.energy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeDailyBriefFromTool(
|
||||
prisma: PrismaClient,
|
||||
context: CoachToolContext,
|
||||
rawArgs: Record<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
const parsed = WriteDailyBriefArgs.safeParse(rawArgs);
|
||||
if (!parsed.success) return invalid("write_daily_brief", parsed.error);
|
||||
const brief = await upsertDailyBrief(prisma, context.userId, {
|
||||
text: parsed.data.text,
|
||||
date: parsed.data.date,
|
||||
botId: context.bot.id,
|
||||
});
|
||||
return { ok: true, date: brief.date };
|
||||
}
|
||||
|
||||
const HANDLERS: Record<
|
||||
CoachToolName,
|
||||
(
|
||||
prisma: PrismaClient,
|
||||
context: CoachToolContext,
|
||||
rawArgs: Record<string, unknown>,
|
||||
) => Promise<ToolResult>
|
||||
> = {
|
||||
update_goal: updateGoalFromTool,
|
||||
update_habit: updateHabitFromTool,
|
||||
log_check_in: logCheckInFromTool,
|
||||
write_daily_brief: writeDailyBriefFromTool,
|
||||
};
|
||||
|
||||
/** Runs one coach tool for the user's personal main agent; every other bot is refused. */
|
||||
export async function executeCoachTool(
|
||||
prisma: PrismaClient,
|
||||
context: CoachToolContext,
|
||||
name: CoachToolName,
|
||||
rawArgs: Record<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
if (!(await isPersonalMainBot(prisma, { kind: context.bot.kind, spaceId: context.spaceId }))) {
|
||||
return { error: "Only the user's main bot can coach the user." };
|
||||
}
|
||||
try {
|
||||
return await HANDLERS[name](prisma, context, rawArgs);
|
||||
} catch (error) {
|
||||
if (error instanceof CoachNotFoundError || error instanceof InvalidCoachInputError) {
|
||||
return { error: error.message };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { formatCurrentTimeInstruction, loadUserTimeZone } from "./current-time.js";
|
||||
import { formatCurrentTimeInstruction, formatLocalDate, loadUserTimeZone } from "./current-time.js";
|
||||
|
||||
describe("formatCurrentTimeInstruction", () => {
|
||||
it("anchors the present moment in UTC with the weekday and no sub-second noise", () => {
|
||||
@ -93,3 +93,12 @@ describe("loadUserTimeZone", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLocalDate", () => {
|
||||
it("is the user's local date, or the UTC date without a known zone", () => {
|
||||
const now = new Date("2026-01-01T03:00:00Z");
|
||||
expect(formatLocalDate("America/Los_Angeles", now)).toBe("2025-12-31");
|
||||
expect(formatLocalDate("Not/AZone", now)).toBe("2026-01-01");
|
||||
expect(formatLocalDate(null, now)).toBe("2026-01-01");
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,6 +27,14 @@ export function formatCurrentTimeInstruction(
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/** Today's date as YYYY-MM-DD in the user's time zone, or in UTC when none is known. */
|
||||
export function formatLocalDate(
|
||||
timeZone: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
return zonedClock(now, knownTimeZone(timeZone) ?? "UTC").local.slice(0, 10);
|
||||
}
|
||||
|
||||
/** The user's time zone from their profile when this runtime knows it. */
|
||||
export async function loadUserTimeZone(
|
||||
prisma: Pick<PrismaClient, "userProfile">,
|
||||
|
||||
@ -117,6 +117,9 @@ async function runBot(
|
||||
user: { findUnique: vi.fn(async () => ({ name: "Ada" })) },
|
||||
userProfile: { findUnique: vi.fn(async () => null) },
|
||||
userFact: { findMany: vi.fn(async () => []) },
|
||||
// The main bot's goals and habits.
|
||||
userGoal: { findMany: vi.fn(async () => []) },
|
||||
habit: { findMany: vi.fn(async () => []) },
|
||||
...models,
|
||||
};
|
||||
let request: AgentRunRequest | undefined;
|
||||
|
||||
@ -319,6 +319,14 @@ describe("mutating tool effect idempotency keys", () => {
|
||||
]);
|
||||
await f.run();
|
||||
expect(f.memoryCommit).toHaveBeenCalledOnce();
|
||||
// remember appends, so only the effect record keeps a retry from adding the note twice.
|
||||
expect(f.memoryCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "append",
|
||||
content: expect.stringMatching(/^- \d{4}-\d{2}-\d{2}: durable fact$/),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(f.effects).toHaveLength(1);
|
||||
|
||||
f.setCalls([
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user