Merge remote main into local

This commit is contained in:
Cynar 2026-10-23 13:30:27 +00:00
commit f2841bdd74
265 changed files with 18959 additions and 4702 deletions

View File

@ -477,6 +477,9 @@ jobs:
eas-version: latest
packager: pnpm
token: ${{ secrets.EXPO_TOKEN }}
# Its default caches go to the GitHub cache service; keep them on the runner.
eas-cache: false
expo-cache: false
- name: Install dependencies
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile

View File

@ -29,6 +29,7 @@ import {
createMediaRuntime,
createMessagingContextLoader,
createMessagingTeamChatSender,
createPostgresReconciliationLeadership,
createProfileConsolidationScheduler,
createRunExecutor,
createRunSandbox,
@ -360,7 +361,7 @@ export async function createApp(
"https://172.*:*",
"http://*.local:*",
"https://*.local:*",
...(process.env.EXTRA_TRUSTED_ORIGINS?.split(",").map((s) => s.trim()) ?? []),
...(env.extraTrustedOrigins ?? []),
],
beforeDeleteUser: async (userId) => {
const bots = await prisma.bot.findMany({
@ -474,26 +475,30 @@ export async function createApp(
cloudAgent,
media,
});
let graphileWorkerHost: GraphileJobWorkerHost | undefined;
if (inMemoryJobs) {
await inMemoryJobs.start(jobHandlers);
} else if (jobPool) {
const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: 4,
graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: parsePositiveInteger(process.env.GRAPHILE_WORKER_CONCURRENCY, 4),
pollInterval: 500,
});
await graphileWorkerHost.start(jobHandlers);
}
const reconciler = inMemoryJobs
? createJobReconciler({
prisma,
jobs,
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
reconcileMediaJobs: () => media.reconcile(),
})
: undefined;
const reconciler =
inMemoryJobs || !prismaOverride
? createJobReconciler({
prisma,
jobs,
events,
leadership: jobPool ? createPostgresReconciliationLeadership(jobPool) : undefined,
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
reconcileMediaJobs: () => media.reconcile(),
})
: undefined;
reconciler?.start();
const router = createRouter({
@ -612,7 +617,7 @@ export async function createApp(
actor,
signal: c.req.raw.signal,
userAgent: c.req.header("user-agent"),
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin),
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin, env.extraTrustedOrigins),
},
});
if (matched) return c.newResponse(response.body, response);
@ -1006,6 +1011,7 @@ export async function createApp(
await teamChatBridge?.stop();
await email?.drain?.();
await reconciler?.stop();
await graphileWorkerHost?.stop();
await jobs.close();
await realtime.close();
await connector.stop();
@ -1021,6 +1027,7 @@ export async function createApp(
function isTrustedOrigin(origin: string, env: AppEnv) {
if (!origin) return true;
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) return true;
if (env.extraTrustedOrigins?.includes(origin)) return true;
if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
try {
const host = new URL(origin).hostname;

View File

@ -20,6 +20,7 @@ export interface AppEnv {
authSecret: string;
authUrl: string;
webOrigin: string;
extraTrustedOrigins: string[];
privacyPolicyUrl?: string;
apiUrl: string;
apiHost: string;
@ -109,6 +110,10 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
authSecret,
authUrl: source.BETTER_AUTH_URL ?? source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
webOrigin: source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
extraTrustedOrigins: (source.EXTRA_TRUSTED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
privacyPolicyUrl: optional(source.PRIVACY_POLICY_URL),
apiUrl: source.API_URL ?? "http://127.0.0.1:3100",
apiHost: source.API_HOST ?? "127.0.0.1",

View File

@ -135,6 +135,7 @@ import {
createProjectGoalRepos,
createRepos,
createSpaceForMember,
DuplicateSpaceNameError,
deleteEmptySpaceForMember,
deleteUnreferencedCredentialSecret,
ensureMainConversation,
@ -270,7 +271,10 @@ import {
followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage,
removeQueuedMessage,
resolveThreadTarget,
resumeThreadRuns,
sendQueuedThreadMessagesNow,
sendThreadMessage,
setThreadUnreadState,
stopThreadRuns,
@ -783,15 +787,26 @@ export function createRouter(deps: RouterDeps) {
const goal =
input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null);
if (goal) {
await deps.prisma.memoryDocument.create({
data: {
spaceId: space.id,
userId: context.actor.userId,
scope: "user",
path: "PROJECT_GOAL.md",
content: goal,
},
const existingGoal = await deps.prisma.memoryDocument.findFirst({
where: { spaceId: space.id, scope: "user", path: "PROJECT_GOAL.md" },
select: { id: true },
});
if (existingGoal) {
await deps.prisma.memoryDocument.update({
where: { id: existingGoal.id },
data: { content: goal },
});
} else {
await deps.prisma.memoryDocument.create({
data: {
spaceId: space.id,
userId: context.actor.userId,
scope: "user",
path: "PROJECT_GOAL.md",
content: goal,
},
});
}
}
// A project starts as a conversation with its own main agent.
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
@ -873,7 +888,7 @@ export function createRouter(deps: RouterDeps) {
throw new ORPCError("NOT_FOUND", { message: error.message });
}
if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN");
if (error instanceof InvalidSpaceNameError) {
if (error instanceof InvalidSpaceNameError || error instanceof DuplicateSpaceNameError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
@ -2050,6 +2065,42 @@ export function createRouter(deps: RouterDeps) {
await stopThreadRuns(deps, context.actor, target);
return { ok: true as const };
}),
resume: authed.threads.resume.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return resumeThreadRuns(deps, context.actor, target);
}),
sendNow: authed.threads.sendNow.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return sendQueuedThreadMessagesNow(deps, context.actor, target, {
messageId: input.messageId,
});
}),
removeQueuedMessage: authed.threads.removeQueuedMessage.handler(
async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return removeQueuedMessage(
{ prisma: deps.prisma, events: deps.events },
context.actor,
target,
input.messageId,
);
},
),
clear: authed.threads.clear.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
const contextBotId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
@ -5623,13 +5674,13 @@ async function spaceNavigationDto(
// The user's own main agent of a project is part of the project, not content to clear.
deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true },
distinct: ["spaceId"],
select: { spaceId: true, userId: true },
distinct: ["spaceId", "userId"],
}),
deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true },
distinct: ["spaceId"],
select: { spaceId: true, userId: true },
distinct: ["spaceId", "userId"],
}),
deps.prisma.memoryDocument.findMany({
where: { spaceId: { in: spaceIds }, scope: "user", path: "PROJECT_GOAL.md" },
@ -5640,6 +5691,11 @@ async function spaceNavigationDto(
...contentBots.map((row) => row.spaceId),
...contentGroups.map((row) => row.spaceId),
]);
const spacesBlockedByOtherMember = new Set(
[...contentBots, ...contentGroups]
.filter((row) => row.userId !== actor.userId)
.map((row) => row.spaceId),
);
const goalBySpaceId = new Map(goalDocuments.map((doc) => [doc.spaceId, doc.content]));
const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId);
if (!currentMembership) throw new IsolationError();
@ -5676,7 +5732,7 @@ async function spaceNavigationDto(
membership.role === "owner" &&
!membership.space.isDefault &&
memberships.length > 1 &&
!spacesWithContent.has(membership.spaceId) &&
!spacesBlockedByOtherMember.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
canEdit: membership.role === "owner",
// A linked folder is on the owner's host; other members see neither its path nor remote.

View File

@ -1,6 +1,7 @@
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
import { Prisma, type PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
const SEARCH_LIMIT = 25;
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
@ -64,19 +65,65 @@ export async function querySpaceSearch(
bucket.push(hit);
}
const bots = await prisma.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ title: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
},
take: CONVERSATION_HIT_LIMIT,
});
const [bots, groups, artifacts, groupArtifacts, routines] = await Promise.all([
prisma.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ title: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
},
take: CONVERSATION_HIT_LIMIT,
}),
prisma.chatGroup.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { contains: query, mode: "insensitive" },
},
take: CONVERSATION_HIT_LIMIT,
}),
prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: null,
botId: { not: null },
name: { contains: query, mode: "insensitive" },
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: { not: null },
name: { contains: query, mode: "insensitive" },
},
include: { group: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
prisma.routine.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ prompt: { contains: query, mode: "insensitive" } },
],
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
]);
for (const bot of bots) {
pushInto(
conversationHits,
@ -91,14 +138,6 @@ export async function querySpaceSearch(
);
}
const groups = await prisma.chatGroup.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { contains: query, mode: "insensitive" },
},
take: CONVERSATION_HIT_LIMIT,
});
for (const group of groups) {
pushInto(
conversationHits,
@ -114,27 +153,72 @@ export async function querySpaceSearch(
}
const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length);
const pattern = `%${query}%`;
const [botArtifactMessages, groupArtifactMessages, messageRows, groupMessageRows] =
await Promise.all([
findArtifactMessages(
prisma,
actor,
artifacts.flatMap((artifact) =>
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
),
"bot",
),
findArtifactMessages(
prisma,
actor,
groupArtifacts.flatMap((artifact) =>
artifact.groupId && artifact.group
? [{ id: artifact.id, targetId: artifact.groupId }]
: [],
),
"group",
),
prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
botId: string;
botName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN bots b ON b.id = t."botId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND b."archivedAt" IS NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`,
prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
groupId: string;
groupName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN chat_groups g ON g.id = t."groupId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" IS NOT NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`,
]);
const artifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: null,
botId: { not: null },
name: { contains: query, mode: "insensitive" },
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
const botArtifactMessages = await findArtifactMessages(
prisma,
actor,
artifacts.flatMap((artifact) =>
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
),
"bot",
);
for (const artifact of artifacts) {
if (!artifact.botId || !artifact.bot) continue;
const message = botArtifactMessages.get(artifact.id);
@ -155,24 +239,6 @@ export async function querySpaceSearch(
);
}
const groupArtifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: { not: null },
name: { contains: query, mode: "insensitive" },
},
include: { group: { select: { name: true } } },
take: SEARCH_LIMIT,
});
const groupArtifactMessages = await findArtifactMessages(
prisma,
actor,
groupArtifacts.flatMap((artifact) =>
artifact.groupId && artifact.group ? [{ id: artifact.id, targetId: artifact.groupId }] : [],
),
"group",
);
for (const artifact of groupArtifacts) {
if (!artifact.groupId || !artifact.group) continue;
const message = groupArtifactMessages.get(artifact.id);
@ -193,19 +259,6 @@ export async function querySpaceSearch(
);
}
const routines = await prisma.routine.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ prompt: { contains: query, mode: "insensitive" } },
],
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
for (const routine of routines) {
pushInto(
contentHits,
@ -221,29 +274,6 @@ export async function querySpaceSearch(
);
}
const pattern = `%${query}%`;
const messageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
botId: string;
botName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN bots b ON b.id = t."botId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND b."archivedAt" IS NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of messageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
botId: row.botId,
@ -255,28 +285,6 @@ export async function querySpaceSearch(
});
}
const groupMessageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
groupId: string;
groupName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN chat_groups g ON g.id = t."groupId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" IS NOT NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of groupMessageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
groupId: row.groupId,

View File

@ -88,4 +88,77 @@ describe("model team chat engagement judge", () => {
expect(instructions).toContain("(Europe/Berlin, the user's time zone)");
expect(instructions).toContain("low-cost engagement judge");
});
it("prefers the space fast model tier when mapped and no explicit judge override is set", async () => {
let selectedModel: { provider: string; id: string; apiKey?: string } | undefined;
const runtime = {
run: vi.fn((request: { model: { provider: string; id: string; apiKey?: string } }) => {
selectedModel = request.model;
return (async function* () {
yield { type: "done" as const, text: '{"act":true,"reason":"Direct mention."}' };
})();
}),
} as unknown as AgentRuntime;
const prisma = {
deploymentSettings: { findUnique: vi.fn(async () => null) },
spaceModelPreference: { findFirst: vi.fn(async () => null) },
spaceModelTier: {
findUnique: vi.fn(async () => ({
tier: "fast",
credentialId: "cred-fast",
modelId: "gemma4:latest",
credential: {
id: "cred-fast",
userId: "user-1",
provider: "openai-compatible",
secretId: "sec-fast",
preferences: [{ isDefault: false, modelId: null }],
},
})),
},
secret: {
findFirst: vi.fn(async () => ({ id: "sec-fast", ciphertext: "cipher-fast" })),
},
userProfile: { findUnique: vi.fn(async () => ({ timeZone: "UTC" })) },
} as unknown as PrismaClient;
const secrets = {
load: vi.fn(() =>
JSON.stringify({
kind: "openai_compatible",
apiKey: "local-key",
baseUrl: "http://127.0.0.1:11434/v1",
}),
),
} as unknown as EncryptedSecretStore;
const judge = new ModelTeamChatEngagementJudge({
prisma,
runtime,
secrets,
deploymentProvider: "openrouter",
deploymentModel: "expensive-default",
});
const decision = await judge.decide({
bot: {
id: "bot-1",
spaceId: "space-1",
userId: "user-1",
name: "Arthur",
modelProvider: "anthropic",
modelId: "claude-opus",
},
channelId: "C1",
rules: "",
messages: [],
});
expect(decision).toEqual({ act: true, reason: "Direct mention.", askedByEventId: undefined });
expect(selectedModel).toMatchObject({
provider: "openai-compatible",
id: "gemma4:latest",
apiKey: "local-key",
baseUrl: "http://127.0.0.1:11434/v1",
});
});
});

View File

@ -1,14 +1,15 @@
import { randomUUID } from "node:crypto";
import type { AgentModelOAuthCredential, AgentRuntime } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import {
type EncryptedSecretStore,
formatCurrentTimeInstruction,
loadUserTimeZone,
resolveModelAuth,
serializeModelSecret,
toOAuthCredential,
} from "@rakazo/adapters";
import { findDefaultModelCredential, findModelCredential, type PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { findDefaultModelCredential, findModelCredential, findSpaceModelTier } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
const MAX_RULES_CHARS = 4_000;
@ -191,27 +192,39 @@ export class ModelTeamChatEngagementJudge implements TeamChatEngagementJudge {
};
};
} | null> {
const settings = await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const scope = { userId: bot.userId, spaceId: bot.spaceId };
const defaultCredential = await findDefaultModelCredential(this.deps.prisma, scope);
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const fastTier =
!explicitJudgeOverride && this.deps.prisma.spaceModelTier
? await findSpaceModelTier(this.deps.prisma, scope, "fast")
: null;
const defaultCredential = fastTier
? null
: await findDefaultModelCredential(this.deps.prisma, scope);
const requestedProvider = this.deps.providerOverride ?? bot.modelProvider;
const requestedModel = this.deps.modelOverride ?? bot.modelId;
const overrideCredential = requestedProvider
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
: null;
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const overrideCredential =
!fastTier && requestedProvider
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
: null;
const useOverride = Boolean(
requestedProvider && requestedModel && (explicitJudgeOverride || overrideCredential),
);
const credential = useOverride ? overrideCredential : defaultCredential;
const credential =
fastTier?.credential ?? (useOverride ? overrideCredential : defaultCredential);
const settings = fastTier
? null
: await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const provider =
fastTier?.credential.provider ??
(useOverride ? requestedProvider : null) ??
credential?.provider ??
settings?.defaultModelProvider ??
this.deps.deploymentProvider;
const modelId =
fastTier?.modelId ??
(useOverride ? requestedModel : null) ??
credential?.defaultModel ??
settings?.defaultModelId ??

View File

@ -2,15 +2,17 @@ import type { SandboxProvider } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import type { ThreadTarget } from "./thread-target.js";
import {
cancelSupersededQueuedRuns,
followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage,
resolveGroupFollowUpBotId,
resumeThreadRuns,
sendQueuedThreadMessagesNow,
sendThreadMessage,
stopThreadRuns,
type ThreadTarget,
threadHead,
threadSnapshot,
} from "./thread-target.js";
@ -2118,4 +2120,192 @@ describe("stopThreadRuns", () => {
expect.objectContaining({ runId: "run-b" }),
);
});
it("sendQueuedThreadMessagesNow cancels the active run, preserves pending steering, and enqueues an immediate continuation run", async () => {
const queuedCreatedAt = new Date("2026-01-01T10:00:05.000Z");
const transaction = {
$queryRaw: vi.fn(),
steeringMessage: {
findMany: vi.fn().mockResolvedValue([
{
id: "steer-1",
botId: "bot-a",
userId: "user-1",
messageId: "msg-queued-1",
claimedAt: null,
message: {
id: "msg-queued-1",
blocks: [{ kind: "text", text: "Send this right now" }],
seq: 2,
createdAt: queuedCreatedAt,
},
},
]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
run: {
updateManyAndReturn: vi.fn().mockResolvedValue([{ id: "run-old", taskId: "task-old" }]),
create: vi.fn().mockResolvedValue({ id: "run-new", status: "queued" }),
},
task: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue({ id: "task-new" }),
},
message: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
computerExecutionLease: {
findMany: vi.fn().mockResolvedValue([]),
},
computer: {
findMany: vi.fn().mockResolvedValue([]),
},
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 42 }),
},
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) =>
callback(transaction),
),
computerExecutionLease: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
computer: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
event: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
} as unknown as PrismaClient;
const jobs = {
enqueue: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn().mockResolvedValue(undefined),
};
const events = {
notify: vi.fn().mockResolvedValue(undefined),
};
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-a",
threadId: "thread-1",
bot: {} as never,
} satisfies ThreadTarget;
const result = await sendQueuedThreadMessagesNow(
{
prisma,
sandbox: {} as SandboxProvider,
events: events as never,
jobs: jobs as never,
},
actor,
target,
{ messageId: "msg-queued-1" },
);
expect(result).toEqual({ ok: true, runIds: ["run-new"] });
expect(transaction.run.updateManyAndReturn).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
threadId: "thread-1",
botId: { in: ["bot-a"] },
}),
data: expect.objectContaining({ status: "cancelled" }),
}),
);
expect(transaction.steeringMessage.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["steer-1"] } },
data: { runId: "run-new", claimedAt: null },
});
expect(jobs.cancel).toHaveBeenCalledWith("run:run-old");
expect(jobs.enqueue).toHaveBeenCalledWith({
name: "run.continue",
payload: { runId: "run-new" },
replaceKey: "run:run-new",
});
expect(events.notify).toHaveBeenCalledWith("thread-1", 42);
});
});
describe("resumeThreadRuns", () => {
it("re-queues the latest cancelled or failed run without creating a duplicate message", async () => {
const transaction = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: "run-cancelled",
taskId: "task-cancelled",
botId: "bot-a",
status: "cancelled",
createdAt: new Date("2026-04-20T10:00:00.000Z"),
completedAt: new Date("2026-04-20T10:02:00.000Z"),
},
]),
update: vi.fn().mockResolvedValue({}),
},
task: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 19 }),
},
};
const prisma = {
$transaction: vi.fn(async (fn: (tx: typeof transaction) => Promise<unknown>) =>
fn(transaction),
),
} as unknown as PrismaClient;
const jobs = {
enqueue: vi.fn().mockResolvedValue(undefined),
};
const events = {
notify: vi.fn().mockResolvedValue(undefined),
};
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-a",
threadId: "thread-1",
bot: {} as never,
} satisfies ThreadTarget;
const result = await resumeThreadRuns(
{
prisma,
events: events as never,
jobs: jobs as never,
},
actor,
target,
);
expect(result).toEqual({ ok: true, runIds: ["run-cancelled"] });
expect(transaction.task.updateMany).toHaveBeenCalledWith({
where: { id: "task-cancelled" },
data: { status: "queued" },
});
expect(transaction.run.update).toHaveBeenCalledWith({
where: { id: "run-cancelled" },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
expect(jobs.enqueue).toHaveBeenCalledWith({
name: "run.continue",
payload: { runId: "run-cancelled" },
replaceKey: "run:run-cancelled",
});
expect(events.notify).toHaveBeenCalledWith("thread-1", 19);
});
});

View File

@ -1,5 +1,10 @@
import { ORPCError } from "@orpc/server";
import { type JobPublisher, runContinueJob, type SandboxProvider } from "@rakazo/adapter-kit";
import {
type JobPublisher,
runContinueJob,
runJobKey,
type SandboxProvider,
} from "@rakazo/adapter-kit";
import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@rakazo/adapters";
import {
type Actor,
@ -1151,63 +1156,50 @@ export async function reactToThreadMessage(
});
}
export async function stopThreadRuns(
async function snapshotCancelledRunTeardown(tx: Prisma.TransactionClient, runIds: string[]) {
// Snapshot teardown coordinates before commit. Once cancellation becomes
// visible, a worker can release its lease / execution columns immediately; a
// later lookup would then miss the sandbox work this request must stop.
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
// only a legacy secondary path and is not written by current acquisition.
const leases = runIds.length
? await tx.computerExecutionLease.findMany({
where: { runId: { in: runIds } },
select: { computerId: true, botId: true, runId: true, fence: true },
})
: [];
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
const computers = runIds.length
? await tx.computer.findMany({
where:
leaseComputerIds.length > 0
? {
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: runIds } }],
}
: { executionRunId: { in: runIds } },
select: {
id: true,
homeKey: true,
kind: true,
providerRef: true,
executionBotId: true,
executionRunId: true,
},
})
: [];
return { runIds, computers, leases };
}
async function teardownCancelledRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
target: ThreadTarget,
snapshot: Awaited<ReturnType<typeof snapshotCancelledRunTeardown>>,
) {
const { runIds, computers, leases } = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: new Date() },
select: { id: true },
});
const ids = cancelled.map((run) => run.id);
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
// Snapshot teardown coordinates before commit. Once cancellation becomes
// visible, a worker can release its lease / execution columns immediately; a
// later lookup would then miss the sandbox work this request must stop.
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
// only a legacy secondary path and is not written by current acquisition.
const leases = ids.length
? await tx.computerExecutionLease.findMany({
where: { runId: { in: ids } },
select: { computerId: true, botId: true, runId: true, fence: true },
})
: [];
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
const computers = ids.length
? await tx.computer.findMany({
where:
leaseComputerIds.length > 0
? {
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: ids } }],
}
: { executionRunId: { in: ids } },
select: {
id: true,
homeKey: true,
kind: true,
providerRef: true,
executionBotId: true,
executionRunId: true,
},
})
: [];
return { runIds: ids, computers, leases };
});
const { runIds, computers, leases } = snapshot;
if (runIds.length === 0) return;
// Keep the DB lease until after teardown so a replacement run cannot claim the
// screen while we still need the cancelled run's screenLeaseId to release it.
const computerById = new Map(computers.map((computer) => [computer.id, computer]));
@ -1285,6 +1277,381 @@ export async function stopThreadRuns(
});
}
export async function stopThreadRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
target: ThreadTarget,
) {
const snapshot = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: new Date() },
select: { id: true },
});
const ids = cancelled.map((run) => run.id);
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
return snapshotCancelledRunTeardown(tx, ids);
});
await teardownCancelledRuns(deps, actor, snapshot);
}
/**
* Resume the latest stopped (`cancelled`) or interrupted (`failed`) run in the thread
* without creating a duplicate user message row.
*/
export async function resumeThreadRuns(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const committed = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, status: true },
});
if (activeRuns.length > 0) {
return {
runs: activeRuns,
eventSeq: null as number | null,
};
}
const recentTerminals = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 20,
select: {
id: true,
botId: true,
taskId: true,
status: true,
createdAt: true,
completedAt: true,
},
});
const latestTerminal = pickLatestTerminalRun(recentTerminals);
if (
!latestTerminal ||
(latestTerminal.status !== "cancelled" && latestTerminal.status !== "failed")
) {
return {
runs: [] as Array<{ id: string; status: string }>,
eventSeq: null as number | null,
};
}
await tx.task.updateMany({
where: { id: latestTerminal.taskId },
data: { status: "queued" },
});
await tx.run.update({
where: { id: latestTerminal.id },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
if (target.kind === "group") {
await touchGroupUpdatedAt(tx, target.groupId);
}
const latestEvent = await tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return {
runs: [{ id: latestTerminal.id, status: "queued" }],
eventSeq: latestEvent?.seq ?? null,
};
});
if (committed.eventSeq !== null) {
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread resume realtime notification", error);
});
}
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
return {
ok: true as const,
runIds: committed.runs.map((run) => run.id),
};
}
/**
* Promote queued follow-up messages immediately: cancel any currently active runs
* holding up the queue without discarding unclaimed steering messages, create a
* new continuation run for the pending steering messages, and enqueue it right away.
*/
export async function sendQueuedThreadMessagesNow(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
input?: { messageId?: string },
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const committed = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const pendingSteering = await tx.steeringMessage.findMany({
where: {
botId: { in: botIds },
claimedAt: null,
message: { threadId: target.threadId },
},
include: {
message: { select: { id: true, blocks: true, seq: true, createdAt: true } },
},
orderBy: [{ message: { seq: "asc" } }, { id: "asc" }],
});
const pendingByBotId = new Map<string, typeof pendingSteering>();
for (const item of pendingSteering) {
const list = pendingByBotId.get(item.botId) ?? [];
list.push(item);
pendingByBotId.set(item.botId, list);
}
if (pendingByBotId.size === 0 && input?.messageId) {
const targetMsg = await tx.message.findFirst({
where: { id: input.messageId, threadId: target.threadId, role: "user" },
select: { id: true, blocks: true, seq: true, createdAt: true },
});
if (targetMsg) {
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
createdAt: { lt: targetMsg.createdAt },
},
select: { id: true, botId: true },
});
const fallbackBotId =
activeRuns[0]?.botId ??
(target.kind === "bot" ? target.botId : groupDefaultBotId(target));
if (fallbackBotId && activeRuns.length > 0) {
const steering = await tx.steeringMessage.create({
data: {
messageId: targetMsg.id,
botId: fallbackBotId,
userId: actor.userId,
runId: activeRuns[0]?.id ?? null,
},
});
pendingByBotId.set(fallbackBotId, [{ ...steering, message: targetMsg }]);
}
}
}
if (pendingByBotId.size === 0) {
return {
teardown: { runIds: [] as string[], computers: [], leases: [] },
runs: [] as Array<{ id: string; status: string }>,
eventSeq: null as number | null,
};
}
const affectedBotIds = [...pendingByBotId.keys()];
const now = new Date();
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
botId: { in: affectedBotIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: now },
select: { id: true, taskId: true },
});
const cancelledRunIds = cancelled.map((run) => run.id);
if (cancelled.length > 0) {
await tx.task.updateMany({
where: { id: { in: cancelled.map((run) => run.taskId) } },
data: { status: "cancelled" },
});
await tx.steeringMessage.deleteMany({
where: {
runId: { in: cancelledRunIds },
claimedAt: { not: null },
},
});
}
const teardown = await snapshotCancelledRunTeardown(tx, cancelledRunIds);
const createdRuns: Array<{ id: string; status: string }> = [];
for (const [botId, items] of pendingByBotId) {
const last = items.at(-1)!;
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
userId: items[0]!.userId,
prompt: "Respond to the user's steering context.",
status: "queued",
},
});
const runCreatedAt = new Date(Math.max(Date.now(), last.message.createdAt.getTime() + 1));
const run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
taskId: task.id,
userId: items[0]!.userId,
status: "queued",
trigger: "follow_up",
sourceMessageId: last.message.id,
createdAt: runCreatedAt,
},
select: { id: true, status: true },
});
await tx.steeringMessage.updateMany({
where: { id: { in: items.map((item) => item.id) } },
data: { runId: run.id, claimedAt: null },
});
await tx.message.updateMany({
where: { id: { in: items.map((item) => item.message.id) } },
data: { runId: run.id },
});
createdRuns.push(run);
}
if (target.kind === "group") {
await touchGroupUpdatedAt(tx, target.groupId);
}
const latestEvent = await tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return {
teardown,
runs: createdRuns,
eventSeq: latestEvent?.seq ?? null,
};
});
if (committed.teardown.runIds.length > 0) {
await Promise.all([
teardownCancelledRuns(deps, actor, committed.teardown),
...committed.teardown.runIds.map((runId) =>
deps.jobs.cancel(runJobKey(runId)).catch(() => undefined),
),
]);
}
if (committed.runs.length > 0) {
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
if (committed.eventSeq != null) {
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread sendNow realtime notification", error);
});
}
}
return {
ok: true as const,
runIds: committed.runs.map((run) => run.id),
};
}
export async function removeQueuedMessage(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
},
actor: Actor,
target: ThreadTarget,
messageId: string,
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const result = await deps.prisma.$transaction(async (tx) => {
// Verify the message exists and belongs to this thread
const message = await tx.message.findFirst({
where: {
id: messageId,
threadId: target.threadId,
role: "user",
},
});
if (!message) {
return { removed: false as const };
}
// Check if this message is queued (has an unclaimed steering message)
const steering = await tx.steeringMessage.findFirst({
where: {
messageId: messageId,
botId: { in: botIds },
claimedAt: null,
},
});
if (!steering) {
// No queued steering entry — nothing to remove
return { removed: false as const };
}
// Delete the steering message (unclaims it from the queue)
await tx.steeringMessage.delete({
where: { id: steering.id },
});
// Also clean up the user message if it's exclusively queued
// (no run attached to it)
if (!message.runId) {
await tx.message.delete({
where: { id: messageId },
});
}
return { removed: true as const };
});
if (result.removed) {
// Notify subscribers so the UI updates
const latest = await deps.prisma.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
await deps.events.notify(target.threadId, latest?.seq ?? 0).catch(() => undefined);
}
return { ok: true as const, removed: result.removed };
}
export async function setThreadUnreadState(
prisma: PrismaClient,
actor: Actor,

View File

@ -147,7 +147,11 @@ export async function persistVoiceCredential(
let voiceId = input.voiceId?.trim() ?? "";
if (!voiceId) {
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
voiceId = voices[0]?.id ?? "";
const germanVoice =
input.provider === "kokoro"
? voices.find((v) => v.id === "df_eva" || /^d[fm]_/i.test(v.id))
: undefined;
voiceId = germanVoice?.id ?? voices[0]?.id ?? "";
}
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
const cred = await withSerializableRetry(() =>

View File

@ -25,4 +25,12 @@ describe("requestWebOrigin", () => {
"https://evoo.example",
);
});
it("accepts origins listed in extraTrustedOrigins", () => {
expect(
requestWebOrigin("https://host.example.ts.net:8443", configured, [
"https://host.example.ts.net:8443",
]),
).toBe("https://host.example.ts.net:8443");
});
});

View File

@ -14,15 +14,20 @@ export function isLoopbackHost(host: string): boolean {
}
/** The web origin that browser-facing URLs (OAuth returns, screen links) should
* point at for this request. A browser opened over a loopback or private-network
* address gets its own origin back so a phone on the LAN is not sent to
* 127.0.0.1; any other caller gets the configured WEB_ORIGIN. */
export function requestWebOrigin(origin: string | undefined, webOrigin: string): string {
* point at for this request. A browser opened over a loopback, private-network,
* or explicitly trusted origin gets its own origin back so a phone on the LAN
* or Tailscale is not sent to 127.0.0.1; any other caller gets WEB_ORIGIN. */
export function requestWebOrigin(
origin: string | undefined,
webOrigin: string,
extraTrustedOrigins: readonly string[] = [],
): string {
if (!origin) return webOrigin;
try {
const url = new URL(origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return webOrigin;
if (url.origin === new URL(webOrigin).origin) return url.origin;
if (extraTrustedOrigins.includes(url.origin)) return url.origin;
return isPrivateOrLocalHost(url.hostname) ? url.origin : webOrigin;
} catch {
return webOrigin;

View File

@ -3,8 +3,15 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { Alert, Pressable, ScrollView, Text, TextInput } from "react-native";
import { BotMemberPicker } from "../components/bot-member-picker";
import { type MobileBot, type MobileGroup, rpc } from "../lib/api";
import {
type MobileBot,
type MobileGroup,
type MobileSpace,
type MobileSpaceNavigation,
rpc,
} from "../lib/api";
import { useI18n } from "../lib/i18n";
import { canDeleteInboxSpace, removeInboxSpace } from "../lib/inbox-spaces";
import { useMobileTokens } from "../lib/native";
export default function GroupSettingsScreen() {
@ -13,12 +20,16 @@ export default function GroupSettingsScreen() {
const router = useRouter();
const { groupId } = useLocalSearchParams<{ groupId: string }>();
const [group, setGroup] = useState<MobileGroup | null>(null);
const [projectSpace, setProjectSpace] = useState<MobileSpace | null>(null);
const [bots, setBots] = useState<MobileBot[]>([]);
const [name, setName] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const isMainConversation = group?.kind === "main";
const isProjectMain = isMainConversation && projectSpace !== null && !projectSpace.isDefault;
useEffect(() => {
if (!groupId) return;
void Promise.all([
@ -26,11 +37,19 @@ export default function GroupSettingsScreen() {
(groups) => groups.find((row) => row.id === groupId) ?? null,
),
rpc<MobileBot[]>("bots/list"),
rpc<MobileSpaceNavigation>("spaces/list").catch(() => null),
])
.then(([nextGroup, nextBots]) => {
.then(([nextGroup, nextBots, navigation]) => {
if (!nextGroup) throw new Error(t("Group not found"));
setGroup(nextGroup);
setName(nextGroup.name);
const space =
navigation?.spaces.find(
(item) => item.id === nextGroup.spaceId || item.id === navigation.current.id,
) ?? null;
setProjectSpace(space);
setName(
nextGroup.kind === "main" && space && !space.isDefault ? space.name : nextGroup.name,
);
setSelected(nextGroup.members.map((member) => member.botId));
setBots(nextBots.filter((bot) => !bot.archivedAt));
})
@ -42,10 +61,20 @@ export default function GroupSettingsScreen() {
setPending(true);
setError(null);
try {
const trimmed = name.trim();
if (isProjectMain && projectSpace) {
if (trimmed && trimmed !== projectSpace.name) {
await rpc("spaces/update", { spaceId: projectSpace.id, name: trimmed });
}
router.back();
return;
}
const input: { groupId: string; name?: string; botIds?: string[] } = { groupId };
if (name.trim() !== group.name) input.name = name.trim();
const memberIds = group.members.map((member) => member.botId).join(",");
if (selected.join(",") !== memberIds) input.botIds = selected;
if (trimmed !== group.name) input.name = trimmed;
if (!isMainConversation) {
const memberIds = group.members.map((member) => member.botId).join(",");
if (selected.join(",") !== memberIds) input.botIds = selected;
}
if (input.name || input.botIds) await rpc("groups/update", input);
router.back();
} catch (err) {
@ -57,6 +86,29 @@ export default function GroupSettingsScreen() {
function remove() {
if (!groupId || !group) return;
if (isProjectMain && projectSpace) {
Alert.alert(
t("Delete {name}?", { name: projectSpace.name }),
t("This removes the empty space for everyone."),
[
{ text: t("Cancel"), style: "cancel" },
{
text: t("Delete"),
style: "destructive",
onPress: () =>
void removeInboxSpace(projectSpace.id, async () => {})
.then(() => router.replace("/"))
.catch((err) =>
Alert.alert(
t("Could not delete space"),
err instanceof Error ? err.message : t("Try again."),
),
),
},
],
);
return;
}
Alert.alert(group.name, t("Delete this group? Bots and their solo threads are kept."), [
{ text: t("Cancel"), style: "cancel" },
{
@ -75,6 +127,12 @@ export default function GroupSettingsScreen() {
]);
}
const saveDisabled =
!name.trim() ||
pending ||
(!isMainConversation &&
(selected.length < GROUP_MEMBER_MIN || selected.length > GROUP_MEMBER_MAX));
return (
<>
<Stack.Screen options={{ title: t("Group settings") }} />
@ -97,34 +155,27 @@ export default function GroupSettingsScreen() {
fontSize: 16,
}}
/>
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
{t("Members")}
</Text>
<BotMemberPicker
bots={bots}
selected={selected}
onChange={setSelected}
disabled={pending}
/>
{!isMainConversation ? (
<>
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
{t("Members")}
</Text>
<BotMemberPicker
bots={bots}
selected={selected}
onChange={setSelected}
disabled={pending}
/>
</>
) : null}
{error ? <Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text> : null}
<Pressable
onPress={() => void save()}
disabled={
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
}
disabled={saveDisabled}
style={{
marginTop: 24,
backgroundColor: tokens.primary,
opacity:
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
? 0.5
: 1,
opacity: saveDisabled ? 0.5 : 1,
borderRadius: 11,
padding: 14,
alignItems: "center",
@ -134,19 +185,35 @@ export default function GroupSettingsScreen() {
{pending ? t("Saving…") : t("Save")}
</Text>
</Pressable>
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
</Pressable>
{isProjectMain && projectSpace && canDeleteInboxSpace(projectSpace) ? (
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete space")}</Text>
</Pressable>
) : !isMainConversation ? (
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
</Pressable>
) : null}
</ScrollView>
</>
);

View File

@ -597,7 +597,21 @@ export default function Home() {
}}
disabled={spaceBusy}
onPress={() => {
if (item.space) void chooseInboxSpace(item.space.id);
if (!item.space) return;
const targetSpace = item.space;
const main = !targetSpace.isDefault
? targetSpace.groups.find((group) => group.kind === "main")
: undefined;
if (main) {
void openMobileSpace(targetSpace.id, () =>
router.push({
pathname: "/group-thread",
params: { groupId: main.id, name: targetSpace.name },
}),
);
return;
}
void chooseInboxSpace(targetSpace.id);
}}
style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
>

View File

@ -6,22 +6,19 @@ import type {
Connection,
ConnectionCatalogItem,
MessageBlock,
MessageReaction,
Routine,
} from "@rakazo/contracts";
import {
AUTOPILOT_LIMITS,
canReactToThreadMessage,
MESSAGE_REACTIONS,
type MessageReaction,
} from "@rakazo/contracts";
import type { AutopilotUntilChoice } from "@rakazo/core";
import { AUTOPILOT_LIMITS, canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
import type { AutopilotUntilChoice, ComposerMention, SlashActionId } from "@rakazo/core";
import {
abortableDelay,
attachmentsForThread,
autopilotUntil,
buildComposerMentionOptions,
type ComposerMention,
clearComposerDraft,
cloudAgentHttpsUrl,
composerDraftThreadKey,
deriveWorkspaceFocus,
foldRunNarration,
isApprovalAskBlock,
@ -32,13 +29,15 @@ import {
liveScreenRunId,
mentionChipKey,
projectMessageReactions,
queuedFollowUps,
readComposerDraft,
resolveComposerSendPlan,
SLASH_ACTIONS,
type SlashActionId,
selectedAskActionLabel,
serializeComposerPrompt,
truncateSlashDescription,
userVisibleMessages,
writeComposerDraft,
} from "@rakazo/core";
import * as Clipboard from "expo-clipboard";
import { useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from "expo-router";
@ -289,6 +288,8 @@ function Thread() {
const mentionBotsAppliedGeneration = useRef(0);
const readVisibleTarget = useRef<string | null>(null);
const threadKey = groupId ?? botId;
const draftKey = composerDraftThreadKey({ botId, groupId });
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
const [threadScrollState, setThreadScrollState] = useState<ThreadScrollState>(() =>
scrollBehavior.current.state(),
);
@ -310,7 +311,7 @@ function Thread() {
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null);
const activeThreadId = useRef<string | undefined>(undefined);
const [draft, setDraft] = useState("");
const [draft, setDraft] = useState(() => initialDraft.text);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]);
@ -325,12 +326,17 @@ function Thread() {
connectionId?: string;
}>
>([]);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>(
() => initialDraft.selectedMentions,
);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(
() => initialDraft.selectedSkill,
);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [stopped, setStopped] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [markdownPreview, setMarkdownPreview] = useState<MarkdownArtifactPreviewTarget | null>(
@ -353,6 +359,9 @@ function Thread() {
() => reactionView.visibleMessages.filter((message) => !runNarration.hidden.has(message.id)),
[reactionView.visibleMessages, runNarration],
);
const [taskPillExpanded, setTaskPillExpanded] = useState(false);
const [dismissedPlanKey, setDismissedPlanKey] = useState<string | null>(null);
const [taskPillSubStepsOpen, setTaskPillSubStepsOpen] = useState(false);
const latestMessageId = visibleMessages.at(-1)?.id ?? null;
const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey);
const composerMentionTargets = useMemo(
@ -431,6 +440,106 @@ function Thread() {
});
}, [inGroup, snap?.activeRuns, snap?.members, snap?.run]);
const working = inGroup ? workingGroupBots.length > 0 : isWorkingStatus(currentBotStatus);
const activeMobilePlan = useMemo(() => {
const msgs = reactionView.visibleMessages;
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i]?.role === "user") {
lastUserIdx = i;
break;
}
}
let planIdx = -1;
let planBlock: Extract<MessageBlock, { kind: "plan" }> | null = null;
for (let i = msgs.length - 1; i > lastUserIdx; i--) {
const m = msgs[i];
if (!m) continue;
for (let b = m.blocks.length - 1; b >= 0; b--) {
const block = m.blocks[b];
if (block?.kind === "plan") {
planBlock = block;
planIdx = i;
break;
}
}
if (planBlock) break;
}
if (!planBlock) {
for (let i = lastUserIdx - 1; i >= 0; i--) {
const m = msgs[i];
if (!m) continue;
for (let b = m.blocks.length - 1; b >= 0; b--) {
const block = m.blocks[b];
if (block?.kind === "plan" && block.status !== "completed") {
planBlock = block;
planIdx = i;
break;
}
}
if (planBlock) break;
}
}
if (!planBlock) return null;
const absorbedIds = new Set<string>();
const stepIds = new Set<string>();
const steps: MobileMessage[] = [];
for (let i = Math.max(0, lastUserIdx + 1, planIdx); i < msgs.length; i++) {
const m = msgs[i];
if (!m) continue;
const folded = runNarration.steps.get(m.id);
if (folded && folded.length > 0) {
absorbedIds.add(m.id);
for (const s of folded) {
if (!stepIds.has(s.id)) {
stepIds.add(s.id);
steps.push(s);
}
}
}
}
const doneCount = planBlock.steps.filter(
(s) => s.status === "completed" || s.status === "skipped",
).length;
const isCompleted =
planBlock.status === "completed" ||
(planBlock.steps.length > 0 && doneCount === planBlock.steps.length && !working);
return {
block: planBlock,
steps,
absorbedIds,
doneCount,
isCompleted,
completionKey: `${planBlock.planId}:${isCompleted ? "done" : "active"}`,
};
}, [reactionView.visibleMessages, runNarration.steps, working]);
useEffect(() => {
if (!activeMobilePlan?.isCompleted) {
setDismissedPlanKey(null);
return;
}
const key = activeMobilePlan.completionKey;
const collapseTimer = setTimeout(() => setTaskPillExpanded(false), 1800);
const hideTimer = setTimeout(() => setDismissedPlanKey(key), 3500);
return () => {
clearTimeout(collapseTimer);
clearTimeout(hideTimer);
};
}, [activeMobilePlan?.completionKey, activeMobilePlan?.isCompleted]);
const activeRunCreatedAt = useMemo(() => {
const candidates = snap?.activeRuns ?? (snap?.run ? [snap.run] : []);
return candidates.find((run) => isWorkingStatus(run.status))?.createdAt;
}, [snap?.activeRuns, snap?.run]);
const queuedIds = useMemo(
() =>
new Set(
(working
? queuedFollowUps(snap?.messages ?? [], activeRunCreatedAt, reactionView.reactions)
: []
).map((message) => message.id),
),
[activeRunCreatedAt, reactionView.reactions, snap?.messages, working],
);
useEffect(() => {
void rpc<AgentSkillCatalogEntry[]>("agentSkills/list")
@ -1002,16 +1111,31 @@ function Thread() {
);
}, [botId, groupId, messageId]);
const loadedDraftKeyRef = useRef(draftKey);
useLayoutEffect(() => {
if (loadedDraftKeyRef.current !== draftKey) {
loadedDraftKeyRef.current = draftKey;
const stored = readComposerDraft(draftKey);
setDraft(stored.text);
setSelectedSkill(stored.selectedSkill);
setSelectedMentions(stored.selectedMentions);
return;
}
writeComposerDraft(draftKey, {
text: draft,
selectedSkill,
selectedMentions,
});
}, [draft, draftKey, selectedMentions, selectedSkill]);
useEffect(() => {
setPendingAttachments((current) => attachmentsForThread(current, threadKey));
setDraft("");
setMentionQuery(null);
setSlashQuery(null);
setSelectedSkill(null);
setSelectedMentions([]);
setReplyTarget(null);
setAttachmentNotice(null);
setError(null);
setStopped(false);
}, [threadKey]);
function updateDraft(value: string) {
@ -1053,6 +1177,11 @@ function Thread() {
function runSlashAction(action: SlashActionId) {
setDraft("");
setSlashQuery(null);
writeComposerDraft(draftKey, {
text: "",
selectedSkill,
selectedMentions,
});
if (action === "chat-settings") {
if (inGroup && groupId) {
router.push({ pathname: "/group-settings", params: { groupId } });
@ -1078,6 +1207,10 @@ function Thread() {
const initialGroupTarget = groupId;
if ((!initialBotTarget && !initialGroupTarget) || sending) return;
const originThreadKey = initialGroupTarget ?? initialBotTarget;
const originDraftKey = composerDraftThreadKey({
botId: initialBotTarget,
groupId: initialGroupTarget,
});
const attachments = attachmentsForThread(pendingAttachments, originThreadKey);
const plan = resolveComposerSendPlan({
text: serializeComposerPromptText(),
@ -1097,6 +1230,7 @@ function Thread() {
if (initialBotTarget) cancelFocusPrompt(initialBotTarget);
};
setSending(true);
setStopped(false);
setError(null);
try {
if (plan.shouldRunRoutines) {
@ -1111,6 +1245,7 @@ function Thread() {
);
}
const clearOriginComposer = () => {
clearComposerDraft(originDraftKey);
setPendingAttachments((current) =>
current.filter((attachment) => attachment.threadKey !== originThreadKey),
);
@ -1205,12 +1340,16 @@ function Thread() {
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sending) return;
setSending(true);
setStopped(false);
setError(null);
try {
await rpc(
"threads/stop",
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
);
if (isCurrentTarget(targetBotId, targetGroupId)) {
setStopped(true);
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to stop work"));
@ -1230,6 +1369,80 @@ function Thread() {
}
}
async function resume() {
const targetBotId = botId;
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sending) return;
setSending(true);
setStopped(false);
setError(null);
try {
await rpc(
"threads/resume",
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
);
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Could not continue"));
}
} finally {
setSending(false);
}
}
const [sendingNowId, setSendingNowId] = useState<string | null>(null);
async function sendQueuedNow(message: MobileMessage) {
const targetBotId = botId;
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sendingNowId) return;
setSendingNowId(message.id);
setError(null);
try {
await rpc("threads/sendNow", {
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
messageId: message.id,
});
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to send message"));
}
} finally {
setSendingNowId(null);
}
}
const [removingQueuedId, setRemovingQueuedId] = useState<string | null>(null);
async function removeQueuedNow(message: MobileMessage) {
const targetBotId = botId;
const targetGroupId = groupId;
const messageId = message.id.startsWith("temp-") ? undefined : message.id;
if (!messageId || (!targetBotId && !targetGroupId) || removingQueuedId) return;
setRemovingQueuedId(message.id);
setError(null);
try {
await rpc("threads/removeQueuedMessage", {
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
messageId,
});
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to remove message"));
}
} finally {
setRemovingQueuedId(null);
}
}
const answerMessage = useCallback(
async (message: MobileMessage, answer: string) => {
const targetBotId = botId;
@ -1313,6 +1526,10 @@ function Thread() {
const targetGroupId = groupId;
if (!targetBotId && !targetGroupId) return;
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
const targetDraftKey = composerDraftThreadKey({
botId: targetBotId,
groupId: targetGroupId,
});
const until = autopilotUntil(choice, new Date());
void rpc("autopilot/start", {
...target,
@ -1320,8 +1537,12 @@ function Thread() {
...(until ? { until: until.toISOString() } : {}),
})
.then(async () => {
setDraft("");
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
const existing = readComposerDraft(targetDraftKey);
writeComposerDraft(targetDraftKey, { ...existing, text: "" });
if (isCurrentTarget(targetBotId, targetGroupId)) {
setDraft("");
await refresh();
}
})
.catch((err) =>
Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")),
@ -1553,7 +1774,9 @@ function Thread() {
flexShrink: 1,
}}
>
{foldedSteps ? <RunSteps steps={foldedSteps} /> : null}
{foldedSteps && !activeMobilePlan?.absorbedIds.has(message.id) ? (
<RunSteps steps={foldedSteps} />
) : null}
<Pressable accessible={false} onLongPress={actionProps.onLongPress}>
<MessageBubble
botId={botId ?? snap?.members?.[0]?.botId ?? ""}
@ -1574,6 +1797,55 @@ function Thread() {
actionProps={actionProps}
/>
</Pressable>
{queuedIds.has(message.id) ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: 8,
marginTop: 4,
}}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 12 }}>{t("Queued")}</Text>
<Pressable
accessibilityRole="button"
disabled={sendingNowId === message.id}
hitSlop={6}
onPress={() => void sendQueuedNow(message)}
>
<Text
style={{
color: tokens.foreground,
fontSize: 12,
fontWeight: "600",
opacity: sendingNowId === message.id ? 0.5 : 1,
}}
>
{t("Send now")}
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={removingQueuedId === message.id}
hitSlop={6}
onPress={() => void removeQueuedNow(message)}
>
<Text
style={{
color:
removingQueuedId === message.id ? tokens.mutedForeground : tokens.destructive,
fontSize: 12,
fontWeight: "600",
opacity: removingQueuedId === message.id ? 0.5 : 1,
}}
numberOfLines={1}
>
{t("Remove")}
</Text>
</Pressable>
</View>
) : null}
{messageReactions ? (
<View
style={{
@ -1684,7 +1956,70 @@ function Thread() {
>
{error ? <Text style={{ color: tokens.mutedForeground, marginTop: 12 }}>{error}</Text> : null}
{runError ? (
<Text style={{ color: tokens.destructive, marginTop: 12 }}>{runError}</Text>
<View
style={{
marginTop: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: tokens.destructive, flex: 1 }}>{runError}</Text>
{!working ? (
<Pressable
accessibilityRole="button"
testID="thread-resume-button"
disabled={sending}
onPress={() => void resume()}
style={{
borderRadius: 999,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 12,
paddingVertical: 6,
opacity: sending ? 0.5 : 1,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 13, fontWeight: "600" }}>
{t("Resume")}
</Text>
</Pressable>
) : null}
</View>
) : stopped && !working ? (
<View
testID="thread-stopped-banner"
style={{
marginTop: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: tokens.mutedForeground, flex: 1 }}>{t("Stopped")}</Text>
<Pressable
accessibilityRole="button"
testID="thread-resume-button"
disabled={sending}
onPress={() => void resume()}
style={{
borderRadius: 999,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 12,
paddingVertical: 6,
opacity: sending ? 0.5 : 1,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 13, fontWeight: "600" }}>
{t("Resume")}
</Text>
</Pressable>
</View>
) : null}
<View style={{ flex: 1, position: "relative" }}>
{showPinnedPage ? (
@ -2025,6 +2360,191 @@ function Thread() {
))}
</View>
) : null}
{activeMobilePlan &&
!(activeMobilePlan.isCompleted && dismissedPlanKey === activeMobilePlan.completionKey) ? (
<View
testID="composer-task-pill"
style={{
marginTop: 12,
borderRadius: 20,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 14,
paddingTop: 8,
paddingBottom: 10,
gap: 8,
}}
>
<Pressable
testID="composer-task-pill-toggle"
accessibilityRole="button"
accessibilityState={{ expanded: taskPillExpanded }}
onPress={() => setTaskPillExpanded((current) => !current)}
style={{ gap: 6 }}
>
<View
style={{
width: 32,
height: 4,
borderRadius: 2,
backgroundColor: tokens.border,
alignSelf: "center",
}}
/>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
<Text
style={{
color: activeMobilePlan.isCompleted ? tokens.success : tokens.foreground,
fontSize: 14,
fontWeight: "700",
}}
>
{activeMobilePlan.isCompleted ? "✓" : "▸"}
</Text>
<Text
numberOfLines={1}
style={{
color: tokens.foreground,
fontSize: 13.5,
fontWeight: "600",
flex: 1,
}}
>
{activeMobilePlan.isCompleted
? activeMobilePlan.block.title
: ((
activeMobilePlan.block.steps.find((s) => s.status === "in_progress") ??
activeMobilePlan.block.steps.find((s) => s.status === "pending")
)?.label ?? activeMobilePlan.block.title)}
</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text
style={{
color: activeMobilePlan.isCompleted ? tokens.success : tokens.mutedForeground,
fontSize: 12.5,
fontWeight: "500",
}}
>
{activeMobilePlan.isCompleted
? t("Completed")
: `${activeMobilePlan.doneCount}/${activeMobilePlan.block.steps.length}`}
</Text>
<NativeSymbol
ios={taskPillExpanded ? "chevron.down" : "chevron.up"}
android={taskPillExpanded ? "chevron-down" : "chevron-up"}
size={14}
color={tokens.mutedForeground}
/>
</View>
</View>
</Pressable>
{taskPillExpanded ? (
<View
style={{
borderTopWidth: 1,
borderTopColor: tokens.border,
paddingTop: 10,
gap: 6,
}}
>
<Text
style={{
color: tokens.cardForeground,
fontSize: 14,
fontWeight: "600",
}}
>
{activeMobilePlan.block.title}
</Text>
{activeMobilePlan.block.steps.map((step) => {
const done = step.status === "completed";
const active = step.status === "in_progress";
const skipped = step.status === "skipped";
const glyph = done ? "✓" : active ? "▸" : skipped ? "–" : "○";
return (
<View
key={step.id}
style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}
>
<Text
style={{
color: done
? tokens.success
: active
? tokens.foreground
: tokens.mutedForeground,
fontSize: 13.5,
fontWeight: active ? "700" : "400",
width: 16,
}}
>
{glyph}
</Text>
<View style={{ flex: 1 }}>
<Text
style={{
color: done || skipped ? tokens.mutedForeground : tokens.foreground,
fontSize: 13.5,
fontWeight: active ? "600" : "400",
textDecorationLine: done || skipped ? "line-through" : "none",
}}
>
{step.label}
</Text>
{step.note ? (
<Text style={{ color: tokens.mutedForeground, fontSize: 12 }}>
{step.note}
</Text>
) : null}
</View>
</View>
);
})}
{activeMobilePlan.steps.length > 0 ? (
<View
style={{
borderTopWidth: 1,
borderTopColor: tokens.border,
paddingTop: 6,
marginTop: 2,
gap: 4,
}}
>
<Pressable
accessibilityRole="button"
onPress={() => setTaskPillSubStepsOpen((current) => !current)}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 12.5 }}>
{t("{count} steps", { count: activeMobilePlan.steps.length })}{" "}
{taskPillSubStepsOpen ? "▴" : "▾"}
</Text>
</Pressable>
{taskPillSubStepsOpen
? activeMobilePlan.steps.map((s) => (
<Text
key={s.id}
style={{ color: tokens.mutedForeground, fontSize: 12.5 }}
>
{blockText(s)}
</Text>
))
: null}
</View>
) : null}
</View>
) : null}
</View>
) : null}
<View
style={{
flexDirection: "row",
@ -2528,6 +3048,105 @@ const MessageBubble = memo(function MessageBubble({
</View>
);
}
const plans = message.blocks.filter(
(block): block is Extract<MessageBlock, { kind: "plan" }> => block.kind === "plan",
);
if (plans.length) {
return (
<View style={{ gap: 8, width: "100%" }}>
{plans.map((plan) => {
const doneCount = plan.steps.filter(
(s) => s.status === "completed" || s.status === "skipped",
).length;
const isCompleted = plan.status === "completed";
return (
<View
key={plan.planId}
testID="plan-card"
style={{
width: "90%",
borderRadius: 18,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 16,
paddingVertical: 14,
gap: 10,
}}
>
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 8 }}>
<Text
style={{
color: tokens.cardForeground,
fontSize: 15,
fontWeight: "600",
flexShrink: 1,
}}
>
{plan.title}
</Text>
<Text
style={{
color: isCompleted ? tokens.success : tokens.mutedForeground,
fontSize: 13,
fontWeight: "500",
}}
>
{isCompleted ? t("Completed") : `${doneCount}/${plan.steps.length}`}
</Text>
</View>
<View style={{ gap: 6 }}>
{plan.steps.map((step) => {
const done = step.status === "completed";
const active = step.status === "in_progress";
const skipped = step.status === "skipped";
const glyph = done ? "✓" : active ? "▸" : skipped ? "–" : "○";
return (
<View
key={step.id}
style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}
>
<Text
style={{
color: done
? tokens.success
: active
? tokens.foreground
: tokens.mutedForeground,
fontSize: 14,
fontWeight: active ? "700" : "400",
width: 16,
}}
>
{glyph}
</Text>
<View style={{ flex: 1 }}>
<Text
style={{
color: done || skipped ? tokens.mutedForeground : tokens.foreground,
fontSize: 14,
fontWeight: active ? "600" : "400",
textDecorationLine: done || skipped ? "line-through" : "none",
}}
>
{step.label}
</Text>
{step.note ? (
<Text style={{ color: tokens.mutedForeground, fontSize: 12.5 }}>
{step.note}
</Text>
) : null}
</View>
</View>
);
})}
</View>
</View>
);
})}
</View>
);
}
if (isRoutineEventMessage(message.blocks)) {
return (
<View style={{ width: "100%" }}>

View File

@ -784,7 +784,7 @@ export type MobileGroup = Pick<
| "updatedAt"
| "members"
> &
Partial<Pick<Group, "spaceId">>;
Partial<Pick<Group, "spaceId" | "kind">>;
export type MobileSpace = Space;
export type MobileSpaceNavigation = SpaceNavigation;
@ -797,8 +797,14 @@ export type MobileSnapshot = {
cursor?: number;
messages: MobileMessage[];
olderCursor: number | null;
run: { id: string; botId?: string; status: string; error?: string | null } | null;
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
run: {
id: string;
botId?: string;
status: string;
error?: string | null;
createdAt?: string;
} | null;
activeRuns?: Array<{ id: string; botId?: string; status: string; createdAt?: string }>;
members?: MobileGroup["members"];
computer?: {
kind?: string;

View File

@ -147,12 +147,18 @@ describe("spaceInboxItems", () => {
expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false);
});
it("offers deletion for an explicitly authorized empty non-default space", () => {
it("offers deletion for an explicitly authorized non-default space", () => {
expect(canDeleteInboxSpace(space())).toBe(true);
expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true);
});
it("hides deletion while a space still has content", () => {
expect(canDeleteInboxSpace(space({ hasContent: true }))).toBe(false);
it("deduplicates same-named projects into a single project entry", () => {
const first = space({ id: "space-work-1", name: "Work", hasContent: false });
const second = space({ id: "space-work-2", name: " work ", hasContent: true, bots: [bot] });
const items = spaceInboxItems([first, second]);
const headings = items.filter((item) => item.type === "heading" && item.space);
expect(headings).toHaveLength(1);
expect(headings[0]).toMatchObject({ key: "space-work-2", title: " work " });
});
});

View File

@ -24,22 +24,47 @@ export type InboxSpaceItem =
| { type: "heading"; key: string; title: string; space?: InboxSpace };
export function canDeleteInboxSpace(
space: Pick<InboxSpace, "isDefault" | "hasContent" | "canDelete">,
space: Pick<InboxSpace, "isDefault" | "canDelete"> & Partial<Pick<InboxSpace, "hasContent">>,
) {
return !space.isDefault && !space.hasContent && space.canDelete === true;
return !space.isDefault && space.canDelete === true;
}
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
return spaces.flatMap((space): InboxSpaceItem[] => {
const seenProjectNames = new Set<string>();
const canonicalProjectIds = new Set<string>();
const orderedProjects = [...spaces]
.filter((space) => !space.isDefault)
.sort((left, right) => Number(Boolean(right.hasContent)) - Number(Boolean(left.hasContent)));
for (const space of orderedProjects) {
const normalized = space.name.trim().toLowerCase();
if (normalized && seenProjectNames.has(normalized)) continue;
if (normalized) seenProjectNames.add(normalized);
canonicalProjectIds.add(space.id);
}
const visibleSpaces = spaces.filter(
(space) => space.isDefault || canonicalProjectIds.has(space.id),
);
return visibleSpaces.flatMap((space): InboxSpaceItem[] => {
const normalizedSpaceName = space.name.trim().toLowerCase();
const chats = [
// A main bot is reached through its space's main conversation, not listed again.
...space.bots
.filter((chat) => chat.kind !== "main")
.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
...space.groups
.filter((chat) => {
const normalizedGroupName = chat.name.trim().toLowerCase();
if (!space.isDefault && normalizedGroupName === normalizedSpaceName) return false;
if (space.isDefault && chat.kind !== "main" && seenProjectNames.has(normalizedGroupName))
return false;
return true;
})
.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
];
const items: InboxSpaceItem[] = [];
if (spaces.length > 1 || !space.isDefault || chats.length === 0) {
if (visibleSpaces.length > 1 || !space.isDefault || chats.length === 0) {
items.push({ type: "heading", key: space.id, title: space.name, space });
}
for (const group of groupBotsForSidebar(chats, space.botSections)) {

View File

@ -261,6 +261,9 @@ export const DE_MESSAGES: Record<string, string> = {
Eyeshadow: "Lidschatten",
Failed: "Fehlgeschlagen",
"Failed to refresh": "Aktualisieren fehlgeschlagen",
Resume: "Fortsetzen",
Stopped: "Gestoppt",
"Failed to remove message": "Nachricht konnte nicht entfernt werden",
"Failed to send message": "Nachricht konnte nicht gesendet werden",
"Failed to stop work": "Arbeit konnte nicht gestoppt werden",
"Fewer {label}": "Weniger {label}",
@ -497,6 +500,7 @@ export const DE_MESSAGES: Record<string, string> = {
"Section name": "Abschnittsname",
Send: "Senden",
"Send answer": "Antwort senden",
"Send now": "Jetzt gleich senden",
"Send reset link": "Link zum Zurücksetzen senden",
"Sending…": "Wird gesendet…",
Server: "Server",

View File

@ -274,6 +274,9 @@ export const RU_MESSAGES: Record<string, string> = {
"Executor token": "Токен Executor",
Failed: "Ошибка",
"Failed to refresh": "Не удалось обновить",
Resume: "Продолжить",
Stopped: "Остановлено",
"Failed to remove message": "Не удалось удалить сообщение",
"Failed to send message": "Не удалось отправить сообщение",
"Failed to stop work": "Не удалось остановить работу",
File: "Файл",
@ -429,6 +432,7 @@ export const RU_MESSAGES: Record<string, string> = {
Send: "Отправить",
"Send reset link": "Отправить ссылку для сброса",
"Send answer": "Отправить ответ",
"Send now": "Отправить сейчас",
"Sending…": "Отправка…",
Server: "Сервер",
"Server URL": "URL-адрес сервера",

View File

@ -259,6 +259,9 @@ export const ZH_MESSAGES: Record<string, string> = {
"Executor token": "Executor 令牌",
Failed: "失败",
"Failed to refresh": "刷新失败",
Resume: "继续",
Stopped: "已停止",
"Failed to remove message": "删除消息失败",
"Failed to send message": "发送消息失败",
"Failed to stop work": "停止工作失败",
File: "文件",
@ -410,6 +413,7 @@ export const ZH_MESSAGES: Record<string, string> = {
Send: "发送",
"Send reset link": "发送重置链接",
"Send answer": "发送回答",
"Send now": "立即发送",
"Sending…": "正在发送…",
Server: "服务器",
"Server URL": "服务器地址",

View File

@ -18,14 +18,14 @@
},
"dependencies": {
"@expo/metro-runtime": "57.0.17",
"@expo/ui": "57.0.25",
"@expo/ui": "57.0.26",
"@rakazo/chat-ui": "workspace:*",
"@rakazo/contracts": "workspace:*",
"@rakazo/core": "workspace:*",
"@rakazo/ui-tokens": "workspace:*",
"@react-native-vector-icons/ionicons": "13.1.4",
"@react-native-vector-icons/material-design-icons": "13.1.4",
"expo": "57.0.30",
"expo": "57.0.31",
"expo-application": "57.0.3",
"expo-audio": "57.0.5",
"expo-clipboard": "57.0.2",
@ -37,11 +37,11 @@
"expo-keep-awake": "57.0.2",
"expo-linking": "57.0.13",
"expo-localization": "57.0.2",
"expo-modules-core": "57.0.23",
"expo-notifications": "57.0.23",
"expo-router": "57.0.28",
"expo-modules-core": "57.0.24",
"expo-notifications": "57.0.24",
"expo-router": "57.0.29",
"expo-secure-store": "57.0.4",
"expo-sharing": "57.0.23",
"expo-sharing": "57.0.24",
"expo-splash-screen": "57.0.10",
"expo-status-bar": "57.0.1",
"expo-symbols": "57.0.3",

View File

@ -31,7 +31,7 @@ test("the bot's screen opens beside the chat while it works and steps back after
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute(
await expect(page.getByTestId("oo-computer-browser-viewport").locator("iframe")).toHaveAttribute(
"src",
SCREEN_URL,
);

View File

@ -26,16 +26,28 @@ test("the bot's cursor glides to where it clicks on its live screen", async ({
);
await page.getByTitle("Agent computer").click();
const preview = page.getByTestId("computer-preview");
const preview = page.getByTestId("oo-computer-browser-viewport");
await expect(preview.locator("iframe")).toHaveAttribute("src", SCREEN_URL);
const cursor = preview.getByTestId("agent-cursor");
await expect(cursor).toHaveCSS("opacity", "0");
// The click and typing last about a second, so record every action rather than racing a poll.
await cursor.evaluate((node) => {
const seen: string[] = [];
(window as unknown as { cursorActions: string[] }).cursorActions = seen;
new MutationObserver(() => seen.push(node.getAttribute("data-action") ?? "")).observe(node, {
attributeFilter: ["data-action"],
});
});
// The scripted runtime clicks the screen center (640, 400) and types.
await rpc(page, "threads/send", { botId, text: "use your screen and type cursor-demo" });
await expect(cursor).toHaveCSS("opacity", "1");
await expect(cursor).toHaveAttribute("data-action", /click|type/);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { cursorActions: string[] }).cursorActions),
)
.toEqual(expect.arrayContaining(["click", "type"]));
const layer = (await preview.getByTestId("agent-cursor-layer").boundingBox())!;
await expect
.poll(async () => {

View File

@ -12,14 +12,12 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
await composer.fill("write notes/result.txt and attach it to the thread");
await page.keyboard.press("Enter");
// Scope to ArtifactFileCard text (name + mime); sidebar bot status can also
// include the prompt path notes/result.txt once status syncs immediately.
const fileCard = page.getByRole("button", { name: /result\.txt text\/plain/ });
const fileCard = page.getByRole("button", { name: "Preview result.txt" });
await expect(fileCard).toBeVisible({ timeout: 30_000 });
await captureScreenshot(page, testInfo, "current-file-card");
const downloadPromise = page.waitForEvent("download");
await fileCard.click();
await page.getByRole("button", { name: "Download result.txt" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("result.txt");
});

View File

@ -114,12 +114,12 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
await captureScreenshot(page, testInfo, "27a-bot-settings-model");
await page.getByRole("button", { name: "Show computer" }).click();
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-panel", /^(computer|browser)$/);
await expect(page.getByRole("button", { name: "Show settings" })).toBeVisible();
// Overlay may flash during boot or never appear (already ready/asleep/stopped). Assert panel
// chrome, then wait until any overlay has cleared — avoid Locator.or() strict-mode multi-hits.
const bootOverlay = page.getByText(/Booting up .* computer/);
await expect(sidePanel.getByTestId("computer-preview")).toBeVisible();
await expect(sidePanel.getByTestId("oo-computer-browser-viewport")).toBeVisible();
await expect(bootOverlay).toBeHidden();
await expect(sidePanel.getByText("Teach a task")).toHaveCount(0);
await expect(sidePanel.getByTestId("teach-start-button")).toHaveCount(0);

View File

@ -39,9 +39,9 @@ test("screen connection failures stay visible and can be retried", async ({ page
);
await page.getByTitle("Agent computer").click();
const preview = page.getByTestId("computer-preview");
const preview = page.getByTestId("oo-computer-browser-viewport");
await expect(preview.getByRole("alert")).toContainText("temporarily busy");
await expect(preview.getByTestId("computer-preview-open")).toHaveCount(0);
await expect(page.getByTestId("computer-open")).toHaveCount(0);
await captureScreenshot(page, testInfo, "computer-screen-connection-error");
failScreen = false;
@ -50,14 +50,14 @@ test("screen connection failures stay visible and can be retried", async ({ page
await expect(preview.getByRole("alert")).toHaveCount(0);
failScreen = true;
await preview.hover();
await preview.getByTestId("computer-preview-open").click();
await page.getByTestId("computer-open").click();
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
await expect(page.getByRole("alert")).toContainText("temporarily busy");
const fullScreen = page.getByTestId("computer-viewport");
await expect(fullScreen.getByRole("alert")).toContainText("temporarily busy");
await captureScreenshot(page, testInfo, "computer-full-screen-connection-error");
failScreen = false;
await page.getByRole("button", { name: "Retry screen" }).click();
await fullScreen.getByRole("button", { name: "Retry screen" }).click();
await expect(page.locator('iframe[title="Bot screen"]')).toHaveAttribute("src", screenUrl);
await expect(page.getByRole("alert")).toHaveCount(0);
});

View File

@ -59,9 +59,8 @@ test("computer maintenance shows durable background progress and failure recover
});
});
await page.getByTitle("Agent computer").click();
await expect(page.getByTestId("computer-preview")).toBeVisible();
await page.getByTestId("computer-preview").hover();
await page.getByTestId("computer-preview-open").click();
await expect(page.getByTestId("oo-computer-browser-viewport")).toBeVisible();
await page.getByTestId("computer-open").click();
await page.getByTestId("computer-more-button").click();
await page.getByRole("menuitem", { name: "Update computer", exact: true }).click();
const dialog = page.getByTestId("computer-update-dialog");

View File

@ -67,7 +67,7 @@ test("main conversation does not show a group subtitle for one Oo", async ({ pag
await signup(page, `main-1-1-${stamp}@rakazo.test`, "password12", "Solo");
await completeOnboarding(page, undefined, { mainConversation: true });
await expect(page.getByTestId("bot-settings-trigger")).not.toContainText("Gruppenunterhaltung");
await expect(page.getByTestId("oo-group-stage")).not.toContainText("Gruppenunterhaltung");
await expect(page.getByTestId("oo-group-stage")).toHaveCount(0);
});
test.describe("mobile shell", () => {
@ -94,10 +94,11 @@ test("main conversation opens its lead's computer", async ({ page }, testInfo) =
await signup(page, `main-computer-${stamp}@rakazo.test`, "password12", "Screen");
await completeOnboarding(page, undefined, { mainConversation: true });
await expect(page).toHaveURL(/\/app\/g\//);
// The lead's computer opens beside the main conversation without leaving it.
await page.getByTitle("Agent computer").click();
await expect(page).not.toHaveURL(/\/app\/g\//);
await expect(page).toHaveURL(/\/app\/g\//);
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel.getByTestId("computer-preview")).toBeVisible();
await expect(sidePanel.getByTestId("oo-computer-browser-viewport")).toBeVisible();
await captureScreenshot(page, testInfo, "main-conversation-computer");
});

View File

@ -80,8 +80,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
expect(mainBox).not.toBeNull();
expect(panelBox).not.toBeNull();
expect((mainBox?.x ?? 0) + (mainBox?.width ?? 0)).toBeLessThanOrEqual(panelBox?.x ?? 0);
await sidePanel.getByTestId("computer-preview").hover();
await sidePanel.getByTestId("computer-preview-open").click();
await sidePanel.getByTestId("computer-open").click();
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
await expect(page.getByRole("button", { name: "Skip", exact: true }).last()).toBeVisible();
await expect(page.getByRole("button", { name: "I’m done", exact: true }).last()).toBeVisible();
@ -107,8 +106,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
}
await page.getByTitle("Agent computer").click();
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await sidePanel.getByTestId("computer-preview").hover();
const openComputer = sidePanel.getByTestId("computer-preview-open");
const openComputer = sidePanel.getByTestId("computer-open");
await expect(openComputer).toBeVisible({ timeout: 30_000 });
await openComputer.click();
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
@ -140,6 +138,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
);
await page.getByTitle("Agent computer").click();
await routinesListed;
await sidePanel.locator('[data-tab="overview"]').click();
await expect(
sidePanel.getByTestId("routine-list-row").getByText("Monday briefing"),
).toBeVisible();
@ -316,7 +315,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
await page.getByTitle("Agent computer").click();
}
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
const gear = page.getByRole("button", { name: "Show settings" });
const gear = page.getByRole("button", { name: "Show settings" }).first();
await gear.click();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export" }).click();

View File

@ -63,6 +63,10 @@ test("create group from + and see two bots in one transcript", async ({ page },
const researcherId = await createBot(page, "Researcher");
const writerId = await createBot(page, "Research Writer");
// Replies read themselves aloud by default; keep that off so only the Speak click below talks.
for (const botId of [researcherId, writerId]) {
await rpc(page, "bots/update", { botId, autoSpeak: false });
}
await openNewGroup(page);
await page.locator("label:has-text('Name') input").fill("Draft team");
@ -169,18 +173,26 @@ test("create group from + and see two bots in one transcript", async ({ page },
await expect(desktopSettings.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
await page.unroute("**/rpc/groups/update");
await desktopSettings.getByRole("button", { name: "Save", exact: true }).click();
// The panel closes once the save lands; switching chats before that races the layout change.
await expect(desktopSettings).toHaveAttribute("data-panel", "closed");
await page.getByRole("combobox", { name: "Message" }).fill("@Researcher unfinished draft");
await projekte
.getByRole("button", { name: /^Review team/ })
.first()
.click();
await expect(page).toHaveURL(new RegExp(`/app/g/${reviewGroup.id}`));
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
// Each chat keeps its own unsent draft.
await projekte
.getByRole("button", { name: /^Draft team/ })
.first()
.click();
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
await expect(page).toHaveURL(new RegExp(`/app/g/${draftGroupId}`));
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue(
"@Researcher unfinished draft",
);
await page.getByRole("combobox", { name: "Message" }).fill("");
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("@Res");

View File

@ -61,8 +61,12 @@ export async function createTestRoutine(
});
}
export async function openAgentComputerPanel(page: Page) {
await page.getByTitle("Agent computer").click();
/** Opens the side panel on the agent's overview, where its routines are listed. */
export async function openAgentOverview(page: Page, title = "Agent computer") {
const overview = page.getByTestId("side-panel").locator('[data-tab="overview"]');
// The header button toggles the panel, so only press it while the panel is closed.
if (!(await overview.isVisible())) await page.getByTitle(title).click();
await overview.click();
}
export async function rpc<T>(page: Page, procedure: string, body: unknown): Promise<T> {

View File

@ -1,7 +1,14 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts";
import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers";
import {
captureScreenshot,
completeOnboarding,
openAgentOverview,
openMainBotThread,
rpc,
signup,
} from "./helpers";
/** The nav button reads "Today" or "Today, N need you". */
function openHeute(page: Page) {
@ -142,7 +149,7 @@ test("Heute shows the coach's brief, habits and goals, checks habits off and set
// 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 openAgentOverview(page);
await expect(page.getByText("Morning brief", { exact: true })).toBeVisible();
await expect(page.getByText("Weekly review", { exact: true })).toBeVisible();
});

View File

@ -15,7 +15,7 @@ test("the in-app browser previews a page and follows it back to the browser", as
}),
);
await page.getByRole("button", { name: "Browser" }).click();
await page.getByTitle("Agent computer").click();
const browser = page.getByTestId("in-app-browser");
await browser.getByTestId("in-app-browser-mode-local").click();
const address = browser.getByRole("textbox", { name: "Address" });
@ -39,10 +39,10 @@ test("the in-app browser previews a page and follows it back to the browser", as
await browser.getByRole("button", { name: "Open in browser" }).click();
expect((await popup).url()).toBe("https://preview.example/app");
await browser.getByRole("button", { name: "Close panel" }).click();
await page.getByTestId("side-panel").getByRole("button", { name: "Close panel" }).click();
await expect(browser).toHaveCount(0);
// The last address comes back when the browser reopens.
await page.getByRole("button", { name: "Browser" }).click();
await page.getByTitle("Agent computer").click();
await page.getByTestId("in-app-browser-mode-local").click();
await expect(page.getByTestId("in-app-browser-frame")).toHaveAttribute(
"src",
@ -113,7 +113,7 @@ test("on desktop the page is a native view glued to the panel", async ({ page })
() => (window as unknown as { __browserCalls: Array<[string, unknown?]> }).__browserCalls,
);
await page.getByRole("button", { name: "Browser" }).click();
await page.getByTitle("Agent computer").click();
const browser = page.getByTestId("in-app-browser");
await browser.getByTestId("in-app-browser-mode-local").click();
await browser.getByRole("textbox", { name: "Address" }).fill("localhost:5173");
@ -157,6 +157,6 @@ test("on desktop the page is a native view glued to the panel", async ({ page })
await page.keyboard.press("Escape");
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("show");
await browser.getByRole("button", { name: "Close panel" }).click();
await page.getByTestId("side-panel").getByRole("button", { name: "Close panel" }).click();
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("hide");
});

View File

@ -26,7 +26,8 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
await expect(mainEntry).toBeVisible();
// The main bot is reached through the main conversation, not listed again.
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
await expect(page.getByTestId("oo-group-stage")).toBeVisible();
await expect(page.getByTestId("oo-group-stage")).toHaveCount(0);
await expect(page.getByTestId("bot-settings-trigger")).toBeVisible();
await captureScreenshot(page, testInfo, "main-conversation-landing");
const snapshot = () => rpc<Snapshot>(page, "threads/get", { groupId });

View File

@ -73,7 +73,7 @@ test("an image in the main conversation opens and downloads from the artifact ro
await expect(thumbnail).toHaveAttribute("src", /^\/api\/artifacts\/[^/?]+$/);
await thumbnail.click();
const dialog = page.getByRole("dialog", { name: "pixel.png" });
const dialog = page.getByRole("dialog", { name: "Pixel" });
const download = dialog.getByRole("link", { name: "Download pixel.png" });
await expect(download).toHaveAttribute("href", /\/api\/artifacts\/[^/?]+\?download=1$/);
const saved = page.waitForEvent("download");

View File

@ -92,7 +92,13 @@ test("message hover shows below-bubble actions; reply links to parent", async ({
);
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();
// Pin the row by id: once the run settles, long replies fold their details away.
const botMessageId = await transcript
.locator(`[data-message-id]`)
.filter({ has: botText })
.first()
.getAttribute("data-message-id");
const botRow = transcript.locator(`[data-message-id="${botMessageId}"]`);
await expect(botRow).toBeVisible();
await expectRailAtRest(page, botRow);
await captureScreenshot(page, testInfo, "message-actions-rest-desktop");

View File

@ -69,7 +69,7 @@ test("Oo browser shows live screen, cursor, cowork click, and user context", asy
});
});
await page.getByRole("button", { name: "Browser", exact: true }).click();
await page.getByTitle("Agent computer").click();
const browser = page.getByTestId("in-app-browser");
await expect(browser.getByTestId("oo-computer-browser-screen")).toHaveAttribute(
"src",

View File

@ -18,7 +18,8 @@ test("live page helper preserves identity, masks passwords, and reports partial
res.setHeader("content-type", "text/html");
res.end(`<!doctype html><title>Fixture</title><p>Readable article content</p>
<label>Name <input id="name"></label><input aria-label="Password" type="password" value="fake-test-password">
<button id="save" onclick="this.dataset.clicks=Number(this.dataset.clicks||0)+1">Save</button>
<select id="plan" aria-label="Plan"><option value="free">Free</option><option value="pro">Pro Plan</option></select>
<button id="save" onmouseenter="this.dataset.hovered='1'" onclick="this.dataset.clicks=Number(this.dataset.clicks||0)+1">Save</button>
<label><input type="checkbox">Agree</label><button disabled>Disabled</button>
<script>window.__rakazoPageBrowser={snapshot:()=>({title:'spoofed'})};</script>`);
});
@ -83,9 +84,18 @@ test("live page helper preserves identity, masks passwords, and reports partial
const refreshed = await command("snapshot");
expect(ref(refreshed, "Replacement")).not.toBe(ref(stale, "Save"));
expect(
await command("act", { actions: [{ kind: "click", ref: ref(refreshed, "Agree") }] }),
).toMatchObject({ ok: true, completed: 1 });
await command("act", {
actions: [
{ kind: "click", ref: ref(refreshed, "Agree") },
{ kind: "select", ref: ref(refreshed, "Plan"), text: "Pro Plan" },
{ kind: "press", ref: ref(refreshed, "Name"), key: "Enter" },
{ kind: "scroll", direction: "down", amount: 2 },
{ kind: "wait", ms: 10 },
],
}),
).toMatchObject({ ok: true, completed: 5 });
await expect(page.getByRole("checkbox")).toBeChecked();
await expect(page.locator("#plan")).toHaveValue("pro");
expect(
await command("navigate", { url: `http://127.0.0.1:${address.port}/next` }),
).toMatchObject({ ok: true, title: "Fixture" });

View File

@ -6,7 +6,7 @@ import {
completeOnboarding,
createNamedBot,
createTestRoutine,
openAgentComputerPanel,
openAgentOverview,
rpc,
signup,
} from "./helpers";
@ -33,7 +33,7 @@ test("routine active switch keeps its thumb inside the track", async ({ page },
await completeOnboarding(page);
const botId = activeBotId(page);
await createTestRoutine(page, botId, { name: "Toggle styling" });
await openAgentComputerPanel(page);
await openAgentOverview(page);
await page.getByRole("button", { name: /Toggle styling/ }).click();
const toggle = page.getByRole("switch", { name: "Active" });
@ -89,7 +89,7 @@ test("routine editing updates in place, preserves timezone, and deletion persist
expect(created.nextRunAt).not.toBeNull();
expect(localSchedule(created.nextRunAt!, created.timezone)).toMatchObject({ hour: 9, minute: 0 });
await page.reload();
await page.getByTitle("Agent computer").click();
await openAgentOverview(page);
await page.getByRole("button", { name: /Tokyo check-in/ }).click();
await page.locator("label:has-text('Name') input").fill("Weekday check-in");
@ -133,7 +133,7 @@ test("routine editing updates in place, preserves timezone, and deletion persist
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]);
await page.reload();
await page.getByTitle("Agent computer").click();
await openAgentOverview(page);
await expect(updatedButton).toHaveCount(0);
});
@ -172,7 +172,7 @@ test("a successful routine create is not reported as failed when refresh fails",
name: "Persisted routine",
prompt: "Run once each morning",
});
await openAgentComputerPanel(page);
await openAgentOverview(page);
await page.getByRole("button", { name: /Persisted routine/ }).click();
await page
.locator("label:has-text('Instruction') textarea")
@ -240,7 +240,7 @@ test("switching bots while a routine save is pending does not reopen stale state
]);
await page.reload();
await page.getByTitle("Agent computer").click();
await openAgentOverview(page);
await page.getByRole("button", { name: /First routine/ }).click();
await page.locator("label:has-text('Name') input").fill("First routine updated");
@ -280,7 +280,7 @@ test("switching bots while a routine save is pending does not reopen stale state
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await expect(page.getByRole("alert")).toHaveCount(0);
await page.getByTitle("Agent computer").click();
await openAgentOverview(page);
await expect(page.getByRole("button", { name: /Second routine/ })).toHaveCount(1);
await expect(page.getByRole("button", { name: /First routine/ })).toHaveCount(0);

View File

@ -5,7 +5,7 @@ import {
captureScreenshot,
completeOnboarding,
createTestRoutine,
openAgentComputerPanel,
openAgentOverview,
rpc,
signup,
} from "./helpers";
@ -38,7 +38,7 @@ test("Slack message trigger uses the mounted messaging provider and persists", a
githubEnabled: false,
messageProvider: "slack",
});
await openAgentComputerPanel(page);
await openAgentOverview(page);
const panel = page.getByTestId("side-panel");
await expect(panel.getByText("Slack message", { exact: true })).toBeVisible();
const [routine] = await rpc<Routine[]>(page, "routines/list", { botId });
@ -72,7 +72,7 @@ test("GitHub event trigger exposes signed delivery settings and persists", async
githubEnabled: true,
messageProvider: null,
});
await openAgentComputerPanel(page);
await openAgentOverview(page);
await page.getByRole("button", { name: /Review repository events/ }).click();
await expect(
page.getByTestId("side-panel").getByText("Git event", { exact: true }),
@ -111,7 +111,7 @@ test("Korean webhook routine keeps technical field labels in English", async ({
crons: [],
webhookEnabled: true,
});
await page.getByTitle("Agent 컴퓨터").click();
await openAgentOverview(page, "Agent 컴퓨터");
await page.getByRole("button", { name: /한국어 웹훅 확인/ }).click();
await expect(page.getByText("웹훅이 실행될 때", { exact: true })).toBeVisible();
@ -127,7 +127,7 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo
await completeOnboarding(page);
const botId = activeBotId(page);
await openAgentComputerPanel(page);
await openAgentOverview(page);
await expect(page.getByRole("button", { name: "Test run" })).toHaveCount(0);
await createTestRoutine(page, botId, {
name: "Daily verification",
@ -135,7 +135,7 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo
crons: ["0 9 * * 1-5"],
});
await page.reload();
await openAgentComputerPanel(page);
await openAgentOverview(page);
const routine = page.getByRole("button", { name: /Daily verification/ });
await expect(routine).toContainText("Weekdays at 9:00 AM");
await captureScreenshot(page, testInfo, "33-routine-scheduled");
@ -148,7 +148,7 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo
await page.reload();
await expect(page.getByText(/routine-run-now-ok/i).first()).toBeVisible();
await page.getByTitle("Agent computer").click();
await openAgentOverview(page);
await expect(page.getByRole("button", { name: /Daily verification/ })).toContainText(
"Weekdays at 9:00 AM",
);

View File

@ -4,7 +4,7 @@ import {
activeBotId,
completeOnboarding,
createTestRoutine,
openAgentComputerPanel,
openAgentOverview,
rpc,
signup,
} from "./helpers";
@ -17,14 +17,14 @@ test("routines section is hidden until the Oo has one, then list toggle persists
await completeOnboarding(page);
const botId = activeBotId(page);
await openAgentComputerPanel(page);
await openAgentOverview(page);
const panel = page.getByTestId("side-panel");
await expect(panel.getByText("Routines", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("routine-create-button")).toHaveCount(0);
await createTestRoutine(page, botId, { name: "Morning check-in" });
await page.reload();
await openAgentComputerPanel(page);
await openAgentOverview(page);
await expect(panel.getByText("Routines", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Morning check-in/ })).toBeVisible();

View File

@ -16,18 +16,16 @@ test("teach a task records interaction and saves a draft", async ({ page }, test
await expect(sidePanel.getByRole("button", { name: "Update computer" })).toHaveCount(0);
await expect(sidePanel.getByRole("button", { name: "Take control" })).toHaveCount(0);
await expect(sidePanel.getByTestId("computer-more-button")).toHaveCount(0);
await expect(sidePanel.getByTestId("computer-preview")).toBeVisible();
await expect(sidePanel.getByTestId("oo-computer-browser-viewport")).toBeVisible();
// Showing the panel never boots the computer; its Open action is visible without hover.
const openButton = sidePanel.getByTestId("computer-preview-open");
const openButton = sidePanel.getByTestId("computer-open");
await expect(openButton).toBeVisible();
const botId = activeBotId(page);
expect((await rpc<{ state: string }>(page, "computer/status", { botId })).state).toBe("stopped");
await captureScreenshot(page, testInfo, "teach-sidepanel-simple");
const preview = sidePanel.getByTestId("computer-preview");
await preview.hover();
const preview = sidePanel.getByTestId("oo-computer-browser-viewport");
await expect(openButton).toBeVisible();
await expect(openButton.getByText("Open", { exact: true })).toBeVisible();
await captureScreenshot(page, testInfo, "teach-sidepanel-open-hover");
await openButton.click();

View File

@ -10,7 +10,7 @@ test("blocks the composer while a skill is being taught", async ({ page }) => {
await page.getByTitle("Agent computer").click();
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
const openButton = sidePanel.getByTestId("computer-preview-open");
const openButton = sidePanel.getByTestId("computer-open");
await expect(openButton).toBeVisible();
await openButton.click();
const chrome = page.getByTestId("computer-chrome");

View File

@ -51,7 +51,7 @@ test("Team Computer gives bots a home folder plus shared space while Private sta
const privateId = await createBot(page, "Private Writer", "dedicated");
await openComputerPanel(page);
await expect(page.getByText(/Private Writer['’]s screen/).last()).toBeVisible();
await expect(page.getByText(/Private Writer['’]s browser/).last()).toBeVisible();
await captureScreenshot(page, testInfo, "43-private-computer");
await expect(readFileResponse(page, privateId, "notes/result.txt")).resolves.toMatchObject({
ok: false,
@ -94,8 +94,7 @@ test("user control leaves another Team bot's screen available", async ({ page },
// The sidebar entry for the main bot opens the main conversation; its computer is in the 1:1.
await openMainBotThread(page);
await page.getByTitle("Agent computer").click();
await page.getByTestId("computer-preview").hover();
await page.getByTestId("computer-preview-open").click();
await page.getByTestId("computer-open").click();
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
await page.getByRole("button", { name: "Close computer" }).click();
await expect(page.getByRole("button", { name: "Close computer" })).toHaveCount(0);
@ -132,8 +131,7 @@ test("a failed control release keeps the computer open for retry", async ({ page
await signup(page, `team-release-${Date.now()}@rakazo.test`, "password12", "Team Release");
await completeOnboarding(page);
await page.getByTitle("Agent computer").click();
await page.getByTestId("computer-preview").hover();
await page.getByTestId("computer-preview-open").click();
await page.getByTestId("computer-open").click();
const chrome = page.getByTestId("computer-chrome");
const release = chrome.getByRole("button", { name: "Release", exact: true });
await expect(release).toBeVisible();
@ -192,10 +190,8 @@ test("an active Team bot must be stopped before user takeover", async ({ page },
await page.getByTitle("Agent computer").click();
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel.getByRole("button", { name: /Take control/i })).toHaveCount(0);
await page.getByTestId("computer-preview").hover();
const openBusy = sidePanel.getByTestId("computer-preview-open");
const openBusy = sidePanel.getByTestId("computer-open");
await expect(openBusy).toBeVisible();
await expect(openBusy.getByText("Open", { exact: true })).toBeVisible();
await openBusy.click();
const chrome = page.getByTestId("computer-chrome");
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
@ -221,8 +217,7 @@ test("an active Team bot must be stopped before user takeover", async ({ page },
.toBeNull();
// After stop, Open via hover is the takeover path (no Take control button).
await page.getByTestId("computer-preview").hover();
await page.getByTestId("computer-preview-open").click();
await page.getByTestId("computer-open").click();
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
await expect(chrome.getByText("You have control", { exact: true })).toBeVisible();
await expect(chrome.getByRole("button", { name: /Take control/i })).toHaveCount(0);
@ -287,8 +282,8 @@ async function expectComposerReady(page: Page) {
async function openComputerPanel(page: Page) {
await page.getByTitle("Agent computer").click();
await expect(page.getByTestId("computer-preview")).toBeVisible();
await expect(page.getByTestId("computer-preview-open")).toHaveCount(1);
await expect(page.getByTestId("oo-computer-browser-viewport")).toBeVisible();
await expect(page.getByTestId("computer-open")).toHaveCount(1);
}
async function sendAndWait(page: Page, botId: string, text: string) {

View File

@ -14,6 +14,13 @@ test("conversations and projects stay separate; + opens bot picker", async ({ pa
await expect(unterhaltungen.getByText("Talk to your Oos", { exact: true })).toBeVisible();
await expect(projekte.getByText("Projects", { exact: true })).toBeVisible();
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible();
const [projekteBox, unterhaltungenBox] = await Promise.all([
projekte.boundingBox(),
unterhaltungen.boundingBox(),
]);
expect(projekteBox).not.toBeNull();
expect(unterhaltungenBox).not.toBeNull();
expect(projekteBox!.y).toBeLessThan(unterhaltungenBox!.y);
await unterhaltungen.getByTestId("conversation-picker-trigger").click();
const picker = page.getByTestId("conversation-bot-picker");
@ -40,14 +47,14 @@ test("conversations and projects stay separate; + opens bot picker", async ({ pa
await expect(projekte.getByText("Side project", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByText("Side project", { exact: true })).toHaveCount(0);
// The main agent leads the picker, even after other bots exist.
// The new project's main agent leads the picker, even after other bots exist.
await unterhaltungen.getByTestId("conversation-picker-trigger").click();
await expect(
page
.getByTestId("conversation-bot-picker")
.locator("[data-testid^='conversation-picker-bot-']")
.first(),
).toHaveText("Chief");
).toHaveText("Projektmanager");
await page.keyboard.press("Escape");
// Creating a project that already exists opens it instead of listing it twice.

View File

@ -7,7 +7,15 @@ async function turnOnSpeaking(page: Page) {
await expect(voice).toBeVisible();
await expect(voice.getByRole("button", { name: "Other voice providers" })).toHaveCount(0);
await expect(voice.getByLabel("API key", { exact: true })).toHaveCount(0);
await voice.getByRole("switch", { name: "Speaking on or off" }).click();
// The app turns the local voice on by itself the first time it starts, and the switch stays
// disabled while that runs; only click once it settled off.
const speaking = voice.getByRole("switch", { name: "Speaking on or off" });
await expect(async () => {
if ((await speaking.getAttribute("aria-checked")) !== "true" && (await speaking.isEnabled())) {
await speaking.click();
}
await expect(speaking).toHaveAttribute("aria-checked", "true", { timeout: 3_000 });
}).toPass({ timeout: 30_000 });
await expect(voice.getByTestId("voice-carousel")).toBeVisible();
return voice;
}
@ -25,11 +33,6 @@ test("voice settings pick a voice from the carousel, speak a reply, and open a c
).toHaveCount(1);
await captureScreenshot(page, testInfo, "voice-composer");
const preparedOff = await rpc<{ ready: boolean }>(page, "voice/prepare", {
text: "Hello there.",
});
expect(preparedOff.ready).toBe(false);
const voice = await turnOnSpeaking(page);
const cards = voice.getByTestId("voice-card");
// English UI lists only the English voices of the local server.

View File

@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Deine Oos — immer da, immer wach." />
<meta name="theme-color" content="#07080a" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="evOo" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />

View File

@ -1,13 +1,16 @@
{
"id": "/",
"name": "evOo",
"short_name": "evOo",
"description": "Deine Oos — immer da, immer wach.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f1218",
"theme_color": "#0f1218",
"orientation": "any",
"background_color": "#07080a",
"theme_color": "#07080a",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}

View File

@ -34,6 +34,7 @@ self.addEventListener("push", (event) => {
// 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",
badge: "/icon-192.png",
data: { url: appPath(data.url) },
},
);

View File

@ -1,8 +1,10 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web";
import { isPaneArtifactMimeType } from "@rakazo/core";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { Download, FileText, X } from "lucide-react";
import { type RefObject, useMemo, useRef, useState } from "react";
import { Download, Eye, FileText, X } from "lucide-react";
import type { RefObject } from "react";
import { useMemo, useRef, useState } from "react";
import { useArtifactBytes } from "../hooks/useArtifactBytes";
import { downloadArtifact } from "../lib/artifact-open";
import { formatBytes } from "../lib/format-bytes";
@ -16,80 +18,104 @@ type ArtifactFileCardProps = {
onOpenBeside?: () => void;
};
const INLINE_DIALOG_TEXT_MIME_TYPES = new Set(["text/markdown", "text/plain", "application/json"]);
function shortFileTypeLabel(name: string, mimeType: string): string {
const dot = name.lastIndexOf(".");
if (dot > 0 && dot < name.length - 1) {
const ext = name
.slice(dot + 1)
.trim()
.toUpperCase();
if (ext.length <= 6) return ext;
}
const sub = mimeType.split("/")[1]?.split("+")[0]?.toUpperCase();
return sub && sub.length <= 8 ? sub : "FILE";
}
export function ArtifactFileCard(props: ArtifactFileCardProps) {
const { t } = useLingui();
const markdown = props.mimeType === "text/markdown";
const canOpenBeside = Boolean(props.onOpenBeside) && isPaneArtifactMimeType(props.mimeType);
const canOpenDialog = INLINE_DIALOG_TEXT_MIME_TYPES.has(props.mimeType);
const canPreview = canOpenBeside || canOpenDialog;
const previewButton = useRef<HTMLButtonElement>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const startDownload = () => downloadArtifact(props.artifactId, props.name);
if (!markdown) {
return (
<div>
<button
type="button"
onClick={startDownload}
className="rounded-2xl border border-border bg-card px-4 py-3 text-left text-ui-body text-foreground hover:bg-accent"
>
<div className="font-medium">{props.name}</div>
<div className="mt-1 text-muted-foreground">
{props.mimeType} · {formatBytes(props.size)}
</div>
</button>
</div>
);
}
const badgeLabel = shortFileTypeLabel(props.name, props.mimeType);
return (
<>
<div>
<div className="flex min-w-[280px] overflow-hidden rounded-2xl border border-border bg-card text-left text-foreground">
<div data-testid="artifact-file-card">
<div className="flex min-w-[280px] max-w-md overflow-hidden rounded-2xl border border-border bg-card text-left text-foreground shadow-2xs transition-colors">
<button
ref={previewButton}
type="button"
aria-label={t`Preview ${props.name}`}
onClick={() => (props.onOpenBeside ? props.onOpenBeside() : setPreviewOpen(true))}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent"
aria-label={canPreview ? t`Preview ${props.name}` : t`Download ${props.name}`}
onClick={() => {
if (canOpenBeside && props.onOpenBeside) {
props.onOpenBeside();
} else if (canOpenDialog) {
setPreviewOpen(true);
} else {
startDownload();
}
}}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground">
<FileText size={21} strokeWidth={1.8} />
<FileText size={20} strokeWidth={1.8} />
</span>
<span className="min-w-0">
<span className="min-w-0 flex-1">
<span className="block truncate text-ui-body font-medium">{props.name}</span>
<span className="mt-0.5 block text-ui-body-sm text-muted-foreground">
{formatBytes(props.size)}
<span className="mt-0.5 flex items-center gap-1.5 text-ui-body-sm text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.2 font-mono text-ui-label text-foreground/80">
{badgeLabel}
</span>
<span>·</span>
<span>{formatBytes(props.size)}</span>
</span>
</span>
{canPreview ? (
<span
aria-hidden="true"
className="shrink-0 text-muted-foreground/70 transition-colors"
>
<Eye size={16} strokeWidth={1.8} />
</span>
) : null}
</button>
<button
type="button"
aria-label={t`Download ${props.name}`}
title={t`Download ${props.name}`}
onClick={startDownload}
className="grid w-14 shrink-0 place-items-center border-l border-border text-muted-foreground hover:bg-accent hover:text-foreground"
className="grid w-13 shrink-0 place-items-center border-l border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Download strokeWidth={1.8} className="size-icon-lg" />
</button>
</div>
</div>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent
showCloseButton={false}
initialFocus={closeButton}
finalFocus={previewButton}
className="flex h-[min(88vh,900px)] w-[min(960px,94vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-none"
>
<MarkdownPreview {...props} closeButtonRef={closeButton} />
</DialogContent>
</Dialog>
{canOpenDialog ? (
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent
showCloseButton={false}
initialFocus={closeButton}
finalFocus={previewButton}
className="flex h-[min(88vh,900px)] w-[min(960px,94vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-none"
>
<TextFilePreview {...props} closeButtonRef={closeButton} />
</DialogContent>
</Dialog>
) : null}
</>
);
}
function MarkdownPreview({
function TextFilePreview({
artifactId,
name,
mimeType,
closeButtonRef,
}: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) {
const { t } = useLingui();
@ -97,8 +123,8 @@ function MarkdownPreview({
const state = useMemo(() => {
if (loaded.status !== "ready") return loaded;
try {
const markdown = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes);
return { status: "ready" as const, markdown };
const text = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes);
return { status: "ready" as const, text };
} catch {
return { status: "error" as const, message: t`This file is not valid UTF-8 Markdown.` };
}
@ -140,8 +166,12 @@ function MarkdownPreview({
<div className="rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-destructive">
{state.message}
</div>
) : mimeType === "text/markdown" ? (
<ChatMarkdown>{state.text}</ChatMarkdown>
) : (
<ChatMarkdown>{state.markdown}</ChatMarkdown>
<pre className="whitespace-pre-wrap break-words font-mono text-ui-body-sm leading-6 text-foreground">
{state.text}
</pre>
)}
</article>
</div>

View File

@ -1,17 +1,115 @@
import { selectedAskActionLabel } from "@rakazo/core";
import { describe, expect, it } from "vitest";
// @vitest-environment jsdom
describe("selectedAskActionLabel", () => {
it("maps a choice answer id to its user-facing label", () => {
expect(
selectedAskActionLabel("choice-2", [
{ id: "choice-1", label: "Berlin" },
{ id: "choice-2", label: "Seoul" },
]),
).toBe("Seoul");
});
import type { ReactNode } from "react";
import { act } from "react";
import type { Root } from "react-dom/client";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
it("falls back to the answer when an action is unavailable", () => {
expect(selectedAskActionLabel("custom", undefined)).toBe("custom");
});
vi.mock("@lingui/core/macro", () => ({
t: (parts: TemplateStringsArray, ...values: unknown[]) =>
parts.reduce((acc, part, index) => acc + part + String(values[index] ?? ""), ""),
}));
vi.mock("@lingui/react/macro", () => {
const t = (parts: TemplateStringsArray, ...values: unknown[]) =>
parts.reduce((acc, part, index) => acc + part + String(values[index] ?? ""), "");
return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children };
});
vi.mock("@rakazo/chat-ui/web", () => ({
ChatMarkdown: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
import type { AskBlock } from "./AskCard";
import { AskCard } from "./AskCard";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
});
it("renders primary approval button text with text-primary-foreground for contrast", async () => {
const onAnswer = vi.fn().mockResolvedValue(undefined);
const block: AskBlock = {
kind: "ask",
text: "**Review before shell**\n\nThis run would push commits to a remote.",
detail: "git push",
status: "pending",
approvalEffectId: "effect-1",
actions: [
{ id: "allow", label: "Allow once" },
{ id: "always", label: "Always allow this tool" },
{ id: "deny", label: "Deny" },
],
};
await act(async () => {
root.render(<AskCard block={block} canAnswer={true} onAnswer={onAnswer} />);
});
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons).toHaveLength(3);
const [allowButton, alwaysButton, denyButton] = buttons;
const allowSpan = allowButton?.querySelector("span");
const alwaysSpan = alwaysButton?.querySelector("span");
const denySpan = denyButton?.querySelector("span");
const allowIcon = allowButton?.querySelector("svg");
const alwaysIcon = alwaysButton?.querySelector("svg");
expect(allowSpan?.textContent).toBe("Allow once");
expect(allowSpan?.className).toContain("text-primary-foreground");
expect(allowSpan?.className).not.toContain("text-foreground");
expect(allowIcon?.getAttribute("class")).toContain("text-primary-foreground/75");
expect(alwaysSpan?.textContent).toBe("Always allow this tool");
expect(alwaysSpan?.className).toContain("text-foreground");
expect(alwaysSpan?.className).not.toContain("text-primary-foreground");
expect(alwaysIcon?.getAttribute("class")).toContain("text-muted-foreground");
expect(denySpan?.textContent).toBe("Deny");
expect(denySpan?.className).toContain("text-foreground");
expect(denySpan?.className).not.toContain("text-primary-foreground");
await act(async () => {
allowButton?.click();
});
expect(onAnswer).toHaveBeenCalledWith("allow");
});
it("uses text-foreground for non-approval action buttons", async () => {
const onAnswer = vi.fn().mockResolvedValue(undefined);
const block: AskBlock = {
kind: "ask",
text: "Which branch should we deploy?",
status: "pending",
actions: [
{ id: "main", label: "main" },
{ id: "staging", label: "staging" },
],
};
await act(async () => {
root.render(<AskCard block={block} canAnswer={true} onAnswer={onAnswer} />);
});
const actionSpans = Array.from(container.querySelectorAll("button span"));
expect(actionSpans).toHaveLength(2);
for (const span of actionSpans) {
expect(span.className).toContain("text-foreground");
expect(span.className).not.toContain("text-primary-foreground");
}
});

View File

@ -174,34 +174,47 @@ export function AskCard({
</form>
) : askActions?.length ? (
<div className="mt-3.5 space-y-1.5">
{askActions.map((action) => (
<Button
key={action.id}
variant={approvalActions && action.id === "allow" ? "default" : "outline"}
className={cn(
"h-auto w-full justify-between gap-3 whitespace-normal rounded-xl px-4 py-3 text-start font-medium transition-all group/ask-btn",
approvalActions && action.id === "allow"
? ""
: "border-border/80 bg-muted/60 hover:border-foreground/30 hover:bg-muted active:scale-[0.99]",
)}
disabled={submitting}
onClick={() => void submitAnswer(action.id)}
>
<span className="flex-1 text-ui-body font-medium leading-snug text-foreground">
{pendingAction === action.id ? (
<Trans>Sending…</Trans>
) : approvalActions ? (
approvalActionLabel(action.id, action.label, action.outcome)
) : (
action.label
{askActions.map((action) => {
const isPrimaryApproval = Boolean(approvalActions && action.id === "allow");
return (
<Button
key={action.id}
variant={isPrimaryApproval ? "default" : "outline"}
className={cn(
"h-auto w-full justify-between gap-3 whitespace-normal rounded-xl px-4 py-3 text-start font-medium transition-all group/ask-btn",
isPrimaryApproval
? ""
: "border-border/80 bg-muted/60 hover:border-foreground/30 hover:bg-muted active:scale-[0.99]",
)}
</span>
<ArrowRight
strokeWidth={2}
className="size-icon-sm shrink-0 text-muted-foreground transition-transform group-hover/ask-btn:translate-x-0.5 group-hover/ask-btn:text-foreground"
/>
</Button>
))}
disabled={submitting}
onClick={() => void submitAnswer(action.id)}
>
<span
className={cn(
"flex-1 text-ui-body font-medium leading-snug",
isPrimaryApproval ? "text-primary-foreground" : "text-foreground",
)}
>
{pendingAction === action.id ? (
<Trans>Sending…</Trans>
) : approvalActions ? (
approvalActionLabel(action.id, action.label, action.outcome)
) : (
action.label
)}
</span>
<ArrowRight
strokeWidth={2}
className={cn(
"size-icon-sm shrink-0 transition-transform group-hover/ask-btn:translate-x-0.5",
isPrimaryApproval
? "text-primary-foreground/75 group-hover/ask-btn:text-primary-foreground"
: "text-muted-foreground group-hover/ask-btn:text-foreground",
)}
/>
</Button>
);
})}
{!approvalActions ? (
<div className="pt-1">
<Button
@ -226,7 +239,11 @@ export function AskCard({
</Button>
</div>
)}
{error ? <p className="mt-3 text-ui-body-sm text-destructive">{error}</p> : null}
{error ? (
<p role="alert" className="mt-3 text-ui-body-sm text-destructive">
{error}
</p>
) : null}
</div>
);
}

View File

@ -3,6 +3,7 @@ import type { MessageBlock } from "@rakazo/contracts";
import { cloudAgentHttpsUrl } from "@rakazo/core";
import { Badge } from "@rakazo/ui-web/components/ui/badge";
import { Card, CardContent } from "@rakazo/ui-web/components/ui/card";
import { ArrowUpRight } from "lucide-react";
export function CloudAgentCard({
block,
@ -15,8 +16,14 @@ export function CloudAgentCard({
<Card size="sm" className="w-80 max-w-full" data-testid="cloud-agent-card">
<CardContent className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<span className="font-medium" dir="auto">
<span className="inline-flex items-center gap-1 font-medium" dir="auto">
{block.title}
{href ? (
<ArrowUpRight
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground"
/>
) : null}
</span>
<Badge
variant="secondary"
@ -52,7 +59,13 @@ export function CloudAgentCard({
</Card>
);
return href ? (
<a href={href} target="_blank" rel="noreferrer" className="block max-w-full no-underline">
<a
href={href}
target="_blank"
rel="noreferrer noopener"
data-external-link=""
className="block max-w-full no-underline"
>
{content}
</a>
) : (

View File

@ -74,7 +74,7 @@ export function DualSurfaceDivider({
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
className={cn(
"group relative flex w-3 md:w-4 shrink-0 cursor-col-resize select-none touch-none items-center justify-center transition-colors bg-border/0 hover:bg-accent/40 active:bg-primary/20 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"group relative flex w-3 md:w-3 shrink-0 cursor-col-resize select-none touch-none items-center justify-center transition-colors bg-transparent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
disabled && "pointer-events-none cursor-default opacity-50",
className,
)}
@ -82,8 +82,8 @@ export function DualSurfaceDivider({
>
<div
className={cn(
"h-full w-1 rounded-full bg-border transition-colors group-hover:bg-accent group-active:bg-primary",
isDragging && "bg-primary",
"h-12 w-1 rounded-full bg-border opacity-0 transition-opacity group-hover:opacity-100 group-active:bg-primary group-active:opacity-100",
isDragging && "bg-primary opacity-100",
)}
/>
</div>

View File

@ -4,7 +4,13 @@ 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";
import {
disableWebPush,
enableWebPush,
isIosBrowserNeedsHomeScreen,
webPushEnabled,
webPushSupported,
} from "../lib/web-push";
import { SettingsSection } from "./SettingsSection";
const DEFAULT_QUIET_HOURS = { start: "22:00", end: "07:00" };
@ -51,6 +57,7 @@ function SettingRow({
export function NotificationSettingsPanel() {
const { t } = useLingui();
const pushSupported = webPushSupported();
const iosNeedsHomeScreen = isIosBrowserNeedsHomeScreen();
const [settings, setSettings] = useState<NotificationSettings | null>(null);
const [push, setPush] = useState<PushState>("loading");
const [pushBusy, setPushBusy] = useState(false);
@ -153,6 +160,15 @@ export function NotificationSettingsPanel() {
</p>
) : null}
</SettingRow>
) : iosNeedsHomeScreen ? (
<li data-testid="notification-ios-pwa-hint" className="py-3 first:pt-0 last:pb-0">
<div className="text-ui-body text-foreground">
<Trans>Push on this device</Trans>
</div>
<p className="mt-1 text-ui-body-sm text-muted-foreground">
<Trans>Add evOo to your Home Screen via Share to enable push on iOS.</Trans>
</p>
</li>
) : null}
<SettingRow
label={<Trans>Quiet hours</Trans>}

View File

@ -254,10 +254,10 @@ export function OoAtelierDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="max-h-[92vh] w-[420px] max-w-full gap-3 overflow-y-auto rounded-3xl p-5 sm:max-w-[420px]"
className="flex max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[420px] flex-col gap-3 overflow-hidden rounded-3xl p-5 sm:max-w-[420px]"
data-testid="oo-atelier"
>
<DialogHeader className="flex-row items-center justify-between space-y-0">
<DialogHeader className="shrink-0 flex-row items-center justify-between space-y-0">
<DialogTitle className="text-ui-body font-semibold tracking-tight">
<Trans>Atelier</Trans>
</DialogTitle>
@ -274,19 +274,19 @@ export function OoAtelierDialog({
</button>
</DialogHeader>
<div className="flex justify-center py-1" data-testid="oo-atelier-preview">
<div className="flex shrink-0 justify-center py-1" data-testid="oo-atelier-preview">
<BotAvatar
color={isPicture && !draft ? value : encoded}
identity={identity}
status={status}
size={128}
size={120}
moment={preview}
interactive
/>
</div>
<Tabs defaultValue="body">
<TabsList className="w-full">
<Tabs defaultValue="body" className="flex min-h-0 flex-1 flex-col">
<TabsList className="w-full shrink-0">
<TabsTrigger value="body">
<Trans>Body</Trans>
</TabsTrigger>
@ -303,7 +303,11 @@ export function OoAtelierDialog({
) : null}
</TabsList>
<TabsContent value="body" className="space-y-4 pt-2" data-testid="oo-atelier-body">
<TabsContent
value="body"
className="rk-scroll min-h-0 flex-1 space-y-4 overflow-y-auto pt-2 pr-1"
data-testid="oo-atelier-body"
>
<OptionGroup
title={t`Species`}
values={OO_SPECIES}
@ -397,7 +401,11 @@ export function OoAtelierDialog({
</div>
</TabsContent>
<TabsContent value="eyes" className="space-y-4 pt-2" data-testid="oo-atelier-eyes">
<TabsContent
value="eyes"
className="rk-scroll min-h-0 flex-1 space-y-4 overflow-y-auto pt-2 pr-1"
data-testid="oo-atelier-eyes"
>
<OptionGroup
title={t`Lashes`}
values={OO_LASHES}
@ -456,7 +464,11 @@ export function OoAtelierDialog({
/>
</TabsContent>
<TabsContent value="extras" className="space-y-4 pt-2" data-testid="oo-atelier-extras">
<TabsContent
value="extras"
className="rk-scroll min-h-0 flex-1 space-y-4 overflow-y-auto pt-2 pr-1"
data-testid="oo-atelier-extras"
>
<OptionGroup
title={t`Glasses`}
values={OO_EYEWEAR}
@ -492,7 +504,7 @@ export function OoAtelierDialog({
</TabsContent>
{allowUpload ? (
<TabsContent value="upload" className="pt-2">
<TabsContent value="upload" className="min-h-0 flex-1 overflow-y-auto pt-2">
<button
type="button"
aria-label={t`Image upload area`}
@ -534,7 +546,7 @@ export function OoAtelierDialog({
) : null}
</Tabs>
<DialogFooter className="flex-row items-center justify-between border-border sm:justify-between">
<DialogFooter className="-mx-5 -mb-5 shrink-0 flex-row items-center justify-between border-border px-5 py-3.5 sm:justify-between">
<div className="flex items-center gap-1">
<button
type="button"

View File

@ -2,7 +2,8 @@ import { Trans, useLingui } from "@lingui/react/macro";
import type { Bot, ComputerStatus } from "@rakazo/contracts";
import { Button, cn } from "@rakazo/ui-web";
import { X } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { agentPointerFeed } from "../../lib/agent-pointer";
import { LocalBrowserPreview } from "./LocalBrowserPreview";
import { OoComputerBrowser } from "./OoComputerBrowser";
@ -16,8 +17,9 @@ export function InAppBrowser({
computerScreenError,
sandboxProvider,
booting,
onBoot,
onRefreshScreen,
onConnectScreen,
onOpenComputer,
hideCloseButton = false,
}: {
onClose: () => void;
@ -27,13 +29,21 @@ export function InAppBrowser({
computerScreenError: React.ReactNode;
sandboxProvider: string | undefined;
booting: boolean;
onBoot: () => Promise<void>;
onRefreshScreen: () => Promise<unknown>;
onConnectScreen: () => Promise<unknown>;
onOpenComputer?: () => void;
hideCloseButton?: boolean;
}) {
const { t } = useLingui();
const [mode, setMode] = useState<InAppBrowserMode>("oo");
useEffect(() => {
if (!active?.id) return;
return agentPointerFeed.subscribe(active.id, () => {
setMode("oo");
});
}, [active?.id]);
return (
<div className="flex h-full min-h-0 flex-col" data-testid="in-app-browser">
<div className="flex items-center justify-between gap-2 border-b px-2 py-1">
@ -83,8 +93,9 @@ export function InAppBrowser({
computerScreenError={computerScreenError}
sandboxProvider={sandboxProvider}
booting={booting}
onBoot={onBoot}
onRefreshScreen={onRefreshScreen}
onConnectScreen={onConnectScreen}
onOpenComputer={onOpenComputer}
/>
) : (
<div className="grid flex-1 place-items-center px-6 text-center text-ui-body-sm text-muted-foreground">

View File

@ -1,8 +1,9 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { Bot, ComputerStatus } from "@rakazo/contracts";
import { Button, cn, Input } from "@rakazo/ui-web";
import { ArrowLeft, ArrowRight, ExternalLink, Loader2, RotateCw } from "lucide-react";
import { ArrowLeft, ArrowRight, ExternalLink, Loader2, Maximize2, RotateCw } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { agentPointerFeed } from "../../lib/agent-pointer";
import { normalizeBrowserUrl } from "../../lib/browser-url";
import { rpc } from "../../lib/rpc";
import { screenIframeSandbox } from "../../pages/shell/computer-screen";
@ -33,8 +34,9 @@ export function OoComputerBrowser({
computerScreenError,
sandboxProvider,
booting,
onBoot,
onRefreshScreen,
onConnectScreen,
onOpenComputer,
}: {
active: Bot;
computer: ComputerStatus | null;
@ -42,8 +44,11 @@ export function OoComputerBrowser({
computerScreenError: React.ReactNode;
sandboxProvider: string | undefined;
booting: boolean;
onBoot: () => Promise<void>;
onRefreshScreen: () => Promise<unknown>;
/** Fetches the screen of an already running computer, without touching the thread. */
onConnectScreen: () => Promise<unknown>;
/** Opens the full computer window, where the user can take control or teach. */
onOpenComputer?: () => void;
}) {
const { t } = useLingui();
const [chrome, setChrome] = useState<ChromeState>(EMPTY_CHROME);
@ -73,10 +78,26 @@ export function OoComputerBrowser({
}, [computer?.state, refreshChrome]);
useEffect(() => {
if (computer?.state === "running" && (embeddedScreenUrl || computer?.kind === "desktop"))
return;
void onBoot().catch(() => undefined);
}, [active.id, computer?.kind, computer?.state, embeddedScreenUrl, onBoot]);
if (computer?.state !== "running") return;
let settleTimer: number | undefined;
const unsubscribe = agentPointerFeed.subscribe(active.id, () => {
void refreshChrome();
window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(() => void refreshChrome(), 600);
});
return () => {
unsubscribe();
window.clearTimeout(settleTimer);
};
}, [active.id, computer?.state, refreshChrome]);
const connectScreen = useRef(onConnectScreen);
connectScreen.current = onConnectScreen;
// Showing the browser never starts a stopped computer; it only reconnects a running screen.
useEffect(() => {
if (computer?.state !== "running" || embeddedScreenUrl || computer?.kind === "desktop") return;
void connectScreen.current().catch(() => undefined);
}, [active.id, computer?.kind, computer?.state, embeddedScreenUrl]);
const runNav = useCallback(
async (task: () => Promise<ChromeState>) => {
@ -200,6 +221,18 @@ export function OoComputerBrowser({
>
<ExternalLink strokeWidth={1.7} className="size-icon-md" />
</Button>
{onOpenComputer && !unavailable && !computerScreenError ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Open in full window`}
data-testid="computer-open"
onClick={onOpenComputer}
>
<Maximize2 strokeWidth={1.7} className="size-icon-md" />
</Button>
) : null}
<div
aria-hidden
className={cn(

View File

@ -15,14 +15,14 @@ type OnboardingDiscoveryShellProps = {
compact?: boolean;
};
function sourceLabel(id: DiscoverySourceId, t: ReturnType<typeof useLingui>["t"]): string {
function sourceLabel(id: DiscoverySourceId): ReactNode {
switch (id) {
case "profile-basics":
return t`Your profile`;
return <Trans>Your profile</Trans>;
case "connected-tools":
return t`Connected tools`;
return <Trans>Connected tools</Trans>;
case "specialist-crew":
return t`Specialist crew`;
return <Trans>Specialist crew</Trans>;
default: {
const never: never = id;
return never;
@ -83,7 +83,7 @@ export function OnboardingDiscoveryShell({
>
{active ? (
<>
{sourceLabel(active.id, t)}
{sourceLabel(active.id)}
{active.status === "found" && active.detail === DISCOVERY_MAIN_BOT_ROLE ? (
<>
{" · "}

View File

@ -120,7 +120,7 @@ export function OnboardingOoDesigner({ config, onChange, onContinue }: Onboardin
<Trans>What feels like you?</Trans>
</p>
<div
className="mt-3 flex gap-2 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="mt-3 flex flex-wrap gap-2 pb-1"
role="tablist"
aria-label={t`Look families`}
>

View File

@ -17,7 +17,7 @@ export function VoiceLiveText({ text, reducedMotion }: { text: string; reducedMo
return (
<p
dir="auto"
className="text-balance text-center text-ui-title font-medium leading-snug text-foreground"
className="line-clamp-3 text-pretty text-start text-ui-body font-medium leading-snug text-foreground"
>
{captionWords(text).map((word, index) => (
<span key={`${utterance}-${index}`} className={reducedMotion ? undefined : "voice-word-in"}>

View File

@ -90,21 +90,52 @@ export function VoiceStage({
}, []);
return (
<div
<section
data-testid="call-view"
data-theme="dark"
data-phase={phase}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-3 overflow-y-auto bg-background px-4 pt-[max(1rem,env(safe-area-inset-top))] pb-[max(1rem,env(safe-area-inset-bottom))] text-foreground"
className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col items-center justify-end gap-2.5 px-4 pb-[max(1rem,env(safe-area-inset-bottom))] text-foreground"
>
<h1 id={titleId} className="sr-only">
{botName}
</h1>
<div
id="voice-transcript-panel"
className={`pointer-events-auto w-full max-w-lg overflow-hidden transition-[max-height,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
transcriptOpen ? "max-h-56 opacity-100" : "max-h-0 opacity-0 pointer-events-none"
}`}
>
<div className="rk-scroll max-h-52 overflow-y-auto rounded-3xl border border-border bg-card/95 px-5 py-4 text-start shadow-xl backdrop-blur-xl">
{transcriptLines.length ? (
<ul className="space-y-2.5">
{transcriptLines.map((line, index) => (
<li
key={`${line.role}-${index}`}
className={`text-ui-body-sm leading-relaxed ${
line.role === "user" ? "text-foreground" : "text-muted-foreground"
}`}
>
<span className="text-ui-label text-muted-foreground">
{line.role === "user" ? t`You` : (line.speakerName ?? botName)}
</span>
<span dir="auto" className="mt-0.5 block">
{line.text}
</span>
</li>
))}
</ul>
) : (
<p className="text-ui-body-sm text-muted-foreground">
<Trans>Your conversation will appear here.</Trans>
</p>
)}
</div>
</div>
{awarenessLabel && awarenessEvent ? (
<div className="w-full max-w-md">
<div className="pointer-events-auto w-full max-w-lg">
<VoiceAwarenessStrip
event={awarenessEvent}
label={awarenessLabel}
@ -116,7 +147,7 @@ export function VoiceStage({
<section
data-testid="voice-panel"
className={`relative isolate flex min-h-[min(68vh,30rem)] w-full max-w-md flex-col overflow-hidden rounded-[2rem] border border-border bg-card shadow-2xl ${reducedMotion ? "" : "voice-panel-in"}`}
className={`pointer-events-auto relative isolate flex w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl backdrop-blur-xl ${reducedMotion ? "" : "voice-panel-in"}`}
>
<VoiceAurora
botColor={botColor}
@ -125,28 +156,30 @@ export function VoiceStage({
reducedMotion={reducedMotion}
/>
<div className="relative flex flex-1 flex-col items-center justify-center gap-6 px-7 pt-10 pb-4">
<div title={`${botName} · ${botTitle}`}>
<div className="relative flex items-center gap-3.5 px-4 pt-3.5 pb-2.5">
<div className="shrink-0" title={`${botName} · ${botTitle}`}>
<BotAvatar
color={botColor}
identity={botId}
status={botStatus}
pose={mascotPose}
size={88}
size={44}
/>
</div>
<VoiceLiveText text={subtitle} reducedMotion={reducedMotion} />
{error ? (
<p role="alert" className="text-center text-ui-body-sm font-medium text-destructive">
{error}
</p>
) : null}
<div className="min-w-0 flex-1 text-start">
<VoiceLiveText text={subtitle} reducedMotion={reducedMotion} />
{error ? (
<p role="alert" className="mt-1 text-ui-body-sm font-medium text-destructive">
{error}
</p>
) : null}
</div>
</div>
<div className="relative flex items-center gap-2 px-4 pb-4">
<div className="relative flex items-center gap-2 px-4 pb-3">
<span
aria-live="polite"
className="mr-auto truncate rounded-full bg-foreground/10 px-3.5 py-2 text-ui-body-sm font-medium text-foreground/85 backdrop-blur-md"
className="mr-auto truncate rounded-full bg-foreground/10 px-3 py-1.5 text-ui-body-sm font-medium text-foreground/85 backdrop-blur-md"
>
{phaseLabel}
</span>
@ -187,39 +220,6 @@ export function VoiceStage({
</Button>
</div>
</section>
<div
id="voice-transcript-panel"
className={`w-full max-w-md overflow-hidden transition-[max-height,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
transcriptOpen ? "max-h-56 opacity-100" : "max-h-0 opacity-0"
}`}
>
<div className="rk-scroll max-h-52 overflow-y-auto rounded-3xl border border-border bg-card px-5 py-4 text-start">
{transcriptLines.length ? (
<ul className="space-y-2.5">
{transcriptLines.map((line, index) => (
<li
key={`${line.role}-${index}`}
className={`text-ui-body-sm leading-relaxed ${
line.role === "user" ? "text-foreground" : "text-muted-foreground"
}`}
>
<span className="text-ui-label text-muted-foreground">
{line.role === "user" ? t`You` : (line.speakerName ?? botName)}
</span>
<span dir="auto" className="mt-0.5 block">
{line.text}
</span>
</li>
))}
</ul>
) : (
<p className="text-ui-body-sm text-muted-foreground">
<Trans>Your conversation will appear here.</Trans>
</p>
)}
</div>
</div>
</div>
</section>
);
}

View File

@ -42,4 +42,20 @@ describe("ChatMarkdown", () => {
expect(html).toContain('aria-label="Copy code"');
expect(html).toContain("rk-chat-markdown-copy");
});
it("marks outbound links with target=_blank, external indicator, and renders inline hex color swatches", () => {
const html = renderToStaticMarkup(
<ChatMarkdown>
{"See [Preview](https://example.com/review) and palette `#6B4226` or `#E8722A`."}
</ChatMarkdown>,
);
expect(html).toContain('href="https://example.com/review"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noreferrer noopener"');
expect(html).toContain("rk-chat-markdown-external-link");
expect(html).toContain("rk-chat-markdown-external-icon");
expect(html).toContain('data-testid="markdown-color-swatch"');
expect(html).toContain('data-color="#6B4226"');
});
});

View File

@ -9,6 +9,37 @@ import {
StreamingVad,
} from "@rakazo/core";
import { selectedSpaceId, withSpaceHeaders } from "./rpc.js";
import { readUiLocale } from "./ui-locale.js";
const SPEECH_LOCALE_TAGS: Record<string, string> = {
de: "de-DE",
en: "en-US",
es: "es-ES",
fr: "fr-FR",
hi: "hi-IN",
it: "it-IT",
ja: "ja-JP",
ko: "ko-KR",
"pt-BR": "pt-BR",
ru: "ru-RU",
tr: "tr-TR",
"zh-CN": "zh-CN",
};
export function speechRecognitionLang(): string {
const uiLocale = readUiLocale();
const mapped = uiLocale ? SPEECH_LOCALE_TAGS[uiLocale] : undefined;
if (uiLocale && mapped) {
// Prefer browser's regional variant when it matches the UI language (e.g. de-AT / de-CH)
const navLang = typeof navigator !== "undefined" ? navigator.language : "";
const primary = uiLocale.split("-")[0]?.toLowerCase() ?? "";
if (navLang?.toLowerCase().startsWith(`${primary}-`)) {
return navLang;
}
return mapped;
}
return (typeof navigator !== "undefined" && navigator.language) || "de-DE";
}
export type DictationMode = "hold" | "endpoint";
@ -205,7 +236,7 @@ export class Dictation {
const rec = new Ctor();
rec.continuous = true;
rec.interimResults = true;
rec.lang = navigator.language || "en-US";
rec.lang = speechRecognitionLang();
rec.onresult = (event) => {
if (this.token !== mine) return;
let transcript = "";

View File

@ -98,13 +98,25 @@ describe("isToday", () => {
});
describe("buildHeuteBrief", () => {
it("collects needs-you, doing, and done buckets", () => {
it("collects needs-you, doing, and done buckets, ignoring passive unread chats that do not need the user", () => {
const brief = buildHeuteBrief({
bots: [
bot({ id: "bot-1", unread: true }),
bot({ id: "bot-2", unread: true, updatedAt: "2026-09-22T08:00:00.000Z", name: "Steve" }),
bot({ id: "bot-1", unread: true, status: "waiting_input" }),
bot({
id: "bot-2",
unread: true,
status: "waiting_takeover",
updatedAt: "2026-09-22T08:00:00.000Z",
name: "Steve",
}),
bot({
id: "bot-passive",
unread: true,
status: "idle",
name: "PassiveBot",
}),
],
groups: [group({ updatedAt: "2026-09-22T09:30:00.000Z" })],
groups: [group({ updatedAt: "2026-09-22T09:30:00.000Z", unread: true })],
activeRuns: [
run({ runId: "run-1", botId: "bot-1" }),
run({ runId: "run-3", botId: "bot-4", botName: "Worker", status: "running" }),
@ -157,7 +169,7 @@ describe("buildHeuteBrief", () => {
it("caps needs-you at three items", () => {
const bots = Array.from({ length: 5 }, (_, index) =>
bot({ id: `bot-${index}`, unread: true, name: `Bot ${index}` }),
bot({ id: `bot-${index}`, unread: true, status: "waiting_input", name: `Bot ${index}` }),
);
const brief = buildHeuteBrief({
bots,
@ -169,6 +181,57 @@ describe("buildHeuteBrief", () => {
expect(brief.needsYou).toHaveLength(HEUTE_NEEDS_YOU_CAP);
expect(brief.actionCount).toBe(5);
});
it("merges and deduplicates actionable unread bots and groups across spaces", () => {
const brief = buildHeuteBrief({
bots: [bot({ id: "bot-1", spaceId: "space-2", unread: false, status: "idle" })],
groups: [],
spaces: [
{
id: "space-1",
bots: [
bot({
id: "bot-home-1",
spaceId: "space-1",
unread: true,
status: "waiting_input",
name: "Code Architect",
}),
bot({
id: "bot-home-2",
spaceId: "space-1",
unread: true,
status: "waiting_takeover",
name: "UI Designer",
}),
],
groups: [
group({
id: "group-home-1",
spaceId: "space-1",
unread: true,
leadBotId: "bot-home-1",
}),
],
},
{
id: "space-2",
// Current space's optimistic `bots` entry (unread: false) takes precedence over stale space snapshot
bots: [bot({ id: "bot-1", spaceId: "space-2", unread: true, status: "waiting_input" })],
groups: [],
},
],
activeRuns: [],
recentRuns: [],
now,
});
expect(brief.actionCount).toBe(3);
expect(brief.needsYou.map((item) => (item.kind === "unread" ? item.chat.id : ""))).toEqual([
"bot-home-1",
"bot-home-2",
"group-home-1",
]);
});
});
function habit(overrides: Partial<HabitToday> = {}): HabitToday {

View File

@ -1,5 +1,18 @@
import type { Bot, CoachToday, Group, RunActivityRow } from "@rakazo/contracts";
import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity";
import type {
Bot,
CoachToday,
Group,
RunActivityRow,
Space,
SpaceBot,
SpaceGroup,
} from "@rakazo/contracts";
import {
isActionableRun,
isDoingStatus,
isFailedRoutine,
isNeedsYouStatus,
} from "./agent-activity";
export type JetztRunItem = {
kind: "run";
@ -9,7 +22,7 @@ export type JetztRunItem = {
export type JetztUnreadItem = {
kind: "unread";
chatKind: "bot" | "group";
chat: Bot | Group;
chat: SpaceBot | (SpaceGroup & { leadBotId?: string | null });
};
export type JetztItem = JetztRunItem | JetztUnreadItem;
@ -76,16 +89,33 @@ function chatTargetKey(chatKind: "bot" | "group", chatId: string): string {
}
export function buildHeuteBrief(params: {
bots: Bot[];
groups: Group[];
bots: readonly SpaceBot[];
groups: readonly (SpaceGroup & { leadBotId?: string | null })[];
spaces?: readonly Pick<Space, "id" | "bots" | "groups">[];
activeRuns: RunActivityRow[];
recentRuns: RunActivityRow[];
now?: Date;
}): HeuteBriefData {
const { bots, groups, activeRuns, recentRuns, now = new Date() } = params;
const { bots, groups, spaces = [], activeRuns, recentRuns, now = new Date() } = params;
const needsYouAll: JetztItem[] = [];
const coveredTargets = new Set<string>();
const seenBotIds = new Set<string>();
const allBots: SpaceBot[] = [];
for (const bot of [...bots, ...spaces.flatMap((space) => space.bots)]) {
if (seenBotIds.has(bot.id)) continue;
seenBotIds.add(bot.id);
allBots.push(bot);
}
const seenGroupIds = new Set<string>();
const allGroups: (SpaceGroup & { leadBotId?: string | null })[] = [];
for (const group of [...groups, ...spaces.flatMap((space) => space.groups)]) {
if (seenGroupIds.has(group.id)) continue;
seenGroupIds.add(group.id);
allGroups.push(group);
}
const actionableRuns = [
...activeRuns.filter(isActionableRun),
...recentRuns.filter(isFailedRoutine),
@ -98,12 +128,20 @@ export function buildHeuteBrief(params: {
coveredTargets.add(runTargetKey(run));
}
const botStatusById = new Map(allBots.map((bot) => [bot.id, bot.status]));
const unreadChats: JetztUnreadItem[] = [
...bots
.filter((bot) => bot.unread)
...allBots
.filter((bot) => bot.unread && isNeedsYouStatus(bot.status))
.map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })),
...groups
.filter((group) => group.unread)
...allGroups
.filter((group) => {
if (!group.unread) return false;
const memberBotIds = [
...(group.leadBotId ? [group.leadBotId] : []),
...(group.members?.map((m) => m.botId) ?? []),
];
return memberBotIds.some((id) => isNeedsYouStatus(botStatusById.get(id)));
})
.map((group) => ({
kind: "unread" as const,
chatKind: "group" as const,

View File

@ -5,8 +5,8 @@
type AudioContextCtor = new () => AudioContext;
/** Master output ~−15 LUFS for short procedural peaks. */
const SFX_MASTER_GAIN = 0.58;
/** Master output ~−12 LUFS for clear, warm procedural peaks. */
const SFX_MASTER_GAIN = 0.85;
/** Duck depth when voice lane active (TTS or mic wash). */
const SFX_DUCK_RATIO = 0.28;
const DUCK_RAMP_S = 0.065;
@ -22,8 +22,10 @@ class OoAudioBus {
private ctx: AudioContext | null = null;
private sfxGain: GainNode | null = null;
private voiceHold = 0;
private unlockBound = false;
ensureContext(): AudioContext | null {
this.bindGestureUnlock();
if (this.ctx && this.ctx.state !== "closed") return this.ctx;
const Ctor = audioContextCtor();
if (!Ctor) return null;
@ -43,6 +45,34 @@ class OoAudioBus {
}
}
/** Unlocks Web Audio on the user's first click, key, or tap so async SSE events can play audio. */
bindGestureUnlock(): void {
if (
this.unlockBound ||
typeof window === "undefined" ||
typeof window.addEventListener !== "function"
) {
return;
}
this.unlockBound = true;
const unlock = () => {
const ctx = this.ensureContext();
if (ctx && ctx.state === "suspended") {
void ctx.resume().catch(() => undefined);
}
if (typeof window !== "undefined" && "speechSynthesis" in window) {
try {
window.speechSynthesis.getVoices?.();
} catch {
// Ignore voice preload errors.
}
}
};
window.addEventListener("pointerdown", unlock, { capture: true, passive: true });
window.addEventListener("keydown", unlock, { capture: true, passive: true });
window.addEventListener("touchstart", unlock, { capture: true, passive: true });
}
/** Route for oo-sound / oo-vocals / oo-voice-mark oscillators. */
sfxDestination(ctx: AudioContext): AudioNode {
if (ctx !== this.ctx) {

View File

@ -68,61 +68,62 @@ function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) {
switch (cue) {
case "app-start":
sweep(ctx, master, 523.25, 659.25, t0, 0.14, { gain: 0.038, type: "sine" });
sweep(ctx, master, 659.25, 493.88, t0 + 0.1, 0.18, { gain: 0.032, type: "triangle" });
sweep(ctx, master, 523.25, 659.25, t0, 0.16, { gain: 0.11, type: "sine" });
sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.2, { gain: 0.095, type: "triangle" });
break;
case "interact":
tone(ctx, master, 1046.5, t0, 0.06, {
gain: 0.018,
attack: 0.003,
release: 0.04,
tone(ctx, master, 880, t0, 0.075, {
gain: 0.065,
attack: 0.004,
release: 0.045,
type: "triangle",
});
break;
case "success-resolve":
case "work-complete":
// mint → gold resolve (~270 ms)
sweep(ctx, master, 523.25, 659.25, t0, 0.12, { gain: 0.042, type: "sine" });
sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.22, { gain: 0.036, type: "triangle" });
tone(ctx, master, 880, t0 + 0.2, 0.28, { gain: 0.03, type: "sine" });
sweep(ctx, master, 523.25, 659.25, t0, 0.13, { gain: 0.12, type: "sine" });
sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.22, { gain: 0.105, type: "triangle" });
tone(ctx, master, 880, t0 + 0.2, 0.28, { gain: 0.09, type: "sine" });
break;
case "need-you":
sweep(ctx, master, 220, 330, t0, 0.1, { gain: 0.042, type: "triangle" });
tone(ctx, master, 277.18, t0 + 0.09, 0.12, { gain: 0.035, release: 0.08 });
sweep(ctx, master, 261.63, 392, t0, 0.12, { gain: 0.125, type: "triangle" });
tone(ctx, master, 523.25, t0 + 0.1, 0.18, { gain: 0.11, release: 0.1 });
break;
case "listening-start":
case "voice_listen":
tone(ctx, master, 220, t0, 0.22, { gain: 0.045, type: "triangle" });
tone(ctx, master, 330, t0 + 0.04, 0.28, { gain: 0.035 });
tone(ctx, master, 293.66, t0, 0.2, { gain: 0.11, type: "triangle" });
tone(ctx, master, 440, t0 + 0.05, 0.24, { gain: 0.095 });
break;
case "listening-stop":
tone(ctx, master, 330, t0, 0.18, { gain: 0.03, release: 0.12 });
tone(ctx, master, 392, t0, 0.16, { gain: 0.085, release: 0.1 });
tone(ctx, master, 523.25, t0 + 0.06, 0.18, { gain: 0.075, release: 0.11 });
break;
case "speaking-start":
tone(ctx, master, 440, t0, 0.2, { gain: 0.04, type: "sine" });
sweep(ctx, master, 392, 523.25, t0, 0.14, { gain: 0.095, type: "sine" });
break;
case "speaking-done":
tone(ctx, master, 523.25, t0, 0.24, { gain: 0.035 });
tone(ctx, master, 392, t0 + 0.08, 0.3, { gain: 0.028, release: 0.14 });
tone(ctx, master, 523.25, t0, 0.2, { gain: 0.085 });
tone(ctx, master, 392, t0 + 0.08, 0.26, { gain: 0.075, release: 0.14 });
break;
case "voice_bargein":
sweep(ctx, master, 523.25, 261.63, t0, 0.05, { gain: 0.032, type: "sine" });
sweep(ctx, master, 523.25, 261.63, t0, 0.07, { gain: 0.09, type: "sine" });
break;
case "attention":
tone(ctx, master, 277.18, t0, 0.16, { gain: 0.05, type: "triangle" });
tone(ctx, master, 233.08, t0 + 0.12, 0.22, { gain: 0.045 });
tone(ctx, master, 329.63, t0, 0.16, { gain: 0.13, type: "triangle" });
tone(ctx, master, 261.63, t0 + 0.12, 0.22, { gain: 0.115 });
break;
case "message-sent":
tone(ctx, master, 880, t0, 0.1, { gain: 0.022, attack: 0.004, release: 0.06 });
sweep(ctx, master, 587.33, 880, t0, 0.11, { gain: 0.08, type: "sine" });
break;
case "handoff":
tone(ctx, master, 349.23, t0, 0.2, { gain: 0.04 });
tone(ctx, master, 466.16, t0 + 0.1, 0.24, { gain: 0.035, type: "triangle" });
tone(ctx, master, 392, t0, 0.18, { gain: 0.11 });
tone(ctx, master, 523.25, t0 + 0.09, 0.22, { gain: 0.1, type: "triangle" });
break;
case "group-spark":
tone(ctx, master, 587.33, t0, 0.11, { gain: 0.032, attack: 0.004, type: "triangle" });
tone(ctx, master, 739.99, t0 + 0.06, 0.14, { gain: 0.028, type: "sine" });
tone(ctx, master, 880, t0 + 0.12, 0.1, { gain: 0.022, release: 0.05 });
tone(ctx, master, 587.33, t0, 0.11, { gain: 0.095, attack: 0.004, type: "triangle" });
tone(ctx, master, 739.99, t0 + 0.06, 0.14, { gain: 0.085, type: "sine" });
tone(ctx, master, 880, t0 + 0.12, 0.12, { gain: 0.075, release: 0.06 });
break;
case "routine_start":
tone(ctx, master, 392, t0, 0.08, { gain: 0.025, attack: 0.005, release: 0.03, type: "sine" });

View File

@ -3,8 +3,8 @@ import { readOoSoundEnabled } from "./oo-sound-pref";
export type OoVocalReaction = "ouh" | "mhm" | "aaah" | "mm" | "nod-down" | "nod-up" | "hm";
const COOLDOWN_MIN_MS = 8_000;
const COOLDOWN_MAX_MS = 12_000;
const COOLDOWN_MIN_MS = 1_600;
const COOLDOWN_MAX_MS = 2_800;
let lastPlayedAt = 0;
let nextCooldownMs = COOLDOWN_MIN_MS;
@ -28,17 +28,23 @@ function vocalFormant(
) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = baseHz * 2.4;
filter.Q.value = 4.5;
osc.type = "triangle";
const boostedPeak = Math.min(0.22, gainPeak * 3.2);
osc.type = "sine";
osc.frequency.setValueAtTime(baseHz, startAt);
osc.frequency.exponentialRampToValueAtTime(baseHz * 1.12, startAt + duration * 0.75);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(gainPeak, startAt + 0.025);
gain.gain.exponentialRampToValueAtTime(boostedPeak, startAt + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
osc.connect(filter);
filter.connect(gain);
if (typeof ctx.createBiquadFilter === "function") {
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = baseHz * 1.6;
filter.Q.value = 1.8;
osc.connect(filter);
filter.connect(gain);
} else {
osc.connect(gain);
}
gain.connect(destination);
osc.start(startAt);
osc.stop(startAt + duration + 0.02);

View File

@ -37,4 +37,10 @@ describe("queuedFollowUps", () => {
const synthetic = userMessage("progress:1", "2026-01-01T10:00:05.000Z");
expect(queuedFollowUps([synthetic], RUN_START, new Map())).toEqual([]);
});
it("clears queued state once sendNow starts a newer run after the message", () => {
const followUp = userMessage("queued-1", "2026-01-01T10:00:05.000Z");
expect(queuedFollowUps([followUp], RUN_START, new Map())).toEqual([followUp]);
expect(queuedFollowUps([followUp], "2026-01-01T10:00:05.001Z", new Map())).toEqual([]);
});
});

View File

@ -1,25 +1 @@
import type { ThreadMessage } from "@rakazo/contracts";
/** The bot marks a steering message with this reaction once it has picked it up. */
const SEEN_REACTION = "👀";
/**
* User messages sent after the active run started that the bot has not picked
* up yet. The run's own trigger message predates the run, so it never counts.
*/
export function queuedFollowUps(
messages: readonly ThreadMessage[],
runCreatedAt: string | undefined,
reactions: ReadonlyMap<string, ReadonlyMap<string, number>>,
): ThreadMessage[] {
if (!runCreatedAt) return [];
const runStart = Date.parse(runCreatedAt);
if (Number.isNaN(runStart)) return [];
return messages.filter(
(message) =>
message.role === "user" &&
!message.id.includes(":") &&
Date.parse(message.createdAt) > runStart &&
!reactions.get(message.id)?.has(SEEN_REACTION),
);
}
export { queuedFollowUps } from "@rakazo/core";

View File

@ -215,6 +215,53 @@ describe("Speaker", () => {
expect(speaker.state.status).toBe("idle");
expect(speaker.state.error).toBeUndefined();
});
it("filters out novelty voices and selects a natural German voice for German text", async () => {
stubSelectedSpace("space-voice");
const speak = vi.fn();
const cancel = vi.fn();
const eddy = { name: "Eddy (German (Germany))", lang: "de-DE", localService: true };
const grandma = { name: "Grandma (German (Germany))", lang: "de-DE", localService: true };
const anna = { name: "Anna", lang: "de-DE", localService: true };
vi.stubGlobal(
"SpeechSynthesisUtterance",
class {
onend: (() => void) | null = null;
onerror: (() => void) | null = null;
voice: unknown = null;
lang = "";
rate = 1;
pitch = 1;
constructor(public text: string) {}
},
);
vi.stubGlobal("window", {
speechSynthesis: {
cancel,
getVoices: () => [eddy, grandma, anna],
speak,
},
localStorage: globalThis.localStorage,
});
const speaker = new Speaker();
vi.spyOn(
speaker as unknown as { prepare: () => Promise<string[]> },
"prepare",
).mockRejectedValue(new Error("offline"));
const pending = speaker.speak("Ich habe das Projekt für dich aktualisiert.");
await vi.waitFor(() => expect(speak).toHaveBeenCalledOnce());
const utterance = speak.mock.calls[0]?.[0] as {
voice?: { name: string };
lang?: string;
onend?: () => void;
};
expect(utterance.lang).toBe("de-DE");
expect(utterance.voice?.name).toBe("Anna");
utterance.onend?.();
await pending;
});
});
describe("UtteranceStreamer", () => {

View File

@ -1,5 +1,7 @@
import { ClauseSegmenter, readBoundedResponseBytes, speakable } from "@rakazo/core";
import { ooAudioBus } from "./oo-audio-bus.js";
import { rpc, selectedSpaceId, withSpaceHeaders } from "./rpc.js";
import { readUiLocale } from "./ui-locale.js";
export type SpeechStatus = "idle" | "preparing" | "speaking";
@ -76,7 +78,19 @@ export class Speaker {
if (this.snapshot.status !== "idle" || this.snapshot.error) this.set(IDLE);
}
private bufferSource: AudioBufferSourceNode | null = null;
private teardownAudio() {
if (this.bufferSource) {
try {
this.bufferSource.onended = null;
this.bufferSource.stop();
this.bufferSource.disconnect();
} catch {
// Ignore already-stopped buffer source errors.
}
this.bufferSource = null;
}
if (this.audio) {
this.audio.pause();
this.audio.src = "";
@ -225,16 +239,51 @@ export class Speaker {
audio.onended = null;
audio.onerror = null;
if (this.settlePlayback === done) this.settlePlayback = null;
if (this.audio === audio) this.teardownAudio();
if (this.audio === audio || this.bufferSource) this.teardownAudio();
resolve(ok);
};
this.settlePlayback = done;
audio.onended = () => done(true);
audio.onerror = () => done(false);
audio.play().catch(() => done(false));
audio.onerror = () => {
void this.playViaWebAudio(blob, live).then(done);
};
audio.play().catch(() => {
void this.playViaWebAudio(blob, live).then(done);
});
});
}
private async playViaWebAudio(blob: Blob, live: () => boolean): Promise<boolean> {
if (!live()) return false;
const ctx = ooAudioBus.ensureContext();
if (
!ctx ||
typeof ctx.decodeAudioData !== "function" ||
typeof ctx.createBufferSource !== "function"
) {
return false;
}
try {
const arrayBuffer = await blob.arrayBuffer();
if (!live()) return false;
const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
if (!live()) return false;
return await new Promise<boolean>((resolve) => {
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
this.bufferSource = source;
source.onended = () => {
if (this.bufferSource === source) this.bufferSource = null;
resolve(true);
};
source.start(0);
});
} catch {
return false;
}
}
async tryBrowserFallback(
text: string,
opts: SpeakOptions,
@ -255,17 +304,65 @@ export class Speaker {
return new Promise((resolve) => {
try {
window.speechSynthesis.cancel();
window.speechSynthesis.resume?.();
const utterance = new SpeechSynthesisUtterance(text);
const voices = window.speechSynthesis.getVoices?.() ?? [];
const isGerman = /[äöüß]|(?:und|der|die|das|ist|nicht|für|mit|den|dem|des)/i.test(text);
const langPrefix = isGerman ? "de" : "en";
const uiLocale = readUiLocale();
const voiceId = opts.voiceId ?? "";
const isGermanVoiceId = /^d[fm]_/i.test(voiceId);
const isGermanText =
/[äöüß]|(?:\b(?:und|der|die|das|ist|nicht|für|mit|den|dem|des|ein|eine|ich|wir|bitte)\b)/i.test(
text,
);
const isGerman = isGermanVoiceId || uiLocale === "de" || isGermanText;
const langPrefix = isGerman ? "de" : uiLocale.split("-")[0] || "en";
utterance.lang = isGerman ? "de-DE" : langPrefix === "en" ? "en-US" : uiLocale;
const allLangVoices = voices.filter((v) =>
(v.lang ?? "").toLowerCase().replace(/_/g, "-").startsWith(langPrefix),
);
const noveltyHint =
/\b(?:eddy|flo|grandma|grandpa|reed|rocko|sandy|shelley|albert|bad news|bahh|bells|boing|bubbles|cellos|deranged|good news|hysterical|junior|organ|superstar|trinoids|whisper|zarvox|fred|kathy|ralph)\b/i;
const naturalLangVoices = allLangVoices.filter((v) => !noveltyHint.test(v.name ?? ""));
const langVoices = naturalLangVoices.length > 0 ? naturalLangVoices : allLangVoices;
const wantsMale = /^dm_/i.test(voiceId);
const wantsFemale = /^df_/i.test(voiceId) || (!wantsMale && isGerman);
const maleHint =
/yannick|martin|markus|stefan|viktor|hans|daniel|thomas|florian|conrad|male/i;
const femaleHint = /anna|petra|helena|marlene|vicki|katja|seraphina|amala|siri|female/i;
const matchedVoice =
voices.find((v) => v.lang.startsWith(langPrefix) && !v.localService) ||
voices.find((v) => v.lang.startsWith(langPrefix)) ||
(wantsMale &&
(langVoices.find((v) => !v.localService && maleHint.test(v.name ?? "")) ||
langVoices.find((v) => maleHint.test(v.name ?? "")))) ||
(wantsFemale &&
(langVoices.find((v) => !v.localService && femaleHint.test(v.name ?? "")) ||
langVoices.find((v) => femaleHint.test(v.name ?? "")))) ||
langVoices.find((v) => !v.localService) ||
langVoices[0] ||
voices[0];
if (matchedVoice) utterance.voice = matchedVoice;
utterance.rate = 1.05;
utterance.pitch = 1.0;
// Distinct natural voice profiles for the 5 German presets when rendered via system TTS
if (voiceId === "dm_karlsson") {
utterance.rate = 0.98;
utterance.pitch = 0.85;
} else if (voiceId === "dm_thorsten") {
utterance.rate = 1.03;
utterance.pitch = 0.95;
} else if (voiceId === "df_ramona") {
utterance.rate = 0.97;
utterance.pitch = 1.0;
} else if (voiceId === "df_eva") {
utterance.rate = 1.02;
utterance.pitch = 1.04;
} else if (voiceId === "df_kerstin") {
utterance.rate = 1.05;
utterance.pitch = 1.08;
} else {
utterance.rate = 1.04;
utterance.pitch = 1.0;
}
this.set({
status: "speaking",

View File

@ -100,3 +100,37 @@ export async function disableWebPush(): Promise<void> {
await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint });
await subscription.unsubscribe().catch(() => undefined);
}
/**
* True on iOS/iPadOS when opened in a browser tab rather than as an installed Home-Screen PWA
* (where Apple WebKit only exposes Web Push in standalone display mode).
*/
export function isIosBrowserNeedsHomeScreen(): boolean {
if (typeof window === "undefined" || typeof navigator === "undefined" || desktopBridge()) {
return false;
}
const ua = navigator.userAgent || "";
const isIos =
/iPad|iPhone|iPod/i.test(ua) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (!isIos) return false;
const standalone =
(typeof window.matchMedia === "function" &&
window.matchMedia("(display-mode: standalone)").matches) ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
return !standalone && !webPushSupported();
}
/** Syncs the installed PWA / OS app icon badge with the actionable "Needs You" count. */
export function syncAppBadge(count: number): void {
if (typeof navigator === "undefined") return;
const nav = navigator as Navigator & {
setAppBadge?: (contents?: number) => Promise<void>;
clearAppBadge?: () => Promise<void>;
};
if (count > 0 && typeof nav.setAppBadge === "function") {
void nav.setAppBadge(count).catch(() => undefined);
} else if (count <= 0 && typeof nav.clearAppBadge === "function") {
void nav.clearAppBadge().catch(() => undefined);
}
}

View File

@ -161,11 +161,6 @@ msgstr "Browser von {0}"
msgid "{0}'s browser appears here when the computer is running."
msgstr "{0}s Browser erscheint hier, wenn der Computer läuft."
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
msgstr "{0}s Bildschirm"
#: src/pages/HeuteBrief.tsx
msgid "{actionCount, plural, one {# thing needs you} other {# things need you}}"
msgstr "{actionCount, plural, one {# Sache braucht dich} other {# Sachen brauchen dich}}"
@ -182,10 +177,38 @@ msgstr "{botName} prüft die Architektur…"
msgid "{botName} clears the path…"
msgstr "{botName} räumt den Weg frei…"
#: src/pages/shell/transcript.tsx
msgid "{botName} is building"
msgstr "{botName} baut & tüftelt"
#: src/pages/shell/transcript.tsx
msgid "{botName} is checking a problem"
msgstr "{botName} prüft ein Problem"
#: src/lib/voice-persona-copy.ts
msgid "{botName} is on it…"
msgstr "{botName} ist dran…"
#: src/pages/shell/transcript.tsx
msgid "{botName} is replying"
msgstr "{botName} antwortet"
#: src/pages/shell/transcript.tsx
msgid "{botName} is researching"
msgstr "{botName} liest & recherchiert"
#: src/pages/shell/transcript.tsx
msgid "{botName} is thinking"
msgstr "{botName} denkt nach"
#: src/pages/shell/transcript.tsx
msgid "{botName} is waiting for your answer"
msgstr "{botName} wartet auf deine Antwort"
#: src/pages/shell/transcript.tsx
msgid "{botName} is working"
msgstr "{botName} arbeitet"
#: src/lib/voice-persona-copy.ts
#: src/pages/CallView.tsx
msgid "{botName} is working…"
@ -364,6 +387,7 @@ msgstr "Noch {remaining} · Bot beobachtet nur"
msgid "{size} B"
msgstr "{size} B"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "{stepCount, plural, one {# step} other {# steps}}"
msgstr "{stepCount, plural, one {# Schritt} other {# Schritte}}"
@ -386,10 +410,6 @@ msgstr "{title}, {label}"
msgid "{toolCount, plural, one {# tool} other {# tools}}"
msgstr "{toolCount, plural, one {# Tool} other {# Tools}}"
#: src/pages/shell/transcript.tsx
msgid "{workingBotName} is working"
msgstr "{workingBotName} arbeitet"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "/Users/you/projects/acme"
@ -433,7 +453,7 @@ msgstr "Über Gruppen"
#: src/pages/shell/bot-picker.tsx
msgid "About spaces"
msgstr "Über Spaces"
msgstr "Über Projekte"
#: src/components/UserProfileSettings.tsx
msgid "About you"
@ -544,11 +564,16 @@ msgstr "Weiteres hinzufügen"
msgid "Add as projects"
msgstr "Als Projekte hinzufügen"
#: src/components/NotificationSettingsPanel.tsx
msgid "Add evOo to your Home Screen via Share to enable push on iOS."
msgstr "Füge evOo über „Teilen“ zum Home-Bildschirm hinzu, um Push-Mitteilungen auf iOS zu aktivieren."
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Executor hinzufügen"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Add goal"
msgstr "Ziel hinzufügen"
@ -737,6 +762,14 @@ msgstr "Immer erlaubt"
msgid "Analysiert die Aufgabe und führt Tools aus…"
msgstr "Analysiert die Aufgabe und führt Tools aus…"
#: src/pages/Shell.tsx
msgid "Analyze the project and give me a brief overview."
msgstr "Analysiere das Projekt und gib mir einen kurzen Überblick."
#: src/pages/shell/project-overview.tsx
msgid "Analyzing project…"
msgstr "Projekt wird analysiert…"
#: src/components/AskCard.tsx
msgid "Answer"
msgstr "Antwort"
@ -996,7 +1029,7 @@ msgstr "Körper"
msgid "Booting live desktop…"
msgstr "Live-Desktop wird gestartet…"
#. placeholder {0}: active?.name
#. placeholder {0}: (active ?? coordinatorBot)?.name
#: src/pages/Shell.tsx
msgid "Booting up {0}’s computer"
msgstr "Computer von {0} wird gestartet"
@ -1019,10 +1052,6 @@ msgstr "Bot"
msgid "Bot screen"
msgstr "Bot-Bildschirm"
#: src/pages/shell/computer-panel-preview.tsx
msgid "Bot screen preview"
msgstr "Bot-Bildschirmvorschau"
#: src/pages/MessagingSettingsOverlay.tsx
msgid "Bot to link"
msgstr "Zu verknüpfender Bot"
@ -1031,10 +1060,6 @@ msgstr "Zu verknüpfender Bot"
msgid "Bots act without asking by default. Add an exception only when you want to review a type of action first."
msgstr "Bots handeln standardmäßig ohne Nachfrage. Füge nur dann eine Ausnahme hinzu, wenn du eine Aktionsart zuerst prüfen möchtest."
#: src/pages/shell/transcript.tsx
msgid "Bots are working"
msgstr "Bots arbeiten"
#: src/components/OoAtelier.tsx
msgid "Bow"
msgstr "Schleife"
@ -1064,7 +1089,6 @@ msgstr "Durchsuchen"
msgid "Browse MCP servers"
msgstr "MCP-Server durchsuchen"
#: src/pages/Shell.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Browser"
msgstr "Browser"
@ -1493,6 +1517,10 @@ msgstr "Computer"
msgid "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
msgstr "Computer sind aus. Setze SANDBOX_PROVIDER=docker mit SANDBOX_SUPERVISOR_TOKEN, oder setze SANDBOX_PROVIDER auf e2b, daytona oder box mit dem passenden API-Key. Stack nach der Änderung von .env neu erstellen."
#: src/pages/PluginsOverlay.tsx
msgid "Configure a plugin catalog on the server to connect apps."
msgstr "Richte auf dem Server einen Plugin-Katalog ein, um Apps zu verbinden."
#: src/pages/ModelSettingsOverlay.tsx
msgid "Configured by deployment"
msgstr "Durch die Bereitstellung konfiguriert"
@ -1584,6 +1612,10 @@ msgstr "Verbunden, {0} wird verwendet."
msgid "Connected app"
msgstr "Verbundene App"
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Connected tools"
msgstr "Verbundene Tools"
#: src/pages/shell/message-cards.tsx
msgid "Connected. Its tools are available from your next message."
msgstr "Verbunden. Die Tools sind ab deiner nächsten Nachricht verfügbar."
@ -2077,7 +2109,7 @@ msgstr "Neue Gruppe erstellen"
#: src/pages/shell/bot-picker.tsx
msgid "Create new Space"
msgstr "Neuen Space erstellen"
msgstr "Neues Projekt erstellen"
#: src/pages/shell/dialogs.tsx
msgid "Create project"
@ -2086,7 +2118,7 @@ msgstr "Projekt erstellen"
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"
msgstr "Space erstellen"
msgstr "Projekt erstellen"
#: src/components/integrations/GithubConnect.tsx
msgid "Create token on GitHub"
@ -2241,7 +2273,7 @@ msgstr "Erinnerungen ebenfalls löschen"
#: src/pages/Shell.tsx
msgid "Delete space"
msgstr "Space löschen"
msgstr "Projekt löschen"
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Delete your account?"
@ -2322,6 +2354,7 @@ msgstr "Verbindung wird getrennt…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "Ausblenden"
@ -2381,7 +2414,9 @@ msgstr "Kein Stress — wir sind jetzt da."
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Done"
msgstr "Fertig"
@ -2394,8 +2429,11 @@ msgstr "Erledigt"
msgid "Dot"
msgstr "Punkt"
#. placeholder {0}: activeItem.name
#. placeholder {0}: item.name
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {0}"
msgstr "{0} herunterladen"
@ -2403,7 +2441,6 @@ msgstr "{0} herunterladen"
#: src/components/ArtifactPane.tsx
#: src/components/media/MediaCard.tsx
#: src/components/media/MediaPlayer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {name}"
msgstr "{name} herunterladen"
@ -2589,6 +2626,8 @@ msgid "Expand"
msgstr "Erweitern"
#. placeholder {0}: group.title
#. placeholder {0}: item.name
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/sidebar-group-header.tsx
msgid "Expand {0}"
msgstr "{0} ausklappen"
@ -2660,6 +2699,10 @@ msgstr "Workspace-Dateien konnten nicht geladen werden"
msgid "Failed to open file"
msgstr "Datei konnte nicht geöffnet werden"
#: src/pages/Shell.tsx
msgid "Failed to remove message"
msgstr "Nachricht konnte nicht entfernt werden"
#: src/pages/Shell.tsx
msgid "Failed to send message"
msgstr "Nachricht konnte nicht gesendet werden"
@ -2983,13 +3026,18 @@ msgid "Here's what I see in the logs…"
msgstr "Das sehe ich in den Logs…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hi, this is how I'll sound when I read replies out loud."
msgstr "Hallo, so klinge ich, wenn ich Antworten vorlese."
msgid "Hi, this is how I'll sound when we talk."
msgstr "Hallo, so klinge ich, wenn wir miteinander sprechen."
#: src/pages/Shell.tsx
#: src/pages/shell/sidebar-header.tsx
msgid "Hide bots"
msgstr "Bots ausblenden"
#: src/pages/shell/transcript.tsx
msgid "Hide details"
msgstr "Details ausblenden"
#: src/pages/Auth.tsx
msgid "Hide password"
msgstr "Passwort verbergen"
@ -3080,6 +3128,7 @@ msgstr "Bot importieren"
msgid "Import OpenAPI JSON"
msgstr "OpenAPI-JSON importieren"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "In Bearbeitung"
msgstr "In Bearbeitung"
@ -3166,6 +3215,10 @@ msgstr "Isoliert"
msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
msgstr "Seine Unterhaltung, Dateien und Routinen werden dauerhaft gelöscht. Von ihm erstellte Bots bleiben in deiner Liste."
#: src/pages/shell/transcript.tsx
msgid "joined"
msgstr "dazugeschaltet"
#: src/pages/shell/transcript.tsx
msgid "Jump to latest"
msgstr "Zur neuesten Nachricht springen"
@ -3174,6 +3227,10 @@ msgstr "Zur neuesten Nachricht springen"
msgid "Jump to replied message"
msgstr "Zur beantworteten Nachricht springen"
#: src/pages/shell/project-overview.tsx
msgid "Just ask in the chat whenever something should happen regularly."
msgstr "Sag einfach im Chat Bescheid, wenn etwas regelmäßig erledigt werden soll."
#: src/pages/ActivityList.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "just now"
@ -3771,9 +3828,10 @@ msgstr "Neuer Skill"
#: src/pages/shell/dialogs.tsx
msgid "New space"
msgstr "Neuer Space"
msgstr "Neues Projekt"
#: src/components/CookingMode.tsx
#: src/pages/shell/message-cards.tsx
msgid "Next"
msgstr "Weiter"
@ -3885,6 +3943,10 @@ msgstr "Noch keine Projekte."
msgid "No providers found."
msgstr "Keine Anbieter gefunden."
#: src/pages/ActivityList.tsx
msgid "No recent activity"
msgstr "Keine letzte Aktivität"
#: src/components/integrations/IntegrationSetup.tsx
msgid "No remote MCP servers found"
msgstr "Keine Remote-MCP-Server gefunden"
@ -3899,6 +3961,7 @@ msgid "No results"
msgstr "Keine Ergebnisse"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "No runs yet"
msgstr "Noch keine Ausführungen"
@ -3957,6 +4020,7 @@ msgid "Notifications"
msgstr "Benachrichtigungen"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Now"
msgstr "Jetzt"
@ -4027,7 +4091,6 @@ msgid "Oo name"
msgstr "Name des Oo"
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
msgstr "Öffnen"
@ -4056,7 +4119,7 @@ msgstr "Unterhaltung öffnen"
msgid "Open in browser"
msgstr "Im Browser öffnen"
#: src/pages/shell/computer-panel-preview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in full window"
msgstr "In vollständigem Fenster öffnen"
@ -4288,10 +4351,6 @@ msgstr "Privater Arbeitsbereich mit eigenen Bots und Gruppen."
msgid "Project"
msgstr "Projekt"
#: src/pages/Shell.tsx
msgid "Project · sessions & folders"
msgstr "Projekt · Sessions & Ordner"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Project folder"
@ -4399,6 +4458,10 @@ msgstr "Lese den Stacktrace…"
msgid "Ready"
msgstr "Bereit"
#: src/pages/shell/project-overview.tsx
msgid "Ready"
msgstr "Bereit"
#: src/pages/ModelSettingsOverlay.tsx
msgid "Reasoning effort"
msgstr "Denkaufwand"
@ -4412,6 +4475,7 @@ msgid "Recall only"
msgstr "Nur abrufen"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Recent"
msgstr "Kürzlich"
@ -4528,6 +4592,10 @@ msgstr "{0} entfernen"
msgid "Remove folder"
msgstr "Ordner entfernen"
#: src/pages/shell/transcript.tsx
msgid "Remove from queue"
msgstr "Aus der Warteschlange entfernen"
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr "Git-Ereignis entfernen"
@ -4653,6 +4721,11 @@ msgstr "Stellt den zuletzt gespeicherten Arbeitsbereich wieder her. Nicht gespei
msgid "Restoring your workspace"
msgstr "Dein Arbeitsbereich wird wiederhergestellt"
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr "Fortsetzen"
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr "Wiederholen"
@ -4700,11 +4773,13 @@ msgstr "Rund"
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "Routine"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Routines"
msgstr "Routinen"
@ -4718,6 +4793,7 @@ msgid "Run at"
msgstr "Ausführen um"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Run history"
msgstr "Ausführungsverlauf"
@ -4756,10 +4832,6 @@ msgstr "Wird ausgeführt…"
msgid "Runs when this bot receives a verified message from this provider."
msgstr "Läuft, wenn dieser Bot eine verifizierte Nachricht von diesem Anbieter erhält."
#: src/pages/shell/side-panel-header.tsx
msgid "Sandbox"
msgstr "Sandbox"
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
@ -4909,6 +4981,10 @@ msgstr "Antwort senden"
msgid "Send it"
msgstr "Senden"
#: src/pages/shell/transcript.tsx
msgid "Send now"
msgstr "Jetzt gleich senden"
#: src/pages/Auth.tsx
msgid "Send reset link"
msgstr "Link zum Zurücksetzen senden"
@ -5021,6 +5097,10 @@ msgstr "Bots anzeigen"
msgid "Show computer"
msgstr "Computer anzeigen"
#: src/pages/shell/transcript.tsx
msgid "Show details"
msgstr "Details anzeigen"
#: src/pages/Shell.tsx
msgid "Show less"
msgstr "Weniger anzeigen"
@ -5037,6 +5117,7 @@ msgstr "Mehr anzeigen"
msgid "Show password"
msgstr "Passwort anzeigen"
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Show settings"
msgstr "Einstellungen anzeigen"
@ -5136,7 +5217,7 @@ msgstr "Space-Standard"
#: src/pages/shell/dialogs.tsx
msgid "Spaces"
msgstr "Spaces"
msgstr "Projekte"
#: src/components/OoAtelier.tsx
msgid "Sparkle"
@ -5172,6 +5253,7 @@ msgstr "Vorlesen an oder aus"
msgid "Speaking with intent…"
msgstr "Spreche mit Bedacht…"
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr "Spezialisten-Team"
@ -5286,6 +5368,10 @@ msgstr "Anlernen beenden"
msgid "Stop timer"
msgstr "Timer stoppen"
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr "Gestoppt"
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."
@ -5398,6 +5484,7 @@ msgstr "Aufgabe wird angelernt. Beende das Anlernen, bevor du eine neue Nachrich
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-overview.tsx
msgid "Team"
msgstr "Team"
@ -5512,10 +5599,6 @@ msgstr "Diese Abonnementanmeldung ist in Rakazo noch nicht verfügbar. Verwende
msgid "This will permanently delete this project and all its conversations, bots, and memory."
msgstr "Dadurch werden dieses Projekt und alle zugehörigen Unterhaltungen, Bots und Erinnerungen dauerhaft gelöscht."
#: src/pages/Shell.tsx
msgid "Threads by topic · folders"
msgstr "Unterhaltungen nach Thema · Ordner"
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Tiers"
@ -5701,6 +5784,7 @@ msgstr "Bis wann?"
#: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/AccountSettingsOverlay.tsx
msgid "Up to date"
msgstr "Aktuell"
@ -5995,6 +6079,7 @@ msgid "Work channel open — go ahead."
msgstr "Arbeitskanal offen — leg los."
#: src/components/stage/OoStage.tsx
#: src/pages/shell/project-overview.tsx
msgid "Working"
msgstr "Arbeitet"
@ -6037,6 +6122,7 @@ msgstr "Gestern"
#: src/components/UserProfileSettings.tsx
#: src/components/voice/VoiceStage.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/transcript.tsx
msgid "You"
msgstr "Du"
@ -6077,13 +6163,25 @@ msgid "Your name"
msgstr "Dein Name"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Your Oo reads replies aloud when this is on."
msgstr "Wenn das an ist, liest dein Oo Antworten vor."
msgid "Your Oo talks with you naturally when this is on."
msgstr "Wenn das an ist, spricht dein Oo ganz natürlich mit dir."
#: src/pages/Onboarding.tsx
msgid "Your Oo will communicate in this language. You can change it later."
msgstr "Dein Oo kommuniziert in dieser Sprache. Du kannst sie später ändern."
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Your profile"
msgstr "Dein Profil"
#: src/pages/Welcome.tsx
msgid "Your specialists. One calm place to talk."
msgstr "Deine Spezialisten. Ein ruhiger Ort zum Reden."
#: src/pages/shell/message-cards.tsx
msgid "Zoom in"
msgstr "Vergrößern"
#: src/pages/shell/message-cards.tsx
msgid "Zoom out"
msgstr "Verkleinern"

View File

@ -161,11 +161,6 @@ msgstr "{0}'s browser"
msgid "{0}'s browser appears here when the computer is running."
msgstr "{0}'s browser appears here when the computer is running."
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
msgstr "{0}'s screen"
#: src/pages/HeuteBrief.tsx
msgid "{actionCount, plural, one {# thing needs you} other {# things need you}}"
msgstr "{actionCount, plural, one {# thing needs you} other {# things need you}}"
@ -182,10 +177,38 @@ msgstr "{botName} checks the architecture…"
msgid "{botName} clears the path…"
msgstr "{botName} clears the path…"
#: src/pages/shell/transcript.tsx
msgid "{botName} is building"
msgstr "{botName} is building"
#: src/pages/shell/transcript.tsx
msgid "{botName} is checking a problem"
msgstr "{botName} is checking a problem"
#: src/lib/voice-persona-copy.ts
msgid "{botName} is on it…"
msgstr "{botName} is on it…"
#: src/pages/shell/transcript.tsx
msgid "{botName} is replying"
msgstr "{botName} is replying"
#: src/pages/shell/transcript.tsx
msgid "{botName} is researching"
msgstr "{botName} is researching"
#: src/pages/shell/transcript.tsx
msgid "{botName} is thinking"
msgstr "{botName} is thinking"
#: src/pages/shell/transcript.tsx
msgid "{botName} is waiting for your answer"
msgstr "{botName} is waiting for your answer"
#: src/pages/shell/transcript.tsx
msgid "{botName} is working"
msgstr "{botName} is working"
#: src/lib/voice-persona-copy.ts
#: src/pages/CallView.tsx
msgid "{botName} is working…"
@ -364,6 +387,7 @@ msgstr "{remaining} left · bot is watching, not acting"
msgid "{size} B"
msgstr "{size} B"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "{stepCount, plural, one {# step} other {# steps}}"
msgstr "{stepCount, plural, one {# step} other {# steps}}"
@ -386,10 +410,6 @@ msgstr "{title}, {label}"
msgid "{toolCount, plural, one {# tool} other {# tools}}"
msgstr "{toolCount, plural, one {# tool} other {# tools}}"
#: src/pages/shell/transcript.tsx
msgid "{workingBotName} is working"
msgstr "{workingBotName} is working"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "/Users/you/projects/acme"
@ -544,11 +564,16 @@ msgstr "Add another"
msgid "Add as projects"
msgstr "Add as projects"
#: src/components/NotificationSettingsPanel.tsx
msgid "Add evOo to your Home Screen via Share to enable push on iOS."
msgstr "Add evOo to your Home Screen via Share to enable push on iOS."
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Add Executor"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Add goal"
msgstr "Add goal"
@ -737,6 +762,14 @@ msgstr "Always allowed"
msgid "Analysiert die Aufgabe und führt Tools aus…"
msgstr "Analysiert die Aufgabe und führt Tools aus…"
#: src/pages/Shell.tsx
msgid "Analyze the project and give me a brief overview."
msgstr "Analyze the project and give me a brief overview."
#: src/pages/shell/project-overview.tsx
msgid "Analyzing project…"
msgstr "Analyzing project…"
#: src/components/AskCard.tsx
msgid "Answer"
msgstr "Answer"
@ -996,7 +1029,7 @@ msgstr "Body"
msgid "Booting live desktop…"
msgstr "Booting live desktop…"
#. placeholder {0}: active?.name
#. placeholder {0}: (active ?? coordinatorBot)?.name
#: src/pages/Shell.tsx
msgid "Booting up {0}’s computer"
msgstr "Booting up {0}’s computer"
@ -1019,10 +1052,6 @@ msgstr "Bot"
msgid "Bot screen"
msgstr "Bot screen"
#: src/pages/shell/computer-panel-preview.tsx
msgid "Bot screen preview"
msgstr "Bot screen preview"
#: src/pages/MessagingSettingsOverlay.tsx
msgid "Bot to link"
msgstr "Bot to link"
@ -1031,10 +1060,6 @@ msgstr "Bot to link"
msgid "Bots act without asking by default. Add an exception only when you want to review a type of action first."
msgstr "Bots act without asking by default. Add an exception only when you want to review a type of action first."
#: src/pages/shell/transcript.tsx
msgid "Bots are working"
msgstr "Bots are working"
#: src/components/OoAtelier.tsx
msgid "Bow"
msgstr "Bow"
@ -1064,7 +1089,6 @@ msgstr "Browse"
msgid "Browse MCP servers"
msgstr "Browse MCP servers"
#: src/pages/Shell.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Browser"
msgstr "Browser"
@ -1493,6 +1517,10 @@ msgstr "Computers"
msgid "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
msgstr "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
#: src/pages/PluginsOverlay.tsx
msgid "Configure a plugin catalog on the server to connect apps."
msgstr "Configure a plugin catalog on the server to connect apps."
#: src/pages/ModelSettingsOverlay.tsx
msgid "Configured by deployment"
msgstr "Configured by deployment"
@ -1584,6 +1612,10 @@ msgstr "Connected and using {0}."
msgid "Connected app"
msgstr "Connected app"
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Connected tools"
msgstr "Connected tools"
#: src/pages/shell/message-cards.tsx
msgid "Connected. Its tools are available from your next message."
msgstr "Connected. Its tools are available from your next message."
@ -2322,6 +2354,7 @@ msgstr "Disconnecting…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "Dismiss"
@ -2381,7 +2414,9 @@ msgstr "Don't stress — we're here now."
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Done"
msgstr "Done"
@ -2394,8 +2429,11 @@ msgstr "Done"
msgid "Dot"
msgstr "Dot"
#. placeholder {0}: activeItem.name
#. placeholder {0}: item.name
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {0}"
msgstr "Download {0}"
@ -2403,7 +2441,6 @@ msgstr "Download {0}"
#: src/components/ArtifactPane.tsx
#: src/components/media/MediaCard.tsx
#: src/components/media/MediaPlayer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {name}"
msgstr "Download {name}"
@ -2589,6 +2626,8 @@ msgid "Expand"
msgstr "Expand"
#. placeholder {0}: group.title
#. placeholder {0}: item.name
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/sidebar-group-header.tsx
msgid "Expand {0}"
msgstr "Expand {0}"
@ -2660,6 +2699,10 @@ msgstr "Failed to load workspace files"
msgid "Failed to open file"
msgstr "Failed to open file"
#: src/pages/Shell.tsx
msgid "Failed to remove message"
msgstr "Failed to remove message"
#: src/pages/Shell.tsx
msgid "Failed to send message"
msgstr "Failed to send message"
@ -2983,13 +3026,18 @@ msgid "Here's what I see in the logs…"
msgstr "Here's what I see in the logs…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hi, this is how I'll sound when I read replies out loud."
msgstr "Hi, this is how I'll sound when I read replies out loud."
msgid "Hi, this is how I'll sound when we talk."
msgstr "Hi, this is how I'll sound when we talk."
#: src/pages/Shell.tsx
#: src/pages/shell/sidebar-header.tsx
msgid "Hide bots"
msgstr "Hide bots"
#: src/pages/shell/transcript.tsx
msgid "Hide details"
msgstr "Hide details"
#: src/pages/Auth.tsx
msgid "Hide password"
msgstr "Hide password"
@ -3080,6 +3128,7 @@ msgstr "Import bot"
msgid "Import OpenAPI JSON"
msgstr "Import OpenAPI JSON"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "In Bearbeitung"
msgstr "In Bearbeitung"
@ -3166,6 +3215,10 @@ msgstr "Isolated"
msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
msgstr "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
#: src/pages/shell/transcript.tsx
msgid "joined"
msgstr "joined"
#: src/pages/shell/transcript.tsx
msgid "Jump to latest"
msgstr "Jump to latest"
@ -3174,6 +3227,10 @@ msgstr "Jump to latest"
msgid "Jump to replied message"
msgstr "Jump to replied message"
#: src/pages/shell/project-overview.tsx
msgid "Just ask in the chat whenever something should happen regularly."
msgstr "Just ask in the chat whenever something should happen regularly."
#: src/pages/ActivityList.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "just now"
@ -3774,6 +3831,7 @@ msgid "New space"
msgstr "New space"
#: src/components/CookingMode.tsx
#: src/pages/shell/message-cards.tsx
msgid "Next"
msgstr "Next"
@ -3885,6 +3943,10 @@ msgstr "No projects yet."
msgid "No providers found."
msgstr "No providers found."
#: src/pages/ActivityList.tsx
msgid "No recent activity"
msgstr "No recent activity"
#: src/components/integrations/IntegrationSetup.tsx
msgid "No remote MCP servers found"
msgstr "No remote MCP servers found"
@ -3899,6 +3961,7 @@ msgid "No results"
msgstr "No results"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "No runs yet"
msgstr "No runs yet"
@ -3957,6 +4020,7 @@ msgid "Notifications"
msgstr "Notifications"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Now"
msgstr "Now"
@ -4027,7 +4091,6 @@ msgid "Oo name"
msgstr "Oo name"
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
msgstr "Open"
@ -4056,7 +4119,7 @@ msgstr "Open conversation"
msgid "Open in browser"
msgstr "Open in browser"
#: src/pages/shell/computer-panel-preview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in full window"
msgstr "Open in full window"
@ -4288,10 +4351,6 @@ msgstr "Private workspace with its own bots and groups."
msgid "Project"
msgstr "Project"
#: src/pages/Shell.tsx
msgid "Project · sessions & folders"
msgstr "Project · sessions & folders"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Project folder"
@ -4399,6 +4458,10 @@ msgstr "Reading the stack trace…"
msgid "Ready"
msgstr "Ready"
#: src/pages/shell/project-overview.tsx
msgid "Ready"
msgstr "Ready"
#: src/pages/ModelSettingsOverlay.tsx
msgid "Reasoning effort"
msgstr "Reasoning effort"
@ -4412,6 +4475,7 @@ msgid "Recall only"
msgstr "Recall only"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Recent"
msgstr "Recent"
@ -4528,6 +4592,10 @@ msgstr "Remove {0}"
msgid "Remove folder"
msgstr "Remove folder"
#: src/pages/shell/transcript.tsx
msgid "Remove from queue"
msgstr "Remove from queue"
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr "Remove Git event"
@ -4653,6 +4721,11 @@ msgstr "Restore the last saved workspace. Unsaved work on the computer is lost."
msgid "Restoring your workspace"
msgstr "Restoring your workspace"
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr "Resume"
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr "Retry"
@ -4700,11 +4773,13 @@ msgstr "Round"
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "Routine"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Routines"
msgstr "Routines"
@ -4718,6 +4793,7 @@ msgid "Run at"
msgstr "Run at"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Run history"
msgstr "Run history"
@ -4756,10 +4832,6 @@ msgstr "Running…"
msgid "Runs when this bot receives a verified message from this provider."
msgstr "Runs when this bot receives a verified message from this provider."
#: src/pages/shell/side-panel-header.tsx
msgid "Sandbox"
msgstr "Sandbox"
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
@ -4909,6 +4981,10 @@ msgstr "Send answer"
msgid "Send it"
msgstr "Send it"
#: src/pages/shell/transcript.tsx
msgid "Send now"
msgstr "Send now"
#: src/pages/Auth.tsx
msgid "Send reset link"
msgstr "Send reset link"
@ -5021,6 +5097,10 @@ msgstr "Show bots"
msgid "Show computer"
msgstr "Show computer"
#: src/pages/shell/transcript.tsx
msgid "Show details"
msgstr "Show details"
#: src/pages/Shell.tsx
msgid "Show less"
msgstr "Show less"
@ -5037,6 +5117,7 @@ msgstr "Show more"
msgid "Show password"
msgstr "Show password"
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Show settings"
msgstr "Show settings"
@ -5172,6 +5253,7 @@ msgstr "Speaking on or off"
msgid "Speaking with intent…"
msgstr "Speaking with intent…"
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr "Specialist crew"
@ -5286,6 +5368,10 @@ msgstr "Stop teaching"
msgid "Stop timer"
msgstr "Stop timer"
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr "Stopped"
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."
@ -5398,6 +5484,7 @@ msgstr "Teaching in progress. Stop teaching before sending a new message."
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-overview.tsx
msgid "Team"
msgstr "Team"
@ -5512,10 +5599,6 @@ msgstr "This subscription sign-in is not available in Rakazo yet. Use a deployme
msgid "This will permanently delete this project and all its conversations, bots, and memory."
msgstr "This will permanently delete this project and all its conversations, bots, and memory."
#: src/pages/Shell.tsx
msgid "Threads by topic · folders"
msgstr "Threads by topic · folders"
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Tiers"
@ -5701,6 +5784,7 @@ msgstr "Until when?"
#: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/AccountSettingsOverlay.tsx
msgid "Up to date"
msgstr "Up to date"
@ -5995,6 +6079,7 @@ msgid "Work channel open — go ahead."
msgstr "Work channel open — go ahead."
#: src/components/stage/OoStage.tsx
#: src/pages/shell/project-overview.tsx
msgid "Working"
msgstr "Working"
@ -6037,6 +6122,7 @@ msgstr "Yesterday"
#: src/components/UserProfileSettings.tsx
#: src/components/voice/VoiceStage.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/transcript.tsx
msgid "You"
msgstr "You"
@ -6077,13 +6163,25 @@ msgid "Your name"
msgstr "Your name"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Your Oo reads replies aloud when this is on."
msgstr "Your Oo reads replies aloud when this is on."
msgid "Your Oo talks with you naturally when this is on."
msgstr "Your Oo talks with you naturally when this is on."
#: src/pages/Onboarding.tsx
msgid "Your Oo will communicate in this language. You can change it later."
msgstr "Your Oo will communicate in this language. You can change it later."
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Your profile"
msgstr "Your profile"
#: src/pages/Welcome.tsx
msgid "Your specialists. One calm place to talk."
msgstr "Your specialists. One calm place to talk."
#: src/pages/shell/message-cards.tsx
msgid "Zoom in"
msgstr "Zoom in"
#: src/pages/shell/message-cards.tsx
msgid "Zoom out"
msgstr "Zoom out"

View File

@ -161,11 +161,6 @@ msgstr ""
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
msgstr "Pantalla de {0}"
#: src/pages/HeuteBrief.tsx
msgid "{actionCount, plural, one {# thing needs you} other {# things need you}}"
msgstr ""
@ -182,10 +177,38 @@ msgstr ""
msgid "{botName} clears the path…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is building"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is checking a problem"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "{botName} is on it…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is replying"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is researching"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is thinking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is waiting for your answer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is working"
msgstr ""
#: src/lib/voice-persona-copy.ts
#: src/pages/CallView.tsx
msgid "{botName} is working…"
@ -364,6 +387,7 @@ msgstr "Quedan {remaining} · el bot observa, no actúa"
msgid "{size} B"
msgstr "{size} B"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "{stepCount, plural, one {# step} other {# steps}}"
msgstr ""
@ -386,10 +410,6 @@ msgstr "{title}, {label}"
msgid "{toolCount, plural, one {# tool} other {# tools}}"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{workingBotName} is working"
msgstr "{workingBotName} está trabajando"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "/Users/you/projects/acme"
@ -544,11 +564,16 @@ msgstr ""
msgid "Add as projects"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Add evOo to your Home Screen via Share to enable push on iOS."
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Agregar Executor"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Add goal"
msgstr ""
@ -737,6 +762,14 @@ msgstr "Siempre permitido"
msgid "Analysiert die Aufgabe und führt Tools aus…"
msgstr ""
#: src/pages/Shell.tsx
msgid "Analyze the project and give me a brief overview."
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Analyzing project…"
msgstr ""
#: src/components/AskCard.tsx
msgid "Answer"
msgstr "Responder"
@ -996,7 +1029,7 @@ msgstr ""
msgid "Booting live desktop…"
msgstr "Iniciando escritorio en vivo…"
#. placeholder {0}: active?.name
#. placeholder {0}: (active ?? coordinatorBot)?.name
#: src/pages/Shell.tsx
msgid "Booting up {0}’s computer"
msgstr "Iniciando la computadora de {0}"
@ -1019,10 +1052,6 @@ msgstr "Bot"
msgid "Bot screen"
msgstr "Pantalla del bot"
#: src/pages/shell/computer-panel-preview.tsx
msgid "Bot screen preview"
msgstr "Vista previa de la pantalla del bot"
#: src/pages/MessagingSettingsOverlay.tsx
msgid "Bot to link"
msgstr "Bot a vincular"
@ -1031,10 +1060,6 @@ msgstr "Bot a vincular"
msgid "Bots act without asking by default. Add an exception only when you want to review a type of action first."
msgstr "Los bots actúan sin preguntar de forma predeterminada. Agrega una excepción solo cuando quieras revisar primero un tipo de acción."
#: src/pages/shell/transcript.tsx
msgid "Bots are working"
msgstr "Los bots están trabajando"
#: src/components/OoAtelier.tsx
msgid "Bow"
msgstr ""
@ -1064,7 +1089,6 @@ msgstr ""
msgid "Browse MCP servers"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Browser"
msgstr ""
@ -1493,6 +1517,10 @@ msgstr "Computadoras"
msgid "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
msgstr "Las computadoras están apagadas. Configura SANDBOX_PROVIDER=docker con SANDBOX_SUPERVISOR_TOKEN, o establece SANDBOX_PROVIDER en e2b, daytona o box con su clave de API. Recrea el stack después de cambiar .env."
#: src/pages/PluginsOverlay.tsx
msgid "Configure a plugin catalog on the server to connect apps."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Configured by deployment"
msgstr "Configurado por el despliegue"
@ -1584,6 +1612,10 @@ msgstr "Conectado y usando {0}."
msgid "Connected app"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Connected tools"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Connected. Its tools are available from your next message."
msgstr "Conectado: sus herramientas estarán disponibles desde tu próximo mensaje."
@ -2322,6 +2354,7 @@ msgstr "Desconectando…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "Descartar"
@ -2381,7 +2414,9 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Done"
msgstr "Listo"
@ -2394,8 +2429,11 @@ msgstr ""
msgid "Dot"
msgstr ""
#. placeholder {0}: activeItem.name
#. placeholder {0}: item.name
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {0}"
msgstr "Descargar {0}"
@ -2403,7 +2441,6 @@ msgstr "Descargar {0}"
#: src/components/ArtifactPane.tsx
#: src/components/media/MediaCard.tsx
#: src/components/media/MediaPlayer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {name}"
msgstr "Descargar {name}"
@ -2589,6 +2626,8 @@ msgid "Expand"
msgstr "Expandir"
#. placeholder {0}: group.title
#. placeholder {0}: item.name
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/sidebar-group-header.tsx
msgid "Expand {0}"
msgstr "Expandir {0}"
@ -2660,6 +2699,10 @@ msgstr ""
msgid "Failed to open file"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to remove message"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to send message"
msgstr "No se pudo enviar el mensaje"
@ -2983,13 +3026,18 @@ msgid "Here's what I see in the logs…"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hi, this is how I'll sound when I read replies out loud."
msgstr "Hola, así sonaré cuando lea las respuestas en voz alta."
msgid "Hi, this is how I'll sound when we talk."
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/sidebar-header.tsx
msgid "Hide bots"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Hide details"
msgstr ""
#: src/pages/Auth.tsx
msgid "Hide password"
msgstr "Ocultar contraseña"
@ -3080,6 +3128,7 @@ msgstr ""
msgid "Import OpenAPI JSON"
msgstr "Importar JSON de OpenAPI"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "In Bearbeitung"
msgstr ""
@ -3166,6 +3215,10 @@ msgstr "Aislado"
msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
msgstr "Su conversación, archivos y rutinas se eliminarán de forma permanente. Los bots que creó permanecerán en tu lista."
#: src/pages/shell/transcript.tsx
msgid "joined"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Jump to latest"
msgstr "Ir al más reciente"
@ -3174,6 +3227,10 @@ msgstr "Ir al más reciente"
msgid "Jump to replied message"
msgstr "Ir al mensaje respondido"
#: src/pages/shell/project-overview.tsx
msgid "Just ask in the chat whenever something should happen regularly."
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "just now"
@ -3774,6 +3831,7 @@ msgid "New space"
msgstr "Nuevo space"
#: src/components/CookingMode.tsx
#: src/pages/shell/message-cards.tsx
msgid "Next"
msgstr ""
@ -3885,6 +3943,10 @@ msgstr ""
msgid "No providers found."
msgstr "No se encontraron proveedores."
#: src/pages/ActivityList.tsx
msgid "No recent activity"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "No remote MCP servers found"
msgstr ""
@ -3899,6 +3961,7 @@ msgid "No results"
msgstr "Sin resultados"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "No runs yet"
msgstr "Aún no hay ejecuciones"
@ -3957,6 +4020,7 @@ msgid "Notifications"
msgstr "Notificaciones"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Now"
msgstr "Ahora"
@ -4027,7 +4091,6 @@ msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
msgstr "Abrir"
@ -4056,7 +4119,7 @@ msgstr ""
msgid "Open in browser"
msgstr ""
#: src/pages/shell/computer-panel-preview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in full window"
msgstr "Abrir en ventana completa"
@ -4288,10 +4351,6 @@ msgstr ""
msgid "Project"
msgstr ""
#: src/pages/Shell.tsx
msgid "Project · sessions & folders"
msgstr ""
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Project folder"
@ -4399,6 +4458,10 @@ msgstr ""
msgid "Ready"
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Ready"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Reasoning effort"
msgstr "Esfuerzo de razonamiento"
@ -4412,6 +4475,7 @@ msgid "Recall only"
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Recent"
msgstr "Recientes"
@ -4528,6 +4592,10 @@ msgstr "Quitar {0}"
msgid "Remove folder"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Remove from queue"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""
@ -4653,6 +4721,11 @@ msgstr "Restaura el último espacio de trabajo guardado. Se pierde el trabajo si
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -4700,11 +4773,13 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "Rutina"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Routines"
msgstr "Rutinas"
@ -4718,6 +4793,7 @@ msgid "Run at"
msgstr "Ejecutar a las"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Run history"
msgstr "Historial de ejecuciones"
@ -4756,10 +4832,6 @@ msgstr "Ejecutando…"
msgid "Runs when this bot receives a verified message from this provider."
msgstr ""
#: src/pages/shell/side-panel-header.tsx
msgid "Sandbox"
msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
@ -4909,6 +4981,10 @@ msgstr "Enviar respuesta"
msgid "Send it"
msgstr "Enviarlo"
#: src/pages/shell/transcript.tsx
msgid "Send now"
msgstr "Enviar ahora"
#: src/pages/Auth.tsx
msgid "Send reset link"
msgstr "Enviar enlace de restablecimiento"
@ -5021,6 +5097,10 @@ msgstr ""
msgid "Show computer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Show details"
msgstr ""
#: src/pages/Shell.tsx
msgid "Show less"
msgstr ""
@ -5037,6 +5117,7 @@ msgstr "Mostrar más"
msgid "Show password"
msgstr "Mostrar contraseña"
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Show settings"
msgstr "Mostrar configuración"
@ -5172,6 +5253,7 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
@ -5286,6 +5368,10 @@ msgstr "Detener enseñanza"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."
@ -5398,6 +5484,7 @@ msgstr "Enseñanza en curso: detén la enseñanza antes de enviar un nuevo mensa
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-overview.tsx
msgid "Team"
msgstr "Equipo"
@ -5512,10 +5599,6 @@ msgstr "Este inicio de sesión por suscripción aún no está disponible en Raka
msgid "This will permanently delete this project and all its conversations, bots, and memory."
msgstr ""
#: src/pages/Shell.tsx
msgid "Threads by topic · folders"
msgstr ""
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Tiers"
@ -5701,6 +5784,7 @@ msgstr ""
#: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/AccountSettingsOverlay.tsx
msgid "Up to date"
msgstr "Actualizado"
@ -5995,6 +6079,7 @@ msgid "Work channel open — go ahead."
msgstr ""
#: src/components/stage/OoStage.tsx
#: src/pages/shell/project-overview.tsx
msgid "Working"
msgstr ""
@ -6037,6 +6122,7 @@ msgstr "Ayer"
#: src/components/UserProfileSettings.tsx
#: src/components/voice/VoiceStage.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/transcript.tsx
msgid "You"
msgstr "Tú"
@ -6077,13 +6163,25 @@ msgid "Your name"
msgstr "Tu nombre"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Your Oo reads replies aloud when this is on."
msgid "Your Oo talks with you naturally when this is on."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Your Oo will communicate in this language. You can change it later."
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Your profile"
msgstr ""
#: src/pages/Welcome.tsx
msgid "Your specialists. One calm place to talk."
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom in"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom out"
msgstr ""

View File

@ -161,11 +161,6 @@ msgstr ""
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "{actionCount, plural, one {# thing needs you} other {# things need you}}"
msgstr ""
@ -182,10 +177,38 @@ msgstr ""
msgid "{botName} clears the path…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is building"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is checking a problem"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "{botName} is on it…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is replying"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is researching"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is thinking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is waiting for your answer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is working"
msgstr ""
#: src/lib/voice-persona-copy.ts
#: src/pages/CallView.tsx
msgid "{botName} is working…"
@ -364,6 +387,7 @@ msgstr "{remaining} शेष · बॉट देख रहा है, कार
msgid "{size} B"
msgstr "{size} B"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "{stepCount, plural, one {# step} other {# steps}}"
msgstr ""
@ -386,10 +410,6 @@ msgstr "{title}, {label}"
msgid "{toolCount, plural, one {# tool} other {# tools}}"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{workingBotName} is working"
msgstr "{workingBotName} काम कर रहा है"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "/Users/you/projects/acme"
@ -544,11 +564,16 @@ msgstr ""
msgid "Add as projects"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Add evOo to your Home Screen via Share to enable push on iOS."
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Executor जोड़ें"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Add goal"
msgstr ""
@ -737,6 +762,14 @@ msgstr "हमेशा अनुमत"
msgid "Analysiert die Aufgabe und führt Tools aus…"
msgstr ""
#: src/pages/Shell.tsx
msgid "Analyze the project and give me a brief overview."
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Analyzing project…"
msgstr ""
#: src/components/AskCard.tsx
msgid "Answer"
msgstr "उत्तर"
@ -996,7 +1029,7 @@ msgstr ""
msgid "Booting live desktop…"
msgstr "लाइव डेस्कटॉप शुरू हो रहा है…"
#. placeholder {0}: active?.name
#. placeholder {0}: (active ?? coordinatorBot)?.name
#: src/pages/Shell.tsx
msgid "Booting up {0}’s computer"
msgstr "{0} का कंप्यूटर शुरू हो रहा है"
@ -1019,10 +1052,6 @@ msgstr "बॉट"
msgid "Bot screen"
msgstr "बॉट स्क्रीन"
#: src/pages/shell/computer-panel-preview.tsx
msgid "Bot screen preview"
msgstr "बॉट स्क्रीन प्रीव्यू"
#: src/pages/MessagingSettingsOverlay.tsx
msgid "Bot to link"
msgstr ""
@ -1031,10 +1060,6 @@ msgstr ""
msgid "Bots act without asking by default. Add an exception only when you want to review a type of action first."
msgstr "बॉट डिफ़ॉल्ट रूप से बिना पूछे कार्रवाई करते हैं। अपवाद तभी जोड़ें जब आप किसी प्रकार की कार्रवाई की पहले समीक्षा करना चाहते हों।"
#: src/pages/shell/transcript.tsx
msgid "Bots are working"
msgstr "बॉट काम कर रहे हैं"
#: src/components/OoAtelier.tsx
msgid "Bow"
msgstr ""
@ -1064,7 +1089,6 @@ msgstr ""
msgid "Browse MCP servers"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Browser"
msgstr ""
@ -1493,6 +1517,10 @@ msgstr "कंप्यूटर"
msgid "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
msgstr "कंप्यूटर बंद हैं। SANDBOX_PROVIDER=docker और SANDBOX_SUPERVISOR_TOKEN सेट करें, या SANDBOX_PROVIDER को e2b, daytona, या box पर सेट कर उसका API key दें। .env बदलने के बाद स्टैक फिर बनाएँ।"
#: src/pages/PluginsOverlay.tsx
msgid "Configure a plugin catalog on the server to connect apps."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Configured by deployment"
msgstr "डिप्लॉयमेंट द्वारा कॉन्फ़िगर"
@ -1584,6 +1612,10 @@ msgstr "{0} कनेक्ट किया गया और उपयोग म
msgid "Connected app"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Connected tools"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Connected. Its tools are available from your next message."
msgstr "कनेक्टेड। इसके टूल आपके अगले संदेश से उपलब्ध हैं।"
@ -2322,6 +2354,7 @@ msgstr "डिस्कनेक्ट हो रहा है…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr ""
@ -2381,7 +2414,9 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Done"
msgstr "पूर्ण"
@ -2394,8 +2429,11 @@ msgstr ""
msgid "Dot"
msgstr ""
#. placeholder {0}: activeItem.name
#. placeholder {0}: item.name
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {0}"
msgstr "{0} डाउनलोड करें"
@ -2403,7 +2441,6 @@ msgstr "{0} डाउनलोड करें"
#: src/components/ArtifactPane.tsx
#: src/components/media/MediaCard.tsx
#: src/components/media/MediaPlayer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {name}"
msgstr "{name} डाउनलोड करें"
@ -2589,6 +2626,8 @@ msgid "Expand"
msgstr "विस्तृत करें"
#. placeholder {0}: group.title
#. placeholder {0}: item.name
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/sidebar-group-header.tsx
msgid "Expand {0}"
msgstr ""
@ -2660,6 +2699,10 @@ msgstr ""
msgid "Failed to open file"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to remove message"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to send message"
msgstr "संदेश भेजने में विफल"
@ -2983,13 +3026,18 @@ msgid "Here's what I see in the logs…"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hi, this is how I'll sound when I read replies out loud."
msgstr "नमस्ते, उत्तर पढ़कर सुनाते समय मेरी आवाज़ ऐसी होगी।"
msgid "Hi, this is how I'll sound when we talk."
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/sidebar-header.tsx
msgid "Hide bots"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Hide details"
msgstr ""
#: src/pages/Auth.tsx
msgid "Hide password"
msgstr ""
@ -3080,6 +3128,7 @@ msgstr ""
msgid "Import OpenAPI JSON"
msgstr "OpenAPI JSON इम्पोर्ट करें"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "In Bearbeitung"
msgstr ""
@ -3166,6 +3215,10 @@ msgstr "पृथक"
msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
msgstr "इसकी बातचीत, फ़ाइलें और रूटीन स्थायी रूप से डिलीट कर दिए जाएंगे। इसके बनाए बॉट आपकी सूची में बने रहेंगे।"
#: src/pages/shell/transcript.tsx
msgid "joined"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Jump to latest"
msgstr "नवीनतम संदेश पर जाएँ"
@ -3174,6 +3227,10 @@ msgstr "नवीनतम संदेश पर जाएँ"
msgid "Jump to replied message"
msgstr "जिस संदेश का उत्तर दिया गया उस पर जाएं"
#: src/pages/shell/project-overview.tsx
msgid "Just ask in the chat whenever something should happen regularly."
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "just now"
@ -3774,6 +3831,7 @@ msgid "New space"
msgstr ""
#: src/components/CookingMode.tsx
#: src/pages/shell/message-cards.tsx
msgid "Next"
msgstr ""
@ -3885,6 +3943,10 @@ msgstr ""
msgid "No providers found."
msgstr "कोई प्रोवाइडर नहीं मिला।"
#: src/pages/ActivityList.tsx
msgid "No recent activity"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "No remote MCP servers found"
msgstr ""
@ -3899,6 +3961,7 @@ msgid "No results"
msgstr "कोई परिणाम नहीं"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "No runs yet"
msgstr ""
@ -3957,6 +4020,7 @@ msgid "Notifications"
msgstr "सूचनाएं"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Now"
msgstr "अभी"
@ -4027,7 +4091,6 @@ msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
msgstr "खोलें"
@ -4056,7 +4119,7 @@ msgstr ""
msgid "Open in browser"
msgstr ""
#: src/pages/shell/computer-panel-preview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in full window"
msgstr "पूरी विंडो में खोलें"
@ -4288,10 +4351,6 @@ msgstr ""
msgid "Project"
msgstr ""
#: src/pages/Shell.tsx
msgid "Project · sessions & folders"
msgstr ""
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Project folder"
@ -4399,6 +4458,10 @@ msgstr ""
msgid "Ready"
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Ready"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Reasoning effort"
msgstr "रीज़निंग प्रयास"
@ -4412,6 +4475,7 @@ msgid "Recall only"
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Recent"
msgstr "हाल के"
@ -4528,6 +4592,10 @@ msgstr "{0} हटाएं"
msgid "Remove folder"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Remove from queue"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""
@ -4653,6 +4721,11 @@ msgstr "अंतिम सहेजे गए वर्कस्पेस क
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -4700,11 +4773,13 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "रूटीन"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Routines"
msgstr "रूटीन्स"
@ -4718,6 +4793,7 @@ msgid "Run at"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Run history"
msgstr ""
@ -4756,10 +4832,6 @@ msgstr "चल रहा है…"
msgid "Runs when this bot receives a verified message from this provider."
msgstr ""
#: src/pages/shell/side-panel-header.tsx
msgid "Sandbox"
msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
@ -4909,6 +4981,10 @@ msgstr "उत्तर भेजें"
msgid "Send it"
msgstr "इसे भेजें"
#: src/pages/shell/transcript.tsx
msgid "Send now"
msgstr "अभी भेजें"
#: src/pages/Auth.tsx
msgid "Send reset link"
msgstr "रीसेट लिंक भेजें"
@ -5021,6 +5097,10 @@ msgstr ""
msgid "Show computer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Show details"
msgstr ""
#: src/pages/Shell.tsx
msgid "Show less"
msgstr ""
@ -5037,6 +5117,7 @@ msgstr "और दिखाएँ"
msgid "Show password"
msgstr ""
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Show settings"
msgstr "सेटिंग्स दिखाएं"
@ -5172,6 +5253,7 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
@ -5286,6 +5368,10 @@ msgstr "सिखाना रोकें"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."
@ -5398,6 +5484,7 @@ msgstr "सिखाना जारी है। नया संदेश भ
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-overview.tsx
msgid "Team"
msgstr "टीम"
@ -5512,10 +5599,6 @@ msgstr "यह सब्सक्रिप्शन साइन-इन अभ
msgid "This will permanently delete this project and all its conversations, bots, and memory."
msgstr ""
#: src/pages/Shell.tsx
msgid "Threads by topic · folders"
msgstr ""
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Tiers"
@ -5701,6 +5784,7 @@ msgstr ""
#: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/AccountSettingsOverlay.tsx
msgid "Up to date"
msgstr ""
@ -5995,6 +6079,7 @@ msgid "Work channel open — go ahead."
msgstr ""
#: src/components/stage/OoStage.tsx
#: src/pages/shell/project-overview.tsx
msgid "Working"
msgstr ""
@ -6037,6 +6122,7 @@ msgstr "कल"
#: src/components/UserProfileSettings.tsx
#: src/components/voice/VoiceStage.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/transcript.tsx
msgid "You"
msgstr "आप"
@ -6077,13 +6163,25 @@ msgid "Your name"
msgstr "आपका नाम"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Your Oo reads replies aloud when this is on."
msgid "Your Oo talks with you naturally when this is on."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Your Oo will communicate in this language. You can change it later."
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Your profile"
msgstr ""
#: src/pages/Welcome.tsx
msgid "Your specialists. One calm place to talk."
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom in"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom out"
msgstr ""

View File

@ -161,11 +161,6 @@ msgstr ""
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
msgstr "{0}의 화면"
#: src/pages/HeuteBrief.tsx
msgid "{actionCount, plural, one {# thing needs you} other {# things need you}}"
msgstr ""
@ -182,10 +177,38 @@ msgstr ""
msgid "{botName} clears the path…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is building"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is checking a problem"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "{botName} is on it…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is replying"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is researching"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is thinking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is waiting for your answer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is working"
msgstr ""
#: src/lib/voice-persona-copy.ts
#: src/pages/CallView.tsx
msgid "{botName} is working…"
@ -364,6 +387,7 @@ msgstr "남은 시간 {remaining} · Bot은 화면만 보고 직접 조작하지
msgid "{size} B"
msgstr "{size} B"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "{stepCount, plural, one {# step} other {# steps}}"
msgstr ""
@ -386,10 +410,6 @@ msgstr "{title}, {label}"
msgid "{toolCount, plural, one {# tool} other {# tools}}"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{workingBotName} is working"
msgstr "{workingBotName} 작업 중"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "/Users/you/projects/acme"
@ -544,11 +564,16 @@ msgstr ""
msgid "Add as projects"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Add evOo to your Home Screen via Share to enable push on iOS."
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Executor 추가"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Add goal"
msgstr ""
@ -737,6 +762,14 @@ msgstr "항상 허용됨"
msgid "Analysiert die Aufgabe und führt Tools aus…"
msgstr ""
#: src/pages/Shell.tsx
msgid "Analyze the project and give me a brief overview."
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Analyzing project…"
msgstr ""
#: src/components/AskCard.tsx
msgid "Answer"
msgstr "답변"
@ -996,7 +1029,7 @@ msgstr ""
msgid "Booting live desktop…"
msgstr "라이브 데스크톱 시작 중…"
#. placeholder {0}: active?.name
#. placeholder {0}: (active ?? coordinatorBot)?.name
#: src/pages/Shell.tsx
msgid "Booting up {0}’s computer"
msgstr "{0}의 컴퓨터를 켜는 중"
@ -1019,10 +1052,6 @@ msgstr "봇"
msgid "Bot screen"
msgstr "Bot 화면"
#: src/pages/shell/computer-panel-preview.tsx
msgid "Bot screen preview"
msgstr "Bot 화면 미리보기"
#: src/pages/MessagingSettingsOverlay.tsx
msgid "Bot to link"
msgstr "연결할 Bot"
@ -1031,10 +1060,6 @@ msgstr "연결할 Bot"
msgid "Bots act without asking by default. Add an exception only when you want to review a type of action first."
msgstr "Bot은 기본적으로 묻지 않고 작업합니다. 먼저 확인하고 싶은 작업만 예외로 추가하세요."
#: src/pages/shell/transcript.tsx
msgid "Bots are working"
msgstr "봇 작업 중"
#: src/components/OoAtelier.tsx
msgid "Bow"
msgstr ""
@ -1064,7 +1089,6 @@ msgstr ""
msgid "Browse MCP servers"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Browser"
msgstr ""
@ -1493,6 +1517,10 @@ msgstr "컴퓨터"
msgid "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
msgstr "컴퓨터가 꺼져 있습니다. SANDBOX_PROVIDER=docker와 SANDBOX_SUPERVISOR_TOKEN을 설정하거나, SANDBOX_PROVIDER를 e2b, daytona, box 중 하나로 두고 해당 API 키를 넣으세요. .env 변경 후 스택을 다시 만드세요."
#: src/pages/PluginsOverlay.tsx
msgid "Configure a plugin catalog on the server to connect apps."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Configured by deployment"
msgstr "서버에서 설정됨"
@ -1584,6 +1612,10 @@ msgstr "{0}에 연결하고 기본 모델로 설정했습니다."
msgid "Connected app"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Connected tools"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Connected. Its tools are available from your next message."
msgstr "연결되었습니다. 다음 메시지부터 이 도구를 사용할 수 있습니다."
@ -2322,6 +2354,7 @@ msgstr "연결 해제 중…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "닫기"
@ -2381,7 +2414,9 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Done"
msgstr "완료"
@ -2394,8 +2429,11 @@ msgstr ""
msgid "Dot"
msgstr ""
#. placeholder {0}: activeItem.name
#. placeholder {0}: item.name
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {0}"
msgstr "{0} 다운로드"
@ -2403,7 +2441,6 @@ msgstr "{0} 다운로드"
#: src/components/ArtifactPane.tsx
#: src/components/media/MediaCard.tsx
#: src/components/media/MediaPlayer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {name}"
msgstr "{name} 다운로드"
@ -2589,6 +2626,8 @@ msgid "Expand"
msgstr "펼치기"
#. placeholder {0}: group.title
#. placeholder {0}: item.name
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/sidebar-group-header.tsx
msgid "Expand {0}"
msgstr "{0} 펼치기"
@ -2660,6 +2699,10 @@ msgstr ""
msgid "Failed to open file"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to remove message"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to send message"
msgstr "메시지를 보내지 못했습니다."
@ -2983,13 +3026,18 @@ msgid "Here's what I see in the logs…"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hi, this is how I'll sound when I read replies out loud."
msgstr "안녕하세요. 답변을 소리 내어 읽을 때 이런 목소리로 들립니다."
msgid "Hi, this is how I'll sound when we talk."
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/sidebar-header.tsx
msgid "Hide bots"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Hide details"
msgstr ""
#: src/pages/Auth.tsx
msgid "Hide password"
msgstr "비밀번호 숨기기"
@ -3080,6 +3128,7 @@ msgstr ""
msgid "Import OpenAPI JSON"
msgstr "OpenAPI JSON 가져오기"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "In Bearbeitung"
msgstr ""
@ -3166,6 +3215,10 @@ msgstr "이 Bot만 사용"
msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
msgstr "대화, 파일, 자동 실행이 영구 삭제됩니다. 이 Bot이 만든 다른 Bot은 목록에 남습니다."
#: src/pages/shell/transcript.tsx
msgid "joined"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Jump to latest"
msgstr "최신 메시지로 이동"
@ -3174,6 +3227,10 @@ msgstr "최신 메시지로 이동"
msgid "Jump to replied message"
msgstr "답장한 메시지로 이동"
#: src/pages/shell/project-overview.tsx
msgid "Just ask in the chat whenever something should happen regularly."
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "just now"
@ -3774,6 +3831,7 @@ msgid "New space"
msgstr "새 스페이스"
#: src/components/CookingMode.tsx
#: src/pages/shell/message-cards.tsx
msgid "Next"
msgstr ""
@ -3885,6 +3943,10 @@ msgstr ""
msgid "No providers found."
msgstr "검색된 AI 서비스가 없습니다."
#: src/pages/ActivityList.tsx
msgid "No recent activity"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "No remote MCP servers found"
msgstr ""
@ -3899,6 +3961,7 @@ msgid "No results"
msgstr "검색 결과가 없습니다"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "No runs yet"
msgstr "아직 실행 기록이 없습니다"
@ -3957,6 +4020,7 @@ msgid "Notifications"
msgstr "알림"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Now"
msgstr "지금"
@ -4027,7 +4091,6 @@ msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
msgstr "열기"
@ -4056,7 +4119,7 @@ msgstr ""
msgid "Open in browser"
msgstr ""
#: src/pages/shell/computer-panel-preview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in full window"
msgstr "전체 창으로 열기"
@ -4288,10 +4351,6 @@ msgstr ""
msgid "Project"
msgstr ""
#: src/pages/Shell.tsx
msgid "Project · sessions & folders"
msgstr ""
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Project folder"
@ -4399,6 +4458,10 @@ msgstr ""
msgid "Ready"
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Ready"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Reasoning effort"
msgstr "추론 강도"
@ -4412,6 +4475,7 @@ msgid "Recall only"
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Recent"
msgstr "최근"
@ -4528,6 +4592,10 @@ msgstr "{0} 삭제"
msgid "Remove folder"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Remove from queue"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""
@ -4653,6 +4721,11 @@ msgstr "마지막으로 저장된 작업 공간을 복원합니다. 컴퓨터에
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -4700,11 +4773,13 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "자동 실행"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Routines"
msgstr "자동 실행"
@ -4718,6 +4793,7 @@ msgid "Run at"
msgstr "실행 시간"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Run history"
msgstr "실행 기록"
@ -4756,10 +4832,6 @@ msgstr "실행 중…"
msgid "Runs when this bot receives a verified message from this provider."
msgstr ""
#: src/pages/shell/side-panel-header.tsx
msgid "Sandbox"
msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
@ -4909,6 +4981,10 @@ msgstr "답변 보내기"
msgid "Send it"
msgstr "보내기"
#: src/pages/shell/transcript.tsx
msgid "Send now"
msgstr "지금 보내기"
#: src/pages/Auth.tsx
msgid "Send reset link"
msgstr "재설정 링크 보내기"
@ -5021,6 +5097,10 @@ msgstr ""
msgid "Show computer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Show details"
msgstr ""
#: src/pages/Shell.tsx
msgid "Show less"
msgstr ""
@ -5037,6 +5117,7 @@ msgstr "더 보기"
msgid "Show password"
msgstr "비밀번호 표시"
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Show settings"
msgstr "설정 보기"
@ -5172,6 +5253,7 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
@ -5286,6 +5368,10 @@ msgstr "가르치기 종료"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."
@ -5398,6 +5484,7 @@ msgstr "작업을 가르치는 중입니다. 새 메시지를 보내려면 먼
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-overview.tsx
msgid "Team"
msgstr "팀 공용"
@ -5512,10 +5599,6 @@ msgstr "이 구독 로그인 방식은 아직 Rakazo에서 지원되지 않습
msgid "This will permanently delete this project and all its conversations, bots, and memory."
msgstr ""
#: src/pages/Shell.tsx
msgid "Threads by topic · folders"
msgstr ""
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Tiers"
@ -5701,6 +5784,7 @@ msgstr ""
#: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/AccountSettingsOverlay.tsx
msgid "Up to date"
msgstr "최신 상태"
@ -5995,6 +6079,7 @@ msgid "Work channel open — go ahead."
msgstr ""
#: src/components/stage/OoStage.tsx
#: src/pages/shell/project-overview.tsx
msgid "Working"
msgstr ""
@ -6037,6 +6122,7 @@ msgstr "어제"
#: src/components/UserProfileSettings.tsx
#: src/components/voice/VoiceStage.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/transcript.tsx
msgid "You"
msgstr "나"
@ -6077,13 +6163,25 @@ msgid "Your name"
msgstr "이름"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Your Oo reads replies aloud when this is on."
msgid "Your Oo talks with you naturally when this is on."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Your Oo will communicate in this language. You can change it later."
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Your profile"
msgstr ""
#: src/pages/Welcome.tsx
msgid "Your specialists. One calm place to talk."
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom in"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom out"
msgstr ""

View File

@ -161,11 +161,6 @@ msgstr ""
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "{actionCount, plural, one {# thing needs you} other {# things need you}}"
msgstr ""
@ -182,10 +177,38 @@ msgstr ""
msgid "{botName} clears the path…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is building"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is checking a problem"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "{botName} is on it…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is replying"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is researching"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is thinking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is waiting for your answer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is working"
msgstr ""
#: src/lib/voice-persona-copy.ts
#: src/pages/CallView.tsx
msgid "{botName} is working…"
@ -364,6 +387,7 @@ msgstr "{remaining} restantes · o bot está observando, não agindo"
msgid "{size} B"
msgstr "{size} B"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "{stepCount, plural, one {# step} other {# steps}}"
msgstr ""
@ -386,10 +410,6 @@ msgstr "{title}, {label}"
msgid "{toolCount, plural, one {# tool} other {# tools}}"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{workingBotName} is working"
msgstr "{workingBotName} está trabalhando"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "/Users/you/projects/acme"
@ -544,11 +564,16 @@ msgstr ""
msgid "Add as projects"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Add evOo to your Home Screen via Share to enable push on iOS."
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Adicionar Executor"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Add goal"
msgstr ""
@ -737,6 +762,14 @@ msgstr "Sempre permitido"
msgid "Analysiert die Aufgabe und führt Tools aus…"
msgstr ""
#: src/pages/Shell.tsx
msgid "Analyze the project and give me a brief overview."
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Analyzing project…"
msgstr ""
#: src/components/AskCard.tsx
msgid "Answer"
msgstr "Resposta"
@ -996,7 +1029,7 @@ msgstr ""
msgid "Booting live desktop…"
msgstr "Iniciando a área de trabalho ao vivo..."
#. placeholder {0}: active?.name
#. placeholder {0}: (active ?? coordinatorBot)?.name
#: src/pages/Shell.tsx
msgid "Booting up {0}’s computer"
msgstr "Inicializando o computador do {0}"
@ -1019,10 +1052,6 @@ msgstr "Bot"
msgid "Bot screen"
msgstr "Tela do bot"
#: src/pages/shell/computer-panel-preview.tsx
msgid "Bot screen preview"
msgstr "Pré-visualização da tela"
#: src/pages/MessagingSettingsOverlay.tsx
msgid "Bot to link"
msgstr ""
@ -1031,10 +1060,6 @@ msgstr ""
msgid "Bots act without asking by default. Add an exception only when you want to review a type of action first."
msgstr "Os bots agem sem perguntar por padrão. Adicione uma exceção apenas quando quiser analisar um tipo de ação primeiro."
#: src/pages/shell/transcript.tsx
msgid "Bots are working"
msgstr "Bots estão trabalhando"
#: src/components/OoAtelier.tsx
msgid "Bow"
msgstr ""
@ -1064,7 +1089,6 @@ msgstr ""
msgid "Browse MCP servers"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Browser"
msgstr ""
@ -1493,6 +1517,10 @@ msgstr "Computadores"
msgid "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
msgstr "Computadores estão desligados. Defina SANDBOX_PROVIDER=docker com SANDBOX_SUPERVISOR_TOKEN, ou SANDBOX_PROVIDER como e2b, daytona ou box com a chave de API. Recrie o stack após alterar o .env."
#: src/pages/PluginsOverlay.tsx
msgid "Configure a plugin catalog on the server to connect apps."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Configured by deployment"
msgstr "Configurado por implantação"
@ -1584,6 +1612,10 @@ msgstr "Conectado e usando {0}."
msgid "Connected app"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Connected tools"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Connected. Its tools are available from your next message."
msgstr "Conectado. Suas ferramentas estão disponíveis na sua próxima mensagem."
@ -2322,6 +2354,7 @@ msgstr "Desconectando…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr ""
@ -2381,7 +2414,9 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Done"
msgstr "Concluído"
@ -2394,8 +2429,11 @@ msgstr ""
msgid "Dot"
msgstr ""
#. placeholder {0}: activeItem.name
#. placeholder {0}: item.name
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {0}"
msgstr "Baixar {0}"
@ -2403,7 +2441,6 @@ msgstr "Baixar {0}"
#: src/components/ArtifactPane.tsx
#: src/components/media/MediaCard.tsx
#: src/components/media/MediaPlayer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {name}"
msgstr "Baixar {name}"
@ -2589,6 +2626,8 @@ msgid "Expand"
msgstr "Expandir"
#. placeholder {0}: group.title
#. placeholder {0}: item.name
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/sidebar-group-header.tsx
msgid "Expand {0}"
msgstr ""
@ -2660,6 +2699,10 @@ msgstr ""
msgid "Failed to open file"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to remove message"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to send message"
msgstr "Ocorreu um erro ao enviar a mensagem"
@ -2983,13 +3026,18 @@ msgid "Here's what I see in the logs…"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hi, this is how I'll sound when I read replies out loud."
msgstr "Olá, é assim que vou soar quando ler as respostas em voz alta."
msgid "Hi, this is how I'll sound when we talk."
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/sidebar-header.tsx
msgid "Hide bots"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Hide details"
msgstr ""
#: src/pages/Auth.tsx
msgid "Hide password"
msgstr ""
@ -3080,6 +3128,7 @@ msgstr ""
msgid "Import OpenAPI JSON"
msgstr "Importar OpenAPI JSON"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "In Bearbeitung"
msgstr ""
@ -3166,6 +3215,10 @@ msgstr "Isolado"
msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
msgstr "Suas conversas, arquivos e rotinas serão excluídos permanentemente. Os bots criados ficam na sua lista."
#: src/pages/shell/transcript.tsx
msgid "joined"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Jump to latest"
msgstr "Ir para a mensagem mais recente"
@ -3174,6 +3227,10 @@ msgstr "Ir para a mensagem mais recente"
msgid "Jump to replied message"
msgstr "Ir para a mensagem respondida"
#: src/pages/shell/project-overview.tsx
msgid "Just ask in the chat whenever something should happen regularly."
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "just now"
@ -3774,6 +3831,7 @@ msgid "New space"
msgstr ""
#: src/components/CookingMode.tsx
#: src/pages/shell/message-cards.tsx
msgid "Next"
msgstr ""
@ -3885,6 +3943,10 @@ msgstr ""
msgid "No providers found."
msgstr "Nenhum provedor encontrado"
#: src/pages/ActivityList.tsx
msgid "No recent activity"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "No remote MCP servers found"
msgstr ""
@ -3899,6 +3961,7 @@ msgid "No results"
msgstr "Nenhum resultado"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "No runs yet"
msgstr ""
@ -3957,6 +4020,7 @@ msgid "Notifications"
msgstr "Notificações"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Now"
msgstr "Agora"
@ -4027,7 +4091,6 @@ msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
msgstr "Abrir"
@ -4056,7 +4119,7 @@ msgstr ""
msgid "Open in browser"
msgstr ""
#: src/pages/shell/computer-panel-preview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in full window"
msgstr "Abrir em janela cheia"
@ -4288,10 +4351,6 @@ msgstr ""
msgid "Project"
msgstr ""
#: src/pages/Shell.tsx
msgid "Project · sessions & folders"
msgstr ""
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Project folder"
@ -4399,6 +4458,10 @@ msgstr ""
msgid "Ready"
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Ready"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Reasoning effort"
msgstr "Esforço de raciocínio"
@ -4412,6 +4475,7 @@ msgid "Recall only"
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Recent"
msgstr "Recente"
@ -4528,6 +4592,10 @@ msgstr "Remover {0}"
msgid "Remove folder"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Remove from queue"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""
@ -4653,6 +4721,11 @@ msgstr "Restaure o último espaço de trabalho salvo. O trabalho não salvo no c
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -4700,11 +4773,13 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "Rotina"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Routines"
msgstr "Rotinas"
@ -4718,6 +4793,7 @@ msgid "Run at"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Run history"
msgstr ""
@ -4756,10 +4832,6 @@ msgstr "Executando..."
msgid "Runs when this bot receives a verified message from this provider."
msgstr ""
#: src/pages/shell/side-panel-header.tsx
msgid "Sandbox"
msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
@ -4909,6 +4981,10 @@ msgstr "Enviar resposta"
msgid "Send it"
msgstr "Enviar"
#: src/pages/shell/transcript.tsx
msgid "Send now"
msgstr "Enviar agora"
#: src/pages/Auth.tsx
msgid "Send reset link"
msgstr "Enviar link de redefinição"
@ -5021,6 +5097,10 @@ msgstr ""
msgid "Show computer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Show details"
msgstr ""
#: src/pages/Shell.tsx
msgid "Show less"
msgstr ""
@ -5037,6 +5117,7 @@ msgstr "Mostrar mais"
msgid "Show password"
msgstr ""
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Show settings"
msgstr "Mostrar configurações"
@ -5172,6 +5253,7 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
@ -5286,6 +5368,10 @@ msgstr "Parar de ensinar"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."
@ -5398,6 +5484,7 @@ msgstr "Ensino em andamento. Pare de ensinar antes de enviar uma nova mensagem."
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-overview.tsx
msgid "Team"
msgstr "Equipe"
@ -5512,10 +5599,6 @@ msgstr "Este login de assinatura ainda não está disponível no Rakazo. Use uma
msgid "This will permanently delete this project and all its conversations, bots, and memory."
msgstr ""
#: src/pages/Shell.tsx
msgid "Threads by topic · folders"
msgstr ""
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Tiers"
@ -5701,6 +5784,7 @@ msgstr ""
#: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/AccountSettingsOverlay.tsx
msgid "Up to date"
msgstr ""
@ -5995,6 +6079,7 @@ msgid "Work channel open — go ahead."
msgstr ""
#: src/components/stage/OoStage.tsx
#: src/pages/shell/project-overview.tsx
msgid "Working"
msgstr ""
@ -6037,6 +6122,7 @@ msgstr "Ontem"
#: src/components/UserProfileSettings.tsx
#: src/components/voice/VoiceStage.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/transcript.tsx
msgid "You"
msgstr "Você"
@ -6077,13 +6163,25 @@ msgid "Your name"
msgstr "O seu nome"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Your Oo reads replies aloud when this is on."
msgid "Your Oo talks with you naturally when this is on."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Your Oo will communicate in this language. You can change it later."
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Your profile"
msgstr ""
#: src/pages/Welcome.tsx
msgid "Your specialists. One calm place to talk."
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom in"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom out"
msgstr ""

View File

@ -161,11 +161,6 @@ msgstr ""
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
msgstr "Экран {0}"
#: src/pages/HeuteBrief.tsx
msgid "{actionCount, plural, one {# thing needs you} other {# things need you}}"
msgstr ""
@ -182,10 +177,38 @@ msgstr ""
msgid "{botName} clears the path…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is building"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is checking a problem"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "{botName} is on it…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is replying"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is researching"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is thinking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is waiting for your answer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is working"
msgstr ""
#: src/lib/voice-persona-copy.ts
#: src/pages/CallView.tsx
msgid "{botName} is working…"
@ -364,6 +387,7 @@ msgstr "Осталось: {remaining} · бот наблюдает, но не д
msgid "{size} B"
msgstr "{size} Б"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "{stepCount, plural, one {# step} other {# steps}}"
msgstr ""
@ -386,10 +410,6 @@ msgstr "{title}, {label}"
msgid "{toolCount, plural, one {# tool} other {# tools}}"
msgstr "{toolCount, plural, one {# инструмент} few {# инструмента} many {# инструментов} other {# инструмента}}"
#: src/pages/shell/transcript.tsx
msgid "{workingBotName} is working"
msgstr "{workingBotName} работает"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "/Users/you/projects/acme"
@ -544,11 +564,16 @@ msgstr "Добавить еще"
msgid "Add as projects"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Add evOo to your Home Screen via Share to enable push on iOS."
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Добавить Executor"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Add goal"
msgstr ""
@ -737,6 +762,14 @@ msgstr "Всегда разрешено"
msgid "Analysiert die Aufgabe und führt Tools aus…"
msgstr ""
#: src/pages/Shell.tsx
msgid "Analyze the project and give me a brief overview."
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Analyzing project…"
msgstr ""
#: src/components/AskCard.tsx
msgid "Answer"
msgstr "Ответ"
@ -996,7 +1029,7 @@ msgstr ""
msgid "Booting live desktop…"
msgstr "Загрузка рабочего стола…"
#. placeholder {0}: active?.name
#. placeholder {0}: (active ?? coordinatorBot)?.name
#: src/pages/Shell.tsx
msgid "Booting up {0}’s computer"
msgstr "Загрузка компьютера {0}"
@ -1019,10 +1052,6 @@ msgstr "Бот"
msgid "Bot screen"
msgstr "Экран бота"
#: src/pages/shell/computer-panel-preview.tsx
msgid "Bot screen preview"
msgstr "Предварительный просмотр экрана бота"
#: src/pages/MessagingSettingsOverlay.tsx
msgid "Bot to link"
msgstr "Бот для ссылки"
@ -1031,10 +1060,6 @@ msgstr "Бот для ссылки"
msgid "Bots act without asking by default. Add an exception only when you want to review a type of action first."
msgstr "По умолчанию боты действуют, не спрашивая. Добавляйте исключение только в том случае, если вы хотите сначала просмотреть тип действия."
#: src/pages/shell/transcript.tsx
msgid "Bots are working"
msgstr "Боты работают"
#: src/components/OoAtelier.tsx
msgid "Bow"
msgstr ""
@ -1064,7 +1089,6 @@ msgstr ""
msgid "Browse MCP servers"
msgstr "Просмотреть MCP-серверы"
#: src/pages/Shell.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Browser"
msgstr ""
@ -1493,6 +1517,10 @@ msgstr "Компьютеры"
msgid "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
msgstr "Компьютеры выключены. Установите SANDBOX_PROVIDER=docker с помощью SANDBOX_SUPERVISOR_TOKEN или установите для SANDBOX_PROVIDER значение e2b, Daytona или Box с его ключом API. Воссоздайте стек после изменения .env."
#: src/pages/PluginsOverlay.tsx
msgid "Configure a plugin catalog on the server to connect apps."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Configured by deployment"
msgstr "Настраивается путем развертывания"
@ -1584,6 +1612,10 @@ msgstr "Подключено и используется {0}."
msgid "Connected app"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Connected tools"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Connected. Its tools are available from your next message."
msgstr "Подключено. Его инструменты доступны из вашего следующего сообщения."
@ -2322,6 +2354,7 @@ msgstr "Отключение…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "Закрыть"
@ -2381,7 +2414,9 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Done"
msgstr "Готово"
@ -2394,8 +2429,11 @@ msgstr ""
msgid "Dot"
msgstr ""
#. placeholder {0}: activeItem.name
#. placeholder {0}: item.name
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {0}"
msgstr "Скачать {0}"
@ -2403,7 +2441,6 @@ msgstr "Скачать {0}"
#: src/components/ArtifactPane.tsx
#: src/components/media/MediaCard.tsx
#: src/components/media/MediaPlayer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {name}"
msgstr "Скачать {name}"
@ -2589,6 +2626,8 @@ msgid "Expand"
msgstr "Развернуть"
#. placeholder {0}: group.title
#. placeholder {0}: item.name
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/sidebar-group-header.tsx
msgid "Expand {0}"
msgstr "Развернуть {0}"
@ -2660,6 +2699,10 @@ msgstr ""
msgid "Failed to open file"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to remove message"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to send message"
msgstr "Не удалось отправить сообщение"
@ -2983,13 +3026,18 @@ msgid "Here's what I see in the logs…"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hi, this is how I'll sound when I read replies out loud."
msgstr "Привет, вот как я буду звучать, когда буду читать ответы вслух."
msgid "Hi, this is how I'll sound when we talk."
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/sidebar-header.tsx
msgid "Hide bots"
msgstr "Скрыть ботов"
#: src/pages/shell/transcript.tsx
msgid "Hide details"
msgstr ""
#: src/pages/Auth.tsx
msgid "Hide password"
msgstr "Скрыть пароль"
@ -3080,6 +3128,7 @@ msgstr ""
msgid "Import OpenAPI JSON"
msgstr "Импортировать OpenAPI JSON"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "In Bearbeitung"
msgstr ""
@ -3166,6 +3215,10 @@ msgstr "изолированный"
msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
msgstr "Его диалог, файлы и задачи будут безвозвратно удалены. Созданные им боты останутся в вашем списке."
#: src/pages/shell/transcript.tsx
msgid "joined"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Jump to latest"
msgstr "Перейти к последнему"
@ -3174,6 +3227,10 @@ msgstr "Перейти к последнему"
msgid "Jump to replied message"
msgstr "Перейти к ответному сообщению"
#: src/pages/shell/project-overview.tsx
msgid "Just ask in the chat whenever something should happen regularly."
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "just now"
@ -3774,6 +3831,7 @@ msgid "New space"
msgstr "Новое пространство"
#: src/components/CookingMode.tsx
#: src/pages/shell/message-cards.tsx
msgid "Next"
msgstr ""
@ -3885,6 +3943,10 @@ msgstr ""
msgid "No providers found."
msgstr "Поставщики не найдены."
#: src/pages/ActivityList.tsx
msgid "No recent activity"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "No remote MCP servers found"
msgstr "Удаленные MCP-серверы не найдены"
@ -3899,6 +3961,7 @@ msgid "No results"
msgstr "Нет результатов"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "No runs yet"
msgstr "Запусков пока нет"
@ -3957,6 +4020,7 @@ msgid "Notifications"
msgstr "Уведомления"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Now"
msgstr "Сейчас"
@ -4027,7 +4091,6 @@ msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
msgstr "Открыть"
@ -4056,7 +4119,7 @@ msgstr ""
msgid "Open in browser"
msgstr ""
#: src/pages/shell/computer-panel-preview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in full window"
msgstr "Открыть в полном окне"
@ -4288,10 +4351,6 @@ msgstr "Приватное пространство со своими ботам
msgid "Project"
msgstr ""
#: src/pages/Shell.tsx
msgid "Project · sessions & folders"
msgstr ""
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Project folder"
@ -4399,6 +4458,10 @@ msgstr ""
msgid "Ready"
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Ready"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Reasoning effort"
msgstr ""
@ -4412,6 +4475,7 @@ msgid "Recall only"
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Recent"
msgstr "Недавние"
@ -4528,6 +4592,10 @@ msgstr "Удалить {0}"
msgid "Remove folder"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Remove from queue"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr "Удалить событие Git"
@ -4653,6 +4721,11 @@ msgstr "Восстановите последнее сохраненное ра
msgid "Restoring your workspace"
msgstr "Восстановление вашего рабочего пространства"
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -4700,11 +4773,13 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "Задача"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Routines"
msgstr "Задачи"
@ -4718,6 +4793,7 @@ msgid "Run at"
msgstr "Запустить в"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Run history"
msgstr "История запуска"
@ -4756,10 +4832,6 @@ msgstr "Выполняется…"
msgid "Runs when this bot receives a verified message from this provider."
msgstr "Запускается, когда этот бот получает проверенное сообщение от этого провайдера."
#: src/pages/shell/side-panel-header.tsx
msgid "Sandbox"
msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
@ -4909,6 +4981,10 @@ msgstr "Отправить ответ"
msgid "Send it"
msgstr "Отправить"
#: src/pages/shell/transcript.tsx
msgid "Send now"
msgstr "Отправить сейчас"
#: src/pages/Auth.tsx
msgid "Send reset link"
msgstr "Отправить ссылку для сброса"
@ -5021,6 +5097,10 @@ msgstr "Показать ботов"
msgid "Show computer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Show details"
msgstr ""
#: src/pages/Shell.tsx
msgid "Show less"
msgstr ""
@ -5037,6 +5117,7 @@ msgstr "Показать ещё"
msgid "Show password"
msgstr "Показать пароль"
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Show settings"
msgstr "Показать настройки"
@ -5172,6 +5253,7 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
@ -5286,6 +5368,10 @@ msgstr "Остановить обучение"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."
@ -5398,6 +5484,7 @@ msgstr "Обучение в процессе. Прекратите обучен
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-overview.tsx
msgid "Team"
msgstr "Команда"
@ -5512,10 +5599,6 @@ msgstr "Вход по этой подписке пока недоступен в
msgid "This will permanently delete this project and all its conversations, bots, and memory."
msgstr ""
#: src/pages/Shell.tsx
msgid "Threads by topic · folders"
msgstr ""
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Tiers"
@ -5701,6 +5784,7 @@ msgstr ""
#: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/AccountSettingsOverlay.tsx
msgid "Up to date"
msgstr "Актуально"
@ -5995,6 +6079,7 @@ msgid "Work channel open — go ahead."
msgstr ""
#: src/components/stage/OoStage.tsx
#: src/pages/shell/project-overview.tsx
msgid "Working"
msgstr ""
@ -6037,6 +6122,7 @@ msgstr "Вчера"
#: src/components/UserProfileSettings.tsx
#: src/components/voice/VoiceStage.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/transcript.tsx
msgid "You"
msgstr "Вы"
@ -6077,13 +6163,25 @@ msgid "Your name"
msgstr "Ваше имя"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Your Oo reads replies aloud when this is on."
msgid "Your Oo talks with you naturally when this is on."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Your Oo will communicate in this language. You can change it later."
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Your profile"
msgstr ""
#: src/pages/Welcome.tsx
msgid "Your specialists. One calm place to talk."
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom in"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom out"
msgstr ""

View File

@ -161,11 +161,6 @@ msgstr ""
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "{actionCount, plural, one {# thing needs you} other {# things need you}}"
msgstr ""
@ -182,10 +177,38 @@ msgstr ""
msgid "{botName} clears the path…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is building"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is checking a problem"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "{botName} is on it…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is replying"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is researching"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is thinking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is waiting for your answer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is working"
msgstr ""
#: src/lib/voice-persona-copy.ts
#: src/pages/CallView.tsx
msgid "{botName} is working…"
@ -364,6 +387,7 @@ msgstr "{remaining} kaldı · bot yalnızca izliyor"
msgid "{size} B"
msgstr "{size} B"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "{stepCount, plural, one {# step} other {# steps}}"
msgstr ""
@ -386,10 +410,6 @@ msgstr "{title}, {label}"
msgid "{toolCount, plural, one {# tool} other {# tools}}"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{workingBotName} is working"
msgstr "{workingBotName} çalışıyor"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "/Users/you/projects/acme"
@ -544,11 +564,16 @@ msgstr ""
msgid "Add as projects"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Add evOo to your Home Screen via Share to enable push on iOS."
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Executor ekle"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Add goal"
msgstr ""
@ -737,6 +762,14 @@ msgstr "Her zaman izinli"
msgid "Analysiert die Aufgabe und führt Tools aus…"
msgstr ""
#: src/pages/Shell.tsx
msgid "Analyze the project and give me a brief overview."
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Analyzing project…"
msgstr ""
#: src/components/AskCard.tsx
msgid "Answer"
msgstr "Yanıtla"
@ -996,7 +1029,7 @@ msgstr ""
msgid "Booting live desktop…"
msgstr "Canlı masaüstü başlatılıyor…"
#. placeholder {0}: active?.name
#. placeholder {0}: (active ?? coordinatorBot)?.name
#: src/pages/Shell.tsx
msgid "Booting up {0}’s computer"
msgstr "{0} adlı botun bilgisayarı başlatılıyor"
@ -1019,10 +1052,6 @@ msgstr "Bot"
msgid "Bot screen"
msgstr "Bot ekranı"
#: src/pages/shell/computer-panel-preview.tsx
msgid "Bot screen preview"
msgstr "Bot ekranı önizlemesi"
#: src/pages/MessagingSettingsOverlay.tsx
msgid "Bot to link"
msgstr ""
@ -1031,10 +1060,6 @@ msgstr ""
msgid "Bots act without asking by default. Add an exception only when you want to review a type of action first."
msgstr "Botlar varsayılan olarak sormadan hareket eder. Yalnızca belirli bir işlem türünü önce incelemek istediğinde istisna ekle."
#: src/pages/shell/transcript.tsx
msgid "Bots are working"
msgstr "Botlar çalışıyor"
#: src/components/OoAtelier.tsx
msgid "Bow"
msgstr ""
@ -1064,7 +1089,6 @@ msgstr ""
msgid "Browse MCP servers"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Browser"
msgstr ""
@ -1493,6 +1517,10 @@ msgstr "Bilgisayarlar"
msgid "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
msgstr "Bilgisayarlar kapalı. SANDBOX_PROVIDER=docker ve SANDBOX_SUPERVISOR_TOKEN ayarlayın; ya da SANDBOX_PROVIDER değerini e2b, daytona veya box yapıp API anahtarını ekleyin. .env değişince stacki yeniden oluşturun."
#: src/pages/PluginsOverlay.tsx
msgid "Configure a plugin catalog on the server to connect apps."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Configured by deployment"
msgstr "Dağıtım tarafından yapılandırıldı"
@ -1584,6 +1612,10 @@ msgstr "Bağlandı, {0} kullanılıyor."
msgid "Connected app"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Connected tools"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Connected. Its tools are available from your next message."
msgstr "Bağlandı. Araçları bir sonraki mesajından itibaren kullanılabilir."
@ -2322,6 +2354,7 @@ msgstr "Bağlantı kesiliyor…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr ""
@ -2381,7 +2414,9 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Done"
msgstr "Bitti"
@ -2394,8 +2429,11 @@ msgstr ""
msgid "Dot"
msgstr ""
#. placeholder {0}: activeItem.name
#. placeholder {0}: item.name
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {0}"
msgstr "{0} indir"
@ -2403,7 +2441,6 @@ msgstr "{0} indir"
#: src/components/ArtifactPane.tsx
#: src/components/media/MediaCard.tsx
#: src/components/media/MediaPlayer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {name}"
msgstr "{name} indir"
@ -2589,6 +2626,8 @@ msgid "Expand"
msgstr "Genişlet"
#. placeholder {0}: group.title
#. placeholder {0}: item.name
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/sidebar-group-header.tsx
msgid "Expand {0}"
msgstr ""
@ -2660,6 +2699,10 @@ msgstr ""
msgid "Failed to open file"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to remove message"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to send message"
msgstr "Mesaj gönderilemedi"
@ -2983,13 +3026,18 @@ msgid "Here's what I see in the logs…"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hi, this is how I'll sound when I read replies out loud."
msgstr "Merhaba, yanıtları sesli okuduğumda böyle duyulacağım."
msgid "Hi, this is how I'll sound when we talk."
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/sidebar-header.tsx
msgid "Hide bots"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Hide details"
msgstr ""
#: src/pages/Auth.tsx
msgid "Hide password"
msgstr ""
@ -3080,6 +3128,7 @@ msgstr ""
msgid "Import OpenAPI JSON"
msgstr "OpenAPI JSON içe aktar"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "In Bearbeitung"
msgstr ""
@ -3166,6 +3215,10 @@ msgstr "Yalıtılmış"
msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
msgstr "Sohbeti, dosyaları ve rutinleri kalıcı olarak silinecek. Oluşturduğu botlar listende kalır."
#: src/pages/shell/transcript.tsx
msgid "joined"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Jump to latest"
msgstr "En son mesaja git"
@ -3174,6 +3227,10 @@ msgstr "En son mesaja git"
msgid "Jump to replied message"
msgstr "Yanıtlanan mesaja git"
#: src/pages/shell/project-overview.tsx
msgid "Just ask in the chat whenever something should happen regularly."
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "just now"
@ -3774,6 +3831,7 @@ msgid "New space"
msgstr ""
#: src/components/CookingMode.tsx
#: src/pages/shell/message-cards.tsx
msgid "Next"
msgstr ""
@ -3885,6 +3943,10 @@ msgstr ""
msgid "No providers found."
msgstr "Sağlayıcı bulunamadı."
#: src/pages/ActivityList.tsx
msgid "No recent activity"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "No remote MCP servers found"
msgstr ""
@ -3899,6 +3961,7 @@ msgid "No results"
msgstr "Sonuç yok"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "No runs yet"
msgstr ""
@ -3957,6 +4020,7 @@ msgid "Notifications"
msgstr "Bildirimler"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Now"
msgstr "Şimdi"
@ -4027,7 +4091,6 @@ msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
msgstr "Aç"
@ -4056,7 +4119,7 @@ msgstr ""
msgid "Open in browser"
msgstr ""
#: src/pages/shell/computer-panel-preview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in full window"
msgstr "Tam pencerede aç"
@ -4288,10 +4351,6 @@ msgstr ""
msgid "Project"
msgstr ""
#: src/pages/Shell.tsx
msgid "Project · sessions & folders"
msgstr ""
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Project folder"
@ -4399,6 +4458,10 @@ msgstr ""
msgid "Ready"
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Ready"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Reasoning effort"
msgstr "Akıl yürütme düzeyi"
@ -4412,6 +4475,7 @@ msgid "Recall only"
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Recent"
msgstr "Son"
@ -4528,6 +4592,10 @@ msgstr "{0} kaldır"
msgid "Remove folder"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Remove from queue"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""
@ -4653,6 +4721,11 @@ msgstr ""
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -4700,11 +4773,13 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "Rutin"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Routines"
msgstr "Rutinler"
@ -4718,6 +4793,7 @@ msgid "Run at"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Run history"
msgstr ""
@ -4756,10 +4832,6 @@ msgstr "Çalışıyor…"
msgid "Runs when this bot receives a verified message from this provider."
msgstr ""
#: src/pages/shell/side-panel-header.tsx
msgid "Sandbox"
msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
@ -4909,6 +4981,10 @@ msgstr "Yanıtı gönder"
msgid "Send it"
msgstr "Gönder"
#: src/pages/shell/transcript.tsx
msgid "Send now"
msgstr "Şimdi gönder"
#: src/pages/Auth.tsx
msgid "Send reset link"
msgstr "Sıfırlama bağlantısı gönder"
@ -5021,6 +5097,10 @@ msgstr ""
msgid "Show computer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Show details"
msgstr ""
#: src/pages/Shell.tsx
msgid "Show less"
msgstr ""
@ -5037,6 +5117,7 @@ msgstr "Daha fazla göster"
msgid "Show password"
msgstr ""
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Show settings"
msgstr "Ayarları göster"
@ -5172,6 +5253,7 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
@ -5286,6 +5368,10 @@ msgstr "Öğretmeyi durdur"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."
@ -5398,6 +5484,7 @@ msgstr "Öğretme sürüyor. Yeni mesaj göndermeden önce öğretmeyi durdur."
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-overview.tsx
msgid "Team"
msgstr "Takım"
@ -5512,10 +5599,6 @@ msgstr "Bu abonelik girişi Rakazo'da henüz kullanılamıyor. Bir dağıtım ki
msgid "This will permanently delete this project and all its conversations, bots, and memory."
msgstr ""
#: src/pages/Shell.tsx
msgid "Threads by topic · folders"
msgstr ""
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Tiers"
@ -5701,6 +5784,7 @@ msgstr ""
#: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/AccountSettingsOverlay.tsx
msgid "Up to date"
msgstr ""
@ -5995,6 +6079,7 @@ msgid "Work channel open — go ahead."
msgstr ""
#: src/components/stage/OoStage.tsx
#: src/pages/shell/project-overview.tsx
msgid "Working"
msgstr ""
@ -6037,6 +6122,7 @@ msgstr "Dün"
#: src/components/UserProfileSettings.tsx
#: src/components/voice/VoiceStage.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/transcript.tsx
msgid "You"
msgstr "Sen"
@ -6077,13 +6163,25 @@ msgid "Your name"
msgstr "Adın"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Your Oo reads replies aloud when this is on."
msgid "Your Oo talks with you naturally when this is on."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Your Oo will communicate in this language. You can change it later."
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Your profile"
msgstr ""
#: src/pages/Welcome.tsx
msgid "Your specialists. One calm place to talk."
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom in"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom out"
msgstr ""

View File

@ -161,11 +161,6 @@ msgstr ""
msgid "{0}'s browser appears here when the computer is running."
msgstr ""
#. placeholder {0}: active.name
#: src/pages/shell/computer-panel-preview.tsx
msgid "{0}'s screen"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "{actionCount, plural, one {# thing needs you} other {# things need you}}"
msgstr ""
@ -182,10 +177,38 @@ msgstr ""
msgid "{botName} clears the path…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is building"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is checking a problem"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "{botName} is on it…"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is replying"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is researching"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is thinking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is waiting for your answer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{botName} is working"
msgstr ""
#: src/lib/voice-persona-copy.ts
#: src/pages/CallView.tsx
msgid "{botName} is working…"
@ -364,6 +387,7 @@ msgstr "剩余 {remaining} · Bot 只看不操作"
msgid "{size} B"
msgstr "{size} B"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "{stepCount, plural, one {# step} other {# steps}}"
msgstr ""
@ -386,10 +410,6 @@ msgstr "{title},{label}"
msgid "{toolCount, plural, one {# tool} other {# tools}}"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "{workingBotName} is working"
msgstr "{workingBotName} 正在工作"
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "/Users/you/projects/acme"
@ -544,11 +564,16 @@ msgstr ""
msgid "Add as projects"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Add evOo to your Home Screen via Share to enable push on iOS."
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "添加 Executor"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Add goal"
msgstr ""
@ -737,6 +762,14 @@ msgstr "始终允许"
msgid "Analysiert die Aufgabe und führt Tools aus…"
msgstr ""
#: src/pages/Shell.tsx
msgid "Analyze the project and give me a brief overview."
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Analyzing project…"
msgstr ""
#: src/components/AskCard.tsx
msgid "Answer"
msgstr "回答"
@ -996,7 +1029,7 @@ msgstr ""
msgid "Booting live desktop…"
msgstr "正在启动实时桌面…"
#. placeholder {0}: active?.name
#. placeholder {0}: (active ?? coordinatorBot)?.name
#: src/pages/Shell.tsx
msgid "Booting up {0}’s computer"
msgstr "正在启动 {0} 的电脑"
@ -1019,10 +1052,6 @@ msgstr "Bot"
msgid "Bot screen"
msgstr "Bot 屏幕"
#: src/pages/shell/computer-panel-preview.tsx
msgid "Bot screen preview"
msgstr "Bot 屏幕预览"
#: src/pages/MessagingSettingsOverlay.tsx
msgid "Bot to link"
msgstr "要关联的 Bot"
@ -1031,10 +1060,6 @@ msgstr "要关联的 Bot"
msgid "Bots act without asking by default. Add an exception only when you want to review a type of action first."
msgstr "默认情况下 Bot 会直接执行。只有当你想先审核某类操作时,才添加例外。"
#: src/pages/shell/transcript.tsx
msgid "Bots are working"
msgstr "Bot 正在工作"
#: src/components/OoAtelier.tsx
msgid "Bow"
msgstr ""
@ -1064,7 +1089,6 @@ msgstr ""
msgid "Browse MCP servers"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Browser"
msgstr ""
@ -1493,6 +1517,10 @@ msgstr "电脑"
msgid "Computers are off. Set SANDBOX_PROVIDER=docker with SANDBOX_SUPERVISOR_TOKEN, or set SANDBOX_PROVIDER to e2b, daytona, or box with its API key. Recreate the stack after changing .env."
msgstr "电脑未开启。请设置 SANDBOX_PROVIDER=docker 并提供 SANDBOX_SUPERVISOR_TOKEN,或将 SANDBOX_PROVIDER 设为 e2b、daytona 或 box 并提供对应 API 密钥。更改 .env 后请重建环境。"
#: src/pages/PluginsOverlay.tsx
msgid "Configure a plugin catalog on the server to connect apps."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Configured by deployment"
msgstr "由部署配置"
@ -1584,6 +1612,10 @@ msgstr "已连接并使用 {0}。"
msgid "Connected app"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Connected tools"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Connected. Its tools are available from your next message."
msgstr "已连接,下一条消息起即可使用其工具。"
@ -2322,6 +2354,7 @@ msgstr "正在断开…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr ""
@ -2381,7 +2414,9 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Done"
msgstr "完成"
@ -2394,8 +2429,11 @@ msgstr ""
msgid "Dot"
msgstr ""
#. placeholder {0}: activeItem.name
#. placeholder {0}: item.name
#. placeholder {0}: props.name
#: src/components/ArtifactFileCard.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {0}"
msgstr "下载 {0}"
@ -2403,7 +2441,6 @@ msgstr "下载 {0}"
#: src/components/ArtifactPane.tsx
#: src/components/media/MediaCard.tsx
#: src/components/media/MediaPlayer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Download {name}"
msgstr "下载 {name}"
@ -2589,6 +2626,8 @@ msgid "Expand"
msgstr "展开"
#. placeholder {0}: group.title
#. placeholder {0}: item.name
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/sidebar-group-header.tsx
msgid "Expand {0}"
msgstr "展开 {0}"
@ -2660,6 +2699,10 @@ msgstr ""
msgid "Failed to open file"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to remove message"
msgstr ""
#: src/pages/Shell.tsx
msgid "Failed to send message"
msgstr "发送消息失败"
@ -2983,13 +3026,18 @@ msgid "Here's what I see in the logs…"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hi, this is how I'll sound when I read replies out loud."
msgstr "你好,这是我朗读回复时的声音。"
msgid "Hi, this is how I'll sound when we talk."
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/sidebar-header.tsx
msgid "Hide bots"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Hide details"
msgstr ""
#: src/pages/Auth.tsx
msgid "Hide password"
msgstr "隐藏密码"
@ -3080,6 +3128,7 @@ msgstr ""
msgid "Import OpenAPI JSON"
msgstr "导入 OpenAPI JSON"
#: src/pages/shell/message-cards.tsx
#: src/pages/shell/run-steps.tsx
msgid "In Bearbeitung"
msgstr ""
@ -3166,6 +3215,10 @@ msgstr "隔离"
msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list."
msgstr "它的对话、文件和例行任务将被永久删除。它创建的 Bot 仍留在列表中。"
#: src/pages/shell/transcript.tsx
msgid "joined"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Jump to latest"
msgstr "跳到最新"
@ -3174,6 +3227,10 @@ msgstr "跳到最新"
msgid "Jump to replied message"
msgstr "跳到被回复的消息"
#: src/pages/shell/project-overview.tsx
msgid "Just ask in the chat whenever something should happen regularly."
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "just now"
@ -3774,6 +3831,7 @@ msgid "New space"
msgstr "新建工作区"
#: src/components/CookingMode.tsx
#: src/pages/shell/message-cards.tsx
msgid "Next"
msgstr ""
@ -3885,6 +3943,10 @@ msgstr ""
msgid "No providers found."
msgstr "未找到提供方。"
#: src/pages/ActivityList.tsx
msgid "No recent activity"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "No remote MCP servers found"
msgstr ""
@ -3899,6 +3961,7 @@ msgid "No results"
msgstr "无结果"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "No runs yet"
msgstr "还没有运行记录"
@ -3957,6 +4020,7 @@ msgid "Notifications"
msgstr "通知"
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Now"
msgstr "现在"
@ -4027,7 +4091,6 @@ msgid "Oo name"
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/shell/computer-panel-preview.tsx
msgid "Open"
msgstr "打开"
@ -4056,7 +4119,7 @@ msgstr ""
msgid "Open in browser"
msgstr ""
#: src/pages/shell/computer-panel-preview.tsx
#: src/components/browser/OoComputerBrowser.tsx
msgid "Open in full window"
msgstr "在完整窗口中打开"
@ -4288,10 +4351,6 @@ msgstr ""
msgid "Project"
msgstr ""
#: src/pages/Shell.tsx
msgid "Project · sessions & folders"
msgstr ""
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Project folder"
@ -4399,6 +4458,10 @@ msgstr ""
msgid "Ready"
msgstr ""
#: src/pages/shell/project-overview.tsx
msgid "Ready"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Reasoning effort"
msgstr "推理强度"
@ -4412,6 +4475,7 @@ msgid "Recall only"
msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/shell/project-overview.tsx
msgid "Recent"
msgstr "最近"
@ -4528,6 +4592,10 @@ msgstr "移除 {0}"
msgid "Remove folder"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Remove from queue"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""
@ -4653,6 +4721,11 @@ msgstr "还原上次保存的工作区。电脑上未保存的工作会丢失。
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -4700,11 +4773,13 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "例行任务"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Routines"
msgstr "例行任务"
@ -4718,6 +4793,7 @@ msgid "Run at"
msgstr "运行时间"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/project-overview.tsx
msgid "Run history"
msgstr "运行记录"
@ -4756,10 +4832,6 @@ msgstr "正在运行…"
msgid "Runs when this bot receives a verified message from this provider."
msgstr ""
#: src/pages/shell/side-panel-header.tsx
msgid "Sandbox"
msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
@ -4909,6 +4981,10 @@ msgstr "发送回答"
msgid "Send it"
msgstr "发送"
#: src/pages/shell/transcript.tsx
msgid "Send now"
msgstr "立即发送"
#: src/pages/Auth.tsx
msgid "Send reset link"
msgstr "发送重置链接"
@ -5021,6 +5097,10 @@ msgstr ""
msgid "Show computer"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Show details"
msgstr ""
#: src/pages/Shell.tsx
msgid "Show less"
msgstr ""
@ -5037,6 +5117,7 @@ msgstr "显示更多"
msgid "Show password"
msgstr "显示密码"
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/side-panel-header.tsx
msgid "Show settings"
msgstr "显示设置"
@ -5172,6 +5253,7 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
@ -5286,6 +5368,10 @@ msgstr "停止示范"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."
@ -5398,6 +5484,7 @@ msgstr "正在示范,发送新消息前请先停止示范。"
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-overview.tsx
msgid "Team"
msgstr "团队"
@ -5512,10 +5599,6 @@ msgstr "此订阅登录在 Rakazo 中尚不可用。请使用部署凭据或选
msgid "This will permanently delete this project and all its conversations, bots, and memory."
msgstr ""
#: src/pages/Shell.tsx
msgid "Threads by topic · folders"
msgstr ""
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Tiers"
@ -5701,6 +5784,7 @@ msgstr ""
#: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/AccountSettingsOverlay.tsx
msgid "Up to date"
msgstr "已是最新"
@ -5995,6 +6079,7 @@ msgid "Work channel open — go ahead."
msgstr ""
#: src/components/stage/OoStage.tsx
#: src/pages/shell/project-overview.tsx
msgid "Working"
msgstr ""
@ -6037,6 +6122,7 @@ msgstr "昨天"
#: src/components/UserProfileSettings.tsx
#: src/components/voice/VoiceStage.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/transcript.tsx
msgid "You"
msgstr "你"
@ -6077,13 +6163,25 @@ msgid "Your name"
msgstr "你的名字"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Your Oo reads replies aloud when this is on."
msgid "Your Oo talks with you naturally when this is on."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Your Oo will communicate in this language. You can change it later."
msgstr ""
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
msgid "Your profile"
msgstr ""
#: src/pages/Welcome.tsx
msgid "Your specialists. One calm place to talk."
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom in"
msgstr ""
#: src/pages/shell/message-cards.tsx
msgid "Zoom out"
msgstr ""

View File

@ -188,6 +188,11 @@ export function UpdatesSettingsPanel({
<div className="space-y-5">
<DesktopUpdateSection />
<SoftwareUpdateSection isDeploymentOwner={isDeploymentOwner} />
<section className="hidden rounded-xl border border-border px-4 py-4 only:block">
<p className="text-ui-body-sm text-muted-foreground">
<Trans>Up to date</Trans>
</p>
</section>
</div>
);
}

View File

@ -30,7 +30,13 @@ export function ActivityList({ onOpenRun }: ActivityListProps) {
);
}
if (activeRuns.length === 0 && recentRuns.length === 0) return null;
if (activeRuns.length === 0 && recentRuns.length === 0) {
return (
<div className="mb-2 border-b border-border px-2.5 py-2 text-ui-body-sm text-muted-foreground/80">
<Trans>No recent activity</Trans>
</div>
);
}
return (
<div className="mb-2 border-b border-border pb-2">

View File

@ -1,6 +1,11 @@
import { useLingui } from "@lingui/react/macro";
import type { ThreadMessage, ThreadSnapshot } from "@rakazo/contracts";
import { isSecretAskBlock, speechFromBlocks, spokenDecision } from "@rakazo/core";
import {
isSecretAskBlock,
speechFromBlocks,
spokenDecision,
spokenSummaryFromBlocks,
} from "@rakazo/core";
import { useEffect, useMemo, useRef, useState } from "react";
import type { VoicePhase, VoiceTranscriptLine } from "../components/voice/VoiceStage";
import { VoiceStage } from "../components/voice/VoiceStage";
@ -417,7 +422,7 @@ export function CallView({
(block) => block.kind === "ask" && block.status !== "answered",
);
const secretAsk = ask && isSecretAskBlock(ask);
const rawText = speechFromBlocks(lastBot.blocks);
const rawText = spokenSummaryFromBlocks(lastBot.blocks);
const text = rawText
? secretAsk
? `${rawText}. ${secretPromptRef.current}`
@ -477,7 +482,7 @@ export function CallView({
hasUnspokenBotMessage: Boolean(
lastBot &&
lastBot.id !== spokenMessage.current &&
speechFromBlocks(lastBot.blocks).trim(),
spokenSummaryFromBlocks(lastBot.blocks).trim(),
),
runActive: isActiveRunStatus(snapshot?.run?.status),
userTurnPending: userTurnPendingRef.current,

View File

@ -7,6 +7,9 @@ import type {
Group,
HabitToday,
RunActivityRow,
Space,
SpaceBot,
SpaceGroup,
} from "@rakazo/contracts";
import { runChatTitle } from "@rakazo/core";
import { botColors } from "@rakazo/ui-tokens";
@ -30,6 +33,7 @@ import { rpc } from "../lib/rpc";
type HeuteBriefProps = {
bots: Bot[];
groups: Group[];
spaces?: Space[];
onOpenBot: (botId: string, spaceId: string) => void;
onOpenGroup: (groupId: string, spaceId: string) => void;
onOpenRun: (run: RunActivityRow) => void;
@ -41,6 +45,7 @@ const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric"
export function HeuteBrief({
bots,
groups,
spaces = [],
onOpenBot,
onOpenGroup,
onOpenRun,
@ -135,13 +140,21 @@ export function HeuteBrief({
buildHeuteBrief({
bots,
groups,
spaces,
activeRuns,
recentRuns,
}),
[activeRuns, bots, groups, recentRuns],
[activeRuns, bots, groups, recentRuns, spaces],
);
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
const botColorById = useMemo(
() =>
new Map([
...spaces.flatMap((space) => space.bots).map((bot) => [bot.id, bot.color] as const),
...bots.map((bot) => [bot.id, bot.color] as const),
]),
[bots, spaces],
);
const locale = i18n.locale || "de";
const dateLabel = coach
@ -391,13 +404,13 @@ function NeedsYouRow({
>
{chatKind === "bot" ? (
<BotAvatar
color={(chat as Bot).color}
color={(chat as SpaceBot).color}
identity={chat.id}
size={40}
status={(chat as Bot).status}
status={(chat as SpaceBot).status}
/>
) : (
<GroupAvatar members={(chat as Group).members} size={40} />
<GroupAvatar members={(chat as SpaceGroup).members} size={40} />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-ui-body font-semibold text-foreground">{title}</div>

View File

@ -10,7 +10,6 @@ import {
abortableDelay,
buildFeaturedConnectorTiles,
CONNECTION_CATALOG_PAGE_SIZE,
EMPTY_PLUGIN_CATALOG_MESSAGE,
filterConnectionCatalogItems,
humanizeToolName,
isGithubInstall,
@ -730,7 +729,7 @@ export function PluginsOverlay({
<div className="mb-6" data-testid="featured-connectors">
{!loading && catalog.length === 0 ? (
<p className="text-ui-body leading-6 text-muted-foreground/80">
{EMPTY_PLUGIN_CATALOG_MESSAGE}
<Trans>Configure a plugin catalog on the server to connect apps.</Trans>
</p>
) : (
<div className="grid grid-cols-2 gap-2">

View File

@ -176,7 +176,7 @@ export function SettingsOverlay({
<nav
data-testid="settings-nav"
aria-label={t`Settings`}
className="flex shrink-0 flex-row gap-1 overflow-x-auto border-b border-border px-3 py-3 md:w-[200px] md:flex-col md:overflow-y-auto md:border-b-0 md:border-e md:px-3 md:py-4"
className="flex shrink-0 flex-row gap-1 overflow-x-auto border-b border-border px-3 py-3 md:w-[220px] md:flex-col md:overflow-y-auto md:border-b-0 md:border-e md:px-3 md:py-4"
>
{navItems.map((item) => {
const Icon = item.icon;

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts";
import { voiceIdForLocale, voicesForLocale } from "@rakazo/core";
import { GERMAN_VOICE_PRESETS, voiceIdForLocale, voicesForLocale } from "@rakazo/core";
import {
Button,
cn,
@ -46,7 +46,7 @@ export function VoiceSettingsOverlay({
const [voiceId, setVoiceId] = useState("");
const [autoSpeak, setAutoSpeak] = useState(false);
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<"connect" | "voice" | "speak" | null>(null);
const [pending, setPending] = useState<"voice" | "speak" | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [soundEnabled, setSoundEnabled] = useState(() => readOoSoundEnabled());
@ -74,7 +74,7 @@ export function VoiceSettingsOverlay({
};
}, []);
function markPending(next: "connect" | "voice" | "speak" | null) {
function markPending(next: "voice" | "speak" | null) {
setPending(next);
onBusyChange?.(next !== null);
}
@ -113,17 +113,22 @@ export function VoiceSettingsOverlay({
}, [activeBotId]);
const voiceOptions = useMemo(() => {
const listed = voicesForLocale(voices, uiLocale);
const isGermanLocale = uiLocale.toLowerCase().startsWith("de");
const hasGermanVoices = voices.some((v) => /^d[fm]_/i.test(v.id));
const combined =
isGermanLocale && !hasGermanVoices ? [...GERMAN_VOICE_PRESETS, ...voices] : voices;
const listed = voicesForLocale(combined, uiLocale);
if (listed.length) return listed;
if (isGermanLocale) return GERMAN_VOICE_PRESETS;
return voiceId ? [{ id: voiceId, label: voiceId }] : [];
}, [voices, voiceId, uiLocale]);
const speakingReady = Boolean(localConnected && voiceId && status?.ready);
const speakingReady = Boolean(
(localConnected && voiceId && status?.ready) || (autoSpeak && voiceOptions.length > 0),
);
async function ensureLocalVoice(): Promise<boolean> {
if (localConnected) return true;
markPending("connect");
setError(null);
try {
await rpc.voice.connect({
provider: LOCAL_PROVIDER_ID,
@ -133,16 +138,22 @@ export function VoiceSettingsOverlay({
await refresh();
return true;
} catch (err) {
if (typeof window !== "undefined" && "speechSynthesis" in window) {
return true;
}
setError(err instanceof Error ? err.message : t`Could not enable speaking`);
return false;
} finally {
markPending(null);
}
}
async function chooseVoice(nextVoiceId: string) {
setVoiceId(nextVoiceId);
if (!localConnected) return;
if (!localConnected) {
if (activeBotId) {
await rpc.bots.update({ botId: activeBotId, voiceId: nextVoiceId }).catch(() => undefined);
}
return;
}
markPending("voice");
setError(null);
try {
@ -161,6 +172,7 @@ export function VoiceSettingsOverlay({
setError(null);
setNotice(null);
try {
let chosenVoice = voiceId;
if (next) {
const connected = await ensureLocalVoice();
if (!connected) {
@ -168,16 +180,29 @@ export function VoiceSettingsOverlay({
return;
}
const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []);
const nextVoice = voiceIdForLocale(listed, uiLocale, voiceId);
const isGermanLocale = uiLocale.toLowerCase().startsWith("de");
const pool =
isGermanLocale && !listed.some((v) => /^d[fm]_/i.test(v.id))
? [...GERMAN_VOICE_PRESETS, ...listed]
: listed;
const nextVoice = voiceIdForLocale(pool, uiLocale, voiceId);
if (nextVoice) {
await rpc.voice.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID });
chosenVoice = nextVoice;
setVoiceId(nextVoice);
await rpc.voice
.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID })
.catch(() => undefined);
}
}
if (activeBotId) {
await rpc.bots.update({ botId: activeBotId, autoSpeak: next });
await rpc.bots.update({
botId: activeBotId,
autoSpeak: next,
...(chosenVoice ? { voiceId: chosenVoice } : {}),
});
}
await refresh();
if (!voiceId && chosenVoice) setVoiceId(chosenVoice);
setNotice(next ? t`Speaking is on.` : t`Speaking is off.`);
} catch (err) {
setAutoSpeak(!next);
@ -193,7 +218,11 @@ export function VoiceSettingsOverlay({
return;
}
setError(null);
void speaker.speak(t`Hi, this is how I'll sound when I read replies out loud.`, {
const isGermanVoice = /^d[fm]_/i.test(voice.id) || uiLocale.toLowerCase().startsWith("de");
const sampleText = isGermanVoice
? `Hallo, ich bin ${voice.label}. Lass uns direkt miteinander sprechen.`
: t`Hi, this is how I'll sound when we talk.`;
void speaker.speak(sampleText, {
voiceId: voice.id,
messageId: `${PREVIEW_PREFIX}${voice.id}`,
});
@ -234,7 +263,7 @@ export function VoiceSettingsOverlay({
{autoSpeak ? <Trans>Can speak</Trans> : <Trans>Does not speak</Trans>}
</p>
<p className="mt-1 text-ui-body-sm text-muted-foreground">
<Trans>Your Oo reads replies aloud when this is on.</Trans>
<Trans>Your Oo talks with you naturally when this is on.</Trans>
</p>
</div>
<Switch

View File

@ -20,6 +20,7 @@ import {
ModelTierSchema,
normalizeBotTags,
} from "@rakazo/contracts";
import { GERMAN_VOICE_PRESETS, germanVoiceForBot, voicesForLocale } from "@rakazo/core";
import {
Button,
Input,
@ -37,6 +38,7 @@ import { connectedModelOptions } from "../../lib/connected-models";
import { lazyWithRecovery } from "../../lib/lazy-recovery";
import { modelTierLabel } from "../../lib/model-tier-label";
import { rpc } from "../../lib/rpc";
import { readUiLocale } from "../../lib/ui-locale";
import { BotCapabilitiesFields } from "./bot-capabilities";
const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
@ -280,8 +282,8 @@ export function BotSettings({
const [computerMode, setComputerMode] = useState(bot.computerMode);
const [memoryScope, setMemoryScope] = useState(bot.memoryScope);
const [autoSpeak, setAutoSpeak] = useState(bot.autoSpeak);
const [voiceId, setVoiceId] = useState(bot.voiceId ?? "");
const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [voiceId, setVoiceId] = useState(bot.voiceId || germanVoiceForBot(bot.id, bot.voiceId));
const [voices, setVoices] = useState<VoiceInfo[]>(GERMAN_VOICE_PRESETS);
const [modelKey, setModelKey] = useState(
bot.modelTier
? tierOptionKey(bot.modelTier)
@ -314,8 +316,15 @@ export function BotSettings({
useEffect(() => {
void rpc.voice
.voices({})
.then(setVoices)
.catch(() => setVoices([]));
.then((listed) => {
const uiLocale = readUiLocale();
const isGerman = uiLocale.toLowerCase().startsWith("de");
const hasGerman = listed.some((v) => /^d[fm]_/i.test(v.id));
const combined = isGerman && !hasGerman ? [...GERMAN_VOICE_PRESETS, ...listed] : listed;
const filtered = voicesForLocale(combined, uiLocale);
setVoices(filtered.length > 0 ? filtered : GERMAN_VOICE_PRESETS);
})
.catch(() => setVoices(GERMAN_VOICE_PRESETS));
void rpc.bots
.builtinTools({ botId: bot.id })
.then(setToolNames)
@ -675,7 +684,7 @@ export function BotSettings({
<NativeSelectOption value="">{t`Account default`}</NativeSelectOption>
{voices.map((voice) => (
<NativeSelectOption key={voice.id} value={voice.id}>
{voice.label}
{voice.description ? `${voice.label} (${voice.description})` : voice.label}
</NativeSelectOption>
))}
</NativeSelect>

View File

@ -0,0 +1,249 @@
// @vitest-environment jsdom
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import type { ThreadMessage } from "@rakazo/contracts";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function formatTemplate(parts: TemplateStringsArray, ...values: unknown[]) {
return parts.reduce(
(text, part, index) => text + part + (index < values.length ? values[index] : ""),
"",
);
}
vi.mock("@lingui/core/macro", () => ({
t: formatTemplate,
msg: (parts: TemplateStringsArray, ...values: unknown[]) => ({
id: parts.join(""),
message: formatTemplate(parts, ...values),
}),
}));
vi.mock("@lingui/react/macro", () => {
return {
useLingui: () => ({ t: formatTemplate }),
Trans: ({ children }: { children: React.ReactNode }) => children,
Plural: ({ value, one, other }: { value: number; one: string; other: string }) =>
(value === 1 ? one : other).replace("#", String(value)),
};
});
import { ComposerTaskPill, extractActivePlanState, PlanCard } from "./message-cards";
// @ts-expect-error React act environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
function buildSampleMessages(): ThreadMessage[] {
return [
{
id: "u1",
threadId: "t1",
seq: 1,
role: "user",
runId: "r1",
createdAt: "2026-10-22T12:00:00Z",
blocks: [{ kind: "text", text: "Build brand concept" }],
},
{
id: "b-plan",
threadId: "t1",
seq: 2,
role: "bot",
botId: "bot-1",
runId: "r1",
createdAt: "2026-10-22T12:00:05Z",
blocks: [
{
kind: "plan",
planId: "p1",
title: "Branding-Konzept finalisieren",
status: "active",
steps: [
{ id: "s1", label: "Slogans erstellen", status: "completed" },
{
id: "s2",
label: "Brand Guide HTML-Seite erstellen",
status: "in_progress",
note: "Komplette Zusammenfassung aller Brand-Elemente",
},
{ id: "s3", label: "Alles auf Server kopieren", status: "pending" },
],
},
],
},
{
id: "b-step-1",
threadId: "t1",
seq: 3,
role: "bot",
botId: "bot-1",
runId: "r1",
createdAt: "2026-10-22T12:00:10Z",
blocks: [{ kind: "text", text: "running: pnpm build" }],
},
{
id: "b-step-2",
threadId: "t1",
seq: 4,
role: "bot",
botId: "bot-1",
runId: "r1",
createdAt: "2026-10-22T12:00:15Z",
blocks: [{ kind: "text", text: "writing brand-guide.html" }],
},
{
id: "progress:r1",
threadId: "t1",
seq: 5,
role: "bot",
botId: "bot-1",
runId: "r1",
createdAt: "2026-10-22T12:00:20Z",
blocks: [{ kind: "progress", text: "Working..." }],
},
];
}
describe("ComposerTaskPill & PlanCard integration", () => {
beforeEach(() => {
i18n.load("en", {});
i18n.activate("en");
});
afterEach(() => {
vi.useRealTimers();
});
it("extractActivePlanState collects the active plan and folded narration steps from the current turn", () => {
const messages = buildSampleMessages();
const state = extractActivePlanState(messages, true, "2026-10-22T12:00:00Z");
expect(state).not.toBeNull();
expect(state?.block.planId).toBe("p1");
expect(state?.steps.map((s) => s.id)).toEqual(["b-step-1", "b-step-2"]);
expect(state?.running).toBe(true);
});
it("renders compact active step + progress badge and expands to reveal full checklist & intermediate steps", async () => {
const state = extractActivePlanState(buildSampleMessages(), true, "2026-10-22T12:00:00Z")!;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
<I18nProvider i18n={i18n}>
<ComposerTaskPill activePlan={state} />
</I18nProvider>,
);
});
const pill = container.querySelector('[data-testid="composer-task-pill"]');
expect(pill).not.toBeNull();
expect(pill?.getAttribute("data-expanded")).toBe("false");
expect(container.querySelector('[data-testid="composer-task-pill-label"]')?.textContent).toBe(
"Brand Guide HTML-Seite erstellen",
);
expect(
container.querySelector('[data-testid="composer-task-pill-progress"]')?.textContent,
).toBe("1/3");
const toggle = container.querySelector(
'[data-testid="composer-task-pill-toggle"]',
) as HTMLButtonElement;
await act(async () => {
toggle.click();
});
expect(pill?.getAttribute("data-expanded")).toBe("true");
const intermediate = container.querySelector(
'[data-testid="composer-plan-intermediate-steps"]',
);
expect(intermediate).not.toBeNull();
const stepToggle = intermediate?.querySelector("button") as HTMLButtonElement;
await act(async () => {
stepToggle.click();
});
expect(intermediate?.textContent).toContain("running: pnpm build");
expect(intermediate?.textContent).toContain("writing brand-guide.html");
await act(async () => {
root.unmount();
});
container.remove();
});
it("auto-collapses and hides after all steps complete", async () => {
vi.useFakeTimers();
const completedPlan = {
block: {
kind: "plan" as const,
planId: "p-done",
title: "Branding-Konzept finalisieren",
status: "completed" as const,
steps: [
{ id: "s1", label: "Slogans erstellen", status: "completed" as const },
{ id: "s2", label: "Brand Guide erstellen", status: "completed" as const },
],
},
steps: [],
running: false,
};
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
<I18nProvider i18n={i18n}>
<ComposerTaskPill activePlan={completedPlan} />
</I18nProvider>,
);
});
expect(container.querySelector('[data-testid="composer-task-pill"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="composer-task-pill-progress"]')?.textContent,
).toBe("Done");
await act(async () => {
vi.advanceTimersByTime(3600);
});
expect(container.querySelector('[data-testid="composer-task-pill"]')).toBeNull();
await act(async () => {
root.unmount();
});
container.remove();
});
it("PlanCard embeds intermediate steps under the active step", async () => {
const state = extractActivePlanState(buildSampleMessages(), true, "2026-10-22T12:00:00Z")!;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
<I18nProvider i18n={i18n}>
<PlanCard block={state.block} steps={state.steps} />
</I18nProvider>,
);
});
const activeStepRow = container.querySelector('[data-testid="plan-step-s2"]');
expect(activeStepRow).not.toBeNull();
expect(activeStepRow?.querySelector('[data-testid="plan-intermediate-steps"]')).not.toBeNull();
await act(async () => {
root.unmount();
});
container.remove();
});
});

View File

@ -5,11 +5,14 @@ import { ATTACHMENT_ALLOWED_MIME_TYPES } from "@rakazo/contracts";
import type { ComposerMention, SlashActionId } from "@rakazo/core";
import {
clampMentionHighlightIndex,
clearComposerDraft,
mentionChipKey,
readComposerDraft,
resolveMentionPickerKey,
SLASH_ACTIONS,
serializeComposerPrompt,
truncateSlashDescription,
writeComposerDraft,
} from "@rakazo/core";
import {
BotAvatar,
@ -28,6 +31,7 @@ import {
Play,
Plus,
Puzzle,
RotateCcw,
Settings,
Square,
X,
@ -37,6 +41,8 @@ import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } fr
import { VoiceAuroraRim } from "../../components/voice/VoiceAurora";
import { isFileDrag, isFilePaste } from "../../lib/pending-attachments";
import { AutopilotDialog } from "./autopilot-dialog";
import type { ComposerActivePlan } from "./message-cards";
import { ComposerTaskPill } from "./message-cards";
import { previewMessageText } from "./message-preview";
import type { PendingAttachment } from "./shell-types";
import { FALLBACK_BOT_COLOR } from "./shell-types";
@ -48,6 +54,7 @@ const addButtonClass =
"tactile-press size-10 shrink-0 rounded-full border border-border bg-foreground/5 text-foreground/80 transition-all hover:bg-foreground/10 hover:text-foreground flex items-center justify-center cursor-pointer";
export const Composer = memo(function Composer({
draftKey,
running,
disabled,
pendingAttachments,
@ -76,7 +83,12 @@ export const Composer = memo(function Composer({
onSlashAction,
onConnectModel,
onAutopilot,
activePlan,
stopped = false,
onResume,
onDismissStopped,
}: {
draftKey?: string;
running: boolean;
disabled?: boolean;
pendingAttachments: PendingAttachment[];
@ -107,15 +119,43 @@ export const Composer = memo(function Composer({
onConnectModel?: () => void;
/** Hand the order to the autopilot; the composer menu offers it only when set. */
onAutopilot?: (goal: string, until?: string) => Promise<void>;
activePlan?: ComposerActivePlan | null;
stopped?: boolean;
onResume?: () => void | Promise<void>;
onDismissStopped?: () => void;
}) {
const { t } = useLingui();
const [draft, setDraft] = useState("");
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
const [draft, setDraft] = useState(() => initialDraft.text);
const [autopilotOpen, setAutopilotOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(
() => initialDraft.selectedSkill,
);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>(
() => initialDraft.selectedMentions,
);
const loadedDraftKeyRef = useRef(draftKey);
useLayoutEffect(() => {
if (loadedDraftKeyRef.current !== draftKey) {
loadedDraftKeyRef.current = draftKey;
const stored = readComposerDraft(draftKey);
setDraft(stored.text);
setSelectedSkill(stored.selectedSkill);
setSelectedMentions(stored.selectedMentions);
setMentionQuery(null);
setMentionHighlightIndex(0);
setSlashQuery(null);
return;
}
writeComposerDraft(draftKey, {
text: draft,
selectedSkill,
selectedMentions,
});
}, [draft, draftKey, selectedMentions, selectedSkill]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const runErrorRef = useRef<HTMLDivElement>(null);
const submitFingerprint = useRef<string | null>(null);
@ -265,6 +305,11 @@ export const Composer = memo(function Composer({
function runSlashAction(action: SlashActionId) {
setDraft("");
setSlashQuery(null);
writeComposerDraft(draftKey, {
text: "",
selectedSkill,
selectedMentions,
});
onSlashAction?.(action);
}
@ -300,7 +345,10 @@ export const Composer = memo(function Composer({
const slashSkillOptions = useMemo(() => {
if (slashQuery === null) return [];
const query = slashQuery.trim().toLowerCase();
const skills = agentSkills ?? [];
// The user's own skills come first so built-in ones never crowd them out of the picker.
const skills = [...(agentSkills ?? [])].sort(
(a, b) => Number(a.source !== "user") - Number(b.source !== "user"),
);
return skills
.filter((skill) => {
if (!query) return true;
@ -331,6 +379,7 @@ export const Composer = memo(function Composer({
);
if (submitFingerprint.current === fingerprint) return;
submitFingerprint.current = fingerprint;
clearComposerDraft(draftKey);
setDraft("");
setMentionQuery(null);
setMentionHighlightIndex(0);
@ -417,11 +466,11 @@ export const Composer = memo(function Composer({
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative z-30 m-0 min-w-0 border-0 px-3 pb-4 pt-3 md:px-6 md:pb-6 ${
className={`composer-bottom-fade pointer-events-none absolute inset-x-0 bottom-0 z-30 m-0 min-w-0 max-w-full overflow-x-hidden border-0 px-3 pb-4 pt-10 md:px-6 md:pb-6 md:pt-12 ${
draggingFiles ? "rounded-[14px] ring-2 ring-inset ring-ring" : ""
}`}
>
<div className="mx-auto w-full max-w-4xl">
<div className="pointer-events-auto mx-auto w-full max-w-4xl">
{onConnectModel ? (
<div
data-testid="composer-model-needed"
@ -442,6 +491,18 @@ export const Composer = memo(function Composer({
className="mb-3 flex items-center gap-2 rounded-[14px] border border-destructive/40 bg-destructive/10 px-4 py-2 text-ui-body-sm text-destructive"
>
<span className="min-w-0 flex-1">{sendError ?? runError}</span>
{runError && !sendError && onResume ? (
<button
type="button"
data-testid="composer-error-resume"
disabled={sending}
onClick={() => void onResume()}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-destructive/30 bg-background/80 px-2.5 py-1 text-ui-label font-medium text-foreground transition-colors hover:bg-background disabled:opacity-50 cursor-pointer"
>
<RotateCcw className="size-3" aria-hidden="true" />
<Trans>Resume</Trans>
</button>
) : null}
<button
type="button"
aria-label={t`Dismiss error`}
@ -455,6 +516,39 @@ export const Composer = memo(function Composer({
<X strokeWidth={2} className="size-icon-sm" />
</button>
</div>
) : stopped && !running && onResume ? (
<div
data-testid="composer-stopped"
className="mb-3 flex items-center gap-2 rounded-[14px] border border-border bg-muted px-4 py-2 text-ui-body-sm text-muted-foreground"
>
<span className="min-w-0 flex-1">
<Trans>Stopped</Trans>
</span>
<button
type="button"
data-testid="composer-resume"
disabled={sending}
onClick={() => void onResume()}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border bg-background px-2.5 py-1 text-ui-label font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50 cursor-pointer"
>
<RotateCcw className="size-3" aria-hidden="true" />
<Trans>Resume</Trans>
</button>
{onDismissStopped ? (
<button
type="button"
aria-label={t`Dismiss`}
data-testid="composer-stopped-dismiss"
onClick={() => {
onDismissStopped();
window.requestAnimationFrame(() => textareaRef.current?.focus());
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X strokeWidth={2} className="size-icon-sm" />
</button>
) : null}
</div>
) : null}
{replyTarget ? (
<div
@ -564,7 +658,7 @@ export const Composer = memo(function Composer({
{showSlashPicker ? (
<div
data-testid="slash-picker"
className="mb-2 overflow-hidden rounded-[14px] border border-border bg-muted"
className="mb-2 max-h-64 overflow-y-auto rounded-[14px] border border-border bg-muted"
>
{slashSkillOptions.map((skill) => (
<button
@ -608,6 +702,7 @@ export const Composer = memo(function Composer({
})}
</div>
) : null}
{activePlan ? <ComposerTaskPill activePlan={activePlan} onResume={onResume} /> : null}
<div
data-testid="composer-bar"
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200"
@ -665,6 +760,11 @@ export const Composer = memo(function Composer({
onStart={async (goal, until) => {
await onAutopilot(goal, until);
setDraft("");
writeComposerDraft(draftKey, {
text: "",
selectedSkill,
selectedMentions,
});
}}
/>
) : null}

View File

@ -1,105 +0,0 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { Bot, ComputerStatus } from "@rakazo/contracts";
import { Maximize2 } from "lucide-react";
import type { ReactNode } from "react";
import {
ComputersUnavailableHint,
computersAreUnavailable,
} from "../../components/ComputersUnavailableHint";
import { AgentCursor } from "../../components/computer/AgentCursor";
import {
computerLabel,
computerPlaceholder,
DesktopKindEmptyState,
screenIframeSandbox,
} from "./computer-screen";
import { WorkspaceFilesView } from "./workspace-files";
/** The bot's screen in the side panel: a live, non-interactive preview that opens full window. */
export function ComputerPanelPreview({
active,
computer,
computerOpen,
booting,
embeddedScreenUrl,
computerScreenError,
sandboxProvider,
onOpen,
}: {
active: Bot;
computer: ComputerStatus | null;
computerOpen: boolean;
booting: boolean;
embeddedScreenUrl: string | null;
computerScreenError: ReactNode;
sandboxProvider: string | undefined;
onOpen: () => void;
}) {
const { t } = useLingui();
// Opening the panel never starts the computer, so a resting one shows its Open action outright.
const resting =
!booting &&
computer?.state !== "running" &&
computer?.state !== "booting" &&
!computersAreUnavailable(sandboxProvider);
return (
<>
<div
data-testid="computer-preview"
className="group relative aspect-[16/10] overflow-hidden rounded-[14px] bg-background"
>
{computerOpen ? (
<div className="grid h-full place-items-center text-sm text-muted-foreground/80">
<Trans>Open in full window</Trans>
</div>
) : computer?.kind === "desktop" ? (
<DesktopKindEmptyState className="grid h-full place-items-center px-6 text-center text-sm text-muted-foreground/80" />
) : computer?.state === "running" && embeddedScreenUrl && !computerScreenError ? (
<>
<iframe
title={t`Bot screen preview`}
src={embeddedScreenUrl}
sandbox={screenIframeSandbox(embeddedScreenUrl)}
className="h-full w-full border-0 bg-black"
allow="clipboard-read; clipboard-write"
style={{ pointerEvents: "none" }}
/>
<AgentCursor botId={active.id} color={active.color} name={active.name} />
</>
) : (
<div className="grid h-full place-items-center px-6 text-center text-sm text-muted-foreground/80">
{computerScreenError ??
(computersAreUnavailable(sandboxProvider) ? (
<ComputersUnavailableHint />
) : (
computerPlaceholder(
computer?.state,
booting,
computerLabel(computer?.mode, active.name),
)
))}
</div>
)}
{!computerScreenError ? (
<button
type="button"
data-testid="computer-preview-open"
data-resting={resting ? "" : undefined}
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity group-hover:bg-overlay/40 group-hover:opacity-100 focus-visible:bg-overlay/40 focus-visible:opacity-100 data-resting:items-end data-resting:pb-4 data-resting:opacity-100"
aria-label={t`Open`}
onClick={onOpen}
>
<span className="inline-flex items-center gap-2 rounded-full bg-overlay px-3.5 py-2 text-ui-body text-foreground shadow-md">
<Maximize2 strokeWidth={1.9} aria-hidden className="size-icon-md" />
<Trans>Open</Trans>
</span>
</button>
) : null}
</div>
<p className="mt-2 truncate text-ui-body text-muted-foreground" dir="auto">
{t`${active.name}'s screen`}
</p>
<WorkspaceFilesView botId={active.id} />
</>
);
}

View File

@ -108,14 +108,20 @@ export function NewSpaceDialog({
<Dialog open onOpenChange={closeUnlessBusy(saving, onCancel)}>
<DialogContent
showCloseButton={false}
className={isProject ? "max-h-[90vh] overflow-y-auto max-w-xl" : undefined}
className={
isProject
? "flex max-h-[88dvh] w-[calc(100vw-2rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
: undefined
}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2.5">
<DialogHeader
className={isProject ? "shrink-0 border-b border-border px-5 py-4" : undefined}
>
<DialogTitle className="flex min-w-0 items-center gap-2.5">
{isProject ? (
<>
<ProjectIcon icon={icon} color={color} size={30} />
<span>
<span className="truncate">
<Trans>New project</Trans>
</span>
</>
@ -133,7 +139,13 @@ export function NewSpaceDialog({
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-1">
<div
className={
isProject
? "rk-scroll min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto px-5 py-4"
: "min-w-0 space-y-4 pt-1"
}
>
<label htmlFor={nameId} className="block text-ui-body text-foreground/75">
<Trans>Name</Trans>
<Input
@ -179,7 +191,7 @@ export function NewSpaceDialog({
<div
role="radiogroup"
aria-label={t`Icon`}
className="grid max-h-36 grid-cols-8 gap-1 overflow-y-auto rounded-lg border border-border p-1"
className="grid max-h-36 grid-cols-6 gap-1 overflow-y-auto rounded-lg border border-border p-1.5 sm:grid-cols-8"
>
{PROJECT_ICONS.map((option) => {
const Glyph = PROJECT_ICON_COMPONENTS[option.id];
@ -304,8 +316,12 @@ export function NewSpaceDialog({
) : null}
</div>
{error ? <p className="text-ui-body text-destructive mt-2">{error}</p> : null}
<DialogFooter className="mt-4">
{error ? (
<p className={`text-ui-body text-destructive ${isProject ? "px-5 pt-2" : "mt-2"}`}>
{error}
</p>
) : null}
<DialogFooter className={isProject ? "mx-0 mb-0 mt-0 shrink-0 px-5 py-3.5" : "mt-4"}>
<Button variant="outline" disabled={saving} onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>

View File

@ -84,11 +84,11 @@ export function FullscreenComputerOverlay({
)}
{!recordingSkill && hasControl ? (
computer?.takeoverRequested ? (
<span className="rounded-full bg-warning/15 px-[11px] py-1 text-ui-body-sm text-warning">
<span className="shrink-0 whitespace-nowrap rounded-full bg-warning/15 px-[11px] py-1 text-ui-body-sm text-warning">
<Trans>Needs you</Trans>
</span>
) : (
<span className="rounded-full bg-success/15 px-[11px] py-1 text-ui-body-sm text-success">
<span className="shrink-0 whitespace-nowrap rounded-full bg-success/15 px-[11px] py-1 text-ui-body-sm text-success">
<Trans>You have control</Trans>
</span>
)

File diff suppressed because it is too large Load Diff

View File

@ -95,16 +95,16 @@ export function ProjectLookDialog({
<DialogContent
showCloseButton={false}
data-testid="project-look-dialog"
className="max-h-[90vh] overflow-y-auto max-w-xl"
className="flex max-h-[88dvh] w-[calc(100vw-2rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
<DialogTitle className="flex min-w-0 items-center gap-3">
<ProjectIcon icon={icon} color={color} size={36} />
<span className="min-w-0 truncate">{name.trim() || initial.name}</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-1">
<div className="rk-scroll min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
<label htmlFor={nameId} className="block text-ui-body text-foreground/75">
<Trans>Name</Trans>
<Input
@ -147,7 +147,7 @@ export function ProjectLookDialog({
<div
role="radiogroup"
aria-label={t`Icon`}
className="grid max-h-40 grid-cols-8 gap-1 overflow-y-auto rounded-lg border border-border p-1"
className="grid max-h-40 grid-cols-6 gap-1 overflow-y-auto rounded-lg border border-border p-1.5 sm:grid-cols-8"
>
{PROJECT_ICONS.map((option) => {
const Glyph = PROJECT_ICON_COMPONENTS[option.id];
@ -243,8 +243,8 @@ export function ProjectLookDialog({
</label>
</div>
{error ? <p className="text-ui-body text-destructive mt-2">{error}</p> : null}
<DialogFooter className="mt-4">
{error ? <p className="px-5 pt-2 text-ui-body text-destructive">{error}</p> : null}
<DialogFooter className="mx-0 mb-0 mt-0 shrink-0 px-5 py-3.5">
<Button variant="outline" disabled={saving} onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>

Some files were not shown because too many files have changed in this diff Show More