evOo-local/apps/web/src/pages/HeuteBrief.tsx
Claude a62e09f36b
fix(web): word the main conversation preview in the user's language during onboarding
While onboarding wrote the main conversation, its sidebar row, project rows and Heute showed the
server preview: nothing for a pending card (a card has no text of its own, so the row fell back
to "Ready") and the English fallback of the closing note, even for a German user.

Groups now carry `previewBlock` next to `preview`: the latest message when it is an onboarding
card or note, validated with the same block schemas the messages use (now named exports). The
web words it with the helpers that render the card and the note, so no copy is duplicated: the
question while a card waits, the answer once given, and the closing note in the user's language
and clock. A dismissed card keeps the plain preview, as the conversation no longer shows it.
The field is left out for every other message, and mobile keeps using the plain `preview`.

The chosen-answer lookup moves from the card into onboarding-copy so the card and the preview
share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:17:32 +00:00

453 lines
15 KiB
TypeScript

import { i18n } from "@lingui/core";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts";
import { botColors } from "@rakazo/ui-tokens";
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CoachRhythmControl } from "../components/heute/CoachRhythmControl";
import { HeuteCoach } from "../components/heute/HeuteCoach";
import { runActivityLabel } from "../lib/agent-activity";
import type { DoingItem, DoneItem, JetztItem } from "../lib/heute-brief";
import {
buildHeuteBrief,
cleanBriefPreview,
coachRefreshSignal,
formatLocalDate,
withHabitDone,
} from "../lib/heute-brief";
import { localizedPreview } from "../lib/onboarding-copy";
import { rpc } from "../lib/rpc";
type HeuteBriefProps = {
bots: Bot[];
groups: Group[];
onOpenBot: (botId: string, spaceId: string) => void;
onOpenGroup: (groupId: string, spaceId: string) => void;
onOpenRun: (run: RunActivityRow) => void;
onStartChat: () => void;
};
const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" };
export function HeuteBrief({
bots,
groups,
onOpenBot,
onOpenGroup,
onOpenRun,
onStartChat,
}: HeuteBriefProps) {
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
const [loading, setLoading] = useState(true);
const [coach, setCoach] = useState<CoachToday | null>(null);
const [coachSettled, setCoachSettled] = useState(false);
const latestCoachRequest = useRef(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;
setActiveRuns(active.runs);
setRecentRuns(recent.runs);
} catch {
if (cancelled) return;
} finally {
if (!cancelled) {
setLoading(false);
timer = window.setTimeout(() => void tick(), 15_000);
}
}
};
void tick();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, []);
const refreshCoach = useCallback(async () => {
const request = ++latestCoachRequest.current;
try {
const today = await rpc.coach.today();
if (request === latestCoachRequest.current) setCoach(today);
} catch {
// Keep the last view; the next refresh retries.
} finally {
setCoachSettled(true);
}
}, []);
// `coachSignal` is the trigger: a finished run may have logged a check-in or written the brief.
const coachSignal = useMemo(() => coachRefreshSignal(recentRuns), [recentRuns]);
useEffect(() => {
void refreshCoach();
}, [refreshCoach, coachSignal]);
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") void refreshCoach();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
document.removeEventListener("visibilitychange", onVisible);
// Ignore responses that arrive after unmount.
latestCoachRequest.current += 1;
};
}, [refreshCoach]);
const setHabitDone = useCallback(
async (habit: HabitToday, done: boolean) => {
if (!coach) return;
setCoach((current) => (current ? withHabitDone(current, habit.id, done) : current));
latestCoachRequest.current += 1;
try {
await rpc.coach.checkIns.log({ kind: "habit", habitId: habit.id, date: coach.date, done });
} finally {
void refreshCoach();
}
},
[coach, refreshCoach],
);
const brief = useMemo(
() =>
buildHeuteBrief({
bots,
groups,
activeRuns,
recentRuns,
}),
[activeRuns, bots, groups, recentRuns],
);
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
const locale = i18n.locale || "de";
const dateLabel = coach
? formatLocalDate(coach.date, locale, DATE_LABEL)
: new Date().toLocaleDateString(locale, DATE_LABEL);
const hasCoachContent = Boolean(
coach && (coach.brief || coach.habits.length > 0 || coach.goals.length > 0),
);
const hasContent =
hasCoachContent || brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-5 md:px-8 lg:px-10">
<div className="min-w-0">
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">
{dateLabel}
</h1>
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
<StatusLine
actionCount={brief.actionCount}
loading={loading}
doingCount={brief.doing.length}
/>
</p>
</div>
<CoachRhythmControl />
</div>
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
{(loading || !coachSettled) && !hasContent ? (
<p className="text-ui-body text-muted-foreground">
<Trans>Loading…</Trans>
</p>
) : !hasContent ? (
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
) : (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8 pb-4">
{coach ? <HeuteCoach today={coach} onHabitDone={setHabitDone} /> : null}
{brief.needsYou.length > 0 ? (
<section data-testid="heute-needs-you">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Needs you</Trans>
</h2>
<div className="flex flex-col gap-1">
{brief.needsYou.map((item) => (
<NeedsYouRow
key={jetztRowKey(item)}
item={item}
botColorById={botColorById}
onOpenBot={onOpenBot}
onOpenGroup={onOpenGroup}
onOpenRun={onOpenRun}
/>
))}
</div>
{brief.actionCount > brief.needsYou.length ? (
<p className="mt-2 px-0.5 text-ui-label text-muted-foreground">
<Plural
value={brief.actionCount - brief.needsYou.length}
one="# more waiting"
other="# more waiting"
/>
</p>
) : null}
</section>
) : null}
{brief.doing.length > 0 ? (
<section data-testid="heute-doing">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Doing</Trans>
</h2>
<div className="flex flex-col gap-1">
{brief.doing.map((item) => (
<DoingRow
key={`doing:${item.run.runId}`}
item={item}
botColorById={botColorById}
onOpenRun={onOpenRun}
/>
))}
</div>
</section>
) : null}
{brief.done.length > 0 ? (
<section data-testid="heute-done">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Done</Trans>
</h2>
<div className="flex flex-col gap-1">
{brief.done.map((item) => (
<DoneRow
key={`done:${item.run.runId}`}
item={item}
botColorById={botColorById}
onOpenRun={onOpenRun}
/>
))}
</div>
</section>
) : null}
</div>
)}
</div>
</div>
);
}
function StatusLine({
actionCount,
doingCount,
loading,
}: {
actionCount: number;
doingCount: number;
loading: boolean;
}) {
if (loading && actionCount === 0 && doingCount === 0) {
return <Trans>Loading…</Trans>;
}
if (actionCount > 0) {
return <Plural value={actionCount} one="# thing needs you" other="# things need you" />;
}
if (doingCount > 0) {
return <Plural value={doingCount} one="# Oo is working" other="# Oos are working" />;
}
return <Trans>All quiet</Trans>;
}
function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots: boolean }) {
return (
<div
className="mx-auto flex w-full max-w-md flex-col items-center gap-5 py-16 text-center"
data-testid="heute-empty"
>
<EvooMascot size={72} pose="greeting" />
<div className="space-y-2">
<p className="text-ui-body-lg font-medium text-foreground">
<Trans>Quiet day so far</Trans>
</p>
<p className="text-ui-body text-muted-foreground">
<Trans>Start a conversation with an Oo when you are ready.</Trans>
</p>
</div>
<Button type="button" onClick={onStartChat} data-testid="heute-start-chat">
{hasBots ? <Trans>Open a chat</Trans> : <Trans>Create an Oo</Trans>}
</Button>
</div>
);
}
function ActionableChip({ label }: { label: string }) {
return (
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
{label}
</span>
);
}
function NeedsYouRow({
item,
botColorById,
onOpenBot,
onOpenGroup,
onOpenRun,
}: {
item: JetztItem;
botColorById: Map<string, string>;
onOpenBot: (botId: string, spaceId: string) => void;
onOpenGroup: (groupId: string, spaceId: string) => void;
onOpenRun: (run: RunActivityRow) => void;
}) {
const { t } = useLingui();
if (item.kind === "run") {
const run = item.run;
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
const subtitle = run.promptSnippet || runActivityLabel(run.status);
const label = t`${title}, ${runActivityLabel(run.status)}`;
return (
<button
type="button"
aria-label={label}
data-testid={`heute-needs-you-run-${run.runId}`}
onClick={() => onOpenRun(run)}
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
>
<BotAvatar
color={botColorById.get(run.botId) ?? botColors[3]}
identity={run.botId}
size={40}
status={run.status}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-ui-body font-semibold text-foreground">{title}</div>
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
</div>
<ActionableChip label={t`Needs you`} />
</button>
);
}
const { chatKind, chat } = item;
const title = chat.name;
const preview = cleanBriefPreview(localizedPreview(chat)) || t`Unread reply`;
const label =
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
return (
<button
type="button"
aria-label={label}
data-testid={`heute-needs-you-unread-${chatKind}-${chat.id}`}
onClick={() =>
chatKind === "bot" ? onOpenBot(chat.id, chat.spaceId) : onOpenGroup(chat.id, chat.spaceId)
}
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
>
{chatKind === "bot" ? (
<BotAvatar
color={(chat as Bot).color}
identity={chat.id}
size={40}
status={(chat as Bot).status}
/>
) : (
<GroupAvatar members={(chat as Group).members} size={40} />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-ui-body font-semibold text-foreground">{title}</div>
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{preview}</div>
</div>
<ActionableChip label={t`Unread`} />
</button>
);
}
function DoingRow({
item,
botColorById,
onOpenRun,
}: {
item: DoingItem;
botColorById: Map<string, string>;
onOpenRun: (run: RunActivityRow) => void;
}) {
const { t } = useLingui();
const run = item.run;
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
const subtitle = run.promptSnippet || runActivityLabel(run.status);
const label = t`${title}, ${runActivityLabel(run.status)}`;
return (
<button
type="button"
aria-label={label}
data-testid={`heute-doing-run-${run.runId}`}
onClick={() => onOpenRun(run)}
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
>
<BotAvatar
color={botColorById.get(run.botId) ?? botColors[3]}
identity={run.botId}
size={40}
status={run.status}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-ui-body font-medium text-foreground">{title}</div>
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
</div>
<span className="shrink-0 text-ui-label text-muted-foreground">
{runActivityLabel(run.status)}
</span>
</button>
);
}
function DoneRow({
item,
botColorById,
onOpenRun,
}: {
item: DoneItem;
botColorById: Map<string, string>;
onOpenRun: (run: RunActivityRow) => void;
}) {
const { t } = useLingui();
const run = item.run;
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
const subtitle = run.promptSnippet || runActivityLabel(run.status);
const label = t`${title}, ${runActivityLabel(run.status)}`;
return (
<button
type="button"
aria-label={label}
data-testid={`heute-done-run-${run.runId}`}
onClick={() => onOpenRun(run)}
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
>
<BotAvatar
color={botColorById.get(run.botId) ?? botColors[3]}
identity={run.botId}
size={40}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-ui-body font-medium text-foreground">{title}</div>
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
</div>
<span className="shrink-0 text-ui-label text-success">{runActivityLabel(run.status)}</span>
</button>
);
}
function jetztRowKey(item: JetztItem): string {
if (item.kind === "run") return `run:${item.run.runId}`;
return `unread:${item.chatKind}:${item.chat.id}`;
}