feat: worker tab by clicking on the worker

This commit is contained in:
Richard Tang
2026-04-18 21:21:22 -07:00
parent 6f1f27b6e9
commit 3df7194003
6 changed files with 137 additions and 14 deletions
+41 -6
View File
@@ -29,12 +29,14 @@ import QuestionWidget from "@/components/QuestionWidget";
import MultiQuestionWidget from "@/components/MultiQuestionWidget";
import { useColony } from "@/context/ColonyContext";
import { useQueenProfile } from "@/context/QueenProfileContext";
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
import ParallelSubagentBubble, {
type SubagentGroup,
} from "@/components/ParallelSubagentBubble";
import {
formatMessageTime,
formatDayDividerLabel,
workerIdFromStreamId,
} from "@/lib/chat-helpers";
export interface ChatMessage {
@@ -348,12 +350,27 @@ function InlineAskUserBubble({
const { queenProfiles } = useColony();
const { openQueenProfile } = useQueenProfile();
const { openColonyWorkers } = useColonyWorkers();
const queenProfileId = isQueen
? queenProfiles.find((q) => q.name === msg.agent)?.id ?? null
: null;
const handleQueenClick = queenProfileId
? () => openQueenProfile(queenProfileId)
: undefined;
const workerId =
!isQueen && msg.role === "worker"
? workerIdFromStreamId(msg.streamId)
: null;
const handleWorkerClick =
msg.role === "worker"
? () => openColonyWorkers(workerId ?? undefined)
: undefined;
const handleAvatarClick = handleQueenClick ?? handleWorkerClick;
const avatarTitle = handleQueenClick
? `View ${msg.agent}'s profile`
: handleWorkerClick
? "Open worker in colony sidebar"
: undefined;
const handleSingle = (answer: string) => {
setState("submitted");
@@ -374,14 +391,14 @@ function InlineAskUserBubble({
return (
<div className="flex gap-3">
<div
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center${handleQueenClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
style={{
backgroundColor: `${color}18`,
border: `1.5px solid ${color}35`,
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
}}
onClick={handleQueenClick}
title={handleQueenClick ? `View ${msg.agent}'s profile` : undefined}
onClick={handleAvatarClick}
title={avatarTitle}
>
{isQueen ? (
<Crown className="w-4 h-4" style={{ color }} />
@@ -456,9 +473,17 @@ const MessageBubble = memo(
// Resolve queen profile ID so clicking avatar/name opens the profile panel
const { queenProfiles } = useColony();
const { openQueenProfile } = useQueenProfile();
const { openColonyWorkers } = useColonyWorkers();
const queenProfileId = isQueen
? queenProfiles.find((q) => q.name === msg.agent)?.id ?? null
: null;
// Worker messages: clicking the avatar opens the Colony Workers
// sidebar, pre-selecting this worker when its uuid is embedded in
// the streamId (parallel fan-out case).
const workerId =
!isQueen && msg.role === "worker"
? workerIdFromStreamId(msg.streamId)
: null;
if (msg.type === "run_divider") {
return (
@@ -557,18 +582,28 @@ const MessageBubble = memo(
const handleQueenClick = queenProfileId
? () => openQueenProfile(queenProfileId)
: undefined;
const handleWorkerClick =
msg.role === "worker"
? () => openColonyWorkers(workerId ?? undefined)
: undefined;
const handleAvatarClick = handleQueenClick ?? handleWorkerClick;
const avatarTitle = handleQueenClick
? `View ${msg.agent}'s profile`
: handleWorkerClick
? "Open worker in colony sidebar"
: undefined;
return (
<div className="flex gap-3">
<div
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center${handleQueenClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
style={{
backgroundColor: `${color}18`,
border: `1.5px solid ${color}35`,
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
}}
onClick={handleQueenClick}
title={handleQueenClick ? `View ${msg.agent}'s profile` : undefined}
onClick={handleAvatarClick}
title={avatarTitle}
>
{isQueen ? (
<Crown className="w-4 h-4" style={{ color }} />
@@ -90,6 +90,15 @@ export default function ColonyWorkersPanel({
onClose,
}: ColonyWorkersPanelProps) {
const [tab, setTab] = useState<TabKey>("sessions");
const { focusWorkerId } = useColonyWorkers();
// When an external caller (e.g. clicking a worker avatar in chat)
// requests focus on a specific worker, jump to the Sessions tab so
// the pre-select in SessionsTab is visible. The actual select +
// focus-clear happens inside SessionsTab.
useEffect(() => {
if (focusWorkerId) setTab("sessions");
}, [focusWorkerId]);
// ── Resizable width (mirrors QueenProfilePanel) ─────────────────────
const MIN_WIDTH = 280;
@@ -489,6 +498,21 @@ function SessionsTab({
const [selected, setSelected] = useState<string | null>(null);
const [stoppingId, setStoppingId] = useState<string | null>(null);
const [stoppingAll, setStoppingAll] = useState(false);
const { focusWorkerId, setFocusWorkerId } = useColonyWorkers();
// Consume focus requests from avatar clicks in chat. Wait for the
// initial fetch before deciding so a click that arrives before the
// workers list has loaded still resolves. If the requested id is
// present we drill into its detail view; if it's aged out we swallow
// the request silently. Either way we clear the focus so it isn't
// re-applied on every re-render.
useEffect(() => {
if (!focusWorkerId || loading) return;
if (workers.some((w) => w.worker_id === focusWorkerId)) {
setSelected(focusWorkerId);
}
setFocusWorkerId(null);
}, [focusWorkerId, workers, loading, setFocusWorkerId]);
const refresh = useCallback(() => {
setLoading(true);
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronUp, Cpu } from "lucide-react";
import type { ChatMessage, ContextUsageEntry } from "@/components/ChatPanel";
import MarkdownContent from "@/components/MarkdownContent";
import { cssVar } from "@/lib/graphUtils";
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
// ---------------------------------------------------------------------------
// Shared helpers
@@ -317,6 +318,7 @@ const ParallelSubagentBubble = memo(
const [expanded, setExpanded] = useState(false);
const [zoomedIdx, setZoomedIdx] = useState<number | null>(null);
const mux = useMuxColors();
const { openColonyWorkers } = useColonyWorkers();
// Labels with instance numbers for duplicates
const labels: string[] = (() => {
@@ -371,16 +373,21 @@ const ParallelSubagentBubble = memo(
return (
<div className="flex gap-3">
{/* Left icon */}
<div
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1"
{/* Left icon — subagents aren't top-level colony workers, so the
click opens the sidebar without pre-selection. */}
<button
type="button"
onClick={() => openColonyWorkers()}
aria-label="Open colony workers sidebar"
title="Open colony workers sidebar"
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1 transition-opacity hover:opacity-80 cursor-pointer"
style={{
backgroundColor: `${workerColor}18`,
border: `1.5px solid ${workerColor}35`,
}}
>
<Cpu className="w-3.5 h-3.5" style={{ color: workerColor }} />
</div>
</button>
<div className="flex-1 min-w-0 max-w-[90%]">
{/* Header */}
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp, Cpu } from "lucide-react";
import type { ChatMessage } from "@/components/ChatPanel";
import { ToolActivityRow } from "@/components/ChatPanel";
import MarkdownContent from "@/components/MarkdownContent";
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
import { workerIdFromStreamId } from "@/lib/chat-helpers";
const workerColor = "hsl(220,60%,55%)";
@@ -68,6 +70,19 @@ const WorkerRunBubble = memo(
function WorkerRunBubble({ group, label }: WorkerRunBubbleProps) {
const [expanded, setExpanded] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
const { openColonyWorkers } = useColonyWorkers();
// Derive the colony worker id from the first message that carries
// a parallel-worker streamId (``worker:{uuid}``). Legacy single-worker
// bubbles (streamId="worker") have no uuid — the click still opens
// the sidebar, just without a preselection.
const workerId = (() => {
for (const m of group.messages) {
const id = workerIdFromStreamId(m.streamId);
if (id) return id;
}
return null;
})();
// Separate text messages from tool status
const textMsgs = group.messages.filter(
@@ -123,16 +138,21 @@ const WorkerRunBubble = memo(
return (
<div className="flex gap-3">
{/* Left icon */}
<div
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1"
{/* Left icon — clicking opens the Colony Workers sidebar and
pre-selects this worker if we can derive its id. */}
<button
type="button"
onClick={() => openColonyWorkers(workerId ?? undefined)}
aria-label="Open worker in colony sidebar"
title="Open worker in colony sidebar"
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1 transition-opacity hover:opacity-80 cursor-pointer"
style={{
backgroundColor: `${workerColor}18`,
border: `1.5px solid ${workerColor}35`,
}}
>
<Cpu className="w-3.5 h-3.5" style={{ color: workerColor }} />
</div>
</button>
<div className="flex-1 min-w-0 max-w-[90%]">
{/* Clickable header */}
@@ -35,6 +35,17 @@ interface ColonyWorkersContextValue {
* colony room. */
toggleColonyWorkers: () => void;
/** Worker the Sessions tab should auto-select on the next render.
* Set by ``openColonyWorkers(workerId)`` when a chat avatar is
* clicked; cleared by the panel after it consumes the value. */
focusWorkerId: string | null;
setFocusWorkerId: (workerId: string | null) => void;
/** Open the panel and optionally pre-select a worker. Un-dismisses
* the panel even if it was previously closed. Passing no workerId
* just opens the panel without changing selection. */
openColonyWorkers: (workerId?: string) => void;
/** Current session's triggers, pushed from whichever page is active
* (colony-chat today). ``ColonyWorkersPanel`` reads these to render
* its Triggers tab without having to re-subscribe to SSE itself. */
@@ -48,6 +59,7 @@ export function ColonyWorkersProvider({ children }: { children: ReactNode }) {
const [sessionId, setSessionIdState] = useState<string | null>(null);
const [colonyName, setColonyName] = useState<string | null>(null);
const [dismissed, setDismissed] = useState(false);
const [focusWorkerId, setFocusWorkerId] = useState<string | null>(null);
const [triggers, setTriggers] = useState<GraphNode[]>([]);
const setSessionId = useCallback((next: string | null) => {
@@ -64,6 +76,11 @@ export function ColonyWorkersProvider({ children }: { children: ReactNode }) {
setDismissed((d) => !d);
}, []);
const openColonyWorkers = useCallback((workerId?: string) => {
setDismissed(false);
setFocusWorkerId(workerId ?? null);
}, []);
return (
<ColonyWorkersContext.Provider
value={{
@@ -73,6 +90,9 @@ export function ColonyWorkersProvider({ children }: { children: ReactNode }) {
setColonyName,
dismissed,
toggleColonyWorkers,
focusWorkerId,
setFocusWorkerId,
openColonyWorkers,
triggers,
setTriggers,
}}
+17
View File
@@ -15,6 +15,23 @@ import type { AgentEvent } from "@/api/types";
* "inbox-management" → "Inbox Management"
* "job_hunter" → "Job Hunter"
*/
/**
* Extract the colony worker uuid from a parallel-worker ``streamId``.
*
* Worker messages tag their ``streamId`` as either ``"worker"`` (single-worker
* legacy case) or ``"worker:{uuid}"`` (parallel fan-out). The uuid half is
* the colony worker id — the same identifier the Colony Workers sidebar uses
* to key its Sessions cards. Returns null for the legacy single-worker case
* or any other stream kind.
*/
export function workerIdFromStreamId(
streamId: string | null | undefined,
): string | null {
if (!streamId) return null;
const m = /^worker:(.+)$/.exec(streamId);
return m ? m[1] : null;
}
export function formatAgentDisplayName(raw: string): string {
// Take the last path segment (in case it's a path like "examples/templates/foo")
const base = raw.split("/").pop() || raw;