Projects: one sidebar row with icon and color, coordinator overview (#190)

Each project is now a single sidebar row with its own icon and color that
opens the project's coordinator conversation. The whole row drags to
reorder. The row menu gains Customize for name, icon and color. Inside
the coordinator conversation, an overview lists the project's agent
threads that need the user, are running, or recently finished.


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

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-10 03:32:40 +02:00 committed by GitHub
parent 1c8d13a837
commit 6ace14c3c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1346 additions and 246 deletions

View File

@ -116,6 +116,8 @@ import {
expandSkillReferencesInPrompt,
hasMixedOneShotSchedule,
isOneShotRoutineCrons,
isProjectColor,
isProjectIcon,
isValidAvatarValue,
nextCronDateAcrossStrict,
} from "@rakazo/core";
@ -168,6 +170,7 @@ import {
selectSpaceModelPreference,
selectSpaceVoicePreference,
updateNotificationSettings,
updateSpaceForOwner,
wireProjectWorkspace,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
@ -755,6 +758,7 @@ export function createRouter(deps: RouterDeps) {
isDefault: false,
hasContent: false,
canDelete: true,
canEdit: true,
workspace,
bots: bots.map(spaceBotDto),
groups: groups.map(spaceGroupDto),
@ -776,6 +780,27 @@ export function createRouter(deps: RouterDeps) {
spaceId: conversation.spaceId,
};
}),
update: authed.spaces.update.handler(async ({ context, input }) => {
if (input.icon !== undefined && !isProjectIcon(input.icon)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
}
if (input.color !== undefined && !isProjectColor(input.color)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
}
try {
await updateSpaceForOwner(deps.prisma, { userId: context.actor.userId, ...input });
} catch (error) {
if (error instanceof SpaceNotFoundError) {
throw new ORPCError("NOT_FOUND", { message: error.message });
}
if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN");
if (error instanceof InvalidSpaceNameError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
return { ok: true as const };
}),
remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null;
let claimActive = false;
@ -5444,6 +5469,8 @@ async function spaceNavigationDto(
space: {
select: {
name: true,
icon: true,
color: true,
isDefault: true,
deletingAt: true,
workspacePath: true,
@ -5524,6 +5551,8 @@ async function spaceNavigationDto(
return {
id: membership.spaceId,
name: membership.space.name,
icon: membership.space.icon,
color: membership.space.color,
isDefault: membership.space.isDefault,
hasContent: spacesWithContent.has(membership.spaceId),
canDelete:
@ -5532,6 +5561,7 @@ async function spaceNavigationDto(
memberships.length > 1 &&
!spacesWithContent.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.
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
bots: spaceBots.map(spaceBotDto),

View File

@ -6,7 +6,7 @@ import {
type SpaceBot,
type SpaceGroup,
} from "@rakazo/contracts";
import { runChatTitle } from "@rakazo/core";
import { projectLook, runChatTitle } from "@rakazo/core";
import { botColors } from "@rakazo/ui-tokens";
import { Redirect, useFocusEffect, useRouter } from "expo-router";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -599,6 +599,7 @@ export default function Home() {
}}
style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
>
<ProjectTile space={item.space} />
<Text style={styles.spaceTitle}>{item.title}</Text>
</Pressable>
{canDeleteInboxSpace(item.space) ? (
@ -907,6 +908,16 @@ function ConversationRow({
);
}
function ProjectTile({ space }: { space: Pick<InboxSpace, "icon" | "color"> }) {
const styles = useThemedStyles(createHomeStyles);
const { icon, color } = projectLook(space);
return (
<View style={[styles.projectTile, { backgroundColor: color.hex }]}>
<NativeSymbol ios={icon.ios} android={icon.android as never} size={15} color={color.ink} />
</View>
);
}
function CircleButton({
children,
onPress,
@ -1192,6 +1203,15 @@ function createHomeStyles() {
spaceSelect: {
flex: 1,
minHeight: 44,
flexDirection: "row",
alignItems: "center",
gap: 10,
},
projectTile: {
width: 28,
height: 28,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
},
spaceTitle: {

View File

@ -12,7 +12,7 @@ import {
export type InboxSpace = Pick<
MobileSpace,
"id" | "name" | "isDefault" | "hasContent" | "canDelete" | "botSections"
"id" | "name" | "isDefault" | "hasContent" | "canDelete" | "botSections" | "icon" | "color"
> & {
bots: (MobileBot | SpaceBot)[];
groups: (MobileGroup | SpaceGroup)[];

View File

@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { completeOnboarding, routePatchRpc, rpc, signup } from "./helpers";
import { completeOnboarding, projectRow, routePatchRpc, rpc, signup } from "./helpers";
test("empty project space offers Start before the main conversation exists", async ({ page }) => {
const stamp = Date.now();
@ -40,8 +40,7 @@ test("empty project space offers Start before the main conversation exists", asy
await expect(mainStart).toBeVisible({ timeout: 30_000 });
await expect(mainStart).toContainText(/Start|Starten/i);
// The sidebar lists the project as one row; opening it starts the main conversation.
const projekte = page.locator(".sidebar-projekte");
const sidebarStart = projekte.getByTestId(`project-start-${side.id}`);
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
await expect(sidebarStart).toContainText(/Start|Starten/i);
await expect(projectRow(projekte, "Empty project")).toBeVisible({ timeout: 30_000 });
});

View File

@ -12,6 +12,11 @@ export function unterhaltungenOnly(page: Page) {
return page.locator(".sidebar-unterhaltungen").locator(":scope > *:not(.sidebar-projekte)");
}
/** A project's row in the sidebar; it opens the project's main conversation. */
export function projectRow(scope: Page | Locator, name: string) {
return scope.getByTestId(/^project-row-/).filter({ hasText: name });
}
export function isRealSandboxProvider(provider = process.env.SANDBOX_PROVIDER) {
return provider === "e2b" || provider === "daytona" || provider === "box";
}

View File

@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
projectRow,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
test("a project is one sidebar row with its own look, and its coordinator lists the delegated threads", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-coordinator-${stamp}@rakazo.test`, "password12", "Coordinator");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Garden");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await waitForChatComposer(page);
const row = projectRow(projekte, "Garden");
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toHaveAttribute("aria-current", "page");
await expect(row.locator("[data-project-icon]")).toHaveAttribute("data-project-icon", "folder");
// Name, icon and color change from the row's menu.
await row.click({ button: "right" });
await page.getByRole("menuitem", { name: "Customize" }).click();
const look = page.getByTestId("project-look-dialog");
await expect(look).toBeVisible();
await look.getByLabel("Name").fill("Garten");
await look.getByRole("radio", { name: "green" }).click();
await look.getByTestId("project-icon-leaf").click();
await captureScreenshot(page, testInfo, "project-look");
await look.getByRole("button", { name: "Save", exact: true }).click();
await expect(look).toBeHidden();
const renamed = projectRow(projekte, "Garten");
await expect(renamed.locator("[data-project-icon]")).toHaveAttribute("data-project-icon", "leaf");
await expect(renamed.locator("[data-project-icon]")).toHaveAttribute(
"data-project-color",
"green",
);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(renamed.locator("[data-project-icon]")).toHaveAttribute(
"data-project-icon",
"leaf",
{ timeout: 30_000 },
);
// The coordinator hands work to a specialist; the finished thread shows in its overview.
await waitForChatComposer(page);
await rpc(page, "bots/create", {
name: "Gardener",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("delegate to the bot named Gardener: plan the spring beds");
await composer.press("Enter");
const card = page.getByTestId("transcript").getByTestId("delegated-task-card");
await expect(card).toHaveAttribute("data-status", "done", { timeout: 60_000 });
const overview = page.getByTestId("project-overview");
const done = overview.getByRole("button", { name: /Done/ });
await expect(done).toBeVisible({ timeout: 30_000 });
await done.click();
await expect(overview.getByText("Gardener").first()).toBeVisible();
await captureScreenshot(page, testInfo, "project-overview");
});

View File

@ -1,9 +1,12 @@
import { expect, type Locator, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
function projectSpace(projekte: Locator, name: string) {
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
}
import { expect, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
projectRow,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
test("a new project opens straight into the conversation with its own main agent", async ({
page,
@ -26,9 +29,7 @@ test("a new project opens straight into the conversation with its own main agent
await waitForChatComposer(page);
// The project is one conversation, led by a main agent of its own.
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
name: "Chief, main conversation",
});
const projectEntry = projectRow(projekte, "Launch");
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;

View File

@ -1,21 +1,15 @@
import { expect, type Locator, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
openNewSpace,
projectRow,
routePatchRpc,
rpc,
signup,
unterhaltungenOnly,
} from "./helpers";
function projectSpace(projekte: Locator, name: string) {
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({
hasText: name,
exact: true,
});
}
test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
@ -42,11 +36,7 @@ test("Projects + opens new project dialog instead of group form", async ({ page
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
0,
);
await expect(
projectSpace(projekte, "Customer project").getByRole("button", {
name: "Chief, main conversation",
}),
).toBeVisible();
await expect(projectRow(projekte, "Customer project")).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
await captureScreenshot(page, testInfo, "new-project-main-conversation");
});
@ -118,14 +108,14 @@ test("spaces stay invisible by default and chat creation requires approval", asy
);
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
const supportSpace = projekte.locator('[data-sidebar-group^="project-space:"]').filter({
hasText: "Customer support",
});
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
const supportSpace = projectRow(projekte, "Customer support");
const supportSpaceId = (await supportSpace.getAttribute("data-testid"))?.replace(
/^project-row-/,
"",
);
expect(supportSpaceId).toBeTruthy();
// A project created from chat comes with its own main agent, reached through its conversation.
await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click();
// A project created from chat comes with its own main agent, reached through its row.
await supportSpace.click();
await page.waitForURL(/\/app\/g\/[^/]+$/);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
@ -134,7 +124,7 @@ test("spaces stay invisible by default and chat creation requires approval", asy
// The home main conversation stays the first entry while another space is open.
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "spaces-sidebar");
@ -168,9 +158,7 @@ test("a new space opens its main conversation and can be deleted from its menu",
const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await expect(projectRow(projekte, "Temporary")).toBeVisible();
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
expect.objectContaining({ kind: "main" }),
@ -178,9 +166,8 @@ test("a new space opens its main conversation and can be deleted from its menu",
await captureScreenshot(page, testInfo, "new-space-main-conversation");
// Deletion is discoverable through the visible Space actions menu.
await projectSpace(projekte, "Temporary")
.getByRole("button", { name: "Actions for Temporary" })
.click();
await projectRow(projekte, "Temporary").hover();
await projekte.getByRole("button", { name: "Actions for Temporary" }).click();
await captureScreenshot(page, testInfo, "space-actions-menu");
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
await page.getByRole("menuitem", { name: "Delete space" }).click();
@ -242,11 +229,10 @@ test("deleting the last bot in a space stays in the app after first use", async
await expect(page).not.toHaveURL(/\/onboarding/);
// The project's main conversation stays, so the project opens there.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await expect(projectRow(projekte, "Side")).toBeVisible();
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
await projectRow(projekte, "Side").hover();
await projekte.getByRole("button", { name: "Actions for Side" }).click();
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
await page.getByRole("menuitem", { name: "Delete space" }).click();
await page

View File

@ -1,62 +1,18 @@
/**
* Sortable wrapper for a project (space) sidebar group card.
* Drag listeners attach only to the grip handle via `useSortableGroupHandle`.
* Sortable wrapper for a project in the sidebar. The whole row drags: a mouse after a short
* move, a finger after a short hold, so a plain click or scroll still works.
*/
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useLingui } from "@lingui/react/macro";
import { createContext, memo, useContext, useMemo } from "react";
import { memo, useMemo } from "react";
interface SortableGroupProps {
group: { spaceId: string; key: string; title?: string | null };
children: React.ReactNode;
}
type SortableGroupHandle = {
setActivatorNodeRef: (element: HTMLElement | null) => void;
listeners: ReturnType<typeof useSortable>["listeners"];
attributes: ReturnType<typeof useSortable>["attributes"];
};
const SortableGroupHandleContext = createContext<SortableGroupHandle | null>(null);
export function useSortableGroupHandle(): SortableGroupHandle | null {
return useContext(SortableGroupHandleContext);
}
export const ProjectDragGrip = memo(function ProjectDragGrip({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
const { t } = useLingui();
const handle = useSortableGroupHandle();
return (
<button
type="button"
ref={handle?.setActivatorNodeRef}
className={className}
aria-label={t`Drag to reorder`}
{...handle?.attributes}
{...handle?.listeners}
>
{children}
</button>
);
});
export const SortableGroup = memo(function SortableGroup({ group, children }: SortableGroupProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: group.spaceId,
});
@ -70,16 +26,9 @@ export const SortableGroup = memo(function SortableGroup({ group, children }: So
[transform, transition, isDragging],
);
const handle = useMemo(
() => ({ setActivatorNodeRef, listeners, attributes }),
[setActivatorNodeRef, listeners, attributes],
);
return (
<SortableGroupHandleContext.Provider value={handle}>
<div ref={setNodeRef} style={style} data-drag-id={group.spaceId}>
<div ref={setNodeRef} style={style} data-drag-id={group.spaceId} {...listeners}>
{children}
</div>
</SortableGroupHandleContext.Provider>
);
});

View File

@ -0,0 +1,167 @@
import type { ProjectIconId } from "@rakazo/core";
import { projectLook } from "@rakazo/core";
import type { LucideIcon } from "lucide-react";
import {
Banknote,
Bike,
BookOpen,
Box,
Briefcase,
BriefcaseMedical,
Calendar,
Camera,
Car,
ChartColumn,
Clock,
Cloud,
Code,
Coffee,
Dumbbell,
FileText,
Film,
Flag,
Flame,
FlaskConical,
Folder,
Gamepad2,
Gift,
Globe,
GraduationCap,
Hammer,
Heart,
House,
Image,
Infinity as InfinityIcon,
KeyRound,
Layers,
Leaf,
Library,
Lightbulb,
Lock,
Mail,
Map as MapIcon,
MapPin,
MessageCircle,
Mic,
Monitor,
Moon,
Music,
Newspaper,
Paintbrush,
PawPrint,
Pencil,
Plane,
Server,
Settings,
Shield,
ShoppingBag,
ShoppingCart,
Smartphone,
Sparkles,
SquareTerminal,
Star,
Sun,
Target,
TrendingUp,
Trophy,
User,
Users,
Utensils,
Wrench,
Zap,
} from "lucide-react";
export const PROJECT_ICON_COMPONENTS: Record<ProjectIconId, LucideIcon> = {
folder: Folder,
briefcase: Briefcase,
house: House,
heart: Heart,
star: Star,
book: BookOpen,
code: Code,
terminal: SquareTerminal,
globe: Globe,
camera: Camera,
film: Film,
music: Music,
mic: Mic,
image: Image,
paintbrush: Paintbrush,
pencil: Pencil,
cart: ShoppingCart,
bag: ShoppingBag,
money: Banknote,
chart: ChartColumn,
trending: TrendingUp,
calendar: Calendar,
clock: Clock,
mail: Mail,
chat: MessageCircle,
people: Users,
person: User,
school: GraduationCap,
flask: FlaskConical,
bulb: Lightbulb,
bolt: Zap,
flame: Flame,
leaf: Leaf,
paw: PawPrint,
airplane: Plane,
car: Car,
bicycle: Bike,
map: MapIcon,
pin: MapPin,
fitness: Dumbbell,
medical: BriefcaseMedical,
food: Utensils,
coffee: Coffee,
game: Gamepad2,
gift: Gift,
trophy: Trophy,
flag: Flag,
key: KeyRound,
lock: Lock,
shield: Shield,
gear: Settings,
hammer: Hammer,
tools: Wrench,
cloud: Cloud,
sun: Sun,
moon: Moon,
box: Box,
layers: Layers,
server: Server,
phone: Smartphone,
desktop: Monitor,
news: Newspaper,
document: FileText,
library: Library,
sparkles: Sparkles,
target: Target,
infinity: InfinityIcon,
};
/** A project's icon on its color, as a rounded tile. */
export function ProjectIcon({
icon,
color,
size = 32,
}: {
icon?: string | null;
color?: string | null;
size?: number;
}) {
const look = projectLook({ icon, color });
const Glyph = PROJECT_ICON_COMPONENTS[look.icon.id];
return (
<span
aria-hidden="true"
data-project-icon={look.icon.id}
data-project-color={look.color.id}
className="grid shrink-0 place-items-center rounded-[28%]"
style={{ width: size, height: size, backgroundColor: look.color.hex, color: look.color.ink }}
>
<Glyph strokeWidth={2} style={{ width: size * 0.55, height: size * 0.55 }} />
</span>
);
}

View File

@ -0,0 +1,59 @@
import type { RunActivityRow } from "@rakazo/contracts";
import { useSyncExternalStore } from "react";
import { rpc } from "./rpc";
export type RunActivity = {
active: RunActivityRow[];
recent: RunActivityRow[];
loaded: boolean;
};
const POLL_MS = 15_000;
let snapshot: RunActivity = { active: [], recent: [], loaded: false };
const listeners = new Set<() => void>();
let timer: number | undefined;
let polling = false;
async function poll() {
try {
const [active, recent] = await Promise.all([
rpc.runs.list({ filter: "active" }),
rpc.runs.list({ filter: "recent" }),
]);
snapshot = { active: active.runs, recent: recent.runs, loaded: true };
} catch {
// Keep the last good snapshot on transient failures.
if (!snapshot.loaded) snapshot = { ...snapshot, loaded: true };
}
for (const listener of listeners) listener();
// Schedule the next poll after this one settles, and only while someone is listening.
timer = listeners.size > 0 ? window.setTimeout(() => void poll(), POLL_MS) : undefined;
polling = timer !== undefined;
}
function subscribe(listener: () => void) {
listeners.add(listener);
if (!polling) {
polling = true;
void poll();
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && timer !== undefined) {
window.clearTimeout(timer);
timer = undefined;
polling = false;
}
};
}
/** The user's active and recently finished runs in every space, polled once for all readers. */
export function useRunActivity(): RunActivity {
return useSyncExternalStore(subscribe, () => snapshot);
}
/** Ask for a fresh snapshot now, e.g. after the user answered a waiting thread. */
export function refreshRunActivity() {
if (timer !== undefined) window.clearTimeout(timer);
void poll();
}

View File

@ -352,6 +352,10 @@ msgstr "{name} ergänzt: {line}"
msgid "{name} stopped"
msgstr "{name} wurde gestoppt"
#: src/pages/shell/project-row.tsx
msgid "{name}, {needsYou} need you"
msgstr "{name}, {needsYou} brauchen dich"
#: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation"
msgstr "{name}, Hauptunterhaltung"
@ -461,6 +465,10 @@ msgstr "Aktionsbestätigungen"
msgid "Actions for {0}"
msgstr "Aktionen für {0}"
#: src/pages/shell/project-row.tsx
msgid "Actions for {name}"
msgstr "Aktionen für {name}"
#: src/pages/Shell.tsx
msgid "Actions for space"
msgstr "Aktionen für Space"
@ -1067,6 +1075,7 @@ msgstr "Server nicht erreichbar."
#: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel"
msgstr "Abbrechen"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr "{0} einklappen"
#: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color"
msgstr "Farbe"
@ -1829,6 +1839,7 @@ msgstr "Routine konnte nicht ausgeführt werden"
#: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save"
msgstr "Speichern fehlgeschlagen"
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr "Kundensupport"
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize"
msgstr "Anpassen"
@ -2280,6 +2292,7 @@ msgstr "Kein Stress — wir sind jetzt da."
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done"
msgstr "Fertig"
@ -2325,10 +2338,6 @@ msgstr "Skill-Entwurf"
msgid "Drafting the spec…"
msgstr "Entwerfe die Spezifikation…"
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr "Ziehen zum Umsortieren"
#: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image"
msgstr "Bild ziehen, ablegen oder einfügen"
@ -2897,6 +2906,10 @@ msgstr "Ich bin fertig"
msgid "I'm listening — what's the priority?"
msgstr "Ich höre zu — was hat Priorität?"
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr "Icon"
#: src/components/UserProfileSettings.tsx
msgid "Identity"
msgstr "Identität"
@ -3483,6 +3496,7 @@ msgstr "In Ordner verschieben"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name"
msgstr "Name"
@ -3520,6 +3534,7 @@ msgstr "Braucht dich"
#: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Needs you"
msgstr "Braucht dich"
@ -4483,6 +4498,7 @@ msgstr "läuft"
#: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running"
msgstr "Läuft"
@ -4518,6 +4534,7 @@ msgstr "Läuft, wenn dieser Bot eine verifizierte Nachricht von diesem Anbieter
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save"
msgstr "Speichern"

View File

@ -352,6 +352,10 @@ msgstr "{name} riffed: {line}"
msgid "{name} stopped"
msgstr "{name} stopped"
#: src/pages/shell/project-row.tsx
msgid "{name}, {needsYou} need you"
msgstr "{name}, {needsYou} need you"
#: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation"
msgstr "{name}, main conversation"
@ -461,6 +465,10 @@ msgstr "Action confirmations"
msgid "Actions for {0}"
msgstr "Actions for {0}"
#: src/pages/shell/project-row.tsx
msgid "Actions for {name}"
msgstr "Actions for {name}"
#: src/pages/Shell.tsx
msgid "Actions for space"
msgstr "Actions for space"
@ -1067,6 +1075,7 @@ msgstr "Can't reach the server."
#: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel"
msgstr "Cancel"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr "Collapse {0}"
#: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color"
msgstr "Color"
@ -1829,6 +1839,7 @@ msgstr "Could not run routine"
#: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save"
msgstr "Could not save"
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr "Customer support"
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize"
msgstr "Customize"
@ -2280,6 +2292,7 @@ msgstr "Don't stress — we're here now."
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done"
msgstr "Done"
@ -2325,10 +2338,6 @@ msgstr "Draft skill"
msgid "Drafting the spec…"
msgstr "Drafting the spec…"
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr "Drag to reorder"
#: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image"
msgstr "Drag, drop, or paste an image"
@ -2897,6 +2906,10 @@ msgstr "I’m done"
msgid "I'm listening — what's the priority?"
msgstr "I'm listening — what's the priority?"
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr "Icon"
#: src/components/UserProfileSettings.tsx
msgid "Identity"
msgstr "Identity"
@ -3483,6 +3496,7 @@ msgstr "Move to folder"
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name"
msgstr "Name"
@ -3520,6 +3534,7 @@ msgstr "Needs you"
#: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Needs you"
msgstr "Needs you"
@ -4483,6 +4498,7 @@ msgstr "running"
#: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running"
msgstr "Running"
@ -4518,6 +4534,7 @@ msgstr "Runs when this bot receives a verified message from this provider."
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save"
msgstr "Save"

View File

@ -352,6 +352,10 @@ msgstr ""
msgid "{name} stopped"
msgstr "{name} se detuvo"
#: src/pages/shell/project-row.tsx
msgid "{name}, {needsYou} need you"
msgstr ""
#: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation"
msgstr ""
@ -461,6 +465,10 @@ msgstr "Confirmaciones de acción"
msgid "Actions for {0}"
msgstr "Acciones para {0}"
#: src/pages/shell/project-row.tsx
msgid "Actions for {name}"
msgstr ""
#: src/pages/Shell.tsx
msgid "Actions for space"
msgstr ""
@ -1067,6 +1075,7 @@ msgstr "No se puede alcanzar el servidor."
#: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel"
msgstr "Cancelar"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr "Contraer {0}"
#: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color"
msgstr "Color"
@ -1829,6 +1839,7 @@ msgstr "No se pudo ejecutar la rutina"
#: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save"
msgstr "No se pudo guardar"
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr "Atención al cliente"
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize"
msgstr ""
@ -2280,6 +2292,7 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done"
msgstr "Listo"
@ -2325,10 +2338,6 @@ msgstr "Habilidad en borrador"
msgid "Drafting the spec…"
msgstr ""
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image"
msgstr "Arrastra, suelta o pega una imagen"
@ -2897,6 +2906,10 @@ msgstr "Listo"
msgid "I'm listening — what's the priority?"
msgstr ""
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Identity"
msgstr ""
@ -3483,6 +3496,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name"
msgstr "Nombre"
@ -3520,6 +3534,7 @@ msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Needs you"
msgstr ""
@ -4483,6 +4498,7 @@ msgstr "en ejecución"
#: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running"
msgstr "En ejecución"
@ -4518,6 +4534,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save"
msgstr "Guardar"

View File

@ -352,6 +352,10 @@ msgstr ""
msgid "{name} stopped"
msgstr "{name} रोक दिया गया"
#: src/pages/shell/project-row.tsx
msgid "{name}, {needsYou} need you"
msgstr ""
#: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation"
msgstr ""
@ -461,6 +465,10 @@ msgstr "कार्रवाई की पुष्टि"
msgid "Actions for {0}"
msgstr "{0} के लिए कार्रवाइयां"
#: src/pages/shell/project-row.tsx
msgid "Actions for {name}"
msgstr ""
#: src/pages/Shell.tsx
msgid "Actions for space"
msgstr ""
@ -1067,6 +1075,7 @@ msgstr "सर्वर तक नहीं पहुंचा जा सका
#: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel"
msgstr "रद्द करें"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr ""
#: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color"
msgstr "रंग"
@ -1829,6 +1839,7 @@ msgstr ""
#: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save"
msgstr "सहेजा नहीं जा सका"
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize"
msgstr ""
@ -2280,6 +2292,7 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done"
msgstr "पूर्ण"
@ -2325,10 +2338,6 @@ msgstr "ड्राफ़्ट स्किल"
msgid "Drafting the spec…"
msgstr ""
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image"
msgstr "छवि खींचें, छोड़ें या पेस्ट करें"
@ -2897,6 +2906,10 @@ msgstr "मेरा काम हो गया"
msgid "I'm listening — what's the priority?"
msgstr ""
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Identity"
msgstr ""
@ -3483,6 +3496,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name"
msgstr "नाम"
@ -3520,6 +3534,7 @@ msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Needs you"
msgstr ""
@ -4483,6 +4498,7 @@ msgstr "चल रहा है"
#: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running"
msgstr "चल रहा है"
@ -4518,6 +4534,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save"
msgstr "सहेजें"

View File

@ -352,6 +352,10 @@ msgstr ""
msgid "{name} stopped"
msgstr "{name} 중지됨"
#: src/pages/shell/project-row.tsx
msgid "{name}, {needsYou} need you"
msgstr ""
#: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation"
msgstr ""
@ -461,6 +465,10 @@ msgstr "작업 실행 전 확인"
msgid "Actions for {0}"
msgstr "{0} 작업 메뉴"
#: src/pages/shell/project-row.tsx
msgid "Actions for {name}"
msgstr ""
#: src/pages/Shell.tsx
msgid "Actions for space"
msgstr ""
@ -1067,6 +1075,7 @@ msgstr "서버에 연결할 수 없습니다."
#: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel"
msgstr "취소"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr "{0} 접기"
#: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color"
msgstr "색상"
@ -1829,6 +1839,7 @@ msgstr "루틴을 실행할 수 없습니다"
#: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save"
msgstr "저장하지 못했습니다."
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr "고객 지원"
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize"
msgstr ""
@ -2280,6 +2292,7 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done"
msgstr "완료"
@ -2325,10 +2338,6 @@ msgstr "작업 기술 초안"
msgid "Drafting the spec…"
msgstr ""
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image"
msgstr "이미지를 끌어다 놓거나 붙여넣으세요"
@ -2897,6 +2906,10 @@ msgstr "조작 끝내기"
msgid "I'm listening — what's the priority?"
msgstr ""
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Identity"
msgstr ""
@ -3483,6 +3496,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name"
msgstr "이름"
@ -3520,6 +3534,7 @@ msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Needs you"
msgstr ""
@ -4483,6 +4498,7 @@ msgstr "실행 중"
#: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running"
msgstr "실행 중"
@ -4518,6 +4534,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save"
msgstr "저장"

View File

@ -352,6 +352,10 @@ msgstr ""
msgid "{name} stopped"
msgstr "{name} foi interrompido"
#: src/pages/shell/project-row.tsx
msgid "{name}, {needsYou} need you"
msgstr ""
#: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation"
msgstr ""
@ -461,6 +465,10 @@ msgstr "Confirmações de ação"
msgid "Actions for {0}"
msgstr "Ações para {0}"
#: src/pages/shell/project-row.tsx
msgid "Actions for {name}"
msgstr ""
#: src/pages/Shell.tsx
msgid "Actions for space"
msgstr ""
@ -1067,6 +1075,7 @@ msgstr "Não é possível acessar o servidor."
#: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel"
msgstr "Cancelar"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr ""
#: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color"
msgstr "Cor"
@ -1829,6 +1839,7 @@ msgstr ""
#: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save"
msgstr "Não foi possível salvar"
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize"
msgstr ""
@ -2280,6 +2292,7 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done"
msgstr "Concluído"
@ -2325,10 +2338,6 @@ msgstr "Habilidade de rascunho"
msgid "Drafting the spec…"
msgstr ""
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image"
msgstr "Arraste, solte ou cole uma imagem"
@ -2897,6 +2906,10 @@ msgstr "Eu terminei."
msgid "I'm listening — what's the priority?"
msgstr ""
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Identity"
msgstr ""
@ -3483,6 +3496,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name"
msgstr "Nome"
@ -3520,6 +3534,7 @@ msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Needs you"
msgstr ""
@ -4483,6 +4498,7 @@ msgstr "em execução"
#: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running"
msgstr "Em execução"
@ -4518,6 +4534,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save"
msgstr "Salvar"

View File

@ -352,6 +352,10 @@ msgstr ""
msgid "{name} stopped"
msgstr "{name}: остановлено"
#: src/pages/shell/project-row.tsx
msgid "{name}, {needsYou} need you"
msgstr ""
#: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation"
msgstr ""
@ -461,6 +465,10 @@ msgstr "Подтверждения действий"
msgid "Actions for {0}"
msgstr "Действия для {0}"
#: src/pages/shell/project-row.tsx
msgid "Actions for {name}"
msgstr ""
#: src/pages/Shell.tsx
msgid "Actions for space"
msgstr "Действия для пространства"
@ -1067,6 +1075,7 @@ msgstr "Не удаётся связаться с сервером."
#: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel"
msgstr "Отмена"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr "Свернуть {0}"
#: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color"
msgstr "Цвет"
@ -1829,6 +1839,7 @@ msgstr "Не удалось запустить задачу"
#: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save"
msgstr "Не удалось сохранить"
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr "Поддержка клиентов"
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize"
msgstr ""
@ -2280,6 +2292,7 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done"
msgstr "Готово"
@ -2325,10 +2338,6 @@ msgstr "Черновик навыка"
msgid "Drafting the spec…"
msgstr ""
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image"
msgstr "Перетащите или вставьте изображение"
@ -2897,6 +2906,10 @@ msgstr "Готово"
msgid "I'm listening — what's the priority?"
msgstr ""
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Identity"
msgstr ""
@ -3483,6 +3496,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name"
msgstr "Имя"
@ -3520,6 +3534,7 @@ msgstr "Требуется ваше участие"
#: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Needs you"
msgstr "Требуется ваше участие"
@ -4483,6 +4498,7 @@ msgstr "выполняется"
#: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running"
msgstr "Выполняется"
@ -4518,6 +4534,7 @@ msgstr "Запускается, когда этот бот получает пр
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save"
msgstr "Сохранить"

View File

@ -352,6 +352,10 @@ msgstr ""
msgid "{name} stopped"
msgstr "{name} durduruldu"
#: src/pages/shell/project-row.tsx
msgid "{name}, {needsYou} need you"
msgstr ""
#: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation"
msgstr ""
@ -461,6 +465,10 @@ msgstr "İşlem onayları"
msgid "Actions for {0}"
msgstr "{0} için işlemler"
#: src/pages/shell/project-row.tsx
msgid "Actions for {name}"
msgstr ""
#: src/pages/Shell.tsx
msgid "Actions for space"
msgstr ""
@ -1067,6 +1075,7 @@ msgstr "Sunucuya ulaşılamıyor."
#: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel"
msgstr "İptal"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr ""
#: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color"
msgstr "Renk"
@ -1829,6 +1839,7 @@ msgstr ""
#: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save"
msgstr "Kaydedilemedi"
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize"
msgstr ""
@ -2280,6 +2292,7 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done"
msgstr "Bitti"
@ -2325,10 +2338,6 @@ msgstr "Taslak beceri"
msgid "Drafting the spec…"
msgstr ""
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image"
msgstr "Görüntüyü sürükleyip bırakın veya yapıştırın"
@ -2897,6 +2906,10 @@ msgstr "Bitirdim"
msgid "I'm listening — what's the priority?"
msgstr ""
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Identity"
msgstr ""
@ -3483,6 +3496,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name"
msgstr "Ad"
@ -3520,6 +3534,7 @@ msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Needs you"
msgstr ""
@ -4483,6 +4498,7 @@ msgstr "çalışıyor"
#: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running"
msgstr "Çalışıyor"
@ -4518,6 +4534,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save"
msgstr "Kaydet"

View File

@ -352,6 +352,10 @@ msgstr ""
msgid "{name} stopped"
msgstr "{name} 已停止"
#: src/pages/shell/project-row.tsx
msgid "{name}, {needsYou} need you"
msgstr ""
#: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation"
msgstr ""
@ -461,6 +465,10 @@ msgstr "操作确认"
msgid "Actions for {0}"
msgstr "{0} 的操作"
#: src/pages/shell/project-row.tsx
msgid "Actions for {name}"
msgstr ""
#: src/pages/Shell.tsx
msgid "Actions for space"
msgstr ""
@ -1067,6 +1075,7 @@ msgstr "无法连接服务器。"
#: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel"
msgstr "取消"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr "折叠 {0}"
#: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color"
msgstr "颜色"
@ -1829,6 +1839,7 @@ msgstr "无法运行例行任务"
#: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save"
msgstr "无法保存"
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr "客户支持"
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize"
msgstr ""
@ -2280,6 +2292,7 @@ msgstr ""
#: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done"
msgstr "完成"
@ -2325,10 +2338,6 @@ msgstr "技能草稿"
msgid "Drafting the spec…"
msgstr ""
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr ""
#: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image"
msgstr "拖放或粘贴图片"
@ -2897,6 +2906,10 @@ msgstr "我做完了"
msgid "I'm listening — what's the priority?"
msgstr ""
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Identity"
msgstr ""
@ -3483,6 +3496,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name"
msgstr "名称"
@ -3520,6 +3534,7 @@ msgstr ""
#: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx
msgid "Needs you"
msgstr ""
@ -4483,6 +4498,7 @@ msgstr "运行中"
#: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running"
msgstr "运行中"
@ -4518,6 +4534,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save"
msgstr "保存"

View File

@ -3,9 +3,8 @@ import { t } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import type { RunActivityRow } from "@rakazo/contracts";
import { runChatTitle } from "@rakazo/core";
import { useEffect, useState } from "react";
import { isNeedsYouStatus, runActivityLabel } from "../lib/agent-activity";
import { rpc } from "../lib/rpc";
import { useRunActivity } from "../lib/run-activity";
function statusTone(status: RunActivityRow["status"]): string {
if (status === "failed") return "text-destructive";
@ -20,41 +19,8 @@ type ActivityListProps = {
};
export function ActivityList({ onOpenRun }: ActivityListProps) {
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
let timer: number | undefined;
const tick = async () => {
try {
const [active, recent] = await Promise.all([
rpc.runs.list({ filter: "active" }),
rpc.runs.list({ filter: "recent" }),
]);
if (cancelled) return;
setActiveRuns(active.runs);
setRecentRuns(recent.runs);
} catch {
// Keep the last good snapshot on transient RPC failures.
if (cancelled) return;
} finally {
if (!cancelled) {
setLoading(false);
// Schedule the next poll after the previous settles — no overlap.
timer = window.setTimeout(() => void tick(), 15_000);
}
}
};
void tick();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, []);
const { active: activeRuns, recent: recentRuns, loaded } = useRunActivity();
const loading = !loaded;
if (loading) {
return (
@ -92,7 +58,7 @@ export function ActivityList({ onOpenRun }: ActivityListProps) {
);
}
function ActivityRow({ run, onOpen }: { run: RunActivityRow; onOpen: () => void }) {
export function ActivityRow({ run, onOpen }: { run: RunActivityRow; onOpen: () => void }) {
const { t } = useLingui();
const title = runChatTitle(run);
const label = statusLabel(run.status);

View File

@ -40,6 +40,7 @@ import {
matchVoiceByName,
type PaneState,
parseVoiceChatCommand,
projectThreads,
recordScreenPaneChoice,
reorderBotTo,
resolveComposerSendPlan,
@ -266,6 +267,10 @@ function voiceStatusRefreshTimeout(): Promise<never> {
export type NavPillar = "heute" | "team" | "projekte";
import { SortableGroup } from "../components/SortableGroup";
import { useRunActivity } from "../lib/run-activity";
import { ProjectLookDialog } from "./shell/project-look-dialog";
import { ProjectOverview } from "./shell/project-overview";
import { ProjectRow } from "./shell/project-row";
export function ShellPage() {
const { t } = useLingui();
@ -512,6 +517,7 @@ export function ShellPage() {
const [deleteTarget, setDeleteTarget] = useState<Bot | null>(null);
const [deleteGroupTarget, setDeleteGroupTarget] = useState<Group | null>(null);
const [deleteSpaceTarget, setDeleteSpaceTarget] = useState<Space | null>(null);
const [lookTarget, setLookTarget] = useState<Space | null>(null);
const [spaceMenu, setSpaceMenu] = useState<{
id: string;
position: ContextMenuPosition;
@ -1824,6 +1830,7 @@ export function ShellPage() {
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
const heuteActionCount = useHeuteActionCount(groups, botsRef);
const runActivity = useRunActivity();
useShellSounds({
bots,
transcriptRunning,
@ -3069,6 +3076,64 @@ export function ShellPage() {
strategy={verticalListSortingStrategy}
>
{projectSidebarGroups.map((group) => {
const projectSpace = group.key.startsWith("project-space:")
? spaces.find((space) => space.id === group.spaceId)
: undefined;
if (projectSpace) {
const threads = projectThreads(runActivity, {
spaceId: projectSpace.id,
});
const projectSelected =
activeNavPillar === "projekte" &&
bootstrapMe?.spaceId === projectSpace.id;
// Other chats in the project show only while it is open.
const projectChats = projectSelected
? group.bots.filter((item) => item.chat.kind !== "main")
: [];
return (
<SortableGroup key={`proj-${group.key}`} group={group}>
<ProjectRow
spaceId={projectSpace.id}
name={projectSpace.name}
icon={projectSpace.icon}
color={projectSpace.color}
hasMenu={
projectSpace.canEdit === true || projectSpace.canDelete === true
}
selected={projectSelected}
unread={group.bots.some((item) => item.chat.unread)}
needsYou={threads.needsYou.length}
running={threads.running.length > 0}
onOpen={() => {
setMobileSidebarOpen(false);
void startProject(projectSpace.id);
}}
onMenu={(menu, anchor) => {
spaceMenuAnchor.current = anchor;
setSpaceMenu(menu);
}}
/>
{projectChats.length > 0 ? (
<div className="pl-6">
{projectChats.map((item) => (
<ProjectRosterRow
key={`${item.kind}:${item.chat.id}`}
item={item}
selected={inGroup && activeGroup?.id === item.chat.id}
onOpen={() => {
setActiveNavPillar("projekte");
openSpaceChat(item.chat.spaceId, `/app/g/${item.chat.id}`);
}}
activeSpaceId={bootstrapMe?.spaceId}
menuAnchor={botMenuAnchor}
onMenu={setBotMenu}
/>
))}
</div>
) : null}
</SortableGroup>
);
}
const collapsed =
Boolean(group.title) && collapsedSidebarSections.has(group.key);
return (
@ -3414,6 +3479,25 @@ export function ShellPage() {
enrichMember={enrichStageMember}
/>
) : null}
{!callOpen && activeGroup?.kind === "main" ? (
<ProjectOverview
spaceId={activeGroup.spaceId}
coordinator={
activeSnapshot?.threadId
? {
threadId: activeSnapshot.threadId,
botId: activeSnapshot.leadBotId ?? undefined,
}
: undefined
}
onOpenRun={(run) =>
openSpaceChat(
run.spaceId,
run.groupId ? `/app/g/${run.groupId}` : `/app/${run.botId}`,
)
}
/>
) : null}
<Transcript
key={activeSnapshot?.threadId}
encounterMode={encounterMode}
@ -4031,6 +4115,21 @@ export function ShellPage() {
label={t`Actions for space`}
onClose={closeSpaceMenu}
>
{spaces.some(
(space) => space.id === spaceMenu.id && space.canEdit && !space.isDefault,
) ? (
<DropdownMenuItem
onClick={() => {
const target = spaces.find((space) => space.id === spaceMenu.id);
if (target) setLookTarget(target);
setSpaceMenu(null);
}}
>
<Pencil />
{t`Customize`}
</DropdownMenuItem>
) : null}
{spaces.find((space) => space.id === spaceMenu.id)?.canDelete ? (
<DropdownMenuItem
variant="destructive"
onClick={() => {
@ -4042,9 +4141,22 @@ export function ShellPage() {
<Trash2 />
{t`Delete space`}
</DropdownMenuItem>
) : null}
</PointerMenu>
) : null}
{lookTarget ? (
<ProjectLookDialog
initial={lookTarget}
onCancel={() => setLookTarget(null)}
onSave={async (look) => {
await rpc.spaces.update({ spaceId: lookTarget.id, ...look });
setLookTarget(null);
await refreshBots();
}}
/>
) : null}
{deleteTarget ? (
<DeleteBotDialog
bot={deleteTarget}

View File

@ -0,0 +1,125 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { PROJECT_COLORS, PROJECT_ICONS, projectLook } from "@rakazo/core";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "@rakazo/ui-web";
import { useId, useState } from "react";
import { PROJECT_ICON_COMPONENTS, ProjectIcon } from "../../components/project-icon";
export type ProjectLook = { name: string; icon: string; color: string };
/** Name, icon and color of a project, picked from the shared catalog. */
export function ProjectLookDialog({
initial,
onCancel,
onSave,
}: {
initial: { name: string; icon?: string | null; color?: string | null };
onCancel: () => void;
onSave: (look: ProjectLook) => Promise<void>;
}) {
const { t } = useLingui();
const nameId = useId();
const start = projectLook(initial);
const [name, setName] = useState(initial.name);
const [icon, setIcon] = useState<string>(start.icon.id);
const [color, setColor] = useState<string>(start.color.id);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = () => {
const trimmed = name.trim();
if (!trimmed || saving) return;
setSaving(true);
setError(null);
void onSave({ name: trimmed, icon, color }).catch((reason: unknown) => {
setError(reason instanceof Error ? reason.message : t`Could not save`);
setSaving(false);
});
};
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !saving) onCancel();
}}
>
<DialogContent showCloseButton={false} data-testid="project-look-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<ProjectIcon icon={icon} color={color} size={36} />
<span className="min-w-0 truncate">{name.trim() || initial.name}</span>
</DialogTitle>
</DialogHeader>
<label htmlFor={nameId} className="block text-ui-body text-foreground/75">
<Trans>Name</Trans>
<Input
id={nameId}
maxLength={60}
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") save();
}}
className="mt-2"
/>
</label>
<div role="radiogroup" aria-label={t`Color`} className="flex flex-wrap gap-2">
{PROJECT_COLORS.map((option) => (
<Button
key={option.id}
variant="ghost"
size="icon-sm"
role="radio"
aria-checked={color === option.id}
aria-label={option.id}
onClick={() => setColor(option.id)}
className="size-8 rounded-full border border-border ring-offset-2 ring-offset-background aria-checked:ring-2 aria-checked:ring-foreground"
style={{ backgroundColor: option.hex }}
/>
))}
</div>
<div
role="radiogroup"
aria-label={t`Icon`}
className="grid max-h-56 grid-cols-8 gap-1 overflow-y-auto rounded-lg border border-border p-1"
>
{PROJECT_ICONS.map((option) => {
const Glyph = PROJECT_ICON_COMPONENTS[option.id];
return (
<Button
key={option.id}
variant="ghost"
size="icon-sm"
role="radio"
aria-checked={icon === option.id}
aria-label={option.id}
data-testid={`project-icon-${option.id}`}
onClick={() => setIcon(option.id)}
className="aspect-square size-auto rounded-md text-foreground/80 aria-checked:bg-primary aria-checked:text-primary-foreground"
>
<Glyph aria-hidden="true" strokeWidth={1.8} className="size-icon-md" />
</Button>
);
})}
</div>
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="outline" disabled={saving} onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>
<Button disabled={saving || !name.trim()} onClick={save}>
<Trans>Save</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,75 @@
import { Trans } from "@lingui/react/macro";
import type { RunActivityRow } from "@rakazo/contracts";
import { projectThreads } from "@rakazo/core";
import type { ReactNode } from "react";
import { useState } from "react";
import { useRunActivity } from "../../lib/run-activity";
import { ActivityRow } from "../ActivityList";
type Section = "needsYou" | "running" | "done";
/**
* The coordinator's view of its project: threads that wait on the user, threads its agents are
* working on, and recently finished ones. Empty groups stay hidden.
*/
export function ProjectOverview({
spaceId,
coordinator,
onOpenRun,
}: {
spaceId: string;
coordinator?: { threadId: string; botId?: string };
onOpenRun: (run: RunActivityRow) => void;
}) {
const activity = useRunActivity();
const threads = projectThreads(activity, { spaceId, coordinator });
// null until the user picks; "none" once they closed every group.
const [open, setOpen] = useState<Section | "none" | null>(null);
const sections: { id: Section; runs: RunActivityRow[]; label: ReactNode; tone: string }[] = [
{
id: "needsYou",
runs: threads.needsYou,
label: <Trans>Needs you</Trans>,
tone: "text-warning",
},
{
id: "running",
runs: threads.running,
label: <Trans>Running</Trans>,
tone: "text-foreground",
},
{ id: "done", runs: threads.done, label: <Trans>Done</Trans>, tone: "text-muted-foreground" },
];
const visible = sections.filter((section) => section.runs.length > 0);
if (visible.length === 0) return null;
// Waiting threads are what the user came for, so they start open.
const shown = open ?? (threads.needsYou.length > 0 ? "needsYou" : null);
const current = visible.find((section) => section.id === shown);
return (
<div data-testid="project-overview" className="mx-auto w-full max-w-3xl px-4 pt-2">
<div className="flex flex-wrap gap-2">
{visible.map((section) => (
<button
key={section.id}
type="button"
aria-expanded={shown === section.id}
onClick={() => setOpen(shown === section.id ? "none" : section.id)}
className="inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-ui-body-sm hover:bg-accent aria-expanded:bg-accent"
>
<span className={section.tone}>{section.label}</span>
<span className="font-semibold tabular-nums text-foreground">
{section.runs.length}
</span>
</button>
))}
</div>
{current ? (
<div className="mt-1">
{current.runs.map((run) => (
<ActivityRow key={run.runId} run={run} onOpen={() => onOpenRun(run)} />
))}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,95 @@
import { useLingui } from "@lingui/react/macro";
import { Button } from "@rakazo/ui-web";
import { MoreHorizontal } from "lucide-react";
import { ProjectIcon } from "../../components/project-icon";
import { rosterNameClass, rosterRowSurfaceClass } from "./roster-row-style";
import type { SpaceMenuRequest } from "./sidebar-group-header";
/** One project in the sidebar: its icon and name; it opens the project's coordinator. */
export function ProjectRow({
spaceId,
name,
icon,
color,
hasMenu,
selected,
unread,
needsYou,
running,
onOpen,
onMenu,
}: {
spaceId: string;
name: string;
icon?: string | null;
color?: string | null;
hasMenu: boolean;
selected: boolean;
unread: boolean;
needsYou: number;
running: boolean;
onOpen: () => void;
onMenu: (menu: SpaceMenuRequest, anchor: HTMLElement) => void;
}) {
const { t } = useLingui();
return (
<div className={`group flex w-full items-center ${rosterRowSurfaceClass(selected)}`}>
<button
type="button"
data-testid={`project-row-${spaceId}`}
data-long-press={hasMenu ? "" : undefined}
aria-label={needsYou > 0 ? t`${name}, ${needsYou} need you` : name}
aria-current={selected ? "page" : undefined}
onClick={onOpen}
onContextMenu={(event) => {
if (!hasMenu) return;
event.preventDefault();
onMenu(
{ id: spaceId, position: { x: event.clientX, y: event.clientY } },
event.currentTarget,
);
}}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 min-h-[48px] rounded-2xl py-2 ps-3 pe-2 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<ProjectIcon icon={icon} color={color} size={30} />
<span
dir="auto"
className={`min-w-0 flex-1 truncate text-ui-body tracking-tight ${rosterNameClass(unread)}`}
>
{name}
</span>
{needsYou > 0 ? (
<span
aria-hidden="true"
className="grid min-w-[20px] shrink-0 place-items-center rounded-full bg-warning px-1.5 text-ui-label font-bold leading-5 text-background"
>
{needsYou > 9 ? "9+" : needsYou}
</span>
) : running ? (
<span
aria-hidden="true"
className="size-2 shrink-0 animate-pulse rounded-full bg-success"
/>
) : null}
</button>
{hasMenu ? (
// Pointer devices reveal the menu on hover; touch opens it with a long press.
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Actions for ${name}`}
className="me-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
onMenu(
{ id: spaceId, position: { x: rect.left, y: rect.bottom } },
event.currentTarget,
);
}}
>
<MoreHorizontal aria-hidden="true" className="size-icon-md" />
</Button>
) : null}
</div>
);
}

View File

@ -1,6 +1,11 @@
/** One look for sidebar conversation rows: the main conversation entry and the bot roster. */
export function rosterRowClass(selected: boolean): string {
return `flex w-full items-center gap-3 rounded-2xl border px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
return `flex w-full items-center gap-3 px-3 py-3 text-start cursor-pointer min-h-[52px] ${rosterRowSurfaceClass(selected)}`;
}
/** The row's card: border, fill and shadow, for rows that lay out their own content. */
export function rosterRowSurfaceClass(selected: boolean): string {
return `rounded-2xl border mb-[2px] transition-all duration-150 ${
selected
? "border-sidebar-border bg-sidebar-accent shadow-xs dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"
: "border-transparent hover:bg-sidebar-accent/50"

View File

@ -1,9 +1,8 @@
import { useLingui } from "@lingui/react/macro";
import type { BotSection, Space } from "@rakazo/contracts";
import { Button } from "@rakazo/ui-web";
import { ChevronDown, Folder, GripVertical, Lock, MoreHorizontal } from "lucide-react";
import { ChevronDown, Folder, Lock, MoreHorizontal } from "lucide-react";
import type { RefObject } from "react";
import { ProjectDragGrip } from "../../components/SortableGroup";
import type { ContextMenuPosition } from "../BotContextMenu";
export type SidebarGroupHeaderGroup = {
@ -26,7 +25,7 @@ export type SectionMenuRequest = {
};
/**
* Collapsible title of a sidebar group. Project groups add a drag grip, show a lock for other
* Collapsible title of a sidebar group. Project groups show a lock for other
* spaces and select an empty space instead of opening it.
*/
export function SidebarGroupHeader({
@ -59,20 +58,10 @@ export function SidebarGroupHeader({
const { t } = useLingui();
const project = variant === "project";
return (
<div
className={
project ? "flex items-center pt-3 pb-0.5 relative" : "flex items-center pt-3 pb-0.5"
}
>
{project ? (
// Drag grip — positioned to the left of the title text
<ProjectDragGrip className="absolute left-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded flex items-center justify-center text-muted-foreground/30 hover:text-muted-foreground/70 active:text-muted-foreground transition-colors cursor-grab active:cursor-grabbing">
<GripVertical className="size-icon-sm" strokeWidth={2} />
</ProjectDragGrip>
) : null}
<div className="flex items-center pt-3 pb-0.5">
<button
type="button"
className={`flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg ${project ? "pl-8" : "px-2.5"} py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring`}
className={`flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring`}
onClick={() => {
if (group.emptySpaceId) {
onOpenEmptySpace(group.emptySpaceId);

View File

@ -1,45 +1,19 @@
import type { Bot, Group } from "@rakazo/contracts";
import type { RefObject } from "react";
import { useEffect, useState } from "react";
import { useMemo } from "react";
import { buildHeuteBrief } from "../../lib/heute-brief";
import { rpc } from "../../lib/rpc";
import { useRunActivity } from "../../lib/run-activity";
export function useHeuteActionCount(groups: Group[], botsRef: RefObject<Bot[]>): number {
const [heuteActionCount, setHeuteActionCount] = useState(0);
useEffect(() => {
let cancelled = false;
let timer: number | undefined;
const tick = async () => {
try {
const [active, recent] = await Promise.all([
rpc.runs.list({ filter: "active" }),
rpc.runs.list({ filter: "recent" }),
]);
if (cancelled) return;
const brief = buildHeuteBrief({
const { active, recent } = useRunActivity();
return useMemo(
() =>
buildHeuteBrief({
bots: botsRef.current,
groups,
activeRuns: active.runs,
recentRuns: recent.runs,
});
setHeuteActionCount(brief.actionCount);
} catch {
if (cancelled) return;
} finally {
if (!cancelled) {
timer = window.setTimeout(() => void tick(), 15_000);
}
}
};
void tick();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [groups]);
return heuteActionCount;
activeRuns: active,
recentRuns: recent,
}).actionCount,
[active, recent, groups, botsRef],
);
}

View File

@ -34,7 +34,7 @@ export function useSidebarModel({
}, []);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }),
);
const sidebarGroups = useMemo(() => {

View File

@ -294,11 +294,16 @@ export type SpaceWorkspace = z.infer<typeof SpaceWorkspaceSchema>;
export const SpaceSchema = z.object({
id: Id,
name: z.string(),
/** Sidebar look; ids from the shared project catalog, null until the user picks one. */
icon: z.string().nullable().optional(),
color: z.string().nullable().optional(),
isDefault: z.boolean(),
/** True when the space has any bot or group, including archived. */
hasContent: z.boolean(),
/** True only when the current member may delete this non-default space. */
canDelete: z.boolean().optional(),
/** True when the current member may rename the space and change its look. */
canEdit: z.boolean().optional(),
workspace: SpaceWorkspaceSchema.nullable().optional(),
bots: z.array(SpaceBotSchema),
groups: z.array(SpaceGroupSchema),

View File

@ -258,6 +258,17 @@ export const appContract = {
}),
)
.output(SpaceSchema),
/** Rename a project or change its sidebar icon and color. */
update: oc
.input(
z.object({
spaceId: Id,
name: z.string().trim().min(1).max(60).optional(),
icon: z.string().max(40).optional(),
color: z.string().max(40).optional(),
}),
)
.output(z.object({ ok: z.literal(true) })),
remove: oc
.input(z.object({ spaceId: Id }))
.output(z.object({ ok: z.literal(true), activeSpaceId: Id })),

View File

@ -37,6 +37,8 @@ export * from "./oo-avatar.js";
export * from "./oo-motion.js";
export * from "./oo-palette.js";
export * from "./oo-rig.js";
export * from "./project-look.js";
export * from "./project-threads.js";
export * from "./quiet-hours.js";
export * from "./response-bytes.js";
export * from "./routine-events.js";

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { isProjectColor, isProjectIcon, PROJECT_ICONS, projectLook } from "./project-look.js";
describe("project look", () => {
it("falls back to the default icon and color", () => {
const look = projectLook({ icon: null, color: "not-a-color" });
expect(look.icon.id).toBe("folder");
expect(look.color.id).toBe("violet");
});
it("keeps a chosen icon and color", () => {
const look = projectLook({ icon: "rocket-not-listed", color: "green" });
expect(look.icon.id).toBe("folder");
expect(look.color.id).toBe("green");
expect(projectLook({ icon: "music" }).icon.ios).toBe("music.note");
});
it("validates ids and offers a large unique catalog", () => {
expect(isProjectIcon("leaf")).toBe(true);
expect(isProjectIcon("Leaf")).toBe(false);
expect(isProjectColor("orange")).toBe(true);
expect(isProjectColor("#FF0000")).toBe(false);
expect(PROJECT_ICONS.length).toBeGreaterThanOrEqual(60);
expect(new Set(PROJECT_ICONS.map((icon) => icon.id)).size).toBe(PROJECT_ICONS.length);
});
});

View File

@ -0,0 +1,106 @@
import { OO_PALETTE } from "./oo-palette.js";
/**
* The icons and colors a project can wear in the sidebar. Ids are stored on the space; each
* platform maps an id to its own symbol (web: lucide, iOS: SF Symbols, Android: Ionicons).
*/
export const PROJECT_ICONS = [
{ id: "folder", ios: "folder", android: "folder-outline" },
{ id: "briefcase", ios: "briefcase", android: "briefcase-outline" },
{ id: "house", ios: "house", android: "home-outline" },
{ id: "heart", ios: "heart", android: "heart-outline" },
{ id: "star", ios: "star", android: "star-outline" },
{ id: "book", ios: "book", android: "book-outline" },
{ id: "code", ios: "chevron.left.forwardslash.chevron.right", android: "code-slash-outline" },
{ id: "terminal", ios: "terminal", android: "terminal-outline" },
{ id: "globe", ios: "globe", android: "globe-outline" },
{ id: "camera", ios: "camera", android: "camera-outline" },
{ id: "film", ios: "film", android: "film-outline" },
{ id: "music", ios: "music.note", android: "musical-notes-outline" },
{ id: "mic", ios: "mic", android: "mic-outline" },
{ id: "image", ios: "photo", android: "image-outline" },
{ id: "paintbrush", ios: "paintbrush", android: "brush-outline" },
{ id: "pencil", ios: "pencil", android: "pencil-outline" },
{ id: "cart", ios: "cart", android: "cart-outline" },
{ id: "bag", ios: "bag", android: "bag-outline" },
{ id: "money", ios: "banknote", android: "cash-outline" },
{ id: "chart", ios: "chart.bar", android: "bar-chart-outline" },
{ id: "trending", ios: "chart.line.uptrend.xyaxis", android: "trending-up-outline" },
{ id: "calendar", ios: "calendar", android: "calendar-outline" },
{ id: "clock", ios: "clock", android: "time-outline" },
{ id: "mail", ios: "envelope", android: "mail-outline" },
{ id: "chat", ios: "bubble.left", android: "chatbubble-outline" },
{ id: "people", ios: "person.2", android: "people-outline" },
{ id: "person", ios: "person", android: "person-outline" },
{ id: "school", ios: "graduationcap", android: "school-outline" },
{ id: "flask", ios: "flask", android: "flask-outline" },
{ id: "bulb", ios: "lightbulb", android: "bulb-outline" },
{ id: "bolt", ios: "bolt", android: "flash-outline" },
{ id: "flame", ios: "flame", android: "flame-outline" },
{ id: "leaf", ios: "leaf", android: "leaf-outline" },
{ id: "paw", ios: "pawprint", android: "paw-outline" },
{ id: "airplane", ios: "airplane", android: "airplane-outline" },
{ id: "car", ios: "car", android: "car-outline" },
{ id: "bicycle", ios: "bicycle", android: "bicycle-outline" },
{ id: "map", ios: "map", android: "map-outline" },
{ id: "pin", ios: "mappin", android: "location-outline" },
{ id: "fitness", ios: "dumbbell", android: "barbell-outline" },
{ id: "medical", ios: "cross.case", android: "medkit-outline" },
{ id: "food", ios: "fork.knife", android: "restaurant-outline" },
{ id: "coffee", ios: "cup.and.saucer", android: "cafe-outline" },
{ id: "game", ios: "gamecontroller", android: "game-controller-outline" },
{ id: "gift", ios: "gift", android: "gift-outline" },
{ id: "trophy", ios: "trophy", android: "trophy-outline" },
{ id: "flag", ios: "flag", android: "flag-outline" },
{ id: "key", ios: "key", android: "key-outline" },
{ id: "lock", ios: "lock", android: "lock-closed-outline" },
{ id: "shield", ios: "shield", android: "shield-outline" },
{ id: "gear", ios: "gearshape", android: "settings-outline" },
{ id: "hammer", ios: "hammer", android: "hammer-outline" },
{ id: "tools", ios: "wrench.and.screwdriver", android: "construct-outline" },
{ id: "cloud", ios: "cloud", android: "cloud-outline" },
{ id: "sun", ios: "sun.max", android: "sunny-outline" },
{ id: "moon", ios: "moon", android: "moon-outline" },
{ id: "box", ios: "cube", android: "cube-outline" },
{ id: "layers", ios: "square.stack.3d.up", android: "layers-outline" },
{ id: "server", ios: "server.rack", android: "server-outline" },
{ id: "phone", ios: "iphone", android: "phone-portrait-outline" },
{ id: "desktop", ios: "desktopcomputer", android: "desktop-outline" },
{ id: "news", ios: "newspaper", android: "newspaper-outline" },
{ id: "document", ios: "doc.text", android: "document-text-outline" },
{ id: "library", ios: "books.vertical", android: "library-outline" },
{ id: "sparkles", ios: "sparkles", android: "sparkles-outline" },
{ id: "target", ios: "target", android: "locate-outline" },
{ id: "infinity", ios: "infinity", android: "infinite-outline" },
] as const;
export type ProjectIconId = (typeof PROJECT_ICONS)[number]["id"];
export const DEFAULT_PROJECT_ICON: ProjectIconId = "folder";
/** Project colors are the Oo identity palette, so projects and bots read as one family. */
export const PROJECT_COLORS = OO_PALETTE.map((color) => ({
id: color.id,
hex: color.hex,
/** The icon drawn on this color, light or dark so it stays legible. */
ink: color.eyeColor,
}));
export const DEFAULT_PROJECT_COLOR = "violet";
export function isProjectIcon(value: string): value is ProjectIconId {
return PROJECT_ICONS.some((icon) => icon.id === value);
}
export function isProjectColor(value: string): boolean {
return PROJECT_COLORS.some((color) => color.id === value);
}
/** The look to draw, with defaults for projects that never chose one. */
export function projectLook(space: { icon?: string | null; color?: string | null }) {
const icon =
PROJECT_ICONS.find((item) => item.id === space.icon) ??
PROJECT_ICONS.find((item) => item.id === DEFAULT_PROJECT_ICON)!;
const color =
PROJECT_COLORS.find((item) => item.id === space.color) ??
PROJECT_COLORS.find((item) => item.id === DEFAULT_PROJECT_COLOR)!;
return { icon, color };
}

View File

@ -0,0 +1,52 @@
import type { RunActivityRow } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import { projectThreads } from "./project-threads.js";
function run(
runId: string,
threadId: string,
status: RunActivityRow["status"],
spaceId = "space-1",
botId = "bot-1",
): RunActivityRow {
return {
runId,
botId,
botName: "Researcher",
groupId: null,
groupName: null,
threadId,
spaceId,
status,
trigger: "delegation",
notificationsEnabled: true,
promptSnippet: "",
updatedAt: "2026-10-10T00:00:00.000Z",
};
}
describe("projectThreads", () => {
it("sorts a project's threads into needs you, running and done, once each", () => {
const threads = projectThreads(
{
active: [
run("r1", "t-research", "running"),
run("r2", "t-design", "queued"),
run("r3", "t-design", "waiting_input"),
run("r4", "t-other-space", "running", "space-2"),
run("r5", "t-chief", "running", "space-1", "chief"),
],
recent: [
run("r6", "t-research", "completed"),
run("r7", "t-write", "failed"),
// A specialist that worked inside the coordinator's conversation.
run("r8", "t-chief", "completed", "space-1", "gardener"),
],
},
{ spaceId: "space-1", coordinator: { threadId: "t-chief", botId: "chief" } },
);
expect(threads.needsYou.map((item) => item.runId)).toEqual(["r3"]);
expect(threads.running.map((item) => item.runId)).toEqual(["r1"]);
expect(threads.done.map((item) => item.runId)).toEqual(["r7", "r8"]);
});
});

View File

@ -0,0 +1,49 @@
import type { RunActivityRow } from "@rakazo/contracts";
export type ProjectThreads = {
needsYou: RunActivityRow[];
running: RunActivityRow[];
done: RunActivityRow[];
};
const NEEDS_YOU = new Set<RunActivityRow["status"]>(["waiting_input", "waiting_takeover"]);
/**
* A project's agent threads by what they ask of the user: waiting on them, working, or recently
* finished. Each agent's thread appears once, under its latest run. Specialists working inside
* the coordinator's conversation count as their own threads; the coordinator's own runs there
* are left out because the overview is shown inside it.
*/
export function projectThreads(
runs: { active: readonly RunActivityRow[]; recent: readonly RunActivityRow[] },
options: { spaceId: string; coordinator?: { threadId: string; botId?: string } },
): ProjectThreads {
const key = (run: Pick<RunActivityRow, "threadId" | "botId">) => `${run.threadId}:${run.botId}`;
const { coordinator } = options;
const seen = new Set<string>();
const result: ProjectThreads = { needsYou: [], running: [], done: [] };
const take = (run: RunActivityRow) => {
if (run.spaceId !== options.spaceId || seen.has(key(run))) return false;
if (
coordinator &&
run.threadId === coordinator.threadId &&
(!coordinator.botId || run.botId === coordinator.botId)
) {
return false;
}
seen.add(key(run));
return true;
};
// Waiting runs first, so a thread that also has a queued follow-up still asks for the user.
const active = [...runs.active].sort(
(left, right) => Number(NEEDS_YOU.has(right.status)) - Number(NEEDS_YOU.has(left.status)),
);
for (const run of active) {
if (!take(run)) continue;
(NEEDS_YOU.has(run.status) ? result.needsYou : result.running).push(run);
}
for (const run of runs.recent) {
if (take(run)) result.done.push(run);
}
return result;
}

View File

@ -0,0 +1,3 @@
-- A project's own icon and color in the sidebar; null falls back to the defaults.
ALTER TABLE "spaces" ADD COLUMN "icon" TEXT;
ALTER TABLE "spaces" ADD COLUMN "color" TEXT;

View File

@ -180,6 +180,9 @@ model Space {
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
/// Project look in the sidebar: an icon id from the shared catalog and a palette color.
icon String?
color String?
workspacePath String?
gitRemoteUrl String?
gitDefaultBranch String?

View File

@ -15,7 +15,7 @@ import {
} from "./main-agent.js";
import { createRepos } from "./repos.js";
import { requireMembership } from "./scope.js";
import { createSpaceForMember } from "./spaces.js";
import { createSpaceForMember, updateSpaceForOwner } from "./spaces.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
@ -102,6 +102,33 @@ describePostgres("main agent and main conversation (PostgreSQL)", () => {
expect(named.id).not.toBe(homeSpaceId);
});
it("lets a project's owner rename it and change its look, and nobody else", async () => {
const { userId, homeSpaceId } = await newUser();
const project = await createSpaceForMember(prisma, {
currentSpaceId: homeSpaceId,
userId,
name: "Garden",
});
await updateSpaceForOwner(prisma, {
userId,
spaceId: project.id,
icon: "leaf",
color: "green",
});
await updateSpaceForOwner(prisma, { userId, spaceId: project.id, name: " Backyard " });
expect(
await prisma.space.findUniqueOrThrow({
where: { id: project.id },
select: { name: true, icon: true, color: true },
}),
).toEqual({ name: "Backyard", icon: "leaf", color: "green" });
const stranger = await newUser("Eve");
await expect(
updateSpaceForOwner(prisma, { userId: stranger.userId, spaceId: project.id, name: "Mine" }),
).rejects.toThrow("Space not found");
});
it("gives every project its own main bot and main conversation next to the home one", async () => {
const { userId, homeSpaceId } = await newUser();
const home = await ensureMainConversation(prisma, userId);

View File

@ -187,6 +187,35 @@ async function copyProviderPreferences(
]);
}
/** Rename a project or change its sidebar look; only its owner may. */
export async function updateSpaceForOwner(
prisma: PrismaClient,
input: {
userId: string;
spaceId: string;
name?: string;
icon?: string;
color?: string;
},
): Promise<void> {
const name = input.name?.trim();
if (name !== undefined && (!name || name.length > 60)) throw new InvalidSpaceNameError();
const membership = await prisma.spaceMember.findUnique({
where: { spaceId_userId: { spaceId: input.spaceId, userId: input.userId } },
select: { role: true, space: { select: { deletingAt: true } } },
});
if (!membership || membership.space.deletingAt) throw new SpaceNotFoundError();
if (membership.role !== "owner") throw new IsolationError();
await prisma.space.update({
where: { id: input.spaceId },
data: {
...(name !== undefined ? { name } : {}),
...(input.icon !== undefined ? { icon: input.icon } : {}),
...(input.color !== undefined ? { color: input.color } : {}),
},
});
}
/** Create a sibling privacy boundary for a member of the active organization. */
export async function createSpaceForMember(
prisma: PrismaClient,