From 1611fe5fa2ec2b2e4f6ea76e9d5a550e989fc88f Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:04:20 +0200 Subject: [PATCH 01/25] Let computers push from project folders through the GitHub broker (#217) Two gaps kept agents from pushing on a containerized supervisor: - The git and gh shims called rakazo-broker by name, and login shells in the computer reset PATH, so the broker was "not found". The shims now call it next to their own resolved path, and /usr/bin/gh is a shim too. - A project folder outside the supervisor container's mounts is invisible to it, so broker git ran against an empty directory. Such calls now run in a one-off computer-image container that binds the folder. Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ Co-authored-by: Claude --- infra/sandboxes/computer/Dockerfile | 5 +- infra/sandboxes/computer/bin/rakazo-gh | 4 +- infra/sandboxes/computer/bin/rakazo-git | 2 +- .../supervisor/src/computer-git-shim.test.ts | 14 ++- .../supervisor/src/github-broker-helper.ts | 100 ++++++++++++++++++ .../supervisor/src/github-broker.test.ts | 58 ++++++++++ .../sandboxes/supervisor/src/github-broker.ts | 43 ++++++-- infra/sandboxes/supervisor/src/index.ts | 10 +- 8 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 infra/sandboxes/supervisor/src/github-broker-helper.ts diff --git a/infra/sandboxes/computer/Dockerfile b/infra/sandboxes/computer/Dockerfile index 3e2b974..c710847 100644 --- a/infra/sandboxes/computer/Dockerfile +++ b/infra/sandboxes/computer/Dockerfile @@ -52,10 +52,13 @@ WORKDIR /home/rakazo # --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would # otherwise leave these files unreadable to USER 1000 at runtime. COPY --chmod=755 bin/rakazo-git bin/rakazo-gh bin/rakazo-broker /usr/local/lib/rakazo/bin/ -# /usr/bin/git is the shim too, so calling git by absolute path still goes through the broker. +# /usr/bin/git and /usr/bin/gh are the shims too, so absolute paths and login shells that reset +# PATH still go through the broker. RUN mkdir -p /usr/local/libexec/rakazo \ && mv /usr/bin/git /usr/local/libexec/rakazo/git \ + && mv /usr/bin/gh /usr/local/libexec/rakazo/gh \ && ln -sf /usr/local/lib/rakazo/bin/rakazo-git /usr/bin/git \ + && ln -sf /usr/local/lib/rakazo/bin/rakazo-gh /usr/bin/gh \ && ln -sf /usr/local/lib/rakazo/bin/rakazo-git /usr/local/lib/rakazo/bin/git \ && ln -sf /usr/local/lib/rakazo/bin/rakazo-gh /usr/local/lib/rakazo/bin/gh COPY --chmod=755 start.sh /usr/local/bin/rakazo-computer diff --git a/infra/sandboxes/computer/bin/rakazo-gh b/infra/sandboxes/computer/bin/rakazo-gh index 3f3424d..36380b3 100755 --- a/infra/sandboxes/computer/bin/rakazo-gh +++ b/infra/sandboxes/computer/bin/rakazo-gh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -REAL_GH=/usr/bin/gh +REAL_GH=/usr/local/libexec/rakazo/gh if [[ "${RAKAZO_GITHUB_BROKER_URL:-}" == "" || "${RAKAZO_GITHUB_BROKER_RUN_TOKEN:-}" == "" ]]; then exec "$REAL_GH" "$@" fi @@ -9,4 +9,4 @@ if [[ ! "$joined" =~ (^|[[:space:]])pr([[:space:]]|$) ]]; then exec "$REAL_GH" "$@" fi payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["gh"]+sys.argv[1:]}))' "$@")" -exec rakazo-broker v1/gh "$payload" +exec "$(dirname "$(readlink -f "$0")")/rakazo-broker" v1/gh "$payload" diff --git a/infra/sandboxes/computer/bin/rakazo-git b/infra/sandboxes/computer/bin/rakazo-git index fbc2b28..617bc5f 100755 --- a/infra/sandboxes/computer/bin/rakazo-git +++ b/infra/sandboxes/computer/bin/rakazo-git @@ -23,7 +23,7 @@ done case "${args[i]:-}" in push | fetch | pull | clone | ls-remote) payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["git"]+sys.argv[2:],"cwd":sys.argv[1]}))' "$cwd" "${args[@]:i}")" - exec rakazo-broker v1/git "$payload" + exec "$(dirname "$(readlink -f "$0")")/rakazo-broker" v1/git "$payload" ;; esac exec "$REAL_GIT" "$@" diff --git a/infra/sandboxes/supervisor/src/computer-git-shim.test.ts b/infra/sandboxes/supervisor/src/computer-git-shim.test.ts index 0622de3..0e24b25 100644 --- a/infra/sandboxes/supervisor/src/computer-git-shim.test.ts +++ b/infra/sandboxes/supervisor/src/computer-git-shim.test.ts @@ -1,5 +1,5 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,17 +8,23 @@ import { describe, expect, it } from "vitest"; const shimPath = (name: string) => fileURLToPath(new URL(`../../computer/bin/${name}`, import.meta.url)); -/** Runs the computer's git shim with a stub broker that echoes what it would send. */ +/** + * Runs the computer's git shim through a symlink, as /usr/bin/git does, next to a stub broker that + * echoes what it would send. The stub is not on PATH: login shells reset PATH in the computer. + */ function brokerPayload(args: string[], cwd: string, shim = "rakazo-git") { const bin = mkdtempSync(path.join(tmpdir(), "rakazo-git-shim-")); + copyFileSync(shimPath(shim), path.join(bin, shim)); writeFileSync(path.join(bin, "rakazo-broker"), '#!/bin/sh\nprintf "%s\\n%s" "$1" "$2"\n', { mode: 0o755, }); - const output = execFileSync("bash", [shimPath(shim), ...args], { + const linked = mkdtempSync(path.join(tmpdir(), "rakazo-git-link-")); + symlinkSync(path.join(bin, shim), path.join(linked, shim)); + const output = execFileSync("bash", [path.join(linked, shim), ...args], { cwd, encoding: "utf8", env: { - PATH: `${bin}:${process.env.PATH}`, + PATH: process.env.PATH, RAKAZO_GITHUB_BROKER_URL: "http://host.docker.internal:7091/internal/github-broker", RAKAZO_GITHUB_BROKER_RUN_TOKEN: "rgb1.test", }, diff --git a/infra/sandboxes/supervisor/src/github-broker-helper.ts b/infra/sandboxes/supervisor/src/github-broker-helper.ts new file mode 100644 index 0000000..8ddae04 --- /dev/null +++ b/infra/sandboxes/supervisor/src/github-broker-helper.ts @@ -0,0 +1,100 @@ +import { readFileSync } from "node:fs"; +import type Docker from "dockerode"; +import type { + BrokerCommandInput, + BrokerCommandResult, + BrokerOutsideRunner, +} from "./github-broker.js"; + +// The computer image keeps the real binaries behind its broker shims. +const HELPER_BINARIES: Record = { + "/usr/bin/git": "/usr/local/libexec/rakazo/git", + "/usr/bin/gh": "/usr/local/libexec/rakazo/gh", +}; + +/** Mount points of this process, from /proc/self/mountinfo. */ +export function readMountPoints( + mountinfo = readFileSync("/proc/self/mountinfo", "utf8"), +): string[] { + return mountinfo + .split("\n") + .map((line) => line.split(" ")[4]) + .filter((point): point is string => Boolean(point)) + .map((point) => point.replace(/\\040/g, " ")); +} + +/** + * Inside a container only bind mounts reach the host; parent folders of a mount exist but are empty, + * so a path counts as visible only below a mount other than the root. + */ +export function visibleThroughMounts(target: string, mountPoints: string[]): boolean { + return mountPoints.some( + (point) => point !== "/" && (target === point || target.startsWith(`${point}/`)), + ); +} + +/** A one-off container that binds the workspace a containerized supervisor cannot see. */ +export function helperContainerOptions( + input: BrokerCommandInput, + image: string, + user: string, +): Docker.ContainerCreateOptions { + const [binary = "", ...args] = input.argv; + return { + Image: image, + Entrypoint: [HELPER_BINARIES[binary] ?? binary], + Cmd: args, + WorkingDir: input.cwd, + User: user, + Env: Object.entries(input.env).map(([name, value]) => `${name}=${value}`), + Labels: { "rakazo.role": "github-broker-helper" }, + HostConfig: { Binds: [`${input.workspacePath}:${input.workspacePath}`] }, + }; +} + +/** Splits Docker's multiplexed log stream: an 8-byte header (stream id, size) per frame. */ +export function demuxDockerLogs(buffer: Buffer): { stdout: string; stderr: string } { + const out: Buffer[] = []; + const err: Buffer[] = []; + let offset = 0; + while (offset + 8 <= buffer.length) { + const size = buffer.readUInt32BE(offset + 4); + const frame = buffer.subarray(offset + 8, offset + 8 + size); + (buffer[offset] === 2 ? err : out).push(frame); + offset += 8 + size; + } + return { + stdout: Buffer.concat(out).toString("utf8"), + stderr: Buffer.concat(err).toString("utf8"), + }; +} + +export function createHelperRunner( + docker: Docker, + image: string, + user: string, + mountPoints: string[] = readMountPoints(), +): BrokerOutsideRunner { + return { + sees: (target) => visibleThroughMounts(target, mountPoints), + async run(input): Promise { + let container: Docker.Container | undefined; + try { + container = await docker.createContainer(helperContainerOptions(input, image, user)); + await container.start(); + const { StatusCode } = (await container.wait()) as { StatusCode: number }; + const logs = (await container.logs({ + stdout: true, + stderr: true, + follow: false, + })) as Buffer; + return { ...demuxDockerLogs(logs), code: StatusCode }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { stdout: "", stderr: message, code: 1 }; + } finally { + await container?.remove({ force: true }).catch(() => undefined); + } + }, + }; +} diff --git a/infra/sandboxes/supervisor/src/github-broker.test.ts b/infra/sandboxes/supervisor/src/github-broker.test.ts index e5d42ae..563a615 100644 --- a/infra/sandboxes/supervisor/src/github-broker.test.ts +++ b/infra/sandboxes/supervisor/src/github-broker.test.ts @@ -12,6 +12,12 @@ import { hostGitInvocation, hostPathFor, } from "./github-broker.js"; +import { + demuxDockerLogs, + helperContainerOptions, + readMountPoints, + visibleThroughMounts, +} from "./github-broker-helper.js"; import { hasValidBearerToken } from "./supervisor-logic.js"; const session: GithubBrokerSession = { @@ -123,3 +129,55 @@ describe("github broker host tools", () => { } }); }); + +describe("github broker helper container", () => { + const mounts = readMountPoints( + [ + "1 0 0:1 / / rw - overlay overlay rw", + "2 1 0:2 /srv/data /srv/data rw - ext4 /dev/sda1 rw", + "3 1 0:3 / /var/run/docker.sock rw - tmpfs tmpfs rw", + ].join("\n"), + ); + + it("sees only paths below the supervisor's own mounts", () => { + expect(mounts).toEqual(["/", "/srv/data", "/var/run/docker.sock"]); + expect(visibleThroughMounts("/srv/data/homes/chief", mounts)).toBe(true); + // The parent of a bind mount exists in the container but is empty. + expect(visibleThroughMounts("/srv", mounts)).toBe(false); + expect(visibleThroughMounts("/home/owner/Projects/app", mounts)).toBe(false); + }); + + it("binds the workspace and runs the computer image's real binary", () => { + const options = helperContainerOptions( + { + argv: ["/usr/bin/git", "push", "origin", "fix"], + cwd: "/home/owner/Projects/app", + env: { GITHUB_TOKEN: "ghp_test" }, + workspacePath: "/home/owner/Projects/app", + }, + "rakazo/computer:local", + "1000:1000", + ); + expect(options).toMatchObject({ + Image: "rakazo/computer:local", + Entrypoint: ["/usr/local/libexec/rakazo/git"], + Cmd: ["push", "origin", "fix"], + WorkingDir: "/home/owner/Projects/app", + User: "1000:1000", + Env: ["GITHUB_TOKEN=ghp_test"], + HostConfig: { Binds: ["/home/owner/Projects/app:/home/owner/Projects/app"] }, + }); + }); + + it("splits Docker's multiplexed output into stdout and stderr", () => { + const frame = (stream: number, text: string) => { + const header = Buffer.alloc(8); + header[0] = stream; + header.writeUInt32BE(Buffer.byteLength(text), 4); + return Buffer.concat([header, Buffer.from(text)]); + }; + expect( + demuxDockerLogs(Buffer.concat([frame(1, "out"), frame(2, "err"), frame(1, "!")])), + ).toEqual({ stdout: "out!", stderr: "err" }); + }); +}); diff --git a/infra/sandboxes/supervisor/src/github-broker.ts b/infra/sandboxes/supervisor/src/github-broker.ts index b799d7a..9e23091 100644 --- a/infra/sandboxes/supervisor/src/github-broker.ts +++ b/infra/sandboxes/supervisor/src/github-broker.ts @@ -94,6 +94,23 @@ export function gitProcessIdentity( } } +export type BrokerCommandResult = { stdout: string; stderr: string; code: number }; + +export type BrokerCommandInput = { + argv: string[]; + cwd: string; + env: Record; + workspacePath: string; +}; + +/** Runs git or gh for a workspace this process cannot see, e.g. a project folder outside a container's mounts. */ +export type BrokerOutsideRunner = { + sees(target: string): boolean; + run(input: BrokerCommandInput): Promise; +}; + +let runOutside: BrokerOutsideRunner | undefined; + const sessions = new Map(); export function githubBrokerSessionsForTests() { @@ -123,18 +140,21 @@ async function runHostGit( session: GithubBrokerSession, argv: string[], cwd = session.workspacePath, -): Promise<{ stdout: string; stderr: string; code: number }> { +): Promise { + const env = { + HOME: session.workspacePath, + GIT_TERMINAL_PROMPT: "0", + GH_TOKEN: session.githubToken, + GITHUB_TOKEN: session.githubToken, + }; + if (runOutside && !runOutside.sees(cwd)) { + return runOutside.run({ argv, cwd, env, workspacePath: session.workspacePath }); + } return new Promise((resolve) => { const child = spawn(argv[0]!, argv.slice(1), { cwd, ...gitProcessIdentity(session.workspacePath), - env: { - ...process.env, - HOME: session.workspacePath, - GIT_TERMINAL_PROMPT: "0", - GH_TOKEN: session.githubToken, - GITHUB_TOKEN: session.githubToken, - }, + env: { ...process.env, ...env }, }); let stdout = ""; let stderr = ""; @@ -177,7 +197,12 @@ async function resolveDefaultBranch(session: GithubBrokerSession): Promise { const pathname = c.req.path; if (pathname.startsWith("/internal/github-broker/sessions")) { diff --git a/infra/sandboxes/supervisor/src/index.ts b/infra/sandboxes/supervisor/src/index.ts index 45f1c48..d417684 100644 --- a/infra/sandboxes/supervisor/src/index.ts +++ b/infra/sandboxes/supervisor/src/index.ts @@ -52,6 +52,7 @@ import { xdotoolCommand, } from "./computer-spec.js"; import { mountGithubBrokerRoutes } from "./github-broker.js"; +import { createHelperRunner } from "./github-broker-helper.js"; import { assertComputerHomeWritable } from "./home-ownership.js"; import { assertRequestIdentity, @@ -135,13 +136,18 @@ const app = new Hono(); export { app as supervisorApp }; -mountGithubBrokerRoutes(app, supervisorToken); +// A containerized supervisor only sees its own mounts; git for other project folders runs in a +// one-off computer-image container that binds the folder. +const brokerOutsideRunner = existsSync("/.dockerenv") + ? createHelperRunner(docker, COMPUTER_IMAGE, COMPUTER_USER) + : undefined; +mountGithubBrokerRoutes(app, supervisorToken, brokerOutsideRunner); // Computers reach the broker through host.docker.internal. A loopback-bound supervisor on Linux is // invisible there, so it also serves the broker alone on the default bridge gateway, which only // containers on this host can reach. const brokerApp = new Hono(); -mountGithubBrokerRoutes(brokerApp, supervisorToken); +mountGithubBrokerRoutes(brokerApp, supervisorToken, brokerOutsideRunner); export function needsBridgeBrokerListener( hostname: string, From f6053dd2a8d1cf0dc069a9c86791ff60d310e1d1 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:04:17 +0200 Subject: [PATCH 02/25] feat: allow project deletion with owned agents, prevent duplicate project names, and refine voice conversation UX --- apps/api/src/router.ts | 45 ++- apps/mobile/app/group-settings.tsx | 149 ++++++--- apps/mobile/app/index.tsx | 16 +- apps/mobile/lib/inbox-spaces.test.ts | 12 +- apps/mobile/lib/inbox-spaces.ts | 35 +- apps/web/e2e/first-run-onboarding-ux.spec.ts | 2 +- apps/web/e2e/main-conversation.spec.ts | 3 +- .../src/components/voice/VoiceLiveText.tsx | 2 +- apps/web/src/components/voice/VoiceStage.tsx | 102 +++--- apps/web/src/lib/dictation.ts | 33 +- apps/web/src/lib/tts.ts | 55 +++- apps/web/src/pages/Shell.tsx | 305 ++++++++++++------ apps/web/src/pages/VoiceSettingsOverlay.tsx | 51 ++- apps/web/src/pages/shell/project-row.tsx | 29 +- apps/web/src/pages/shell/sidebar-header.tsx | 28 +- apps/web/src/pages/shell/transcript.tsx | 88 +++-- apps/web/src/pages/shell/use-sidebar-model.ts | 26 +- packages/adapters/src/executor-prompt.ts | 19 ++ packages/adapters/src/executor.ts | 4 + packages/core/src/speech-text.test.ts | 20 ++ packages/core/src/speech-text.ts | 37 ++- packages/db/src/main-agent.postgres.test.ts | 9 + packages/db/src/main-agent.ts | 37 ++- packages/db/src/spaces.ts | 40 ++- packages/testkit/src/authorization.test.ts | 42 ++- 25 files changed, 861 insertions(+), 328 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index bf00631..80a3e6a 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -135,6 +135,7 @@ import { createProjectGoalRepos, createRepos, createSpaceForMember, + DuplicateSpaceNameError, deleteEmptySpaceForMember, deleteUnreferencedCredentialSecret, ensureMainConversation, @@ -783,15 +784,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 +885,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; @@ -5623,13 +5635,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 +5652,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 +5693,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. diff --git a/apps/mobile/app/group-settings.tsx b/apps/mobile/app/group-settings.tsx index bd667a6..e2c7e43 100644 --- a/apps/mobile/app/group-settings.tsx +++ b/apps/mobile/app/group-settings.tsx @@ -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(null); + const [projectSpace, setProjectSpace] = useState(null); const [bots, setBots] = useState([]); const [name, setName] = useState(""); const [selected, setSelected] = useState([]); const [error, setError] = useState(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("bots/list"), + rpc("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 ( <> @@ -97,34 +155,27 @@ export default function GroupSettingsScreen() { fontSize: 16, }} /> - - {t("Members")} - - + {!isMainConversation ? ( + <> + + {t("Members")} + + + + ) : null} {error ? {error} : null} 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")} - - {t("Delete group")} - + {isProjectMain && projectSpace && canDeleteInboxSpace(projectSpace) ? ( + + {t("Delete space")} + + ) : !isMainConversation ? ( + + {t("Delete group")} + + ) : null} ); diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 361d4b4..220c6d5 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -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]} > diff --git a/apps/mobile/lib/inbox-spaces.test.ts b/apps/mobile/lib/inbox-spaces.test.ts index 42e9472..e0cb18a 100644 --- a/apps/mobile/lib/inbox-spaces.test.ts +++ b/apps/mobile/lib/inbox-spaces.test.ts @@ -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 " }); }); }); diff --git a/apps/mobile/lib/inbox-spaces.ts b/apps/mobile/lib/inbox-spaces.ts index 532c081..dbb44e8 100644 --- a/apps/mobile/lib/inbox-spaces.ts +++ b/apps/mobile/lib/inbox-spaces.ts @@ -24,22 +24,47 @@ export type InboxSpaceItem = | { type: "heading"; key: string; title: string; space?: InboxSpace }; export function canDeleteInboxSpace( - space: Pick, + space: Pick & Partial>, ) { - 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(); + const canonicalProjectIds = new Set(); + 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)) { diff --git a/apps/web/e2e/first-run-onboarding-ux.spec.ts b/apps/web/e2e/first-run-onboarding-ux.spec.ts index 73fea3b..d368d08 100644 --- a/apps/web/e2e/first-run-onboarding-ux.spec.ts +++ b/apps/web/e2e/first-run-onboarding-ux.spec.ts @@ -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", () => { diff --git a/apps/web/e2e/main-conversation.spec.ts b/apps/web/e2e/main-conversation.spec.ts index 1f68767..23ac2e8 100644 --- a/apps/web/e2e/main-conversation.spec.ts +++ b/apps/web/e2e/main-conversation.spec.ts @@ -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(page, "threads/get", { groupId }); diff --git a/apps/web/src/components/voice/VoiceLiveText.tsx b/apps/web/src/components/voice/VoiceLiveText.tsx index 1c0f262..bf69a03 100644 --- a/apps/web/src/components/voice/VoiceLiveText.tsx +++ b/apps/web/src/components/voice/VoiceLiveText.tsx @@ -17,7 +17,7 @@ export function VoiceLiveText({ text, reducedMotion }: { text: string; reducedMo return (

{captionWords(text).map((word, index) => ( diff --git a/apps/web/src/components/voice/VoiceStage.tsx b/apps/web/src/components/voice/VoiceStage.tsx index 31250c9..f099fb0 100644 --- a/apps/web/src/components/voice/VoiceStage.tsx +++ b/apps/web/src/components/voice/VoiceStage.tsx @@ -90,21 +90,52 @@ export function VoiceStage({ }, []); return ( -

{botName}

+
+
+ {transcriptLines.length ? ( +
    + {transcriptLines.map((line, index) => ( +
  • + + {line.role === "user" ? t`You` : (line.speakerName ?? botName)} + + + {line.text} + +
  • + ))} +
+ ) : ( +

+ Your conversation will appear here. +

+ )} +
+
+ {awarenessLabel && awarenessEvent ? ( -
+
-
-
+
+
- - {error ? ( -

- {error} -

- ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} +
-
+
{phaseLabel} @@ -187,39 +220,6 @@ export function VoiceStage({
- -
-
- {transcriptLines.length ? ( -
    - {transcriptLines.map((line, index) => ( -
  • - - {line.role === "user" ? t`You` : (line.speakerName ?? botName)} - - - {line.text} - -
  • - ))} -
- ) : ( -

- Your conversation will appear here. -

- )} -
-
-
+ ); } diff --git a/apps/web/src/lib/dictation.ts b/apps/web/src/lib/dictation.ts index f66150a..38c92a6 100644 --- a/apps/web/src/lib/dictation.ts +++ b/apps/web/src/lib/dictation.ts @@ -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 = { + 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 = ""; diff --git a/apps/web/src/lib/tts.ts b/apps/web/src/lib/tts.ts index 6ece0f1..3848fa6 100644 --- a/apps/web/src/lib/tts.ts +++ b/apps/web/src/lib/tts.ts @@ -1,5 +1,6 @@ import { ClauseSegmenter, readBoundedResponseBytes, speakable } from "@rakazo/core"; import { rpc, selectedSpaceId, withSpaceHeaders } from "./rpc.js"; +import { readUiLocale } from "./ui-locale.js"; export type SpeechStatus = "idle" | "preparing" | "speaking"; @@ -257,15 +258,57 @@ export class Speaker { window.speechSynthesis.cancel(); 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 langVoices = voices.filter((v) => + v.lang.toLowerCase().replace(/_/g, "-").startsWith(langPrefix), + ); + const wantsMale = /^dm_/i.test(voiceId); + const wantsFemale = /^df_/i.test(voiceId); + const maleHint = /yannick|martin|markus|stefan|viktor|hans|daniel|thomas|male/i; + const femaleHint = /anna|petra|helena|marlene|vicki|katja|siri|eloquence|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", diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index df30c10..627d0dc 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -97,7 +97,7 @@ import { InAppBrowser } from "../components/browser/InAppBrowser"; import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress"; import { DualSurfaceDivider } from "../components/DualSurfaceDivider"; import { MainInbox } from "../components/MainInbox"; -import { OoGroupStage, OoHeaderStatus, OoStage } from "../components/stage/OoStage"; +import { OoHeaderStatus } from "../components/stage/OoStage"; import { useDualSurfaceLayout } from "../hooks/useDualSurfaceLayout"; import { useSidebarResize } from "../hooks/useSidebarResize"; import { useWideLayout } from "../hooks/useWideLayout"; @@ -394,18 +394,6 @@ export function ShellPage() { const [screenHabit, setScreenHabit] = useState(0); const wideLayout = useWideLayout(); - const handleSelectLayoutMode = useCallback( - (mode: DualSurfaceLayoutMode) => { - dualSurface.setLayoutMode(mode); - if (mode === "stream") { - setPanel(null); - } else if (mode === "split" || mode === "canvas") { - setPanel((current) => current ?? "computer"); - } - }, - [dualSurface], - ); - const prevPanelRef = useRef(panel); useEffect(() => { if (!prevPanelRef.current && panel && dualSurface.layoutMode === "stream") { @@ -679,11 +667,13 @@ export function ShellPage() { activeGroup?.leadBotId ?? activeGroup?.members?.[0]?.botId ?? activeSpace.bots?.find((b) => b.kind === "main")?.id; - return ( + const found = (leadId ? bots.find((b) => b.id === leadId) : undefined) ?? bots.find((b) => b.kind === "main" && b.spaceId === activeSpace.id) ?? - bots.find((b) => b.spaceId === activeSpace.id) - ); + bots.find((b) => b.spaceId === activeSpace.id); + if (!found) return undefined; + const isLegacyName = found.name === "Chief" || found.name === "Projektkoordinator"; + return isLegacyName ? { ...found, name: "Projektmanager" } : found; }, [ inProject, activeSpace, @@ -693,16 +683,35 @@ export function ShellPage() { bots, ]); + const handleSelectLayoutMode = useCallback( + (mode: DualSurfaceLayoutMode) => { + dualSurface.setLayoutMode(mode); + if (mode === "stream") { + setPanel(null); + } else if (mode === "split" || mode === "canvas") { + setPanel((current) => current ?? (inProject ? null : "computer")); + } + }, + [dualSurface, inProject], + ); + + const autoOpenedProjectSpaceRef = useRef(null); useEffect(() => { - if (inProject && !dualSurface.isMobile && dualSurface.layoutMode === "stream") { + const projectSpaceId = inProject ? (activeSpace?.id ?? "__project__") : null; + if (!projectSpaceId) { + autoOpenedProjectSpaceRef.current = null; + return; + } + if (autoOpenedProjectSpaceRef.current === projectSpaceId) return; + autoOpenedProjectSpaceRef.current = projectSpaceId; + if (!dualSurface.isMobile && dualSurface.layoutMode === "stream") { dualSurface.setLayoutMode("split"); } - }, [inProject, dualSurface]); + }, [inProject, activeSpace?.id, dualSurface]); const activeProjectSection = activeGroup?.sectionId ? botSections.find((section) => section.id === activeGroup.sectionId) : undefined; - const showProjectTimeline = - inGroup && activeNavPillar !== "heute" && !projectSessionKey && Boolean(activeGroup?.sectionId); + const showProjectTimeline = false; const activePendingAttachments = useMemo( () => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id), [active?.id, groupId, inGroup, pendingAttachments], @@ -1953,9 +1962,57 @@ export function ShellPage() { status: member.status, })); }, [activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot, workingRuns]); + const projectGroupMembers: GroupAvatarMember[] = useMemo(() => { + const byId = new Map(); + const rawMembers = activeGroup?.members ?? activeSnapshot?.members ?? []; + for (const member of rawMembers) { + const bot = resolveSpeakerBot(member.botId); + const name = + member.botId === coordinatorBot?.id ? coordinatorBot.name : (member.name ?? bot?.name); + byId.set(member.botId, { + botId: member.botId, + name, + color: member.color || bot?.color || FALLBACK_BOT_COLOR, + status: member.status ?? bot?.status, + }); + } + if (inProject && activeSpace) { + for (const spaceBot of activeSpace.bots ?? []) { + if (!byId.has(spaceBot.id)) { + const name = spaceBot.id === coordinatorBot?.id ? coordinatorBot.name : spaceBot.name; + byId.set(spaceBot.id, { + botId: spaceBot.id, + name, + color: spaceBot.color || FALLBACK_BOT_COLOR, + status: spaceBot.status, + }); + } + } + for (const bot of bots) { + if (bot.spaceId === activeSpace.id && !byId.has(bot.id)) { + const name = bot.id === coordinatorBot?.id ? coordinatorBot.name : bot.name; + byId.set(bot.id, { + botId: bot.id, + name, + color: bot.color || FALLBACK_BOT_COLOR, + status: bot.status, + }); + } + } + } + return [...byId.values()]; + }, [ + activeGroup?.members, + activeSnapshot?.members, + activeSpace, + bots, + coordinatorBot, + inProject, + resolveSpeakerBot, + ]); - const encounterMode = Boolean((active || activeGroup) && !showProjectTimeline && !callOpen); - const voiceEncounterActive = encounterMode || callOpen || voiceEntry; + const encounterMode = false; + const voiceEncounterActive = callOpen || voiceEntry; const voiceCallBot = useMemo(() => { if (active) return active; if (!inGroup || !activeSnapshot) return undefined; @@ -1983,14 +2040,15 @@ export function ShellPage() { useEffect(() => { if (!voiceEntry || showProjectTimeline || callOpen) return; if (!voiceCallBot) return; - if (voiceStatus?.ready) { - voiceSetupPrompted.current = false; - setCallOpen(true); - return; + voiceSetupPrompted.current = false; + setCallOpen(true); + if (!voiceStatus?.ready) { + void rpc.voice + .connect({ provider: "kokoro", apiKey: "local-kokoro" }) + .then(() => rpc.voice.status()) + .then(setVoiceStatus) + .catch(() => undefined); } - if (voiceSetupPrompted.current) return; - voiceSetupPrompted.current = true; - openSettings("voice"); }, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]); const heuteActionCount = useHeuteActionCount(groups, botsRef); @@ -2005,9 +2063,15 @@ export function ShellPage() { }); const resolveTranscriptMemberName = useCallback( - (botId: string | undefined) => - memberName(transcriptMembers, botId) ?? (botId ? knownBots.get(botId)?.name : undefined), - [knownBots, transcriptMembers], + (botId: string | undefined) => { + if (botId && coordinatorBot && botId === coordinatorBot.id) { + return coordinatorBot.name; + } + return ( + memberName(transcriptMembers, botId) ?? (botId ? knownBots.get(botId)?.name : undefined) + ); + }, + [coordinatorBot, knownBots, transcriptMembers], ); const replyTargetName = activeReplyTarget ? activeReplyTarget.role === "user" @@ -2124,11 +2188,14 @@ export function ShellPage() { [groupId, navigate, searchParams, setSearchParams], ); const startVoiceEncounter = useCallback(() => { - if (voiceStatus?.ready) { - setCallOpen(true); - return; + setCallOpen(true); + if (!voiceStatus?.ready) { + void rpc.voice + .connect({ provider: "kokoro", apiKey: "local-kokoro" }) + .then(() => rpc.voice.status()) + .then(setVoiceStatus) + .catch(() => undefined); } - openSettings("voice"); }, [voiceStatus?.ready]); const closeProjectSession = useCallback(() => { @@ -3026,24 +3093,27 @@ export function ShellPage() { width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth, maxWidth: mobileSidebarOpen ? 316 : undefined, }} - className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:static md:z-auto md:translate-x-0 ${ + className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:relative md:z-auto md:translate-x-0 ${ mobileSidebarOpen ? "translate-x-0" : "-translate-x-full rtl:translate-x-full" } ${ botsSidebarCollapsed - ? "md:w-0 md:max-w-0 md:overflow-hidden md:border-e-0 md:opacity-0 md:pointer-events-none" + ? "md:w-0 md:max-w-0 md:overflow-hidden md:border-0 md:-me-3 md:opacity-0 md:pointer-events-none" : "" } ${!isResizingLeft ? "transition-[transform,width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`} > -
+ {!botsSidebarCollapsed ? ( +
+ ) : null} setMobileSidebarOpen(false)} + onCollapseSidebar={() => setBotsSidebarCollapsedPref(true)} bots={bots} onCreateBot={() => setPanel("create")} onOpenBot={(id) => navigate(`/app/${id}`)} @@ -3315,6 +3385,15 @@ export function ShellPage() { name={projectSpace.name} icon={projectSpace.icon} color={projectSpace.color} + members={(projectSpace.bots ?? []).map((b) => ({ + botId: b.id, + name: + b.name === "Chief" || b.name === "Projektkoordinator" + ? "Projektmanager" + : b.name, + color: b.color || FALLBACK_BOT_COLOR, + status: b.status, + }))} hasMenu={ projectSpace.canEdit === true || projectSpace.canDelete === true } @@ -3469,8 +3548,9 @@ export function ShellPage() { data-testid="bots-sidebar-edge" aria-label={botsSidebarCollapsed ? t`Show bots` : t`Hide bots`} aria-pressed={!botsSidebarCollapsed} + style={botsSidebarCollapsed ? undefined : { insetInlineStart: `${leftSidebarWidth + 8}px` }} className={`absolute inset-y-0 z-50 hidden w-2 cursor-ew-resize touch-none border-0 bg-transparent p-0 md:block ${ - botsSidebarCollapsed ? "start-0" : "start-[308px]" + botsSidebarCollapsed ? "start-0" : "" }`} onPointerDown={onBotsSidebarEdgePointerDown} onPointerMove={onBotsSidebarEdgePointerMove} @@ -3545,7 +3625,7 @@ export function ShellPage() { : showCanvasOnDesktop; const dividerElement = - !dualSurface.isMobile && dualSurface.layoutMode === "split" ? ( + !dualSurface.isMobile && showCanvasOnDesktop && dualSurface.layoutMode === "split" ? ( - {(!inGroup && active) || - (inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( + {inGroup && + (inProject || + activeGroup?.kind !== "main" || + projectGroupMembers.length > 1) ? ( + + ) : (!inGroup && active) || + (inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( ) : inGroup ? ( - + ) : null}
- - {inGroup && activeGroup?.kind === "main" - ? coordinatorBot?.name === "Chief" - ? "Projektkoordinator" - : (coordinatorBot?.name ?? - (activeGroup.name === "Chief" - ? "Projektkoordinator" - : activeGroup.name)) - : inGroup - ? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`) - : (active?.name ?? t`Choose a conversation`)} - - {(!inGroup && active) || - (inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( +
+ + {inGroup && activeGroup?.kind === "main" + ? inProject + ? (activeSpace?.name ?? + coordinatorBot?.name ?? + (activeGroup.name === "Chief" || + activeGroup.name === "Projektkoordinator" + ? "Projektmanager" + : activeGroup.name)) + : (coordinatorBot?.name ?? activeGroup.name) + : inGroup + ? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`) + : (active?.name ?? t`Choose a conversation`)} + + {inGroup && + (inProject || + activeGroup?.kind !== "main" || + projectGroupMembers.length > 1) && + projectGroupMembers.length > 0 ? ( +
+ {projectGroupMembers.slice(0, 5).map((member) => ( +
+ +
+ ))} +
+ ) : null} +
+ {inGroup && + (inProject || activeGroup?.kind !== "main") && + projectGroupMembers.length > 0 ? ( + + {projectGroupMembers + .map((m) => m.name) + .filter(Boolean) + .join(", ") || t`Group conversation`} + + ) : (!inGroup && active) || + (inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( ) : inGroup && activeGroup?.kind !== "main" ? ( - {t`Project · sessions & folders`} + {t`Group conversation`} ) : null}
@@ -3717,7 +3832,7 @@ export function ShellPage() {
{ + if (inProject && !active) { + if (showCanvasOnDesktop) { + setPanel(null); + dualSurface.setLayoutMode("stream"); + } else { + dualSurface.setLayoutMode("split"); + } + return; + } // The computer belongs to a bot; from a group, open its lead's computer. if (!active && groupComputerBotId) { navigate(`/app/${groupComputerBotId}`); @@ -3778,7 +3902,7 @@ export function ShellPage() { } }} data-active={ - panel === "computer" && dualSurface.layoutMode !== "stream" + (panel === "computer" || (inProject && !active)) && showCanvasOnDesktop ? "" : undefined } @@ -3829,25 +3953,6 @@ export function ShellPage() {
) : ( <> - {!callOpen && !inGroup && active && transcriptMessages.length === 0 ? ( - - ) : null} - {!callOpen && inGroup ? ( - - ) : null} {active && activeDelegation ? (
{ - 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 { if (localConnected) return true; @@ -133,6 +140,9 @@ 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 { @@ -142,7 +152,12 @@ export function VoiceSettingsOverlay({ 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 +176,7 @@ export function VoiceSettingsOverlay({ setError(null); setNotice(null); try { + let chosenVoice = voiceId; if (next) { const connected = await ensureLocalVoice(); if (!connected) { @@ -168,16 +184,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 +222,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 +267,7 @@ export function VoiceSettingsOverlay({ {autoSpeak ? Can speak : Does not speak}

- Your Oo reads replies aloud when this is on. + Your Oo talks with you naturally when this is on.

{name} + {members && members.length > 0 ? ( +
+ {members.slice(0, 3).map((member) => ( +
+ +
+ ))} +
+ ) : null} {needsYou > 0 ? (
); })} - {running && !encounterMode ? ( + {(running || messages.some((m) => m.id.startsWith("progress:"))) && !encounterMode ? (
0) || message.blocks.some((b) => b.kind === "steps"); const visibleNarrationBlocks = message.blocks - .filter((block) => !isToolActivityBlock(block)) + .filter((block) => !isToolActivityBlock(block) && block.kind !== "progress") .filter((block) => { // Discard broken, truncated text tokens like "Let" or "Ke" when steps exist on this message if (hasSteps && block.kind === "text") { @@ -944,14 +950,18 @@ const MessageView = memo(function MessageView({ const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null); const messageContext = ( <> - {effectiveSpeakerName && message.role === "bot" && !encounterMode ? ( -
-
-
+ {effectiveSpeakerName && message.role === "bot" ? ( +
+
-
+ {new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", @@ -974,16 +984,24 @@ const MessageView = memo(function MessageView({ })}
- ) : effectiveSpeakerName && message.role === "bot" && encounterMode ? ( -
- {effectiveSpeakerName} - · -
+ ) : isProjectSpace ? ( +
+ No runs yet +
) : null}
); } + +/** + * 1:1 Agent overview in the side panel: agent identity & status, active and recent + * task runs for this specific agent, and its scheduled routines. + */ +export function AgentOverview({ + bot, + running = false, + routines = [], + runningRoutineId, + togglingRoutineId, + onToggleRoutineActive, + onOpenRoutine, + onAddRoutine, + onStopRun, + onOpenRun, + onOpenSettings, +}: { + bot: Bot; + running?: boolean; + routines?: readonly Routine[]; + runningRoutineId?: string | null; + togglingRoutineId?: string | null; + onToggleRoutineActive?: (routine: Routine, active: boolean) => void; + onOpenRoutine?: (routine: Routine) => void; + onAddRoutine?: () => void; + onStopRun?: () => void; + onOpenRun: (run: RunActivityRow) => void; + onOpenSettings?: () => void; +}) { + const { t } = useLingui(); + const { active: allActiveRuns, recent: allRecentRuns } = useRunActivity(); + + const botActiveRuns = allActiveRuns.filter((run) => run.botId === bot.id); + const botRecentRuns = allRecentRuns.filter((run) => run.botId === bot.id); + const needsYouCount = botActiveRuns.filter((run) => isNeedsYouStatus(run.status)).length; + const isWorking = running || botActiveRuns.length > 0; + + return ( +
+ {/* Agent Identity & Status Card */} +
+
+
+
+ +
+
+
+ + {bot.name} + + {bot.title ? ( + + {bot.title} + + ) : null} +
+
+ {needsYouCount > 0 ? ( + + Needs you + + ) : isWorking ? ( + + Working + + ) : ( + + Ready + + )} +
+
+
+ + {onOpenSettings ? ( + + ) : null} +
+ + {bot.description ? ( +

+ {bot.description} +

+ ) : null} +
+ + {/* Agent Runs & Tasks */} +
+
+ + Run history + +
+ + {botActiveRuns.length > 0 ? ( +
+
+ Now +
+ {botActiveRuns.map((run) => ( + onOpenRun(run)} /> + ))} +
+ ) : null} + + {botRecentRuns.length > 0 ? ( +
+ {botActiveRuns.length > 0 ? ( +
+ Recent +
+ ) : null} + {botRecentRuns.slice(0, 8).map((run) => ( + onOpenRun(run)} /> + ))} +
+ ) : botActiveRuns.length === 0 ? ( +
+ No runs yet +
+ ) : null} +
+ + {/* Scheduled Routines for this 1:1 Agent */} +
+
+ + Routines + + {onAddRoutine ? ( + + ) : null} +
+ + {routines.length > 0 ? ( +
+ {routines.map((routine) => ( + onToggleRoutineActive?.(routine, next)} + onOpen={() => onOpenRoutine?.(routine)} + onStop={() => onStopRun?.()} + /> + ))} +
+ ) : ( +
+ + + On a schedule + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/shell/side-panel-header.tsx b/apps/web/src/pages/shell/side-panel-header.tsx index f2525b7..6c3cecc 100644 --- a/apps/web/src/pages/shell/side-panel-header.tsx +++ b/apps/web/src/pages/shell/side-panel-header.tsx @@ -42,8 +42,10 @@ export function SidePanelHeader({ {panel === "settings" ? ( Settings - ) : inProject && (!panel || activeTab === "overview") ? ( + ) : inProject && activeTab === "overview" ? ( Project overview + ) : !inProject && activeTab === "overview" ? ( + Overview ) : ( Living Canvas )} @@ -112,33 +114,31 @@ export function SidePanelHeader({
{/* Surface Switcher Tabs */} - {active && panel !== "settings" && onSelectTab ? ( + {active && onSelectTab ? (
- {inProject ? ( - - ) : null} +
); })} - {(running || messages.some((m) => m.id.startsWith("progress:"))) && !encounterMode ? ( -
- -
- ) : null}
{quoteDraft ? ( From f820c75632e285e1b584e709fca6490bb2c0242e Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:56:59 +0200 Subject: [PATCH 04/25] feat: persist unsent composer drafts across chat and page switches --- apps/mobile/app/thread.tsx | 70 +++++-- apps/web/src/pages/Shell.tsx | 27 ++- apps/web/src/pages/shell/composer.tsx | 46 +++- packages/core/src/composer-drafts.test.ts | 176 ++++++++++++++++ packages/core/src/composer-drafts.ts | 244 ++++++++++++++++++++++ packages/core/src/index.ts | 1 + 6 files changed, 534 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/composer-drafts.test.ts create mode 100644 packages/core/src/composer-drafts.ts diff --git a/apps/mobile/app/thread.tsx b/apps/mobile/app/thread.tsx index efc92ba..82feb24 100644 --- a/apps/mobile/app/thread.tsx +++ b/apps/mobile/app/thread.tsx @@ -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,14 @@ import { liveScreenRunId, mentionChipKey, projectMessageReactions, + 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 +287,8 @@ function Thread() { const mentionBotsAppliedGeneration = useRef(0); const readVisibleTarget = useRef(null); const threadKey = groupId ?? botId; + const draftKey = composerDraftThreadKey({ botId, groupId }); + const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]); const [threadScrollState, setThreadScrollState] = useState(() => scrollBehavior.current.state(), ); @@ -310,7 +310,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(null); const activeThreadId = useRef(undefined); - const [draft, setDraft] = useState(""); + const [draft, setDraft] = useState(() => initialDraft.text); const [mentionQuery, setMentionQuery] = useState(null); const [slashQuery, setSlashQuery] = useState(null); const [agentSkills, setAgentSkills] = useState([]); @@ -325,8 +325,12 @@ function Thread() { connectionId?: string; }> >([]); - const [selectedMentions, setSelectedMentions] = useState([]); - const [selectedSkill, setSelectedSkill] = useState(null); + const [selectedMentions, setSelectedMentions] = useState( + () => initialDraft.selectedMentions, + ); + const [selectedSkill, setSelectedSkill] = useState( + () => initialDraft.selectedSkill, + ); const [pendingAttachments, setPendingAttachments] = useState([]); const [replyTarget, setReplyTarget] = useState(null); const [attachmentNotice, setAttachmentNotice] = useState(null); @@ -1002,13 +1006,27 @@ 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); @@ -1053,6 +1071,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 +1101,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(), @@ -1111,6 +1138,7 @@ function Thread() { ); } const clearOriginComposer = () => { + clearComposerDraft(originDraftKey); setPendingAttachments((current) => current.filter((attachment) => attachment.threadKey !== originThreadKey), ); @@ -1313,6 +1341,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 +1352,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.")), diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index a917c5d..45291fa 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -13,6 +13,7 @@ import type { ImportBotInput, MainConversationRef, Me, + MessageReaction, Routine, RunStatus, Space, @@ -22,16 +23,19 @@ import type { ThreadSnapshot, VoiceStatus, } from "@rakazo/contracts"; -import { - ATTACHMENT_MAX_COUNT, - type MessageReaction, - normalizeCreateBotProfile, -} from "@rakazo/contracts"; -import type { OptimisticMessage } from "@rakazo/core"; +import { ATTACHMENT_MAX_COUNT, normalizeCreateBotProfile } from "@rakazo/contracts"; +import type { + ComposerMention, + OptimisticMessage, + PaneState, + WorkspaceArtifact, + WorkspaceFocus, +} from "@rakazo/core"; import { applyWorkspaceFocus, attachmentsForThread, - type ComposerMention, + clearComposerDraft, + composerDraftThreadKey, cronFromPreset, deriveWorkspaceFocus, inferAttachmentMimeType, @@ -41,7 +45,6 @@ import { liveScreenRunId, matchVoiceByName, OptimisticInputQueue, - type PaneState, parseVoiceChatCommand, projectThreads, recordScreenPaneChoice, @@ -53,8 +56,6 @@ import { speechFromBlocks, unconfirmedOptimistic, userVisibleMessages, - type WorkspaceArtifact, - type WorkspaceFocus, } from "@rakazo/core"; import { BotAvatar, @@ -4057,6 +4058,10 @@ export function ShellPage() { {(active || activeGroup) && !showProjectTimeline ? ( setDeleteTarget(null)} onConfirm={async (deleteMemories) => { await rpc.bots.remove({ botId: deleteTarget.id, deleteMemories }); + clearComposerDraft(composerDraftThreadKey({ botId: deleteTarget.id })); setDeleteTarget(null); setPanel(null); await refreshBots(true); @@ -4829,6 +4835,7 @@ export function ShellPage() { onCancel={() => setDeleteGroupTarget(null)} onConfirm={async () => { await rpc.groups.remove({ groupId: deleteGroupTarget.id }); + clearComposerDraft(composerDraftThreadKey({ groupId: deleteGroupTarget.id })); setDeleteGroupTarget(null); setPanel(null); await refreshBots(true); diff --git a/apps/web/src/pages/shell/composer.tsx b/apps/web/src/pages/shell/composer.tsx index 33c7379..3c7a725 100644 --- a/apps/web/src/pages/shell/composer.tsx +++ b/apps/web/src/pages/shell/composer.tsx @@ -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, @@ -48,6 +51,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, @@ -77,6 +81,7 @@ export const Composer = memo(function Composer({ onConnectModel, onAutopilot, }: { + draftKey?: string; running: boolean; disabled?: boolean; pendingAttachments: PendingAttachment[]; @@ -109,13 +114,37 @@ export const Composer = memo(function Composer({ onAutopilot?: (goal: string, until?: string) => Promise; }) { 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(null); const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0); const [slashQuery, setSlashQuery] = useState(null); - const [selectedSkill, setSelectedSkill] = useState(null); - const [selectedMentions, setSelectedMentions] = useState([]); + const [selectedSkill, setSelectedSkill] = useState( + () => initialDraft.selectedSkill, + ); + const [selectedMentions, setSelectedMentions] = useState( + () => 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(null); const runErrorRef = useRef(null); const submitFingerprint = useRef(null); @@ -265,6 +294,11 @@ export const Composer = memo(function Composer({ function runSlashAction(action: SlashActionId) { setDraft(""); setSlashQuery(null); + writeComposerDraft(draftKey, { + text: "", + selectedSkill, + selectedMentions, + }); onSlashAction?.(action); } @@ -331,6 +365,7 @@ export const Composer = memo(function Composer({ ); if (submitFingerprint.current === fingerprint) return; submitFingerprint.current = fingerprint; + clearComposerDraft(draftKey); setDraft(""); setMentionQuery(null); setMentionHighlightIndex(0); @@ -665,6 +700,11 @@ export const Composer = memo(function Composer({ onStart={async (goal, until) => { await onAutopilot(goal, until); setDraft(""); + writeComposerDraft(draftKey, { + text: "", + selectedSkill, + selectedMentions, + }); }} /> ) : null} diff --git a/packages/core/src/composer-drafts.test.ts b/packages/core/src/composer-drafts.test.ts new file mode 100644 index 0000000..8fdce7e --- /dev/null +++ b/packages/core/src/composer-drafts.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { ComposerDraftStorage } from "./composer-drafts.js"; +import { + COMPOSER_DRAFT_LIMIT, + COMPOSER_DRAFT_STORAGE_KEY, + clearComposerDraft, + composerDraftThreadKey, + EMPTY_COMPOSER_DRAFT, + isComposerDraftEmpty, + readComposerDraft, + resetMemoryComposerDrafts, + writeComposerDraft, +} from "./composer-drafts.js"; + +function createFakeStorage(): ComposerDraftStorage & { map: Map } { + const map = new Map(); + return { + map, + getItem(key: string) { + return map.get(key) ?? null; + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + removeItem(key: string) { + map.delete(key); + }, + }; +} + +describe("composer-drafts", () => { + beforeEach(() => { + resetMemoryComposerDrafts(); + }); + + it("derives thread keys for bot and group conversations", () => { + expect(composerDraftThreadKey({ botId: "bot-1" })).toBe("bot:bot-1"); + expect(composerDraftThreadKey({ groupId: "grp-1", botId: "bot-1" })).toBe("group:grp-1"); + expect(composerDraftThreadKey({ botId: " ", groupId: null })).toBeUndefined(); + }); + + it("detects empty vs non-empty drafts", () => { + expect(isComposerDraftEmpty(EMPTY_COMPOSER_DRAFT)).toBe(true); + expect( + isComposerDraftEmpty({ + text: "Weiter an der Aufgabe schreiben", + selectedSkill: null, + selectedMentions: [], + }), + ).toBe(false); + expect( + isComposerDraftEmpty({ + text: "", + selectedSkill: { + id: "skill-1", + name: "research", + description: "Research skill", + source: "user", + readOnly: false, + }, + selectedMentions: [], + }), + ).toBe(false); + }); + + it("persists and restores drafts across thread and page switches independently", () => { + const storage = createFakeStorage(); + const botKey = composerDraftThreadKey({ botId: "alpha" }); + const groupKey = composerDraftThreadKey({ groupId: "team-room" }); + + writeComposerDraft( + botKey, + { + text: "Entwurf im ersten Chat", + selectedSkill: { + id: "sk-1", + name: "summarize", + description: "Summarize notes", + source: "builtin", + readOnly: true, + }, + selectedMentions: [ + { + kind: "bot", + id: "helper-bot", + name: "Helper", + color: "#112233", + }, + ], + }, + storage, + ); + + writeComposerDraft( + groupKey, + { + text: "Andere Nachricht in der Gruppe", + selectedSkill: null, + selectedMentions: [], + }, + storage, + ); + + expect(readComposerDraft(botKey, storage)).toEqual({ + text: "Entwurf im ersten Chat", + selectedSkill: { + id: "sk-1", + name: "summarize", + description: "Summarize notes", + source: "builtin", + readOnly: true, + }, + selectedMentions: [ + { + kind: "bot", + id: "helper-bot", + name: "Helper", + color: "#112233", + }, + ], + }); + expect(readComposerDraft(groupKey, storage)).toEqual({ + text: "Andere Nachricht in der Gruppe", + selectedSkill: null, + selectedMentions: [], + }); + + clearComposerDraft(botKey, storage); + expect(readComposerDraft(botKey, storage)).toEqual(EMPTY_COMPOSER_DRAFT); + expect(readComposerDraft(groupKey, storage).text).toBe("Andere Nachricht in der Gruppe"); + + clearComposerDraft(groupKey, storage); + expect(storage.map.has(COMPOSER_DRAFT_STORAGE_KEY)).toBe(false); + }); + + it("evicts oldest drafts beyond COMPOSER_DRAFT_LIMIT", () => { + const storage = createFakeStorage(); + for (let index = 0; index < COMPOSER_DRAFT_LIMIT + 5; index += 1) { + writeComposerDraft( + `bot:b-${index}`, + { + text: `Draft ${index}`, + selectedSkill: null, + selectedMentions: [], + }, + storage, + ); + } + + expect(readComposerDraft("bot:b-0", storage)).toEqual(EMPTY_COMPOSER_DRAFT); + expect(readComposerDraft("bot:b-4", storage)).toEqual(EMPTY_COMPOSER_DRAFT); + expect(readComposerDraft("bot:b-5", storage).text).toBe("Draft 5"); + expect(readComposerDraft(`bot:b-${COMPOSER_DRAFT_LIMIT + 4}`, storage).text).toBe( + `Draft ${COMPOSER_DRAFT_LIMIT + 4}`, + ); + }); + + it("falls back to in-memory storage when persistent storage is unavailable", () => { + const key = composerDraftThreadKey({ botId: "mobile-bot" }); + writeComposerDraft( + key, + { + text: "Mobile Entwurf bleibt beim Tab-Wechsel erhalten", + selectedSkill: null, + selectedMentions: [], + }, + null, + ); + + expect(readComposerDraft(key, null).text).toBe( + "Mobile Entwurf bleibt beim Tab-Wechsel erhalten", + ); + clearComposerDraft(key, null); + expect(readComposerDraft(key, null)).toEqual(EMPTY_COMPOSER_DRAFT); + }); +}); diff --git a/packages/core/src/composer-drafts.ts b/packages/core/src/composer-drafts.ts new file mode 100644 index 0000000..2665d7f --- /dev/null +++ b/packages/core/src/composer-drafts.ts @@ -0,0 +1,244 @@ +import type { AgentSkillCatalogEntry } from "@rakazo/contracts"; +import type { ComposerMention, ComposerMentionKind } from "./composer-mentions.js"; +import { COMPOSER_MENTION_KINDS } from "./composer-mentions.js"; + +export const COMPOSER_DRAFT_STORAGE_KEY = "rakazo:composer-drafts:v1"; +export const COMPOSER_DRAFT_LIMIT = 50; + +export type ComposerDraftStorage = Pick; + +export type ComposerDraftState = { + text: string; + selectedSkill: AgentSkillCatalogEntry | null; + selectedMentions: ComposerMention[]; +}; + +export const EMPTY_COMPOSER_DRAFT: ComposerDraftState = { + text: "", + selectedSkill: null, + selectedMentions: [], +}; + +type StoredComposerDraftEntry = { + key: string; + text: string; + selectedSkill: AgentSkillCatalogEntry | null; + selectedMentions: ComposerMention[]; +}; + +const memoryDrafts = new Map(); + +function browserStorage(): ComposerDraftStorage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +function cloneDraft(draft: ComposerDraftState): ComposerDraftState { + return { + text: draft.text, + selectedSkill: draft.selectedSkill ? { ...draft.selectedSkill } : null, + selectedMentions: draft.selectedMentions.map((mention) => ({ ...mention })), + }; +} + +function isSkillSource(value: unknown): value is AgentSkillCatalogEntry["source"] { + return value === "user" || value === "builtin" || value === "plugin"; +} + +function sanitizeSkill(value: unknown): AgentSkillCatalogEntry | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if ( + typeof record.id !== "string" || + !record.id || + typeof record.name !== "string" || + !record.name || + typeof record.description !== "string" || + !isSkillSource(record.source) || + typeof record.readOnly !== "boolean" + ) { + return null; + } + return { + id: record.id, + name: record.name, + description: record.description, + source: record.source, + readOnly: record.readOnly, + }; +} + +function isComposerMentionKind(value: unknown): value is ComposerMentionKind { + return typeof value === "string" && (COMPOSER_MENTION_KINDS as readonly string[]).includes(value); +} + +function sanitizeMention(value: unknown): ComposerMention | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if ( + !isComposerMentionKind(record.kind) || + typeof record.id !== "string" || + !record.id || + typeof record.name !== "string" || + !record.name + ) { + return null; + } + const mention: ComposerMention = { + kind: record.kind, + id: record.id, + name: record.name, + }; + if (typeof record.subtitle === "string") mention.subtitle = record.subtitle; + if (typeof record.color === "string") mention.color = record.color; + if (typeof record.botId === "string") mention.botId = record.botId; + if (typeof record.connectionId === "string") mention.connectionId = record.connectionId; + if (record.authStatus === "connected" || record.authStatus === "needs_auth") { + mention.authStatus = record.authStatus; + } + return mention; +} + +function sanitizeDraft(draft: ComposerDraftState): ComposerDraftState { + const text = typeof draft.text === "string" ? draft.text : ""; + const selectedSkill = sanitizeSkill(draft.selectedSkill); + const selectedMentions = Array.isArray(draft.selectedMentions) + ? draft.selectedMentions + .map((item) => sanitizeMention(item)) + .filter((item): item is ComposerMention => item !== null) + : []; + return { text, selectedSkill, selectedMentions }; +} + +function readStoredEntries(storage: ComposerDraftStorage): StoredComposerDraftEntry[] { + try { + const raw = storage.getItem(COMPOSER_DRAFT_STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const entries: StoredComposerDraftEntry[] = []; + for (const item of parsed) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + const key = typeof record.key === "string" ? record.key.trim() : ""; + if (!key) continue; + const draft = sanitizeDraft({ + text: typeof record.text === "string" ? record.text : "", + selectedSkill: sanitizeSkill(record.selectedSkill), + selectedMentions: Array.isArray(record.selectedMentions) + ? (record.selectedMentions as ComposerMention[]) + : [], + }); + if (isComposerDraftEmpty(draft)) continue; + entries.push({ key, ...draft }); + } + return entries; + } catch { + return []; + } +} + +function setMemoryDraft(key: string, draft: ComposerDraftState): void { + memoryDrafts.delete(key); + if (isComposerDraftEmpty(draft)) return; + memoryDrafts.set(key, cloneDraft(draft)); + while (memoryDrafts.size > COMPOSER_DRAFT_LIMIT) { + const oldest = memoryDrafts.keys().next().value; + if (oldest === undefined) break; + memoryDrafts.delete(oldest); + } +} + +export function composerDraftThreadKey(target: { + botId?: string | null; + groupId?: string | null; +}): string | undefined { + const groupId = target.groupId?.trim(); + if (groupId) return `group:${groupId}`; + const botId = target.botId?.trim(); + if (botId) return `bot:${botId}`; + return undefined; +} + +export function isComposerDraftEmpty(draft: ComposerDraftState): boolean { + return ( + draft.text.length === 0 && draft.selectedSkill === null && draft.selectedMentions.length === 0 + ); +} + +export function resetMemoryComposerDrafts(): void { + memoryDrafts.clear(); +} + +export function readComposerDraft( + threadKey: string | null | undefined, + storage?: ComposerDraftStorage | null, +): ComposerDraftState { + const key = threadKey?.trim(); + if (!key) return cloneDraft(EMPTY_COMPOSER_DRAFT); + + const resolvedStorage = storage === undefined ? browserStorage() : storage; + if (resolvedStorage) { + const entries = readStoredEntries(resolvedStorage); + const match = entries.find((entry) => entry.key === key); + if (match) { + return cloneDraft({ + text: match.text, + selectedSkill: match.selectedSkill, + selectedMentions: match.selectedMentions, + }); + } + if (storage !== undefined) { + return cloneDraft(EMPTY_COMPOSER_DRAFT); + } + } + + const fromMemory = memoryDrafts.get(key); + return fromMemory ? cloneDraft(fromMemory) : cloneDraft(EMPTY_COMPOSER_DRAFT); +} + +export function writeComposerDraft( + threadKey: string | null | undefined, + draft: ComposerDraftState, + storage?: ComposerDraftStorage | null, +): void { + const key = threadKey?.trim(); + if (!key) return; + + const sanitized = sanitizeDraft(draft); + if (storage === undefined || storage === null) { + setMemoryDraft(key, sanitized); + } + + const resolvedStorage = storage === undefined ? browserStorage() : storage; + if (!resolvedStorage) return; + + try { + const remaining = readStoredEntries(resolvedStorage).filter((entry) => entry.key !== key); + if (isComposerDraftEmpty(sanitized)) { + if (remaining.length === 0) { + resolvedStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + } else { + resolvedStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, JSON.stringify(remaining)); + } + return; + } + const nextEntries: StoredComposerDraftEntry[] = [ + { key, ...cloneDraft(sanitized) }, + ...remaining, + ].slice(0, COMPOSER_DRAFT_LIMIT); + resolvedStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, JSON.stringify(nextEntries)); + } catch { + // Keep in-memory draft behavior when persistent storage is unavailable or full. + } +} + +export function clearComposerDraft( + threadKey: string | null | undefined, + storage?: ComposerDraftStorage | null, +): void { + writeComposerDraft(threadKey, EMPTY_COMPOSER_DRAFT, storage); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0c8822d..07dab2a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export * from "./cards.js"; export * from "./cloud-agent.js"; export * from "./coach.js"; export * from "./compose-update.js"; +export * from "./composer-drafts.js"; export * from "./composer-mention-picker.js"; export * from "./composer-mentions.js"; export * from "./composer-slash.js"; From ac9fcce0fb8f659565cc611e792a2cdca4291a9e Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:29:31 +0200 Subject: [PATCH 05/25] feat(web): show routines with quick toggle in project overview sidebar --- apps/web/src/locales/de/messages.po | 48 ++-- apps/web/src/locales/en/messages.po | 48 ++-- apps/web/src/locales/es/messages.po | 46 ++-- apps/web/src/locales/hi/messages.po | 46 ++-- apps/web/src/locales/ko/messages.po | 46 ++-- apps/web/src/locales/pt-BR/messages.po | 46 ++-- apps/web/src/locales/ru/messages.po | 46 ++-- apps/web/src/locales/tr/messages.po | 46 ++-- apps/web/src/locales/zh-CN/messages.po | 46 ++-- apps/web/src/pages/Shell.tsx | 228 +++++++++++++++--- .../src/pages/shell/project-overview.test.tsx | 142 +++++++++++ apps/web/src/pages/shell/project-overview.tsx | 66 ++++- 12 files changed, 680 insertions(+), 174 deletions(-) create mode 100644 apps/web/src/pages/shell/project-overview.test.tsx diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index c315583..48fbb68 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -386,10 +386,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" @@ -549,6 +545,7 @@ 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 +734,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" @@ -1031,10 +1036,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" @@ -2983,10 +2984,11 @@ 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" @@ -3174,6 +3176,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" @@ -3899,6 +3905,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 +3964,7 @@ msgid "Notifications" msgstr "Benachrichtigungen" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Now" msgstr "Jetzt" @@ -4288,10 +4296,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 +4403,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 +4420,7 @@ msgid "Recall only" msgstr "Nur abrufen" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Recent" msgstr "Kürzlich" @@ -4700,11 +4709,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 +4729,7 @@ msgid "Run at" msgstr "Ausführen um" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "Run history" msgstr "Ausführungsverlauf" @@ -5037,6 +5049,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" @@ -5398,6 +5411,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" @@ -5995,6 +6009,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 +6052,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,8 +6093,8 @@ 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." diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index 60c0cdd..760afa8 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -386,10 +386,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" @@ -549,6 +545,7 @@ msgid "Add Executor" msgstr "Add Executor" #: src/pages/ProjectSessionTimeline.tsx +#: src/pages/shell/project-overview.tsx msgid "Add goal" msgstr "Add goal" @@ -737,6 +734,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" @@ -1031,10 +1036,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" @@ -2983,10 +2984,11 @@ 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" @@ -3174,6 +3176,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" @@ -3899,6 +3905,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 +3964,7 @@ msgid "Notifications" msgstr "Notifications" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Now" msgstr "Now" @@ -4288,10 +4296,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 +4403,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 +4420,7 @@ msgid "Recall only" msgstr "Recall only" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Recent" msgstr "Recent" @@ -4700,11 +4709,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 +4729,7 @@ msgid "Run at" msgstr "Run at" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "Run history" msgstr "Run history" @@ -5037,6 +5049,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" @@ -5398,6 +5411,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" @@ -5995,6 +6009,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 +6052,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,8 +6093,8 @@ 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." diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 8c0fe44..7fb5ef1 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -386,10 +386,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" @@ -549,6 +545,7 @@ msgid "Add Executor" msgstr "Agregar Executor" #: src/pages/ProjectSessionTimeline.tsx +#: src/pages/shell/project-overview.tsx msgid "Add goal" msgstr "" @@ -737,6 +734,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" @@ -1031,10 +1036,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 "" @@ -2983,10 +2984,11 @@ 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 "" @@ -3174,6 +3176,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" @@ -3899,6 +3905,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 +3964,7 @@ msgid "Notifications" msgstr "Notificaciones" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Now" msgstr "Ahora" @@ -4288,10 +4296,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 +4403,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 +4420,7 @@ msgid "Recall only" msgstr "" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Recent" msgstr "Recientes" @@ -4700,11 +4709,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 +4729,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" @@ -5037,6 +5049,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" @@ -5398,6 +5411,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" @@ -5995,6 +6009,7 @@ msgid "Work channel open — go ahead." msgstr "" #: src/components/stage/OoStage.tsx +#: src/pages/shell/project-overview.tsx msgid "Working" msgstr "" @@ -6037,6 +6052,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,7 +6093,7 @@ 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 diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index e195083..e08d23e 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -386,10 +386,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" @@ -549,6 +545,7 @@ msgid "Add Executor" msgstr "Executor जोड़ें" #: src/pages/ProjectSessionTimeline.tsx +#: src/pages/shell/project-overview.tsx msgid "Add goal" msgstr "" @@ -737,6 +734,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 "उत्तर" @@ -1031,10 +1036,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 "" @@ -2983,10 +2984,11 @@ 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 "" @@ -3174,6 +3176,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" @@ -3899,6 +3905,7 @@ msgid "No results" msgstr "कोई परिणाम नहीं" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "No runs yet" msgstr "" @@ -3957,6 +3964,7 @@ msgid "Notifications" msgstr "सूचनाएं" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Now" msgstr "अभी" @@ -4288,10 +4296,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 +4403,10 @@ msgstr "" msgid "Ready" msgstr "" +#: src/pages/shell/project-overview.tsx +msgid "Ready" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Reasoning effort" msgstr "रीज़निंग प्रयास" @@ -4412,6 +4420,7 @@ msgid "Recall only" msgstr "" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Recent" msgstr "हाल के" @@ -4700,11 +4709,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 +4729,7 @@ msgid "Run at" msgstr "" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "Run history" msgstr "" @@ -5037,6 +5049,7 @@ msgstr "और दिखाएँ" msgid "Show password" msgstr "" +#: src/pages/shell/project-overview.tsx #: src/pages/shell/side-panel-header.tsx msgid "Show settings" msgstr "सेटिंग्स दिखाएं" @@ -5398,6 +5411,7 @@ msgstr "सिखाना जारी है। नया संदेश भ #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/bot-panel.tsx +#: src/pages/shell/project-overview.tsx msgid "Team" msgstr "टीम" @@ -5995,6 +6009,7 @@ msgid "Work channel open — go ahead." msgstr "" #: src/components/stage/OoStage.tsx +#: src/pages/shell/project-overview.tsx msgid "Working" msgstr "" @@ -6037,6 +6052,7 @@ msgstr "कल" #: src/components/UserProfileSettings.tsx #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx +#: src/pages/shell/transcript.tsx msgid "You" msgstr "आप" @@ -6077,7 +6093,7 @@ 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 diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index 512f3d4..3f2ad9d 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -386,10 +386,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" @@ -549,6 +545,7 @@ msgid "Add Executor" msgstr "Executor 추가" #: src/pages/ProjectSessionTimeline.tsx +#: src/pages/shell/project-overview.tsx msgid "Add goal" msgstr "" @@ -737,6 +734,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 "답변" @@ -1031,10 +1036,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 "" @@ -2983,10 +2984,11 @@ 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 "" @@ -3174,6 +3176,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" @@ -3899,6 +3905,7 @@ msgid "No results" msgstr "검색 결과가 없습니다" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "No runs yet" msgstr "아직 실행 기록이 없습니다" @@ -3957,6 +3964,7 @@ msgid "Notifications" msgstr "알림" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Now" msgstr "지금" @@ -4288,10 +4296,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 +4403,10 @@ msgstr "" msgid "Ready" msgstr "" +#: src/pages/shell/project-overview.tsx +msgid "Ready" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Reasoning effort" msgstr "추론 강도" @@ -4412,6 +4420,7 @@ msgid "Recall only" msgstr "" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Recent" msgstr "최근" @@ -4700,11 +4709,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 +4729,7 @@ msgid "Run at" msgstr "실행 시간" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "Run history" msgstr "실행 기록" @@ -5037,6 +5049,7 @@ msgstr "더 보기" msgid "Show password" msgstr "비밀번호 표시" +#: src/pages/shell/project-overview.tsx #: src/pages/shell/side-panel-header.tsx msgid "Show settings" msgstr "설정 보기" @@ -5398,6 +5411,7 @@ msgstr "작업을 가르치는 중입니다. 새 메시지를 보내려면 먼 #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/bot-panel.tsx +#: src/pages/shell/project-overview.tsx msgid "Team" msgstr "팀 공용" @@ -5995,6 +6009,7 @@ msgid "Work channel open — go ahead." msgstr "" #: src/components/stage/OoStage.tsx +#: src/pages/shell/project-overview.tsx msgid "Working" msgstr "" @@ -6037,6 +6052,7 @@ msgstr "어제" #: src/components/UserProfileSettings.tsx #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx +#: src/pages/shell/transcript.tsx msgid "You" msgstr "나" @@ -6077,7 +6093,7 @@ 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 diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index c828b0a..12d0927 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -386,10 +386,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" @@ -549,6 +545,7 @@ msgid "Add Executor" msgstr "Adicionar Executor" #: src/pages/ProjectSessionTimeline.tsx +#: src/pages/shell/project-overview.tsx msgid "Add goal" msgstr "" @@ -737,6 +734,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" @@ -1031,10 +1036,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 "" @@ -2983,10 +2984,11 @@ 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 "" @@ -3174,6 +3176,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" @@ -3899,6 +3905,7 @@ msgid "No results" msgstr "Nenhum resultado" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "No runs yet" msgstr "" @@ -3957,6 +3964,7 @@ msgid "Notifications" msgstr "Notificações" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Now" msgstr "Agora" @@ -4288,10 +4296,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 +4403,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 +4420,7 @@ msgid "Recall only" msgstr "" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Recent" msgstr "Recente" @@ -4700,11 +4709,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 +4729,7 @@ msgid "Run at" msgstr "" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "Run history" msgstr "" @@ -5037,6 +5049,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" @@ -5398,6 +5411,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" @@ -5995,6 +6009,7 @@ msgid "Work channel open — go ahead." msgstr "" #: src/components/stage/OoStage.tsx +#: src/pages/shell/project-overview.tsx msgid "Working" msgstr "" @@ -6037,6 +6052,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,7 +6093,7 @@ 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 diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index 60ca017..26eea64 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -386,10 +386,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" @@ -549,6 +545,7 @@ msgid "Add Executor" msgstr "Добавить Executor" #: src/pages/ProjectSessionTimeline.tsx +#: src/pages/shell/project-overview.tsx msgid "Add goal" msgstr "" @@ -737,6 +734,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 "Ответ" @@ -1031,10 +1036,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 "" @@ -2983,10 +2984,11 @@ 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 "Скрыть ботов" @@ -3174,6 +3176,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" @@ -3899,6 +3905,7 @@ msgid "No results" msgstr "Нет результатов" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "No runs yet" msgstr "Запусков пока нет" @@ -3957,6 +3964,7 @@ msgid "Notifications" msgstr "Уведомления" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Now" msgstr "Сейчас" @@ -4288,10 +4296,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 +4403,10 @@ msgstr "" msgid "Ready" msgstr "" +#: src/pages/shell/project-overview.tsx +msgid "Ready" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Reasoning effort" msgstr "" @@ -4412,6 +4420,7 @@ msgid "Recall only" msgstr "" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Recent" msgstr "Недавние" @@ -4700,11 +4709,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 +4729,7 @@ msgid "Run at" msgstr "Запустить в" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "Run history" msgstr "История запуска" @@ -5037,6 +5049,7 @@ msgstr "Показать ещё" msgid "Show password" msgstr "Показать пароль" +#: src/pages/shell/project-overview.tsx #: src/pages/shell/side-panel-header.tsx msgid "Show settings" msgstr "Показать настройки" @@ -5398,6 +5411,7 @@ msgstr "Обучение в процессе. Прекратите обучен #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/bot-panel.tsx +#: src/pages/shell/project-overview.tsx msgid "Team" msgstr "Команда" @@ -5995,6 +6009,7 @@ msgid "Work channel open — go ahead." msgstr "" #: src/components/stage/OoStage.tsx +#: src/pages/shell/project-overview.tsx msgid "Working" msgstr "" @@ -6037,6 +6052,7 @@ msgstr "Вчера" #: src/components/UserProfileSettings.tsx #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx +#: src/pages/shell/transcript.tsx msgid "You" msgstr "Вы" @@ -6077,7 +6093,7 @@ 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 diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index e3ccf9d..5cf6374 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -386,10 +386,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" @@ -549,6 +545,7 @@ msgid "Add Executor" msgstr "Executor ekle" #: src/pages/ProjectSessionTimeline.tsx +#: src/pages/shell/project-overview.tsx msgid "Add goal" msgstr "" @@ -737,6 +734,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" @@ -1031,10 +1036,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 "" @@ -2983,10 +2984,11 @@ 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 "" @@ -3174,6 +3176,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" @@ -3899,6 +3905,7 @@ msgid "No results" msgstr "Sonuç yok" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "No runs yet" msgstr "" @@ -3957,6 +3964,7 @@ msgid "Notifications" msgstr "Bildirimler" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Now" msgstr "Şimdi" @@ -4288,10 +4296,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 +4403,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 +4420,7 @@ msgid "Recall only" msgstr "" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Recent" msgstr "Son" @@ -4700,11 +4709,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 +4729,7 @@ msgid "Run at" msgstr "" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "Run history" msgstr "" @@ -5037,6 +5049,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" @@ -5398,6 +5411,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" @@ -5995,6 +6009,7 @@ msgid "Work channel open — go ahead." msgstr "" #: src/components/stage/OoStage.tsx +#: src/pages/shell/project-overview.tsx msgid "Working" msgstr "" @@ -6037,6 +6052,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,7 +6093,7 @@ 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 diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index 42d5df8..7463a5e 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -386,10 +386,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" @@ -549,6 +545,7 @@ msgid "Add Executor" msgstr "添加 Executor" #: src/pages/ProjectSessionTimeline.tsx +#: src/pages/shell/project-overview.tsx msgid "Add goal" msgstr "" @@ -737,6 +734,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 "回答" @@ -1031,10 +1036,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 "" @@ -2983,10 +2984,11 @@ 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 "" @@ -3174,6 +3176,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" @@ -3899,6 +3905,7 @@ msgid "No results" msgstr "无结果" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "No runs yet" msgstr "还没有运行记录" @@ -3957,6 +3964,7 @@ msgid "Notifications" msgstr "通知" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Now" msgstr "现在" @@ -4288,10 +4296,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 +4403,10 @@ msgstr "" msgid "Ready" msgstr "" +#: src/pages/shell/project-overview.tsx +msgid "Ready" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Reasoning effort" msgstr "推理强度" @@ -4412,6 +4420,7 @@ msgid "Recall only" msgstr "" #: src/pages/ActivityList.tsx +#: src/pages/shell/project-overview.tsx msgid "Recent" msgstr "最近" @@ -4700,11 +4709,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 +4729,7 @@ msgid "Run at" msgstr "运行时间" #: src/pages/RoutineEditor.tsx +#: src/pages/shell/project-overview.tsx msgid "Run history" msgstr "运行记录" @@ -5037,6 +5049,7 @@ msgstr "显示更多" msgid "Show password" msgstr "显示密码" +#: src/pages/shell/project-overview.tsx #: src/pages/shell/side-panel-header.tsx msgid "Show settings" msgstr "显示设置" @@ -5398,6 +5411,7 @@ msgstr "正在示范,发送新消息前请先停止示范。" #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/bot-panel.tsx +#: src/pages/shell/project-overview.tsx msgid "Team" msgstr "团队" @@ -5995,6 +6009,7 @@ msgid "Work channel open — go ahead." msgstr "" #: src/components/stage/OoStage.tsx +#: src/pages/shell/project-overview.tsx msgid "Working" msgstr "" @@ -6037,6 +6052,7 @@ msgstr "昨天" #: src/components/UserProfileSettings.tsx #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx +#: src/pages/shell/transcript.tsx msgid "You" msgstr "你" @@ -6077,7 +6093,7 @@ 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 diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 45291fa..678bf75 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -435,6 +435,9 @@ export function ShellPage() { } | null>(null); const [routines, setRoutines] = useState([]); const [routinesBotId, setRoutinesBotId] = useState(null); + const [projectRoutines, setProjectRoutines] = useState([]); + const [projectRoutinesSpaceId, setProjectRoutinesSpaceId] = useState(null); + const projectRoutinesRequest = useRef(0); const [routineActiveToggleId, setRoutineActiveToggleId] = useState(null); const routineActiveToggleRequest = useRef(0); const [taughtSkills, setTaughtSkills] = useState([]); @@ -718,6 +721,61 @@ export function ShellPage() { [active?.id, groupId, inGroup, pendingAttachments], ); const activeRoutines = !inGroup && routinesBotId === active?.id ? routines : []; + const projectBotIds = useMemo(() => { + if (!inProject || !activeSpace) return []; + const ids = new Set(); + if (coordinatorBot) ids.add(coordinatorBot.id); + for (const member of activeSpace.bots ?? []) ids.add(member.id); + for (const bot of bots) { + if (bot.spaceId === activeSpace.id) ids.add(bot.id); + } + return [...ids]; + }, [inProject, activeSpace, coordinatorBot, bots]); + const projectBotIdsRef = useRef([]); + projectBotIdsRef.current = projectBotIds; + const activeProjectSpaceIdRef = useRef( + inProject && activeSpace ? activeSpace.id : null, + ); + activeProjectSpaceIdRef.current = inProject && activeSpace ? activeSpace.id : null; + const refreshProjectRoutines = useCallback(async (extraBotIds?: readonly string[]) => { + const spaceId = activeProjectSpaceIdRef.current; + if (!spaceId) return; + const botIds = [...new Set([...projectBotIdsRef.current, ...(extraBotIds ?? [])])]; + if (botIds.length === 0) { + setProjectRoutines([]); + setProjectRoutinesSpaceId(spaceId); + return; + } + const request = ++projectRoutinesRequest.current; + const lists = await Promise.all( + botIds.map((id) => rpc.routines.list({ botId: id }).catch(() => null)), + ); + if (request !== projectRoutinesRequest.current || activeProjectSpaceIdRef.current !== spaceId) { + return; + } + const seen = new Set(); + const merged: Routine[] = []; + for (const list of lists) { + if (!list) continue; + for (const item of list) { + if (!seen.has(item.id)) { + seen.add(item.id); + merged.push(item); + } + } + } + setProjectRoutines(merged); + setProjectRoutinesSpaceId(spaceId); + }, []); + const projectBotIdsKey = projectBotIds.join(","); + useEffect(() => { + if (!inProject || !activeSpace) { + setProjectRoutines([]); + setProjectRoutinesSpaceId(null); + return; + } + void refreshProjectRoutines(); + }, [inProject, activeSpace?.id, projectBotIdsKey, refreshProjectRoutines]); const activeTaughtSkills = taughtSkillsBotId === active?.id ? taughtSkills : []; const recordingSkill = activeTaughtSkills.find((skill) => skill.status === "recording") ?? null; const routeBotId = useRef(botId); @@ -903,6 +961,11 @@ export function ShellPage() { commitComputer(null); setRoutines([]); setRoutinesBotId(null); + void refreshProjectRoutines( + [snap.leadBotId, ...(snap.members?.map((member) => member.botId) ?? [])].filter( + (botId): botId is string => Boolean(botId), + ), + ); // Keep the search-jump viewport; expandedHistoryThread merge still accepts live messages. if ( stickToEnd && @@ -962,6 +1025,7 @@ export function ShellPage() { if (routines) { setRoutines(routines); setRoutinesBotId(id); + void refreshProjectRoutines([id]); } if (skills) { setTaughtSkills(skills); @@ -1409,6 +1473,16 @@ export function ShellPage() { if (blocks.some((block) => block.kind === "project_created")) { void refreshBots().catch(() => undefined); } + if (blocks.some((block) => block.kind === "event")) { + void refreshProjectRoutines(); + } + } + if ( + event.type === "thread.message.updated" || + event.type === "routine.created" || + event.type === "routine.updated" + ) { + void refreshProjectRoutines(); } if (event.type === "run.waiting_input" || event.type === "computer.takeover.requested") { ooSound.play("need-you"); @@ -2689,7 +2763,10 @@ export function ShellPage() { const setRoutineActive = useCallback( async (routine: Routine, active: boolean) => { const botId = activeBotId.current; - if (!botId || routine.botId !== botId) return; + const inCurrentProject = + Boolean(activeProjectSpaceIdRef.current) && + projectBotIdsRef.current.includes(routine.botId); + if ((!botId || routine.botId !== botId) && !inCurrentProject) return; const request = ++routineActiveToggleRequest.current; const previousActive = routine.active; setRoutineActiveToggleId(routine.id); @@ -2698,16 +2775,32 @@ export function ShellPage() { ? current.map((item) => (item.id === routine.id ? { ...item, active } : item)) : current, ); + setProjectRoutines((current) => + current.map((item) => (item.id === routine.id ? { ...item, active } : item)), + ); try { const saved = await rpc.routines.update({ routineId: routine.id, active }); - if (routineActiveToggleRequest.current !== request || activeBotId.current !== botId) return; + if ( + routineActiveToggleRequest.current !== request || + (activeBotId.current !== botId && !activeProjectSpaceIdRef.current) + ) { + return; + } setRoutines((current) => routinesBotId === botId ? current.map((item) => (item.id === saved.id ? saved : item)) : current, ); + setProjectRoutines((current) => + current.map((item) => (item.id === saved.id ? saved : item)), + ); } catch { - if (routineActiveToggleRequest.current !== request || activeBotId.current !== botId) return; + if ( + routineActiveToggleRequest.current !== request || + (activeBotId.current !== botId && !activeProjectSpaceIdRef.current) + ) { + return; + } setRoutines((current) => routinesBotId === botId ? current.map((item) => @@ -2715,6 +2808,11 @@ export function ShellPage() { ) : current, ); + setProjectRoutines((current) => + current.map((item) => + item.id === routine.id ? { ...item, active: previousActive } : item, + ), + ); } finally { if (routineActiveToggleRequest.current === request) { setRoutineActiveToggleId((current) => (current === routine.id ? null : current)); @@ -4127,6 +4225,16 @@ export function ShellPage() { : inProject && !panel && canvasTab === "sandbox" ? "overview" : canvasTab; + const isOverlayPanel = + panel === "settings" || + panel === "routine" || + panel === "create" || + panel === "create-group" || + panel === "group-settings"; + const routineTargetBot = + (editingRoutine ? bots.find((b) => b.id === editingRoutine.botId) : undefined) ?? + active ?? + coordinatorBot; const canvasElement = (