Files
roboco/panel/src/components/knowledge-base/mentor-chat.tsx
T
a89d3cc885 [a1e2bfb4] Fix 26 verified UI bugs across the panel dashboard (#175)
* [41219301] Fix 26 verified UI bugs in the panel dashboard (#174)

* [115788ef] API/state bugs batch 1 — PATCH fix, WebSocket reconnect, agent roster, sessions export, timestamp (#173)

* fix(orchestrator): launch agent MCP servers with uv run --no-sync

Agent MCP servers (flow/do/git-readonly/optimal/docs/search) are launched as
`uv run python -m roboco.mcp.<server>` with cwd = the agent's workspace clone.
When that clone's uv.lock drifts from the baked image, `uv run` re-syncs the
dependency set mid-spawn and the servers never reach "connected" — they sit at
status="pending", so the agent gets zero gateway verbs. It then can't claim,
commit, or even i_am_idle (all MCP verbs), so its Stop is rejected and it
respawns in a loop, re-doing work it can't submit.

UV_PROJECT_ENVIRONMENT pins the venv location but does not stop the cwd-relative
resolve/sync; --no-sync does, so the servers reuse the baked /app/.venv as-is
and start instantly. PMs were unaffected only because they run from /app where
the env already matches the lock.

* fix(agent): launch agent uv-run subprocesses with --no-sync

Agents with a write workspace (developer/product_owner/head_marketing/documenter)
run with cwd = their git workspace clone. Claude Code launches each MCP server
(flow/do/git-readonly/optimal/docs/search) and the SDK server as
`uv run python -m ...` from that cwd. When the clone's uv.lock drifts from the
baked image, `uv run` re-resolves and re-syncs /app/.venv against the clone's
lock — a multi-minute stall on a cold wheel cache — so the servers never reach
"connected": they sit at status="pending" and the agent gets ZERO gateway
verbs. It then can't claim/commit/idle (all MCP verbs), its Stop is rejected,
and it respawns in a loop redoing work it can't submit.

UV_PROJECT_ENVIRONMENT pins the venv location but does NOT stop the cwd-relative
resolve/sync (confirmed empirically on uv 0.11.1); `--no-sync` does, so the
servers reuse the baked /app/.venv as-is and start instantly. The /app-cwd roles
(qa/cell_pm/main_pm/auditor) were unaffected because their env already matches.

- orchestrator.py: --no-sync on all 6 generated MCP servers
- docker/scripts/sdk-startup-hook.sh: --no-sync on the agent_sdk.server launch
- test_spawn_strict_mcp.py: assert every server's args start with run,--no-sync

* [115788ef] fix(api): use PATCH not PUT in tasksApi.update(), remove WS double-increment, fix staleTime/roster id, remove sessions groupsApi dup, add < 1h ago label

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [db2c341f] UI/visual bugs batch 1 — priority labels, QA columns, DnD prompt, CEO dialog, dark mode (#172)

* fix(orchestrator): launch agent MCP servers with uv run --no-sync

Agent MCP servers (flow/do/git-readonly/optimal/docs/search) are launched as
`uv run python -m roboco.mcp.<server>` with cwd = the agent's workspace clone.
When that clone's uv.lock drifts from the baked image, `uv run` re-syncs the
dependency set mid-spawn and the servers never reach "connected" — they sit at
status="pending", so the agent gets zero gateway verbs. It then can't claim,
commit, or even i_am_idle (all MCP verbs), so its Stop is rejected and it
respawns in a loop, re-doing work it can't submit.

UV_PROJECT_ENVIRONMENT pins the venv location but does not stop the cwd-relative
resolve/sync; --no-sync does, so the servers reuse the baked /app/.venv as-is
and start instantly. PMs were unaffected only because they run from /app where
the env already matches the lock.

* fix(agent): launch agent uv-run subprocesses with --no-sync

Agents with a write workspace (developer/product_owner/head_marketing/documenter)
run with cwd = their git workspace clone. Claude Code launches each MCP server
(flow/do/git-readonly/optimal/docs/search) and the SDK server as
`uv run python -m ...` from that cwd. When the clone's uv.lock drifts from the
baked image, `uv run` re-resolves and re-syncs /app/.venv against the clone's
lock — a multi-minute stall on a cold wheel cache — so the servers never reach
"connected": they sit at status="pending" and the agent gets ZERO gateway
verbs. It then can't claim/commit/idle (all MCP verbs), its Stop is rejected,
and it respawns in a loop redoing work it can't submit.

UV_PROJECT_ENVIRONMENT pins the venv location but does NOT stop the cwd-relative
resolve/sync (confirmed empirically on uv 0.11.1); `--no-sync` does, so the
servers reuse the baked /app/.venv as-is and start instantly. The /app-cwd roles
(qa/cell_pm/main_pm/auditor) were unaffected because their env already matches.

- orchestrator.py: --no-sync on all 6 generated MCP servers
- docker/scripts/sdk-startup-hook.sh: --no-sync on the agent_sdk.server launch
- test_spawn_strict_mcp.py: assert every server's args start with run,--no-sync

* [db2c341f] fix(ui): priority badges, QA columns, DnD dialog, CEO label, dark mode

- priority-indicator.tsx: update labels P0→P0-Highest etc, add text-xs to all color strings, fix className operator precedence bug
- task-table.tsx: match priority label format and add text-xs to badge className
- kanban-column.tsx: show QA Pass/Fail buttons in VERIFYING column alongside AWAITING_QA
- kanban-board.tsx: intercept DnD drops onto NEEDS_REVISION/AWAITING_DOCUMENTATION to show notes dialog when showQaActions is true
- task-action-dialogs.tsx: change CeoApproveDialog Label from 'Approval notes' to 'Notes required'; fix all Cancel buttons to call handleOpenChange(false) so state is cleared on dismiss
- create-task-dialog.tsx: reset form when dialog is closed without submitting
- active-blockers-panel.tsx: add dark:border-red-900 dark:bg-red-950 dark:hover:bg-red-900 to blocker items
- notifications/page.tsx: add dark: Tailwind variants for NORMAL, HIGH, URGENT priority badge colors

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [123e2ec2] Fix remaining 15 UI bugs — revision pass after CEO rejection (#178)

* [cdb9b22a] fix(ui): task-header BACKLOG/NEEDS_REVISION actions, P0 priority label, chat-composer safe clear, and inline-edit double-mutation guards (#176)

- task-header.tsx: add BACKLOG ('Activate Task') and NEEDS_REVISION ('Start Revision') cases to getAvailableActions() switch so the Actions dropdown is never empty for those statuses
- draft-proposal-card.tsx: PRIORITY_LABELS[0] changed from 'Urgent' to 'Highest' to match backend contract
- chat-composer.tsx: move setValue('') inside try-block after onSend resolves; a failed send now preserves the textarea text
- acceptance-criteria.tsx: onMouseDown={(e)=>e.preventDefault()} on inline-edit save button to prevent onBlur+onClick double API mutation
- tab-dependencies.tsx: same onMouseDown guard on parent-task inline-edit save button
- tab-plan.tsx: onMouseDown guards on all inline-edit/add save buttons (ApproachSection, SubTasks, TechConsiderations, Risks, OpenQuestions)

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [01852d69] fix(dashboard): wire real agent status, refetch all 4 queries, error indicator, Coming Soon tooltip on search, sentinel div auto-scroll in message-list and mentor-chat, and New Report / Generate Report button mutations in reports-panel and auditor-dashboard — all 9 files fixed (#177)

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [3f35f502] feat(tasks): add case activate and case start-revision to handleAction switch in task detail page (#179) (#180)

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* fix(tasks): route Start Revision through the operator status override

The new "Start Revision" action on a NEEDS_REVISION task called
lifecycle.start (POST /tasks/{id}/start), which is assignee-only — so an
operator/CEO clicking it from the task detail page got a 403 ("Only the
assigned agent can start this task") instead of a transition.

Route it through useUpdateTask (PATCH /tasks/{id} with status=in_progress)
instead. The backend treats status as an audited admin override applied via
admin_set_status and gated on elevated (ASSIGN) permissions — the same
god-mode path the kanban board uses for operator status changes — so the
operator can nudge a needs_revision task back into progress for its assignee
to rework. Mirrors the existing kanban updateTask.mutateAsync shape.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
2026-06-16 06:57:00 +02:00

314 lines
11 KiB
TypeScript

"use client";
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Markdown } from "@/components/ui/markdown";
import {
Send,
Loader2,
Brain,
User,
BookMarked,
MessageCircle,
RotateCcw,
Sparkles,
} from "lucide-react";
import { MentorAskResponse, KBSearchResult } from "@/types";
import { RAGCitationCard } from "./rag-citation-card";
interface ChatMessage {
role: "user" | "mentor";
content: string;
sources?: KBSearchResult[];
followups?: string[];
agentRole?: string;
agentTeam?: string;
journalEntriesUsed?: number;
}
interface MentorChatProps {
onAsk: (question: string, conversationId?: string) => Promise<MentorAskResponse>;
isLoading: boolean;
}
export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [conversationId, setConversationId] = useState<string | null>(null);
const [expandedSources, setExpandedSources] = useState<number | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll to the newest message using a sentinel div + scrollIntoView
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isLoading]);
const handleSubmit = async (question?: string) => {
const q = question || input.trim();
if (!q || isLoading) return;
// Add user message
setMessages((prev) => [...prev, { role: "user", content: q }]);
setInput("");
try {
const response = await onAsk(q, conversationId ?? undefined);
// Save conversation ID for follow-ups
setConversationId(response.conversation_id);
// Add mentor response
setMessages((prev) => [
...prev,
{
role: "mentor",
content: response.answer,
sources: response.sources,
followups: response.suggested_followups,
agentRole: response.agent_role,
agentTeam: response.agent_team,
journalEntriesUsed: response.journal_entries_used,
},
]);
} catch {
// Error handled by parent
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleNewChat = () => {
setMessages([]);
setConversationId(null);
setExpandedSources(null);
inputRef.current?.focus();
};
const handleFollowUp = (question: string) => {
handleSubmit(question);
};
// Empty state
if (messages.length === 0 && !isLoading) {
return (
<div className="flex flex-col h-[calc(100vh-280px)]">
{/* Empty state */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md">
<div className="relative inline-block mb-4">
<Brain className="h-16 w-16 text-primary/50" />
<Sparkles className="h-6 w-6 text-yellow-500 absolute -top-1 -right-1" />
</div>
<h2 className="text-xl font-semibold mb-2">AI Mentor</h2>
<p className="text-muted-foreground mb-4">
Your personalized mentor that knows your role, searches your journals,
and provides tailored guidance. Start a conversation below.
</p>
<div className="flex justify-center gap-2 flex-wrap">
<Badge variant="secondary" className="gap-1">
<User className="h-3 w-3" /> Role-aware
</Badge>
<Badge variant="secondary" className="gap-1">
<BookMarked className="h-3 w-3" /> Personal context
</Badge>
<Badge variant="secondary" className="gap-1">
<MessageCircle className="h-3 w-3" /> Multi-turn chat
</Badge>
</div>
</div>
</div>
{/* Input at bottom */}
<div className="border-t pt-4">
<div className="relative">
<Textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask your mentor a question..."
className="min-h-20 pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col h-[calc(100vh-280px)]">
{/* Header with New Chat */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-primary" />
<span className="font-medium">Mentor Chat</span>
{conversationId && (
<Badge variant="outline" className="text-xs">
Conversation active
</Badge>
)}
</div>
<Button variant="ghost" size="sm" onClick={handleNewChat}>
<RotateCcw className="h-4 w-4 mr-1" />
New Chat
</Button>
</div>
{/* Messages */}
<ScrollArea className="flex-1 pr-4">
<div className="space-y-4">
{messages.map((msg, idx) => (
<div key={idx}>
{msg.role === "user" ? (
// User message
<div className="flex justify-end">
<div className="bg-primary text-primary-foreground rounded-lg px-4 py-2 max-w-[80%]">
<p className="text-sm">{msg.content}</p>
</div>
</div>
) : (
// Mentor message
<div className="space-y-3">
{/* Personalization badge */}
{(msg.agentRole || msg.journalEntriesUsed) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
{msg.agentRole && (
<span>
Role: <Badge variant="secondary" className="text-xs">{msg.agentRole}</Badge>
{msg.agentTeam && <span className="ml-1">({msg.agentTeam})</span>}
</span>
)}
{msg.journalEntriesUsed !== undefined && msg.journalEntriesUsed > 0 && (
<span className="flex items-center gap-1">
<BookMarked className="h-3 w-3" />
{msg.journalEntriesUsed} journal{msg.journalEntriesUsed !== 1 ? "s" : ""} used
</span>
)}
</div>
)}
{/* Answer */}
<Card>
<CardContent className="pt-4">
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown>{msg.content}</Markdown>
</div>
</CardContent>
</Card>
{/* Sources toggle */}
{msg.sources && msg.sources.length > 0 && (
<div>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => setExpandedSources(expandedSources === idx ? null : idx)}
>
{expandedSources === idx ? "Hide" : "Show"} {msg.sources.length} sources
</Button>
{expandedSources === idx && (
<div className="mt-2 space-y-2">
{msg.sources.map((source, sidx) => (
<RAGCitationCard
key={sidx}
citation={{
content: source.content,
source: source.source,
score: source.score,
index_type: source.index_type,
metadata: source.metadata,
}}
index={sidx}
/>
))}
</div>
)}
</div>
)}
{/* Follow-ups */}
{msg.followups && msg.followups.length > 0 && (
<div className="flex flex-wrap gap-2">
{msg.followups.map((followup, fidx) => (
<Button
key={fidx}
variant="outline"
size="sm"
className="text-xs h-auto py-1.5"
onClick={() => handleFollowUp(followup)}
disabled={isLoading}
>
{followup}
</Button>
))}
</div>
)}
</div>
)}
</div>
))}
{/* Loading indicator */}
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Mentor is thinking...</span>
</div>
)}
{/* Sentinel div — scrollIntoView targets this to keep the newest message visible */}
<div ref={bottomRef} />
</div>
</ScrollArea>
{/* Input at bottom */}
<div className="border-t pt-4 mt-4">
<div className="relative">
<Textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Continue the conversation..."
className="min-h-16 pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
);
}