diff --git a/.gitignore b/.gitignore index d9d146b1..0a25d26f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ tests/e2e/.auth/ playwright-report/ test-results/ .next-e2e/ +.next-lifecycle/ +playwright-report-lifecycle/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fb7ef0..6f07ac58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,21 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. ## [Unreleased] +## [2026-07-14] — v0.13.0 · Reliable lifecycle operations + +### Added + +- **A deterministic Lifecycle Lab now covers the operator journey end to end.** Eight isolated, frozen states exercise ready, assigned, active, waiting, approval, review, completed, and stalled work without touching shared development or production data. + +### Changed + +- **Requests for operator input now stay visible across the product.** Action requests and conversational agent questions share one deduplicated “Needs your input” stream in Inbox and the notification drawer, with matching unread badge behavior and less empty-section scrolling. +- **Command Center carries four attention lanes cleanly at wide widths.** Asks, runtime approvals, stalled runs, and review gates remain visible together, while issue breadcrumbs, notification landmarks, headings, and mention autocomplete use clearer accessibility semantics. + +### Fixed + +- **AI triage no longer asks Hermes to execute an unknown `submit_triage` tool.** Hermes receives a compatible structured response contract, provider credentials resolve consistently, stale triage claims can be retried safely, and accepted suggestions follow Forge’s normal label, assignment, dispatch, audit, and wake lifecycle. + ## [2026-07-14] — v0.12.0 · Calmer issue operations ### Added diff --git a/DEVLOG.md b/DEVLOG.md index 0ba1055f..d24dc523 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -12669,3 +12669,62 @@ focused database suites passed (156 tests). The parallel full Vitest run passed 9-test file passed immediately in isolation. A fresh production build passed the Command Center and issue-flow Playwright specs, and the notification drawer focus-containment/restore regression also passed (3 focused browser tests). + +--- + +## 2026-07-14 — AXI-104 AI triage audit and repair + +Audited the full AI-triage path after AXI-104 exposed a Hermes response saying +`Unknown tool: submit_triage`. Hermes now receives an exact plain-JSON response +contract instead of a request-scoped function that its server-side tool +registry cannot execute. Direct OpenAI-compatible providers retain forced tool +output. The compatibility parser now safely resolves unique label names, agent +profile keys, and agent names, while restricting name-based assignments to +explicit assignment clauses and stripping provider/tool failure prose from the +operator-facing rationale. + +Unified Triage, Coach, description assist, plan generation, and AI settings +health around the same workspace-first provider credential resolver. Added +atomic triage claims and apply decisions, rejected duplicate in-flight reruns, +added a five-minute claim lease plus stale-only Retry so process exits cannot +strand the card in `PENDING`, and made Apply run the standard assignment lifecycle: label audit/events, +manual dispatch attribution, engagement-mode metadata, previous-run cleanup, +agent templates, and a single current wake target. + +Verification: focused parser, lease, and database router suites passed 17 tests; lint +passed with existing repository warnings; typecheck passed; and the full +Vitest suite passed 1,223 tests with 1 skip. + +--- + +## 2026-07-14 — Deterministic lifecycle lab and operator journey audit + +Added an isolated Lifecycle Lab for rapid, repeatable issue-lifecycle testing. +It uses the dedicated `forge_lifecycle` database, Redis database 14, port 3300, +and `.next-lifecycle` build output, with guarded deterministic fixtures for +ready, assigned/unacknowledged, active, waiting for user input, runtime +approval, review, completed, and stalled states. The lab disables its in-process +worker so those states remain frozen for inspection and supports both dev and +production-mode runs. + +Added a four-journey Playwright audit that verifies Command Center continuity, +navigate-away/reload persistence, issue waiting and completion handoffs, the +Inbox, the shared notifications drawer, mobile overflow, screenshots, and axe +results. Unified ActionRequests and conversational agent waits into one +deduplicated “Needs your input” stream in both Inbox and the notification +drawer, included new ActionRequests in the Inbox badge, and made agent-authored +mentions robust when an agent API key shares the owner's author ID. Empty Inbox +buckets now stay hidden, Command Center attention cards use a four-column wide +layout, and landmark, heading, dialog, breadcrumb, and mention-combobox +semantics were repaired. + +Verification: lint passed with existing repository warnings; typecheck passed; +the full Vitest suite passed 1,225 tests with 1 skip; a fresh production +Lifecycle Lab build passed; all 4 lifecycle Playwright journeys passed; and all +6 accepted desktop/mobile audit states reported zero axe violations (with +color-contrast excluded from automation and reviewed visually). The remaining +low-priority mobile observation is that the persistent agent-status pill can +cover card content while scrolling. The repository-wide browser run passed 36 +of 38 tests in parallel; the two failures (shared chat state and a composer +locator exposed by the accessibility change) both passed independently after +the composer name was corrected. diff --git a/docs/agents/ai-triage-and-coach.md b/docs/agents/ai-triage-and-coach.md index 7571121a..59d77789 100644 --- a/docs/agents/ai-triage-and-coach.md +++ b/docs/agents/ai-triage-and-coach.md @@ -22,12 +22,17 @@ When enabled, two columns control routing: ### Provider matrix -| Provider | Default model | Env vars | -| --- | --- | --- | -| `hermes` | `claude-haiku-4-5-20251001` | `HERMES_GATEWAY_URL`, `HERMES_GATEWAY_TOKEN` | -| `openai` | `gpt-4o-mini` | `OPENAI_API_KEY`, `OPENAI_BASE_URL` | -| `anthropic` | `claude-haiku-4-5-20251001` | `ANTHROPIC_API_KEY` | -| `custom` | `gpt-4o-mini` | `FORGE_AI_BASE_URL`, `FORGE_AI_API_KEY` | +| Provider | Default model | Env vars | +| ----------- | --------------------------- | -------------------------------------------- | +| `hermes` | `claude-haiku-4-5-20251001` | `HERMES_GATEWAY_URL`, `HERMES_GATEWAY_TOKEN` | +| `openai` | `gpt-4o-mini` | `OPENAI_API_KEY`, `OPENAI_BASE_URL` | +| `anthropic` | `claude-haiku-4-5-20251001` | `ANTHROPIC_API_KEY` | +| `custom` | `gpt-4o-mini` | `FORGE_AI_BASE_URL`, `FORGE_AI_API_KEY` | + +OpenAI, Anthropic, and custom providers can also use an enabled encrypted +`ProviderCredential` stored for the workspace. Stored credentials take +precedence over environment variables and are shared by Triage, Coach, +description assist, and plan generation. `custom` exists for OpenAI-compatible endpoints that aren't OpenAI themselves — Together, Groq, vLLM behind a proxy, a local LLM gateway, etc. @@ -47,8 +52,8 @@ Suggests priority, labels, and an assignee on issue create. ### Knob -| Column | Purpose | -| --- | --- | +| Column | Purpose | +| ---------------------------- | -------------------------------------------------------------------------------------- | | `Workspace.aiTriageOnCreate` | Default `true`. When `aiEnabled` is also true, every new issue triggers a triage call. | ### How it runs @@ -66,8 +71,8 @@ The call loads four pieces of context: capabilities, role). - A small recent-issues sample (titles only) for tone calibration. -It then issues a structured tool-use call against a single tool named -`submit_triage`: +Direct OpenAI-compatible providers receive a structured tool-use call against +a single tool named `submit_triage`: ```ts const tool = { @@ -86,24 +91,33 @@ const tool = { }; ``` -The tool input is validated, mapped against the workspace's actual labels -and agents, and persisted onto the issue: - -| Column | Set to | -| --- | --- | -| `aiTriageStatus` | `READY` on success; `ERROR` on failure. Starts at `PENDING` while in flight. | -| `aiSuggestedPriority` | The model's `priority` choice. | -| `aiSuggestedLabelIds[]` | The model's `label_ids`, filtered to ids that exist in the workspace. | -| `aiSuggestedAgentId` | The model's `agent_id`, or null. | -| `aiTriageReasoning` | The model's free-text rationale. | -| `aiTriagedAt` | Timestamp of completion. | -| `aiTriageDecidedAt` | Set when a human applies or dismisses the suggestion. | +Hermes receives the same schema as a plain-JSON response contract. Forge does +not register `submit_triage` with Hermes because the gateway owns its own +server-side tool registry; sending a request-scoped function there would turn +the schema into an executable agent tool and fail as unknown. + +The response is validated, mapped against the workspace's actual labels and +agents, and persisted onto the issue. Exact ids are preferred. Unique label +names, agent profile keys, and agent names are accepted as a compatibility +fallback; ambiguous names are ignored. Name-based assignment is only parsed +from an explicit agent/assignee clause, never from free-form reasoning. + +| Column | Set to | +| ----------------------- | ---------------------------------------------------------------------------- | +| `aiTriageStatus` | `READY` on success; `ERROR` on failure. Starts at `PENDING` while in flight. | +| `aiSuggestedPriority` | The model's `priority` choice. | +| `aiSuggestedLabelIds[]` | The model's `label_ids`, filtered to ids that exist in the workspace. | +| `aiSuggestedAgentId` | The model's `agent_id`, or null. | +| `aiTriageReasoning` | The model's free-text rationale. | +| `aiTriagedAt` | Timestamp of completion. | +| `aiTriageDecidedAt` | Set when a human applies or dismisses the suggestion. | ### Idempotency -Triage skips any issue with `aiTriageStatus IS NOT NULL`. So if you -manually re-trigger triage you have to clear the status first; otherwise -the call is a no-op. +The runner atomically claims an issue by changing a null status to `PENDING`. +Concurrent create hooks or rerun requests therefore produce at most one model +call. The public rerun mutation clears a completed/failed/decided state before +starting a new claim and rejects a duplicate request while one is pending. ### What the user sees @@ -112,7 +126,9 @@ proposed priority, labels, and agent, plus the reasoning. Two buttons: - **Apply** — writes the suggested values onto the issue's actual `priority`, `labels[]`, and `assignedAgentId`. Sets `aiTriageStatus = - APPLIED` and `aiTriageDecidedAt`. +APPLIED` and `aiTriageDecidedAt`. Label changes are audited, and agent + changes run the standard manual-assignment lifecycle (dispatch reason, + engagement mode, run cleanup, template application, and watcher wake target). - **Dismiss** — leaves the issue alone. Sets `aiTriageStatus = DISMISSED` and `aiTriageDecidedAt`. @@ -125,8 +141,8 @@ Posts a diagnostic comment when work goes off-track. ### Knob -| Column | Purpose | -| --- | --- | +| Column | Purpose | +| -------------------------- | ------------------------------------------------------------------------ | | `Workspace.aiCoachEnabled` | Default `true`. Requires a `COACH`-role agent to exist in the workspace. | ### How it runs diff --git a/package.json b/package.json index 6b7412a0..2fbe788b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "0.12.0", + "version": "0.13.0", "private": true, "description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.", "license": "MIT", @@ -12,6 +12,7 @@ "dev:live:lan": "./scripts/dev-live-stack.sh --lan", "dev:isolated": "next dev --turbo", "dev:local": "./scripts/dev-local.sh", + "dev:lifecycle": "./scripts/lifecycle-lab.sh", "dev:host": "DEV_HOST=0.0.0.0 ./scripts/dev-live.sh", "db:clone-prod": "./scripts/db-clone-prod.sh", "build": "./scripts/build-docs.sh && next build", @@ -24,6 +25,7 @@ "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "tsx prisma/seed.ts", + "seed:lifecycle": "FORGE_LIFECYCLE_LAB=1 tsx scripts/seed-lifecycle-lab.ts", "seed:agents": "tsx scripts/seed-agents.ts", "prisma:studio": "prisma studio", "test": "vitest run", @@ -43,7 +45,8 @@ "forge": "node tools/forge-cli/dist/index.js", "changelog": "tsx tools/scripts/changelog-scaffold.ts", "e2e": "fuser -k 3200/tcp 2>/dev/null; E2E_FORCE_BUILD=1 playwright test", - "e2e:web": "bash scripts/e2e-web.sh" + "e2e:web": "bash scripts/e2e-web.sh", + "test:lifecycle": "playwright test --config=playwright.lifecycle.config.ts" }, "dependencies": { "@auth/prisma-adapter": "^2.7.4", diff --git a/playwright.lifecycle.config.ts b/playwright.lifecycle.config.ts new file mode 100644 index 00000000..3430392f --- /dev/null +++ b/playwright.lifecycle.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = Number(process.env.LIFECYCLE_PORT ?? 3300); +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./tests/lifecycle", + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report-lifecycle" }]], + globalSetup: "./tests/e2e/global-setup.ts", + outputDir: "test-results/lifecycle", + timeout: 90_000, + expect: { timeout: 15_000 }, + use: { + baseURL: BASE_URL, + storageState: "tests/e2e/.auth/owner.json", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "off", + }, + projects: [{ name: "chromium", use: devices["Desktop Chrome"] }], + webServer: { + command: "bash scripts/lifecycle-lab.sh --fresh --production", + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 420_000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/scripts/lifecycle-lab.sh b/scripts/lifecycle-lab.sh new file mode 100755 index 00000000..29f27a6f --- /dev/null +++ b/scripts/lifecycle-lab.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Deterministic issue-lifecycle lab for product iteration and browser audits. +# Uses its own Postgres database, Redis logical DB, Next build directory, and +# port. It cannot read or mutate production or the shared dev:local database. +# +# pnpm dev:lifecycle --fresh # HMR server at http://localhost:3300 +# pnpm dev:lifecycle # reseed lab scenarios, preserve schema +# pnpm dev:lifecycle --production # deterministic next start (Playwright) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="$ROOT/docker/docker-compose.yml" +PORT="${LIFECYCLE_PORT:-3300}" +FRESH=0 +PRODUCTION=0 + +for arg in "$@"; do + case "$arg" in + --fresh) FRESH=1 ;; + --production) PRODUCTION=1 ;; + *) echo "[lifecycle] Unknown arg: $arg" >&2; exit 1 ;; + esac +done + +# Deliberately ignore inherited application URLs. A shell pointed at staging or +# production must still land on the disposable local lab stores. +export DATABASE_URL="postgresql://forge:forge@localhost:55432/forge_lifecycle?schema=public" +export REDIS_URL="redis://localhost:56379/14" +export S3_ENDPOINT="http://localhost:59000" +export S3_PUBLIC_ENDPOINT="http://localhost:59000" +export S3_REGION="${S3_REGION:-us-east-1}" +export S3_ACCESS_KEY="${S3_ACCESS_KEY:-forgeminio}" +export S3_SECRET_KEY="${S3_SECRET_KEY:-forgeminio-dev-password}" +export S3_FORCE_PATH_STYLE="true" + +export AUTH_SECRET="${AUTH_SECRET:-lifecycle-lab-secret-changeme-32bytes}" +export AUTH_URL="http://localhost:${PORT}" +export NEXT_PUBLIC_APP_URL="$AUTH_URL" +export AUTH_TRUST_HOST="true" +export ADMIN_EMAIL="${ADMIN_EMAIL:-owner@forge.local}" +export ADMIN_PASSWORD="${ADMIN_PASSWORD:-forge-dev}" +export ADMIN_NAME="${ADMIN_NAME:-Forge Owner}" +export ADMIN_HANDLE="${ADMIN_HANDLE:-forge}" +export PLUGIN_JWT_SECRET="${PLUGIN_JWT_SECRET:-lifecycle-plugin-signing-key}" +export PLUGIN_JWT_ISSUER="${PLUGIN_JWT_ISSUER:-forge}" +export PLUGIN_JWT_AUDIENCE="${PLUGIN_JWT_AUDIENCE:-forge-plugins}" + +# Reuse the in-process mock runtime, but keep the lab opt-in independently. +export FORGE_E2E=1 +export FORGE_LIFECYCLE_LAB=1 +# The lab is a frozen UX state matrix. Background dispatch/watchdog workers +# would correctly advance those states, which would make visual audits racey. +export FORGE_DISABLE_IN_PROCESS_WORKER=1 +export NEXT_DIST_DIR=".next-lifecycle" + +echo "[lifecycle] Ensuring isolated docker stack is up…" +docker compose -f "$COMPOSE_FILE" up -d >/dev/null + +echo "[lifecycle] Waiting for Postgres…" +for i in $(seq 1 30); do + docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U forge >/dev/null 2>&1 && break + [[ "$i" == "30" ]] && { echo "[lifecycle] Postgres not ready" >&2; exit 1; } + sleep 1 +done + +docker compose -f "$COMPOSE_FILE" exec -T postgres psql -U forge -d forge -tAc \ + "SELECT 1 FROM pg_database WHERE datname='forge_lifecycle'" 2>/dev/null | grep -q 1 \ + || docker compose -f "$COMPOSE_FILE" exec -T postgres createdb -U forge forge_lifecycle + +if [[ "$FRESH" == "1" ]]; then + echo "[lifecycle] --fresh: recreating the lab schema…" + docker compose -f "$COMPOSE_FILE" exec -T postgres \ + psql -U forge -d forge_lifecycle -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" >/dev/null 2>&1 +fi + +echo "[lifecycle] Applying migrations and deterministic fixtures…" +pnpm exec prisma migrate deploy >/dev/null +pnpm exec prisma generate >/dev/null +pnpm exec tsx prisma/seed.ts >/dev/null +pnpm exec tsx scripts/seed-lifecycle-lab.ts + +echo +echo "[lifecycle] Lab: http://localhost:${PORT}/w/forge/command-center" +echo "[lifecycle] Sign in: $ADMIN_EMAIL / $ADMIN_PASSWORD" +echo "[lifecycle] Data: forge_lifecycle · Redis /14 · $NEXT_DIST_DIR" +echo + +if [[ "$PRODUCTION" == "1" ]]; then + if [[ "${LIFECYCLE_FORCE_BUILD:-0}" == "1" || ! -f "$ROOT/$NEXT_DIST_DIR/BUILD_ID" ]]; then + echo "[lifecycle] Building deterministic production bundle…" + pnpm exec next build + fi + exec pnpm exec next start -p "$PORT" +fi + +exec pnpm exec next dev --turbo -p "$PORT" diff --git a/scripts/seed-lifecycle-lab.ts b/scripts/seed-lifecycle-lab.ts new file mode 100644 index 00000000..6ccf1b6f --- /dev/null +++ b/scripts/seed-lifecycle-lab.ts @@ -0,0 +1,649 @@ +/** + * Deterministic issue lifecycle fixtures for UX audits. + * + * Guarded behind FORGE_LIFECYCLE_LAB=1 and intended only for the dedicated + * forge_lifecycle database provisioned by scripts/lifecycle-lab.sh. + */ +import { + ActionRequestKind, + ActionRequestStatus, + AgentRunStatus, + CommentKind, + EventKind, + ExecutionPlanStatus, + ExecutionStepStatus, + NotificationSeverity, + NotificationStatus, + PrismaClient, + Priority, + ReviewGateStatus, +} from "@prisma/client"; + +if (process.env.FORGE_LIFECYCLE_LAB !== "1") { + throw new Error("Refusing to seed lifecycle fixtures without FORGE_LIFECYCLE_LAB=1"); +} + +const prisma = new PrismaClient(); +const MINUTE = 60_000; +const ISSUE_IDS = { + queued: "clifecyclequeued000000000001", + assigned: "clifecycleassigned0000000001", + active: "clifecycleactive000000000001", + waiting: "clifecyclewaiting0000000001", + approval: "clifecycleapproval000000001", + review: "clifecyclereview00000000001", + completed: "clifecyclecompleted00000001", + stalled: "clifecyclestalled0000000001", +} as const; +const LAB_ISSUE_IDS = Object.values(ISSUE_IDS); + +function ago(minutes: number) { + return new Date(Date.now() - minutes * MINUTE); +} + +async function main() { + const workspace = await prisma.workspace.findUnique({ where: { slug: "forge" } }); + const owner = await prisma.user.findUnique({ where: { email: "owner@forge.local" } }); + if (!workspace || !owner) throw new Error("Run prisma/seed.ts before the lifecycle seed"); + + const [agent, project, statuses] = await Promise.all([ + prisma.agent.findUnique({ + where: { + workspaceId_profileKey: { workspaceId: workspace.id, profileKey: "e2ebot" }, + }, + }), + prisma.project.findUnique({ + where: { workspaceId_key: { workspaceId: workspace.id, key: "CORE" } }, + }), + prisma.status.findMany({ where: { workspaceId: workspace.id } }), + ]); + if (!agent || !project) throw new Error("Lifecycle seed requires E2E fixtures and CORE project"); + + const statusId = (name: string) => { + const status = statuses.find((row) => row.name === name); + if (!status) throw new Error(`Missing status: ${name}`); + return status.id; + }; + + // Polymorphic tables do not all cascade from Issue, so remove the previous + // lab slice explicitly before recreating it. Other seed/user data is untouched. + const oldPlans = await prisma.executionPlan.findMany({ + where: { issueId: { in: LAB_ISSUE_IDS } }, + select: { id: true, steps: { select: { id: true } } }, + }); + const oldTargetIds = oldPlans.flatMap((plan) => [plan.id, ...plan.steps.map((step) => step.id)]); + await prisma.$transaction([ + prisma.reviewGate.deleteMany({ + where: { + workspaceId: workspace.id, + OR: [ + { targetId: { in: LAB_ISSUE_IDS } }, + ...(oldTargetIds.length ? [{ targetId: { in: oldTargetIds } }] : []), + ], + }, + }), + prisma.actionRequest.deleteMany({ + where: { workspaceId: workspace.id, issueId: { in: LAB_ISSUE_IDS } }, + }), + prisma.activityEvent.deleteMany({ + where: { workspaceId: workspace.id, subjectType: "issue", subjectId: { in: LAB_ISSUE_IDS } }, + }), + prisma.executionPlan.deleteMany({ + where: { workspaceId: workspace.id, issueId: { in: LAB_ISSUE_IDS } }, + }), + prisma.issue.deleteMany({ + where: { workspaceId: workspace.id, id: { in: LAB_ISSUE_IDS } }, + }), + ]); + + const common = { + workspaceId: workspace.id, + projectId: project.id, + authorId: owner.id, + priority: Priority.HIGH, + expectedOutput: "A clear final comment with outcome, evidence, and any follow-up work.", + verificationChecklist: [ + { id: "journey", label: "Lifecycle state is visible after reload", kind: "manual" }, + { id: "handoff", label: "Operator can identify the next action", kind: "manual" }, + ], + }; + + await prisma.issue.createMany({ + data: [ + { + ...common, + id: ISSUE_IDS.queued, + number: 9001, + title: "Lifecycle Lab · Ready to assign", + description: + "Queued work with no agent yet. The operator should see that it is ready, unowned, and needs dispatch.", + statusId: statusId("Todo"), + queued: true, + }, + { + ...common, + id: ISSUE_IDS.assigned, + number: 9002, + title: "Lifecycle Lab · Assigned, awaiting acknowledgement", + description: "The agent has been dispatched but has not acknowledged the wake.", + statusId: statusId("Todo"), + assignedAgentId: agent.id, + }, + { + ...common, + id: ISSUE_IDS.active, + number: 9003, + title: "Lifecycle Lab · Agent actively working", + description: "An acknowledged run with live progress and a rolling status update.", + statusId: statusId("In Progress"), + assignedAgentId: agent.id, + startedAt: ago(42), + }, + { + ...common, + id: ISSUE_IDS.waiting, + number: 9004, + title: "Lifecycle Lab · Waiting for user reply", + description: "The agent needs a product decision before it can continue.", + statusId: statusId("In Progress"), + assignedAgentId: agent.id, + startedAt: ago(65), + }, + { + ...common, + id: ISSUE_IDS.approval, + number: 9005, + title: "Lifecycle Lab · Runtime approval required", + description: "The runtime paused before a destructive command and needs explicit approval.", + statusId: statusId("In Progress"), + assignedAgentId: agent.id, + startedAt: ago(28), + }, + { + ...common, + id: ISSUE_IDS.review, + number: 9006, + title: "Lifecycle Lab · Ready for review", + description: "Execution is complete and a human review gate is blocking closure.", + statusId: statusId("In Review"), + assignedAgentId: agent.id, + startedAt: ago(110), + }, + { + ...common, + id: ISSUE_IDS.completed, + number: 9007, + title: "Lifecycle Lab · Completed with final handoff", + description: + "A successful run with verification evidence and an explicit final agent comment.", + statusId: statusId("Done"), + assignedAgentId: agent.id, + startedAt: ago(150), + completedAt: ago(12), + }, + { + ...common, + id: ISSUE_IDS.stalled, + number: 9008, + title: "Lifecycle Lab · Stalled and needs recovery", + description: + "A run stopped emitting progress and should surface as a recoverable operational alert.", + statusId: statusId("In Progress"), + assignedAgentId: agent.id, + startedAt: ago(190), + }, + ], + }); + + const assignmentEvent = await prisma.activityEvent.create({ + data: { + workspaceId: workspace.id, + kind: EventKind.AGENT_ASSIGNED, + actorId: owner.id, + subjectType: "issue", + subjectId: ISSUE_IDS.assigned, + payload: { agentId: agent.id, agentProfileKey: agent.profileKey, number: 9002 }, + createdAt: ago(18), + }, + }); + const assignedRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.assigned, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + startedAt: ago(18), + lastEventAt: ago(1), + currentStep: "Wake sent · awaiting acknowledgement", + assignmentEventId: assignmentEvent.id, + triggerEventId: assignmentEvent.id, + triggerKind: EventKind.AGENT_ASSIGNED, + wakeAttempts: 1, + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: assignedRun.id, + kind: "STARTED", + payload: { message: "Dispatch wake sent" }, + createdAt: ago(18), + }, + }); + + const activeRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.active, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + startedAt: ago(42), + lastEventAt: ago(2), + currentStep: "Running lifecycle regression tests", + acknowledgedAt: ago(41), + outputStartedAt: ago(39), + tokensIn: 8400, + tokensOut: 1900, + costUsd: 0.1834, + }, + }); + await prisma.agentRunEvent.createMany({ + data: [ + { + workspaceId: workspace.id, + runId: activeRun.id, + kind: "STARTED", + payload: { message: "Run acknowledged" }, + createdAt: ago(42), + }, + { + workspaceId: workspace.id, + runId: activeRun.id, + kind: "STEP", + payload: { message: "Mapped state transitions" }, + createdAt: ago(12), + }, + { + workspaceId: workspace.id, + runId: activeRun.id, + kind: "STATUS", + payload: { message: "Running lifecycle regression tests" }, + createdAt: ago(2), + }, + ], + }); + await prisma.comment.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.active, + authorId: owner.id, + authoringAgentId: agent.id, + kind: CommentKind.STATUS, + runId: activeRun.id, + currentStep: "Running lifecycle regression tests", + body: "Mapped the issue states and now verifying navigation, reload persistence, and responsive visibility.", + revisions: [], + createdAt: ago(39), + updatedAt: ago(2), + }, + }); + + const waitingRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.waiting, + agentId: agent.id, + status: AgentRunStatus.WAITING, + startedAt: ago(65), + lastEventAt: ago(8), + currentStep: "Waiting on your product decision", + acknowledgedAt: ago(64), + outputStartedAt: ago(61), + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: waitingRun.id, + kind: "BLOCKED", + payload: { reason: "Operator decision required" }, + createdAt: ago(8), + }, + }); + const waitingComment = await prisma.comment.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.waiting, + authorId: owner.id, + authoringAgentId: agent.id, + body: "@owner Should the completion summary optimize for operators scanning the Command Center, or for the full issue activity record? I can proceed as soon as you choose.", + suggestedReplies: ["Optimize for Command Center", "Optimize for issue detail", "Use both"], + confidence: "HIGH", + createdAt: ago(8), + updatedAt: ago(8), + }, + }); + await prisma.actionRequest.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.waiting, + title: "Choose the completion-summary emphasis", + body: "The agent is blocked until the operator chooses how the handoff should be optimized. This intentionally has enough detail to verify clean card truncation and expansion behavior across Command Center and issue detail.", + status: ActionRequestStatus.OPEN, + severity: NotificationSeverity.WARNING, + kind: ActionRequestKind.FREE_FORM, + requestedByAgentId: agent.id, + assignedUserId: owner.id, + sourceType: "comment", + sourceId: waitingComment.id, + dueAt: new Date(Date.now() + 120 * MINUTE), + createdAt: ago(8), + updatedAt: ago(8), + }, + }); + + const approvalRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.approval, + agentId: agent.id, + status: AgentRunStatus.WAITING, + startedAt: ago(28), + lastEventAt: ago(4), + currentStep: "Approval required before database migration", + acknowledgedAt: ago(27), + outputStartedAt: ago(25), + awaitingApprovalAt: ago(4), + pendingApproval: { + command: "pnpm exec prisma migrate deploy", + description: + "Apply the reviewed lifecycle notification migration to the isolated lab database.", + choices: ["approve", "reject"], + }, + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: approvalRun.id, + kind: "APPROVAL_REQUESTED", + payload: { command: "pnpm exec prisma migrate deploy" }, + createdAt: ago(4), + }, + }); + + const reviewPlan = await prisma.executionPlan.create({ + data: { + id: "clifecycleplanreview000000001", + workspaceId: workspace.id, + issueId: ISSUE_IDS.review, + title: "Lifecycle review handoff", + description: "Verify that the completed work is clear and ready to close.", + status: ExecutionPlanStatus.RUNNING, + createdById: owner.id, + startedAt: ago(105), + }, + }); + const reviewStep = await prisma.executionStep.create({ + data: { + id: "clifecyclestepreview000000001", + workspaceId: workspace.id, + planId: reviewPlan.id, + issueId: ISSUE_IDS.review, + title: "Verify lifecycle handoff", + body: "Check the operator-facing status, evidence, final comment, and next action.", + position: 0, + status: ExecutionStepStatus.REVIEW, + assignedAgentId: agent.id, + expectedOutput: "A review decision with concise feedback.", + verification: [{ label: "State and next action are immediately clear", done: true }], + }, + }); + const reviewRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.review, + agentId: agent.id, + executionStepId: reviewStep.id, + status: AgentRunStatus.COMPLETED, + startedAt: ago(100), + lastEventAt: ago(22), + finishedAt: ago(22), + completedAt: ago(22), + currentStep: "Ready for review", + summary: "Implemented the lifecycle status pass and verified the primary operator surfaces.", + acknowledgedAt: ago(99), + outputStartedAt: ago(96), + verificationResult: [ + { + id: "journey", + label: "Lifecycle state is visible after reload", + done: true, + }, + { + id: "handoff", + label: "Operator can identify the next action", + done: true, + }, + ], + completionMeta: { confidence: "HIGH", contractVersion: 1 }, + }, + }); + await prisma.executionStep.update({ + where: { id: reviewStep.id }, + data: { sourceRunId: reviewRun.id }, + }); + await prisma.reviewGate.create({ + data: { + id: "clifecyclegate00000000000001", + workspaceId: workspace.id, + targetType: "execution-step", + targetId: reviewStep.id, + status: ReviewGateStatus.PENDING, + requestedByAgentId: agent.id, + prompt: + "Does this handoff clearly communicate outcome, evidence, and the next operator action?", + createdAt: ago(20), + }, + }); + await prisma.comment.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.review, + authorId: owner.id, + authoringAgentId: agent.id, + body: "Implementation is complete and the verification checklist passes. I moved this to review; please approve the gate or leave specific feedback.", + confidence: "HIGH", + createdAt: ago(22), + updatedAt: ago(22), + }, + }); + + const completedRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.completed, + agentId: agent.id, + status: AgentRunStatus.COMPLETED, + startedAt: ago(150), + lastEventAt: ago(12), + finishedAt: ago(12), + completedAt: ago(12), + currentStep: "Complete", + summary: "Lifecycle states remain visible and actionable across navigation and reloads.", + acknowledgedAt: ago(149), + outputStartedAt: ago(145), + verificationResult: [ + { + id: "journey", + label: "Lifecycle state is visible after reload", + done: true, + }, + { + id: "handoff", + label: "Operator can identify the next action", + done: true, + }, + ], + followUps: [{ title: "Monitor notification fatigue after rollout", kind: "observation" }], + completionMeta: { confidence: "HIGH", contractVersion: 1 }, + tokensIn: 12900, + tokensOut: 3400, + costUsd: 0.2761, + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: completedRun.id, + kind: "COMPLETED", + payload: { summary: "Lifecycle verification passed" }, + createdAt: ago(12), + }, + }); + await prisma.comment.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.completed, + authorId: owner.id, + authoringAgentId: agent.id, + body: "Completed. The operator journey now exposes queued, active, waiting, review, recovery, and done states across the issue page, Inbox, Command Center, and shared alert drawer. Verification passed after navigating away and reloading. Follow-up: monitor whether repeated stalled alerts create noise.", + confidence: "HIGH", + createdAt: ago(12), + updatedAt: ago(12), + }, + }); + + const stalledRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.stalled, + agentId: agent.id, + status: AgentRunStatus.STALLED, + startedAt: ago(190), + lastEventAt: ago(95), + finishedAt: ago(90), + currentStep: "No progress after test runner launch", + summary: "The run stopped emitting events while waiting for the browser test process.", + acknowledgedAt: ago(189), + outputStartedAt: ago(184), + wakeAttempts: 2, + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: stalledRun.id, + kind: "ERRORED", + payload: { error: "No output received before the stale threshold" }, + createdAt: ago(90), + }, + }); + const stalledEvent = await prisma.activityEvent.create({ + data: { + workspaceId: workspace.id, + kind: EventKind.ISSUE_STALLED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.stalled, + payload: { + issuePrefix: "FRG-9008", + number: 9008, + agentId: agent.id, + agentProfileKey: agent.profileKey, + idleMinutes: 90, + }, + createdAt: ago(90), + }, + }); + await prisma.notificationState.create({ + data: { + workspaceId: workspace.id, + userId: owner.id, + eventId: stalledEvent.id, + replacementKey: `issue:${ISSUE_IDS.stalled}:stalled`, + severity: NotificationSeverity.WARNING, + importance: 65, + status: NotificationStatus.UNREAD, + persistent: true, + primaryHref: `/w/forge/issues/${ISSUE_IDS.stalled}`, + detailHref: `/w/forge/issues/${ISSUE_IDS.stalled}?tab=activity`, + summary: "FRG-9008 stalled", + reason: "The assigned agent stopped emitting progress while the test runner was active.", + recommendedAction: "Inspect the latest trace, then retry, reassign, or clear the failed run.", + createdAt: ago(90), + }, + }); + + // Give every scenario at least one immutable activity marker so the default + // issue rail explains how the current state was reached. + await prisma.activityEvent.createMany({ + data: [ + { + workspaceId: workspace.id, + kind: EventKind.ISSUE_QUEUED, + actorId: owner.id, + subjectType: "issue", + subjectId: ISSUE_IDS.queued, + payload: { number: 9001 }, + createdAt: ago(24), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_STARTED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.active, + payload: { runId: activeRun.id, number: 9003 }, + createdAt: ago(42), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_BLOCKED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.waiting, + payload: { runId: waitingRun.id, reason: "Operator decision required", number: 9004 }, + createdAt: ago(8), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_BLOCKED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.approval, + payload: { runId: approvalRun.id, reason: "Runtime approval required", number: 9005 }, + createdAt: ago(4), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_COMPLETED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.review, + payload: { runId: reviewRun.id, number: 9006 }, + createdAt: ago(22), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_COMPLETED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.completed, + payload: { runId: completedRun.id, number: 9007 }, + createdAt: ago(12), + }, + ], + }); + + console.log("[lifecycle] Seeded FRG-9001…FRG-9008 lifecycle scenarios"); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/(app)/w/[slug]/command-center/page.tsx b/src/app/(app)/w/[slug]/command-center/page.tsx index 51ba12e4..b49d7258 100644 --- a/src/app/(app)/w/[slug]/command-center/page.tsx +++ b/src/app/(app)/w/[slug]/command-center/page.tsx @@ -213,7 +213,8 @@ export default function CommandCenterPage() { className={cn( "grid grid-cols-1 gap-3 p-3", activeAttentionGroups === 2 && "lg:grid-cols-2", - activeAttentionGroups >= 3 && "lg:grid-cols-3", + activeAttentionGroups === 3 && "lg:grid-cols-3", + activeAttentionGroups >= 4 && "lg:grid-cols-2 xl:grid-cols-4", )} data-testid="command-center-attention-groups" > diff --git a/src/app/(app)/w/[slug]/inbox/page.tsx b/src/app/(app)/w/[slug]/inbox/page.tsx index d39f0518..e4c22ed1 100644 --- a/src/app/(app)/w/[slug]/inbox/page.tsx +++ b/src/app/(app)/w/[slug]/inbox/page.tsx @@ -1,5 +1,6 @@ "use client"; import type { AgentStatus } from "@prisma/client"; +import type { inferRouterOutputs } from "@trpc/server"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { toast } from "sonner"; @@ -36,6 +37,7 @@ import { ProjectChip } from "@/components/project-chip"; import { RowQuickActions } from "@/components/row-quick-actions"; import { SnoozeMenu } from "@/components/snooze-menu"; import { trpc } from "@/lib/trpc"; +import type { AppRouter } from "@/server/routers/_app"; import { cn, formatIssueId, relativeTime } from "@/lib/utils"; import { useWorkspace } from "@/hooks/use-workspace"; import { useHotkey } from "@/lib/keyboard"; @@ -70,6 +72,27 @@ export default function InboxPage() { const [allWorkspaces, setAllWorkspaces] = useState(false); const { data, isLoading } = trpc.inbox.get.useQuery({ allWorkspaces }); + const waitingOnMeQ = trpc.inbox.waitingOnMe.useQuery( + { limit: 25 }, + { enabled: !allWorkspaces }, + ); + const actionRequestsQ = trpc.inbox.actionRequestsForMe.useQuery( + { limit: 50 }, + { enabled: !allWorkspaces }, + ); + const actionRequests = useMemo( + () => actionRequestsQ.data?.items ?? [], + [actionRequestsQ.data?.items], + ); + const requestedIssueIds = useMemo( + () => new Set(actionRequests.flatMap((request) => (request.issueId ? [request.issueId] : []))), + [actionRequests], + ); + const waitingOnMe = useMemo( + () => + (waitingOnMeQ.data?.items ?? []).filter((row) => !requestedIssueIds.has(row.issue.id)), + [requestedIssueIds, waitingOnMeQ.data?.items], + ); const { data: agentQueue, isLoading: queueLoading } = trpc.issue.queue.useQuery( { includeClaimed: true, limit: 25 }, { enabled: !allWorkspaces }, @@ -429,7 +452,9 @@ export default function InboxPage() { /> - {isLoading || !data ? ( + {isLoading || + !data || + (!allWorkspaces && (waitingOnMeQ.isLoading || actionRequestsQ.isLoading)) ? (
@@ -438,7 +463,9 @@ export default function InboxPage() { data.mentions.length === 0 && data.humanStalled.length === 0 && data.agentStalled.length === 0 && - data.snoozed.length === 0 ? ( + data.snoozed.length === 0 && + actionRequests.length === 0 && + waitingOnMe.length === 0 ? (
) : ( <> - 0 && (hoveredRowRef.current = id)} onPickStatus={(id) => setStatusPicker({ issueId: id })} onPickAssignee={(id) => setAssigneePicker({ issueId: id })} - /> + />} -
0 &&
@@ -555,11 +582,17 @@ export default function InboxPage() { }) )} -
+
} - {!allWorkspaces && } + {!allWorkspaces && ( + + )} - 0 && setStatusPicker({ issueId: id })} onPickAssignee={(id) => setAssigneePicker({ issueId: id })} tone="warn" - /> + />} - 0 && setAssigneePicker({ issueId: id })} tone="warn" showAgent - /> + />} ["inbox"]["waitingOnMe"]["items"][number]; +type ActionRequestItem = + inferRouterOutputs["inbox"]["actionRequestsForMe"]["items"][number]; + +function NeedsInputSection({ + slug, + actionRequests, + waitingOnMe, +}: { + slug: string; + actionRequests: ActionRequestItem[]; + waitingOnMe: WaitingOnMeItem[]; +}) { + const count = actionRequests.length + waitingOnMe.length; + if (count === 0) return null; return (
- Waiting on me - {items.length} + Needs your input + {count} } - hint="Agent comments that @-mention you with no reply from you yet." + hint="Direct asks and agent questions waiting for your response." > - {items.map((row) => ( + {actionRequests.map((request) => { + const issue = request.issue; + const href = issue ? `/w/${slug}/issues/${issue.id}` : `/w/${slug}/command-center`; + return ( +
  • + + + +
    +
    + + {issue ? formatIssueId(issue.workspace.key, issue.number) : "Workspace ask"} + + + {request.title} + + {request.issueOpenCount > 1 && ( + +{request.issueOpenCount - 1} more + )} +
    + {request.body && ( +
    {request.body}
    + )} +
    + + {relativeTime(request.createdAt)} + +
  • + ); + })} + {waitingOnMe.map((row) => (
  • } + title={ + + } subtitle={archived} actions={
  • - +
    ); } @@ -1553,11 +1591,17 @@ function DrawerTab({ } type InboxData = inferRouterOutputs["inbox"]["get"]; +type WaitingOnMeItem = inferRouterOutputs["inbox"]["waitingOnMe"]["items"][number]; +type ActionRequestItem = + inferRouterOutputs["inbox"]["actionRequestsForMe"]["items"][number]; function MinePanel({ ws, data, isLoading, + actionRequests, + waitingOnMe, + needsInputLoading, alertRows, alertsLoading, error, @@ -1571,6 +1615,9 @@ function MinePanel({ ws: { slug: string }; data: InboxData | undefined; isLoading: boolean; + actionRequests: ActionRequestItem[]; + waitingOnMe: WaitingOnMeItem[]; + needsInputLoading: boolean; alertRows: NotificationRow[]; alertsLoading: boolean; error: string | null; @@ -1581,14 +1628,26 @@ function MinePanel({ alertsMutatingId: string | null; onNavigate: () => void; }) { - if ((isLoading || alertsLoading) && !data && alertRows.length === 0) { + if ( + (isLoading || needsInputLoading || alertsLoading) && + !data && + actionRequests.length === 0 && + waitingOnMe.length === 0 && + alertRows.length === 0 + ) { return (
    ); } - if (error && !data && alertRows.length === 0) { + if ( + error && + !data && + actionRequests.length === 0 && + waitingOnMe.length === 0 && + alertRows.length === 0 + ) { return (
    ); } - if (!data && alertRows.length === 0) { + if (!data && actionRequests.length === 0 && waitingOnMe.length === 0 && alertRows.length === 0) { return (
    } title="You're caught up." - description="No assigned, mentioned, or stalled items right now." + description="No asks, assignments, mentions, alerts, or stalled items right now." />
    ); @@ -1677,6 +1738,50 @@ function MinePanel({ )} + {actionRequests.length + waitingOnMe.length > 0 && ( + } + > + {actionRequests.slice(0, 6).map((request) => { + const issue = request.issue; + return ( + + {issue ? `${issue.workspace.key}-${issue.number}` : "Ask"} + + } + title={request.title} + meta={ + request.requestedByAgent + ? `@${request.requestedByAgent.profileKey} · ${relativeTime(request.createdAt)}` + : relativeTime(request.createdAt) + } + /> + ); + })} + {waitingOnMe.slice(0, Math.max(0, 6 - actionRequests.length)).map((row) => ( + + {row.issue.workspace.key}-{row.issue.number} + + } + title={row.issue.title} + meta={`@${row.lastComment.author.profileKey} · ${relativeTime(row.lastComment.createdAt)}`} + /> + ))} + + )} + {(data?.counts.assignedUnblocked ?? 0) > 0 && ( = { URGENT: "bg-red-400/15 text-red-200", }; -export function AiTriageCard({ - issue, - slug, -}: { - issue: Issue; - slug: string; -}) { +export function AiTriageCard({ issue, slug }: { issue: Issue; slug: string }) { const utils = trpc.useUtils(); const { data: allLabels } = trpc.label.list.useQuery(); const { data: agents } = trpc.agent.list.useQuery(); @@ -70,14 +59,8 @@ export function AiTriageCard({ useEffect(() => { if (issue.aiSuggestedPriority === issue.priority) setApplyPriority(false); - if (issue.aiSuggestedAgentId === (issue.assignedAgent?.id ?? null)) - setApplyAgent(false); - }, [ - issue.aiSuggestedPriority, - issue.priority, - issue.aiSuggestedAgentId, - issue.assignedAgent, - ]); + if (issue.aiSuggestedAgentId === (issue.assignedAgent?.id ?? null)) setApplyAgent(false); + }, [issue.aiSuggestedPriority, issue.priority, issue.aiSuggestedAgentId, issue.assignedAgent]); const apply = trpc.ai.triageApply.useMutation({ onSuccess: () => { @@ -113,10 +96,25 @@ export function AiTriageCard({ if (status === "APPLIED" || status === "DISMISSED") return null; if (status === "PENDING") { + const stale = isAiTriagePendingStale(issue.aiTriagedAt); return ( -
    - - Triaging… +
    +
    + + {stale ? "Triage is taking longer than expected." : "Triaging…"} +
    + {stale && ( + + )}
    ); } @@ -154,9 +152,7 @@ export function AiTriageCard({ } // READY - const labelLookup = new Map( - (allLabels ?? []).map((l) => [l.id, l]), - ); + const labelLookup = new Map((allLabels ?? []).map((l) => [l.id, l])); const agentLookup = new Map((agents ?? []).map((a) => [a.id, a])); const newLabels = issue.aiSuggestedLabelIds.filter( @@ -165,12 +161,9 @@ export function AiTriageCard({ const suggestedAgent = issue.aiSuggestedAgentId ? agentLookup.get(issue.aiSuggestedAgentId) : null; - const priorityChanged = - issue.aiSuggestedPriority && - issue.aiSuggestedPriority !== issue.priority; + const priorityChanged = issue.aiSuggestedPriority && issue.aiSuggestedPriority !== issue.priority; - const nothingToApply = - !priorityChanged && newLabels.length === 0 && !suggestedAgent; + const nothingToApply = !priorityChanged && newLabels.length === 0 && !suggestedAgent; return (
    @@ -200,11 +193,7 @@ export function AiTriageCard({
    {priorityChanged && ( - + @@ -220,11 +209,7 @@ export function AiTriageCard({ )} {newLabels.length > 0 && ( - +
    {newLabels.map((id) => { const l = labelLookup.get(id); @@ -239,17 +224,11 @@ export function AiTriageCard({ )} {suggestedAgent && ( - + {suggestedAgent.profileKey} - - ({suggestedAgent.name}) - + ({suggestedAgent.name}) )} @@ -292,9 +271,7 @@ export function AiTriageCard({ }) } disabled={ - apply.isPending || - nothingToApply || - (!applyPriority && !applyLabels && !applyAgent) + apply.isPending || nothingToApply || (!applyPriority && !applyLabels && !applyAgent) } > {apply.isPending ? "Applying…" : "Apply"} diff --git a/src/components/breadcrumb.tsx b/src/components/breadcrumb.tsx index f3fe1969..b756d313 100644 --- a/src/components/breadcrumb.tsx +++ b/src/components/breadcrumb.tsx @@ -26,14 +26,16 @@ export type BreadcrumbItem = { export function Breadcrumb({ items, className, + ariaLabel = "Breadcrumb", }: { items: BreadcrumbItem[]; className?: string; + ariaLabel?: string; }) { if (items.length === 0) return null; return (