Skip to content
Merged
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
58 changes: 58 additions & 0 deletions app/src-tauri/src/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,58 @@ pub struct AgentEventEnvelope {
pub event: agent_stream::Event,
}

/// Payload emitted on the `"agent-started"` Tauri event the moment a spawned
/// agent is wired up for streaming (i.e. attached to its reviewed object).
///
/// The frontend derives the agent fleet from the reviews/plans it already holds;
/// this event is the push signal that a new agent exists so the roster and fleet
/// strip refresh without polling. Hand-typed on the frontend (no `ts-rs`), same
/// as the sibling `"agent-completed"` payload. `mode` serializes to the same
/// bare string as the ts-rs `AgentMode` union, so the frontend types it as
/// `AgentMode`.
#[derive(Debug, Clone, serde::Serialize)]
pub struct AgentStartedPayload {
/// UI key of the object the agent runs on: a review's PR ref, or a project
/// id for plan agents.
pub object_id: String,
/// Which mode the spawned agent runs in.
pub mode: AgentMode,
/// OS process id of the spawned agent.
pub pid: u32,
/// Wall-clock spawn time in epoch milliseconds.
pub started_at_epoch_ms: u64,
/// The model override the agent runs on, or `None` for the CLI default.
pub model: Option<String>,
}

/// Emit an [`AgentStartedPayload`] on the `"agent-started"` channel.
///
/// Best-effort: if no frontend window is listening, the event is dropped. Fired
/// once per spawn from [`start_stream_forwarding`] — the single choke point every
/// spawned agent passes through — so every mode (Fix / Review / Plan / Implement
/// / Restack) is covered exactly once.
fn emit_agent_started(
app_handle: &tauri::AppHandle,
object_id: &str,
mode: AgentMode,
run: &cockpit_core::model::AgentRun,
) {
use tauri::Emitter;
let started_at_epoch_ms = run
.started_at
.duration_since(UNIX_EPOCH)
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0);
let payload = AgentStartedPayload {
object_id: object_id.to_string(),
mode,
pid: run.pid,
started_at_epoch_ms,
model: run.model.clone(),
};
let _ = app_handle.emit("agent-started", &payload);
}

/// Emit a parsed agent [`Event`](agent_stream::Event) wrapped in an
/// [`AgentEventEnvelope`] on the `"agent-event"` channel, keyed by `object_id`.
///
Expand Down Expand Up @@ -262,6 +314,12 @@ pub fn start_stream_forwarding(
let agent_run = spawn_result.run.clone();
let log_path = spawn_result.log_path.clone();

// Push the "a new agent exists" signal to the frontend at the moment the run
// is wired up, so the fleet roster/strip surface it without polling. The
// caller attaches this run to its reviewed object synchronously right after
// this returns, so a listener's refetch observes the attached agent.
emit_agent_started(&app_handle, &ctx.object_id, ctx.mode, &agent_run);

tauri::async_runtime::spawn(async move {
let (saw_error, accumulator) = stream_agent_output(
&mut spawn_result.child,
Expand Down
83 changes: 81 additions & 2 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { ReviewWorkspace } from "./components/ReviewWorkspace";
import { PlanView } from "./components/PlanView";
import { NewProjectView } from "./components/NewProjectView";
import { SkillsView } from "./components/SkillsView";
import { AgentEditor } from "./components/AgentEditor";
import { AgentsView } from "./components/AgentsView";
import { FleetStrip } from "./components/FleetStrip";
import { SettingsView } from "./components/SettingsView";
import { PermissionBanner } from "./components/PermissionBanner";
import { CommandPalette } from "./components/CommandPalette";
Expand All @@ -41,11 +42,14 @@ import {
import { cn } from "@/lib/utils";
import { buildBoardItems } from "./lib/stack-tree";
import { isFastLane } from "./lib/attention";
import { collectFleetReviews } from "./lib/fleet";
import { agentActionLine } from "./lib/agent-event";
import type { CardDensity } from "./components/ReviewCard";
import type { GateState } from "./bindings/GateState";
import type { Review } from "./bindings/Review";
import type { Project } from "./bindings/Project";
import type { AgentMode } from "./bindings/AgentMode";
import type { Event as AgentStreamEvent } from "./bindings/Event";

/** Payload emitted by the Tauri backend on "agent-completed" events. */
interface CompletionEventPayload {
Expand All @@ -61,6 +65,21 @@ interface CompletionEventPayload {
readonly outcome?: "reworked" | "failed" | "completed";
}

/** Payload emitted by the backend on `"agent-started"` (hand-typed, no ts-rs). */
interface AgentStartedPayload {
readonly object_id: string;
readonly mode: AgentMode;
readonly pid: number;
readonly started_at_epoch_ms: number;
readonly model: string | null;
}

/** Envelope wrapping an agent stream event with its object key (`"agent-event"`). */
interface AgentEventEnvelope {
readonly object_id: string;
readonly event: AgentStreamEvent;
}

type ReviewTab = "my-prs" | "review-requests" | "all";

const SIDEBAR_COLLAPSED_KEY = "cockpit-sidebar-collapsed";
Expand Down Expand Up @@ -363,6 +382,16 @@ function App() {
const settledLocally =
isReviewMode(mode) && alreadySettledLocally(object_id);

// Record the completion into the session-observed "Recent" buffer the
// fleet roster reads (see RecentCompletion for why this is session-based).
useAppStore.getState().recordCompletion({
objectId: object_id,
kind: isReviewMode(mode) ? "review" : "plan",
mode,
outcome: outcome ?? null,
endedAtEpochMs: Date.now(),
});

void fetchReviews();
void listProjects();
// Refresh the open plan (if any) so a completed Plan agent updates it.
Expand Down Expand Up @@ -408,6 +437,36 @@ function App() {
})();
});

// Fleet: a new agent was spawned somewhere (pre-review / rework / restack /
// plan / batch). Seed an initial action line so its roster row shows
// something immediately, then refresh the lists/plans so the newly-attached
// agent surfaces in the roster and strip without polling.
const unlistenAgentStarted = listen<AgentStartedPayload>(
"agent-started",
(event) => {
const { object_id, started_at_epoch_ms } = event.payload;
useAppStore
.getState()
.recordAgentAction(object_id, "Starting…", started_at_epoch_ms);
void fetchReviews();
void listProjects();
},
);

// Fleet: one global agent-event listener multiplexes every object's stream
// into the `agentActivity` slice (the roster's "last action"). This is
// separate from the per-review AgentPanel's own listener, which keeps its
// full timeline buffers unchanged.
const unlistenAgentEvent = listen<AgentEventEnvelope>(
"agent-event",
(tauriEvent) => {
const { object_id, event } = tauriEvent.payload;
useAppStore
.getState()
.recordAgentAction(object_id, agentActionLine(event), Date.now());
},
);

// D4: the notify poller emits `board-updated` (empty payload) when it detects
// external changes (new review requests, CI going green, new commits).
// Refresh the review lists so the board reflects them. The backend owns the
Expand Down Expand Up @@ -461,6 +520,12 @@ function App() {
void unlisten.then((f) => {
f();
});
void unlistenAgentStarted.then((f) => {
f();
});
void unlistenAgentEvent.then((f) => {
f();
});
void unlistenBoard.then((f) => {
f();
});
Expand Down Expand Up @@ -512,6 +577,15 @@ function App() {
[stateFilter, showStale, searchQuery],
);

// Live count of running agents for the sidebar badge and empty-state logic.
// Derived (no polling) from the same lists the fleet roster reads.
const agentCount = useMemo(() => {
const merged = collectFleetReviews(reviews, authoredPrs, reviewRequests);
const reviewAgents = merged.filter((r) => r.agent !== null).length;
const planAgents = projects.filter((p) => p.plan?.agent != null).length;
return reviewAgents + planAgents;
}, [reviews, authoredPrs, reviewRequests, projects]);

const reviewsForTab: readonly Review[] = useMemo(() => {
switch (reviewTab) {
case "my-prs":
Expand Down Expand Up @@ -971,7 +1045,7 @@ function App() {
return <SkillsView />;

case "agents":
return <AgentEditor />;
return <AgentsView />;

case "settings":
return <SettingsView />;
Expand All @@ -987,13 +1061,18 @@ function App() {
<Sidebar
activeView={view.kind}
reviewCount={reviews.length}
agentCount={agentCount}
onNavigate={handleNavigate}
collapsed={sidebarCollapsed}
onToggleCollapse={toggleSidebar}
/>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<PermissionBanner />
<main className="flex-1 overflow-y-auto">{renderContent()}</main>
{/* The fleet strip lives outside the view switch so it is visible on
every view (including inside the review workspace); it hides itself
entirely when no agent is running. */}
<FleetStrip />
</div>
</div>
<CommandPalette
Expand Down
12 changes: 11 additions & 1 deletion app/src/bindings/AgentRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,14 @@ prompt_hash: string,
/**
* Path to the agent's log file.
*/
log_path: string, };
log_path: string,
/**
* The model this agent was spawned with, when an explicit override was
* applied at spawn time (e.g. [`crate::config::Config::review_model`]);
* `None` when the agent runs on the CLI's default model.
*
* Recorded from the [`crate::adapters::agent::SpawnConfig`] so the fleet
* view can show what each running agent is using. `#[serde(default)]` keeps
* older persisted runs (which predate this field) loadable as `None`.
*/
model: string | null, };
Loading
Loading