Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/components/terminal/TerminalCapacityBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"use client";

import { Loader2 } from "lucide-react";
import { agentLabel } from "@/lib/agent-resolution";

export type AgentAvailability = {
id: string;
label: string;
available: boolean;
availabilityReason?: string;
};

type Props = {
/** The agent that hit the capacity wall. */
currentAgent: string;
/** All agents in fallback order with their availability status. */
agents: AgentAvailability[];
/** When the capacity resets, if detected from the error message. */
resetsAt?: string;
/** Callback to perform the agent switch. */
onSwitch: (agent: string) => void;
/** True while a switch is in progress. */
switching?: boolean;
};

/**
* Terminal capacity overlay — hides the vendor error and shows one decision:
* switch to the next available agent.
*
* No menu of agents, no cloud/local puzzle, no vendor error visible.
* One sentence, one button. The operator confirms; we handle the rest.
*/
export function TerminalCapacityBanner({
currentAgent,
agents,
resetsAt,
onSwitch,
switching = false,
}: Props) {
const currentLabel = agentLabel(currentAgent);

// Find the next available agent in the fallback order
const nextAgent = agents.find((a) => a.available);
const allExhausted = !nextAgent;

return (
<div className="absolute inset-0 flex items-center justify-center bg-surface-terminal p-4">
<div className="flex max-w-sm flex-col items-center gap-3 text-center">
{allExhausted ? (
<>
<p className="text-sm text-text-secondary">
{currentLabel} hit its limit{resetsAt ? ` (resets ${resetsAt})` : ""}.
</p>
<p className="text-xs text-text-muted">
All agents are at capacity. Wait for the reset time or install additional agents.
</p>
</>
) : (
<>
<p className="text-sm text-text-secondary">
{currentLabel} hit its limit{resetsAt ? ` (resets ${resetsAt})` : ""}.
</p>
<button
type="button"
disabled={switching}
onClick={() => onSwitch(nextAgent.id)}
className="ui-btn-primary inline-flex items-center gap-2"
>
{switching ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Switching to {nextAgent.label}…
</>
) : (
<>Switch to {nextAgent.label}</>
)}
</button>
</>
)}
</div>
</div>
);
}
23 changes: 17 additions & 6 deletions src/components/terminal/TerminalLaunch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,16 @@ export function TerminalLaunch({
};

return (
<div className="mt-2 flex flex-col items-center gap-2">
<div className="flex flex-col items-center gap-3">
{/* Launch form — one row, consistent sizing */}
<div className="flex flex-wrap items-center justify-center gap-2">
<select
className="ui-input-compact"
className="ui-chip-toggle min-w-[120px] cursor-pointer appearance-none bg-surface-base pr-7 font-normal"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8z'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 0.75rem center",
}}
aria-label="Project to start an agent in"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
Expand All @@ -85,7 +91,12 @@ export function TerminalLaunch({
))}
</select>
<select
className="ui-input-compact"
className="ui-chip-toggle min-w-[100px] cursor-pointer appearance-none bg-surface-base pr-7 font-normal"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8z'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 0.75rem center",
}}
aria-label="Agent to start"
value={agentId}
onChange={(e) => setAgentOverride(e.target.value)}
Expand All @@ -98,12 +109,12 @@ export function TerminalLaunch({
</select>
<button
type="button"
className="ui-btn-primary"
className="ui-btn-primary ui-btn-xs inline-flex items-center gap-1.5"
onClick={() => void start()}
disabled={busy || !project || !agentId}
>
{busy ? <Loader2 className="ui-spinner" /> : <Play className="h-3.5 w-3.5" />}
Start here
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
Start
</button>
</div>
{error && <p className="ui-error">{error}</p>}
Expand Down
79 changes: 75 additions & 4 deletions src/components/terminal/TerminalSurface.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { Loader2, MonitorSmartphone } from "lucide-react";
import { cn } from "@/lib/utils";
import { postJson } from "@/lib/api/fetch";
import { EXECUTOR_COPY } from "@/config/executor-copy";
import { deriveExecutorHonestyLabel } from "@/lib/executor-honesty";
import { listAgentRegistry, AGENT_FALLBACK_ORDER } from "@/lib/agent-registry";
import type { AgentAvailability } from "@/components/terminal/TerminalCapacityBanner";
import { useFetch } from "@/hooks/use-fetch";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { useTerminalFont } from "@/hooks/use-terminal-font";
Expand Down Expand Up @@ -174,6 +176,44 @@ export function TerminalSurface({
loading,
});

// Auto-switch sources when Watch opens a tab that's on the other side.
// Uses a ref to track the switch signature so we only auto-switch once per
// deep-link miss episode, preventing infinite loops.
const autoSwitchSignatureRef = useRef<string | null>(null);
const [autoSwitching, setAutoSwitching] = useState(false);

useEffect(() => {
// When we have a deep link miss and haven't auto-switched for this episode,
// automatically try the other source. This makes Watch land on the right
// side without requiring a manual "Look on X" click.
if (!deepLinkMiss || !initialTab) {
autoSwitchSignatureRef.current = null;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAutoSwitching(false);
return;
}

const signature = `${initialTab}:${source}`;
if (signature === autoSwitchSignatureRef.current) return;

const otherSource = source === "machine" ? "cloud" : "machine";
if (!sources.includes(otherSource)) {
setAutoSwitching(false);
return;
}

autoSwitchSignatureRef.current = signature;
setAutoSwitching(true);
setSource(otherSource);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deepLinkMiss, initialTab, source]);
// ^^^ Episodic automation: this effect reacts to a polled tab list and must
// switch sources exactly once per miss episode (ref-tracked signature as loop
// guard). Deriving the state during render would rewire the loop-safety
// guarantees of a live source-switching path for no user-visible gain. sources
// and setSource are intentionally omitted from deps (would cause loops) — the
// [deepLinkMiss, initialTab, source] triple captures the decision point.

// The terminal is one of the four project surfaces, so the tab you are
// watching IS the fleet's active project — Control, Loki and the project
// profile follow you here instead of resetting.
Expand Down Expand Up @@ -271,6 +311,20 @@ export function TerminalSurface({
[activeTab, tabDir, activeAgentId],
);

// Build agent fallback chain for capacity banner — uses the registry SSOT.
const agentFallbackChain: AgentAvailability[] = useMemo(() => {
const registry = listAgentRegistry();
return AGENT_FALLBACK_ORDER.map((id) => {
const entry = registry.find((r) => r.id === id);
return {
id,
label: entry?.label ?? id,
available: entry?.available ?? false,
availabilityReason: entry?.availabilityReason,
};
}).filter((a) => a.id !== activeAgentId); // Exclude current agent from the chain
}, [activeAgentId]);

// The strip tells the truth about each tab: the project it resolves to (by
// name, or by pane cwd for generically named tabs) and the agent CLI actually
// running in it — so "Tab #1 · claude" and "Tab #2 · grok" are distinguishable
Expand Down Expand Up @@ -394,6 +448,19 @@ export function TerminalSurface({
// ── Agent sessions ──────────────────────────────────────────────────────
const body = () => {
if (deepLinkMiss) {
// Show loading while we auto-switch to check the other source.
if (autoSwitching) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-text-muted">
<Loader2 className="ui-spinner" /> Looking for session on{" "}
{source === "machine"
? EXECUTOR_COPY.terminal.thisComputerLabel
: EXECUTOR_COPY.terminal.cloudLabel}
</div>
);
}

return (
<TerminalSessionMiss
requestedTab={initialTab!}
Expand Down Expand Up @@ -441,11 +508,11 @@ export function TerminalSurface({
defaultAgent={context?.agents.defaultAgent ?? null}
channel={channel}
/>
<div className="mt-2 flex flex-wrap justify-center gap-2">
<Link href={controlHref} className="ui-btn-secondary">
<div className="flex flex-wrap justify-center gap-2">
<Link href={controlHref} className="ui-btn-xs">
Open on Control
</Link>
<Link href="/loki" className="ui-btn-secondary">
<Link href="/loki" className="ui-btn-xs">
Ask Loki
</Link>
</div>
Expand All @@ -471,6 +538,10 @@ export function TerminalSurface({
font={font}
onLive={setLiveState}
onGeometry={setGeometry}
currentAgent={activeAgentId}
availableAgents={agentFallbackChain}
onSwitchAgent={switchAgent}
switchingAgent={switchingAgent}
/>
);
};
Expand Down
Loading
Loading