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
453 lines
15 KiB
TypeScript
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}`;
|
|
}
|