diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts new file mode 100644 index 0000000..7b13767 --- /dev/null +++ b/apps/web/e2e/heute-brief.spec.ts @@ -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"); +}); diff --git a/apps/web/scripts/translations-de.json b/apps/web/scripts/translations-de.json index 2cd65c1..0fe5fb0 100644 --- a/apps/web/scripts/translations-de.json +++ b/apps/web/scripts/translations-de.json @@ -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", diff --git a/apps/web/src/lib/heute-brief.test.ts b/apps/web/src/lib/heute-brief.test.ts new file mode 100644 index 0000000..21b32af --- /dev/null +++ b/apps/web/src/lib/heute-brief.test.ts @@ -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 { + 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 { + 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 { + 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); + }); +}); diff --git a/apps/web/src/lib/heute-brief.ts b/apps/web/src/lib/heute-brief.ts new file mode 100644 index 0000000..41b2d4a --- /dev/null +++ b/apps/web/src/lib/heute-brief.ts @@ -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(); + + const actionableRuns = [ + ...activeRuns.filter(isActionableRun), + ...recentRuns.filter(isFailedRoutine), + ]; + const seenRunIds = new Set(); + 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, + }; +} diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx new file mode 100644 index 0000000..babba84 --- /dev/null +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -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([]); + const [recentRuns, setRecentRuns] = useState([]); + 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 ( +
+
+

{dateLabel}

+

+ +

+
+ +
+ {loading && brief.jetzt.length === 0 && brief.weiter.length === 0 ? ( +

+ Loading… +

+ ) : brief.jetzt.length === 0 && brief.weiter.length === 0 ? ( + 0} /> + ) : ( +
+ {brief.jetzt.length > 0 ? ( +
+

+ Now +

+
+ {brief.jetzt.map((item) => ( + + ))} +
+
+ ) : null} + + {brief.weiter.length > 0 ? ( +
+

+ Next +

+
+ {brief.weiter.map((item) => ( + + ))} +
+
+ ) : null} +
+ )} + + +
+
+ ); +} + +function StatusLine({ actionCount, loading }: { actionCount: number; loading: boolean }) { + if (loading && actionCount === 0) { + return Loading…; + } + if (actionCount === 0) { + return All quiet; + } + return ( + + ); +} + +function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots: boolean }) { + return ( +
+ +
+

+ Quiet day so far +

+

+ Start a conversation with an Oo when you are ready. +

+
+ +
+ ); +} + +function JetztRow({ + item, + botColorById, + onOpenBot, + onOpenGroup, + onOpenRun, +}: { + item: JetztItem; + botColorById: Map; + 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 ( + + ); + } + + 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 ( + + ); +} + +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 ( + + ); +} + +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" }); +} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 299462a..baf29b1 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -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([]); const [searchLoading, setSearchLoading] = useState(false); @@ -3928,7 +3894,7 @@ export function ShellPage() { > {activeNavPillar === "heute" ? (
-
+
{botsSidebarCollapsed && desktopBridge() ? : null} -
-
- - -
- - evOo Mission Control - - - HEUTE · PORT 3088 - -
-
-
- - - Vollbild - -
-
-