evOo-local/apps/web/src/pages/shell/project-overview.tsx
cynarAI 263e3a31f8
Fix red main: mobile group kind, i18n catalogs, project picker check (#218)
* Fix red main: mobile group kind, i18n catalogs, project picker check

Main CI failed on typecheck, unit tests and web E2E after the last two
direct pushes. MobileGroup now carries the optional chat kind the mobile
screens read, the Lingui catalogs are re-extracted with German strings
for the new voice texts, the project overview plus icon meets the UI
scale guard, and the sidebar E2E expects the renamed project lead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

* Format files merged from main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Align E2E with the canvas close button and per-chat drafts

The in-app browser now closes from the side panel header, and each chat
keeps its own unsent draft. The group chat test waits for the settings
save to land before switching chats, so the switch no longer races the
panel closing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 09:34:40 +02:00

501 lines
19 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import type { Bot, Routine, RunActivityRow, Space } from "@rakazo/contracts";
import { projectThreads } from "@rakazo/core";
import { BotAvatar, Button, cn } from "@rakazo/ui-web";
import { Clock, FolderOpen, GitBranch, Pencil, Plus, Settings, Target } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { ProjectIcon } from "../../components/project-icon";
import { isNeedsYouStatus } from "../../lib/agent-activity";
import { useRunActivity } from "../../lib/run-activity";
import { ActivityRow } from "../ActivityList";
import { RoutineListRow } from "../RoutineEditor";
import { FALLBACK_BOT_COLOR } from "./shell-types";
type Section = "needsYou" | "running" | "done";
/**
* The coordinator's view of its project: project identity, mission goal, context badges,
* project specialist roster, sub-thread creation launcher, and delegated task activity.
*/
export function ProjectOverview({
spaceId,
space,
coordinator,
routines = [],
runningRoutineId,
togglingRoutineId,
onToggleRoutineActive,
onOpenRoutine,
onStopRun,
onOpenRun,
onOpenBot,
onOpenNewInquiry,
onEditProject,
}: {
spaceId: string;
space?: Space;
coordinator?: { threadId: string; botId?: string };
routines?: readonly Routine[];
runningRoutineId?: string | null;
togglingRoutineId?: string | null;
onToggleRoutineActive?: (routine: Routine, active: boolean) => void;
onOpenRoutine?: (routine: Routine) => void;
onStopRun?: () => void;
onOpenRun: (run: RunActivityRow) => void;
onOpenBot?: (botId: string) => void;
onOpenNewInquiry?: () => void;
onEditProject?: () => void;
}) {
const { t } = useLingui();
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);
const isProjectSpace = Boolean(space && !space.isDefault);
// If not a project space and no runs, keep hidden
if (!isProjectSpace && visible.length === 0) return null;
// Waiting or actively running threads start open automatically so progress is visible right away.
const shown =
open ??
(threads.needsYou.length > 0 ? "needsYou" : threads.running.length > 0 ? "running" : null);
const current = visible.find((section) => section.id === shown);
const folderPath = space?.workspace?.path;
const gitRemote = space?.workspace?.git?.remoteUrl;
const goal = space?.goal;
const projectBots = space?.bots ?? [];
const coordinatorWorking =
projectBots.some(
(member) =>
member.status &&
member.status !== "idle" &&
member.status !== "ready" &&
member.status !== "done",
) || activity.active.some((row) => row.spaceId === spaceId);
return (
<div
data-testid="project-overview"
className="mx-auto w-full max-w-3xl px-4 pt-3 pb-2 space-y-3"
>
{isProjectSpace && space ? (
<div className="rounded-xl border border-border bg-card/60 p-3.5 backdrop-blur-xs shadow-2xs space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2.5 min-w-0">
<ProjectIcon icon={space.icon} color={space.color} size={28} />
<span className="font-semibold text-ui-title-sm truncate text-foreground">
{space.name}
</span>
{onEditProject ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Edit project`}
onClick={onEditProject}
className="size-7 text-muted-foreground hover:text-foreground"
>
<Pencil size={14} strokeWidth={1.8} />
</Button>
) : null}
</div>
{onOpenNewInquiry ? (
<Button
variant="outline"
size="sm"
onClick={onOpenNewInquiry}
className="gap-1.5 text-ui-label text-foreground"
>
<Plus size={15} strokeWidth={2.2} />
<Trans>New inquiry</Trans>
</Button>
) : null}
</div>
{goal ? (
<div className="rounded-lg border border-border/80 bg-accent/30 p-2.5 flex items-start gap-2.5">
<Target size={16} strokeWidth={2} className="text-primary shrink-0 mt-0.5" />
<div className="min-w-0 text-ui-body-sm text-foreground/90 whitespace-pre-wrap break-words leading-relaxed">
{goal}
</div>
</div>
) : onEditProject ? (
<button
type="button"
onClick={onEditProject}
className="flex w-full items-center gap-2 rounded-lg border border-dashed border-border/80 bg-background/40 px-3 py-2 text-start text-ui-body-sm text-muted-foreground hover:border-border hover:bg-accent/30 hover:text-foreground transition-colors cursor-pointer"
>
<Target size={15} strokeWidth={1.8} className="shrink-0" />
<span>
<Trans>Add goal</Trans>
</span>
</button>
) : null}
{folderPath || gitRemote ? (
<div className="flex items-center gap-2 flex-wrap pt-0.5 text-ui-caption text-muted-foreground">
{folderPath ? (
<div
title={folderPath}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background/50 px-2.5 py-0.5"
>
<FolderOpen size={14} strokeWidth={1.8} />
<span className="max-w-[200px] truncate">{folderPath}</span>
</div>
) : null}
{gitRemote ? (
<a
href={gitRemote}
target="_blank"
rel="noreferrer"
title={gitRemote}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background/50 px-2.5 py-0.5 hover:text-foreground transition-colors"
>
<GitBranch size={14} strokeWidth={1.8} />
<span className="max-w-[200px] truncate">
{gitRemote.replace(/^https?:\/\/(www\.)?github\.com\//, "")}
</span>
</a>
) : null}
</div>
) : null}
{projectBots.length > 0 ? (
<div className="border-t border-border/50 pt-2.5">
<div className="mb-1.5 text-ui-label font-medium text-muted-foreground">
<Trans>Team</Trans>
</div>
<div className="flex flex-wrap gap-1.5">
{projectBots.map((member) => {
const displayName =
member.name === "Chief" || member.name === "Projektkoordinator"
? "Projektmanager"
: member.name;
const isWorking = Boolean(
member.status &&
member.status !== "idle" &&
member.status !== "ready" &&
member.status !== "done",
);
return (
<button
key={member.id}
type="button"
disabled={!onOpenBot}
onClick={() => onOpenBot?.(member.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/70 px-2.5 py-1 text-ui-label text-foreground transition-colors",
onOpenBot && "hover:bg-accent cursor-pointer",
)}
>
<BotAvatar
color={member.color || FALLBACK_BOT_COLOR}
identity={member.id}
size={16}
moment={isWorking ? "working" : "idle"}
/>
<span className="truncate max-w-[130px] font-medium">{displayName}</span>
<span
aria-hidden="true"
className={cn(
"size-1.5 rounded-full",
isWorking ? "bg-warning animate-pulse" : "bg-success",
)}
/>
</button>
);
})}
</div>
</div>
) : null}
</div>
) : null}
{visible.length > 0 ? (
<div className="space-y-1.5">
<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 cursor-pointer transition-colors"
>
<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>
) : isProjectSpace ? (
coordinatorWorking ? (
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-card/30 px-3.5 py-3 text-ui-body-sm text-foreground">
<span aria-hidden="true" className="size-2 rounded-full bg-warning animate-pulse" />
<Trans>Analyzing project…</Trans>
</div>
) : (
<div className="rounded-xl border border-border/60 bg-card/30 px-3.5 py-3 text-ui-body-sm text-muted-foreground">
<Trans>No runs yet</Trans>
</div>
)
) : null}
{isProjectSpace ? (
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<span className="text-ui-label font-semibold uppercase tracking-wider text-muted-foreground">
<Trans>Routines</Trans>
</span>
</div>
{routines.length > 0 ? (
<div className="rounded-xl border border-border/60 bg-card/30 p-1.5 space-y-0.5">
{routines.map((routine) => (
<RoutineListRow
key={routine.id}
routine={routine}
running={runningRoutineId === routine.id}
togglingActive={togglingRoutineId === routine.id}
onActiveChange={(next) => onToggleRoutineActive?.(routine, next)}
onOpen={() => onOpenRoutine?.(routine)}
onStop={() => onStopRun?.()}
/>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-card/30 px-3.5 py-2.5 text-ui-body-sm text-muted-foreground">
<Clock size={14} className="shrink-0" />
<span>
<Trans>Just ask in the chat whenever something should happen regularly.</Trans>
</span>
</div>
)}
</div>
) : null}
</div>
);
}
/**
* 1:1 Agent overview in the side panel: agent identity & status, active and recent
* task runs for this specific agent, and its scheduled routines.
*/
export function AgentOverview({
bot,
running = false,
routines = [],
runningRoutineId,
togglingRoutineId,
onToggleRoutineActive,
onOpenRoutine,
onAddRoutine,
onStopRun,
onOpenRun,
onOpenSettings,
}: {
bot: Bot;
running?: boolean;
routines?: readonly Routine[];
runningRoutineId?: string | null;
togglingRoutineId?: string | null;
onToggleRoutineActive?: (routine: Routine, active: boolean) => void;
onOpenRoutine?: (routine: Routine) => void;
onAddRoutine?: () => void;
onStopRun?: () => void;
onOpenRun: (run: RunActivityRow) => void;
onOpenSettings?: () => void;
}) {
const { t } = useLingui();
const { active: allActiveRuns, recent: allRecentRuns } = useRunActivity();
const botActiveRuns = allActiveRuns.filter((run) => run.botId === bot.id);
const botRecentRuns = allRecentRuns.filter((run) => run.botId === bot.id);
const needsYouCount = botActiveRuns.filter((run) => isNeedsYouStatus(run.status)).length;
const isWorking = running || botActiveRuns.length > 0;
return (
<div data-testid="agent-overview" className="mx-auto w-full max-w-3xl px-4 pt-3 pb-4 space-y-4">
{/* Agent Identity & Status Card */}
<div className="rounded-xl border border-border bg-card/60 p-3.5 backdrop-blur-xs shadow-2xs space-y-2.5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="relative shrink-0">
<BotAvatar
color={bot.color || FALLBACK_BOT_COLOR}
identity={bot.id}
size={34}
moment={isWorking ? "working" : "idle"}
/>
<span
aria-hidden="true"
className={cn(
"absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full ring-2 ring-background",
needsYouCount > 0
? "bg-warning"
: isWorking
? "bg-warning animate-pulse"
: "bg-success",
)}
/>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-ui-title-sm truncate text-foreground">
{bot.name}
</span>
{bot.title ? (
<span className="rounded-full border border-border/60 bg-muted/50 px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
{bot.title}
</span>
) : null}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-ui-label text-muted-foreground">
{needsYouCount > 0 ? (
<span className="text-warning font-medium">
<Trans>Needs you</Trans>
</span>
) : isWorking ? (
<span className="text-foreground font-medium">
<Trans>Working</Trans>
</span>
) : (
<span>
<Trans>Ready</Trans>
</span>
)}
</div>
</div>
</div>
{onOpenSettings ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Show settings`}
onClick={onOpenSettings}
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
>
<Settings size={15} strokeWidth={1.8} />
</Button>
) : null}
</div>
{bot.description ? (
<p className="text-ui-body-sm text-muted-foreground leading-relaxed line-clamp-3">
{bot.description}
</p>
) : null}
</div>
{/* Agent Runs & Tasks */}
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<span className="text-ui-label font-semibold uppercase tracking-wider text-muted-foreground">
<Trans>Run history</Trans>
</span>
</div>
{botActiveRuns.length > 0 ? (
<div className="rounded-xl border border-border/70 bg-card/40 p-1.5 space-y-1">
<div className="px-2 pt-1 text-ui-label font-medium text-warning">
<Trans>Now</Trans>
</div>
{botActiveRuns.map((run) => (
<ActivityRow key={run.runId} run={run} onOpen={() => onOpenRun(run)} />
))}
</div>
) : null}
{botRecentRuns.length > 0 ? (
<div className="rounded-xl border border-border/60 bg-card/30 p-1.5 space-y-1">
{botActiveRuns.length > 0 ? (
<div className="px-2 pt-1 text-ui-label font-medium text-muted-foreground">
<Trans>Recent</Trans>
</div>
) : null}
{botRecentRuns.slice(0, 8).map((run) => (
<ActivityRow key={run.runId} run={run} onOpen={() => onOpenRun(run)} />
))}
</div>
) : botActiveRuns.length === 0 ? (
<div className="rounded-xl border border-border/60 bg-card/30 px-3.5 py-3 text-ui-body-sm text-muted-foreground">
<Trans>No runs yet</Trans>
</div>
) : null}
</div>
{/* Scheduled Routines for this 1:1 Agent */}
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<span className="text-ui-label font-semibold uppercase tracking-wider text-muted-foreground">
<Trans>Routines</Trans>
</span>
{onAddRoutine ? (
<Button
variant="outline"
size="xs"
onClick={onAddRoutine}
className="gap-1 text-ui-label"
>
<Plus size={14} strokeWidth={2.2} />
<Trans>Routine</Trans>
</Button>
) : null}
</div>
{routines.length > 0 ? (
<div className="rounded-xl border border-border/60 bg-card/30 p-1.5 space-y-0.5">
{routines.map((routine) => (
<RoutineListRow
key={routine.id}
routine={routine}
running={runningRoutineId === routine.id}
togglingActive={togglingRoutineId === routine.id}
onActiveChange={(next) => onToggleRoutineActive?.(routine, next)}
onOpen={() => onOpenRoutine?.(routine)}
onStop={() => onStopRun?.()}
/>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-card/30 px-3.5 py-2.5 text-ui-body-sm text-muted-foreground">
<Clock size={14} className="shrink-0" />
<span>
<Trans>Just ask in the chat whenever something should happen regularly.</Trans>
</span>
</div>
)}
</div>
</div>
);
}