- {project ? (
- // Drag grip — positioned to the left of the title text
-
-
-
- ) : null}
+
{
if (group.emptySpaceId) {
onOpenEmptySpace(group.emptySpaceId);
diff --git a/apps/web/src/pages/shell/use-heute-action-count.ts b/apps/web/src/pages/shell/use-heute-action-count.ts
index 1ea9f43..9e34847 100644
--- a/apps/web/src/pages/shell/use-heute-action-count.ts
+++ b/apps/web/src/pages/shell/use-heute-action-count.ts
@@ -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): 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],
+ );
}
diff --git a/apps/web/src/pages/shell/use-sidebar-model.ts b/apps/web/src/pages/shell/use-sidebar-model.ts
index 847506f..652d169 100644
--- a/apps/web/src/pages/shell/use-sidebar-model.ts
+++ b/apps/web/src/pages/shell/use-sidebar-model.ts
@@ -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(() => {
diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts
index 56f9d4b..6a4d1b6 100644
--- a/packages/contracts/src/domain.ts
+++ b/packages/contracts/src/domain.ts
@@ -294,11 +294,16 @@ export type SpaceWorkspace = z.infer;
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),
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 6c37b0e..37c841b 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -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 })),
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 1a6fd97..fb22039 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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";
diff --git a/packages/core/src/project-look.test.ts b/packages/core/src/project-look.test.ts
new file mode 100644
index 0000000..a717944
--- /dev/null
+++ b/packages/core/src/project-look.test.ts
@@ -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);
+ });
+});
diff --git a/packages/core/src/project-look.ts b/packages/core/src/project-look.ts
new file mode 100644
index 0000000..804d6cf
--- /dev/null
+++ b/packages/core/src/project-look.ts
@@ -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 };
+}
diff --git a/packages/core/src/project-threads.test.ts b/packages/core/src/project-threads.test.ts
new file mode 100644
index 0000000..c1c9534
--- /dev/null
+++ b/packages/core/src/project-threads.test.ts
@@ -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"]);
+ });
+});
diff --git a/packages/core/src/project-threads.ts b/packages/core/src/project-threads.ts
new file mode 100644
index 0000000..93280ad
--- /dev/null
+++ b/packages/core/src/project-threads.ts
@@ -0,0 +1,49 @@
+import type { RunActivityRow } from "@rakazo/contracts";
+
+export type ProjectThreads = {
+ needsYou: RunActivityRow[];
+ running: RunActivityRow[];
+ done: RunActivityRow[];
+};
+
+const NEEDS_YOU = new Set(["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) => `${run.threadId}:${run.botId}`;
+ const { coordinator } = options;
+ const seen = new Set();
+ 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;
+}
diff --git a/packages/db/prisma/migrations/20261010090000_space_icon_color/migration.sql b/packages/db/prisma/migrations/20261010090000_space_icon_color/migration.sql
new file mode 100644
index 0000000..a9ffe03
--- /dev/null
+++ b/packages/db/prisma/migrations/20261010090000_space_icon_color/migration.sql
@@ -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;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 2ba907b..4112f79 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -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?
diff --git a/packages/db/src/main-agent.postgres.test.ts b/packages/db/src/main-agent.postgres.test.ts
index 7ab9b65..1ff7f77 100644
--- a/packages/db/src/main-agent.postgres.test.ts
+++ b/packages/db/src/main-agent.postgres.test.ts
@@ -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);
diff --git a/packages/db/src/spaces.ts b/packages/db/src/spaces.ts
index 32f039b..ebcb58f 100644
--- a/packages/db/src/spaces.ts
+++ b/packages/db/src/spaces.ts
@@ -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 {
+ 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,