feat(FN-2294): merge fusion/fn-2294

This commit is contained in:
Fusion
2026-04-23 08:07:53 -07:00
committed by gsxdsm
parent 70ad1836d0
commit c04c5386b6
16 changed files with 96 additions and 53 deletions

View File

@@ -291,6 +291,8 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState<number>(1);
/** Whether the heartbeat multiplier is currently being saved */
const [isSavingMultiplier, setIsSavingMultiplier] = useState(false);
/** Agent IDs with an in-flight state transition (for optimistic update guard) */
const [transitioningAgentIds, setTransitioningAgentIds] = useState<Set<string>>(new Set());
// Load heartbeat multiplier from project settings on mount
useEffect(() => {
@@ -423,11 +425,13 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
}, [loadAgents]);
const handleStateChange = async (agentId: string, newState: AgentState) => {
if (transitioningAgentIds.has(agentId)) return;
const previousAgent = agents.find(a => a.id === agentId);
setAgents(prev => prev.map(a => a.id === agentId ? { ...a, state: newState } : a));
setTransitioningAgentIds(prev => new Set(prev).add(agentId));
try {
await updateAgentState(agentId, newState, projectId);
addToast(`Agent state updated to ${newState}`, "success");
// When activating an agent, also start a heartbeat run so it shows activity
if (newState === "active") {
try {
await startAgentRun(agentId, projectId);
@@ -435,10 +439,14 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
addToast(`Agent activated, but failed to start run: ${runErr.message}`, "error");
}
}
void loadAgents();
} catch (err: any) {
if (previousAgent) {
setAgents(prev => prev.map(a => a.id === agentId ? previousAgent : a));
}
addToast(`Failed to update state: ${err.message}`, "error");
} finally {
setTransitioningAgentIds(prev => { const next = new Set(prev); next.delete(agentId); return next; });
}
};
@@ -1114,6 +1122,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Activate"
>
<Play size={14} /> Start
@@ -1132,6 +1141,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<button
className="btn btn--sm"
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
disabled={transitioningAgentIds.has(agent.id)}
title="Run Now"
aria-label={`Run now for ${agent.name}`}
>
@@ -1140,6 +1150,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
>
<Pause size={14} /> Pause
@@ -1150,6 +1161,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Resume"
>
<Play size={14} /> Resume
@@ -1168,6 +1180,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
>
<Pause size={14} /> Pause
@@ -1178,6 +1191,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Retry"
>
<Play size={14} /> Retry
@@ -1188,6 +1202,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Start"
>
<Play size={14} /> Start

View File

@@ -322,7 +322,7 @@ export function MailboxModal({
</div>
<div className="mailbox-header-actions">
<button
className="btn-sm btn-primary"
className="btn btn-sm btn-primary"
onClick={handleOpenCompose}
title="Compose message"
data-testid="mailbox-header-compose"
@@ -587,7 +587,7 @@ export function MailboxModal({
</select>
</div>
<button
className="btn-sm btn-secondary mailbox-compose-btn"
className="btn btn-sm btn-secondary mailbox-compose-btn"
onClick={handleOpenCompose}
data-testid="mailbox-compose-btn"
>

View File

@@ -381,7 +381,7 @@ export function MailboxView({
</div>
<div className="mailbox-header-actions">
<button
className="btn-sm btn-primary"
className="btn btn-sm btn-primary"
onClick={handleOpenCompose}
title="Compose message"
data-testid="mailbox-header-compose"
@@ -650,7 +650,7 @@ export function MailboxView({
</select>
</div>
<button
className="btn-sm btn-secondary mailbox-compose-btn"
className="btn btn-sm btn-secondary mailbox-compose-btn"
onClick={handleOpenCompose}
data-testid="mailbox-compose-btn"
>

View File

@@ -219,7 +219,7 @@ export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManager
return (
<div className="pi-ext-manager">
<div className="pi-ext-manager-header">
<h3>Pi Extensions</h3>
<h4 className="settings-section-heading">Pi Extensions</h4>
<div className="pi-ext-manager-actions">
<button className="btn-icon" onClick={loadSettings} title="Refresh" disabled={loading}>
<RefreshCw size={16} className={loading ? "spin" : ""} />
@@ -254,7 +254,7 @@ export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManager
disabled={installing}
/>
<button
className="btn-primary"
className="btn btn-primary"
onClick={handleInstall}
disabled={installing || !newSource.trim()}
>
@@ -389,7 +389,7 @@ export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManager
<div className="pi-ext-discovered-header">
<h4>Discovered Extensions</h4>
<button
className="btn btn-sm"
className="btn-icon"
onClick={loadExtensions}
disabled={extensionsLoading}
title="Refresh extensions"

View File

@@ -349,6 +349,9 @@ describe("MailboxModal", () => {
await waitFor(() => {
expect(screen.getByTestId("mailbox-header-compose")).toBeDefined();
});
const headerComposeButton = screen.getByTestId("mailbox-header-compose");
expect(headerComposeButton).toHaveClass("btn", "btn-sm", "btn-primary");
});
it("shows compose button in header on agents tab", async () => {
@@ -365,6 +368,9 @@ describe("MailboxModal", () => {
await waitFor(() => {
expect(screen.getByTestId("mailbox-compose-btn")).toBeDefined();
});
const agentsComposeButton = screen.getByTestId("mailbox-compose-btn");
expect(agentsComposeButton).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-compose-btn");
});
it("compose opened from Agents tab without selected agent shows recipient select", async () => {

View File

@@ -385,6 +385,9 @@ describe("MailboxView", () => {
await waitFor(() => {
expect(screen.getByTestId("mailbox-header-compose")).toBeDefined();
});
const headerComposeButton = screen.getByTestId("mailbox-header-compose");
expect(headerComposeButton).toHaveClass("btn", "btn-sm", "btn-primary");
});
it("shows compose button in header on agents tab", async () => {
@@ -552,6 +555,9 @@ describe("MailboxView", () => {
expect(screen.getByTestId("mailbox-agent-subtab-inbox")).toBeDefined();
expect(screen.getByTestId("mailbox-agent-subtab-outbox")).toBeDefined();
});
const agentsComposeButton = screen.getByTestId("mailbox-compose-btn");
expect(agentsComposeButton).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-compose-btn");
});
it("switches to outbox view when clicking outbox sub-tab", async () => {