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:
parent
1c8d13a837
commit
6ace14c3c8
@ -116,6 +116,8 @@ import {
|
||||
expandSkillReferencesInPrompt,
|
||||
hasMixedOneShotSchedule,
|
||||
isOneShotRoutineCrons,
|
||||
isProjectColor,
|
||||
isProjectIcon,
|
||||
isValidAvatarValue,
|
||||
nextCronDateAcrossStrict,
|
||||
} from "@rakazo/core";
|
||||
@ -168,6 +170,7 @@ import {
|
||||
selectSpaceModelPreference,
|
||||
selectSpaceVoicePreference,
|
||||
updateNotificationSettings,
|
||||
updateSpaceForOwner,
|
||||
wireProjectWorkspace,
|
||||
} from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
@ -755,6 +758,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
isDefault: false,
|
||||
hasContent: false,
|
||||
canDelete: true,
|
||||
canEdit: true,
|
||||
workspace,
|
||||
bots: bots.map(spaceBotDto),
|
||||
groups: groups.map(spaceGroupDto),
|
||||
@ -776,6 +780,27 @@ export function createRouter(deps: RouterDeps) {
|
||||
spaceId: conversation.spaceId,
|
||||
};
|
||||
}),
|
||||
update: authed.spaces.update.handler(async ({ context, input }) => {
|
||||
if (input.icon !== undefined && !isProjectIcon(input.icon)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
|
||||
}
|
||||
if (input.color !== undefined && !isProjectColor(input.color)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
|
||||
}
|
||||
try {
|
||||
await updateSpaceForOwner(deps.prisma, { userId: context.actor.userId, ...input });
|
||||
} catch (error) {
|
||||
if (error instanceof SpaceNotFoundError) {
|
||||
throw new ORPCError("NOT_FOUND", { message: error.message });
|
||||
}
|
||||
if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN");
|
||||
if (error instanceof InvalidSpaceNameError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { ok: true as const };
|
||||
}),
|
||||
remove: authed.spaces.remove.handler(async ({ context, input }) => {
|
||||
let claimId: string | null = null;
|
||||
let claimActive = false;
|
||||
@ -5444,6 +5469,8 @@ async function spaceNavigationDto(
|
||||
space: {
|
||||
select: {
|
||||
name: true,
|
||||
icon: true,
|
||||
color: true,
|
||||
isDefault: true,
|
||||
deletingAt: true,
|
||||
workspacePath: true,
|
||||
@ -5524,6 +5551,8 @@ async function spaceNavigationDto(
|
||||
return {
|
||||
id: membership.spaceId,
|
||||
name: membership.space.name,
|
||||
icon: membership.space.icon,
|
||||
color: membership.space.color,
|
||||
isDefault: membership.space.isDefault,
|
||||
hasContent: spacesWithContent.has(membership.spaceId),
|
||||
canDelete:
|
||||
@ -5532,6 +5561,7 @@ async function spaceNavigationDto(
|
||||
memberships.length > 1 &&
|
||||
!spacesWithContent.has(membership.spaceId) &&
|
||||
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
|
||||
canEdit: membership.role === "owner",
|
||||
// A linked folder is on the owner's host; other members see neither its path nor remote.
|
||||
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
|
||||
bots: spaceBots.map(spaceBotDto),
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
type SpaceBot,
|
||||
type SpaceGroup,
|
||||
} from "@rakazo/contracts";
|
||||
import { runChatTitle } from "@rakazo/core";
|
||||
import { projectLook, runChatTitle } from "@rakazo/core";
|
||||
import { botColors } from "@rakazo/ui-tokens";
|
||||
import { Redirect, useFocusEffect, useRouter } from "expo-router";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@ -599,6 +599,7 @@ export default function Home() {
|
||||
}}
|
||||
style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
|
||||
>
|
||||
<ProjectTile space={item.space} />
|
||||
<Text style={styles.spaceTitle}>{item.title}</Text>
|
||||
</Pressable>
|
||||
{canDeleteInboxSpace(item.space) ? (
|
||||
@ -907,6 +908,16 @@ function ConversationRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectTile({ space }: { space: Pick<InboxSpace, "icon" | "color"> }) {
|
||||
const styles = useThemedStyles(createHomeStyles);
|
||||
const { icon, color } = projectLook(space);
|
||||
return (
|
||||
<View style={[styles.projectTile, { backgroundColor: color.hex }]}>
|
||||
<NativeSymbol ios={icon.ios} android={icon.android as never} size={15} color={color.ink} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CircleButton({
|
||||
children,
|
||||
onPress,
|
||||
@ -1192,6 +1203,15 @@ function createHomeStyles() {
|
||||
spaceSelect: {
|
||||
flex: 1,
|
||||
minHeight: 44,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
},
|
||||
projectTile: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
spaceTitle: {
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
|
||||
export type InboxSpace = Pick<
|
||||
MobileSpace,
|
||||
"id" | "name" | "isDefault" | "hasContent" | "canDelete" | "botSections"
|
||||
"id" | "name" | "isDefault" | "hasContent" | "canDelete" | "botSections" | "icon" | "color"
|
||||
> & {
|
||||
bots: (MobileBot | SpaceBot)[];
|
||||
groups: (MobileGroup | SpaceGroup)[];
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { completeOnboarding, routePatchRpc, rpc, signup } from "./helpers";
|
||||
import { completeOnboarding, projectRow, routePatchRpc, rpc, signup } from "./helpers";
|
||||
|
||||
test("empty project space offers Start before the main conversation exists", async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
@ -40,8 +40,7 @@ test("empty project space offers Start before the main conversation exists", asy
|
||||
await expect(mainStart).toBeVisible({ timeout: 30_000 });
|
||||
await expect(mainStart).toContainText(/Start|Starten/i);
|
||||
|
||||
// The sidebar lists the project as one row; opening it starts the main conversation.
|
||||
const projekte = page.locator(".sidebar-projekte");
|
||||
const sidebarStart = projekte.getByTestId(`project-start-${side.id}`);
|
||||
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sidebarStart).toContainText(/Start|Starten/i);
|
||||
await expect(projectRow(projekte, "Empty project")).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
@ -12,6 +12,11 @@ export function unterhaltungenOnly(page: Page) {
|
||||
return page.locator(".sidebar-unterhaltungen").locator(":scope > *:not(.sidebar-projekte)");
|
||||
}
|
||||
|
||||
/** A project's row in the sidebar; it opens the project's main conversation. */
|
||||
export function projectRow(scope: Page | Locator, name: string) {
|
||||
return scope.getByTestId(/^project-row-/).filter({ hasText: name });
|
||||
}
|
||||
|
||||
export function isRealSandboxProvider(provider = process.env.SANDBOX_PROVIDER) {
|
||||
return provider === "e2b" || provider === "daytona" || provider === "box";
|
||||
}
|
||||
|
||||
77
apps/web/e2e/project-coordinator.spec.ts
Normal file
77
apps/web/e2e/project-coordinator.spec.ts
Normal 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");
|
||||
});
|
||||
@ -1,9 +1,12 @@
|
||||
import { expect, type Locator, test } from "@playwright/test";
|
||||
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
|
||||
|
||||
function projectSpace(projekte: Locator, name: string) {
|
||||
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
|
||||
}
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
captureScreenshot,
|
||||
completeOnboarding,
|
||||
projectRow,
|
||||
rpc,
|
||||
signup,
|
||||
waitForChatComposer,
|
||||
} from "./helpers";
|
||||
|
||||
test("a new project opens straight into the conversation with its own main agent", async ({
|
||||
page,
|
||||
@ -26,9 +29,7 @@ test("a new project opens straight into the conversation with its own main agent
|
||||
await waitForChatComposer(page);
|
||||
|
||||
// The project is one conversation, led by a main agent of its own.
|
||||
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
|
||||
name: "Chief, main conversation",
|
||||
});
|
||||
const projectEntry = projectRow(projekte, "Launch");
|
||||
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
||||
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
|
||||
|
||||
@ -1,21 +1,15 @@
|
||||
import { expect, type Locator, test } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
captureScreenshot,
|
||||
completeOnboarding,
|
||||
openNewSpace,
|
||||
projectRow,
|
||||
routePatchRpc,
|
||||
rpc,
|
||||
signup,
|
||||
unterhaltungenOnly,
|
||||
} from "./helpers";
|
||||
|
||||
function projectSpace(projekte: Locator, name: string) {
|
||||
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({
|
||||
hasText: name,
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
|
||||
@ -42,11 +36,7 @@ test("Projects + opens new project dialog instead of group form", async ({ page
|
||||
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
projectSpace(projekte, "Customer project").getByRole("button", {
|
||||
name: "Chief, main conversation",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(projectRow(projekte, "Customer project")).toBeVisible();
|
||||
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
|
||||
await captureScreenshot(page, testInfo, "new-project-main-conversation");
|
||||
});
|
||||
@ -118,14 +108,14 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
||||
);
|
||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
|
||||
const supportSpace = projekte.locator('[data-sidebar-group^="project-space:"]').filter({
|
||||
hasText: "Customer support",
|
||||
});
|
||||
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
|
||||
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
|
||||
const supportSpace = projectRow(projekte, "Customer support");
|
||||
const supportSpaceId = (await supportSpace.getAttribute("data-testid"))?.replace(
|
||||
/^project-row-/,
|
||||
"",
|
||||
);
|
||||
expect(supportSpaceId).toBeTruthy();
|
||||
// A project created from chat comes with its own main agent, reached through its conversation.
|
||||
await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click();
|
||||
// A project created from chat comes with its own main agent, reached through its row.
|
||||
await supportSpace.click();
|
||||
await page.waitForURL(/\/app\/g\/[^/]+$/);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
||||
@ -134,7 +124,7 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
||||
// The home main conversation stays the first entry while another space is open.
|
||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
||||
await captureScreenshot(page, testInfo, "spaces-sidebar");
|
||||
|
||||
@ -168,9 +158,7 @@ test("a new space opens its main conversation and can be deleted from its menu",
|
||||
const projekte = page.locator(".sidebar-projekte");
|
||||
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
|
||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||
await expect(
|
||||
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
|
||||
).toBeVisible();
|
||||
await expect(projectRow(projekte, "Temporary")).toBeVisible();
|
||||
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
||||
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
|
||||
expect.objectContaining({ kind: "main" }),
|
||||
@ -178,9 +166,8 @@ test("a new space opens its main conversation and can be deleted from its menu",
|
||||
await captureScreenshot(page, testInfo, "new-space-main-conversation");
|
||||
|
||||
// Deletion is discoverable through the visible Space actions menu.
|
||||
await projectSpace(projekte, "Temporary")
|
||||
.getByRole("button", { name: "Actions for Temporary" })
|
||||
.click();
|
||||
await projectRow(projekte, "Temporary").hover();
|
||||
await projekte.getByRole("button", { name: "Actions for Temporary" }).click();
|
||||
await captureScreenshot(page, testInfo, "space-actions-menu");
|
||||
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "Delete space" }).click();
|
||||
@ -242,11 +229,10 @@ test("deleting the last bot in a space stays in the app after first use", async
|
||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||
// The project's main conversation stays, so the project opens there.
|
||||
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||
await expect(
|
||||
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
|
||||
).toBeVisible();
|
||||
await expect(projectRow(projekte, "Side")).toBeVisible();
|
||||
|
||||
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
|
||||
await projectRow(projekte, "Side").hover();
|
||||
await projekte.getByRole("button", { name: "Actions for Side" }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "Delete space" }).click();
|
||||
await page
|
||||
|
||||
@ -1,62 +1,18 @@
|
||||
/**
|
||||
* Sortable wrapper for a project (space) sidebar group card.
|
||||
* Drag listeners attach only to the grip handle via `useSortableGroupHandle`.
|
||||
* Sortable wrapper for a project in the sidebar. The whole row drags: a mouse after a short
|
||||
* move, a finger after a short hold, so a plain click or scroll still works.
|
||||
*/
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
interface SortableGroupProps {
|
||||
group: { spaceId: string; key: string; title?: string | null };
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
type SortableGroupHandle = {
|
||||
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||
listeners: ReturnType<typeof useSortable>["listeners"];
|
||||
attributes: ReturnType<typeof useSortable>["attributes"];
|
||||
};
|
||||
|
||||
const SortableGroupHandleContext = createContext<SortableGroupHandle | null>(null);
|
||||
|
||||
export function useSortableGroupHandle(): SortableGroupHandle | null {
|
||||
return useContext(SortableGroupHandleContext);
|
||||
}
|
||||
|
||||
export const ProjectDragGrip = memo(function ProjectDragGrip({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const handle = useSortableGroupHandle();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={handle?.setActivatorNodeRef}
|
||||
className={className}
|
||||
aria-label={t`Drag to reorder`}
|
||||
{...handle?.attributes}
|
||||
{...handle?.listeners}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export const SortableGroup = memo(function SortableGroup({ group, children }: SortableGroupProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: group.spaceId,
|
||||
});
|
||||
|
||||
@ -70,16 +26,9 @@ export const SortableGroup = memo(function SortableGroup({ group, children }: So
|
||||
[transform, transition, isDragging],
|
||||
);
|
||||
|
||||
const handle = useMemo(
|
||||
() => ({ setActivatorNodeRef, listeners, attributes }),
|
||||
[setActivatorNodeRef, listeners, attributes],
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableGroupHandleContext.Provider value={handle}>
|
||||
<div ref={setNodeRef} style={style} data-drag-id={group.spaceId}>
|
||||
{children}
|
||||
</div>
|
||||
</SortableGroupHandleContext.Provider>
|
||||
<div ref={setNodeRef} style={style} data-drag-id={group.spaceId} {...listeners}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
167
apps/web/src/components/project-icon.tsx
Normal file
167
apps/web/src/components/project-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/web/src/lib/run-activity.ts
Normal file
59
apps/web/src/lib/run-activity.ts
Normal 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();
|
||||
}
|
||||
@ -352,6 +352,10 @@ msgstr "{name} ergänzt: {line}"
|
||||
msgid "{name} stopped"
|
||||
msgstr "{name} wurde gestoppt"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "{name}, {needsYou} need you"
|
||||
msgstr "{name}, {needsYou} brauchen dich"
|
||||
|
||||
#: src/pages/shell/main-conversation-row.tsx
|
||||
msgid "{name}, main conversation"
|
||||
msgstr "{name}, Hauptunterhaltung"
|
||||
@ -461,6 +465,10 @@ msgstr "Aktionsbestätigungen"
|
||||
msgid "Actions for {0}"
|
||||
msgstr "Aktionen für {0}"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "Actions for {name}"
|
||||
msgstr "Aktionen für {name}"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Actions for space"
|
||||
msgstr "Aktionen für Space"
|
||||
@ -1067,6 +1075,7 @@ msgstr "Server nicht erreichbar."
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
||||
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
|
||||
msgstr "{0} einklappen"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Color"
|
||||
msgstr "Farbe"
|
||||
|
||||
@ -1829,6 +1839,7 @@ msgstr "Routine konnte nicht ausgeführt werden"
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/ModelTierSettings.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Could not save"
|
||||
msgstr "Speichern fehlgeschlagen"
|
||||
|
||||
@ -2056,6 +2067,7 @@ msgid "Customer support"
|
||||
msgstr "Kundensupport"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Customize"
|
||||
msgstr "Anpassen"
|
||||
|
||||
@ -2280,6 +2292,7 @@ msgstr "Kein Stress — wir sind jetzt da."
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Done"
|
||||
msgstr "Fertig"
|
||||
|
||||
@ -2325,10 +2338,6 @@ msgstr "Skill-Entwurf"
|
||||
msgid "Drafting the spec…"
|
||||
msgstr "Entwerfe die Spezifikation…"
|
||||
|
||||
#: src/components/SortableGroup.tsx
|
||||
msgid "Drag to reorder"
|
||||
msgstr "Ziehen zum Umsortieren"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Drag, drop, or paste an image"
|
||||
msgstr "Bild ziehen, ablegen oder einfügen"
|
||||
@ -2897,6 +2906,10 @@ msgstr "Ich bin fertig"
|
||||
msgid "I'm listening — what's the priority?"
|
||||
msgstr "Ich höre zu — was hat Priorität?"
|
||||
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Icon"
|
||||
msgstr "Icon"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Identity"
|
||||
msgstr "Identität"
|
||||
@ -3483,6 +3496,7 @@ msgstr "In Ordner verschieben"
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
@ -3520,6 +3534,7 @@ msgstr "Braucht dich"
|
||||
#: src/pages/ActivityList.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Needs you"
|
||||
msgstr "Braucht dich"
|
||||
@ -4483,6 +4498,7 @@ msgstr "läuft"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Running"
|
||||
msgstr "Läuft"
|
||||
|
||||
@ -4518,6 +4534,7 @@ msgstr "Läuft, wenn dieser Bot eine verifizierte Nachricht von diesem Anbieter
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Save"
|
||||
msgstr "Speichern"
|
||||
|
||||
|
||||
@ -352,6 +352,10 @@ msgstr "{name} riffed: {line}"
|
||||
msgid "{name} stopped"
|
||||
msgstr "{name} stopped"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "{name}, {needsYou} need you"
|
||||
msgstr "{name}, {needsYou} need you"
|
||||
|
||||
#: src/pages/shell/main-conversation-row.tsx
|
||||
msgid "{name}, main conversation"
|
||||
msgstr "{name}, main conversation"
|
||||
@ -461,6 +465,10 @@ msgstr "Action confirmations"
|
||||
msgid "Actions for {0}"
|
||||
msgstr "Actions for {0}"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "Actions for {name}"
|
||||
msgstr "Actions for {name}"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Actions for space"
|
||||
msgstr "Actions for space"
|
||||
@ -1067,6 +1075,7 @@ msgstr "Can't reach the server."
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
|
||||
msgstr "Collapse {0}"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Color"
|
||||
msgstr "Color"
|
||||
|
||||
@ -1829,6 +1839,7 @@ msgstr "Could not run routine"
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/ModelTierSettings.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Could not save"
|
||||
msgstr "Could not save"
|
||||
|
||||
@ -2056,6 +2067,7 @@ msgid "Customer support"
|
||||
msgstr "Customer support"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Customize"
|
||||
msgstr "Customize"
|
||||
|
||||
@ -2280,6 +2292,7 @@ msgstr "Don't stress — we're here now."
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Done"
|
||||
msgstr "Done"
|
||||
|
||||
@ -2325,10 +2338,6 @@ msgstr "Draft skill"
|
||||
msgid "Drafting the spec…"
|
||||
msgstr "Drafting the spec…"
|
||||
|
||||
#: src/components/SortableGroup.tsx
|
||||
msgid "Drag to reorder"
|
||||
msgstr "Drag to reorder"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Drag, drop, or paste an image"
|
||||
msgstr "Drag, drop, or paste an image"
|
||||
@ -2897,6 +2906,10 @@ msgstr "I’m done"
|
||||
msgid "I'm listening — what's the priority?"
|
||||
msgstr "I'm listening — what's the priority?"
|
||||
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Icon"
|
||||
msgstr "Icon"
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Identity"
|
||||
msgstr "Identity"
|
||||
@ -3483,6 +3496,7 @@ msgstr "Move to folder"
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
@ -3520,6 +3534,7 @@ msgstr "Needs you"
|
||||
#: src/pages/ActivityList.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Needs you"
|
||||
msgstr "Needs you"
|
||||
@ -4483,6 +4498,7 @@ msgstr "running"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Running"
|
||||
msgstr "Running"
|
||||
|
||||
@ -4518,6 +4534,7 @@ msgstr "Runs when this bot receives a verified message from this provider."
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Save"
|
||||
msgstr "Save"
|
||||
|
||||
|
||||
@ -352,6 +352,10 @@ msgstr ""
|
||||
msgid "{name} stopped"
|
||||
msgstr "{name} se detuvo"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "{name}, {needsYou} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/main-conversation-row.tsx
|
||||
msgid "{name}, main conversation"
|
||||
msgstr ""
|
||||
@ -461,6 +465,10 @@ msgstr "Confirmaciones de acción"
|
||||
msgid "Actions for {0}"
|
||||
msgstr "Acciones para {0}"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "Actions for {name}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Actions for space"
|
||||
msgstr ""
|
||||
@ -1067,6 +1075,7 @@ msgstr "No se puede alcanzar el servidor."
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Cancelar"
|
||||
|
||||
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
|
||||
msgstr "Contraer {0}"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Color"
|
||||
msgstr "Color"
|
||||
|
||||
@ -1829,6 +1839,7 @@ msgstr "No se pudo ejecutar la rutina"
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/ModelTierSettings.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Could not save"
|
||||
msgstr "No se pudo guardar"
|
||||
|
||||
@ -2056,6 +2067,7 @@ msgid "Customer support"
|
||||
msgstr "Atención al cliente"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Customize"
|
||||
msgstr ""
|
||||
|
||||
@ -2280,6 +2292,7 @@ msgstr ""
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Done"
|
||||
msgstr "Listo"
|
||||
|
||||
@ -2325,10 +2338,6 @@ msgstr "Habilidad en borrador"
|
||||
msgid "Drafting the spec…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/SortableGroup.tsx
|
||||
msgid "Drag to reorder"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Drag, drop, or paste an image"
|
||||
msgstr "Arrastra, suelta o pega una imagen"
|
||||
@ -2897,6 +2906,10 @@ msgstr "Listo"
|
||||
msgid "I'm listening — what's the priority?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Icon"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
@ -3483,6 +3496,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Name"
|
||||
msgstr "Nombre"
|
||||
|
||||
@ -3520,6 +3534,7 @@ msgstr ""
|
||||
#: src/pages/ActivityList.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Needs you"
|
||||
msgstr ""
|
||||
@ -4483,6 +4498,7 @@ msgstr "en ejecución"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Running"
|
||||
msgstr "En ejecución"
|
||||
|
||||
@ -4518,6 +4534,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Save"
|
||||
msgstr "Guardar"
|
||||
|
||||
|
||||
@ -352,6 +352,10 @@ msgstr ""
|
||||
msgid "{name} stopped"
|
||||
msgstr "{name} रोक दिया गया"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "{name}, {needsYou} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/main-conversation-row.tsx
|
||||
msgid "{name}, main conversation"
|
||||
msgstr ""
|
||||
@ -461,6 +465,10 @@ msgstr "कार्रवाई की पुष्टि"
|
||||
msgid "Actions for {0}"
|
||||
msgstr "{0} के लिए कार्रवाइयां"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "Actions for {name}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Actions for space"
|
||||
msgstr ""
|
||||
@ -1067,6 +1075,7 @@ msgstr "सर्वर तक नहीं पहुंचा जा सका
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "रद्द करें"
|
||||
|
||||
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Color"
|
||||
msgstr "रंग"
|
||||
|
||||
@ -1829,6 +1839,7 @@ msgstr ""
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/ModelTierSettings.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Could not save"
|
||||
msgstr "सहेजा नहीं जा सका"
|
||||
|
||||
@ -2056,6 +2067,7 @@ msgid "Customer support"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Customize"
|
||||
msgstr ""
|
||||
|
||||
@ -2280,6 +2292,7 @@ msgstr ""
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Done"
|
||||
msgstr "पूर्ण"
|
||||
|
||||
@ -2325,10 +2338,6 @@ msgstr "ड्राफ़्ट स्किल"
|
||||
msgid "Drafting the spec…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/SortableGroup.tsx
|
||||
msgid "Drag to reorder"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Drag, drop, or paste an image"
|
||||
msgstr "छवि खींचें, छोड़ें या पेस्ट करें"
|
||||
@ -2897,6 +2906,10 @@ msgstr "मेरा काम हो गया"
|
||||
msgid "I'm listening — what's the priority?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Icon"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
@ -3483,6 +3496,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Name"
|
||||
msgstr "नाम"
|
||||
|
||||
@ -3520,6 +3534,7 @@ msgstr ""
|
||||
#: src/pages/ActivityList.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Needs you"
|
||||
msgstr ""
|
||||
@ -4483,6 +4498,7 @@ msgstr "चल रहा है"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Running"
|
||||
msgstr "चल रहा है"
|
||||
|
||||
@ -4518,6 +4534,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Save"
|
||||
msgstr "सहेजें"
|
||||
|
||||
|
||||
@ -352,6 +352,10 @@ msgstr ""
|
||||
msgid "{name} stopped"
|
||||
msgstr "{name} 중지됨"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "{name}, {needsYou} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/main-conversation-row.tsx
|
||||
msgid "{name}, main conversation"
|
||||
msgstr ""
|
||||
@ -461,6 +465,10 @@ msgstr "작업 실행 전 확인"
|
||||
msgid "Actions for {0}"
|
||||
msgstr "{0} 작업 메뉴"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "Actions for {name}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Actions for space"
|
||||
msgstr ""
|
||||
@ -1067,6 +1075,7 @@ msgstr "서버에 연결할 수 없습니다."
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "취소"
|
||||
|
||||
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
|
||||
msgstr "{0} 접기"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Color"
|
||||
msgstr "색상"
|
||||
|
||||
@ -1829,6 +1839,7 @@ msgstr "루틴을 실행할 수 없습니다"
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/ModelTierSettings.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Could not save"
|
||||
msgstr "저장하지 못했습니다."
|
||||
|
||||
@ -2056,6 +2067,7 @@ msgid "Customer support"
|
||||
msgstr "고객 지원"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Customize"
|
||||
msgstr ""
|
||||
|
||||
@ -2280,6 +2292,7 @@ msgstr ""
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Done"
|
||||
msgstr "완료"
|
||||
|
||||
@ -2325,10 +2338,6 @@ msgstr "작업 기술 초안"
|
||||
msgid "Drafting the spec…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/SortableGroup.tsx
|
||||
msgid "Drag to reorder"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Drag, drop, or paste an image"
|
||||
msgstr "이미지를 끌어다 놓거나 붙여넣으세요"
|
||||
@ -2897,6 +2906,10 @@ msgstr "조작 끝내기"
|
||||
msgid "I'm listening — what's the priority?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Icon"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
@ -3483,6 +3496,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Name"
|
||||
msgstr "이름"
|
||||
|
||||
@ -3520,6 +3534,7 @@ msgstr ""
|
||||
#: src/pages/ActivityList.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Needs you"
|
||||
msgstr ""
|
||||
@ -4483,6 +4498,7 @@ msgstr "실행 중"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Running"
|
||||
msgstr "실행 중"
|
||||
|
||||
@ -4518,6 +4534,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Save"
|
||||
msgstr "저장"
|
||||
|
||||
|
||||
@ -352,6 +352,10 @@ msgstr ""
|
||||
msgid "{name} stopped"
|
||||
msgstr "{name} foi interrompido"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "{name}, {needsYou} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/main-conversation-row.tsx
|
||||
msgid "{name}, main conversation"
|
||||
msgstr ""
|
||||
@ -461,6 +465,10 @@ msgstr "Confirmações de ação"
|
||||
msgid "Actions for {0}"
|
||||
msgstr "Ações para {0}"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "Actions for {name}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Actions for space"
|
||||
msgstr ""
|
||||
@ -1067,6 +1075,7 @@ msgstr "Não é possível acessar o servidor."
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Cancelar"
|
||||
|
||||
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Color"
|
||||
msgstr "Cor"
|
||||
|
||||
@ -1829,6 +1839,7 @@ msgstr ""
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/ModelTierSettings.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Could not save"
|
||||
msgstr "Não foi possível salvar"
|
||||
|
||||
@ -2056,6 +2067,7 @@ msgid "Customer support"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Customize"
|
||||
msgstr ""
|
||||
|
||||
@ -2280,6 +2292,7 @@ msgstr ""
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Done"
|
||||
msgstr "Concluído"
|
||||
|
||||
@ -2325,10 +2338,6 @@ msgstr "Habilidade de rascunho"
|
||||
msgid "Drafting the spec…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/SortableGroup.tsx
|
||||
msgid "Drag to reorder"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Drag, drop, or paste an image"
|
||||
msgstr "Arraste, solte ou cole uma imagem"
|
||||
@ -2897,6 +2906,10 @@ msgstr "Eu terminei."
|
||||
msgid "I'm listening — what's the priority?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Icon"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
@ -3483,6 +3496,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Name"
|
||||
msgstr "Nome"
|
||||
|
||||
@ -3520,6 +3534,7 @@ msgstr ""
|
||||
#: src/pages/ActivityList.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Needs you"
|
||||
msgstr ""
|
||||
@ -4483,6 +4498,7 @@ msgstr "em execução"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Running"
|
||||
msgstr "Em execução"
|
||||
|
||||
@ -4518,6 +4534,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Save"
|
||||
msgstr "Salvar"
|
||||
|
||||
|
||||
@ -352,6 +352,10 @@ msgstr ""
|
||||
msgid "{name} stopped"
|
||||
msgstr "{name}: остановлено"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "{name}, {needsYou} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/main-conversation-row.tsx
|
||||
msgid "{name}, main conversation"
|
||||
msgstr ""
|
||||
@ -461,6 +465,10 @@ msgstr "Подтверждения действий"
|
||||
msgid "Actions for {0}"
|
||||
msgstr "Действия для {0}"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "Actions for {name}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Actions for space"
|
||||
msgstr "Действия для пространства"
|
||||
@ -1067,6 +1075,7 @@ msgstr "Не удаётся связаться с сервером."
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Отмена"
|
||||
|
||||
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
|
||||
msgstr "Свернуть {0}"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Color"
|
||||
msgstr "Цвет"
|
||||
|
||||
@ -1829,6 +1839,7 @@ msgstr "Не удалось запустить задачу"
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/ModelTierSettings.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Could not save"
|
||||
msgstr "Не удалось сохранить"
|
||||
|
||||
@ -2056,6 +2067,7 @@ msgid "Customer support"
|
||||
msgstr "Поддержка клиентов"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Customize"
|
||||
msgstr ""
|
||||
|
||||
@ -2280,6 +2292,7 @@ msgstr ""
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Done"
|
||||
msgstr "Готово"
|
||||
|
||||
@ -2325,10 +2338,6 @@ msgstr "Черновик навыка"
|
||||
msgid "Drafting the spec…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/SortableGroup.tsx
|
||||
msgid "Drag to reorder"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Drag, drop, or paste an image"
|
||||
msgstr "Перетащите или вставьте изображение"
|
||||
@ -2897,6 +2906,10 @@ msgstr "Готово"
|
||||
msgid "I'm listening — what's the priority?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Icon"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
@ -3483,6 +3496,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Name"
|
||||
msgstr "Имя"
|
||||
|
||||
@ -3520,6 +3534,7 @@ msgstr "Требуется ваше участие"
|
||||
#: src/pages/ActivityList.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Needs you"
|
||||
msgstr "Требуется ваше участие"
|
||||
@ -4483,6 +4498,7 @@ msgstr "выполняется"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Running"
|
||||
msgstr "Выполняется"
|
||||
|
||||
@ -4518,6 +4534,7 @@ msgstr "Запускается, когда этот бот получает пр
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
|
||||
@ -352,6 +352,10 @@ msgstr ""
|
||||
msgid "{name} stopped"
|
||||
msgstr "{name} durduruldu"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "{name}, {needsYou} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/main-conversation-row.tsx
|
||||
msgid "{name}, main conversation"
|
||||
msgstr ""
|
||||
@ -461,6 +465,10 @@ msgstr "İşlem onayları"
|
||||
msgid "Actions for {0}"
|
||||
msgstr "{0} için işlemler"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "Actions for {name}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Actions for space"
|
||||
msgstr ""
|
||||
@ -1067,6 +1075,7 @@ msgstr "Sunucuya ulaşılamıyor."
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "İptal"
|
||||
|
||||
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Color"
|
||||
msgstr "Renk"
|
||||
|
||||
@ -1829,6 +1839,7 @@ msgstr ""
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/ModelTierSettings.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Could not save"
|
||||
msgstr "Kaydedilemedi"
|
||||
|
||||
@ -2056,6 +2067,7 @@ msgid "Customer support"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Customize"
|
||||
msgstr ""
|
||||
|
||||
@ -2280,6 +2292,7 @@ msgstr ""
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Done"
|
||||
msgstr "Bitti"
|
||||
|
||||
@ -2325,10 +2338,6 @@ msgstr "Taslak beceri"
|
||||
msgid "Drafting the spec…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/SortableGroup.tsx
|
||||
msgid "Drag to reorder"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Drag, drop, or paste an image"
|
||||
msgstr "Görüntüyü sürükleyip bırakın veya yapıştırın"
|
||||
@ -2897,6 +2906,10 @@ msgstr "Bitirdim"
|
||||
msgid "I'm listening — what's the priority?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Icon"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
@ -3483,6 +3496,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Name"
|
||||
msgstr "Ad"
|
||||
|
||||
@ -3520,6 +3534,7 @@ msgstr ""
|
||||
#: src/pages/ActivityList.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Needs you"
|
||||
msgstr ""
|
||||
@ -4483,6 +4498,7 @@ msgstr "çalışıyor"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Running"
|
||||
msgstr "Çalışıyor"
|
||||
|
||||
@ -4518,6 +4534,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Save"
|
||||
msgstr "Kaydet"
|
||||
|
||||
|
||||
@ -352,6 +352,10 @@ msgstr ""
|
||||
msgid "{name} stopped"
|
||||
msgstr "{name} 已停止"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "{name}, {needsYou} need you"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/main-conversation-row.tsx
|
||||
msgid "{name}, main conversation"
|
||||
msgstr ""
|
||||
@ -461,6 +465,10 @@ msgstr "操作确认"
|
||||
msgid "Actions for {0}"
|
||||
msgstr "{0} 的操作"
|
||||
|
||||
#: src/pages/shell/project-row.tsx
|
||||
msgid "Actions for {name}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Actions for space"
|
||||
msgstr ""
|
||||
@ -1067,6 +1075,7 @@ msgstr "无法连接服务器。"
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
#: src/pages/PrivacySettingsOverlay.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
@ -1376,6 +1385,7 @@ msgid "Collapse {0}"
|
||||
msgstr "折叠 {0}"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Color"
|
||||
msgstr "颜色"
|
||||
|
||||
@ -1829,6 +1839,7 @@ msgstr "无法运行例行任务"
|
||||
#: src/pages/KnowledgeSection.tsx
|
||||
#: src/pages/ModelTierSettings.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Could not save"
|
||||
msgstr "无法保存"
|
||||
|
||||
@ -2056,6 +2067,7 @@ msgid "Customer support"
|
||||
msgstr "客户支持"
|
||||
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Customize"
|
||||
msgstr ""
|
||||
|
||||
@ -2280,6 +2292,7 @@ msgstr ""
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Done"
|
||||
msgstr "完成"
|
||||
|
||||
@ -2325,10 +2338,6 @@ msgstr "技能草稿"
|
||||
msgid "Drafting the spec…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/SortableGroup.tsx
|
||||
msgid "Drag to reorder"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Drag, drop, or paste an image"
|
||||
msgstr "拖放或粘贴图片"
|
||||
@ -2897,6 +2906,10 @@ msgstr "我做完了"
|
||||
msgid "I'm listening — what's the priority?"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Icon"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/UserProfileSettings.tsx
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
@ -3483,6 +3496,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Name"
|
||||
msgstr "名称"
|
||||
|
||||
@ -3520,6 +3534,7 @@ msgstr ""
|
||||
#: src/pages/ActivityList.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Needs you"
|
||||
msgstr ""
|
||||
@ -4483,6 +4498,7 @@ msgstr "运行中"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
#: src/pages/shell/project-overview.tsx
|
||||
msgid "Running"
|
||||
msgstr "运行中"
|
||||
|
||||
@ -4518,6 +4534,7 @@ msgstr ""
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
#: src/pages/shell/dialogs.tsx
|
||||
#: src/pages/shell/project-look-dialog.tsx
|
||||
msgid "Save"
|
||||
msgstr "保存"
|
||||
|
||||
|
||||
@ -3,9 +3,8 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { RunActivityRow } from "@rakazo/contracts";
|
||||
import { runChatTitle } from "@rakazo/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { isNeedsYouStatus, runActivityLabel } from "../lib/agent-activity";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { useRunActivity } from "../lib/run-activity";
|
||||
|
||||
function statusTone(status: RunActivityRow["status"]): string {
|
||||
if (status === "failed") return "text-destructive";
|
||||
@ -20,41 +19,8 @@ type ActivityListProps = {
|
||||
};
|
||||
|
||||
export function ActivityList({ onOpenRun }: ActivityListProps) {
|
||||
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
||||
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const [active, recent] = await Promise.all([
|
||||
rpc.runs.list({ filter: "active" }),
|
||||
rpc.runs.list({ filter: "recent" }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setActiveRuns(active.runs);
|
||||
setRecentRuns(recent.runs);
|
||||
} catch {
|
||||
// Keep the last good snapshot on transient RPC failures.
|
||||
if (cancelled) return;
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
// Schedule the next poll after the previous settles — no overlap.
|
||||
timer = window.setTimeout(() => void tick(), 15_000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
const { active: activeRuns, recent: recentRuns, loaded } = useRunActivity();
|
||||
const loading = !loaded;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -92,7 +58,7 @@ export function ActivityList({ onOpenRun }: ActivityListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityRow({ run, onOpen }: { run: RunActivityRow; onOpen: () => void }) {
|
||||
export function ActivityRow({ run, onOpen }: { run: RunActivityRow; onOpen: () => void }) {
|
||||
const { t } = useLingui();
|
||||
const title = runChatTitle(run);
|
||||
const label = statusLabel(run.status);
|
||||
|
||||
@ -40,6 +40,7 @@ import {
|
||||
matchVoiceByName,
|
||||
type PaneState,
|
||||
parseVoiceChatCommand,
|
||||
projectThreads,
|
||||
recordScreenPaneChoice,
|
||||
reorderBotTo,
|
||||
resolveComposerSendPlan,
|
||||
@ -266,6 +267,10 @@ function voiceStatusRefreshTimeout(): Promise<never> {
|
||||
export type NavPillar = "heute" | "team" | "projekte";
|
||||
|
||||
import { SortableGroup } from "../components/SortableGroup";
|
||||
import { useRunActivity } from "../lib/run-activity";
|
||||
import { ProjectLookDialog } from "./shell/project-look-dialog";
|
||||
import { ProjectOverview } from "./shell/project-overview";
|
||||
import { ProjectRow } from "./shell/project-row";
|
||||
|
||||
export function ShellPage() {
|
||||
const { t } = useLingui();
|
||||
@ -512,6 +517,7 @@ export function ShellPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Bot | null>(null);
|
||||
const [deleteGroupTarget, setDeleteGroupTarget] = useState<Group | null>(null);
|
||||
const [deleteSpaceTarget, setDeleteSpaceTarget] = useState<Space | null>(null);
|
||||
const [lookTarget, setLookTarget] = useState<Space | null>(null);
|
||||
const [spaceMenu, setSpaceMenu] = useState<{
|
||||
id: string;
|
||||
position: ContextMenuPosition;
|
||||
@ -1824,6 +1830,7 @@ export function ShellPage() {
|
||||
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
|
||||
|
||||
const heuteActionCount = useHeuteActionCount(groups, botsRef);
|
||||
const runActivity = useRunActivity();
|
||||
useShellSounds({
|
||||
bots,
|
||||
transcriptRunning,
|
||||
@ -3069,6 +3076,64 @@ export function ShellPage() {
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{projectSidebarGroups.map((group) => {
|
||||
const projectSpace = group.key.startsWith("project-space:")
|
||||
? spaces.find((space) => space.id === group.spaceId)
|
||||
: undefined;
|
||||
if (projectSpace) {
|
||||
const threads = projectThreads(runActivity, {
|
||||
spaceId: projectSpace.id,
|
||||
});
|
||||
const projectSelected =
|
||||
activeNavPillar === "projekte" &&
|
||||
bootstrapMe?.spaceId === projectSpace.id;
|
||||
// Other chats in the project show only while it is open.
|
||||
const projectChats = projectSelected
|
||||
? group.bots.filter((item) => item.chat.kind !== "main")
|
||||
: [];
|
||||
return (
|
||||
<SortableGroup key={`proj-${group.key}`} group={group}>
|
||||
<ProjectRow
|
||||
spaceId={projectSpace.id}
|
||||
name={projectSpace.name}
|
||||
icon={projectSpace.icon}
|
||||
color={projectSpace.color}
|
||||
hasMenu={
|
||||
projectSpace.canEdit === true || projectSpace.canDelete === true
|
||||
}
|
||||
selected={projectSelected}
|
||||
unread={group.bots.some((item) => item.chat.unread)}
|
||||
needsYou={threads.needsYou.length}
|
||||
running={threads.running.length > 0}
|
||||
onOpen={() => {
|
||||
setMobileSidebarOpen(false);
|
||||
void startProject(projectSpace.id);
|
||||
}}
|
||||
onMenu={(menu, anchor) => {
|
||||
spaceMenuAnchor.current = anchor;
|
||||
setSpaceMenu(menu);
|
||||
}}
|
||||
/>
|
||||
{projectChats.length > 0 ? (
|
||||
<div className="pl-6">
|
||||
{projectChats.map((item) => (
|
||||
<ProjectRosterRow
|
||||
key={`${item.kind}:${item.chat.id}`}
|
||||
item={item}
|
||||
selected={inGroup && activeGroup?.id === item.chat.id}
|
||||
onOpen={() => {
|
||||
setActiveNavPillar("projekte");
|
||||
openSpaceChat(item.chat.spaceId, `/app/g/${item.chat.id}`);
|
||||
}}
|
||||
activeSpaceId={bootstrapMe?.spaceId}
|
||||
menuAnchor={botMenuAnchor}
|
||||
onMenu={setBotMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</SortableGroup>
|
||||
);
|
||||
}
|
||||
const collapsed =
|
||||
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||||
return (
|
||||
@ -3414,6 +3479,25 @@ export function ShellPage() {
|
||||
enrichMember={enrichStageMember}
|
||||
/>
|
||||
) : null}
|
||||
{!callOpen && activeGroup?.kind === "main" ? (
|
||||
<ProjectOverview
|
||||
spaceId={activeGroup.spaceId}
|
||||
coordinator={
|
||||
activeSnapshot?.threadId
|
||||
? {
|
||||
threadId: activeSnapshot.threadId,
|
||||
botId: activeSnapshot.leadBotId ?? undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onOpenRun={(run) =>
|
||||
openSpaceChat(
|
||||
run.spaceId,
|
||||
run.groupId ? `/app/g/${run.groupId}` : `/app/${run.botId}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Transcript
|
||||
key={activeSnapshot?.threadId}
|
||||
encounterMode={encounterMode}
|
||||
@ -4031,20 +4115,48 @@ export function ShellPage() {
|
||||
label={t`Actions for space`}
|
||||
onClose={closeSpaceMenu}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
const target = spaces.find((space) => space.id === spaceMenu.id);
|
||||
if (target) setDeleteSpaceTarget(target);
|
||||
setSpaceMenu(null);
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
{t`Delete space`}
|
||||
</DropdownMenuItem>
|
||||
{spaces.some(
|
||||
(space) => space.id === spaceMenu.id && space.canEdit && !space.isDefault,
|
||||
) ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const target = spaces.find((space) => space.id === spaceMenu.id);
|
||||
if (target) setLookTarget(target);
|
||||
setSpaceMenu(null);
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
{t`Customize`}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{spaces.find((space) => space.id === spaceMenu.id)?.canDelete ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
const target = spaces.find((space) => space.id === spaceMenu.id);
|
||||
if (target) setDeleteSpaceTarget(target);
|
||||
setSpaceMenu(null);
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
{t`Delete space`}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</PointerMenu>
|
||||
) : null}
|
||||
|
||||
{lookTarget ? (
|
||||
<ProjectLookDialog
|
||||
initial={lookTarget}
|
||||
onCancel={() => setLookTarget(null)}
|
||||
onSave={async (look) => {
|
||||
await rpc.spaces.update({ spaceId: lookTarget.id, ...look });
|
||||
setLookTarget(null);
|
||||
await refreshBots();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{deleteTarget ? (
|
||||
<DeleteBotDialog
|
||||
bot={deleteTarget}
|
||||
|
||||
125
apps/web/src/pages/shell/project-look-dialog.tsx
Normal file
125
apps/web/src/pages/shell/project-look-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/web/src/pages/shell/project-overview.tsx
Normal file
75
apps/web/src/pages/shell/project-overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/pages/shell/project-row.tsx
Normal file
95
apps/web/src/pages/shell/project-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,11 @@
|
||||
/** One look for sidebar conversation rows: the main conversation entry and the bot roster. */
|
||||
export function rosterRowClass(selected: boolean): string {
|
||||
return `flex w-full items-center gap-3 rounded-2xl border px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
||||
return `flex w-full items-center gap-3 px-3 py-3 text-start cursor-pointer min-h-[52px] ${rosterRowSurfaceClass(selected)}`;
|
||||
}
|
||||
|
||||
/** The row's card: border, fill and shadow, for rows that lay out their own content. */
|
||||
export function rosterRowSurfaceClass(selected: boolean): string {
|
||||
return `rounded-2xl border mb-[2px] transition-all duration-150 ${
|
||||
selected
|
||||
? "border-sidebar-border bg-sidebar-accent shadow-xs dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"
|
||||
: "border-transparent hover:bg-sidebar-accent/50"
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { BotSection, Space } from "@rakazo/contracts";
|
||||
import { Button } from "@rakazo/ui-web";
|
||||
import { ChevronDown, Folder, GripVertical, Lock, MoreHorizontal } from "lucide-react";
|
||||
import { ChevronDown, Folder, Lock, MoreHorizontal } from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
import { ProjectDragGrip } from "../../components/SortableGroup";
|
||||
import type { ContextMenuPosition } from "../BotContextMenu";
|
||||
|
||||
export type SidebarGroupHeaderGroup = {
|
||||
@ -26,7 +25,7 @@ export type SectionMenuRequest = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapsible title of a sidebar group. Project groups add a drag grip, show a lock for other
|
||||
* Collapsible title of a sidebar group. Project groups show a lock for other
|
||||
* spaces and select an empty space instead of opening it.
|
||||
*/
|
||||
export function SidebarGroupHeader({
|
||||
@ -59,20 +58,10 @@ export function SidebarGroupHeader({
|
||||
const { t } = useLingui();
|
||||
const project = variant === "project";
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
project ? "flex items-center pt-3 pb-0.5 relative" : "flex items-center pt-3 pb-0.5"
|
||||
}
|
||||
>
|
||||
{project ? (
|
||||
// Drag grip — positioned to the left of the title text
|
||||
<ProjectDragGrip className="absolute left-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded flex items-center justify-center text-muted-foreground/30 hover:text-muted-foreground/70 active:text-muted-foreground transition-colors cursor-grab active:cursor-grabbing">
|
||||
<GripVertical className="size-icon-sm" strokeWidth={2} />
|
||||
</ProjectDragGrip>
|
||||
) : null}
|
||||
<div className="flex items-center pt-3 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg ${project ? "pl-8" : "px-2.5"} py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring`}
|
||||
className={`flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring`}
|
||||
onClick={() => {
|
||||
if (group.emptySpaceId) {
|
||||
onOpenEmptySpace(group.emptySpaceId);
|
||||
|
||||
@ -1,45 +1,19 @@
|
||||
import type { Bot, Group } from "@rakazo/contracts";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { buildHeuteBrief } from "../../lib/heute-brief";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { useRunActivity } from "../../lib/run-activity";
|
||||
|
||||
export function useHeuteActionCount(groups: Group[], botsRef: RefObject<Bot[]>): number {
|
||||
const [heuteActionCount, setHeuteActionCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const [active, recent] = await Promise.all([
|
||||
rpc.runs.list({ filter: "active" }),
|
||||
rpc.runs.list({ filter: "recent" }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const brief = buildHeuteBrief({
|
||||
bots: botsRef.current,
|
||||
groups,
|
||||
activeRuns: active.runs,
|
||||
recentRuns: recent.runs,
|
||||
});
|
||||
setHeuteActionCount(brief.actionCount);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
timer = window.setTimeout(() => void tick(), 15_000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [groups]);
|
||||
|
||||
return heuteActionCount;
|
||||
const { active, recent } = useRunActivity();
|
||||
return useMemo(
|
||||
() =>
|
||||
buildHeuteBrief({
|
||||
bots: botsRef.current,
|
||||
groups,
|
||||
activeRuns: active,
|
||||
recentRuns: recent,
|
||||
}).actionCount,
|
||||
[active, recent, groups, botsRef],
|
||||
);
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ export function useSidebarModel({
|
||||
}, []);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }),
|
||||
);
|
||||
|
||||
const sidebarGroups = useMemo(() => {
|
||||
|
||||
@ -294,11 +294,16 @@ export type SpaceWorkspace = z.infer<typeof SpaceWorkspaceSchema>;
|
||||
export const SpaceSchema = z.object({
|
||||
id: Id,
|
||||
name: z.string(),
|
||||
/** Sidebar look; ids from the shared project catalog, null until the user picks one. */
|
||||
icon: z.string().nullable().optional(),
|
||||
color: z.string().nullable().optional(),
|
||||
isDefault: z.boolean(),
|
||||
/** True when the space has any bot or group, including archived. */
|
||||
hasContent: z.boolean(),
|
||||
/** True only when the current member may delete this non-default space. */
|
||||
canDelete: z.boolean().optional(),
|
||||
/** True when the current member may rename the space and change its look. */
|
||||
canEdit: z.boolean().optional(),
|
||||
workspace: SpaceWorkspaceSchema.nullable().optional(),
|
||||
bots: z.array(SpaceBotSchema),
|
||||
groups: z.array(SpaceGroupSchema),
|
||||
|
||||
@ -258,6 +258,17 @@ export const appContract = {
|
||||
}),
|
||||
)
|
||||
.output(SpaceSchema),
|
||||
/** Rename a project or change its sidebar icon and color. */
|
||||
update: oc
|
||||
.input(
|
||||
z.object({
|
||||
spaceId: Id,
|
||||
name: z.string().trim().min(1).max(60).optional(),
|
||||
icon: z.string().max(40).optional(),
|
||||
color: z.string().max(40).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ ok: z.literal(true) })),
|
||||
remove: oc
|
||||
.input(z.object({ spaceId: Id }))
|
||||
.output(z.object({ ok: z.literal(true), activeSpaceId: Id })),
|
||||
|
||||
@ -37,6 +37,8 @@ export * from "./oo-avatar.js";
|
||||
export * from "./oo-motion.js";
|
||||
export * from "./oo-palette.js";
|
||||
export * from "./oo-rig.js";
|
||||
export * from "./project-look.js";
|
||||
export * from "./project-threads.js";
|
||||
export * from "./quiet-hours.js";
|
||||
export * from "./response-bytes.js";
|
||||
export * from "./routine-events.js";
|
||||
|
||||
26
packages/core/src/project-look.test.ts
Normal file
26
packages/core/src/project-look.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
packages/core/src/project-look.ts
Normal file
106
packages/core/src/project-look.ts
Normal 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 };
|
||||
}
|
||||
52
packages/core/src/project-threads.test.ts
Normal file
52
packages/core/src/project-threads.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
49
packages/core/src/project-threads.ts
Normal file
49
packages/core/src/project-threads.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -180,6 +180,9 @@ model Space {
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
/// Project look in the sidebar: an icon id from the shared catalog and a palette color.
|
||||
icon String?
|
||||
color String?
|
||||
workspacePath String?
|
||||
gitRemoteUrl String?
|
||||
gitDefaultBranch String?
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
} from "./main-agent.js";
|
||||
import { createRepos } from "./repos.js";
|
||||
import { requireMembership } from "./scope.js";
|
||||
import { createSpaceForMember } from "./spaces.js";
|
||||
import { createSpaceForMember, updateSpaceForOwner } from "./spaces.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const describePostgres =
|
||||
@ -102,6 +102,33 @@ describePostgres("main agent and main conversation (PostgreSQL)", () => {
|
||||
expect(named.id).not.toBe(homeSpaceId);
|
||||
});
|
||||
|
||||
it("lets a project's owner rename it and change its look, and nobody else", async () => {
|
||||
const { userId, homeSpaceId } = await newUser();
|
||||
const project = await createSpaceForMember(prisma, {
|
||||
currentSpaceId: homeSpaceId,
|
||||
userId,
|
||||
name: "Garden",
|
||||
});
|
||||
await updateSpaceForOwner(prisma, {
|
||||
userId,
|
||||
spaceId: project.id,
|
||||
icon: "leaf",
|
||||
color: "green",
|
||||
});
|
||||
await updateSpaceForOwner(prisma, { userId, spaceId: project.id, name: " Backyard " });
|
||||
expect(
|
||||
await prisma.space.findUniqueOrThrow({
|
||||
where: { id: project.id },
|
||||
select: { name: true, icon: true, color: true },
|
||||
}),
|
||||
).toEqual({ name: "Backyard", icon: "leaf", color: "green" });
|
||||
|
||||
const stranger = await newUser("Eve");
|
||||
await expect(
|
||||
updateSpaceForOwner(prisma, { userId: stranger.userId, spaceId: project.id, name: "Mine" }),
|
||||
).rejects.toThrow("Space not found");
|
||||
});
|
||||
|
||||
it("gives every project its own main bot and main conversation next to the home one", async () => {
|
||||
const { userId, homeSpaceId } = await newUser();
|
||||
const home = await ensureMainConversation(prisma, userId);
|
||||
|
||||
@ -187,6 +187,35 @@ async function copyProviderPreferences(
|
||||
]);
|
||||
}
|
||||
|
||||
/** Rename a project or change its sidebar look; only its owner may. */
|
||||
export async function updateSpaceForOwner(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
userId: string;
|
||||
spaceId: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const name = input.name?.trim();
|
||||
if (name !== undefined && (!name || name.length > 60)) throw new InvalidSpaceNameError();
|
||||
const membership = await prisma.spaceMember.findUnique({
|
||||
where: { spaceId_userId: { spaceId: input.spaceId, userId: input.userId } },
|
||||
select: { role: true, space: { select: { deletingAt: true } } },
|
||||
});
|
||||
if (!membership || membership.space.deletingAt) throw new SpaceNotFoundError();
|
||||
if (membership.role !== "owner") throw new IsolationError();
|
||||
await prisma.space.update({
|
||||
where: { id: input.spaceId },
|
||||
data: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(input.icon !== undefined ? { icon: input.icon } : {}),
|
||||
...(input.color !== undefined ? { color: input.color } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a sibling privacy boundary for a member of the active organization. */
|
||||
export async function createSpaceForMember(
|
||||
prisma: PrismaClient,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user