feat: worker tab by clicking on the worker
This commit is contained in:
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user