Plan 00 (Wave 0): DB migration for message_type, shared types/validators, test stubs Plan 01 (Wave 1): Server — addSystemMessage, handoff route, status-update route Plan 02 (Wave 1): UI — ChatSpecCard, ChatHandoffIndicator, ChatTaskCreatedBadge, ChatStatusUpdateBadge, useBrainstormerDefault Plan 03 (Wave 2): Wiring — ChatMessage dispatch, ChatMessageList propagation, ChatPanel brainstormer default, chatApi handoff Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 23-brainstormer-flow | 02 | execute | 1 |
|
|
true |
|
|
Purpose: These components render the four structured message types (spec_card, handoff, task_created, status_update) and provide the brainstormer default agent selection. Plan 03 wires them into ChatMessage dispatch. Output: 4 new components + 1 new hook, all independently testable.
<execution_context> @.claude/get-shit-done/workflows/execute-plan.md @.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/23-brainstormer-flow/23-RESEARCH.md @.planning/phases/23-brainstormer-flow/23-UI-SPEC.md @.planning/phases/23-brainstormer-flow/23-00-SUMMARY.mdFrom ui/src/components/ChatMessage.tsx:
interface ChatMessageProps {
id?: string;
role: "user" | "assistant" | "system";
content: string;
agentName?: string | null;
agentIcon?: string | null;
agentRole?: AgentRole | null;
timestamp?: string;
isStreaming?: boolean;
isAnyStreaming?: boolean;
onEdit?: (messageId: string, newContent: string) => void;
onRetry?: (messageId: string) => void;
}
From ui/src/api/chat.ts:
export const chatApi = {
editMessage(conversationId: string, messageId: string, content: string) { ... },
// Will need: handoffSpec(conversationId, spec, targetRole) — added in Plan 03
};
From ui/src/api/issues.ts:
export const issuesApi = {
create: (companyId: string, data: Record<string, unknown>) => api.post<Issue>(`/companies/${companyId}/issues`, data),
};
Existing shadcn components available: button, card, textarea (all installed). Lucide icons needed: CheckCircle2, Brain (from lucide-react ^0.574.0, already installed).
From agent-role-colors.ts: general role maps to text-slate-600 dark:text-slate-400.
Task 1: ChatSpecCard and ChatHandoffIndicator components - ui/src/components/ChatMessage.tsx - ui/src/components/ChatMessageIdentityBar.tsx - .planning/phases/23-brainstormer-flow/23-UI-SPEC.md (Spec Card Layout section and Handoff Indicator section) ui/src/components/ChatSpecCard.tsx, ui/src/components/ChatHandoffIndicator.tsx 1. Create `ui/src/components/ChatSpecCard.tsx`:Props interface:
interface ChatSpecCardProps {
content: string; // JSON string of SpecContent
messageId?: string;
conversationId?: string;
onHandoff?: (spec: SpecContent) => void;
}
interface SpecContent {
what: string;
why: string;
constraints: string;
success: string;
}
Implementation:
- Parse
contentviaJSON.parsein a try/catch. On failure, render:<div className="text-destructive text-[13px]">Could not render spec.</div> - Container:
role="region" aria-label="Specification"withclassName="motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 rounded-lg border border-border bg-card p-4 max-w-[480px]" - Four sections, each with:
- Label:
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">What</p>(and Why, Constraints, Success) - Content:
<p className="text-[15px] font-normal text-foreground leading-relaxed">{spec.what}</p> - Sections wrapped in
<div className="space-y-4">
- Label:
- Action row:
<div className="flex gap-2 pt-4 border-t border-border mt-4">- "Send to PM" button:
variant="default" size="sm"— callsonHandoff?.(spec), disables during submission witharia-disabled="true"andaria-busy="true"on container - "Edit" button:
variant="outline" size="sm"— toggles localisEditingstate - "Save as Draft" button:
variant="ghost" size="sm"— sets localisDraftstate, adds "[Draft]" badge
- "Send to PM" button:
- Edit mode (when
isEditing === true):- Each field becomes a
<textarea>with explicitaria-label("What to build", "Why it matters", "Constraints", "Success criteria") and placeholder text per UI-SPEC copywriting contract - Tab order: What -> Why -> Constraints -> Success -> Save changes -> Discard
- "Save changes" button:
variant="default" size="sm", disabled when all four fields are empty. UseschatApi.editMessage(conversationId, messageId, JSON.stringify(editedSpec))if conversationId and messageId are available - "Discard" button:
variant="ghost" size="sm", reverts local state - Escape key discards (add keydown handler)
- Each field becomes a
- Draft mode: When
isDraftis true, show<span className="text-[11px] text-muted-foreground ml-2">[Draft]</span>in the header area - "Send to PM" disabled state while in-flight: Use local
isSubmittingstate. Set true before calling onHandoff, caller resets via success/failure.
- Create
ui/src/components/ChatHandoffIndicator.tsx:
import { cn } from "../lib/utils";
interface ChatHandoffIndicatorProps {
content: string;
}
export function ChatHandoffIndicator({ content }: ChatHandoffIndicatorProps) {
return (
<div
className={cn(
"flex items-center gap-3 py-2 text-[13px] text-muted-foreground",
"motion-safe:animate-in motion-safe:fade-in"
)}
aria-label="Agent handoff from Brainstormer to PM"
>
<hr className="flex-1 border-border" aria-hidden="true" />
<span className="whitespace-nowrap">{content}</span>
<hr className="flex-1 border-border" aria-hidden="true" />
</div>
);
}
cd /opt/nexus && pnpm exec tsc --noEmit -p ui/tsconfig.json 2>&1 | head -20
- grep -q "ChatSpecCard" ui/src/components/ChatSpecCard.tsx
- grep -q "role=\"region\"" ui/src/components/ChatSpecCard.tsx
- grep -q "Send to PM" ui/src/components/ChatSpecCard.tsx
- grep -q "ChatHandoffIndicator" ui/src/components/ChatHandoffIndicator.tsx
- grep -q "aria-label" ui/src/components/ChatHandoffIndicator.tsx
- grep -q "aria-hidden" ui/src/components/ChatHandoffIndicator.tsx
ChatSpecCard renders spec sections with edit mode and action buttons; ChatHandoffIndicator renders separator-style indicator with accessibility labels
Task 2: ChatTaskCreatedBadge, ChatStatusUpdateBadge, and useBrainstormerDefault
- .planning/phases/23-brainstormer-flow/23-UI-SPEC.md (Task Created and Status Update sections)
- ui/src/hooks/useStreamingChat.ts (first 20 lines for hook pattern)
- ui/src/context/CompanyContext.tsx (for useCompany import path)
- ui/src/api/agents.ts (for agentsApi.list pattern)
ui/src/components/ChatTaskCreatedBadge.tsx,
ui/src/components/ChatStatusUpdateBadge.tsx,
ui/src/hooks/useBrainstormerDefault.ts
1. Create `ui/src/components/ChatTaskCreatedBadge.tsx`:
import { Link } from "react-router-dom";
import { cn } from "../lib/utils";
interface ChatTaskCreatedBadgeProps {
taskId?: string | null;
taskTitle?: string | null;
taskUrl?: string | null;
}
export function ChatTaskCreatedBadge({ taskId, taskTitle, taskUrl }: ChatTaskCreatedBadgeProps) {
if (!taskId) {
return (
<div className="motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1 inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1 text-[13px] text-muted-foreground">
Creating task...
</div>
);
}
return (
<div
className="motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1 inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1 text-[13px]"
role="status"
>
<span className="text-[11px] font-semibold text-muted-foreground">{taskId}</span>
<span className="text-foreground">{taskTitle}</span>
{taskUrl && (
<Link
to={taskUrl}
className="text-primary underline-offset-2 hover:underline"
aria-label={`View task ${taskId}`}
>
View task
</Link>
)}
</div>
);
}
- Create
ui/src/components/ChatStatusUpdateBadge.tsx:
import { Link } from "react-router-dom";
import { CheckCircle2 } from "lucide-react";
interface ChatStatusUpdateBadgeProps {
agentName: string;
taskId: string;
taskTitle?: string;
taskUrl?: string;
}
export function ChatStatusUpdateBadge({ agentName, taskId, taskTitle, taskUrl }: ChatStatusUpdateBadgeProps) {
const displayTitle = taskTitle && taskTitle.length > 40
? taskTitle.slice(0, 40) + "..."
: taskTitle;
return (
<div
className="motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1 inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1 text-[13px]"
role="status"
>
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 dark:text-green-400" />
<span className="text-foreground">
{agentName} completed {taskId}{displayTitle ? `: ${displayTitle}` : ""}
</span>
{taskUrl && (
<Link
to={taskUrl}
className="text-primary underline-offset-2 hover:underline"
aria-label={`View task ${taskId}`}
>
View task
</Link>
)}
</div>
);
}
- Create
ui/src/hooks/useBrainstormerDefault.ts:
import { useQuery } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
export function useBrainstormerDefault(): string | null {
const { selectedCompanyId } = useCompany();
const { data: agents = [] } = useQuery({
queryKey: ["agents", selectedCompanyId],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
// Reuses same queryKey as ChatPanel's agent list — React Query deduplicates
const generalAgent = agents
.filter((a: { role: string }) => a.role === "general")
.sort((a: { createdAt: string }, b: { createdAt: string }) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)[0];
return generalAgent?.id ?? null;
}
IMPORTANT: Check the exact import paths for useCompany and agentsApi by reading existing hooks (like useStreamingChat.ts or ChatPanel.tsx). The agentsApi.list return type may need a type assertion — check the actual API client.
NOTE: The Link component import — check if this project uses react-router-dom or wouter or another router. Read an existing component that has a Link or <a> with client-side navigation to confirm the import pattern.
cd /opt/nexus && pnpm exec tsc --noEmit -p ui/tsconfig.json 2>&1 | head -20
<acceptance_criteria>
- grep -q "ChatTaskCreatedBadge" ui/src/components/ChatTaskCreatedBadge.tsx
- grep -q "Creating task" ui/src/components/ChatTaskCreatedBadge.tsx
- grep -q "role="status"" ui/src/components/ChatTaskCreatedBadge.tsx
- grep -q "ChatStatusUpdateBadge" ui/src/components/ChatStatusUpdateBadge.tsx
- grep -q "CheckCircle2" ui/src/components/ChatStatusUpdateBadge.tsx
- grep -q "useBrainstormerDefault" ui/src/hooks/useBrainstormerDefault.ts
- grep -q "general" ui/src/hooks/useBrainstormerDefault.ts
</acceptance_criteria>
ChatTaskCreatedBadge renders loading and resolved states with View task link; ChatStatusUpdateBadge shows CheckCircle2 + agent completion text; useBrainstormerDefault returns general role agent ID with cache deduplication
<success_criteria>
- ChatSpecCard renders spec card with 4 sections, edit mode, and 3 action buttons
- ChatHandoffIndicator renders separator-style with flanking hr and aria-label
- ChatTaskCreatedBadge shows "Creating task..." or resolved badge
- ChatStatusUpdateBadge shows CheckCircle2 + agent + task reference
- useBrainstormerDefault returns general agent ID or null
- All components use CSS variables for theme compatibility
- All components respect prefers-reduced-motion via motion-safe: prefix </success_criteria>