From ac0384e49cb8774b13204228ae144ad2a4fd1738 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:59:35 +0200 Subject: [PATCH] feat: add automatic plan mode, live step tracking, and chat UX updates --- apps/api/src/app.ts | 32 +- apps/api/src/router.ts | 15 + apps/api/src/team-chat-judge.test.ts | 73 + apps/api/src/team-chat-judge.ts | 35 +- apps/api/src/thread-target.ts | 60 + apps/mobile/app/thread.tsx | 143 ++ apps/web/index.html | 3 + apps/web/public/site.webmanifest | 11 +- apps/web/public/sw.js | 1 + apps/web/src/components/ArtifactFileCard.tsx | 118 +- apps/web/src/components/AskCard.test.tsx | 126 +- apps/web/src/components/AskCard.tsx | 73 +- apps/web/src/components/CloudAgentCard.tsx | 17 +- .../components/NotificationSettingsPanel.tsx | 18 +- apps/web/src/lib/chat-markdown.test.tsx | 16 + apps/web/src/lib/heute-brief.test.ts | 73 +- apps/web/src/lib/heute-brief.ts | 58 +- apps/web/src/lib/web-push.ts | 34 + apps/web/src/locales/de/messages.po | 8 + apps/web/src/pages/CallView.tsx | 11 +- apps/web/src/pages/HeuteBrief.tsx | 23 +- apps/web/src/pages/Shell.tsx | 308 +-- apps/web/src/pages/shell/composer.tsx | 2 +- apps/web/src/pages/shell/message-cards.tsx | 575 +++++- apps/web/src/pages/shell/run-steps.tsx | 21 +- .../web/src/pages/shell/side-panel-header.tsx | 21 +- apps/web/src/pages/shell/transcript.tsx | 1644 ++++++++++++----- .../src/pages/shell/use-heute-action-count.ts | 10 +- apps/web/src/pages/shell/use-sidebar-model.ts | 21 +- apps/web/src/styles.css | 157 +- .../tests/e2e-minimalist-tactile-ux.test.tsx | 47 +- .../sandboxes/supervisor/src/computer-spec.ts | 43 + .../supervisor/src/github-broker.test.ts | 51 +- .../sandboxes/supervisor/src/github-broker.ts | 48 +- .../supervisor/src/home-ownership.ts | 2 +- infra/sandboxes/supervisor/src/index.ts | 34 +- .../supervisor/src/supervisor-logic.ts | 26 +- packages/adapters/src/builtin-tools.ts | 98 + .../adapters/src/computer-support.test.ts | 21 + packages/adapters/src/computer-support.ts | 22 +- .../src/desktop-sandbox-paths.test.ts | 17 +- .../adapters/src/desktop-sandbox-paths.ts | 40 +- packages/adapters/src/desktop-sandbox.ts | 448 +---- packages/adapters/src/docker-sandbox.ts | 30 +- .../src/executor-instructions.test.ts | 16 + packages/adapters/src/executor-prompt.test.ts | 50 +- packages/adapters/src/executor-prompt.ts | 47 +- packages/adapters/src/executor-tools.ts | 1 + packages/adapters/src/executor.ts | 214 ++- .../adapters/src/host-aware-sandbox.test.ts | 32 +- packages/adapters/src/lazy-tool-catalog.ts | 6 +- packages/adapters/src/pi-model-config.ts | 685 ++++++- packages/adapters/src/pi-runtime-limits.ts | 26 + .../src/pi-runtime-tool-schema.test.ts | 42 +- .../adapters/src/pi-runtime-transport.test.ts | 25 +- packages/adapters/src/pi-runtime.test.ts | 265 ++- packages/adapters/src/pi-runtime.ts | 84 +- .../adapters/src/sandbox-conformance.test.ts | 30 +- packages/adapters/src/sandbox-faults.test.ts | 10 +- .../adapters/src/thread-artifacts.test.ts | 32 +- packages/chat-ui/src/markdown.native.tsx | 40 +- packages/chat-ui/src/markdown.test.ts | 29 +- packages/chat-ui/src/markdown.ts | 13 + packages/chat-ui/src/markdown.web.css | 49 + packages/chat-ui/src/markdown.web.tsx | 106 +- packages/contracts/src/events.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/plan.ts | 38 + packages/contracts/src/rpc.ts | 3 + packages/core/src/action-approval.ts | 2 + packages/core/src/attachments.ts | 2 + packages/core/src/delegated-tasks.test.ts | 46 + packages/core/src/delegated-tasks.ts | 88 + packages/core/src/index.ts | 1 + packages/core/src/model-providers.test.ts | 43 +- packages/core/src/model-providers.ts | 35 +- packages/core/src/plan.test.ts | 121 ++ packages/core/src/plan.ts | 216 +++ packages/core/src/run-narration.test.ts | 116 +- packages/core/src/run-narration.ts | 155 ++ packages/core/src/speech-text.test.ts | 73 + packages/core/src/speech-text.ts | 165 ++ packages/core/src/suggestions.ts | 2 + packages/core/src/teach-playbook.test.ts | 47 +- packages/core/src/teach-playbook.ts | 62 +- packages/core/src/workspace-focus.test.ts | 45 +- packages/core/src/workspace-focus.ts | 74 + packages/db/prisma/schema.prisma | 2 +- packages/db/src/index.ts | 1 + packages/db/src/main-agent.ts | 1 + packages/db/src/plan-messages.ts | 53 + packages/db/src/repos.ts | 4 +- packages/db/src/thread-listing.ts | 8 + packages/memory/src/index.test.ts | 49 +- packages/memory/src/index.ts | 120 +- 95 files changed, 6530 insertions(+), 1481 deletions(-) create mode 100644 packages/contracts/src/plan.ts create mode 100644 packages/core/src/plan.test.ts create mode 100644 packages/core/src/plan.ts create mode 100644 packages/db/src/plan-messages.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fa61f56..a79f8b5 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -29,6 +29,7 @@ import { createMediaRuntime, createMessagingContextLoader, createMessagingTeamChatSender, + createPostgresReconciliationLeadership, createProfileConsolidationScheduler, createRunExecutor, createRunSandbox, @@ -474,26 +475,30 @@ export async function createApp( cloudAgent, media, }); + let graphileWorkerHost: GraphileJobWorkerHost | undefined; if (inMemoryJobs) { await inMemoryJobs.start(jobHandlers); } else if (jobPool) { - const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, { - concurrency: 4, + graphileWorkerHost = new GraphileJobWorkerHost(jobPool, { + concurrency: parsePositiveInteger(process.env.GRAPHILE_WORKER_CONCURRENCY, 4), pollInterval: 500, }); await graphileWorkerHost.start(jobHandlers); } - const reconciler = inMemoryJobs - ? createJobReconciler({ - prisma, - jobs, - reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }), - reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }), - reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }), - reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }), - reconcileMediaJobs: () => media.reconcile(), - }) - : undefined; + const reconciler = + inMemoryJobs || !prismaOverride + ? createJobReconciler({ + prisma, + jobs, + events, + leadership: jobPool ? createPostgresReconciliationLeadership(jobPool) : undefined, + reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }), + reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }), + reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }), + reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }), + reconcileMediaJobs: () => media.reconcile(), + }) + : undefined; reconciler?.start(); const router = createRouter({ @@ -1006,6 +1011,7 @@ export async function createApp( await teamChatBridge?.stop(); await email?.drain?.(); await reconciler?.stop(); + await graphileWorkerHost?.stop(); await jobs.close(); await realtime.close(); await connector.stop(); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2225cca..f3a8ab3 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -271,6 +271,7 @@ import { followUpGroupThreadMessage, groupDefaultBotId, reactToThreadMessage, + removeQueuedMessage, resolveThreadTarget, sendQueuedThreadMessagesNow, sendThreadMessage, @@ -2075,6 +2076,20 @@ export function createRouter(deps: RouterDeps) { messageId: input.messageId, }); }), + removeQueuedMessage: authed.threads.removeQueuedMessage.handler( + async ({ context, input }) => { + const target = await resolveThreadTarget(deps.prisma, context.actor, input); + if (target.kind === "bot") { + await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId); + } + return removeQueuedMessage( + { prisma: deps.prisma, events: deps.events }, + context.actor, + target, + input.messageId, + ); + }, + ), clear: authed.threads.clear.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); const contextBotId = target.kind === "bot" ? target.botId : groupDefaultBotId(target); diff --git a/apps/api/src/team-chat-judge.test.ts b/apps/api/src/team-chat-judge.test.ts index d6ea68b..a7048de 100644 --- a/apps/api/src/team-chat-judge.test.ts +++ b/apps/api/src/team-chat-judge.test.ts @@ -88,4 +88,77 @@ describe("model team chat engagement judge", () => { expect(instructions).toContain("(Europe/Berlin, the user's time zone)"); expect(instructions).toContain("low-cost engagement judge"); }); + + it("prefers the space fast model tier when mapped and no explicit judge override is set", async () => { + let selectedModel: { provider: string; id: string; apiKey?: string } | undefined; + const runtime = { + run: vi.fn((request: { model: { provider: string; id: string; apiKey?: string } }) => { + selectedModel = request.model; + return (async function* () { + yield { type: "done" as const, text: '{"act":true,"reason":"Direct mention."}' }; + })(); + }), + } as unknown as AgentRuntime; + const prisma = { + deploymentSettings: { findUnique: vi.fn(async () => null) }, + spaceModelPreference: { findFirst: vi.fn(async () => null) }, + spaceModelTier: { + findUnique: vi.fn(async () => ({ + tier: "fast", + credentialId: "cred-fast", + modelId: "gemma4:latest", + credential: { + id: "cred-fast", + userId: "user-1", + provider: "openai-compatible", + secretId: "sec-fast", + preferences: [{ isDefault: false, modelId: null }], + }, + })), + }, + secret: { + findFirst: vi.fn(async () => ({ id: "sec-fast", ciphertext: "cipher-fast" })), + }, + userProfile: { findUnique: vi.fn(async () => ({ timeZone: "UTC" })) }, + } as unknown as PrismaClient; + const secrets = { + load: vi.fn(() => + JSON.stringify({ + kind: "openai_compatible", + apiKey: "local-key", + baseUrl: "http://127.0.0.1:11434/v1", + }), + ), + } as unknown as EncryptedSecretStore; + + const judge = new ModelTeamChatEngagementJudge({ + prisma, + runtime, + secrets, + deploymentProvider: "openrouter", + deploymentModel: "expensive-default", + }); + + const decision = await judge.decide({ + bot: { + id: "bot-1", + spaceId: "space-1", + userId: "user-1", + name: "Arthur", + modelProvider: "anthropic", + modelId: "claude-opus", + }, + channelId: "C1", + rules: "", + messages: [], + }); + + expect(decision).toEqual({ act: true, reason: "Direct mention.", askedByEventId: undefined }); + expect(selectedModel).toMatchObject({ + provider: "openai-compatible", + id: "gemma4:latest", + apiKey: "local-key", + baseUrl: "http://127.0.0.1:11434/v1", + }); + }); }); diff --git a/apps/api/src/team-chat-judge.ts b/apps/api/src/team-chat-judge.ts index bb8f79a..c8deb7b 100644 --- a/apps/api/src/team-chat-judge.ts +++ b/apps/api/src/team-chat-judge.ts @@ -1,14 +1,15 @@ import { randomUUID } from "node:crypto"; import type { AgentModelOAuthCredential, AgentRuntime } from "@rakazo/adapter-kit"; +import type { EncryptedSecretStore } from "@rakazo/adapters"; import { - type EncryptedSecretStore, formatCurrentTimeInstruction, loadUserTimeZone, resolveModelAuth, serializeModelSecret, toOAuthCredential, } from "@rakazo/adapters"; -import { findDefaultModelCredential, findModelCredential, type PrismaClient } from "@rakazo/db"; +import type { PrismaClient } from "@rakazo/db"; +import { findDefaultModelCredential, findModelCredential, findSpaceModelTier } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; const MAX_RULES_CHARS = 4_000; @@ -191,27 +192,39 @@ export class ModelTeamChatEngagementJudge implements TeamChatEngagementJudge { }; }; } | null> { - const settings = await this.deps.prisma.deploymentSettings.findUnique({ - where: { id: "default" }, - }); const scope = { userId: bot.userId, spaceId: bot.spaceId }; - const defaultCredential = await findDefaultModelCredential(this.deps.prisma, scope); + const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride); + const fastTier = + !explicitJudgeOverride && this.deps.prisma.spaceModelTier + ? await findSpaceModelTier(this.deps.prisma, scope, "fast") + : null; + const defaultCredential = fastTier + ? null + : await findDefaultModelCredential(this.deps.prisma, scope); const requestedProvider = this.deps.providerOverride ?? bot.modelProvider; const requestedModel = this.deps.modelOverride ?? bot.modelId; - const overrideCredential = requestedProvider - ? await findModelCredential(this.deps.prisma, scope, requestedProvider) - : null; - const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride); + const overrideCredential = + !fastTier && requestedProvider + ? await findModelCredential(this.deps.prisma, scope, requestedProvider) + : null; const useOverride = Boolean( requestedProvider && requestedModel && (explicitJudgeOverride || overrideCredential), ); - const credential = useOverride ? overrideCredential : defaultCredential; + const credential = + fastTier?.credential ?? (useOverride ? overrideCredential : defaultCredential); + const settings = fastTier + ? null + : await this.deps.prisma.deploymentSettings.findUnique({ + where: { id: "default" }, + }); const provider = + fastTier?.credential.provider ?? (useOverride ? requestedProvider : null) ?? credential?.provider ?? settings?.defaultModelProvider ?? this.deps.deploymentProvider; const modelId = + fastTier?.modelId ?? (useOverride ? requestedModel : null) ?? credential?.defaultModel ?? settings?.defaultModelId ?? diff --git a/apps/api/src/thread-target.ts b/apps/api/src/thread-target.ts index 1efc9d4..24eb60c 100644 --- a/apps/api/src/thread-target.ts +++ b/apps/api/src/thread-target.ts @@ -1487,6 +1487,66 @@ export async function sendQueuedThreadMessagesNow( }; } +export async function removeQueuedMessage( + deps: { + prisma: PrismaClient; + events: ThreadEvents; + }, + actor: Actor, + target: ThreadTarget, + messageId: string, +) { + const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds; + const result = await deps.prisma.$transaction(async (tx) => { + // Verify the message exists and belongs to this thread + const message = await tx.message.findFirst({ + where: { + id: messageId, + threadId: target.threadId, + role: "user", + }, + }); + if (!message) { + return { removed: false as const }; + } + + // Check if this message is queued (has an unclaimed steering message) + const steering = await tx.steeringMessage.findFirst({ + where: { + messageId: messageId, + botId: { in: botIds }, + claimedAt: null, + }, + }); + + if (!steering) { + // No queued steering entry — nothing to remove + return { removed: false as const }; + } + + // Delete the steering message (unclaims it from the queue) + await tx.steeringMessage.delete({ + where: { id: steering.id }, + }); + + // Also clean up the user message if it's exclusively queued + // (no run attached to it) + if (!message.runId) { + await tx.message.delete({ + where: { id: messageId }, + }); + } + + return { removed: true as const }; + }); + + if (result.removed) { + // Notify subscribers so the UI updates + await deps.events.notify(target.threadId, undefined).catch(() => undefined); + } + + return { ok: true as const, removed: result.removed }; +} export async function setThreadUnreadState( prisma: PrismaClient, actor: Actor, diff --git a/apps/mobile/app/thread.tsx b/apps/mobile/app/thread.tsx index c4e2f5a..37b1125 100644 --- a/apps/mobile/app/thread.tsx +++ b/apps/mobile/app/thread.tsx @@ -1298,6 +1298,31 @@ function Thread() { } } + const [removingQueuedId, setRemovingQueuedId] = useState(null); + + async function removeQueuedNow(message: MobileMessage) { + const targetBotId = botId; + const targetGroupId = groupId; + const messageId = message.id.startsWith("temp-") ? undefined : message.id; + if (!messageId || (!targetBotId && !targetGroupId) || removingQueuedId) return; + setRemovingQueuedId(message.id); + setError(null); + try { + await rpc("threads/removeQueuedMessage", { + ...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }), + messageId, + }); + if (isCurrentTarget(targetBotId, targetGroupId)) { + await refresh(); + } + } catch (err) { + if (isCurrentTarget(targetBotId, targetGroupId)) { + setError(err instanceof Error ? err.message : t("Failed to remove message")); + } + } finally { + setRemovingQueuedId(null); + } + } const answerMessage = useCallback( async (message: MobileMessage, answer: string) => { const targetBotId = botId; @@ -1678,6 +1703,25 @@ function Thread() { {t("Send now")} + void removeQueuedNow(message)} + > + + {t("Remove")} + + ) : null} {messageReactions ? ( @@ -2634,6 +2678,105 @@ const MessageBubble = memo(function MessageBubble({ ); } + const plans = message.blocks.filter( + (block): block is Extract => block.kind === "plan", + ); + if (plans.length) { + return ( + + {plans.map((plan) => { + const doneCount = plan.steps.filter( + (s) => s.status === "completed" || s.status === "skipped", + ).length; + const isCompleted = plan.status === "completed"; + return ( + + + + {plan.title} + + + {isCompleted ? t("Completed") : `${doneCount}/${plan.steps.length}`} + + + + {plan.steps.map((step) => { + const done = step.status === "completed"; + const active = step.status === "in_progress"; + const skipped = step.status === "skipped"; + const glyph = done ? "✓" : active ? "▸" : skipped ? "–" : "○"; + return ( + + + {glyph} + + + + {step.label} + + {step.note ? ( + + {step.note} + + ) : null} + + + ); + })} + + + ); + })} + + ); + } if (isRoutineEventMessage(message.blocks)) { return ( diff --git a/apps/web/index.html b/apps/web/index.html index 2bd5e9f..c95b295 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,6 +5,9 @@ + + + diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest index c57083f..9404728 100644 --- a/apps/web/public/site.webmanifest +++ b/apps/web/public/site.webmanifest @@ -1,13 +1,16 @@ { + "id": "/", "name": "evOo", "short_name": "evOo", "description": "Deine Oos — immer da, immer wach.", "start_url": "/", + "scope": "/", "display": "standalone", - "background_color": "#0f1218", - "theme_color": "#0f1218", + "orientation": "any", + "background_color": "#07080a", + "theme_color": "#07080a", "icons": [ - { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, - { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ] } diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 374172c..23efcd4 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -34,6 +34,7 @@ self.addEventListener("push", (event) => { // Same tag as the in-app notification, so one replaces the other. tag: typeof data.tag === "string" && data.tag ? data.tag : undefined, icon: "/icon-192.png", + badge: "/icon-192.png", data: { url: appPath(data.url) }, }, ); diff --git a/apps/web/src/components/ArtifactFileCard.tsx b/apps/web/src/components/ArtifactFileCard.tsx index 50b29ec..bdba834 100644 --- a/apps/web/src/components/ArtifactFileCard.tsx +++ b/apps/web/src/components/ArtifactFileCard.tsx @@ -1,8 +1,10 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { ChatMarkdown } from "@rakazo/chat-ui/web"; +import { isPaneArtifactMimeType } from "@rakazo/core"; import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web"; -import { Download, FileText, X } from "lucide-react"; -import { type RefObject, useMemo, useRef, useState } from "react"; +import { Download, Eye, FileText, X } from "lucide-react"; +import type { RefObject } from "react"; +import { useMemo, useRef, useState } from "react"; import { useArtifactBytes } from "../hooks/useArtifactBytes"; import { downloadArtifact } from "../lib/artifact-open"; import { formatBytes } from "../lib/format-bytes"; @@ -16,80 +18,104 @@ type ArtifactFileCardProps = { onOpenBeside?: () => void; }; +const INLINE_DIALOG_TEXT_MIME_TYPES = new Set(["text/markdown", "text/plain", "application/json"]); + +function shortFileTypeLabel(name: string, mimeType: string): string { + const dot = name.lastIndexOf("."); + if (dot > 0 && dot < name.length - 1) { + const ext = name + .slice(dot + 1) + .trim() + .toUpperCase(); + if (ext.length <= 6) return ext; + } + const sub = mimeType.split("/")[1]?.split("+")[0]?.toUpperCase(); + return sub && sub.length <= 8 ? sub : "FILE"; +} + export function ArtifactFileCard(props: ArtifactFileCardProps) { const { t } = useLingui(); - const markdown = props.mimeType === "text/markdown"; + const canOpenBeside = Boolean(props.onOpenBeside) && isPaneArtifactMimeType(props.mimeType); + const canOpenDialog = INLINE_DIALOG_TEXT_MIME_TYPES.has(props.mimeType); + const canPreview = canOpenBeside || canOpenDialog; const previewButton = useRef(null); const closeButton = useRef(null); const [previewOpen, setPreviewOpen] = useState(false); const startDownload = () => downloadArtifact(props.artifactId, props.name); - - if (!markdown) { - return ( -
- -
- ); - } + const badgeLabel = shortFileTypeLabel(props.name, props.mimeType); return ( <> -
-
+
+
- - - - - + {canOpenDialog ? ( + + + + + + ) : null} ); } -function MarkdownPreview({ +function TextFilePreview({ artifactId, name, + mimeType, closeButtonRef, }: ArtifactFileCardProps & { closeButtonRef: RefObject }) { const { t } = useLingui(); @@ -97,8 +123,8 @@ function MarkdownPreview({ const state = useMemo(() => { if (loaded.status !== "ready") return loaded; try { - const markdown = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes); - return { status: "ready" as const, markdown }; + const text = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes); + return { status: "ready" as const, text }; } catch { return { status: "error" as const, message: t`This file is not valid UTF-8 Markdown.` }; } @@ -140,8 +166,12 @@ function MarkdownPreview({
{state.message}
+ ) : mimeType === "text/markdown" ? ( + {state.text} ) : ( - {state.markdown} +
+              {state.text}
+            
)}
diff --git a/apps/web/src/components/AskCard.test.tsx b/apps/web/src/components/AskCard.test.tsx index fb657f3..de9a774 100644 --- a/apps/web/src/components/AskCard.test.tsx +++ b/apps/web/src/components/AskCard.test.tsx @@ -1,17 +1,115 @@ -import { selectedAskActionLabel } from "@rakazo/core"; -import { describe, expect, it } from "vitest"; +// @vitest-environment jsdom -describe("selectedAskActionLabel", () => { - it("maps a choice answer id to its user-facing label", () => { - expect( - selectedAskActionLabel("choice-2", [ - { id: "choice-1", label: "Berlin" }, - { id: "choice-2", label: "Seoul" }, - ]), - ).toBe("Seoul"); - }); +import type { ReactNode } from "react"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; - it("falls back to the answer when an action is unavailable", () => { - expect(selectedAskActionLabel("custom", undefined)).toBe("custom"); - }); +vi.mock("@lingui/core/macro", () => ({ + t: (parts: TemplateStringsArray, ...values: unknown[]) => + parts.reduce((acc, part, index) => acc + part + String(values[index] ?? ""), ""), +})); + +vi.mock("@lingui/react/macro", () => { + const t = (parts: TemplateStringsArray, ...values: unknown[]) => + parts.reduce((acc, part, index) => acc + part + String(values[index] ?? ""), ""); + return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children }; +}); + +vi.mock("@rakazo/chat-ui/web", () => ({ + ChatMarkdown: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +import type { AskBlock } from "./AskCard"; +import { AskCard } from "./AskCard"; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); +}); + +it("renders primary approval button text with text-primary-foreground for contrast", async () => { + const onAnswer = vi.fn().mockResolvedValue(undefined); + const block: AskBlock = { + kind: "ask", + text: "**Review before shell**\n\nThis run would push commits to a remote.", + detail: "git push", + status: "open", + approvalEffectId: "effect-1", + actions: [ + { id: "allow", label: "Allow once", style: "primary" }, + { id: "always", label: "Always allow this tool", style: "secondary" }, + { id: "deny", label: "Deny", style: "danger" }, + ], + }; + + await act(async () => { + root.render(); + }); + + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons).toHaveLength(3); + + const [allowButton, alwaysButton, denyButton] = buttons; + const allowSpan = allowButton?.querySelector("span"); + const alwaysSpan = alwaysButton?.querySelector("span"); + const denySpan = denyButton?.querySelector("span"); + const allowIcon = allowButton?.querySelector("svg"); + const alwaysIcon = alwaysButton?.querySelector("svg"); + + expect(allowSpan?.textContent).toBe("Allow once"); + expect(allowSpan?.className).toContain("text-primary-foreground"); + expect(allowSpan?.className).not.toContain("text-foreground"); + expect(allowIcon?.getAttribute("class")).toContain("text-primary-foreground/75"); + + expect(alwaysSpan?.textContent).toBe("Always allow this tool"); + expect(alwaysSpan?.className).toContain("text-foreground"); + expect(alwaysSpan?.className).not.toContain("text-primary-foreground"); + expect(alwaysIcon?.getAttribute("class")).toContain("text-muted-foreground"); + + expect(denySpan?.textContent).toBe("Deny"); + expect(denySpan?.className).toContain("text-foreground"); + expect(denySpan?.className).not.toContain("text-primary-foreground"); + + await act(async () => { + allowButton?.click(); + }); + expect(onAnswer).toHaveBeenCalledWith("allow"); +}); + +it("uses text-foreground for non-approval action buttons", async () => { + const onAnswer = vi.fn().mockResolvedValue(undefined); + const block: AskBlock = { + kind: "ask", + text: "Which branch should we deploy?", + status: "open", + actions: [ + { id: "main", label: "main" }, + { id: "staging", label: "staging" }, + ], + }; + + await act(async () => { + root.render(); + }); + + const actionSpans = Array.from(container.querySelectorAll("button span")); + expect(actionSpans).toHaveLength(2); + for (const span of actionSpans) { + expect(span.className).toContain("text-foreground"); + expect(span.className).not.toContain("text-primary-foreground"); + } }); diff --git a/apps/web/src/components/AskCard.tsx b/apps/web/src/components/AskCard.tsx index 4bd4ceb..68937e2 100644 --- a/apps/web/src/components/AskCard.tsx +++ b/apps/web/src/components/AskCard.tsx @@ -174,34 +174,47 @@ export function AskCard({ ) : askActions?.length ? (
- {askActions.map((action) => ( - - ))} + disabled={submitting} + onClick={() => void submitAnswer(action.id)} + > + + {pendingAction === action.id ? ( + Sending… + ) : approvalActions ? ( + approvalActionLabel(action.id, action.label, action.outcome) + ) : ( + action.label + )} + + + + ); + })} {!approvalActions ? (
)} - {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null}
); } diff --git a/apps/web/src/components/CloudAgentCard.tsx b/apps/web/src/components/CloudAgentCard.tsx index fefc6be..e5f24d3 100644 --- a/apps/web/src/components/CloudAgentCard.tsx +++ b/apps/web/src/components/CloudAgentCard.tsx @@ -3,6 +3,7 @@ import type { MessageBlock } from "@rakazo/contracts"; import { cloudAgentHttpsUrl } from "@rakazo/core"; import { Badge } from "@rakazo/ui-web/components/ui/badge"; import { Card, CardContent } from "@rakazo/ui-web/components/ui/card"; +import { ArrowUpRight } from "lucide-react"; export function CloudAgentCard({ block, @@ -15,8 +16,14 @@ export function CloudAgentCard({
- + {block.title} + {href ? ( + ); return href ? ( - + {content} ) : ( diff --git a/apps/web/src/components/NotificationSettingsPanel.tsx b/apps/web/src/components/NotificationSettingsPanel.tsx index 16bf3f8..0683223 100644 --- a/apps/web/src/components/NotificationSettingsPanel.tsx +++ b/apps/web/src/components/NotificationSettingsPanel.tsx @@ -4,7 +4,13 @@ import { Input, Switch } from "@rakazo/ui-web"; import type { ReactNode } from "react"; import { useEffect, useId, useRef, useState } from "react"; import { rpc } from "../lib/rpc"; -import { disableWebPush, enableWebPush, webPushEnabled, webPushSupported } from "../lib/web-push"; +import { + disableWebPush, + enableWebPush, + isIosBrowserNeedsHomeScreen, + webPushEnabled, + webPushSupported, +} from "../lib/web-push"; import { SettingsSection } from "./SettingsSection"; const DEFAULT_QUIET_HOURS = { start: "22:00", end: "07:00" }; @@ -51,6 +57,7 @@ function SettingRow({ export function NotificationSettingsPanel() { const { t } = useLingui(); const pushSupported = webPushSupported(); + const iosNeedsHomeScreen = isIosBrowserNeedsHomeScreen(); const [settings, setSettings] = useState(null); const [push, setPush] = useState("loading"); const [pushBusy, setPushBusy] = useState(false); @@ -153,6 +160,15 @@ export function NotificationSettingsPanel() {

) : null} + ) : iosNeedsHomeScreen ? ( +
  • +
    + Push on this device +
    +

    + Add evOo to your Home Screen via Share to enable push on iOS. +

    +
  • ) : null} Quiet hours} diff --git a/apps/web/src/lib/chat-markdown.test.tsx b/apps/web/src/lib/chat-markdown.test.tsx index db3c0b2..27b9d76 100644 --- a/apps/web/src/lib/chat-markdown.test.tsx +++ b/apps/web/src/lib/chat-markdown.test.tsx @@ -42,4 +42,20 @@ describe("ChatMarkdown", () => { expect(html).toContain('aria-label="Copy code"'); expect(html).toContain("rk-chat-markdown-copy"); }); + + it("marks outbound links with target=_blank, external indicator, and renders inline hex color swatches", () => { + const html = renderToStaticMarkup( + + {"See [Preview](https://example.com/review) and palette `#6B4226` or `#E8722A`."} + , + ); + + expect(html).toContain('href="https://example.com/review"'); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noreferrer noopener"'); + expect(html).toContain("rk-chat-markdown-external-link"); + expect(html).toContain("rk-chat-markdown-external-icon"); + expect(html).toContain('data-testid="markdown-color-swatch"'); + expect(html).toContain('data-color="#6B4226"'); + }); }); diff --git a/apps/web/src/lib/heute-brief.test.ts b/apps/web/src/lib/heute-brief.test.ts index 1373105..14475ab 100644 --- a/apps/web/src/lib/heute-brief.test.ts +++ b/apps/web/src/lib/heute-brief.test.ts @@ -98,13 +98,25 @@ describe("isToday", () => { }); describe("buildHeuteBrief", () => { - it("collects needs-you, doing, and done buckets", () => { + it("collects needs-you, doing, and done buckets, ignoring passive unread chats that do not need the user", () => { 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" }), + bot({ id: "bot-1", unread: true, status: "waiting_input" }), + bot({ + id: "bot-2", + unread: true, + status: "waiting_takeover", + updatedAt: "2026-09-22T08:00:00.000Z", + name: "Steve", + }), + bot({ + id: "bot-passive", + unread: true, + status: "idle", + name: "PassiveBot", + }), ], - groups: [group({ updatedAt: "2026-09-22T09:30:00.000Z" })], + groups: [group({ updatedAt: "2026-09-22T09:30:00.000Z", unread: true })], activeRuns: [ run({ runId: "run-1", botId: "bot-1" }), run({ runId: "run-3", botId: "bot-4", botName: "Worker", status: "running" }), @@ -157,7 +169,7 @@ describe("buildHeuteBrief", () => { it("caps needs-you at three items", () => { const bots = Array.from({ length: 5 }, (_, index) => - bot({ id: `bot-${index}`, unread: true, name: `Bot ${index}` }), + bot({ id: `bot-${index}`, unread: true, status: "waiting_input", name: `Bot ${index}` }), ); const brief = buildHeuteBrief({ bots, @@ -169,6 +181,57 @@ describe("buildHeuteBrief", () => { expect(brief.needsYou).toHaveLength(HEUTE_NEEDS_YOU_CAP); expect(brief.actionCount).toBe(5); }); + + it("merges and deduplicates actionable unread bots and groups across spaces", () => { + const brief = buildHeuteBrief({ + bots: [bot({ id: "bot-1", spaceId: "space-2", unread: false, status: "idle" })], + groups: [], + spaces: [ + { + id: "space-1", + bots: [ + bot({ + id: "bot-home-1", + spaceId: "space-1", + unread: true, + status: "waiting_input", + name: "Code Architect", + }), + bot({ + id: "bot-home-2", + spaceId: "space-1", + unread: true, + status: "waiting_takeover", + name: "UI Designer", + }), + ], + groups: [ + group({ + id: "group-home-1", + spaceId: "space-1", + unread: true, + leadBotId: "bot-home-1", + }), + ], + }, + { + id: "space-2", + // Current space's optimistic `bots` entry (unread: false) takes precedence over stale space snapshot + bots: [bot({ id: "bot-1", spaceId: "space-2", unread: true, status: "waiting_input" })], + groups: [], + }, + ], + activeRuns: [], + recentRuns: [], + now, + }); + expect(brief.actionCount).toBe(3); + expect(brief.needsYou.map((item) => (item.kind === "unread" ? item.chat.id : ""))).toEqual([ + "bot-home-1", + "bot-home-2", + "group-home-1", + ]); + }); }); function habit(overrides: Partial = {}): HabitToday { diff --git a/apps/web/src/lib/heute-brief.ts b/apps/web/src/lib/heute-brief.ts index cd9c1df..3a24c7e 100644 --- a/apps/web/src/lib/heute-brief.ts +++ b/apps/web/src/lib/heute-brief.ts @@ -1,5 +1,18 @@ -import type { Bot, CoachToday, Group, RunActivityRow } from "@rakazo/contracts"; -import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity"; +import type { + Bot, + CoachToday, + Group, + RunActivityRow, + Space, + SpaceBot, + SpaceGroup, +} from "@rakazo/contracts"; +import { + isActionableRun, + isDoingStatus, + isFailedRoutine, + isNeedsYouStatus, +} from "./agent-activity"; export type JetztRunItem = { kind: "run"; @@ -9,7 +22,7 @@ export type JetztRunItem = { export type JetztUnreadItem = { kind: "unread"; chatKind: "bot" | "group"; - chat: Bot | Group; + chat: SpaceBot | (SpaceGroup & { leadBotId?: string | null }); }; export type JetztItem = JetztRunItem | JetztUnreadItem; @@ -76,16 +89,33 @@ function chatTargetKey(chatKind: "bot" | "group", chatId: string): string { } export function buildHeuteBrief(params: { - bots: Bot[]; - groups: Group[]; + bots: readonly SpaceBot[]; + groups: readonly (SpaceGroup & { leadBotId?: string | null })[]; + spaces?: readonly Pick[]; activeRuns: RunActivityRow[]; recentRuns: RunActivityRow[]; now?: Date; }): HeuteBriefData { - const { bots, groups, activeRuns, recentRuns, now = new Date() } = params; + const { bots, groups, spaces = [], activeRuns, recentRuns, now = new Date() } = params; const needsYouAll: JetztItem[] = []; const coveredTargets = new Set(); + const seenBotIds = new Set(); + const allBots: SpaceBot[] = []; + for (const bot of [...bots, ...spaces.flatMap((space) => space.bots)]) { + if (seenBotIds.has(bot.id)) continue; + seenBotIds.add(bot.id); + allBots.push(bot); + } + + const seenGroupIds = new Set(); + const allGroups: (SpaceGroup & { leadBotId?: string | null })[] = []; + for (const group of [...groups, ...spaces.flatMap((space) => space.groups)]) { + if (seenGroupIds.has(group.id)) continue; + seenGroupIds.add(group.id); + allGroups.push(group); + } + const actionableRuns = [ ...activeRuns.filter(isActionableRun), ...recentRuns.filter(isFailedRoutine), @@ -98,12 +128,20 @@ export function buildHeuteBrief(params: { coveredTargets.add(runTargetKey(run)); } + const botStatusById = new Map(allBots.map((bot) => [bot.id, bot.status])); const unreadChats: JetztUnreadItem[] = [ - ...bots - .filter((bot) => bot.unread) + ...allBots + .filter((bot) => bot.unread && isNeedsYouStatus(bot.status)) .map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })), - ...groups - .filter((group) => group.unread) + ...allGroups + .filter((group) => { + if (!group.unread) return false; + const memberBotIds = [ + ...(group.leadBotId ? [group.leadBotId] : []), + ...(group.members?.map((m) => m.botId) ?? []), + ]; + return memberBotIds.some((id) => isNeedsYouStatus(botStatusById.get(id))); + }) .map((group) => ({ kind: "unread" as const, chatKind: "group" as const, diff --git a/apps/web/src/lib/web-push.ts b/apps/web/src/lib/web-push.ts index 2dd999d..12d3329 100644 --- a/apps/web/src/lib/web-push.ts +++ b/apps/web/src/lib/web-push.ts @@ -100,3 +100,37 @@ export async function disableWebPush(): Promise { await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint }); await subscription.unsubscribe().catch(() => undefined); } + +/** + * True on iOS/iPadOS when opened in a browser tab rather than as an installed Home-Screen PWA + * (where Apple WebKit only exposes Web Push in standalone display mode). + */ +export function isIosBrowserNeedsHomeScreen(): boolean { + if (typeof window === "undefined" || typeof navigator === "undefined" || desktopBridge()) { + return false; + } + const ua = navigator.userAgent || ""; + const isIos = + /iPad|iPhone|iPod/i.test(ua) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + if (!isIos) return false; + const standalone = + (typeof window.matchMedia === "function" && + window.matchMedia("(display-mode: standalone)").matches) || + Boolean((navigator as Navigator & { standalone?: boolean }).standalone); + return !standalone && !webPushSupported(); +} + +/** Syncs the installed PWA / OS app icon badge with the actionable "Needs You" count. */ +export function syncAppBadge(count: number): void { + if (typeof navigator === "undefined") return; + const nav = navigator as Navigator & { + setAppBadge?: (contents?: number) => Promise; + clearAppBadge?: () => Promise; + }; + if (count > 0 && typeof nav.setAppBadge === "function") { + void nav.setAppBadge(count).catch(() => undefined); + } else if (count <= 0 && typeof nav.clearAppBadge === "function") { + void nav.clearAppBadge().catch(() => undefined); + } +} diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index a471c4e..ef66485 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -2673,6 +2673,10 @@ msgstr "Datei konnte nicht geöffnet werden" msgid "Failed to send message" msgstr "Nachricht konnte nicht gesendet werden" +#: src/pages/Shell.tsx +msgid "Failed to remove message" +msgstr "Nachricht konnte nicht entfernt werden" + #: src/pages/Shell.tsx msgid "Failed to stop" msgstr "Stoppen fehlgeschlagen" @@ -4937,6 +4941,10 @@ msgstr "Senden" msgid "Send now" msgstr "Jetzt gleich senden" +#: src/pages/shell/transcript.tsx +msgid "Remove from queue" +msgstr "Aus der Warteschlange entfernen" + #: src/pages/Auth.tsx msgid "Send reset link" msgstr "Link zum Zurücksetzen senden" diff --git a/apps/web/src/pages/CallView.tsx b/apps/web/src/pages/CallView.tsx index 04fa459..881d86f 100644 --- a/apps/web/src/pages/CallView.tsx +++ b/apps/web/src/pages/CallView.tsx @@ -1,6 +1,11 @@ import { useLingui } from "@lingui/react/macro"; import type { ThreadMessage, ThreadSnapshot } from "@rakazo/contracts"; -import { isSecretAskBlock, speechFromBlocks, spokenDecision } from "@rakazo/core"; +import { + isSecretAskBlock, + speechFromBlocks, + spokenDecision, + spokenSummaryFromBlocks, +} from "@rakazo/core"; import { useEffect, useMemo, useRef, useState } from "react"; import type { VoicePhase, VoiceTranscriptLine } from "../components/voice/VoiceStage"; import { VoiceStage } from "../components/voice/VoiceStage"; @@ -417,7 +422,7 @@ export function CallView({ (block) => block.kind === "ask" && block.status !== "answered", ); const secretAsk = ask && isSecretAskBlock(ask); - const rawText = speechFromBlocks(lastBot.blocks); + const rawText = spokenSummaryFromBlocks(lastBot.blocks); const text = rawText ? secretAsk ? `${rawText}. ${secretPromptRef.current}` @@ -477,7 +482,7 @@ export function CallView({ hasUnspokenBotMessage: Boolean( lastBot && lastBot.id !== spokenMessage.current && - speechFromBlocks(lastBot.blocks).trim(), + spokenSummaryFromBlocks(lastBot.blocks).trim(), ), runActive: isActiveRunStatus(snapshot?.run?.status), userTurnPending: userTurnPendingRef.current, diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx index 99660bc..552eafa 100644 --- a/apps/web/src/pages/HeuteBrief.tsx +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -7,6 +7,9 @@ import type { Group, HabitToday, RunActivityRow, + Space, + SpaceBot, + SpaceGroup, } from "@rakazo/contracts"; import { runChatTitle } from "@rakazo/core"; import { botColors } from "@rakazo/ui-tokens"; @@ -30,6 +33,7 @@ import { rpc } from "../lib/rpc"; type HeuteBriefProps = { bots: Bot[]; groups: Group[]; + spaces?: Space[]; onOpenBot: (botId: string, spaceId: string) => void; onOpenGroup: (groupId: string, spaceId: string) => void; onOpenRun: (run: RunActivityRow) => void; @@ -41,6 +45,7 @@ const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric" export function HeuteBrief({ bots, groups, + spaces = [], onOpenBot, onOpenGroup, onOpenRun, @@ -135,13 +140,21 @@ export function HeuteBrief({ buildHeuteBrief({ bots, groups, + spaces, activeRuns, recentRuns, }), - [activeRuns, bots, groups, recentRuns], + [activeRuns, bots, groups, recentRuns, spaces], ); - const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]); + const botColorById = useMemo( + () => + new Map([ + ...spaces.flatMap((space) => space.bots).map((bot) => [bot.id, bot.color] as const), + ...bots.map((bot) => [bot.id, bot.color] as const), + ]), + [bots, spaces], + ); const locale = i18n.locale || "de"; const dateLabel = coach @@ -391,13 +404,13 @@ function NeedsYouRow({ > {chatKind === "bot" ? ( ) : ( - + )}
    {title}
    diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 049fcda..c3d1ac1 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -54,6 +54,7 @@ import { screenPaneMode, searchHitThreadTarget, speechFromBlocks, + spokenSummaryFromBlocks, unconfirmedOptimistic, userVisibleMessages, } from "@rakazo/core"; @@ -152,7 +153,7 @@ import { } from "../lib/thread-events"; import { transcriptCanSnapAfterFrame, transcriptIsNearEnd } from "../lib/transcript-scroll"; import { speaker } from "../lib/tts"; -import { disableWebPush } from "../lib/web-push"; +import { disableWebPush, syncAppBadge } from "../lib/web-push"; import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref"; import { ActivityList } from "./ActivityList"; import type { ContextMenuPosition } from "./BotContextMenu"; @@ -165,8 +166,6 @@ import { emptyRoutineDraft, type RoutineDraftState, RoutineEditor, - RoutineListHeader, - RoutineListRow, routineNeedsOneShotArm, } from "./RoutineEditor"; import type { SettingsSection } from "./SettingsOverlay"; @@ -181,7 +180,6 @@ import { } from "./shell/collapsed-sidebar-sections"; import { CommandPalette, isCommandPaletteHotkey } from "./shell/command-palette"; import { Composer } from "./shell/composer"; -import { ComputerPanelPreview } from "./shell/computer-panel-preview"; import { embeddableScreenUrl } from "./shell/computer-screen"; import { ClearConversationDialog, @@ -384,7 +382,7 @@ export function ShellPage() { } = useBotsSidebarCollapse(userId); const [panel, setPanel] = useState(null); - const [canvasTab, setCanvasTab] = useState("sandbox"); + const [canvasTab, setCanvasTab] = useState("browser"); /** The pane the conversation's focus opened; cleared as soon as the user changes the pane. */ const [autoPanel, setAutoPanel] = useState(null); const [artifactPane, setArtifactPane] = useState(null); @@ -806,6 +804,21 @@ export function ShellPage() { candidate.id === id ? { ...candidate, unread } : candidate, ); }); + setSpaces((current) => { + if ( + !current.some((space) => space.bots.some((bot) => bot.id === id && bot.unread !== unread)) + ) { + return current; + } + return current.map((space) => + space.bots.some((bot) => bot.id === id) + ? { + ...space, + bots: space.bots.map((bot) => (bot.id === id ? { ...bot, unread } : bot)), + } + : space, + ); + }); }, []); const markBotRead = useCallback( async (id: string) => { @@ -1218,7 +1231,21 @@ export function ShellPage() { useEffect(() => { void rpc.voice .status() - .then(setVoiceStatus) + .then(async (status) => { + if (!status.configured) { + const defaultVoiceId = + typeof navigator !== "undefined" && /^de\b/i.test(navigator.language) + ? "df_eva" + : "af_heart"; + await rpc.voice + .connect({ provider: "kokoro", apiKey: "local-kokoro", voiceId: defaultVoiceId }) + .catch(() => undefined); + const refreshed = await rpc.voice.status().catch(() => status); + setVoiceStatus(refreshed); + return; + } + setVoiceStatus(status); + }) .catch(() => undefined); const unsubSpeech = speaker.subscribe((state) => { setSpeakingMessageId(state.status === "idle" ? null : (state.messageId ?? null)); @@ -1229,29 +1256,55 @@ export function ShellPage() { }, []); useEffect(() => { - if (!active || !snapshot || snapshot.botId !== active.id) return; - const lastBot = [...snapshot.messages].reverse().find((message) => message.role === "bot"); - if (autoSpokenBotId.current !== active.id) { - autoSpokenBotId.current = active.id; + const currentSnapshot = inGroup + ? snapshot?.groupId === groupId + ? snapshot + : null + : snapshot?.botId === active?.id + ? snapshot + : null; + const threadKey = inGroup ? groupId : active?.id; + if (!threadKey || !currentSnapshot) return; + const lastBot = [...currentSnapshot.messages] + .reverse() + .find((message) => message.role === "bot"); + if (autoSpokenBotId.current !== threadKey) { + autoSpokenBotId.current = threadKey; autoSpoken.current = lastBot?.id ?? null; return; } - if (callOpen || !active.autoSpeak) { + const speakerBotId = + lastBot?.botId ?? + (inGroup + ? (currentSnapshot.leadBotId ?? activeGroup?.leadBotId ?? coordinatorBot?.id) + : active?.id); + const speakerBot = speakerBotId ? bots.find((b) => b.id === speakerBotId) : active; + const shouldAutoSpeak = speakerBot + ? (speakerBot.autoSpeak ?? true) + : (active?.autoSpeak ?? true); + if (callOpen || !shouldAutoSpeak || !speakerBotId) { autoSpoken.current = lastBot?.id ?? null; return; } - if (snapshot.run && ["running", "queued", "leased"].includes(snapshot.run.status)) return; + if ( + currentSnapshot.run && + ["running", "queued", "leased"].includes(currentSnapshot.run.status) + ) { + return; + } if (!lastBot || lastBot.id === autoSpoken.current) return; - const text = speechFromBlocks(lastBot.blocks); + const text = spokenSummaryFromBlocks(lastBot.blocks); if (!text) return; autoSpoken.current = lastBot.id; - void speaker.speak(text, { botId: active.id, messageId: lastBot.id }); + void speaker.speak(text, { botId: speakerBotId, messageId: lastBot.id }); }, [ - snapshot?.messages, - snapshot?.run?.status, - snapshot?.botId, - active?.autoSpeak, - active?.id, + snapshot, + inGroup, + groupId, + active, + activeGroup?.leadBotId, + coordinatorBot?.id, + bots, callOpen, ]); @@ -1851,11 +1904,15 @@ export function ShellPage() { const activeDelegationsByBotId = useMemo(() => { const map = new Map(); + const allBots = [...bots, ...spaces.flatMap((space) => space.bots)]; // 1. From formal delegated tasks: for (const task of delegatedTasks) { if (task.status === "running" || task.status === "queued" || task.status === "waiting") { if (!map.has(task.requesterBotId)) { - const assignee = bots.find((b) => b.id === task.assigneeBotId); + const assignee = allBots.find((b) => b.id === task.assigneeBotId); + if (assignee && !isActive(assignee.status as RunStatus)) { + continue; + } map.set(task.requesterBotId, { assigneeBotId: task.assigneeBotId, assigneeName: assignee?.name ?? "Specialist", @@ -1902,7 +1959,7 @@ export function ShellPage() { } } return map; - }, [delegatedTasks, bots, active, transcriptMessages]); + }, [delegatedTasks, bots, spaces, active, transcriptMessages]); const activeDelegation = active ? activeDelegationsByBotId.get(active.id) : undefined; @@ -2126,7 +2183,10 @@ export function ShellPage() { } }, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]); - const heuteActionCount = useHeuteActionCount(groups, botsRef); + const heuteActionCount = useHeuteActionCount(groups, bots, spaces); + useEffect(() => { + syncAppBadge(heuteActionCount); + }, [heuteActionCount]); const runActivity = useRunActivity(); useShellSounds({ bots, @@ -2143,10 +2203,12 @@ export function ShellPage() { return coordinatorBot.name; } return ( - memberName(transcriptMembers, botId) ?? (botId ? knownBots.get(botId)?.name : undefined) + memberName(transcriptMembers, botId) ?? + (botId ? knownBots.get(botId)?.name : undefined) ?? + (!inGroup ? active?.name : coordinatorBot?.name) ); }, - [coordinatorBot, knownBots, transcriptMembers], + [active?.name, coordinatorBot, inGroup, knownBots, transcriptMembers], ); const replyTargetName = activeReplyTarget ? activeReplyTarget.role === "user" @@ -2210,7 +2272,24 @@ export function ShellPage() { initiallyScrolledThread.current = snapshot.threadId; }, [active, groupId, inGroup, snapshot?.botId, snapshot?.groupId, snapshot?.threadId]); - const openBot = useCallback((id: string) => navigate(`/app/${id}`), [navigate]); + const openBot = useCallback( + (id: string) => { + if (id === mainConversation?.botId) { + openMainConversation(); + return; + } + setActiveNavPillar("team"); + const targetBot = + bots.find((item) => item.id === id) ?? + spaces.flatMap((space) => space.bots).find((item) => item.id === id); + if (targetBot) { + openSpaceChat(targetBot.spaceId, `/app/${id}`); + return; + } + navigate(`/app/${id}`); + }, + [bots, mainConversation?.botId, navigate, openMainConversation, openSpaceChat, spaces], + ); const openGroup = useCallback( (id: string) => { setActiveNavPillar("projekte"); @@ -2683,6 +2762,28 @@ export function ShellPage() { }, [refreshBots, t], ); + const removeQueuedMessage = useCallback( + async (message: ThreadMessage) => { + const groupTarget = activeGroupId.current; + const botTarget = activeBotId.current; + const messageId = message.id.startsWith("temp-") ? undefined : message.id; + if (!messageId) return; + setSendError(null); + try { + if (groupTarget) { + await rpc.threads.removeQueuedMessage({ groupId: groupTarget, messageId }); + await refreshGroupThreadRef.current(groupTarget); + } else if (botTarget) { + await rpc.threads.removeQueuedMessage({ botId: botTarget, messageId }); + await refreshThreadRef.current(botTarget); + } + void refreshBots().catch(() => undefined); + } catch (error) { + setSendError(error instanceof Error ? error.message : t`Failed to remove message`); + } + }, + [refreshBots, t], + ); const stopRun = useCallback(async () => { inputQueueRef.current.requestStop(); setSending(true); @@ -2844,15 +2945,18 @@ export function ShellPage() { ); const speakingMessageIdRef = useRef(speakingMessageId); speakingMessageIdRef.current = speakingMessageId; - const speakMessage = useCallback((message: ThreadMessage) => { - if (speakingMessageIdRef.current === message.id) { - speaker.stop(); - return; - } - const text = speechFromBlocks(message.blocks); - const id = message.botId ?? activeBotId.current; - if (text && id) void speaker.speak(text, { botId: id, messageId: message.id }); - }, []); + const speakMessage = useCallback( + (message: ThreadMessage) => { + if (speakingMessageIdRef.current === message.id) { + speaker.stop(); + return; + } + const text = spokenSummaryFromBlocks(message.blocks) || speechFromBlocks(message.blocks); + const id = message.botId ?? activeBotId.current ?? defaultSpeakerId ?? coordinatorBot?.id; + if (text && id) void speaker.speak(text, { botId: id, messageId: message.id }); + }, + [coordinatorBot?.id, defaultSpeakerId], + ); async function createGroup(input: { name: string; botIds: string[] }) { const group = await rpc.groups.create(input); @@ -3098,30 +3202,18 @@ export function ShellPage() { }, []); useEffect(() => { - if ((panel !== "computer" && !computerOpen) || !active || computer?.state !== "running") return; + if ( + (panel !== "computer" && panel !== "browser" && !computerOpen) || + !active || + computer?.state !== "running" + ) + return; const ping = () => void rpc.computer.heartbeat({ botId: active.id }).catch(() => undefined); ping(); const timer = window.setInterval(ping, 60_000); return () => window.clearInterval(timer); }, [panel, computerOpen, active?.id, computer?.state]); - async function openComputer() { - const targetBot = active ?? coordinatorBot; - if (!targetBot) return; - const needsTakeover = !userHoldsComputerControl(computer, targetBot.id); - const blocked = computerTakeoverBlocked(computer, snapshot?.run?.status); - try { - await bootComputer({ - takeControl: needsTakeover && !blocked, - overlay: (needsTakeover && !blocked) || computer?.state !== "running", - force: computer?.state !== "running", - }); - setComputerOpen(true); - } catch { - // computerError already set in bootComputer - } - } - const releaseComputer = useCallback( async (reason?: ComputerReleaseReason) => { const botId = activeBotId.current; @@ -3242,7 +3334,7 @@ export function ShellPage() { onCollapseSidebar={() => setBotsSidebarCollapsedPref(true)} bots={bots} onCreateBot={() => setPanel("create")} - onOpenBot={(id) => navigate(`/app/${id}`)} + onOpenBot={openBot} onCreateGroup={() => setPanel("create-group")} onCreateSpace={() => openNewSpaceDialog("space")} onShowGroupInfo={() => setPickerInfoTopic("group")} @@ -3353,12 +3445,17 @@ export function ShellPage() { spaceId: projectSpace.id, }); const projectSelected = - activeNavPillar === "projekte" && + (activeNavPillar === "projekte" || + (!inGroup && active?.spaceId === projectSpace.id)) && bootstrapMe?.spaceId === projectSpace.id; - // Other chats in the project show only while it is open. - const projectChats = projectSelected - ? group.bots.filter((item) => item.chat.kind !== "main") - : []; + // Other chats in the project show while it is open, or when unread. + const projectChats = group.bots.filter( + (item) => + item.chat.kind !== "main" && + (projectSelected || + item.chat.unread || + (!inGroup && active?.id === item.chat.id)), + ); return ( { - setActiveNavPillar("projekte"); - openSpaceChat(item.chat.spaceId, `/app/g/${item.chat.id}`); + setActiveNavPillar(item.kind === "bot" ? "team" : "projekte"); + openSpaceChat( + item.chat.spaceId, + item.kind === "bot" + ? `/app/${item.chat.id}` + : `/app/g/${item.chat.id}`, + ); }} activeSpaceId={bootstrapMe?.spaceId} menuAnchor={botMenuAnchor} @@ -3771,6 +3877,7 @@ export function ShellPage() { { setActiveNavPillar("team"); openSpaceChat(spaceId, `/app/${botId}`); @@ -3991,19 +4098,20 @@ export function ShellPage() { // The computer belongs to a bot; from a group, open its lead's computer. if (!active && groupComputerBotId) { navigate(`/app/${groupComputerBotId}`); - setCanvasTab("sandbox"); + setCanvasTab("browser"); setPanel("computer"); dualSurface.setLayoutMode("split"); return; } const isCurrentlyOpen = - panel === "computer" && dualSurface.layoutMode !== "stream"; + (panel === "computer" || panel === "browser") && + dualSurface.layoutMode !== "stream"; if (isCurrentlyOpen) { setPanel(null); dualSurface.setLayoutMode("stream"); } else { if (transcriptRunning) learnScreenPane("opened"); - setCanvasTab("sandbox"); + setCanvasTab("browser"); setPanel("computer"); dualSurface.setLayoutMode("split"); if (active) { @@ -4013,7 +4121,8 @@ export function ShellPage() { } }} data-active={ - (panel === "computer" || (inProject && !active)) && showCanvasOnDesktop + (panel === "computer" || panel === "browser" || (inProject && !active)) && + showCanvasOnDesktop ? "" : undefined } @@ -4150,6 +4259,7 @@ export function ShellPage() { onSpeak={speakMessage} onRestartTask={sendMessage} onSendQueuedNow={sendQueuedMessageNow} + onRemoveQueuedMessage={removeQueuedMessage} /> )} @@ -4235,7 +4345,7 @@ export function ShellPage() { const effectiveTab: CanvasTab = panel === "browser" ? "browser" - : inProject && !panel && canvasTab === "sandbox" + : inProject && !panel && canvasTab === "browser" ? "overview" : canvasTab; const isOverlayPanel = @@ -4297,8 +4407,7 @@ export function ShellPage() { refreshThread={refreshThread} onPanelChange={(next) => { setPanel(next); - if (next === "browser") setCanvasTab("browser"); - else if (next === "computer") setCanvasTab("sandbox"); + if (next === "browser" || next === "computer") setCanvasTab("browser"); }} onCloseSurface={() => { dualSurface.setLayoutMode("stream"); @@ -4309,7 +4418,7 @@ export function ShellPage() { setCanvasTab(tab); if (tab === "browser") { setPanel("browser"); - } else if (inProject && !dualSurface.isMobile && tab !== "sandbox") { + } else if (inProject && !dualSurface.isMobile) { setPanel(null); } else { setPanel("computer"); @@ -4409,7 +4518,17 @@ export function ShellPage() { onOpenSettings={() => setPanel("settings")} />
    - ) : panel === "browser" || (effectiveTab === "browser" && !isOverlayPanel) ? ( + ) : effectiveTab === "files" && !isOverlayPanel ? ( +
    + {(active ?? coordinatorBot) ? ( + + ) : null} +
    + ) : !isOverlayPanel && + (panel === "browser" || + effectiveTab === "browser" || + panel === "computer" || + (!panel && !dualSurface.isMobile && showCanvasOnDesktop)) ? (
    - ) : effectiveTab === "files" && !isOverlayPanel ? ( -
    - {(active ?? coordinatorBot) ? ( - - ) : null} -
    ) : (
    - {(panel === "computer" || - (!panel && !dualSurface.isMobile && showCanvasOnDesktop)) && - (active ?? coordinatorBot) ? ( -
    - void openComputer()} - /> - {activeRoutines.length > 0 ? ( - <> - - {activeRoutines.map((routine) => { - const routineRunning = - snapshot?.run?.routineId === routine.id && - isActive(snapshot.run.status); - return ( - void setRoutineActive(routine, next)} - onOpen={() => { - setRoutineDraft(draftFromRoutine(routine)); - setRoutineWebhookSecret(null); - setEditingRoutine(routine); - setRoutineError(null); - setPanel("routine"); - }} - onStop={() => void stopRun()} - /> - ); - })} - - ) : null} -
    - ) : null} {panel === "create-group" ? ( diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index bd22f57..e99dd71 100644 --- a/apps/web/src/pages/shell/message-cards.tsx +++ b/apps/web/src/pages/shell/message-cards.tsx @@ -1,10 +1,12 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { MessageBlock } from "@rakazo/contracts"; import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; -import { abortableDelay } from "@rakazo/core"; +import { abortableDelay, extractExternalUrls, formatAssetDisplayName } from "@rakazo/core"; +import { darkTokens, lightTokens } from "@rakazo/ui-tokens"; import { Button, buttonVariants, + cn, Dialog, DialogClose, DialogContent, @@ -14,8 +16,27 @@ import { InputGroupButton, InputGroupInput, } from "@rakazo/ui-web"; -import { ArrowUp, Check, Download, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { + ArrowUp, + ArrowUpRight, + Check, + CheckCircle2, + ChevronLeft, + ChevronRight, + Circle, + Download, + Globe, + Grid, + Loader2, + Maximize2, + MinusCircle, + Moon, + Sun, + X, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { BuiCard, SuccessPop } from "../../components/ai/primitives"; import { artifactUrl } from "../../lib/artifact-url"; import { chartViewport } from "../../lib/chart-viewport"; @@ -569,65 +590,513 @@ export function ChartBlockView({ ); } -export function ArtifactImage({ artifactId, name }: { artifactId: string; name: string }) { - const { t } = useLingui(); - const [open, setOpen] = useState(false); - const [failed, setFailed] = useState(false); - const src = artifactUrl(artifactId); +export type GalleryAssetItem = { + artifactId: string; + name: string; + mimeType?: string; +}; - if (failed) { - return ( -
    - {name} -
    - ); +type CanvasBackdrop = "grid" | "light" | "dark"; + +function backdropStyle(backdrop: CanvasBackdrop): React.CSSProperties { + if (backdrop === "light") { + return { backgroundColor: lightTokens.background }; } + if (backdrop === "dark") { + return { backgroundColor: darkTokens.background }; + } + return { + backgroundColor: "var(--color-card)", + backgroundImage: + "linear-gradient(45deg, color-mix(in oklab, var(--color-foreground) 7%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in oklab, var(--color-foreground) 7%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in oklab, var(--color-foreground) 7%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in oklab, var(--color-foreground) 7%, transparent) 75%)", + backgroundSize: "16px 16px", + backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0", + }; +} + +function BackdropToggleGroup({ + value, + onChange, +}: { + value: CanvasBackdrop; + onChange: (next: CanvasBackdrop) => void; +}) { + const options: { id: CanvasBackdrop; label: string; icon: React.ReactNode }[] = [ + { id: "grid", label: "Grid", icon: }, + { id: "light", label: "Light", icon: }, + { id: "dark", label: "Dark", icon: }, + ]; return ( -
    - + ); + })} + + ); +} + +export function ArtifactGallery({ + items, + align = "start", +}: { + items: readonly GalleryAssetItem[]; + align?: "start" | "end"; +}) { + const { t } = useLingui(); + const [backdrop, setBackdrop] = useState("grid"); + const [activeIndex, setActiveIndex] = useState(null); + const [zoomed, setZoomed] = useState(false); + const [failedIds, setFailedIds] = useState>({}); + const [dimensions, setDimensions] = useState>({}); + + const activeItem = activeIndex !== null ? items[activeIndex] : undefined; + + useEffect(() => { + if (activeIndex === null || items.length <= 1) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + setZoomed(false); + setActiveIndex((cur) => (cur === null ? null : (cur - 1 + items.length) % items.length)); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + setZoomed(false); + setActiveIndex((cur) => (cur === null ? null : (cur + 1) % items.length)); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [activeIndex, items.length]); + + if (items.length === 0) return null; + + const isMulti = items.length > 1; + + return ( +
    +
    +
    +
    + + {isMulti + ? items.map((item) => formatAssetDisplayName(item.name).title).join(" · ") + : formatAssetDisplayName(items[0]!.name).title} + + {isMulti ? ( + + {items.length} + + ) : dimensions[items[0]!.artifactId] ? ( + + {dimensions[items[0]!.artifactId]!.w}×{dimensions[items[0]!.artifactId]!.h} + + ) : null} +
    + +
    + +
    + {items.map((item, idx) => { + const src = artifactUrl(item.artifactId); + const failed = Boolean(failedIds[item.artifactId]); + const { title, ext } = formatAssetDisplayName(item.name); + const dim = dimensions[item.artifactId]; + + if (failed) { + return ( +
    + {item.name} +
    + ); + } + + return ( +
    + + +
    +
    +
    + {title} +
    +
    + {ext} + {dim ? ( + <> + · + + {dim.w}×{dim.h} + + + ) : null} +
    +
    + + + +
    +
    + ); + })} +
    +
    + + { + if (!nextOpen) { + setActiveIndex(null); + setZoomed(false); + } + }} > - {name} setFailed(true)} - className="max-h-48 w-full object-cover" - /> - - - {name} -
    - - - - } - > - - -
    - {name} + {activeItem ? ( + <> +
    +
    + + {formatAssetDisplayName(activeItem.name).title} + + + ({activeItem.name}) + + {isMulti && activeIndex !== null ? ( + + {activeIndex + 1} / {items.length} + + ) : null} +
    +
    + + + + + + } + > + + +
    +
    + +
    + {isMulti ? ( + + ) : null} + + + + {isMulti ? ( + + ) : null} +
    + + {isMulti ? ( +
    + {items.map((thumb, idx) => { + const selected = idx === activeIndex; + return ( + + ); + })} +
    + ) : null} + + ) : null}
    ); } + +export function ArtifactImage({ artifactId, name }: { artifactId: string; name: string }) { + const items = useMemo(() => [{ artifactId, name }], [artifactId, name]); + return ; +} + +export function ExternalLinksBar({ text }: { text: string }) { + const links = useMemo(() => extractExternalUrls(text), [text]); + if (links.length === 0) return null; + + return ( +
    + {links.map((link) => ( + + + ))} +
    + ); +} + +export function PlanCard({ block }: { block: Extract }) { + const { t } = useLingui(); + const total = block.steps.length; + const doneCount = block.steps.filter( + (s) => s.status === "completed" || s.status === "skipped", + ).length; + const isCompleted = block.status === "completed"; + const isAwaiting = block.status === "awaiting_approval"; + + return ( +
    +
    + + {block.title} + + + {isCompleted ? t`Done` : `${doneCount}/${total}`} + +
    +
      + {block.steps.map((step) => { + const done = step.status === "completed"; + const active = step.status === "in_progress"; + const skipped = step.status === "skipped"; + return ( +
    1. + + {done ? ( + +
      +
      + {step.label} +
      + {step.note ? ( +
      + {step.note} +
      + ) : null} +
      +
    2. + ); + })} +
    +
    + ); +} diff --git a/apps/web/src/pages/shell/run-steps.tsx b/apps/web/src/pages/shell/run-steps.tsx index 4fc81df..9ee106e 100644 --- a/apps/web/src/pages/shell/run-steps.tsx +++ b/apps/web/src/pages/shell/run-steps.tsx @@ -202,6 +202,16 @@ export function JobProgressCard({ const botRole = bot?.title || bot?.role || t`Agent`; const isInterrupted = status === "interrupted"; const stepCount = steps.length; + const stepMoment = useMemo(() => { + if (status === "waiting") return "waiting" as const; + if (isInterrupted) return "concerned" as const; + if (!running) return undefined; + const lastStep = steps.at(-1); + if (!lastStep) return "thinking" as const; + const meta = detectStepMeta(lastStep); + if (meta.type === "file" || meta.type === "web") return "reading" as const; + return "working" as const; + }, [status, isInterrupted, running, steps]); return (
    {running ? ( @@ -288,8 +299,8 @@ export function JobProgressCard({
    - {/* Task / Objective Brief */} - {taskTitle ? ( + {/* Task / Objective Brief (only while running or interrupted to avoid repeating user bubble above completed answers) */} + {taskTitle && status !== "completed" ? (
    diff --git a/apps/web/src/pages/shell/side-panel-header.tsx b/apps/web/src/pages/shell/side-panel-header.tsx index 6c3cecc..f1828f5 100644 --- a/apps/web/src/pages/shell/side-panel-header.tsx +++ b/apps/web/src/pages/shell/side-panel-header.tsx @@ -1,11 +1,11 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { Bot, ComputerStatus } from "@rakazo/contracts"; import { Button, cn } from "@rakazo/ui-web"; -import { Folder, Globe, Monitor, Settings, Target, X } from "lucide-react"; +import { Folder, Globe, Settings, Target, X } from "lucide-react"; import { ComputerMaintenanceActions } from "../../components/ComputerMaintenanceActions"; import { computerPanelNeedsMaintenance } from "../../lib/thread-events"; -export type CanvasTab = "overview" | "files" | "sandbox" | "browser"; +export type CanvasTab = "overview" | "files" | "browser"; /** Title row and navigation header of the Living Canvas side panel. */ export function SidePanelHeader({ @@ -17,7 +17,7 @@ export function SidePanelHeader({ refreshThread, onPanelChange, onCloseSurface, - activeTab = "sandbox", + activeTab = "browser", onSelectTab, inProject = false, }: { @@ -146,21 +146,6 @@ export function SidePanelHeader({ Files - + ) : null}
    ) : null} {!peerReceipt && messageReactions ? ( @@ -630,6 +852,18 @@ export const Transcript = memo(function Transcript({
    ); })} + {trailingAnsweringBots.map((bot) => ( + + ))}
    {quoteDraft ? ( @@ -854,10 +1088,321 @@ function MessageHoverActions({ ); } +type LiveBotPhaseKind = + | "thinking" + | "reading" + | "working" + | "cooking" + | "speaking" + | "waiting" + | "concerned"; + +function resolveLiveBotPhase({ + message, + foldedSteps, + botStatus, + elapsedSec, +}: { + message?: ThreadMessage; + foldedSteps?: readonly ThreadMessage[]; + botStatus?: string; + elapsedSec: number; +}): { moment: OoMoment; pose: EvooPose; kind: LiveBotPhaseKind } { + if (botStatus === "error" || botStatus === "failed") { + return { moment: "concerned", pose: "error", kind: "concerned" }; + } + if ( + botStatus === "waiting_input" || + botStatus === "waiting_takeover" || + message?.blocks.some((b) => b.kind === "ask") + ) { + return { moment: "waiting", pose: "waiting", kind: "waiting" }; + } + + // Always begin with at least 3s of thinking ("denkt nach") right after sending, + // before transitioning to tool-specific phases or speaking. + if (elapsedSec < 3) { + return { moment: "thinking", pose: "thinking", kind: "thinking" }; + } + + const blocks = message?.blocks ?? []; + const hasActiveTool = blocks.some( + (b) => + b.kind === "progress" && + (b.activity === true || (b.pendingToolNames && b.pendingToolNames.length > 0)), + ); + const streamingReplyText = blocks + .filter( + (b): b is Extract => + (b.kind === "progress" && !b.activity) || b.kind === "text", + ) + .map((b) => b.text.trim()) + .join(" ") + .trim(); + + if (streamingReplyText.length > 20 && !hasActiveTool) { + return { moment: "speaking", pose: "speaking", kind: "speaking" }; + } + + const toolNames = blocks.flatMap((b) => + b.kind === "progress" ? [...(b.pendingToolNames ?? []), b.activity ? b.text : ""] : [], + ); + const stepLabels = blocks.flatMap((b) => (b.kind === "steps" ? b.steps.map((s) => s.label) : [])); + const lastFoldedStep = foldedSteps?.at(-1); + const lastStepText = + lastFoldedStep?.blocks + .filter((b): b is { kind: "text"; text: string } => b.kind === "text") + .map((b) => b.text) + .join(" ") ?? ""; + + const signal = [...toolNames, ...stepLabels, lastStepText].join(" ").toLowerCase(); + + if ( + /\b(write|edit|patch|create|generate|build|compile|artifact|project_created|child_bot|skill_draft|erstell|baue|entwurf)\b/.test( + signal, + ) || + blocks.some( + (b) => + b.kind === "project_created" || + b.kind === "child_bot" || + b.kind === "skill_draft" || + b.kind === "file" || + b.kind === "image", + ) + ) { + return { moment: "cooking", pose: "working", kind: "cooking" }; + } + + if ( + botStatus === "booting" || + /\b(read|cat|head|tail|grep|rg|glob|find|ls|list|search|web|browser|fetch|http|url|inspect|view|lese|suche|recherch|prüfe|analysier)\b/.test( + signal, + ) + ) { + return { moment: "reading", pose: "reading", kind: "reading" }; + } + + const hasDelegationOrToolWork = + hasActiveTool || + stepLabels.length > 0 || + (foldedSteps && foldedSteps.length > 0) || + blocks.some( + (b) => + b.kind === "subagent" || + b.kind === "task" || + b.kind === "handoff" || + b.kind === "bot_message_sent" || + b.kind === "cloud_agent" || + b.kind === "computer", + ) || + /\b(bash|sh|zsh|shell|exec|run|command|terminal|git|ssh|scp|docker|pnpm|npm|test|deploy)\b/.test( + signal, + ); + + if (hasDelegationOrToolWork) { + return { moment: "working", pose: "working", kind: "working" }; + } + + // Natural progression when a run runs without explicit intermediate tool events: + // 0–3s: thinking ("denkt nach") -> 4–8s: working ("arbeitet") -> 9s+: speaking ("antwortet") + if (elapsedSec >= 9 || streamingReplyText.length > 0) { + return { moment: "speaking", pose: "speaking", kind: "speaking" }; + } + if (elapsedSec >= 4) { + return { moment: "working", pose: "working", kind: "working" }; + } + + return { moment: "thinking", pose: "thinking", kind: "thinking" }; +} + +function OoHoleEntrance({ color, children }: { color: string; children: React.ReactNode }) { + return ( + +