Several web surfaces hard-coded German (the project card, roster and
activity status labels, the sidebar "Heute" label) or used German text as
the Lingui source message (sidebar sections, project timeline, bot picker,
voice entry, group stage, copy and read-aloud buttons). English users saw
German, and because production builds keep only message ids, none of these
strings could be translated per locale.
Convert them to English source messages, keeping the meaning:
- ProjectCreatedCard: Lingui Trans/Plural instead of literals.
- agent-activity: labels go through i18n._ with literal ids so the module
stays importable from plain unit tests; the extractor still collects them.
- Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage:
English sources, reusing existing messages where one already fit
("just now", "{n}m ago", "Search", "No bots match", "Set up voice",
"Group conversation", "Transcript").
- The conversation picker matches its create entry against the translated
label instead of a fixed German string.
The German wording returns through the de catalog in the next commit.
Unit and E2E tests now assert the English labels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
import { t } from "@lingui/core/macro";
|
|
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import type { ProjectGoal, ProjectGoalStatus, ProjectSession } from "@rakazo/contracts";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { rpc } from "../lib/rpc";
|
|
|
|
const INITIAL_VISIBLE = 4;
|
|
|
|
function goalStatusTone(status: ProjectGoalStatus): string {
|
|
if (status === "done") return "text-success";
|
|
if (status === "doing") return "text-warning";
|
|
return "text-foreground";
|
|
}
|
|
|
|
function goalStatusLabel(status: ProjectGoalStatus): string {
|
|
switch (status) {
|
|
case "open":
|
|
return t`To do`;
|
|
case "doing":
|
|
return t`In progress`;
|
|
case "done":
|
|
return t`Done`;
|
|
default: {
|
|
const _exhaustive: never = status;
|
|
return _exhaustive;
|
|
}
|
|
}
|
|
}
|
|
|
|
function sessionStatusTone(status: ProjectSession["status"]): string {
|
|
if (status === "failed") return "text-destructive";
|
|
if (status === "cancelled") return "text-muted-foreground";
|
|
if (status === "completed") return "text-success";
|
|
if (status === "waiting_input" || status === "waiting_takeover") return "text-warning";
|
|
return "text-foreground";
|
|
}
|
|
|
|
function sessionStatusLabel(status: ProjectSession["status"]): string {
|
|
switch (status) {
|
|
case "queued":
|
|
return t`Queued`;
|
|
case "leased":
|
|
case "running":
|
|
return t`Running`;
|
|
case "waiting_input":
|
|
return t`Waiting`;
|
|
case "waiting_takeover":
|
|
return t`Takeover`;
|
|
case "completed":
|
|
return t`Done`;
|
|
case "failed":
|
|
return t`Failed`;
|
|
case "cancelled":
|
|
return t`Cancelled`;
|
|
default: {
|
|
const _exhaustive: never = status;
|
|
return _exhaustive;
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatRelativeTime(iso: string): string {
|
|
const delta = Date.now() - new Date(iso).getTime();
|
|
const minutes = Math.round(delta / 60_000);
|
|
if (minutes < 1) return t`just now`;
|
|
if (minutes < 60) return t`${minutes}m ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 24) return t`${hours}h ago`;
|
|
const days = Math.round(hours / 24);
|
|
return t`${days}d ago`;
|
|
}
|
|
|
|
type ProjectSessionTimelineProps = {
|
|
sectionId: string;
|
|
sectionName: string;
|
|
groupId: string;
|
|
groupName: string;
|
|
onOpenSession: (sourceMessageId: string) => void;
|
|
onStartSession: () => void;
|
|
onOpenGroupChat: (groupId: string) => void;
|
|
onStartVoiceFirst: (groupId: string) => void;
|
|
};
|
|
|
|
export function ProjectSessionTimeline({
|
|
sectionId,
|
|
sectionName,
|
|
groupId,
|
|
groupName,
|
|
onOpenSession,
|
|
onStartSession,
|
|
onOpenGroupChat,
|
|
onStartVoiceFirst,
|
|
}: ProjectSessionTimelineProps) {
|
|
const { t } = useLingui();
|
|
const [goals, setGoals] = useState<ProjectGoal[]>([]);
|
|
const [sessions, setSessions] = useState<ProjectSession[]>([]);
|
|
const [loadingGoals, setLoadingGoals] = useState(true);
|
|
const [loadingSessions, setLoadingSessions] = useState(true);
|
|
const [sessionsExpanded, setSessionsExpanded] = useState(false);
|
|
const [creatingGoal, setCreatingGoal] = useState(false);
|
|
const [newGoalTitle, setNewGoalTitle] = useState("");
|
|
|
|
const reloadGoals = useCallback(async () => {
|
|
const result = await rpc.projectGoals.list({
|
|
sectionId,
|
|
groupId,
|
|
seedDefaults: true,
|
|
});
|
|
setGoals(result);
|
|
}, [groupId, sectionId]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void reloadGoals()
|
|
.catch(() => {
|
|
if (!cancelled) setGoals([]);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoadingGoals(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [reloadGoals]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void rpc.groups
|
|
.listSessions({ groupId })
|
|
.then((result) => {
|
|
if (!cancelled) setSessions(result.sessions);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setSessions([]);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoadingSessions(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [groupId]);
|
|
|
|
const visibleSessions = sessionsExpanded ? sessions : sessions.slice(0, INITIAL_VISIBLE);
|
|
const hiddenSessionCount = Math.max(0, sessions.length - INITIAL_VISIBLE);
|
|
|
|
const handleCreateGoal = async () => {
|
|
const title = newGoalTitle.trim();
|
|
if (!title) return;
|
|
setCreatingGoal(true);
|
|
try {
|
|
await rpc.projectGoals.create({
|
|
sectionId,
|
|
title,
|
|
groupId,
|
|
});
|
|
setNewGoalTitle("");
|
|
await reloadGoals();
|
|
} finally {
|
|
setCreatingGoal(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<div className="border-b border-border/60 px-4 py-4 md:px-8 md:py-5 lg:px-10">
|
|
<p className="text-ui-label font-medium uppercase tracking-wide text-muted-foreground">
|
|
<Trans>Project</Trans>
|
|
</p>
|
|
<h2 className="text-ui-body font-semibold tracking-tight text-foreground">{sectionName}</h2>
|
|
<p className="mt-1 text-ui-body-sm text-muted-foreground">
|
|
<Trans>
|
|
Goals for this project, separate from chat messages. Conversations are below.
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 py-4 md:px-8 lg:px-10">
|
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
|
<section aria-labelledby="project-goals-heading">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<h3
|
|
id="project-goals-heading"
|
|
className="text-ui-body-sm font-semibold text-foreground"
|
|
>
|
|
<Trans>Goals</Trans>
|
|
</h3>
|
|
<span className="rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-ui-label font-medium text-primary">
|
|
<Trans>Goal</Trans>
|
|
</span>
|
|
</div>
|
|
|
|
{loadingGoals ? (
|
|
<p className="text-ui-body-sm text-muted-foreground">
|
|
<Trans>Loading goals…</Trans>
|
|
</p>
|
|
) : goals.length === 0 ? (
|
|
<div className="rounded-[16px] border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
|
|
<p className="text-ui-body-sm text-muted-foreground">
|
|
<Trans>No goals in this project yet.</Trans>
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{goals.map((goal) => {
|
|
const tone = goalStatusTone(goal.status);
|
|
const label = goalStatusLabel(goal.status);
|
|
const linkedGroupId = goal.groupId ?? groupId;
|
|
return (
|
|
<article
|
|
key={goal.id}
|
|
data-testid="project-goal-row"
|
|
className="rounded-[16px] border border-primary/20 bg-card/80 px-4 py-3.5 shadow-xs"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 text-ui-label font-semibold uppercase tracking-wide text-primary">
|
|
<Trans>Goal</Trans>
|
|
</span>
|
|
<p className="truncate text-ui-body font-medium text-foreground">
|
|
{goal.title}
|
|
</p>
|
|
</div>
|
|
{goal.description ? (
|
|
<p className="mt-1.5 line-clamp-2 text-ui-body-sm text-muted-foreground">
|
|
{goal.description}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
<span className="text-ui-label text-muted-foreground">
|
|
{formatRelativeTime(goal.updatedAt)}
|
|
</span>
|
|
<span
|
|
className={`flex items-center gap-1 text-ui-label font-medium ${tone}`}
|
|
>
|
|
<span
|
|
className="h-1.5 w-1.5 rounded-full bg-current"
|
|
aria-hidden="true"
|
|
/>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{/voice\s*first/i.test(goal.title) ? (
|
|
<button
|
|
type="button"
|
|
data-testid="voice-first-start"
|
|
onClick={() => onStartVoiceFirst(linkedGroupId)}
|
|
className="rounded-lg border border-emerald-500/40 bg-emerald-500/15 px-3 py-1.5 text-ui-label font-semibold text-emerald-200 transition-colors hover:bg-emerald-500/25"
|
|
>
|
|
<Trans>Start Voice First</Trans>
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenGroupChat(linkedGroupId)}
|
|
className="rounded-lg border border-border bg-background px-3 py-1.5 text-ui-label font-medium text-foreground transition-colors hover:bg-accent"
|
|
>
|
|
<Trans>Open conversation</Trans>
|
|
</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
|
<input
|
|
type="text"
|
|
value={newGoalTitle}
|
|
onChange={(event) => setNewGoalTitle(event.target.value)}
|
|
placeholder={t`New goal…`}
|
|
className="min-w-0 flex-1 rounded-xl border border-border bg-background px-3 py-2 text-ui-body-sm text-foreground"
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") void handleCreateGoal();
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={creatingGoal || !newGoalTitle.trim()}
|
|
onClick={() => void handleCreateGoal()}
|
|
className="rounded-xl border border-border bg-background px-4 py-2 text-ui-body-sm font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
|
>
|
|
<Trans>Add goal</Trans>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section aria-labelledby="project-chats-heading">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<h3
|
|
id="project-chats-heading"
|
|
className="text-ui-body-sm font-semibold text-foreground"
|
|
>
|
|
<Trans>Conversations</Trans>
|
|
</h3>
|
|
<span className="text-ui-label text-muted-foreground">{groupName}</span>
|
|
</div>
|
|
|
|
{loadingSessions ? (
|
|
<p className="text-ui-body-sm text-muted-foreground">
|
|
<Trans>Loading conversations…</Trans>
|
|
</p>
|
|
) : sessions.length === 0 ? (
|
|
<div className="rounded-[16px] border border-border/70 bg-card/50 px-4 py-6 text-center">
|
|
<p className="text-ui-body-sm text-muted-foreground">
|
|
<Trans>No conversations in this project chat yet.</Trans>
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={onStartSession}
|
|
className="mt-4 rounded-xl border border-border bg-background px-4 py-2 text-ui-body-sm font-medium text-foreground transition-colors hover:bg-accent"
|
|
>
|
|
<Trans>Start chat</Trans>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{visibleSessions.map((session) => {
|
|
const tone = sessionStatusTone(session.status);
|
|
const label = sessionStatusLabel(session.status);
|
|
const participants =
|
|
session.botNames.length > 0 ? session.botNames.join(", ") : t`Team`;
|
|
return (
|
|
<button
|
|
key={session.sessionId}
|
|
type="button"
|
|
data-testid="project-session-row"
|
|
aria-label={t`${session.promptSnippet}, ${label}`}
|
|
onClick={() => onOpenSession(session.sourceMessageId)}
|
|
className="rounded-[14px] border border-border/60 bg-muted/20 px-4 py-3 text-left transition-all hover:border-border hover:bg-muted/35"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-ui-label font-medium uppercase tracking-wide text-muted-foreground">
|
|
<Trans>Chat</Trans>
|
|
</p>
|
|
<p className="truncate text-ui-body-sm font-medium text-foreground">
|
|
{session.promptSnippet}
|
|
</p>
|
|
<p className="mt-0.5 truncate text-ui-label text-muted-foreground">
|
|
{participants}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
<span className="text-ui-label text-muted-foreground">
|
|
{formatRelativeTime(session.startedAt)}
|
|
</span>
|
|
<span
|
|
className={`flex items-center gap-1 text-ui-label font-medium ${tone}`}
|
|
>
|
|
<span
|
|
className="h-1.5 w-1.5 rounded-full bg-current"
|
|
aria-hidden="true"
|
|
/>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
{!sessionsExpanded && hiddenSessionCount > 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSessionsExpanded(true)}
|
|
className="self-start px-1 py-1 text-ui-body-sm font-medium text-primary hover:underline"
|
|
>
|
|
<Trans>More conversations ({hiddenSessionCount})</Trans>
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={onStartSession}
|
|
className="mt-1 self-start rounded-lg border border-border/70 px-3 py-1.5 text-ui-label font-medium text-foreground transition-colors hover:bg-accent"
|
|
>
|
|
<Trans>New conversation</Trans>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|