diff --git a/README.md b/README.md index d4a879528..a0315902b 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,18 @@ Reviewer agents are configured separately. The current reviewer harnesses are: **If it runs in a terminal, it runs on Agent Orchestrator.** -## Download +## Install -Download the latest desktop build for your platform: +The fastest path is the same flow used by the installation docs: + +```bash +npm install -g @aoagents/ao +ao start +``` + +Run `ao start` from the repository you want AO to manage. See the [installation guide](https://aoagents.dev/docs/installation) for pnpm, yarn, source installs, agent CLI setup, and troubleshooting. + +You can also download the latest desktop build for your platform: | Platform | Download | | -------- | ------------------------------------------------------------------------------------------------- | @@ -120,17 +129,17 @@ Download the latest desktop build for your platform: - Agent Orchestrator journey screenshot one + Agent Orchestrator journey screenshot one - Agent Orchestrator journey screenshot two + Agent Orchestrator journey screenshot two - Agent Orchestrator journey screenshot three + Agent Orchestrator journey screenshot three @@ -148,7 +157,7 @@ Download the latest desktop build for your platform: ## Telemetry -Agent Orchestrator collects minimal telemetry for reliability and product understanding. Data is stored locally by default; remote transmission is opt-in through environment variables. See [docs/telemetry.md](docs/telemetry.md). +Agent Orchestrator's Electron renderer sends anonymous usage events to PostHog for reliability and product understanding, and PostHog session recording is enabled with local paths and local URLs redacted before transmission. Set `VITE_AO_POSTHOG_KEY` to an empty string before building to disable transmission. See [docs/telemetry.md](docs/telemetry.md). ## License diff --git a/backend/internal/adapters/agent/kiro/hooks.go b/backend/internal/adapters/agent/kiro/hooks.go index 04dc96a01..018870950 100644 --- a/backend/internal/adapters/agent/kiro/hooks.go +++ b/backend/internal/adapters/agent/kiro/hooks.go @@ -27,13 +27,18 @@ const ( // skips duplicates and uninstall recognizes AO entries by prefix without an // embedded template to diff against. kiroHookCommandPrefix = "ao hooks kiro " + + kiroAgentName = "ao" + kiroAgentDescription = "Agent Orchestrator session instructions" ) // kiroHookFile is the on-disk shape of .kiro/agents/ao.json. It is used by // tests to decode the written file. Kiro hooks are a map of camelCase event // name to a flat array of {matcher?, command} entries. type kiroHookFile struct { - Hooks map[string][]kiroHookEntry `json:"hooks"` + Name string `json:"name"` + Prompt *string `json:"prompt"` + Hooks map[string][]kiroHookEntry `json:"hooks"` } type kiroHookEntry struct { @@ -97,7 +102,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi } } - if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil { + if err := writeKiroHooks(hooksPath, topLevel, rawHooks, cfg.SystemPrompt, cfg.Config); err != nil { return fmt.Errorf("kiro.GetAgentHooks: %w", err) } if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), kiroAgentFileName); err != nil { @@ -137,7 +142,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error } } - if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil { + if err := writeKiroHooks(hooksPath, topLevel, rawHooks, "", ports.AgentConfig{}); err != nil { return fmt.Errorf("kiro.UninstallHooks: %w", err) } return nil @@ -210,7 +215,11 @@ func readKiroHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMess // writeKiroHooks folds rawHooks back into topLevel and writes the file. An // empty hooks map drops the "hooks" key entirely. -func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { +func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error { + if err := setKiroAgentDefaults(topLevel, systemPrompt, agentConfig); err != nil { + return err + } + if len(rawHooks) == 0 { delete(topLevel, "hooks") } else { @@ -235,6 +244,44 @@ func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMess return nil } +func setKiroAgentDefaults(topLevel map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error { + defaults := map[string]any{ + "name": kiroAgentName, + "description": kiroAgentDescription, + "prompt": nil, + "mcpServers": map[string]any{}, + "tools": []string{"*"}, + "toolAliases": map[string]any{}, + "allowedTools": []any{}, + "resources": []any{}, + "toolsSettings": map[string]any{}, + "includeMcpJson": true, + } + if systemPrompt != "" { + defaults["prompt"] = systemPrompt + } + if model := strings.TrimSpace(agentConfig.Model); model != "" { + defaults["model"] = model + } else { + delete(topLevel, "model") + } + + for key, value := range defaults { + managedKey := key == "name" || key == "prompt" || key == "model" + if !managedKey { + if _, ok := topLevel[key]; ok { + continue + } + } + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("encode agent %s: %w", key, err) + } + topLevel[key] = data + } + return nil +} + // groupKiroHooksByEvent groups the managed hook specs by their Kiro event so // each event's array is rewritten once. func groupKiroHooksByEvent() map[string][]kiroHookSpec { diff --git a/backend/internal/adapters/agent/kiro/kiro.go b/backend/internal/adapters/agent/kiro/kiro.go index 5f54d73f0..0e386efcd 100644 --- a/backend/internal/adapters/agent/kiro/kiro.go +++ b/backend/internal/adapters/agent/kiro/kiro.go @@ -3,9 +3,9 @@ // and reading hook-derived session info. // // Kiro is AWS's agentic coding assistant. Its terminal CLI ships as the -// `kiro-cli` binary and exposes a non-interactive ("headless") mode via -// `kiro-cli chat --no-interactive ""`, suitable for AO-driven worker -// sessions. See https://kiro.dev/docs/cli/headless/ and +// `kiro-cli` binary. AO launches Kiro with a workspace-local custom agent so +// both worker and orchestrator sessions can use Kiro's normal interactive +// approval flow. See https://kiro.dev/docs/cli/headless/ and // https://kiro.dev/docs/cli/reference/cli-commands/. // // Launch delivers the initial prompt as a positional argument after `--` so a @@ -26,6 +26,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -58,29 +59,47 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetLaunchCommand builds the argv to start a new headless Kiro session: -// `kiro-cli chat --no-interactive [trust flags] -- `. +// GetLaunchCommand builds the argv to start a new Kiro session: +// `kiro-cli chat --agent ao [trust flags] [-- ]`. // // The prompt is passed as a positional argument after `--` so a leading "-" is -// not read as a flag. Kiro's --no-interactive mode requires a prompt argument. +// not read as a flag. Kiro runs interactively for both workers and orchestrators; +// standing instructions come from the generated custom agent. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.kiroBinary(ctx) if err != nil { return nil, err } - cmd = []string{binary, "chat", "--no-interactive"} + cmd = []string{binary, "chat", "--agent", kiroAgentName} appendApprovalFlags(&cmd, cfg.Permissions) - if cfg.Prompt != "" { - cmd = append(cmd, "--", cfg.Prompt) + prompt := cfg.Prompt + if prompt != "" { + cmd = append(cmd, "--", prompt) } return cmd, nil } -// GetRestoreCommand rebuilds the argv that continues an existing Kiro session: -// `kiro-cli chat --no-interactive --resume-id [trust flags]`. +// GetPromptDeliveryStrategy reports how Kiro receives the initial task prompt. +// Orchestrator standing instructions are delivered through the generated +// custom-agent prompt, so no command or post-start prompt injection is needed +// there. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + if cfg.Prompt != "" { + return ports.PromptDeliveryInCommand, nil + } + if cfg.Kind == domain.KindOrchestrator { + return ports.PromptDeliveryCustomAgent, nil + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Kiro session. // ok is false when the hook-derived native session id has not landed yet, so // callers can fall back to fresh launch behavior. func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { @@ -97,8 +116,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return nil, false, err } - cmd = make([]string, 0, 8) - cmd = append(cmd, binary, "chat", "--no-interactive", "--resume-id", agentSessionID) + cmd = []string{binary, "chat", "--agent", kiroAgentName, "--resume-id", agentSessionID} appendApprovalFlags(&cmd, cfg.Permissions) return cmd, true, nil } @@ -152,14 +170,14 @@ func (p *Plugin) kiroBinary(ctx context.Context) (string, error) { return binary, nil } -// appendApprovalFlags maps AO's 4 permission modes onto Kiro's tool-trust -// flags. Default emits no flag so Kiro defers to the user's own configuration -// (the interactive per-tool prompt). accept-edits grants the write-capable -// built-in tools; auto/bypass grant all tools. +// appendApprovalFlags maps AO's permission modes onto Kiro's tool-trust flags. +// Default emits no flag so Kiro uses its normal interactive approval flow. +// accept-edits grants the write-capable built-in tools; auto/bypass grant all +// tools. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: - // No flag: defer to the user's Kiro config / per-tool prompting. + // No flag: defer to Kiro's normal interactive approval flow. case ports.PermissionModeAcceptEdits: *cmd = append(*cmd, "--trust-tools=fs_read,fs_write") case ports.PermissionModeAuto: diff --git a/backend/internal/adapters/agent/kiro/kiro_test.go b/backend/internal/adapters/agent/kiro/kiro_test.go index 6e4f437fd..e891ed4ef 100644 --- a/backend/internal/adapters/agent/kiro/kiro_test.go +++ b/backend/internal/adapters/agent/kiro/kiro_test.go @@ -11,6 +11,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -27,7 +28,7 @@ func TestManifestIDIsKiro(t *testing.T) { } } -func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) { +func TestGetLaunchCommandBuildsInteractiveArgv(t *testing.T) { plugin := &Plugin{resolvedBinary: "kiro-cli"} cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ @@ -39,7 +40,8 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) { } want := []string{ - "kiro-cli", "chat", "--no-interactive", + "kiro-cli", "chat", + "--agent", "ao", "--trust-all-tools", "--", "-fix this", } @@ -48,6 +50,64 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) { } } +func TestGetLaunchCommandOrchestratorUsesInteractiveAgent(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Kind: domain.KindOrchestrator, + SystemPrompt: "You are the human-facing coordinator.", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "kiro-cli", "chat", + "--agent", "ao", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandPromptlessWorkerStaysInteractive(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "kiro-cli", "chat", + "--agent", "ao", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandPromptTakesPrecedenceOverSystemPrompt(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "fix the failing test", + SystemPrompt: "standing role instructions", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "kiro-cli", "chat", + "--agent", "ao", + "--", "fix the failing test", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string @@ -124,6 +184,33 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } } +func TestGetPromptDeliveryStrategyOrchestratorUsesCustomAgent(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{Kind: domain.KindOrchestrator}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryCustomAgent { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetPromptDeliveryStrategyPromptedOrchestratorUsesCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{ + Kind: domain.KindOrchestrator, + Prompt: "do this explicit task", + }) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { plugin := &Plugin{} @@ -182,7 +269,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) { t.Fatal(err) } hooksPath := filepath.Join(hooksDir, kiroAgentFileName) - existing := `{"name":"ao","hooks":{"stop":[{"command":"custom stop hook"}]}}` + existing := `{"name":"stale","hooks":{"stop":[{"command":"custom stop hook"}]}}` if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } @@ -190,6 +277,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) { cfg := ports.WorkspaceHookConfig{ DataDir: t.TempDir(), SessionID: "sess-1", + SystemPrompt: "standing AO instructions", WorkspacePath: workspace, } if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { @@ -204,13 +292,23 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) { if err != nil { t.Fatal(err) } - // The unmanaged top-level "name" key must be preserved. var topLevel map[string]json.RawMessage if err := json.Unmarshal(data, &topLevel); err != nil { t.Fatal(err) } - if _, ok := topLevel["name"]; !ok { - t.Fatalf("unmanaged top-level key 'name' was dropped: %s", data) + var name string + if err := json.Unmarshal(topLevel["name"], &name); err != nil { + t.Fatalf("decode name from %s: %v", data, err) + } + if name != kiroAgentName { + t.Fatalf("name = %q, want %q", name, kiroAgentName) + } + var prompt string + if err := json.Unmarshal(topLevel["prompt"], &prompt); err != nil { + t.Fatalf("decode prompt from %s: %v", data, err) + } + if prompt != "standing AO instructions" { + t.Fatalf("prompt = %q, want system prompt", prompt) } var config kiroHookFile @@ -232,6 +330,167 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) { } } +func TestGetAgentHooksCreatesNamedKiroAgentFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + workspace := t.TempDir() + hooksPath := kiroAgentPath(workspace) + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + SystemPrompt: "exact orchestrator system prompt", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var topLevel map[string]json.RawMessage + if err := json.Unmarshal(data, &topLevel); err != nil { + t.Fatal(err) + } + var name string + if err := json.Unmarshal(topLevel["name"], &name); err != nil { + t.Fatalf("decode name from %s: %v", data, err) + } + if name != kiroAgentName { + t.Fatalf("name = %q, want %q", name, kiroAgentName) + } + var config kiroHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if config.Prompt == nil || *config.Prompt != "exact orchestrator system prompt" { + t.Fatalf("prompt = %#v, want exact system prompt", config.Prompt) + } +} + +func TestGetAgentHooksWritesConfiguredModel(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + workspace := t.TempDir() + hooksPath := kiroAgentPath(workspace) + + cfg := ports.WorkspaceHookConfig{ + Config: ports.AgentConfig{Model: "claude-sonnet-4-5"}, + DataDir: t.TempDir(), + SessionID: "sess-1", + SystemPrompt: "standing AO instructions", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var topLevel map[string]json.RawMessage + if err := json.Unmarshal(data, &topLevel); err != nil { + t.Fatal(err) + } + var model string + if err := json.Unmarshal(topLevel["model"], &model); err != nil { + t.Fatalf("decode model from %s: %v", data, err) + } + if model != "claude-sonnet-4-5" { + t.Fatalf("model = %q, want configured model", model) + } +} + +func TestGetAgentHooksOverwritesStaleConfiguredModel(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + workspace := t.TempDir() + hooksPath := kiroAgentPath(workspace) + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"name":"ao","model":"stale-model","tools":["custom"]}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + Config: ports.AgentConfig{Model: "project-model"}, + DataDir: t.TempDir(), + SessionID: "sess-1", + SystemPrompt: "standing AO instructions", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var topLevel map[string]json.RawMessage + if err := json.Unmarshal(data, &topLevel); err != nil { + t.Fatal(err) + } + var model string + if err := json.Unmarshal(topLevel["model"], &model); err != nil { + t.Fatalf("decode model from %s: %v", data, err) + } + if model != "project-model" { + t.Fatalf("model = %q, want project override", model) + } + var tools []string + if err := json.Unmarshal(topLevel["tools"], &tools); err != nil { + t.Fatalf("decode tools from %s: %v", data, err) + } + if !reflect.DeepEqual(tools, []string{"custom"}) { + t.Fatalf("tools = %#v, want preserved custom tools", tools) + } +} + +func TestGetAgentHooksClearsStaleModelWhenConfigRemoved(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + workspace := t.TempDir() + hooksPath := kiroAgentPath(workspace) + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"name":"ao","model":"stale-model","tools":["custom"]}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + SystemPrompt: "standing AO instructions", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var topLevel map[string]json.RawMessage + if err := json.Unmarshal(data, &topLevel); err != nil { + t.Fatal(err) + } + if _, ok := topLevel["model"]; ok { + t.Fatalf("model still present in %s, want cleared when config has no model", data) + } + var tools []string + if err := json.Unmarshal(topLevel["tools"], &tools); err != nil { + t.Fatalf("decode tools from %s: %v", data, err) + } + if !reflect.DeepEqual(tools, []string{"custom"}) { + t.Fatalf("tools = %#v, want preserved custom tools", tools) + } +} + func TestUninstallHooksRemovesKiroHooks(t *testing.T) { plugin := &Plugin{resolvedBinary: "kiro-cli"} workspace := t.TempDir() @@ -309,7 +568,8 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { t.Fatal("ok = false, want true") } want := []string{ - "kiro-cli", "chat", "--no-interactive", + "kiro-cli", "chat", + "--agent", "ao", "--resume-id", "uuid-123", "--trust-all-tools", } @@ -318,6 +578,32 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { } } +func TestGetRestoreCommandOrchestratorUsesInteractiveAgent(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Kind: domain.KindOrchestrator, + Permissions: ports.PermissionModeDefault, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "uuid-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "kiro-cli", "chat", + "--agent", "ao", + "--resume-id", "uuid-123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { plugin := &Plugin{resolvedBinary: "kiro-cli"} diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index 00859b7eb..5f1b342ca 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -130,6 +130,7 @@ const ( type LaunchConfig struct { Config AgentConfig IssueID string + Kind domain.SessionKind Permissions PermissionMode Prompt string SessionID string @@ -151,12 +152,14 @@ type WorkspaceHookConfig struct { Config AgentConfig DataDir string SessionID string + SystemPrompt string WorkspacePath string } // RestoreConfig carries inputs needed to continue an existing native agent session. type RestoreConfig struct { Config AgentConfig + Kind domain.SessionKind Permissions PermissionMode Session SessionRef // SystemPrompt carries the session's standing instructions (e.g. the @@ -217,6 +220,7 @@ type PromptDeliveryStrategy string // How the orchestrator hands the initial prompt to a freshly launched agent. const ( - PromptDeliveryInCommand PromptDeliveryStrategy = "in_command" - PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start" + PromptDeliveryInCommand PromptDeliveryStrategy = "in_command" + PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start" + PromptDeliveryCustomAgent PromptDeliveryStrategy = "custom_agent" ) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 11da9edd8..d55da460c 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -256,15 +256,16 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness) } - if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { + agentConfig := effectiveAgentConfig(cfg.Kind, project.Config) + if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil { _ = m.workspace.Destroy(ctx, ws) m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } - agentConfig := effectiveAgentConfig(cfg.Kind, project.Config) argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ SessionID: string(id), WorkspacePath: ws.Path, + Kind: cfg.Kind, Prompt: prompt, SystemPrompt: systemPrompt, IssueID: string(cfg.IssueID), @@ -575,9 +576,6 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if !ok { return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", id, rec.Harness) } - if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { - return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) - } // The system prompt is derived, not persisted: recompute it so a restored // session keeps its standing instructions across the relaunch. systemPrompt, err := m.buildSystemPrompt(ctx, rec.Kind, rec.ProjectID) @@ -586,7 +584,11 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess } // Restore re-applies the project's resolved agent config so a configured // model/permissions carry across a restore, matching fresh spawn. - argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, effectiveAgentConfig(rec.Kind, project.Config), rec.Kind) + agentConfig := effectiveAgentConfig(rec.Kind, project.Config) + if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil { + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) + } + argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, agentConfig, rec.Kind) if err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) } @@ -1297,11 +1299,13 @@ type preLauncher interface { // starts the agent: installing the workspace-local activity hooks (so early // startup hooks can update the already-created session row), then any optional // PreLaunch step. Shared by Spawn and Restore. -func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string) error { +func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath, systemPrompt string, agentConfig ports.AgentConfig) error { if err := agent.GetAgentHooks(ctx, ports.WorkspaceHookConfig{ SessionID: string(id), WorkspacePath: workspacePath, DataDir: m.dataDir, + SystemPrompt: systemPrompt, + Config: agentConfig, }); err != nil { return fmt.Errorf("install hooks: %w", err) } @@ -1326,7 +1330,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo WorkspacePath: workspacePath, Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID}, } - cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions}) + cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, Kind: kind, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions}) if err != nil { return nil, fmt.Errorf("restore command: %w", err) } @@ -1343,6 +1347,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ SessionID: string(id), WorkspacePath: workspacePath, + Kind: kind, Prompt: meta.Prompt, SystemPrompt: systemPrompt, Config: agentConfig, diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c408e41c9..334c6873a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -70,6 +70,14 @@ process.stderr.on("error", ignoreStdStreamError); // Must run before app ready so the About panel and default-menu role labels use it. app.setName("Agent Orchestrator"); +// Windows shows native toasts only when the app declares an AppUserModelID that +// matches its installer shortcut (the NSIS maker's appId). Without it, +// Notification.isSupported() still returns true but show() silently drops the +// toast, so notifications never appear. No-op on macOS/Linux. +if (process.platform === "win32") { + app.setAppUserModelId("dev.agent-orchestrator.desktop"); +} + // Pin ALL Electron-owned state (Chromium cache, cookies, local/session storage, // crash dumps) under the canonical AO home at ~/.ao instead of Electron's macOS // default ~/Library/Application Support/. Keeps the app's entire footprint @@ -478,6 +486,7 @@ async function refreshDaemonStatus(): Promise { setDaemonStatus({ state: "stopped", message: "AO daemon is no longer reachable.", + code: "daemon_unreachable", }); } return daemonStatus; @@ -518,6 +527,7 @@ async function startDaemonInner(startEpoch: number): Promise { setDaemonStatus({ state: "stopped", message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.", + code: "not_configured", }); return daemonStatus; } @@ -640,6 +650,7 @@ async function startDaemonInner(startEpoch: number): Promise { setDaemonStatus({ state: "error", message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`, + code: "binary_missing", }); return daemonStatus; } @@ -737,6 +748,7 @@ async function startDaemonInner(startEpoch: number): Promise { state: "ready", port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined, message: "Daemon port not confirmed from logs or running.json; assuming the configured port.", + code: "port_unconfirmed", }); }, PORT_DISCOVERY_TIMEOUT_MS); @@ -745,17 +757,29 @@ async function startDaemonInner(startEpoch: number): Promise { if (daemonProcess !== child) return; daemonProcess = null; if (daemonStoppingProcess === child) daemonStoppingProcess = null; - setDaemonStatus({ state: "error", message: error.message }); + setDaemonStatus({ state: "error", message: error.message, code: "spawn_failed" }); }); child.once("exit", (code, signal) => { stopDiscovery(); if (daemonProcess !== child) return; daemonProcess = null; - if (daemonStoppingProcess === child) daemonStoppingProcess = null; + // An explicit stopDaemon() already set a clean `{ state: "stopped" }`. + // daemon-telemetry reports any status carrying a `code` as + // ao.renderer.daemon_failure, so don't stamp `code: "exited"` on a stop + // the user or app asked for — that would count intentional stops as + // failures. Preserve the clean stopped status instead. + if (daemonStoppingProcess === child) { + daemonStoppingProcess = null; + setDaemonStatus({ state: "stopped" }); + return; + } setDaemonStatus({ state: "stopped", message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`, + code: "exited", + exitCode: code, + signal, }); }); diff --git a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx index 4411716be..88b7088cb 100644 --- a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx +++ b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx @@ -38,20 +38,10 @@ function respondWithProjectAndPRs() { projectId: "proj-1", displayName: "fix the bug", harness: "claude-code", - status: "pr_open", + status: "draft", isTerminated: false, updatedAt: "2026-06-10T16:15:04Z", prs: [ - { - number: 278, - state: "open", - url: "https://github.com/aoagents/ReverbCode/pull/278", - ci: "passing", - review: "approved", - mergeability: "clean", - reviewComments: false, - updatedAt: "2026-06-10T16:15:04Z", - }, { number: 279, state: "draft", @@ -62,6 +52,46 @@ function respondWithProjectAndPRs() { reviewComments: false, updatedAt: "2026-06-10T16:20:04Z", }, + { + number: 278, + state: "open", + url: "https://github.com/aoagents/ReverbCode/pull/278", + ci: "passing", + review: "review_required", + mergeability: "clean", + reviewComments: false, + updatedAt: "2026-06-10T16:15:04Z", + }, + { + number: 280, + state: "open", + url: "https://github.com/aoagents/ReverbCode/issues/280", + ci: "passing", + review: "approved", + mergeability: "clean", + reviewComments: false, + updatedAt: "2026-06-10T16:25:04Z", + }, + { + number: 281, + state: "merged", + url: "https://github.com/aoagents/ReverbCode/pull/281", + ci: "passing", + review: "approved", + mergeability: "mergeable", + reviewComments: false, + updatedAt: "2026-06-10T16:30:04Z", + }, + { + number: 282, + state: "closed", + url: "https://github.com/aoagents/ReverbCode/pull/282", + ci: "passing", + review: "approved", + mergeability: "unknown", + reviewComments: false, + updatedAt: "2026-06-10T16:35:04Z", + }, ], }, ], @@ -179,11 +209,40 @@ beforeEach(() => { }); describe("PR hydration for a normal project (#251)", () => { - it("renders every session PR on the Board card instead of 'no PR yet'", async () => { + it("renders Board card PR numbers with lifecycle statuses only instead of 'no PR yet'", async () => { renderWithProviders(); - expect(await screen.findByText("PR #278 · open")).toBeInTheDocument(); - expect(screen.getByText("PR #279 · draft")).toBeInTheDocument(); + expect(await screen.findByRole("link", { name: "#278" })).toHaveAttribute( + "href", + "https://github.com/aoagents/ReverbCode/pull/278", + ); + expect(screen.getByText("open")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "#279" })).toHaveAttribute( + "href", + "https://github.com/aoagents/ReverbCode/pull/279", + ); + expect(screen.getByRole("link", { name: "#280" })).toHaveAttribute( + "href", + "https://github.com/aoagents/ReverbCode/pull/280", + ); + expect(screen.getByRole("link", { name: "#281" })).toHaveAttribute( + "href", + "https://github.com/aoagents/ReverbCode/pull/281", + ); + expect(screen.getByRole("link", { name: "#282" })).toHaveAttribute( + "href", + "https://github.com/aoagents/ReverbCode/pull/282", + ); + expect(screen.getByLabelText("#278, #280 open")).toBeInTheDocument(); + expect(screen.getByLabelText("#279 draft")).toBeInTheDocument(); + expect(screen.getByLabelText("#281 merged")).toBeInTheDocument(); + expect(screen.getByLabelText("#282 closed")).toBeInTheDocument(); + expect(screen.getByText("draft")).toBeInTheDocument(); + expect(screen.getByText("merged")).toHaveClass("text-accent"); + expect(screen.getByText("closed")).toHaveClass("text-error"); + expect(screen.queryByText("review pending")).not.toBeInTheDocument(); + expect(screen.queryByText("CI")).not.toBeInTheDocument(); + expect(screen.queryByText("Needs attention")).not.toBeInTheDocument(); expect(screen.queryByText("no PR yet")).not.toBeInTheDocument(); }); @@ -191,14 +250,13 @@ describe("PR hydration for a normal project (#251)", () => { respondWithAttentionPR(); renderWithProviders(); - expect(await screen.findByRole("link", { name: "PR" })).toHaveAttribute( + expect(await screen.findByRole("link", { name: "#278" })).toHaveAttribute( "href", "https://github.com/aoagents/ReverbCode/pull/278", ); - expect(screen.getByRole("link", { name: "conflicts" })).toHaveAttribute( - "href", - "https://github.com/aoagents/ReverbCode/pull/278/conflicts", - ); + expect(screen.getByText("open")).toBeInTheDocument(); + expect(screen.queryByText("changes requested")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "conflicts" })).not.toBeInTheDocument(); }); it("lists every session PR on the PR page instead of being empty", async () => { @@ -206,7 +264,10 @@ describe("PR hydration for a normal project (#251)", () => { expect(await screen.findByText("#278")).toBeInTheDocument(); expect(screen.getByText("#279")).toBeInTheDocument(); + expect(screen.getByText("#280")).toBeInTheDocument(); + expect(screen.getByText("#281")).toBeInTheDocument(); + expect(screen.getByText("#282")).toBeInTheDocument(); expect(screen.queryByText("No open pull requests.")).not.toBeInTheDocument(); - expect(screen.getAllByText("fix the bug")).toHaveLength(2); + expect(screen.getAllByText("fix the bug")).toHaveLength(5); }); }); diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index 745ac9a72..a6959a954 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -58,6 +58,11 @@ export function CreateProjectAgentSheet({ ? agentsQuery.error.message : "Could not load agent catalog." : null; + const displayError = refreshAgentsMutation.isError + ? refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog." + : agentsError; const [workerAgent, setWorkerAgent] = useState(""); const [orchestratorAgent, setOrchestratorAgent] = useState(""); const [intake, setIntake] = useState(EMPTY_INTAKE); @@ -143,12 +148,13 @@ export function CreateProjectAgentSheet({ - {agentsError && ( + {displayError && (
- {agentsError} + {displayError}
)} - {refreshAgentsMutation.isError && ( -
- {refreshAgentsMutation.error instanceof Error - ? refreshAgentsMutation.error.message - : "Could not refresh agent catalog."} -
- )} -
setIntake((f) => ({ ...f, ...patch }))} compact />
diff --git a/frontend/src/renderer/components/NewTaskDialog.tsx b/frontend/src/renderer/components/NewTaskDialog.tsx index 80a9bcddc..a51f379d9 100644 --- a/frontend/src/renderer/components/NewTaskDialog.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -7,6 +7,7 @@ import { Input } from "./ui/input"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { captureRendererEvent } from "../lib/telemetry"; import type { AgentProvider } from "../types/workspace"; import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; @@ -88,6 +89,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT setIsSubmitting(true); setError(undefined); + void captureRendererEvent("ao.renderer.task_create_requested", { project_id: projectId }); try { const { data, error: apiError } = await apiClient.POST("/api/v1/sessions", { body: { @@ -101,9 +103,11 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT }); if (apiError) throw new Error(apiErrorMessage(apiError, "Unable to start task")); if (!data?.session?.id) throw new Error("Task creation returned no session"); + void captureRendererEvent("ao.renderer.task_create_succeeded", { project_id: projectId }); onCreated(data.session.id); onOpenChange(false); } catch (err) { + void captureRendererEvent("ao.renderer.task_create_failed", { project_id: projectId }); void queryClient.invalidateQueries({ queryKey: agentsQueryKey }); setError(err instanceof Error ? err.message : "Unable to start task"); } finally { diff --git a/frontend/src/renderer/components/NotificationCenter.tsx b/frontend/src/renderer/components/NotificationCenter.tsx index 582205261..02da22b80 100644 --- a/frontend/src/renderer/components/NotificationCenter.tsx +++ b/frontend/src/renderer/components/NotificationCenter.tsx @@ -10,6 +10,7 @@ import { import { aoBridge } from "../lib/bridge"; import { formatTimeCompact } from "../lib/format-time"; import { createNotificationsTransport, type NotificationDTO, unreadNotificationsQueryKey } from "../lib/notifications"; +import { captureRendererEvent } from "../lib/telemetry"; import { cn } from "../lib/utils"; import { DropdownMenu, @@ -37,11 +38,13 @@ export function NotificationCenter({ style }: NotificationCenterProps) { (notification: NotificationDTO) => { const target = notification.target; if (target.kind === "pr" && target.prUrl) { + void captureRendererEvent("ao.renderer.notification_opened", { target: "pr" }); window.open(target.prUrl, "_blank", "noopener,noreferrer"); return; } const sessionId = target.sessionId || notification.sessionId; if (!sessionId) return; + void captureRendererEvent("ao.renderer.notification_opened", { target: "session" }); if (notification.projectId) { void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -66,6 +69,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) { const markOneRead = async (id: string) => { setActionError(null); + void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "single" }); try { await markRead.mutateAsync(id); } catch (error) { @@ -75,6 +79,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) { const markAll = async () => { setActionError(null); + void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "all" }); try { await markAllRead.mutateAsync(); } catch (error) { diff --git a/frontend/src/renderer/components/PRSummaryDisplay.test.tsx b/frontend/src/renderer/components/PRSummaryDisplay.test.tsx new file mode 100644 index 000000000..cc183b174 --- /dev/null +++ b/frontend/src/renderer/components/PRSummaryDisplay.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { SessionPRSummary } from "../hooks/useSessionScmSummary"; +import { PRSummaryParts } from "./PRSummaryDisplay"; + +const summary = (overrides: Partial = {}): SessionPRSummary => ({ + url: "https://github.com/acme/repo/pull/7", + htmlUrl: "https://github.com/acme/repo/pull/7", + number: 7, + title: "Fix dashboard", + state: "open", + provider: "github", + repo: "acme/repo", + author: "ada", + sourceBranch: "fix/dashboard", + targetBranch: "main", + headSha: "abc123", + additions: 10, + deletions: 3, + changedFiles: 2, + ci: { state: "passing", failingChecks: [] }, + review: { decision: "approved", hasUnresolvedHumanComments: false, unresolvedBy: [] }, + mergeability: { state: "mergeable", reasons: [], prUrl: "https://github.com/acme/repo/pull/7" }, + updatedAt: "2026-06-15T00:00:00Z", + observedAt: "2026-06-15T00:00:00Z", + ciObservedAt: "2026-06-15T00:00:00Z", + reviewObservedAt: "2026-06-15T00:00:00Z", + ...overrides, +}); + +describe("PRSummaryParts", () => { + it("counts overflow from the rendered maxLinks limit", () => { + render( + , + ); + + expect(screen.getByText("unit")).toBeInTheDocument(); + expect(screen.getByText("lint")).toBeInTheDocument(); + expect(screen.queryByText("types")).not.toBeInTheDocument(); + expect(screen.getByText("+1 check")).toBeInTheDocument(); + }); + + it("counts overflow beyond helper-truncated links", () => { + render( + , + ); + + expect(screen.getByText("unit")).toBeInTheDocument(); + expect(screen.getByText("lint")).toBeInTheDocument(); + expect(screen.getByText("types")).toBeInTheDocument(); + expect(screen.queryByText("build")).not.toBeInTheDocument(); + expect(screen.getByText("+1 check")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/PRSummaryDisplay.tsx b/frontend/src/renderer/components/PRSummaryDisplay.tsx index 3eb54f154..bf45f5fc4 100644 --- a/frontend/src/renderer/components/PRSummaryDisplay.tsx +++ b/frontend/src/renderer/components/PRSummaryDisplay.tsx @@ -1,7 +1,7 @@ import { ArrowUpDown, ArrowUpRight } from "lucide-react"; import { Fragment, type ReactNode } from "react"; import type { SessionPRSummary } from "../hooks/useSessionScmSummary"; -import { prAttentionItems, prStatusRows, type PRAttentionLink, type PRDisplayTone } from "../lib/pr-display"; +import { prSummaryParts, type PRDisplayTone, type PRSummaryLink } from "../lib/pr-display"; import { cn } from "../lib/utils"; const toneClass: Record = { @@ -12,20 +12,6 @@ const toneClass: Record = { error: "text-error", }; -export function PRStatusStrip({ className, pr }: { className?: string; pr: SessionPRSummary }) { - return ( -
- {prStatusRows(pr).map((row) => ( - - {row.label}{" "} - {row.value} - {row.detail ? · {row.detail} : null} - - ))} -
- ); -} - export function PRSummaryMeta({ className, leading, @@ -85,56 +71,66 @@ function PRDiffMeta({ pr }: { pr: SessionPRSummary }) { ); } -export function PRAttentionPanel({ +export function PRSummaryParts({ className, interactiveLinks = true, - maxItems = 3, + maxLinks = 3, pr, + variant = "compact", }: { className?: string; interactiveLinks?: boolean; - maxItems?: number; + maxLinks?: number; pr: SessionPRSummary; + variant?: "compact" | "stacked"; }) { - const items = prAttentionItems(pr); - if (items.length === 0) { - return null; - } - const visible = items.slice(0, maxItems); - const extra = items.length - visible.length; + const parts = prSummaryParts(pr); + const stacked = variant === "stacked"; return ( -
-
- Needs attention -
-
- {visible.map((item) => ( -
-
{item.title}
- {item.summary ? ( -
{item.summary}
- ) : null} - {item.links.length > 0 ? ( -
- {item.links.map((link, index) => ( - +
+ {parts.map((part) => { + const links = part.links.slice(0, maxLinks); + const overflowLabel = overflowPartLabel( + (part.linkTotal ?? part.links.length) - links.length, + part.overflowNoun, + ); + return ( +
+
+ {part.label}{" "} + {part.status} + {part.summary ? · {part.summary} : null} +
+ {links.length > 0 || overflowLabel ? ( +
+ {links.map((link, index) => ( + ))} - {item.overflowLabel ? {item.overflowLabel} : null} + {overflowLabel ? {overflowLabel} : null}
) : null}
- ))} - {extra > 0 ?
+{extra} more
: null} -
+ ); + })}
); } -function AttentionLink({ interactive, link }: { interactive: boolean; link: PRAttentionLink }) { +function overflowPartLabel(extra: number, noun?: string): string | undefined { + if (extra <= 0) { + return undefined; + } + return noun ? `+${extra} ${pluralize(noun, extra)}` : `+${extra}`; +} + +function SummaryLink({ interactive, link }: { interactive: boolean; link: PRSummaryLink }) { if (interactive && link.href) { return ( { + void captureRendererEvent("ao.renderer.settings_save_requested", { project_id: projectId }); // PUT replaces the whole config; merge the edited fields over what loaded // so we don't drop env/symlinks/postCreate the form doesn't expose. const next: ProjectConfig = { @@ -146,7 +148,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje (activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent) ) { try { - await spawnOrchestrator(projectId, true); + await spawnOrchestrator(projectId, "settings", true); } catch (error) { return { replacementError: error instanceof Error ? error.message : "Could not replace orchestrator", @@ -156,12 +158,16 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje return { replacementError: null }; }, onSuccess: (result) => { + void captureRendererEvent("ao.renderer.settings_save_succeeded", { project_id: projectId }); setSavedAt(Date.now()); setReplacementError(result.replacementError); setValidationError(null); void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); onSaved(); }, + onError: () => { + void captureRendererEvent("ao.renderer.settings_save_failed", { project_id: projectId }); + }, }); return ( diff --git a/frontend/src/renderer/components/PullRequestsPage.tsx b/frontend/src/renderer/components/PullRequestsPage.tsx index 8bc76ab49..062c11b19 100644 --- a/frontend/src/renderer/components/PullRequestsPage.tsx +++ b/frontend/src/renderer/components/PullRequestsPage.tsx @@ -13,7 +13,7 @@ import type { WorkspaceSession } from "../types/workspace"; import { DashboardSubhead } from "./DashboardSubhead"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; -import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay"; +import { PRSummaryParts } from "./PRSummaryDisplay"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; import { cn } from "../lib/utils"; @@ -145,8 +145,7 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) { .filter(Boolean) .join(" · ")}
- - + diff --git a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx index d5f99d338..f568cc01d 100644 --- a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx +++ b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx @@ -22,7 +22,7 @@ export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecrea setBusy(true); setError(undefined); try { - const id = await spawnOrchestrator(session.workspaceId, true); + const id = await spawnOrchestrator(session.workspaceId, "restore_dialog", true); onOpenChange(false); onRecreated(id); } catch (err) { diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index b3a64b397..b849ea046 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -6,7 +6,7 @@ import { apiClient, apiErrorMessage } from "../lib/api-client"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { formatTimeCompact } from "../lib/format-time"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; -import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display"; +import { prBrowserUrl, sessionPRDisplaySummaries } from "../lib/pr-display"; import type { SessionActivityState, WorkspaceSession } from "../types/workspace"; import { canonicalTrackerIssueId, sortedPRs } from "../types/workspace"; import { BrowserPanelView } from "./BrowserPanel"; @@ -14,7 +14,7 @@ import type { BrowserViewModel } from "../hooks/useBrowserView"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { cn } from "../lib/utils"; -import { PRAttentionPanel, PRSummaryMeta } from "./PRSummaryDisplay"; +import { PRSummaryMeta, PRSummaryParts } from "./PRSummaryDisplay"; type ProjectConfig = components["schemas"]["ProjectConfig"]; type PRReviewState = components["schemas"]["PRReviewState"]; @@ -127,7 +127,15 @@ export function SessionInspector({ ))}
-
+
{view === "summary" ? : null} {view === "reviews" ? : null} {view === "browser" ? ( @@ -225,40 +233,11 @@ function PRSummaryCard({ pr }: { pr: SessionPRSummary }) {
{pr.title ?
{pr.title}
: null} - - +
); } -function PRStatusStack({ className, pr }: { className?: string; pr: SessionPRSummary }) { - return ( -
- {prStatusRows(pr).map((row) => ( -
- {row.label}{" "} - {row.value} -
- ))} -
- ); -} - -function inspectorStatusToneClass(tone: PRDisplayTone): string { - switch (tone) { - case "success": - return "text-success"; - case "warning": - return "text-warning"; - case "error": - return "text-error"; - case "neutral": - return "text-muted-foreground"; - case "passive": - return "text-passive"; - } -} - type TimelineTone = "now" | "good" | "warn" | "neutral"; function ActivityTimeline({ session }: { session: WorkspaceSession }) { diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 8a5cb42c0..0103a5c30 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -18,9 +18,8 @@ import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { restartProjectOrchestrator } from "../lib/restart-orchestrator"; -import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display"; +import { prBrowserUrl, sessionPRDisplaySummaries } from "../lib/pr-display"; import { cn } from "../lib/utils"; -import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay"; import { useUiStore } from "../stores/ui-store"; type SessionsBoardProps = { @@ -118,7 +117,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { } setIsSpawning(true); try { - const sessionId = await spawnOrchestrator(projectId); + const sessionId = await spawnOrchestrator(projectId, "board"); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -320,73 +319,103 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: ( onOpen(); }; return ( -
-
- - - {badge.label} - - {issueId && ( - - {issueId} +
+
+
+ + + {badge.label} - )} - - {agentLabel(session.provider)} - + {issueId && ( + + {issueId} + + )} + + {agentLabel(session.provider)} + +
+
+ {session.title} +
+ {showBranch &&
{branch}
}
event.stopPropagation()} > - {session.title} -
- {showBranch &&
{branch}
} -
- {prSummaries.length > 0 ? ( -
- {prSummaries.map((prSummary, index) => ( - 0 && "border-t border-border pt-2")} - key={prSummary.number} - pr={prSummary} - /> + {prSummaries.length === 0 ? ( + "no PR yet" + ) : ( +
+ {groupPRsByLifecycle(prSummaries).map((group) => ( + ))}
- ) : ( - "no PR yet" )}
); } -function BoardPRSummary({ className, pr }: { className?: string; pr: SessionPRSummary }) { - const diffSummary = prDiffSummary(pr); +type BoardPRLifecycleStatus = { label: "closed" | "open" | "draft" | "merged"; className: string }; +type BoardPRGroup = { status: BoardPRLifecycleStatus; prs: SessionPRSummary[] }; + +function BoardPRGroup({ group }: { group: BoardPRGroup }) { return ( -
- - PR #{pr.number} · {pr.state} - - {diffSummary ? {diffSummary} : null} - - -
+ `#${pr.number}`).join(", ")} ${group.status.label}`} + className="inline-flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1" + > + PR + {group.prs.map((pr, index) => ( + +
+ #{pr.number} + + {index < group.prs.length - 1 ? "," : null} + + ))} + {group.status.label} + ); } +function groupPRsByLifecycle(prs: SessionPRSummary[]): BoardPRGroup[] { + const groups = new Map(); + for (const pr of prs) { + const status = prLifecycleStatus(pr); + const group = groups.get(status.label); + if (group) { + group.prs.push(pr); + } else { + groups.set(status.label, { status, prs: [pr] }); + } + } + return Array.from(groups.values()); +} + +function prLifecycleStatus(pr: SessionPRSummary): BoardPRLifecycleStatus { + if (pr.state === "draft") return { label: "draft", className: "text-passive" }; + if (pr.state === "merged") return { label: "merged", className: "text-accent" }; + if (pr.state === "closed") return { label: "closed", className: "text-error" }; + return { label: "open", className: "text-success" }; +} + function sameLabel(a: string, b: string): boolean { const normalize = (value: string) => value diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index 2929475c7..892fcabbd 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -112,7 +112,7 @@ export function ShellTopbar() { } setIsSpawning(true); try { - const sessionId = await spawnOrchestrator(projectId); + const sessionId = await spawnOrchestrator(projectId, "topbar"); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -252,16 +252,21 @@ export function TopbarKillButton({ session }: { session: WorkspaceSession }) { const kill = useMutation({ mutationFn: async () => { + void captureRendererEvent("ao.renderer.session_kill_requested", { project_id: session.workspaceId }); const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", { params: { path: { sessionId: session.id } }, }); if (apiError) throw new Error(apiErrorMessage(apiError)); }, onSuccess: () => { + void captureRendererEvent("ao.renderer.session_kill_succeeded", { project_id: session.workspaceId }); setConfirming(false); void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); }, - onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"), + onError: (e) => { + void captureRendererEvent("ao.renderer.session_kill_failed", { project_id: session.workspaceId }); + setError(e instanceof Error ? e.message : "Kill failed"); + }, }); if (confirming) { diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index d405fa7c3..89c480fe9 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -445,7 +445,7 @@ function ProjectItem({ } setIsSpawning(true); try { - const sessionId = await spawnOrchestrator(workspace.id); + const sessionId = await spawnOrchestrator(workspace.id, "sidebar"); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); selection.goSession(workspace.id, sessionId); } catch (err) { @@ -754,7 +754,7 @@ function CreateProjectListItem({ onCreateProject }: Pick