11import { useState , useEffect , useCallback } from "react" ;
2- import { Radar , Pause , Play , Loader2 , CircleDot } from "lucide-react" ;
2+ import { Radar , Pause , Play , Loader2 , CircleDot , Minus , Plus } from "lucide-react" ;
33import { Switch } from "@/components/ui" ;
44
55interface HeldCard {
@@ -21,23 +21,38 @@ interface PauseState {
2121
2222const EMPTY_PAUSE : PauseState = { global : false , lanes : { } } ;
2323
24+ // Global concurrency cap: how many cards the fleet may hold in flight at once
25+ // (the dispatch loop reads it as MAX_OPEN_PRS). null = no override, the loop
26+ // default applies. Pause is the on/off switch; this is the volume knob.
27+ function coerceCap ( v : unknown ) : number | null {
28+ return typeof v === "number" && Number . isFinite ( v ) && v > 0 ? Math . floor ( v ) : null ;
29+ }
30+
2431export function ObservatoryApp ( { windowId : _windowId } : { windowId : string } ) {
2532 const [ agents , setAgents ] = useState < FleetAgent [ ] > ( [ ] ) ;
2633 const [ pause , setPause ] = useState < PauseState > ( EMPTY_PAUSE ) ;
34+ const [ cap , setCap ] = useState < number | null > ( null ) ;
2735 const [ loading , setLoading ] = useState ( true ) ;
2836 const [ busy , setBusy ] = useState < string | null > ( null ) ;
2937
3038 const load = useCallback ( async ( opts ?: { silent ?: boolean } ) => {
3139 if ( ! opts ?. silent ) setLoading ( true ) ;
3240 try {
33- const res = await fetch ( "/api/observatory/fleet" ) ;
34- if ( res . ok ) {
35- const data = await res . json ( ) ;
41+ const [ fleetRes , throttleRes ] = await Promise . all ( [
42+ fetch ( "/api/observatory/fleet" ) ,
43+ fetch ( "/api/observatory/throttle" ) ,
44+ ] ) ;
45+ if ( fleetRes . ok ) {
46+ const data = await fleetRes . json ( ) ;
3647 setAgents ( Array . isArray ( data . agents ) ? data . agents : [ ] ) ;
3748 setPause (
3849 data . paused && typeof data . paused === "object" ? data . paused : EMPTY_PAUSE ,
3950 ) ;
4051 }
52+ if ( throttleRes . ok ) {
53+ const data = await throttleRes . json ( ) ;
54+ setCap ( coerceCap ( data ?. global ) ) ;
55+ }
4156 } catch {
4257 // Non-critical: keep the last-loaded view.
4358 } finally {
@@ -80,6 +95,26 @@ export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
8095 [ load ] ,
8196 ) ;
8297
98+ const setGlobalCap = useCallback (
99+ async ( next : number | null ) => {
100+ setBusy ( "cap" ) ;
101+ setCap ( next ) ; // optimistic; reconciled on the next poll
102+ try {
103+ await fetch ( "/api/observatory/throttle" , {
104+ method : "POST" ,
105+ headers : { "Content-Type" : "application/json" } ,
106+ body : JSON . stringify ( { scope : "global" , max_concurrent : next } ) ,
107+ } ) ;
108+ await load ( { silent : true } ) ;
109+ } catch {
110+ await load ( { silent : true } ) ;
111+ } finally {
112+ setBusy ( null ) ;
113+ }
114+ } ,
115+ [ load ] ,
116+ ) ;
117+
83118 return (
84119 < div className = "flex h-full flex-col overflow-hidden bg-shell-bg" >
85120 { /* Header + global steer */ }
@@ -113,6 +148,52 @@ export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
113148 </ div >
114149 ) }
115150
151+ { /* Steer: global concurrency cap (volume knob alongside the pause switch) */ }
152+ < div className = "flex items-center gap-3 border-b border-shell-border px-5 py-2.5" >
153+ < span className = "text-xs font-medium uppercase tracking-wide text-shell-text-tertiary" >
154+ Concurrency cap
155+ </ span >
156+ < div className = "flex items-center gap-1.5" >
157+ < button
158+ type = "button"
159+ onClick = { ( ) => setGlobalCap ( cap && cap > 1 ? cap - 1 : null ) }
160+ disabled = { busy === "cap" || cap == null }
161+ aria-label = "Lower concurrency cap"
162+ className = "flex h-7 w-7 items-center justify-center rounded-md border border-shell-border text-shell-text-secondary transition-colors hover:text-shell-text hover:border-shell-border-strong disabled:cursor-not-allowed disabled:opacity-40"
163+ >
164+ < Minus size = { 14 } />
165+ </ button >
166+ < span
167+ className = "min-w-[3.5rem] text-center text-sm font-medium text-shell-text tabular-nums"
168+ aria-label = "Concurrency cap value"
169+ >
170+ { cap == null ? "No cap" : cap }
171+ </ span >
172+ < button
173+ type = "button"
174+ onClick = { ( ) => setGlobalCap ( ( cap ?? 0 ) + 1 ) }
175+ disabled = { busy === "cap" }
176+ aria-label = "Raise concurrency cap"
177+ className = "flex h-7 w-7 items-center justify-center rounded-md border border-shell-border text-shell-text-secondary transition-colors hover:text-shell-text hover:border-shell-border-strong disabled:cursor-not-allowed disabled:opacity-40"
178+ >
179+ < Plus size = { 14 } />
180+ </ button >
181+ </ div >
182+ { cap != null && (
183+ < button
184+ type = "button"
185+ onClick = { ( ) => setGlobalCap ( null ) }
186+ disabled = { busy === "cap" }
187+ className = "text-xs text-shell-text-tertiary transition-colors hover:text-shell-text"
188+ >
189+ Clear
190+ </ button >
191+ ) }
192+ < span className = "ml-auto text-xs text-shell-text-tertiary" >
193+ Max cards the fleet holds at once
194+ </ span >
195+ </ div >
196+
116197 { /* Fleet (Observe) */ }
117198 < div className = "flex-1 overflow-y-auto px-5 py-4" >
118199 < h2 className = "mb-3 text-xs font-medium uppercase tracking-wide text-shell-text-tertiary" >
0 commit comments