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, expandSkillReferencesInPrompt,
hasMixedOneShotSchedule, hasMixedOneShotSchedule,
isOneShotRoutineCrons, isOneShotRoutineCrons,
isProjectColor,
isProjectIcon,
isValidAvatarValue, isValidAvatarValue,
nextCronDateAcrossStrict, nextCronDateAcrossStrict,
} from "@rakazo/core"; } from "@rakazo/core";
@ -168,6 +170,7 @@ import {
selectSpaceModelPreference, selectSpaceModelPreference,
selectSpaceVoicePreference, selectSpaceVoicePreference,
updateNotificationSettings, updateNotificationSettings,
updateSpaceForOwner,
wireProjectWorkspace, wireProjectWorkspace,
} from "@rakazo/db"; } from "@rakazo/db";
import { getLogger } from "@rakazo/logging"; import { getLogger } from "@rakazo/logging";
@ -755,6 +758,7 @@ export function createRouter(deps: RouterDeps) {
isDefault: false, isDefault: false,
hasContent: false, hasContent: false,
canDelete: true, canDelete: true,
canEdit: true,
workspace, workspace,
bots: bots.map(spaceBotDto), bots: bots.map(spaceBotDto),
groups: groups.map(spaceGroupDto), groups: groups.map(spaceGroupDto),
@ -776,6 +780,27 @@ export function createRouter(deps: RouterDeps) {
spaceId: conversation.spaceId, 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 }) => { remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null; let claimId: string | null = null;
let claimActive = false; let claimActive = false;
@ -5444,6 +5469,8 @@ async function spaceNavigationDto(
space: { space: {
select: { select: {
name: true, name: true,
icon: true,
color: true,
isDefault: true, isDefault: true,
deletingAt: true, deletingAt: true,
workspacePath: true, workspacePath: true,
@ -5524,6 +5551,8 @@ async function spaceNavigationDto(
return { return {
id: membership.spaceId, id: membership.spaceId,
name: membership.space.name, name: membership.space.name,
icon: membership.space.icon,
color: membership.space.color,
isDefault: membership.space.isDefault, isDefault: membership.space.isDefault,
hasContent: spacesWithContent.has(membership.spaceId), hasContent: spacesWithContent.has(membership.spaceId),
canDelete: canDelete:
@ -5532,6 +5561,7 @@ async function spaceNavigationDto(
memberships.length > 1 && memberships.length > 1 &&
!spacesWithContent.has(membership.spaceId) && !spacesWithContent.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), (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. // A linked folder is on the owner's host; other members see neither its path nor remote.
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null, workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
bots: spaceBots.map(spaceBotDto), bots: spaceBots.map(spaceBotDto),

View File

@ -6,7 +6,7 @@ import {
type SpaceBot, type SpaceBot,
type SpaceGroup, type SpaceGroup,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { runChatTitle } from "@rakazo/core"; import { projectLook, runChatTitle } from "@rakazo/core";
import { botColors } from "@rakazo/ui-tokens"; import { botColors } from "@rakazo/ui-tokens";
import { Redirect, useFocusEffect, useRouter } from "expo-router"; import { Redirect, useFocusEffect, useRouter } from "expo-router";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; 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]} style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
> >
<ProjectTile space={item.space} />
<Text style={styles.spaceTitle}>{item.title}</Text> <Text style={styles.spaceTitle}>{item.title}</Text>
</Pressable> </Pressable>
{canDeleteInboxSpace(item.space) ? ( {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({ function CircleButton({
children, children,
onPress, onPress,
@ -1192,6 +1203,15 @@ function createHomeStyles() {
spaceSelect: { spaceSelect: {
flex: 1, flex: 1,
minHeight: 44, minHeight: 44,
flexDirection: "row",
alignItems: "center",
gap: 10,
},
projectTile: {
width: 28,
height: 28,
borderRadius: 8,
alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
spaceTitle: { spaceTitle: {

View File

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

View File

@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test"; 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 }) => { test("empty project space offers Start before the main conversation exists", async ({ page }) => {
const stamp = Date.now(); 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).toBeVisible({ timeout: 30_000 });
await expect(mainStart).toContainText(/Start|Starten/i); 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 projekte = page.locator(".sidebar-projekte");
const sidebarStart = projekte.getByTestId(`project-start-${side.id}`); await expect(projectRow(projekte, "Empty project")).toBeVisible({ timeout: 30_000 });
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
await expect(sidebarStart).toContainText(/Start|Starten/i);
}); });

View File

@ -12,6 +12,11 @@ export function unterhaltungenOnly(page: Page) {
return page.locator(".sidebar-unterhaltungen").locator(":scope > *:not(.sidebar-projekte)"); 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) { export function isRealSandboxProvider(provider = process.env.SANDBOX_PROVIDER) {
return provider === "e2b" || provider === "daytona" || provider === "box"; 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 { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers"; import {
captureScreenshot,
function projectSpace(projekte: Locator, name: string) { completeOnboarding,
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name }); projectRow,
} rpc,
signup,
waitForChatComposer,
} from "./helpers";
test("a new project opens straight into the conversation with its own main agent", async ({ test("a new project opens straight into the conversation with its own main agent", async ({
page, page,
@ -26,9 +29,7 @@ test("a new project opens straight into the conversation with its own main agent
await waitForChatComposer(page); await waitForChatComposer(page);
// The project is one conversation, led by a main agent of its own. // The project is one conversation, led by a main agent of its own.
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", { const projectEntry = projectRow(projekte, "Launch");
name: "Chief, main conversation",
});
await expect(projectEntry).toBeVisible({ timeout: 30_000 }); await expect(projectEntry).toBeVisible({ timeout: 30_000 });
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!; 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 { import {
captureScreenshot, captureScreenshot,
completeOnboarding, completeOnboarding,
openNewSpace, openNewSpace,
projectRow,
routePatchRpc, routePatchRpc,
rpc, rpc,
signup, signup,
unterhaltungenOnly, unterhaltungenOnly,
} from "./helpers"; } 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) => { test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create"); 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( await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
0, 0,
); );
await expect( await expect(projectRow(projekte, "Customer project")).toBeVisible();
projectSpace(projekte, "Customer project").getByRole("button", {
name: "Chief, main conversation",
}),
).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveCount(0); await expect(page.getByTestId("project-start-main")).toHaveCount(0);
await captureScreenshot(page, testInfo, "new-project-main-conversation"); 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); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
const supportSpace = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ const supportSpace = projectRow(projekte, "Customer support");
hasText: "Customer support", const supportSpaceId = (await supportSpace.getAttribute("data-testid"))?.replace(
}); /^project-row-/,
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group"); "",
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1]; );
expect(supportSpaceId).toBeTruthy(); expect(supportSpaceId).toBeTruthy();
// A project created from chat comes with its own main agent, reached through its conversation. // A project created from chat comes with its own main agent, reached through its row.
await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click(); await supportSpace.click();
await page.waitForURL(/\/app\/g\/[^/]+$/); await page.waitForURL(/\/app\/g\/[^/]+$/);
await expect await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .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. // The home main conversation stays the first entry while another space is open.
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); 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 expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "spaces-sidebar"); 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"); const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible(); await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect( await expect(projectRow(projekte, "Temporary")).toBeVisible();
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([ expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
expect.objectContaining({ kind: "main" }), 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"); await captureScreenshot(page, testInfo, "new-space-main-conversation");
// Deletion is discoverable through the visible Space actions menu. // Deletion is discoverable through the visible Space actions menu.
await projectSpace(projekte, "Temporary") await projectRow(projekte, "Temporary").hover();
.getByRole("button", { name: "Actions for Temporary" }) await projekte.getByRole("button", { name: "Actions for Temporary" }).click();
.click();
await captureScreenshot(page, testInfo, "space-actions-menu"); await captureScreenshot(page, testInfo, "space-actions-menu");
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
await page.getByRole("menuitem", { name: "Delete space" }).click(); 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/); await expect(page).not.toHaveURL(/\/onboarding/);
// The project's main conversation stays, so the project opens there. // The project's main conversation stays, so the project opens there.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect( await expect(projectRow(projekte, "Side")).toBeVisible();
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
).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 expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
await page.getByRole("menuitem", { name: "Delete space" }).click(); await page.getByRole("menuitem", { name: "Delete space" }).click();
await page await page

View File

@ -1,62 +1,18 @@
/** /**
* Sortable wrapper for a project (space) sidebar group card. * Sortable wrapper for a project in the sidebar. The whole row drags: a mouse after a short
* Drag listeners attach only to the grip handle via `useSortableGroupHandle`. * move, a finger after a short hold, so a plain click or scroll still works.
*/ */
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { useLingui } from "@lingui/react/macro"; import { memo, useMemo } from "react";
import { createContext, memo, useContext, useMemo } from "react";
interface SortableGroupProps { interface SortableGroupProps {
group: { spaceId: string; key: string; title?: string | null }; group: { spaceId: string; key: string; title?: string | null };
children: React.ReactNode; 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) { export const SortableGroup = memo(function SortableGroup({ group, children }: SortableGroupProps) {
const { const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: group.spaceId, id: group.spaceId,
}); });
@ -70,16 +26,9 @@ export const SortableGroup = memo(function SortableGroup({ group, children }: So
[transform, transition, isDragging], [transform, transition, isDragging],
); );
const handle = useMemo(
() => ({ setActivatorNodeRef, listeners, attributes }),
[setActivatorNodeRef, listeners, attributes],
);
return ( return (
<SortableGroupHandleContext.Provider value={handle}> <div ref={setNodeRef} style={style} data-drag-id={group.spaceId} {...listeners}>
<div ref={setNodeRef} style={style} data-drag-id={group.spaceId}>
{children} {children}
</div> </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" msgid "{name} stopped"
msgstr "{name} wurde gestoppt" 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 #: src/pages/shell/main-conversation-row.tsx
msgid "{name}, main conversation" msgid "{name}, main conversation"
msgstr "{name}, Hauptunterhaltung" msgstr "{name}, Hauptunterhaltung"
@ -461,6 +465,10 @@ msgstr "Aktionsbestätigungen"
msgid "Actions for {0}" msgid "Actions for {0}"
msgstr "Aktionen für {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 #: src/pages/Shell.tsx
msgid "Actions for space" msgid "Actions for space"
msgstr "Aktionen für Space" msgstr "Aktionen für Space"
@ -1067,6 +1075,7 @@ msgstr "Server nicht erreichbar."
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
#: src/pages/PrivacySettingsOverlay.tsx #: src/pages/PrivacySettingsOverlay.tsx
#: src/pages/shell/dialogs.tsx #: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Cancel" msgid "Cancel"
msgstr "Abbrechen" msgstr "Abbrechen"
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
msgstr "{0} einklappen" msgstr "{0} einklappen"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Color" msgid "Color"
msgstr "Farbe" msgstr "Farbe"
@ -1829,6 +1839,7 @@ msgstr "Routine konnte nicht ausgeführt werden"
#: src/pages/KnowledgeSection.tsx #: src/pages/KnowledgeSection.tsx
#: src/pages/ModelTierSettings.tsx #: src/pages/ModelTierSettings.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Could not save" msgid "Could not save"
msgstr "Speichern fehlgeschlagen" msgstr "Speichern fehlgeschlagen"
@ -2056,6 +2067,7 @@ msgid "Customer support"
msgstr "Kundensupport" msgstr "Kundensupport"
#: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/pages/Shell.tsx
msgid "Customize" msgid "Customize"
msgstr "Anpassen" msgstr "Anpassen"
@ -2280,6 +2292,7 @@ msgstr "Kein Stress — wir sind jetzt da."
#: src/components/ThreadCard.tsx #: src/components/ThreadCard.tsx
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/ProjectSessionTimeline.tsx #: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Done" msgid "Done"
msgstr "Fertig" msgstr "Fertig"
@ -2325,10 +2338,6 @@ msgstr "Skill-Entwurf"
msgid "Drafting the spec…" msgid "Drafting the spec…"
msgstr "Entwerfe die Spezifikation…" msgstr "Entwerfe die Spezifikation…"
#: src/components/SortableGroup.tsx
msgid "Drag to reorder"
msgstr "Ziehen zum Umsortieren"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Drag, drop, or paste an image" msgid "Drag, drop, or paste an image"
msgstr "Bild ziehen, ablegen oder einfügen" msgstr "Bild ziehen, ablegen oder einfügen"
@ -2897,6 +2906,10 @@ msgstr "Ich bin fertig"
msgid "I'm listening — what's the priority?" msgid "I'm listening — what's the priority?"
msgstr "Ich höre zu — was hat Priorität?" msgstr "Ich höre zu — was hat Priorität?"
#: src/pages/shell/project-look-dialog.tsx
msgid "Icon"
msgstr "Icon"
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
msgid "Identity" msgid "Identity"
msgstr "Identität" msgstr "Identität"
@ -3483,6 +3496,7 @@ msgstr "In Ordner verschieben"
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx #: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
@ -3520,6 +3534,7 @@ msgstr "Braucht dich"
#: src/pages/ActivityList.tsx #: src/pages/ActivityList.tsx
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/project-overview.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Needs you" msgid "Needs you"
msgstr "Braucht dich" msgstr "Braucht dich"
@ -4483,6 +4498,7 @@ msgstr "läuft"
#: src/components/ThreadCard.tsx #: src/components/ThreadCard.tsx
#: src/pages/ProjectSessionTimeline.tsx #: src/pages/ProjectSessionTimeline.tsx
#: src/pages/shell/project-overview.tsx
msgid "Running" msgid "Running"
msgstr "Läuft" 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/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx #: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx #: src/pages/shell/dialogs.tsx
#: src/pages/shell/project-look-dialog.tsx
msgid "Save" msgid "Save"
msgstr "Speichern" msgstr "Speichern"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,9 +3,8 @@ import { t } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import type { RunActivityRow } from "@rakazo/contracts"; import type { RunActivityRow } from "@rakazo/contracts";
import { runChatTitle } from "@rakazo/core"; import { runChatTitle } from "@rakazo/core";
import { useEffect, useState } from "react";
import { isNeedsYouStatus, runActivityLabel } from "../lib/agent-activity"; import { isNeedsYouStatus, runActivityLabel } from "../lib/agent-activity";
import { rpc } from "../lib/rpc"; import { useRunActivity } from "../lib/run-activity";
function statusTone(status: RunActivityRow["status"]): string { function statusTone(status: RunActivityRow["status"]): string {
if (status === "failed") return "text-destructive"; if (status === "failed") return "text-destructive";
@ -20,41 +19,8 @@ type ActivityListProps = {
}; };
export function ActivityList({ onOpenRun }: ActivityListProps) { export function ActivityList({ onOpenRun }: ActivityListProps) {
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]); const { active: activeRuns, recent: recentRuns, loaded } = useRunActivity();
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]); const loading = !loaded;
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);
};
}, []);
if (loading) { if (loading) {
return ( 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 { t } = useLingui();
const title = runChatTitle(run); const title = runChatTitle(run);
const label = statusLabel(run.status); const label = statusLabel(run.status);

View File

@ -40,6 +40,7 @@ import {
matchVoiceByName, matchVoiceByName,
type PaneState, type PaneState,
parseVoiceChatCommand, parseVoiceChatCommand,
projectThreads,
recordScreenPaneChoice, recordScreenPaneChoice,
reorderBotTo, reorderBotTo,
resolveComposerSendPlan, resolveComposerSendPlan,
@ -266,6 +267,10 @@ function voiceStatusRefreshTimeout(): Promise<never> {
export type NavPillar = "heute" | "team" | "projekte"; export type NavPillar = "heute" | "team" | "projekte";
import { SortableGroup } from "../components/SortableGroup"; 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() { export function ShellPage() {
const { t } = useLingui(); const { t } = useLingui();
@ -512,6 +517,7 @@ export function ShellPage() {
const [deleteTarget, setDeleteTarget] = useState<Bot | null>(null); const [deleteTarget, setDeleteTarget] = useState<Bot | null>(null);
const [deleteGroupTarget, setDeleteGroupTarget] = useState<Group | null>(null); const [deleteGroupTarget, setDeleteGroupTarget] = useState<Group | null>(null);
const [deleteSpaceTarget, setDeleteSpaceTarget] = useState<Space | null>(null); const [deleteSpaceTarget, setDeleteSpaceTarget] = useState<Space | null>(null);
const [lookTarget, setLookTarget] = useState<Space | null>(null);
const [spaceMenu, setSpaceMenu] = useState<{ const [spaceMenu, setSpaceMenu] = useState<{
id: string; id: string;
position: ContextMenuPosition; position: ContextMenuPosition;
@ -1824,6 +1830,7 @@ export function ShellPage() {
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]); }, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
const heuteActionCount = useHeuteActionCount(groups, botsRef); const heuteActionCount = useHeuteActionCount(groups, botsRef);
const runActivity = useRunActivity();
useShellSounds({ useShellSounds({
bots, bots,
transcriptRunning, transcriptRunning,
@ -3069,6 +3076,64 @@ export function ShellPage() {
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
{projectSidebarGroups.map((group) => { {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 = const collapsed =
Boolean(group.title) && collapsedSidebarSections.has(group.key); Boolean(group.title) && collapsedSidebarSections.has(group.key);
return ( return (
@ -3414,6 +3479,25 @@ export function ShellPage() {
enrichMember={enrichStageMember} enrichMember={enrichStageMember}
/> />
) : null} ) : 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 <Transcript
key={activeSnapshot?.threadId} key={activeSnapshot?.threadId}
encounterMode={encounterMode} encounterMode={encounterMode}
@ -4031,6 +4115,21 @@ export function ShellPage() {
label={t`Actions for space`} label={t`Actions for space`}
onClose={closeSpaceMenu} 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 <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
@ -4042,9 +4141,22 @@ export function ShellPage() {
<Trash2 /> <Trash2 />
{t`Delete space`} {t`Delete space`}
</DropdownMenuItem> </DropdownMenuItem>
) : null}
</PointerMenu> </PointerMenu>
) : null} ) : 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 ? ( {deleteTarget ? (
<DeleteBotDialog <DeleteBotDialog
bot={deleteTarget} 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. */ /** One look for sidebar conversation rows: the main conversation entry and the bot roster. */
export function rosterRowClass(selected: boolean): string { 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 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-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" : "border-transparent hover:bg-sidebar-accent/50"

View File

@ -1,9 +1,8 @@
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import type { BotSection, Space } from "@rakazo/contracts"; import type { BotSection, Space } from "@rakazo/contracts";
import { Button } from "@rakazo/ui-web"; 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 type { RefObject } from "react";
import { ProjectDragGrip } from "../../components/SortableGroup";
import type { ContextMenuPosition } from "../BotContextMenu"; import type { ContextMenuPosition } from "../BotContextMenu";
export type SidebarGroupHeaderGroup = { 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. * spaces and select an empty space instead of opening it.
*/ */
export function SidebarGroupHeader({ export function SidebarGroupHeader({
@ -59,20 +58,10 @@ export function SidebarGroupHeader({
const { t } = useLingui(); const { t } = useLingui();
const project = variant === "project"; const project = variant === "project";
return ( return (
<div <div className="flex items-center pt-3 pb-0.5">
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}
<button <button
type="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={() => { onClick={() => {
if (group.emptySpaceId) { if (group.emptySpaceId) {
onOpenEmptySpace(group.emptySpaceId); onOpenEmptySpace(group.emptySpaceId);

View File

@ -1,45 +1,19 @@
import type { Bot, Group } from "@rakazo/contracts"; import type { Bot, Group } from "@rakazo/contracts";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useEffect, useState } from "react"; import { useMemo } from "react";
import { buildHeuteBrief } from "../../lib/heute-brief"; 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 { export function useHeuteActionCount(groups: Group[], botsRef: RefObject<Bot[]>): number {
const [heuteActionCount, setHeuteActionCount] = useState(0); const { active, recent } = useRunActivity();
return useMemo(
useEffect(() => { () =>
let cancelled = false; buildHeuteBrief({
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({
bots: botsRef.current, bots: botsRef.current,
groups, groups,
activeRuns: active.runs, activeRuns: active,
recentRuns: recent.runs, recentRuns: recent,
}); }).actionCount,
setHeuteActionCount(brief.actionCount); [active, recent, groups, botsRef],
} 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;
} }

View File

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

View File

@ -294,11 +294,16 @@ export type SpaceWorkspace = z.infer<typeof SpaceWorkspaceSchema>;
export const SpaceSchema = z.object({ export const SpaceSchema = z.object({
id: Id, id: Id,
name: z.string(), 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(), isDefault: z.boolean(),
/** True when the space has any bot or group, including archived. */ /** True when the space has any bot or group, including archived. */
hasContent: z.boolean(), hasContent: z.boolean(),
/** True only when the current member may delete this non-default space. */ /** True only when the current member may delete this non-default space. */
canDelete: z.boolean().optional(), 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(), workspace: SpaceWorkspaceSchema.nullable().optional(),
bots: z.array(SpaceBotSchema), bots: z.array(SpaceBotSchema),
groups: z.array(SpaceGroupSchema), groups: z.array(SpaceGroupSchema),

View File

@ -258,6 +258,17 @@ export const appContract = {
}), }),
) )
.output(SpaceSchema), .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 remove: oc
.input(z.object({ spaceId: Id })) .input(z.object({ spaceId: Id }))
.output(z.object({ ok: z.literal(true), activeSpaceId: 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-motion.js";
export * from "./oo-palette.js"; export * from "./oo-palette.js";
export * from "./oo-rig.js"; export * from "./oo-rig.js";
export * from "./project-look.js";
export * from "./project-threads.js";
export * from "./quiet-hours.js"; export * from "./quiet-hours.js";
export * from "./response-bytes.js"; export * from "./response-bytes.js";
export * from "./routine-events.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 organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String name String
/// Project look in the sidebar: an icon id from the shared catalog and a palette color.
icon String?
color String?
workspacePath String? workspacePath String?
gitRemoteUrl String? gitRemoteUrl String?
gitDefaultBranch String? gitDefaultBranch String?

View File

@ -15,7 +15,7 @@ import {
} from "./main-agent.js"; } from "./main-agent.js";
import { createRepos } from "./repos.js"; import { createRepos } from "./repos.js";
import { requireMembership } from "./scope.js"; import { requireMembership } from "./scope.js";
import { createSpaceForMember } from "./spaces.js"; import { createSpaceForMember, updateSpaceForOwner } from "./spaces.js";
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
const describePostgres = const describePostgres =
@ -102,6 +102,33 @@ describePostgres("main agent and main conversation (PostgreSQL)", () => {
expect(named.id).not.toBe(homeSpaceId); 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 () => { it("gives every project its own main bot and main conversation next to the home one", async () => {
const { userId, homeSpaceId } = await newUser(); const { userId, homeSpaceId } = await newUser();
const home = await ensureMainConversation(prisma, userId); 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. */ /** Create a sibling privacy boundary for a member of the active organization. */
export async function createSpaceForMember( export async function createSpaceForMember(
prisma: PrismaClient, prisma: PrismaClient,