From cfbb9b2d1a881d8f58f27528a873df74d6859b6e Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:30:25 +0100 Subject: [PATCH 1/4] feat: move waiting_for_input badge from Inbox to HQ nav, remove Inbox entry Badge showing agents in waiting_for_input status now appears on HQ nav item. Inbox link removed from the nav. Adds regression test for navItems structure. --- apps/web/src/layouts/AppLayout.test.tsx | 66 +++++++++++++++++++++++++ apps/web/src/layouts/AppLayout.tsx | 3 +- 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/layouts/AppLayout.test.tsx diff --git a/apps/web/src/layouts/AppLayout.test.tsx b/apps/web/src/layouts/AppLayout.test.tsx new file mode 100644 index 0000000..8d12ba8 --- /dev/null +++ b/apps/web/src/layouts/AppLayout.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom +import { render, screen, within } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' +import { vi } from 'vitest' +import { AppLayout } from './AppLayout' + +// Mock dependencies +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to }: any) => { + const content = typeof children === 'function' ? children({ isActive: false }) : children + return {content} + }, +})) +vi.mock('@/components/ThemeToggle', () => ({ ThemeToggle: () => null })) +vi.mock('@/components/HealthDot', () => ({ HealthDot: () => null })) +vi.mock('@/components/NavBadge', () => ({ + NavBadge: ({ count }: { count: number }) => ( + count > 0 ? {count} : null + ), +})) + +const mockUseQuery = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/trpc', () => ({ + trpc: { + listAgents: { useQuery: mockUseQuery }, + }, +})) + +beforeEach(() => { + vi.clearAllMocks() + mockUseQuery.mockReturnValue({ data: [] }) +}) + +describe('AppLayout navItems', () => { + it('renders HQ nav link', () => { + render({null}) + expect(screen.getByRole('link', { name: /hq/i })).toBeInTheDocument() + }) + + it('does not render Inbox nav link', () => { + render({null}) + expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument() + }) + + it('shows badge on HQ when agents are waiting_for_input', () => { + mockUseQuery.mockReturnValue({ + data: [ + { id: '1', status: 'waiting_for_input' }, + { id: '2', status: 'running' }, + ], + }) + render({null}) + // NavBadge rendered next to HQ link (count=1) + const hqLink = screen.getByRole('link', { name: /hq/i }) + const badge = within(hqLink).getByTestId('nav-badge') + expect(badge).toHaveTextContent('1') + }) + + it('does not show questions badge on any Inbox link (Inbox removed)', () => { + mockUseQuery.mockReturnValue({ + data: [{ id: '1', status: 'waiting_for_input' }], + }) + render({null}) + expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 7dbc2eb..aa41888 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -7,11 +7,10 @@ import { trpc } from '@/lib/trpc' import type { ConnectionState } from '@/hooks/useConnectionStatus' const navItems = [ - { label: 'HQ', to: '/hq', badgeKey: null }, + { label: 'HQ', to: '/hq', badgeKey: 'questions' as const }, { label: 'Initiatives', to: '/initiatives', badgeKey: null }, { label: 'Agents', to: '/agents', badgeKey: 'running' as const }, { label: 'Radar', to: '/radar', badgeKey: null }, - { label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const }, { label: 'Settings', to: '/settings', badgeKey: null }, ] as const From 1ae7a64b4bf099730a3f3efeda8e2ddb2bbe3024 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:30:43 +0100 Subject: [PATCH 2/4] refactor: replace InboxPage with redirect to /hq Converts /inbox from a 270-line interactive page to a minimal TanStack Router redirect route. Bookmarked or externally linked /inbox URLs now redirect cleanly to /hq instead of 404-ing. InboxList and InboxDetailPanel components are preserved for reuse in the HQ page (Phase 569ZNKArI1OYRolaOZLhB). Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/routes/inbox.tsx | 270 +--------------------------------- 1 file changed, 4 insertions(+), 266 deletions(-) diff --git a/apps/web/src/routes/inbox.tsx b/apps/web/src/routes/inbox.tsx index 50e6399..56bb42f 100644 --- a/apps/web/src/routes/inbox.tsx +++ b/apps/web/src/routes/inbox.tsx @@ -1,269 +1,7 @@ -import { useState } from "react"; -import { createFileRoute } from "@tanstack/react-router"; -import { motion } from "motion/react"; -import { AlertCircle, MessageSquare } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Skeleton } from "@/components/Skeleton"; -import { toast } from "sonner"; -import { trpc } from "@/lib/trpc"; -import { InboxList } from "@/components/InboxList"; -import { InboxDetailPanel } from "@/components/InboxDetailPanel"; -import { useLiveUpdates } from "@/hooks"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/inbox")({ - component: InboxPage, + beforeLoad: () => { + throw redirect({ to: "/hq" }); + }, }); - -function InboxPage() { - const [selectedAgentId, setSelectedAgentId] = useState(null); - - // Single SSE stream for live updates - useLiveUpdates([ - { prefix: 'agent:', invalidate: ['listWaitingAgents', 'listMessages'] }, - ]); - - const utils = trpc.useUtils(); - - // Data fetching - const agentsQuery = trpc.listWaitingAgents.useQuery(); - const messagesQuery = trpc.listMessages.useQuery({}); - const questionsQuery = trpc.getAgentQuestions.useQuery( - { id: selectedAgentId! }, - { enabled: !!selectedAgentId } - ); - - // Mutations - const resumeAgentMutation = trpc.resumeAgent.useMutation({ - onSuccess: () => { - setSelectedAgentId(null); - toast.success("Answer submitted"); - }, - onError: () => { - toast.error("Failed to submit answer"); - }, - }); - - const dismissQuestionsMutation = trpc.stopAgent.useMutation({ - onSuccess: () => { - setSelectedAgentId(null); - toast.success("Questions dismissed"); - }, - onError: () => { - toast.error("Failed to dismiss questions"); - }, - }); - - const respondToMessageMutation = trpc.respondToMessage.useMutation({ - onSuccess: () => { - setSelectedAgentId(null); - toast.success("Response sent"); - }, - onError: () => { - toast.error("Failed to send response"); - }, - }); - - // Find selected agent info - const agents = agentsQuery.data ?? []; - const messages = messagesQuery.data ?? []; - const selectedAgent = selectedAgentId - ? agents.find((a) => a.id === selectedAgentId) ?? null - : null; - - // Find the latest message for the selected agent - const selectedMessage = selectedAgentId - ? messages - .filter((m) => m.senderId === selectedAgentId) - .sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - )[0] ?? null - : null; - - const pendingQuestions = questionsQuery.data ?? null; - - // Handlers - function handleRefresh() { - void utils.listWaitingAgents.invalidate(); - void utils.listMessages.invalidate(); - } - - function handleSubmitAnswers(answers: Record) { - if (!selectedAgentId) return; - resumeAgentMutation.mutate({ id: selectedAgentId, answers }); - } - - function handleDismissQuestions() { - if (!selectedAgentId) return; - dismissQuestionsMutation.mutate({ id: selectedAgentId }); - } - - function handleDismiss() { - if (!selectedMessage) return; - respondToMessageMutation.mutate({ - id: selectedMessage.id, - response: "Acknowledged", - }); - } - - // Loading state - if (agentsQuery.isLoading && messagesQuery.isLoading) { - return ( -
- {/* Skeleton header */} -
-
- - -
- -
- {/* Skeleton message rows */} -
- {Array.from({ length: 4 }).map((_, i) => ( - -
-
-
- - -
- -
- -
-
- ))} -
-
- ); - } - - // Error state - if (agentsQuery.isError || messagesQuery.isError) { - const errorMessage = - agentsQuery.error?.message ?? messagesQuery.error?.message ?? "Unknown error"; - return ( -
- -

- Failed to load inbox: {errorMessage} -

- -
- ); - } - - // Serialize agents for InboxList (convert Date to string for wire format) - const serializedAgents = agents.map((a) => ({ - id: a.id, - name: a.name, - status: a.status, - taskId: a.taskId, - updatedAt: String(a.updatedAt), - })); - - // Serialize messages for InboxList - const serializedMessages = messages.map((m) => ({ - id: m.id, - senderId: m.senderId, - content: m.content, - requiresResponse: m.requiresResponse, - status: m.status, - createdAt: String(m.createdAt), - })); - - return ( - -
- {/* Left: Inbox List -- hidden on mobile when agent selected */} - - - - - {/* Right: Detail Panel */} - {selectedAgent && ( - ({ - id: q.id, - question: q.question, - options: q.options, - multiSelect: q.multiSelect, - })) - : null - } - isLoadingQuestions={questionsQuery.isLoading} - questionsError={ - questionsQuery.isError ? questionsQuery.error.message : null - } - onBack={() => setSelectedAgentId(null)} - onSubmitAnswers={handleSubmitAnswers} - onDismissQuestions={handleDismissQuestions} - onDismissMessage={handleDismiss} - isSubmitting={resumeAgentMutation.isPending} - isDismissingQuestions={dismissQuestionsMutation.isPending} - isDismissingMessage={respondToMessageMutation.isPending} - submitError={ - resumeAgentMutation.isError - ? resumeAgentMutation.error.message - : null - } - dismissMessageError={ - respondToMessageMutation.isError - ? respondToMessageMutation.error.message - : null - } - /> - )} - - {/* Empty detail panel placeholder */} - {!selectedAgent && ( -
- -
-

No message selected

-

Select an agent from the inbox to view details

-
-
- )} -
-
- ); -} From 419dda3a1ab001adcbbaf8bc8826289ed4978e92 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:31:10 +0100 Subject: [PATCH 3/4] chore: update TypeScript build info after inbox route replacement --- apps/web/tsconfig.app.tsbuildinfo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tsconfig.app.tsbuildinfo b/apps/web/tsconfig.app.tsbuildinfo index de94090..a48ea07 100644 --- a/apps/web/tsconfig.app.tsbuildinfo +++ b/apps/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/accountcard.tsx","./src/components/actionmenu.tsx","./src/components/addaccountdialog.tsx","./src/components/agentactions.tsx","./src/components/agentdetailspanel.tsx","./src/components/agentoutputviewer.test.tsx","./src/components/agentoutputviewer.tsx","./src/components/browsertitleupdater.tsx","./src/components/changesetbanner.tsx","./src/components/commandpalette.tsx","./src/components/connectionbanner.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencychip.tsx","./src/components/dependencyindicator.tsx","./src/components/emptystate.tsx","./src/components/errorboundary.tsx","./src/components/errorstate.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/healthdot.tsx","./src/components/inboxdetailpanel.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/keyboardshortcuthint.tsx","./src/components/messagecard.tsx","./src/components/navbadge.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/saveindicator.tsx","./src/components/skeleton.tsx","./src/components/skeletoncard.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskrow.tsx","./src/components/themetoggle.tsx","./src/components/updatecredentialsdialog.test.tsx","./src/components/updatecredentialsdialog.tsx","./src/components/chat/changesetinline.tsx","./src/components/chat/chatbubble.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/chatslideover.tsx","./src/components/editor/blockdraghandle.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contenttab.tsx","./src/components/editor/deletesubpagedialog.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkdeletiondetector.ts","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/phasecontenteditor.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasedetailpanel.tsx","./src/components/execution/phasegraph.tsx","./src/components/execution/phasesidebaritem.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/plansection.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskgraph.tsx","./src/components/execution/taskslideover.tsx","./src/components/execution/index.ts","./src/components/hq/hqblockedsection.tsx","./src/components/hq/hqemptystate.tsx","./src/components/hq/hqneedsapprovalsection.tsx","./src/components/hq/hqneedsreviewsection.tsx","./src/components/hq/hqsections.test.tsx","./src/components/hq/hqwaitingforinputsection.tsx","./src/components/hq/types.ts","./src/components/pipeline/pipelinegraph.tsx","./src/components/pipeline/pipelinephasegroup.tsx","./src/components/pipeline/pipelinestagecolumn.tsx","./src/components/pipeline/pipelinetab.tsx","./src/components/pipeline/index.ts","./src/components/review/commentform.tsx","./src/components/review/commentthread.tsx","./src/components/review/conflictresolutionpanel.tsx","./src/components/review/diffviewer.tsx","./src/components/review/filecard.tsx","./src/components/review/hunkrows.tsx","./src/components/review/initiativereview.tsx","./src/components/review/linewithcomments.tsx","./src/components/review/previewcontrols.tsx","./src/components/review/reviewheader.tsx","./src/components/review/reviewsidebar.test.tsx","./src/components/review/reviewsidebar.tsx","./src/components/review/reviewtab.tsx","./src/components/review/dummy-data.ts","./src/components/review/index.ts","./src/components/review/parse-diff.ts","./src/components/review/types.ts","./src/components/review/use-syntax-highlight.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usechatsession.ts","./src/hooks/useconflictagent.ts","./src/hooks/useconnectionstatus.ts","./src/hooks/usedebounce.ts","./src/hooks/useglobalkeyboard.ts","./src/hooks/useliveupdates.ts","./src/hooks/useoptimisticmutation.ts","./src/hooks/usephaseautosave.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/category.ts","./src/lib/invalidation.ts","./src/lib/labels.ts","./src/lib/markdown-to-tiptap.ts","./src/lib/parse-agent-output.test.ts","./src/lib/parse-agent-output.ts","./src/lib/theme.tsx","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/hq.test.tsx","./src/routes/hq.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/settings.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx","./src/routes/settings/projects.tsx"],"errors":true,"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/accountcard.tsx","./src/components/actionmenu.tsx","./src/components/addaccountdialog.tsx","./src/components/agentactions.tsx","./src/components/agentdetailspanel.tsx","./src/components/agentoutputviewer.test.tsx","./src/components/agentoutputviewer.tsx","./src/components/browsertitleupdater.tsx","./src/components/changesetbanner.tsx","./src/components/commandpalette.tsx","./src/components/connectionbanner.tsx","./src/components/createerranddialog.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencychip.tsx","./src/components/dependencyindicator.tsx","./src/components/emptystate.tsx","./src/components/erranddetailpanel.tsx","./src/components/errorboundary.tsx","./src/components/errorstate.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/healthdot.tsx","./src/components/inboxdetailpanel.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/keyboardshortcuthint.tsx","./src/components/messagecard.tsx","./src/components/navbadge.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/saveindicator.tsx","./src/components/skeleton.tsx","./src/components/skeletoncard.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskrow.tsx","./src/components/themetoggle.tsx","./src/components/updatecredentialsdialog.test.tsx","./src/components/updatecredentialsdialog.tsx","./src/components/chat/changesetinline.tsx","./src/components/chat/chatbubble.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/chatslideover.tsx","./src/components/editor/blockdraghandle.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contenttab.tsx","./src/components/editor/deletesubpagedialog.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkdeletiondetector.ts","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/phasecontenteditor.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasedetailpanel.tsx","./src/components/execution/phasegraph.tsx","./src/components/execution/phasesidebaritem.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/plansection.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskgraph.tsx","./src/components/execution/taskslideover.tsx","./src/components/execution/index.ts","./src/components/hq/hqblockedsection.tsx","./src/components/hq/hqemptystate.tsx","./src/components/hq/hqneedsapprovalsection.tsx","./src/components/hq/hqneedsreviewsection.tsx","./src/components/hq/hqresolvingconflictssection.tsx","./src/components/hq/hqsections.test.tsx","./src/components/hq/hqwaitingforinputsection.tsx","./src/components/hq/types.ts","./src/components/pipeline/pipelinegraph.tsx","./src/components/pipeline/pipelinephasegroup.tsx","./src/components/pipeline/pipelinestagecolumn.tsx","./src/components/pipeline/pipelinetab.tsx","./src/components/pipeline/index.ts","./src/components/radar/compactioneventsdialog.tsx","./src/components/radar/interagentmessagesdialog.tsx","./src/components/radar/questionsaskeddialog.tsx","./src/components/radar/subagentspawnsdialog.tsx","./src/components/radar/types.ts","./src/components/radar/__tests__/compactioneventsdialog.test.tsx","./src/components/radar/__tests__/interagentmessagesdialog.test.tsx","./src/components/radar/__tests__/questionsaskeddialog.test.tsx","./src/components/radar/__tests__/subagentspawnsdialog.test.tsx","./src/components/review/commentform.tsx","./src/components/review/commentthread.tsx","./src/components/review/conflictresolutionpanel.tsx","./src/components/review/diffviewer.test.tsx","./src/components/review/diffviewer.tsx","./src/components/review/filecard.test.tsx","./src/components/review/filecard.tsx","./src/components/review/hunkrows.tsx","./src/components/review/initiativereview.tsx","./src/components/review/linewithcomments.tsx","./src/components/review/previewcontrols.tsx","./src/components/review/reviewheader.tsx","./src/components/review/reviewsidebar.test.tsx","./src/components/review/reviewsidebar.tsx","./src/components/review/reviewtab.test.tsx","./src/components/review/reviewtab.tsx","./src/components/review/comment-index.test.tsx","./src/components/review/comment-index.ts","./src/components/review/dummy-data.ts","./src/components/review/highlight-worker.ts","./src/components/review/index.ts","./src/components/review/parse-diff.ts","./src/components/review/types.test.ts","./src/components/review/types.ts","./src/components/review/use-syntax-highlight.fallback.test.ts","./src/components/review/use-syntax-highlight.test.ts","./src/components/review/use-syntax-highlight.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usechatsession.ts","./src/hooks/useconflictagent.ts","./src/hooks/useconnectionstatus.ts","./src/hooks/usedebounce.ts","./src/hooks/useglobalkeyboard.ts","./src/hooks/useliveupdates.ts","./src/hooks/useoptimisticmutation.ts","./src/hooks/usephaseautosave.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/category.ts","./src/lib/invalidation.ts","./src/lib/labels.ts","./src/lib/markdown-to-tiptap.ts","./src/lib/parse-agent-output.test.ts","./src/lib/parse-agent-output.ts","./src/lib/theme.tsx","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/hq.test.tsx","./src/routes/hq.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/radar.tsx","./src/routes/settings.tsx","./src/routes/errands/index.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx","./src/routes/settings/projects.tsx"],"errors":true,"version":"5.9.3"} \ No newline at end of file From e8d332e04bfc06bccc29b4ef452ebdad969d94ff Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:45:26 +0100 Subject: [PATCH 4/4] feat: embed InboxList + InboxDetailPanel inline on HQ page Replaces HQWaitingForInputSection with a two-column inline panel that lets users answer agent questions directly from HQ without navigating to /inbox. Adds SSE invalidation for listWaitingAgents/listMessages, useState for agent selection, and all required mutations. Co-Authored-By: Claude Sonnet 4.6 --- .../hq/HQWaitingForInputSection.tsx | 65 ------- apps/web/src/routes/hq.test.tsx | 112 ++++++++++- apps/web/src/routes/hq.tsx | 176 +++++++++++++++++- 3 files changed, 276 insertions(+), 77 deletions(-) delete mode 100644 apps/web/src/components/hq/HQWaitingForInputSection.tsx diff --git a/apps/web/src/components/hq/HQWaitingForInputSection.tsx b/apps/web/src/components/hq/HQWaitingForInputSection.tsx deleted file mode 100644 index 6a23e33..0000000 --- a/apps/web/src/components/hq/HQWaitingForInputSection.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useNavigate } from '@tanstack/react-router' -import { Card } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { - Tooltip, - TooltipTrigger, - TooltipContent, - TooltipProvider, -} from '@/components/ui/tooltip' -import { formatRelativeTime } from '@/lib/utils' -import type { WaitingForInputItem } from './types' - -interface Props { - items: WaitingForInputItem[] -} - -export function HQWaitingForInputSection({ items }: Props) { - const navigate = useNavigate() - - return ( -
-

- Waiting for Input -

-
- {items.map((item) => { - const truncated = - item.questionText.slice(0, 120) + - (item.questionText.length > 120 ? '…' : '') - - return ( - -
-

- {item.agentName} - {item.initiativeName && ( - · {item.initiativeName} - )} -

- - - -

{truncated}

-
- {item.questionText} -
-
-

- waiting {formatRelativeTime(item.waitingSince)} -

-
- -
- ) - })} -
-
- ) -} diff --git a/apps/web/src/routes/hq.test.tsx b/apps/web/src/routes/hq.test.tsx index 066cd0d..c9f3ae2 100644 --- a/apps/web/src/routes/hq.test.tsx +++ b/apps/web/src/routes/hq.test.tsx @@ -4,9 +4,24 @@ import { render, screen, fireEvent } from '@testing-library/react' import { vi, describe, it, expect, beforeEach } from 'vitest' const mockUseQuery = vi.hoisted(() => vi.fn()) +const mockListWaitingAgentsQuery = vi.hoisted(() => vi.fn()) +const mockListMessagesQuery = vi.hoisted(() => vi.fn()) +const mockGetAgentQuestionsQuery = vi.hoisted(() => vi.fn()) +const mockResumeAgentMutation = vi.hoisted(() => vi.fn()) +const mockStopAgentMutation = vi.hoisted(() => vi.fn()) +const mockRespondToMessageMutation = vi.hoisted(() => vi.fn()) +const mockUseUtils = vi.hoisted(() => vi.fn()) + vi.mock('@/lib/trpc', () => ({ trpc: { getHeadquartersDashboard: { useQuery: mockUseQuery }, + listWaitingAgents: { useQuery: mockListWaitingAgentsQuery }, + listMessages: { useQuery: mockListMessagesQuery }, + getAgentQuestions: { useQuery: mockGetAgentQuestionsQuery }, + resumeAgent: { useMutation: mockResumeAgentMutation }, + stopAgent: { useMutation: mockStopAgentMutation }, + respondToMessage: { useMutation: mockRespondToMessageMutation }, + useUtils: mockUseUtils, }, })) @@ -15,8 +30,33 @@ vi.mock('@/hooks', () => ({ LiveUpdateRule: undefined, })) -vi.mock('@/components/hq/HQWaitingForInputSection', () => ({ - HQWaitingForInputSection: ({ items }: any) =>
{items.length}
, +vi.mock('@/components/InboxList', () => ({ + InboxList: ({ agents, selectedAgentId, onSelectAgent }: any) => ( +
+ {agents.map((a: any) => ( + + ))} +
+ ), +})) + +vi.mock('@/components/InboxDetailPanel', () => ({ + InboxDetailPanel: ({ agent, onSubmitAnswers, onDismissQuestions, onDismissMessage, onBack }: any) => ( +
+ {agent.name} + + + + +
+ ), })) vi.mock('@/components/hq/HQNeedsReviewSection', () => ({ @@ -56,6 +96,16 @@ const emptyData = { describe('HeadquartersPage', () => { beforeEach(() => { vi.clearAllMocks() + mockListWaitingAgentsQuery.mockReturnValue({ data: [], isLoading: false }) + mockListMessagesQuery.mockReturnValue({ data: [], isLoading: false }) + mockGetAgentQuestionsQuery.mockReturnValue({ data: null, isLoading: false, isError: false }) + mockResumeAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false }) + mockStopAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false }) + mockRespondToMessageMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false }) + mockUseUtils.mockReturnValue({ + listWaitingAgents: { invalidate: vi.fn() }, + listMessages: { invalidate: vi.fn() }, + }) }) it('renders skeleton loading state', () => { @@ -68,7 +118,7 @@ describe('HeadquartersPage', () => { const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]') expect(skeletons.length).toBeGreaterThan(0) // No section components - expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() + expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() @@ -93,24 +143,25 @@ describe('HeadquartersPage', () => { render() expect(screen.getByTestId('empty-state')).toBeInTheDocument() - expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() + expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() }) - it('renders WaitingForInput section when items exist', () => { + it('renders InboxList when waitingForInput items exist', () => { mockUseQuery.mockReturnValue({ isLoading: false, isError: false, data: { ...emptyData, waitingForInput: [{ id: '1' }] }, }) + mockListWaitingAgentsQuery.mockReturnValue({ + data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }], + isLoading: false, + }) render() - expect(screen.getByTestId('waiting')).toBeInTheDocument() - expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() - expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() - expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() + expect(screen.getByTestId('inbox-list')).toBeInTheDocument() expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() }) @@ -127,9 +178,13 @@ describe('HeadquartersPage', () => { blockedPhases: [{ id: '6' }], }, }) + mockListWaitingAgentsQuery.mockReturnValue({ + data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }], + isLoading: false, + }) render() - expect(screen.getByTestId('waiting')).toBeInTheDocument() + expect(screen.getByTestId('inbox-list')).toBeInTheDocument() expect(screen.getByTestId('needs-review')).toBeInTheDocument() expect(screen.getByTestId('needs-approval')).toBeInTheDocument() expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument() @@ -160,4 +215,41 @@ describe('HeadquartersPage', () => { expect(screen.getByTestId('needs-review')).toBeInTheDocument() expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() }) + + it('shows InboxDetailPanel when an agent is selected from InboxList', async () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, waitingForInput: [{ id: '1' }] }, + }) + mockListWaitingAgentsQuery.mockReturnValue({ + data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }], + isLoading: false, + }) + render() + + fireEvent.click(screen.getByTestId('agent-a1')) + + expect(await screen.findByTestId('inbox-detail')).toBeInTheDocument() + expect(screen.getByTestId('selected-agent-name')).toHaveTextContent('Agent 1') + }) + + it('clears selection when Back is clicked in InboxDetailPanel', async () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, waitingForInput: [{ id: '1' }] }, + }) + mockListWaitingAgentsQuery.mockReturnValue({ + data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }], + isLoading: false, + }) + render() + + fireEvent.click(screen.getByTestId('agent-a1')) + expect(screen.getByTestId('inbox-detail')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /back/i })) + expect(screen.queryByTestId('inbox-detail')).not.toBeInTheDocument() + }) }) diff --git a/apps/web/src/routes/hq.tsx b/apps/web/src/routes/hq.tsx index d1f881e..368e6bf 100644 --- a/apps/web/src/routes/hq.tsx +++ b/apps/web/src/routes/hq.tsx @@ -1,10 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; import { motion } from "motion/react"; +import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { useLiveUpdates, type LiveUpdateRule } from "@/hooks"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; -import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection"; +import { InboxList } from "@/components/InboxList"; +import { InboxDetailPanel } from "@/components/InboxDetailPanel"; import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection"; import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection"; import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection"; @@ -19,12 +22,108 @@ const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [ { prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] }, { prefix: "phase:", invalidate: ["getHeadquartersDashboard"] }, { prefix: "agent:", invalidate: ["getHeadquartersDashboard"] }, + { prefix: "agent:", invalidate: ["listWaitingAgents", "listMessages"] }, ]; export function HeadquartersPage() { useLiveUpdates(HQ_LIVE_UPDATE_RULES); const query = trpc.getHeadquartersDashboard.useQuery(); + const [selectedAgentId, setSelectedAgentId] = useState(null); + + const utils = trpc.useUtils(); + const agentsQuery = trpc.listWaitingAgents.useQuery(); + const messagesQuery = trpc.listMessages.useQuery({}); + const questionsQuery = trpc.getAgentQuestions.useQuery( + { id: selectedAgentId! }, + { enabled: !!selectedAgentId } + ); + + const resumeAgentMutation = trpc.resumeAgent.useMutation({ + onSuccess: () => { + setSelectedAgentId(null); + toast.success("Answer submitted"); + }, + onError: () => { + toast.error("Failed to submit answer"); + }, + }); + + const dismissQuestionsMutation = trpc.stopAgent.useMutation({ + onSuccess: () => { + setSelectedAgentId(null); + toast.success("Questions dismissed"); + }, + onError: () => { + toast.error("Failed to dismiss questions"); + }, + }); + + const respondToMessageMutation = trpc.respondToMessage.useMutation({ + onSuccess: () => { + setSelectedAgentId(null); + toast.success("Response sent"); + }, + onError: () => { + toast.error("Failed to send response"); + }, + }); + + const agents = agentsQuery.data ?? []; + const messages = messagesQuery.data ?? []; + const selectedAgent = selectedAgentId + ? agents.find((a) => a.id === selectedAgentId) ?? null + : null; + const selectedMessage = selectedAgentId + ? messages + .filter((m) => m.senderId === selectedAgentId) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + )[0] ?? null + : null; + const pendingQuestions = questionsQuery.data ?? null; + + function handleRefresh() { + void utils.listWaitingAgents.invalidate(); + void utils.listMessages.invalidate(); + } + + function handleSubmitAnswers(answers: Record) { + if (!selectedAgentId) return; + resumeAgentMutation.mutate({ id: selectedAgentId, answers }); + } + + function handleDismissQuestions() { + if (!selectedAgentId) return; + dismissQuestionsMutation.mutate({ id: selectedAgentId }); + } + + function handleDismiss() { + if (!selectedMessage) return; + respondToMessageMutation.mutate({ + id: selectedMessage.id, + response: "Acknowledged", + }); + } + + const serializedAgents = agents.map((a) => ({ + id: a.id, + name: a.name, + status: a.status, + taskId: a.taskId ?? "", + updatedAt: String(a.updatedAt), + })); + + const serializedMessages = messages.map((m) => ({ + id: m.id, + senderId: m.senderId, + content: m.content, + requiresResponse: m.requiresResponse, + status: m.status, + createdAt: String(m.createdAt), + })); + if (query.isLoading) { return ( {data.waitingForInput.length > 0 && ( - +
+

+ Waiting for Input +

+
+ {/* Left: agent list — hidden on mobile when an agent is selected */} +
+ +
+ {/* Right: detail panel */} + {selectedAgent ? ( + ({ + id: q.id, + question: q.question, + options: q.options, + multiSelect: q.multiSelect, + })) + : null + } + isLoadingQuestions={questionsQuery.isLoading} + questionsError={ + questionsQuery.isError ? questionsQuery.error.message : null + } + onBack={() => setSelectedAgentId(null)} + onSubmitAnswers={handleSubmitAnswers} + onDismissQuestions={handleDismissQuestions} + onDismissMessage={handleDismiss} + isSubmitting={resumeAgentMutation.isPending} + isDismissingQuestions={dismissQuestionsMutation.isPending} + isDismissingMessage={respondToMessageMutation.isPending} + submitError={ + resumeAgentMutation.isError + ? resumeAgentMutation.error.message + : null + } + dismissMessageError={ + respondToMessageMutation.isError + ? respondToMessageMutation.error.message + : null + } + /> + ) : ( +
+

No agent selected

+

Select an agent from the list to answer their questions

+
+ )} +
+
)} {(data.pendingReviewInitiatives.length > 0 || data.pendingReviewPhases.length > 0) && (