storkit: create 365_story_surface_api_rate_limit_warnings_in_chat
This commit is contained in:
@@ -1,419 +0,0 @@
|
||||
import * as React from "react";
|
||||
import type {
|
||||
AgentConfigInfo,
|
||||
AgentEvent,
|
||||
AgentStatusValue,
|
||||
} from "../api/agents";
|
||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||
import { settingsApi } from "../api/settings";
|
||||
import { useLozengeFly } from "./LozengeFlyContext";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
interface AgentState {
|
||||
agentName: string;
|
||||
status: AgentStatusValue;
|
||||
sessionId: string | null;
|
||||
worktreePath: string | null;
|
||||
baseBranch: string | null;
|
||||
terminalAt: number | null;
|
||||
}
|
||||
|
||||
const formatTimestamp = (value: Date | null): string => {
|
||||
if (!value) return "";
|
||||
return value.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
function RosterBadge({ agent }: { agent: AgentConfigInfo }) {
|
||||
const { registerRosterEl } = useLozengeFly();
|
||||
const badgeRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Register this element so fly animations know where to start/end
|
||||
useEffect(() => {
|
||||
const el = badgeRef.current;
|
||||
if (el) registerRosterEl(agent.name, el);
|
||||
return () => registerRosterEl(agent.name, null);
|
||||
}, [agent.name, registerRosterEl]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={badgeRef}
|
||||
data-testid={`roster-badge-${agent.name}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.7em",
|
||||
background: "#aaaaaa18",
|
||||
color: "#aaa",
|
||||
border: "1px solid #aaaaaa44",
|
||||
}}
|
||||
title={`${agent.role || agent.name} — available`}
|
||||
>
|
||||
<span
|
||||
data-testid={`roster-dot-${agent.name}`}
|
||||
style={{
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
background: "#3fb950",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 600, color: "#aaa" }}>{agent.name}</span>
|
||||
{agent.model && <span style={{ color: "#888" }}>{agent.model}</span>}
|
||||
<span style={{ color: "#888", fontStyle: "italic" }}>available</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a composite key for tracking agent state. */
|
||||
function agentKey(storyId: string, agentName: string): string {
|
||||
return `${storyId}:${agentName}`;
|
||||
}
|
||||
|
||||
interface AgentPanelProps {
|
||||
/** Increment this to trigger a re-fetch of the agent roster. */
|
||||
configVersion?: number;
|
||||
/** Increment this to trigger a re-fetch of the agent list (agent state changed). */
|
||||
stateVersion?: number;
|
||||
}
|
||||
|
||||
export function AgentPanel({
|
||||
configVersion = 0,
|
||||
stateVersion = 0,
|
||||
}: AgentPanelProps) {
|
||||
const { hiddenRosterAgents } = useLozengeFly();
|
||||
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
||||
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
const [editorCommand, setEditorCommand] = useState<string | null>(null);
|
||||
const [editorInput, setEditorInput] = useState<string>("");
|
||||
const [editingEditor, setEditingEditor] = useState(false);
|
||||
const cleanupRefs = useRef<Record<string, () => void>>({});
|
||||
|
||||
// Re-fetch roster whenever configVersion changes (triggered by agent_config_changed WS event).
|
||||
useEffect(() => {
|
||||
agentsApi
|
||||
.getAgentConfig()
|
||||
.then(setRoster)
|
||||
.catch((err) => console.error("Failed to load agent config:", err));
|
||||
}, [configVersion]);
|
||||
|
||||
const subscribeToAgent = useCallback((storyId: string, agentName: string) => {
|
||||
const key = agentKey(storyId, agentName);
|
||||
cleanupRefs.current[key]?.();
|
||||
|
||||
const cleanup = subscribeAgentStream(
|
||||
storyId,
|
||||
agentName,
|
||||
(event: AgentEvent) => {
|
||||
setAgents((prev) => {
|
||||
const current = prev[key] ?? {
|
||||
agentName,
|
||||
status: "pending" as AgentStatusValue,
|
||||
sessionId: null,
|
||||
worktreePath: null,
|
||||
baseBranch: null,
|
||||
terminalAt: null,
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case "status": {
|
||||
const newStatus =
|
||||
(event.status as AgentStatusValue) ?? current.status;
|
||||
const isTerminal =
|
||||
newStatus === "completed" || newStatus === "failed";
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...current,
|
||||
status: newStatus,
|
||||
terminalAt: isTerminal
|
||||
? (current.terminalAt ?? Date.now())
|
||||
: current.terminalAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "done":
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...current,
|
||||
status: "completed",
|
||||
sessionId: event.session_id ?? current.sessionId,
|
||||
terminalAt: current.terminalAt ?? Date.now(),
|
||||
},
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...current,
|
||||
status: "failed",
|
||||
terminalAt: current.terminalAt ?? Date.now(),
|
||||
},
|
||||
};
|
||||
default:
|
||||
// output, thinking, and other events are not displayed in the sidebar.
|
||||
// Agent output streams appear in the work item detail panel instead.
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
},
|
||||
() => {
|
||||
// SSE error — agent may not be streaming yet
|
||||
},
|
||||
);
|
||||
|
||||
cleanupRefs.current[key] = cleanup;
|
||||
}, []);
|
||||
|
||||
/** Shared helper: fetch the agent list and update state + SSE subscriptions. */
|
||||
const refreshAgents = useCallback(() => {
|
||||
agentsApi
|
||||
.listAgents()
|
||||
.then((agentList) => {
|
||||
const agentMap: Record<string, AgentState> = {};
|
||||
const now = Date.now();
|
||||
for (const a of agentList) {
|
||||
const key = agentKey(a.story_id, a.agent_name);
|
||||
const isTerminal = a.status === "completed" || a.status === "failed";
|
||||
agentMap[key] = {
|
||||
agentName: a.agent_name,
|
||||
status: a.status,
|
||||
sessionId: a.session_id,
|
||||
worktreePath: a.worktree_path,
|
||||
baseBranch: a.base_branch,
|
||||
terminalAt: isTerminal ? now : null,
|
||||
};
|
||||
if (a.status === "running" || a.status === "pending") {
|
||||
subscribeToAgent(a.story_id, a.agent_name);
|
||||
}
|
||||
}
|
||||
setAgents(agentMap);
|
||||
setLastRefresh(new Date());
|
||||
})
|
||||
.catch((err) => console.error("Failed to load agents:", err));
|
||||
}, [subscribeToAgent]);
|
||||
|
||||
// Load existing agents and editor preference on mount
|
||||
useEffect(() => {
|
||||
refreshAgents();
|
||||
|
||||
settingsApi
|
||||
.getEditorCommand()
|
||||
.then((s) => {
|
||||
setEditorCommand(s.editor_command);
|
||||
setEditorInput(s.editor_command ?? "");
|
||||
})
|
||||
.catch((err) => console.error("Failed to load editor command:", err));
|
||||
|
||||
return () => {
|
||||
for (const cleanup of Object.values(cleanupRefs.current)) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Re-fetch agent list when agent state changes (via WebSocket notification).
|
||||
// Skip the initial render (stateVersion=0) since the mount effect handles that.
|
||||
useEffect(() => {
|
||||
if (stateVersion > 0) {
|
||||
refreshAgents();
|
||||
}
|
||||
}, [stateVersion, refreshAgents]);
|
||||
|
||||
const handleSaveEditor = async () => {
|
||||
try {
|
||||
const trimmed = editorInput.trim() || null;
|
||||
const result = await settingsApi.setEditorCommand(trimmed);
|
||||
setEditorCommand(result.editor_command);
|
||||
setEditorInput(result.editor_command ?? "");
|
||||
setEditingEditor(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionError(`Failed to save editor: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #333",
|
||||
borderRadius: "10px",
|
||||
padding: "12px 16px",
|
||||
background: "#1f1f1f",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>Agents</div>
|
||||
{Object.values(agents).filter((a) => a.status === "running").length >
|
||||
0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#777",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{
|
||||
Object.values(agents).filter((a) => a.status === "running")
|
||||
.length
|
||||
}{" "}
|
||||
running
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lastRefresh && (
|
||||
<div style={{ fontSize: "0.7em", color: "#555" }}>
|
||||
Loaded {formatTimestamp(lastRefresh)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor preference */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<span style={{ fontSize: "0.75em", color: "#666" }}>Editor:</span>
|
||||
{editingEditor ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={editorInput}
|
||||
onChange={(e) => setEditorInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveEditor();
|
||||
if (e.key === "Escape") setEditingEditor(false);
|
||||
}}
|
||||
placeholder="zed, code, cursor..."
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
background: "#111",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "#ccc",
|
||||
padding: "2px 6px",
|
||||
width: "120px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveEditor}
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #238636",
|
||||
background: "#238636",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingEditor(false)}
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #444",
|
||||
background: "none",
|
||||
color: "#888",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingEditor(true)}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
background: "none",
|
||||
border: "1px solid #333",
|
||||
borderRadius: "4px",
|
||||
color: editorCommand ? "#aaa" : "#555",
|
||||
cursor: "pointer",
|
||||
padding: "2px 8px",
|
||||
fontFamily: editorCommand ? "monospace" : "inherit",
|
||||
}}
|
||||
>
|
||||
{editorCommand ?? "Set editor..."}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Roster badges — agents always display in idle state here */}
|
||||
{roster.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
{roster.map((a) => {
|
||||
const isHidden = hiddenRosterAgents.has(a.name);
|
||||
return (
|
||||
<div
|
||||
key={`roster-wrapper-${a.name}`}
|
||||
data-testid={`roster-badge-wrapper-${a.name}`}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
maxWidth: isHidden ? "0" : "300px",
|
||||
opacity: isHidden ? 0 : 1,
|
||||
transition: "max-width 0.35s ease, opacity 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<RosterBadge agent={a} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: "#ff7b72",
|
||||
padding: "4px 8px",
|
||||
background: "#ff7b7211",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user