feat(web): replace Heute iframe with native daily brief (#72)
Replace Mission Control iframe on the Heute pillar with a native React brief that surfaces actionable runs, unread replies, and today’s threads. Mission Control remains available via a small footer link for power users. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
0474875142
commit
83302961ba
20
apps/web/e2e/heute-brief.spec.ts
Normal file
20
apps/web/e2e/heute-brief.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
||||
|
||||
test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
|
||||
await completeOnboarding(page);
|
||||
|
||||
await page.getByRole("button", { name: "Heute" }).click();
|
||||
await expect(page).toHaveURL(/\/app$/);
|
||||
|
||||
const brief = page.getByTestId("heute-brief");
|
||||
await expect(brief).toBeVisible();
|
||||
await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0);
|
||||
await expect(page.getByText("evOo Mission Control")).toHaveCount(0);
|
||||
await expect(page.getByTestId("heute-status")).toBeVisible();
|
||||
await expect(page.getByTestId("heute-mission-control-link")).toBeVisible();
|
||||
|
||||
await captureScreenshot(page, testInfo, "heute-native-brief");
|
||||
});
|
||||
@ -389,6 +389,16 @@
|
||||
"Not connected": "Nicht verbunden",
|
||||
"Not now": "Jetzt nicht",
|
||||
"Now": "Jetzt",
|
||||
"Next": "Weiter",
|
||||
"All quiet": "Alles ruhig",
|
||||
"# thing needs you": "1 Ding braucht dich",
|
||||
"# things need you": "# Dinge brauchen dich",
|
||||
"Quiet day so far": "Heute noch ruhig",
|
||||
"Start a conversation with an Oo when you are ready.": "Starte eine Unterhaltung mit einem Oo, wenn du bereit bist.",
|
||||
"Open a chat": "Chat öffnen",
|
||||
"Create an Oo": "Oo anlegen",
|
||||
"Unread reply": "Ungelesene Antwort",
|
||||
"Mission Control": "Mission Control",
|
||||
"Now using {0}.": "Jetzt wird {0} verwendet.",
|
||||
"OAuth authorization was cancelled.": "OAuth-Autorisierung wurde abgebrochen.",
|
||||
"OAuth connected": "OAuth verbunden",
|
||||
|
||||
128
apps/web/src/lib/heute-brief.test.ts
Normal file
128
apps/web/src/lib/heute-brief.test.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHeuteBrief, isToday } from "./heute-brief";
|
||||
|
||||
const now = new Date("2026-09-22T12:00:00.000Z");
|
||||
|
||||
function bot(overrides: Partial<Bot> = {}): Bot {
|
||||
return {
|
||||
id: "bot-1",
|
||||
spaceId: "space-1",
|
||||
name: "Chief",
|
||||
title: "",
|
||||
description: "",
|
||||
instructions: "",
|
||||
color: "#000",
|
||||
notifyOnFinish: false,
|
||||
pinned: false,
|
||||
sectionId: null,
|
||||
archivedAt: null,
|
||||
unread: false,
|
||||
parentBotId: null,
|
||||
memoryScope: null,
|
||||
threadId: "thread-1",
|
||||
preview: "",
|
||||
status: "idle",
|
||||
computerMode: "team",
|
||||
updatedAt: "2026-09-22T10:00:00.000Z",
|
||||
createdAt: "2026-09-01T00:00:00.000Z",
|
||||
voiceId: null,
|
||||
autoSpeak: false,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
thinkingLevel: null,
|
||||
teamChatAmbientEnabled: false,
|
||||
teamChatRules: "",
|
||||
webhookConfigured: false,
|
||||
spawnKey: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function group(overrides: Partial<Group> = {}): Group {
|
||||
return {
|
||||
id: "group-1",
|
||||
spaceId: "space-1",
|
||||
name: "Launch",
|
||||
pinned: false,
|
||||
sectionId: null,
|
||||
archivedAt: null,
|
||||
members: [],
|
||||
threadId: "thread-g-1",
|
||||
preview: "",
|
||||
unread: false,
|
||||
updatedAt: "2026-09-22T09:00:00.000Z",
|
||||
createdAt: "2026-09-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function run(overrides: Partial<RunActivityRow> = {}): RunActivityRow {
|
||||
return {
|
||||
runId: "run-1",
|
||||
botId: "bot-1",
|
||||
botName: "Chief",
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
threadId: "thread-1",
|
||||
status: "waiting_input",
|
||||
trigger: "user",
|
||||
notificationsEnabled: true,
|
||||
promptSnippet: "Need approval",
|
||||
updatedAt: "2026-09-22T11:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isToday", () => {
|
||||
it("matches same calendar day", () => {
|
||||
expect(isToday("2026-09-22T08:30:00.000Z", now)).toBe(true);
|
||||
expect(isToday("2026-09-21T23:59:00.000Z", now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHeuteBrief", () => {
|
||||
it("collects actionable runs, unread chats, and today follow-ups without duplicates", () => {
|
||||
const brief = buildHeuteBrief({
|
||||
bots: [
|
||||
bot({ id: "bot-1", unread: true }),
|
||||
bot({ id: "bot-2", unread: true, updatedAt: "2026-09-22T08:00:00.000Z", name: "Steve" }),
|
||||
],
|
||||
groups: [group({ updatedAt: "2026-09-22T09:30:00.000Z" })],
|
||||
activeRuns: [run({ runId: "run-1", botId: "bot-1" })],
|
||||
recentRuns: [
|
||||
run({
|
||||
runId: "run-2",
|
||||
botId: "bot-3",
|
||||
botName: "Ops",
|
||||
status: "failed",
|
||||
trigger: "routine",
|
||||
}),
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
expect(brief.actionCount).toBe(3);
|
||||
expect(brief.jetzt.map((item) => item.kind)).toEqual(["run", "run", "unread"]);
|
||||
expect(brief.weiter).toHaveLength(1);
|
||||
expect(brief.weiter[0]?.chat.id).toBe("group-1");
|
||||
});
|
||||
|
||||
it("caps Weiter at five items", () => {
|
||||
const bots = Array.from({ length: 7 }, (_, index) =>
|
||||
bot({
|
||||
id: `bot-${index}`,
|
||||
name: `Bot ${index}`,
|
||||
updatedAt: `2026-09-22T${String(index).padStart(2, "0")}:00:00.000Z`,
|
||||
}),
|
||||
);
|
||||
const brief = buildHeuteBrief({
|
||||
bots,
|
||||
groups: [],
|
||||
activeRuns: [],
|
||||
recentRuns: [],
|
||||
now,
|
||||
});
|
||||
expect(brief.weiter).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
121
apps/web/src/lib/heute-brief.ts
Normal file
121
apps/web/src/lib/heute-brief.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
|
||||
export type JetztRunItem = {
|
||||
kind: "run";
|
||||
run: RunActivityRow;
|
||||
};
|
||||
|
||||
export type JetztUnreadItem = {
|
||||
kind: "unread";
|
||||
chatKind: "bot" | "group";
|
||||
chat: Bot | Group;
|
||||
};
|
||||
|
||||
export type JetztItem = JetztRunItem | JetztUnreadItem;
|
||||
|
||||
export type WeiterItem = {
|
||||
chatKind: "bot" | "group";
|
||||
chat: Bot | Group;
|
||||
};
|
||||
|
||||
export type HeuteBriefData = {
|
||||
jetzt: JetztItem[];
|
||||
weiter: WeiterItem[];
|
||||
actionCount: number;
|
||||
};
|
||||
|
||||
export function isToday(iso: string, now = new Date()): boolean {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return false;
|
||||
return (
|
||||
date.getDate() === now.getDate() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getFullYear() === now.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
export function cleanBriefPreview(raw?: string | null): string {
|
||||
if (!raw) return "";
|
||||
let text = raw;
|
||||
text = text.replace(/^#{1,6}\s+/gm, "");
|
||||
text = text.replace(/\[(?:to|RECOVERY PROTOCOL|PO)[^\]]*\]/gi, "");
|
||||
text = text.replace(/[*_`#]/g, "");
|
||||
text = text.replace(/\\/g, "");
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function runTargetKey(run: RunActivityRow): string {
|
||||
return run.groupId ? `group:${run.groupId}` : `bot:${run.botId}`;
|
||||
}
|
||||
|
||||
function chatTargetKey(chatKind: "bot" | "group", chatId: string): string {
|
||||
return chatKind === "group" ? `group:${chatId}` : `bot:${chatId}`;
|
||||
}
|
||||
|
||||
function isActionableRun(run: RunActivityRow): boolean {
|
||||
return run.status === "waiting_input" || run.status === "waiting_takeover";
|
||||
}
|
||||
|
||||
function isFailedRoutine(run: RunActivityRow): boolean {
|
||||
return run.trigger === "routine" && run.status === "failed";
|
||||
}
|
||||
|
||||
export function buildHeuteBrief(params: {
|
||||
bots: Bot[];
|
||||
groups: Group[];
|
||||
activeRuns: RunActivityRow[];
|
||||
recentRuns: RunActivityRow[];
|
||||
now?: Date;
|
||||
}): HeuteBriefData {
|
||||
const { bots, groups, activeRuns, recentRuns, now = new Date() } = params;
|
||||
const jetzt: JetztItem[] = [];
|
||||
const coveredTargets = new Set<string>();
|
||||
|
||||
const actionableRuns = [
|
||||
...activeRuns.filter(isActionableRun),
|
||||
...recentRuns.filter(isFailedRoutine),
|
||||
];
|
||||
const seenRunIds = new Set<string>();
|
||||
for (const run of actionableRuns) {
|
||||
if (seenRunIds.has(run.runId)) continue;
|
||||
seenRunIds.add(run.runId);
|
||||
jetzt.push({ kind: "run", run });
|
||||
coveredTargets.add(runTargetKey(run));
|
||||
}
|
||||
|
||||
const unreadChats: JetztUnreadItem[] = [
|
||||
...bots.filter((bot) => bot.unread).map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })),
|
||||
...groups.filter((group) => group.unread).map((group) => ({
|
||||
kind: "unread" as const,
|
||||
chatKind: "group" as const,
|
||||
chat: group,
|
||||
})),
|
||||
];
|
||||
for (const item of unreadChats) {
|
||||
const key = chatTargetKey(item.chatKind, item.chat.id);
|
||||
if (coveredTargets.has(key)) continue;
|
||||
jetzt.push(item);
|
||||
coveredTargets.add(key);
|
||||
}
|
||||
|
||||
const weiterCandidates: WeiterItem[] = [
|
||||
...bots
|
||||
.filter((bot) => isToday(bot.updatedAt, now))
|
||||
.map((bot) => ({ chatKind: "bot" as const, chat: bot })),
|
||||
...groups
|
||||
.filter((group) => isToday(group.updatedAt, now))
|
||||
.map((group) => ({ chatKind: "group" as const, chat: group })),
|
||||
];
|
||||
weiterCandidates.sort(
|
||||
(a, b) => new Date(b.chat.updatedAt).getTime() - new Date(a.chat.updatedAt).getTime(),
|
||||
);
|
||||
const weiter = weiterCandidates
|
||||
.filter((item) => !coveredTargets.has(chatTargetKey(item.chatKind, item.chat.id)))
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
jetzt,
|
||||
weiter,
|
||||
actionCount: jetzt.length,
|
||||
};
|
||||
}
|
||||
372
apps/web/src/pages/HeuteBrief.tsx
Normal file
372
apps/web/src/pages/HeuteBrief.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
import { botColors } from "@rakazo/ui-tokens";
|
||||
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
buildHeuteBrief,
|
||||
cleanBriefPreview,
|
||||
type JetztItem,
|
||||
type WeiterItem,
|
||||
} from "../lib/heute-brief";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
const MISSION_CONTROL_URL = "http://127.0.0.1:3088/";
|
||||
|
||||
type HeuteBriefProps = {
|
||||
bots: Bot[];
|
||||
groups: Group[];
|
||||
onOpenBot: (botId: string, spaceId: string) => void;
|
||||
onOpenGroup: (groupId: string, spaceId: string) => void;
|
||||
onOpenRun: (run: RunActivityRow) => void;
|
||||
onStartChat: () => void;
|
||||
};
|
||||
|
||||
export function HeuteBrief({
|
||||
bots,
|
||||
groups,
|
||||
onOpenBot,
|
||||
onOpenGroup,
|
||||
onOpenRun,
|
||||
onStartChat,
|
||||
}: HeuteBriefProps) {
|
||||
const { t } = useLingui();
|
||||
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
||||
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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 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 dateLabel = formatHeuteDate(new Date());
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
data-testid="heute-brief"
|
||||
>
|
||||
<div className="border-b border-border px-4 py-5 md:px-8 lg:px-10">
|
||||
<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} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
|
||||
{loading && brief.jetzt.length === 0 && brief.weiter.length === 0 ? (
|
||||
<p className="text-ui-body text-muted-foreground">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
) : brief.jetzt.length === 0 && brief.weiter.length === 0 ? (
|
||||
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
|
||||
) : (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
||||
{brief.jetzt.length > 0 ? (
|
||||
<section data-testid="heute-jetzt">
|
||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||
<Trans>Now</Trans>
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{brief.jetzt.map((item) => (
|
||||
<JetztRow
|
||||
key={jetztRowKey(item)}
|
||||
item={item}
|
||||
botColorById={botColorById}
|
||||
onOpenBot={onOpenBot}
|
||||
onOpenGroup={onOpenGroup}
|
||||
onOpenRun={onOpenRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{brief.weiter.length > 0 ? (
|
||||
<section data-testid="heute-weiter">
|
||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||
<Trans>Next</Trans>
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{brief.weiter.map((item) => (
|
||||
<WeiterRow
|
||||
key={`${item.chatKind}:${item.chat.id}`}
|
||||
item={item}
|
||||
onOpenBot={onOpenBot}
|
||||
onOpenGroup={onOpenGroup}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mx-auto mt-10 flex w-full max-w-2xl justify-center pb-4">
|
||||
<a
|
||||
href={MISSION_CONTROL_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground/70 transition-colors hover:text-muted-foreground"
|
||||
data-testid="heute-mission-control-link"
|
||||
>
|
||||
<Trans>Mission Control</Trans>
|
||||
<ExternalLink strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLine({ actionCount, loading }: { actionCount: number; loading: boolean }) {
|
||||
if (loading && actionCount === 0) {
|
||||
return <Trans>Loading…</Trans>;
|
||||
}
|
||||
if (actionCount === 0) {
|
||||
return <Trans>All quiet</Trans>;
|
||||
}
|
||||
return (
|
||||
<Plural
|
||||
value={actionCount}
|
||||
one="# thing needs you"
|
||||
other="# things need you"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 JetztRow({
|
||||
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 || runStatusLabel(run.status);
|
||||
const label = t`${title}, ${runStatusLabel(run.status)}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-testid={`heute-jetzt-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-semibold 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 font-medium text-warning">
|
||||
{runStatusLabel(run.status)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const { chatKind, chat } = item;
|
||||
const title = chat.name;
|
||||
const preview = cleanBriefPreview(chat.preview) || 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-jetzt-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>
|
||||
<span className="inline-block size-2 shrink-0 rounded-full bg-primary" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function WeiterRow({
|
||||
item,
|
||||
onOpenBot,
|
||||
onOpenGroup,
|
||||
}: {
|
||||
item: WeiterItem;
|
||||
onOpenBot: (botId: string, spaceId: string) => void;
|
||||
onOpenGroup: (groupId: string, spaceId: string) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const { chatKind, chat } = item;
|
||||
const title = chat.name;
|
||||
const preview =
|
||||
cleanBriefPreview(chat.preview) ||
|
||||
(chatKind === "group"
|
||||
? (chat as Group).members.map((member) => member.name).join(", ")
|
||||
: t`Ready`);
|
||||
const label =
|
||||
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-testid={`heute-weiter-${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="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-ui-body font-medium text-foreground">{title}</span>
|
||||
<span className="shrink-0 text-ui-label text-muted-foreground">
|
||||
{formatBriefTime(chat.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{preview}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function jetztRowKey(item: JetztItem): string {
|
||||
if (item.kind === "run") return `run:${item.run.runId}`;
|
||||
return `unread:${item.chatKind}:${item.chat.id}`;
|
||||
}
|
||||
|
||||
function runStatusLabel(status: RunActivityRow["status"]): string {
|
||||
switch (status) {
|
||||
case "waiting_input":
|
||||
return t`Needs input`;
|
||||
case "waiting_takeover":
|
||||
return t`Needs takeover`;
|
||||
case "failed":
|
||||
return t`Failed`;
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHeuteDate(date: Date): string {
|
||||
const locale = i18n.locale || "de";
|
||||
return date.toLocaleDateString(locale, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBriefTime(iso: string): string {
|
||||
const locale = i18n.locale || "de";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
@ -91,7 +91,6 @@ import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Gauge,
|
||||
LayoutGrid,
|
||||
Lock,
|
||||
@ -108,7 +107,6 @@ import {
|
||||
Plus,
|
||||
Puzzle,
|
||||
Reply,
|
||||
RotateCw,
|
||||
Search,
|
||||
Settings,
|
||||
Smile,
|
||||
@ -216,6 +214,7 @@ import {
|
||||
} from "../lib/transcript-scroll";
|
||||
import { speaker } from "../lib/tts";
|
||||
import { ActivityList } from "./ActivityList";
|
||||
import { HeuteBrief } from "./HeuteBrief";
|
||||
import type { ContextMenuPosition } from "./BotContextMenu";
|
||||
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
|
||||
import { HostComputerPrompt } from "./HostComputerPrompt";
|
||||
@ -357,7 +356,6 @@ export function ShellPage() {
|
||||
if (botId) return "team";
|
||||
return "heute";
|
||||
});
|
||||
const [dashboardReloadKey, setDashboardReloadKey] = useState(0);
|
||||
const [tradingMode, setTradingMode] = useState<"live" | "paper">(() => {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
const saved = window.localStorage.getItem("evoo_trading_mode");
|
||||
@ -425,38 +423,6 @@ export function ShellPage() {
|
||||
setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId));
|
||||
}, [userId]);
|
||||
|
||||
// Listen for navigation messages from embedded Mission Control iframe (Heute tab)
|
||||
useEffect(() => {
|
||||
const handleDashboardMessage = (event: MessageEvent) => {
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
if (event.data.type === "open_team_tab") {
|
||||
setActiveNavPillar("team");
|
||||
const author = (event.data.botAuthor || event.data.botName || "").toLowerCase().trim();
|
||||
if (author && botsRef.current.length > 0) {
|
||||
const match = botsRef.current.find((b) => {
|
||||
const bName = b.name.toLowerCase();
|
||||
return (
|
||||
bName.includes(author) ||
|
||||
author.includes(bName) ||
|
||||
(author.includes("steve") && bName.includes("steve")) ||
|
||||
(author.includes("quant") && bName.includes("quant")) ||
|
||||
(author.includes("architect") && bName.includes("architect")) ||
|
||||
(author.includes("qa") && bName.includes("qa")) ||
|
||||
(author.includes("devops") && bName.includes("devops")) ||
|
||||
(author.includes("ux") && bName.includes("ux"))
|
||||
);
|
||||
});
|
||||
if (match) {
|
||||
navigate(`/app/${match.id}`);
|
||||
}
|
||||
}
|
||||
} else if (event.data.type === "open_projekte_tab") {
|
||||
setActiveNavPillar("projekte");
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleDashboardMessage);
|
||||
return () => window.removeEventListener("message", handleDashboardMessage);
|
||||
}, [navigate]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
@ -3928,7 +3894,7 @@ export function ShellPage() {
|
||||
>
|
||||
{activeNavPillar === "heute" ? (
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="app-drag flex items-center justify-between border-b border-sidebar-border px-3 py-[17px] md:px-[22px] lg:px-8 2xl:px-12">
|
||||
<div className="app-drag flex items-center border-b border-sidebar-border px-3 py-[17px] md:px-[22px] lg:px-8 2xl:px-12">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{botsSidebarCollapsed && desktopBridge() ? <WindowChrome /> : null}
|
||||
<button
|
||||
@ -3953,49 +3919,39 @@ export function ShellPage() {
|
||||
<PanelLeftClose strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
|
||||
</div>
|
||||
<span className="text-ui-body font-semibold text-foreground tracking-tight">
|
||||
evOo Mission Control
|
||||
</span>
|
||||
<span className="hidden sm:inline-flex text-ui-label font-mono px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 font-medium">
|
||||
HEUTE · PORT 3088
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDashboardReloadKey((k) => k + 1)}
|
||||
title={t`Reload`}
|
||||
className="app-no-drag grid size-control-lg place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<RotateCw strokeWidth={1.8} className="size-icon-md" />
|
||||
</button>
|
||||
<a
|
||||
href="http://127.0.0.1:3088"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t`Open in new window`}
|
||||
className="app-no-drag flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-ui-label font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Vollbild</span>
|
||||
<ExternalLink strokeWidth={1.8} className="size-icon-sm" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 w-full h-full bg-background overflow-hidden">
|
||||
<iframe
|
||||
key={dashboardReloadKey}
|
||||
src="http://127.0.0.1:3088/"
|
||||
className="w-full h-full border-0"
|
||||
title="evOo Mission Control"
|
||||
/>
|
||||
</div>
|
||||
<HeuteBrief
|
||||
bots={bots}
|
||||
groups={groups}
|
||||
onOpenBot={(botId, spaceId) => {
|
||||
setActiveNavPillar("team");
|
||||
openSpaceChat(spaceId, `/app/${botId}`);
|
||||
}}
|
||||
onOpenGroup={(groupId, spaceId) => {
|
||||
setActiveNavPillar("projekte");
|
||||
openSpaceChat(spaceId, `/app/g/${groupId}`);
|
||||
}}
|
||||
onOpenRun={(run) => {
|
||||
if (run.groupId) {
|
||||
setActiveNavPillar("projekte");
|
||||
navigate(`/app/g/${run.groupId}`);
|
||||
} else {
|
||||
setActiveNavPillar("team");
|
||||
navigate(`/app/${run.botId}`);
|
||||
}
|
||||
}}
|
||||
onStartChat={() => {
|
||||
const first = bots[0];
|
||||
if (first) {
|
||||
setActiveNavPillar("team");
|
||||
openSpaceChat(first.spaceId, `/app/${first.id}`);
|
||||
return;
|
||||
}
|
||||
setCreateMenuOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user