From c4cb65846fa078354be6a2d3f084a4feeb506221 Mon Sep 17 00:00:00 2001 From: zhangjianan Date: Fri, 8 May 2026 12:40:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(work):=20A-class=20UI=20surfaces=20+?= =?UTF-8?q?=20B.6=20SubAgent=20acceptance/reviewer=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class — UI 串联缺口 (5/5) • Auto-mode dashboard at /projects/auto-mode - Backend: extended GET /v1/auto-mode payload (config + permits + last_tick_at), AutoModeRuntime::record_tick(), new GET /v1/diagnostics/runs/recent reusing RequirementRunStore::list_all - Frontend: scheduler header strip, in-flight runs, recent runs, recent failures (with Retry → POST /v1/requirements/:id/runs), blocked/waiting derivation, per-run drawer (logs/usage/error), cross-project Pending-triage panel • Triage 增量 - TriageDrawer 加 "全部通过 (N)" 按钮 (parallel approveRequirement) - cross-project pending callout in Auto-mode dashboard • Conversations archive - new /conversations browse page (search + project filter + date grouping) + /conversations/:id deep-link redirect • Worktrees panel at /projects/worktrees - reuses /v1/diagnostics/worktrees/orphans + cleanup, with size formatting, age, confirm-before-delete, last-cleanup report • Plan-mode chat UX - PlanModeBanner mirrors BypassBanner; visible when permissionMode==plan && !proposedPlan; "Exit plan mode" → ask B.6 SubAgent — acceptance policy 全栈闭环 • Requirement.acceptance_policy (Subagent / Human) 前端通路 - PATCH /v1/requirements/:id 接受 acceptance_policy - types/frames.ts: AcceptancePolicy + Requirement.acceptance_policy - RequirementDetail Select picker + kanban "human review" badge • auto_mode 真正读 acceptance_policy - completed_requirement_target_status 在 Human policy 下停在 Review 而非 Done - tick eligibility filter 跳过 Human-policy 的 Review 行 (避免无限重选) • Reviewer auto-accept dispatch (opt-in via JARVIS_REVIEWER_AUTO_ACCEPT) - AutoModeConfig.reviewer_auto_accept (default false) - dispatch_acceptance_review 从 state.tools 解析 subagent.review,构造 task + context (req_id / run_id / verification_plan) 并调用; reviewer 通过其 requirement.review_verdict 工具自己写最终状态 - Activity rows: reviewer_dispatched + reviewer_dispatch_failed Tests • 232/232 harness-server unit tests pass • 6 new tests: - tick_skips_human_acceptance_policy_at_review - tick_advances_human_policy_in_progress_to_review - completed_target_status_respects_acceptance_policy - advance_dispatches_reviewer_when_flag_enabled_and_policy_subagent - advance_skips_dispatch_when_flag_disabled - advance_skips_dispatch_for_human_policy_even_with_flag_enabled - dispatch_acceptance_review_errors_when_tool_missing • cargo clippy --workspace --all-targets -D warnings 干净 i18n • 全部新增字符串 en + zh 双语 (auto-mode dashboard, conversations archive, worktrees page, plan-mode banner, acceptance policy, triage approve-all, sidebar nav entries) Note: this commit also bundles in-flight Observability + EvalStore scaffolding (HarnessObservabilityPanel, observability_routes, EvalStore trait) that was already in the working tree and is now entangled with state.rs / routes.rs hunks. Those changes build + test cleanly but are scaffolding only — full OTel/eval wiring is a separate workstream. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 53 +- apps/jarvis-web/src/App.tsx | 90 ++ .../jarvis-web/src/components/AppChatPane.tsx | 2 + apps/jarvis-web/src/components/AppSidebar.tsx | 46 + .../components/Approvals/PlanModeBanner.tsx | 52 + .../AutoMode/AutoModeDashboardPage.tsx | 980 ++++++++++++++++++ .../ConversationsArchivePage.tsx | 310 ++++++ .../src/components/Projects/ProjectBoard.tsx | 61 +- .../components/Projects/RequirementDetail.tsx | 52 + .../HarnessObservabilityPanel.tsx | 316 ++++++ .../WorkOverview/WorkOverviewPage.tsx | 6 + .../components/Worktrees/WorktreesPage.tsx | 337 ++++++ apps/jarvis-web/src/services/autoMode.ts | 28 + apps/jarvis-web/src/services/diagnostics.ts | 29 + apps/jarvis-web/src/services/requirements.ts | 7 + apps/jarvis-web/src/services/workOverview.ts | 120 +++ apps/jarvis-web/src/styles.css | 361 +++++++ apps/jarvis-web/src/types/frames.ts | 14 + apps/jarvis-web/src/utils/i18n.ts | 381 +++++++ apps/jarvis/src/serve.rs | 73 +- crates/harness-core/src/agent.rs | 5 +- crates/harness-core/src/lib.rs | 6 + crates/harness-core/src/observability.rs | 242 +++++ crates/harness-server/src/auto_mode.rs | 613 ++++++++++- crates/harness-server/src/auto_mode_routes.rs | 94 +- crates/harness-server/src/diagnostics.rs | 74 ++ .../harness-server/src/diagnostics_routes.rs | 25 + crates/harness-server/src/lib.rs | 1 + .../src/observability_routes.rs | 856 +++++++++++++++ crates/harness-server/src/plugin_routes.rs | 2 +- crates/harness-server/src/projects.rs | 2 +- .../harness-server/src/requirements_routes.rs | 29 + crates/harness-server/src/roadmap_routes.rs | 2 +- crates/harness-server/src/routes.rs | 566 +++++++++- crates/harness-server/src/state.rs | 49 +- crates/harness-server/src/ui.rs | 2 +- crates/harness-store/src/json_file.rs | 339 +++++- crates/harness-store/src/lib.rs | 43 +- crates/harness-store/src/mysql.rs | 130 ++- crates/harness-store/src/postgres.rs | 115 +- crates/harness-store/src/sqlite.rs | 123 +-- crates/harness-store/tests/connect.rs | 65 +- docs/proposals/README.md | 1 + .../otel-native-eval-harness.zh-CN.md | 504 ++++++++- docs/proposals/self-improving-agent.zh-CN.md | 542 ++++++++++ 45 files changed, 7452 insertions(+), 296 deletions(-) create mode 100644 apps/jarvis-web/src/components/Approvals/PlanModeBanner.tsx create mode 100644 apps/jarvis-web/src/components/AutoMode/AutoModeDashboardPage.tsx create mode 100644 apps/jarvis-web/src/components/Conversations/ConversationsArchivePage.tsx create mode 100644 apps/jarvis-web/src/components/Projects/WorkOverview/HarnessObservabilityPanel.tsx create mode 100644 apps/jarvis-web/src/components/Worktrees/WorktreesPage.tsx create mode 100644 crates/harness-core/src/observability.rs create mode 100644 crates/harness-server/src/observability_routes.rs create mode 100644 docs/proposals/self-improving-agent.zh-CN.md diff --git a/CLAUDE.md b/CLAUDE.md index e197f7a..5b3c572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,10 @@ per-tick; v1.1 routes it to the real concurrency cap)), `JARVIS_WORK_MAX_RETRIES` (default `1`), `JARVIS_WORK_RUN_TIMEOUT_MS` (default `300000` — 5 min wall-clock budget per agent loop pickup), +`JARVIS_REVIEWER_AUTO_ACCEPT` (any non-empty / non-`0` / non-`false` +value opts in to reviewer-subagent dispatch on Review → Done under +`AcceptancePolicy::Subagent`; default off — see "Reviewer +auto-accept" below), `JARVIS_WORKTREE_MODE` (`off` / `per_run` / `per_unit`; auto mode upgrades from `off` to `per_run` automatically so the scheduler never mutates the main checkout), @@ -685,11 +689,50 @@ REST surface around triage: - All `depends_on` entries reach `RequirementStatus::Done` (topo sort) - No in-flight Pending/Running run for the same requirement - `failed_count < max_retries` - -The four guards are silent skips — the row stays in Backlog until -all conditions clear. Operators see the missing pickups in the -activity timeline (no `RunStarted` row) rather than via an explicit -"blocked" signal. +- v1.0 SubAgent — rows at Review under + `acceptance_policy == AcceptancePolicy::Human` are skipped (the + policy gate keeps them at Review until a person clicks + "complete"; re-running would burn LLM budget without ever + advancing status). + +The guards are silent skips — the row stays in Backlog (or Review, +under the human-policy gate) until all conditions clear. Operators +see the missing pickups in the activity timeline (no `RunStarted` +row) rather than via an explicit "blocked" signal. + +**Acceptance policy** — `Requirement.acceptance_policy` +(v1.0 SubAgent): +- `Subagent` (default): preserves the pre-v1.0 auto-flip + Review → Done semantics **unless** `JARVIS_REVIEWER_AUTO_ACCEPT` + is set — see below. +- `Human`: keeps the row at Review after a Completed run. The + picker also skips already-at-Review rows under this policy so + the auto loop doesn't burn cycles re-running them. Use it when + the verification plan can't model what "done" means + (UX/visual design, security-sensitive changes, anything subtly + judgment-dependent). + +**Reviewer auto-accept** — opt-in via +`JARVIS_REVIEWER_AUTO_ACCEPT=1` (any non-empty / non-`0` / +non-`false` value enables it). When enabled and a Completed run +arrives against a `Subagent`-policy requirement, the auto loop +holds the row at Review and dispatches the +[`subagent.review`](crates/harness-subagents/src/reviewer.rs) +subagent (looked up in `state.tools`). The reviewer's terminal +call to +[`requirement.review_verdict`](crates/harness-tools/src/requirement.rs) +flips the row to Done (`pass`) or InProgress (`fail`) with the +commentary attached so the next pickup can adapt. Two Activity +rows fire around the dispatch: +`{kind:"reviewer_dispatched", run_id}` before, plus +`{kind:"reviewer_dispatch_failed", run_id, error}` if the +subagent tool isn't registered or the invocation errors. + +Default off so existing deployments keep the synchronous +auto-flip. Tests live in +[`auto_mode.rs::tests`](crates/harness-server/src/auto_mode.rs) +(`advance_dispatches_reviewer_when_flag_enabled_and_policy_subagent`, +`advance_skips_dispatch_when_flag_disabled`, etc.). **Roadmap → Work bootstrap** — `POST /v1/roadmap/import`: diff --git a/apps/jarvis-web/src/App.tsx b/apps/jarvis-web/src/App.tsx index 661aed5..defe0d1 100644 --- a/apps/jarvis-web/src/App.tsx +++ b/apps/jarvis-web/src/App.tsx @@ -22,6 +22,12 @@ import { SettingsPage } from "./components/Settings/SettingsPage"; import { ProjectsPage } from "./components/Projects/ProjectsPage"; import { DocsPage } from "./components/Docs/DocsPage"; import { WorkOverviewPage } from "./components/Projects/WorkOverview/WorkOverviewPage"; +import { AutoModeDashboardPage } from "./components/AutoMode/AutoModeDashboardPage"; +import { + ConversationDeepLinkRedirect, + ConversationsArchivePage, +} from "./components/Conversations/ConversationsArchivePage"; +import { WorktreesPage } from "./components/Worktrees/WorktreesPage"; import { SubAgentDemoPage } from "./components/SubAgent/SubAgentDemoPage"; import { DesktopStartupOverlay } from "./components/Desktop/DesktopStartupOverlay"; import { useAppStore, appStore } from "./store/appStore"; @@ -72,6 +78,8 @@ export function App() { } /> } /> + } /> + } /> } /> {/* `/projects/:projectId` deep-links into a specific project's kanban so browser back, bookmarks, and sidebar links all @@ -94,6 +102,14 @@ export function App() { path="/diagnostics" element={} /> + } /> + {/* `/conversations/:id` resumes the persisted conversation + and redirects to chat. Useful for bookmarks / shared URLs + that should land back in the right thread. */} + } + /> } /> {/* SubAgent UI preview — static prototype with mocked frame data. Reachable directly only; not linked from nav. Will @@ -164,6 +180,80 @@ function DocsLayout() { ); } +// Conversation history archive — full-page browse over every +// persisted conversation. Same shell as ProjectsLayout so the +// sidebar stays put. +function ConversationsArchiveLayout() { + return ( + <> + + Skip to main content + +
+ + + + + + ); +} + +// Worktree management — sibling to the Auto-mode dashboard under +// Work mode. Lists orphan git worktrees + cleanup. +function WorktreesLayout() { + return ( + <> + + Skip to main content + +
+ + + + + + ); +} + +// Auto-mode scheduler dashboard. Sibling to Overview / List under +// the Work-mode sidebar; same shell so the sidebar nav stays put as +// the user moves between Work sub-pages. +function AutoModeDashboardLayout() { + return ( + <> + Skip to main content +
+ + + + + + ); +} + // v1.0 — full-page WorkOverview, reachable from the Work-mode // sidebar's "工作总览" link. Same shell as `ProjectsLayout` (same // `page-app projects-app` class so the sidebar layout matches); diff --git a/apps/jarvis-web/src/components/AppChatPane.tsx b/apps/jarvis-web/src/components/AppChatPane.tsx index a31c283..bfd67f1 100644 --- a/apps/jarvis-web/src/components/AppChatPane.tsx +++ b/apps/jarvis-web/src/components/AppChatPane.tsx @@ -11,6 +11,7 @@ import { AskTextCard } from "./Chat/AskTextCard"; import { ApprovalCard } from "./Approvals/ApprovalCard"; import { BypassBanner } from "./Approvals/BypassBanner"; import { ModeBadge } from "./Approvals/ModeBadge"; +import { PlanModeBanner } from "./Approvals/PlanModeBanner"; import { PlanProposedCard } from "./Approvals/PlanProposedCard"; import { ModelMenu } from "./ModelMenu/ModelMenu"; import { UsageBadge } from "./UsageBadge"; @@ -52,6 +53,7 @@ export function AppChatPane() { + diff --git a/apps/jarvis-web/src/components/AppSidebar.tsx b/apps/jarvis-web/src/components/AppSidebar.tsx index 7f6c7d3..16a0d00 100644 --- a/apps/jarvis-web/src/components/AppSidebar.tsx +++ b/apps/jarvis-web/src/components/AppSidebar.tsx @@ -140,6 +140,29 @@ function ChatSidebarBody() { <> @@ -203,6 +226,29 @@ function WorkSidebarBody() { {t("sidebarNavWorkOverview")} + "nav-item" + (isActive ? " active" : "")} + > + + {t("sidebarNavAutoMode")} + + "nav-item" + (isActive ? " active" : "")} + > + + {t("sidebarNavWorktrees")} + "nav-item" + (isActive ? " active" : "")} diff --git a/apps/jarvis-web/src/components/Approvals/PlanModeBanner.tsx b/apps/jarvis-web/src/components/Approvals/PlanModeBanner.tsx new file mode 100644 index 0000000..b2740df --- /dev/null +++ b/apps/jarvis-web/src/components/Approvals/PlanModeBanner.tsx @@ -0,0 +1,52 @@ +// Persistent banner shown above the message list when the per-socket +// permission mode is `plan` AND no plan has been proposed yet. Tells +// the user "you're in plan mode — the agent will draft a plan before +// touching anything; nothing will execute until you accept it." Once +// the agent calls `exit_plan` and a `plan_proposed` frame arrives, +// `PlanProposedCard` takes over the review dock and this banner +// becomes redundant — so we hide it whenever `proposedPlan != null`. +// +// The "Exit plan mode" button switches back to `ask` (the safest +// mode). Same pattern as BypassBanner. + +import { useAppStore } from "../../store/appStore"; +import { setSocketMode } from "../../services/permissions"; +import { t } from "../../utils/i18n"; + +export function PlanModeBanner() { + const mode = useAppStore((s) => s.permissionMode); + const proposedPlan = useAppStore((s) => s.proposedPlan); + if (mode !== "plan") return null; + // Once a plan arrives, PlanProposedCard provides much richer feedback + // and the banner becomes noise. + if (proposedPlan) return null; + return ( +
+ + + {t("permModePlanActiveBanner")} + + +
+ ); +} diff --git a/apps/jarvis-web/src/components/AutoMode/AutoModeDashboardPage.tsx b/apps/jarvis-web/src/components/AutoMode/AutoModeDashboardPage.tsx new file mode 100644 index 0000000..944c23a --- /dev/null +++ b/apps/jarvis-web/src/components/AutoMode/AutoModeDashboardPage.tsx @@ -0,0 +1,980 @@ +// Auto-mode scheduler dashboard. Shows what the background loop is +// doing right now: pause / resume toggle, scheduler config snapshot, +// in-flight runs, recent run history, recent failures, and +// blocked-by-guard requirements that the loop didn't pick up. +// +// Data flow: REST snapshots polled every 5s (no WS frame parsing — +// the dashboard is rarely the active tab and 5s lag is acceptable). +// On socket reconnect / window-focus the polling timer is reset so +// the user sees a fresh snapshot when they return. + +import { useEffect, useMemo, useState } from "react"; +import type React from "react"; +import { useNavigate } from "react-router-dom"; + +import { + AutoModeStatus, + getAutoModeStatus, + setAutoModeEnabled, +} from "../../services/autoMode"; +import { + getRun, + listFailedRuns, + listRecentRuns, + listStuckRuns, +} from "../../services/diagnostics"; +import { + listRequirements, + startRequirementRun, + subscribeRequirements, +} from "../../services/requirements"; +import { useAppStore } from "../../store/appStore"; +import type { + Project, + Requirement, + RequirementRun, + RequirementRunStatus, +} from "../../types/frames"; +import { t } from "../../utils/i18n"; +import { relTime } from "../../utils/time"; + +const POLL_INTERVAL_MS = 5_000; + +// ============================================================ +// Page +// ============================================================ + +export function AutoModeDashboardPage() { + // Subscribe to language so the whole page re-renders when the + // user toggles zh ↔ en. Without this, t() reads the new language + // on its next call but React doesn't know to re-render. + useAppStore((s) => s.lang); + const status = useAutoModeStatus(); + const inFlight = usePoll(() => listStuckRuns(0).then((rs) => rs ?? []), []); + const recent = usePoll(() => listRecentRuns(50).then((rs) => rs ?? []), []); + const failures = usePoll(() => listFailedRuns(20).then((rs) => rs ?? []), []); + const projects = useAppStore((s) => s.projects); + const requirementsCacheVersion = useRequirementsCacheVersion(); + const [openRunId, setOpenRunId] = useState(null); + + const projectsById = useMemo(() => { + const m = new Map(); + projects.forEach((p) => m.set(p.id, p)); + return m; + }, [projects]); + + const requirementsById = useMemo(() => { + const m = new Map(); + projects.forEach((p) => { + listRequirements(p.id).forEach((r) => m.set(r.id, r)); + }); + return m; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projects, requirementsCacheVersion]); + + return ( +
+
+
+

{t("autoModePageTitle")}

+

+ {t("autoModePageSubtitle")} +

+
+
+ + + +
+ + setOpenRunId(r.id)} + showAge + /> + + + + setOpenRunId(r.id)} + onRetry={(r) => { + void startRequirementRun(r.requirement_id).catch((err) => { + console.warn("retry run failed", err); + }); + }} + /> + +
+ + + setOpenRunId(r.id)} + showStatus + /> + + + + + + + + + + + {openRunId && ( + setOpenRunId(null)} /> + )} +
+ ); +} + +// ============================================================ +// Hooks +// ============================================================ + +interface AutoModeStatusState { + data: AutoModeStatus | null; + toggling: boolean; + toggle: (next: boolean) => Promise; +} + +function useAutoModeStatus(): AutoModeStatusState { + const [data, setData] = useState(null); + const [toggling, setToggling] = useState(false); + + useEffect(() => { + let mounted = true; + const tick = async () => { + const s = await getAutoModeStatus(); + if (mounted) setData(s); + }; + void tick(); + const id = window.setInterval(tick, POLL_INTERVAL_MS); + const onFocus = () => void tick(); + window.addEventListener("focus", onFocus); + return () => { + mounted = false; + window.clearInterval(id); + window.removeEventListener("focus", onFocus); + }; + }, []); + + const toggle = async (next: boolean) => { + setToggling(true); + try { + const s = await setAutoModeEnabled(next); + setData((prev) => ({ ...(prev ?? s), ...s })); + } catch (e) { + console.warn("auto-mode toggle failed", e); + } finally { + setToggling(false); + } + }; + + return { data, toggling, toggle }; +} + +function usePoll(fetcher: () => Promise, deps: unknown[]): T[] { + const [rows, setRows] = useState([]); + useEffect(() => { + let mounted = true; + const tick = async () => { + try { + const next = await fetcher(); + if (mounted) setRows(next); + } catch (e) { + console.warn("dashboard poll failed", e); + } + }; + void tick(); + const id = window.setInterval(tick, POLL_INTERVAL_MS); + const onFocus = () => void tick(); + window.addEventListener("focus", onFocus); + return () => { + mounted = false; + window.clearInterval(id); + window.removeEventListener("focus", onFocus); + }; + // Caller-controlled deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + return rows; +} + +function useRequirementsCacheVersion(): number { + const [version, setVersion] = useState(0); + useEffect(() => { + const unsub = subscribeRequirements(() => setVersion((v) => v + 1)); + return () => unsub(); + }, []); + return version; +} + +// ============================================================ +// Sub-components +// ============================================================ + +function SchedulerHeaderStrip({ status }: { status: AutoModeStatusState }) { + const data = status.data; + if (!data) { + return
{t("autoModeLoading")}
; + } + if (!data.configured) { + return
{t("autoModeNotConfigured")}
; + } + return ( +
+
+ void status.toggle(next)} + /> + + {data.enabled + ? t("autoModeSchedulerRunning") + : t("autoModeSchedulerPaused")} + +
+
+ + + + + + + +
+
+ ); +} + +function ToggleButton({ + enabled, + busy, + onChange, +}: { + enabled: boolean; + busy: boolean; + onChange: (next: boolean) => void; +}) { + const hint = enabled ? t("autoModePauseHint") : t("autoModeResumeHint"); + return ( + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function Panel({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle && {subtitle}} +
+
{children}
+
+ ); +} + +interface RunRow extends RequirementRun { + age_seconds?: number; // present on StuckRun rows +} + +function RunTable({ + rows, + requirementsById, + projectsById, + emptyText, + onOpen, + onRetry, + showAge, + showStatus, +}: { + rows: RunRow[]; + requirementsById: Map; + projectsById: Map; + emptyText: string; + onOpen: (r: RunRow) => void; + onRetry?: (r: RunRow) => void; + showAge?: boolean; + showStatus?: boolean; +}) { + if (rows.length === 0) { + return

{emptyText}

; + } + return ( +
    + {rows.map((r) => { + const req = requirementsById.get(r.requirement_id); + const project = req ? projectsById.get(req.project_id) : undefined; + return ( +
  • + +
    + + {onRetry && ( + + )} +
    +
  • + ); + })} +
+ ); +} + +function StatusBadge({ status }: { status: RequirementRunStatus }) { + return ( + + {status} + + ); +} + +function PendingTriagePanel({ + projects, + requirementsById, +}: { + projects: Project[]; + requirementsById: Map; +}) { + const navigate = useNavigate(); + const pending = useMemo(() => { + const projectsById = new Map(projects.map((p) => [p.id, p])); + const out: Array<{ req: Requirement; project: Project; source: string }> = []; + for (const req of requirementsById.values()) { + const ts = req.triage_state; + if (!ts || ts === "approved") continue; + const project = projectsById.get(req.project_id); + if (!project) continue; + const source = + ts === "proposed_by_scan" + ? t("triageSourceScan") + : t("triageSourceAgent"); + out.push({ req, project, source }); + } + // Newest first by updated_at + out.sort((a, b) => b.req.updated_at.localeCompare(a.req.updated_at)); + return out; + }, [projects, requirementsById]); + + if (pending.length === 0) { + return

{t("autoModeEmptyPendingTriage")}

; + } + + return ( +
    + {pending.map(({ req, project, source }) => ( +
  • + + + {source} + +
  • + ))} +
+ ); +} + +function BlockedRequirements({ + projects, + requirementsById, + maxRetries, + recentRuns, +}: { + projects: Project[]; + requirementsById: Map; + maxRetries: number | undefined; + recentRuns: RequirementRun[]; +}) { + const blocked = useMemo(() => { + const out: Array<{ req: Requirement; project: Project; reason: string }> = []; + const projectsById = new Map(projects.map((p) => [p.id, p])); + for (const req of requirementsById.values()) { + if (req.status !== "backlog") continue; + // Triage gate: only Approved (or absent — legacy rows) qualify + // for auto-mode pickup. ProposedBy* rows are pre-triage. + if (req.triage_state && req.triage_state !== "approved") continue; + const reasons: string[] = []; + if (!req.assignee_id) reasons.push(t("autoModeBlockedReasonAssignee")); + const deps = req.depends_on ?? []; + const unmet = deps.filter((depId) => { + const dep = requirementsById.get(depId); + return !dep || dep.status !== "done"; + }); + if (unmet.length > 0) { + reasons.push(t("autoModeBlockedReasonDeps", unmet.length)); + } + if (maxRetries != null) { + const failedRecently = recentRuns.filter( + (r) => r.requirement_id === req.id && r.status === "failed", + ).length; + if (failedRecently >= maxRetries) { + reasons.push( + t("autoModeBlockedReasonRetries", failedRecently, maxRetries), + ); + } + } + if (reasons.length === 0) continue; + const project = projectsById.get(req.project_id); + if (!project) continue; + out.push({ req, project, reason: reasons.join(" · ") }); + } + return out; + }, [projects, requirementsById, maxRetries, recentRuns]); + + if (blocked.length === 0) { + return

{t("autoModeEmptyBlocked")}

; + } + + return ( +
    + {blocked.map(({ req, project, reason }) => ( +
  • +
    + {req.title} + + {project.name} · {reason} + +
    +
  • + ))} +
+ ); +} + +function RunDetailDrawer({ runId, onClose }: { runId: string; onClose: () => void }) { + const [run, setRun] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let mounted = true; + setLoading(true); + void getRun(runId).then((r) => { + if (mounted) { + setRun(r); + setLoading(false); + } + }); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => { + mounted = false; + window.removeEventListener("keydown", onKey); + }; + }, [runId, onClose]); + + return ( +
+ +
+ ); +} + +function DetailBody({ run }: { run: RequirementRun }) { + return ( +
+ {run.id}} /> + } + /> + {run.requirement_id}} + /> + {run.conversation_id}} + /> + + {run.finished_at && ( + + )} + {run.summary && ( + {run.summary}} + /> + )} + {run.error && ( + {run.error}} + /> + )} + {run.verification && ( + + {run.verification.status} + {run.verification.notes ? ` — ${run.verification.notes}` : ""} + + } + /> + )} + {run.logs && run.logs.length > 0 && ( +
+
+ {t("runDetailLogsLabel")} +
+
    + {run.logs.map((l) => ( +
  • + + {l.created_at} + + + [{l.level}] {l.message} + +
  • + ))} +
+
+ )} +
+ ); +} + +function KV({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +// ============================================================ +// Helpers +// ============================================================ + +function fmtSeconds(s?: number): string { + if (s == null) return "—"; + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.round(s / 60)}m`; + return `${(s / 3600).toFixed(1)}h`; +} + +function formatAge(seconds: number): string { + if (seconds < 60) return t("autoModeAgeStartedSeconds", seconds); + if (seconds < 3600) + return t("autoModeAgeStartedMinutes", Math.round(seconds / 60)); + return t("autoModeAgeStartedHours", (seconds / 3600).toFixed(1)); +} + +function firstLine(s: string): string { + const ix = s.indexOf("\n"); + return ix < 0 ? s : s.slice(0, ix); +} + +function badgeColor(status: RequirementRunStatus): string { + switch (status) { + case "running": + return "#1f77ff"; + case "pending": + return "#888"; + case "completed": + return "#1f9e54"; + case "failed": + return "#d33b3b"; + case "cancelled": + return "#bf7d3a"; + default: + return "#666"; + } +} + +function badgeTextColor(_: RequirementRunStatus): string { + return "#fff"; +} + +// ============================================================ +// Inline styles — kept local so this surface ships without +// depending on style refactors elsewhere. Class-name based styling +// can come later once the shape settles. +// ============================================================ + +const pageStyle: React.CSSProperties = { + padding: 24, + display: "flex", + flexDirection: "column", + gap: 16, + overflowY: "auto", + height: "100vh", + boxSizing: "border-box", +}; + +const pageHeaderStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + gap: 16, +}; + +const headerStripStyle: React.CSSProperties = { + display: "flex", + flexWrap: "wrap", + gap: 16, + alignItems: "center", + padding: 16, + border: "1px solid var(--border, #2b2b2b)", + borderRadius: 8, + background: "var(--card-bg, rgba(255,255,255,0.02))", +}; + +const statRowStyle: React.CSSProperties = { + display: "flex", + flexWrap: "wrap", + gap: 18, + marginLeft: "auto", +}; + +const statStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 2, +}; + +const panelGridStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(420px, 1fr))", + gap: 16, +}; + +const panelStyle: React.CSSProperties = { + border: "1px solid var(--border, #2b2b2b)", + borderRadius: 8, + background: "var(--card-bg, rgba(255,255,255,0.02))", + padding: 12, +}; + +const panelHeaderStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, +}; + +const listStyle: React.CSSProperties = { + listStyle: "none", + padding: 0, + margin: 0, + display: "flex", + flexDirection: "column", + gap: 6, +}; + +const rowStyle: React.CSSProperties = { + display: "flex", + gap: 8, + alignItems: "center", + padding: "8px 10px", + border: "1px solid var(--border-subtle, rgba(255,255,255,0.06))", + borderRadius: 6, +}; + +const rowMainBtnStyle: React.CSSProperties = { + flex: 1, + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: 2, + background: "transparent", + border: "none", + cursor: "pointer", + textAlign: "left", + color: "inherit", + padding: 0, +}; + +const rowTitleStyle: React.CSSProperties = { + fontSize: 13, + fontWeight: 500, +}; + +const rowSubStyle: React.CSSProperties = { + fontSize: 11, + opacity: 0.65, +}; + +const errorLineStyle: React.CSSProperties = { + fontSize: 11, + color: "#d33b3b", + marginTop: 2, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + maxWidth: "100%", +}; + +const emptyStyle: React.CSSProperties = { + margin: 0, + padding: "16px 4px", + fontSize: 12, + opacity: 0.6, +}; + +const badgeStyle: React.CSSProperties = { + fontSize: 10, + textTransform: "uppercase", + letterSpacing: 0.4, + padding: "2px 6px", + borderRadius: 4, + fontWeight: 600, +}; + +const retryBtnStyle: React.CSSProperties = { + fontSize: 11, + padding: "4px 8px", + border: "1px solid var(--border, #2b2b2b)", + borderRadius: 4, + background: "transparent", + color: "inherit", + cursor: "pointer", +}; + +const drawerOverlayStyle: React.CSSProperties = { + position: "fixed", + inset: 0, + background: "rgba(0,0,0,0.5)", + zIndex: 200, + display: "flex", + justifyContent: "flex-end", +}; + +const drawerStyle: React.CSSProperties = { + width: "min(560px, 95vw)", + height: "100vh", + background: "var(--bg, #1a1a1a)", + borderLeft: "1px solid var(--border, #2b2b2b)", + display: "flex", + flexDirection: "column", +}; + +const drawerHeaderStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + borderBottom: "1px solid var(--border, #2b2b2b)", +}; + +const closeBtnStyle: React.CSSProperties = { + fontSize: 22, + background: "transparent", + border: "none", + color: "inherit", + cursor: "pointer", + padding: 0, + width: 32, + height: 32, +}; + +const preStyle: React.CSSProperties = { + margin: 0, + padding: 8, + background: "rgba(0,0,0,0.25)", + borderRadius: 4, + fontSize: 12, + whiteSpace: "pre-wrap", + wordBreak: "break-word", +}; + +const logRowStyle: React.CSSProperties = { + fontSize: 12, + fontFamily: "var(--mono-font, ui-monospace, SFMono-Regular, Menlo, monospace)", + padding: "4px 6px", + background: "rgba(0,0,0,0.2)", + borderRadius: 4, +}; diff --git a/apps/jarvis-web/src/components/Conversations/ConversationsArchivePage.tsx b/apps/jarvis-web/src/components/Conversations/ConversationsArchivePage.tsx new file mode 100644 index 0000000..12a6fa1 --- /dev/null +++ b/apps/jarvis-web/src/components/Conversations/ConversationsArchivePage.tsx @@ -0,0 +1,310 @@ +// Full-page conversation history browse. Sibling to the sidebar's +// ConvoList (which is space-constrained and only shows recent rows); +// this page surfaces every persisted conversation with a search box, +// project filter, and date grouping. Click a row → resume the +// conversation and land on the chat layout. +// +// Deep-link: `/conversations/:id` directly resumes that id and +// redirects to `/`. Useful for chat URLs that survive bookmarks / +// browser back even when the row scrolled out of the sidebar. + +import { useEffect, useMemo, useState } from "react"; +import { Navigate, useNavigate, useParams } from "react-router-dom"; + +import { resumeConversation, refreshConvoList } from "../../services/conversations"; +import { useAppStore } from "../../store/appStore"; +import type { ConvoListRow } from "../../types/frames"; +import { t } from "../../utils/i18n"; +import { convoGroupLabel, relTime } from "../../utils/time"; + +/// `/conversations/:id` — resumes the conversation server-side then +/// redirects to chat. Renders nothing visible. +export function ConversationDeepLinkRedirect() { + const { id } = useParams<{ id: string }>(); + const [done, setDone] = useState(false); + useEffect(() => { + if (!id) return; + void resumeConversation(id).finally(() => setDone(true)); + }, [id]); + if (!id) return ; + if (!done) return null; + return ; +} + +/// `/conversations` — full-page archive browse. +export function ConversationsArchivePage() { + // Subscribe to lang for re-render on toggle + useAppStore((s) => s.lang); + const rows = useAppStore((s) => s.convoRows); + const projects = useAppStore((s) => s.projects); + const persistEnabled = useAppStore((s) => s.persistEnabled); + const [query, setQuery] = useState(""); + const [projectFilter, setProjectFilter] = useState("all"); + const navigate = useNavigate(); + + useEffect(() => { + void refreshConvoList(); + }, []); + + const projectsById = useMemo(() => { + const m = new Map(); + projects.forEach((p) => m.set(p.id, p.name)); + return m; + }, [projects]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return rows.filter((row) => { + if (projectFilter !== "all" && row.project_id !== projectFilter) { + return false; + } + if (!q) return true; + const haystack = [row.title, row.id, row.requirement_title] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(q); + }); + }, [rows, query, projectFilter]); + + const grouped = useMemo(() => groupByDate(filtered), [filtered]); + + return ( +
+
+
+

+ {t("conversationsArchiveTitle")} +

+

+ {t("conversationsArchiveSubtitle")} +

+
+
+ setQuery(e.target.value)} + placeholder={t("conversationsArchiveSearchPlaceholder")} + aria-label={t("conversationsArchiveSearchAria")} + style={searchInputStyle} + /> + +
+
+ + {!persistEnabled ? ( +
+

{t("conversationsArchivePersistDisabled")}

+
+ ) : rows.length === 0 ? ( +
+

{t("conversationsArchiveEmpty")}

+
+ ) : filtered.length === 0 ? ( +
+

{t("conversationsArchiveNoMatch")}

+
+ ) : ( + grouped.map(({ label, items }) => ( +
+
+

+ {label} +

+ + {t("conversationsArchiveGroupCount", items.length)} + +
+
    + {items.map((row) => { + const projectName = + row.project_id && projectsById.get(row.project_id); + return ( +
  • + +
  • + ); + })} +
+
+ )) + )} +
+ ); +} + +// ============================================================ +// Helpers +// ============================================================ + +function groupByDate( + rows: ConvoListRow[], +): { label: string; items: ConvoListRow[] }[] { + const map = new Map(); + // Preserve insertion order so the "newest" group sticks to the top + // when the input is already sorted desc by updated_at. + const order: string[] = []; + for (const r of rows) { + const label = convoGroupLabel(r); + if (!map.has(label)) { + map.set(label, []); + order.push(label); + } + map.get(label)!.push(r); + } + return order.map((label) => ({ label, items: map.get(label)! })); +} + +// ============================================================ +// Inline styles +// ============================================================ + +const pageStyle: React.CSSProperties = { + padding: 24, + display: "flex", + flexDirection: "column", + gap: 16, + overflowY: "auto", + height: "100vh", + boxSizing: "border-box", +}; + +const pageHeaderStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + gap: 16, + flexWrap: "wrap", +}; + +const panelStyle: React.CSSProperties = { + border: "1px solid var(--border, #2b2b2b)", + borderRadius: 8, + background: "var(--card-bg, rgba(255,255,255,0.02))", + padding: 12, +}; + +const groupHeaderStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, +}; + +const listStyle: React.CSSProperties = { + listStyle: "none", + padding: 0, + margin: 0, + display: "flex", + flexDirection: "column", + gap: 6, +}; + +const rowStyle: React.CSSProperties = { + display: "flex", + gap: 8, + alignItems: "center", + padding: "8px 10px", + border: "1px solid var(--border-subtle, rgba(255,255,255,0.06))", + borderRadius: 6, +}; + +const rowMainBtnStyle: React.CSSProperties = { + flex: 1, + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: 2, + background: "transparent", + border: "none", + cursor: "pointer", + textAlign: "left", + color: "inherit", + padding: 0, +}; + +const rowTitleStyle: React.CSSProperties = { + fontSize: 13, + fontWeight: 500, +}; + +const rowSubStyle: React.CSSProperties = { + fontSize: 11, + opacity: 0.65, +}; + +const rowMetaStyle: React.CSSProperties = { + fontSize: 11, + opacity: 0.55, + marginTop: 2, +}; + +const emptyStyle: React.CSSProperties = { + margin: 0, + padding: "16px 4px", + fontSize: 12, + opacity: 0.6, +}; + +const searchInputStyle: React.CSSProperties = { + background: "var(--input-bg, rgba(255,255,255,0.04))", + border: "1px solid var(--border, #2b2b2b)", + borderRadius: 6, + padding: "6px 10px", + color: "inherit", + fontSize: 13, + minWidth: 220, +}; + +const selectStyle: React.CSSProperties = { + background: "var(--input-bg, rgba(255,255,255,0.04))", + border: "1px solid var(--border, #2b2b2b)", + borderRadius: 6, + padding: "6px 10px", + color: "inherit", + fontSize: 13, +}; diff --git a/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx b/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx index 0e95b51..d9c9009 100644 --- a/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx +++ b/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx @@ -775,6 +775,14 @@ function RequirementCard({ {sessions}× )} + {requirement.acceptance_policy === "human" && ( + + {t("reqAcceptancePolicyHumanBadge")} + + )} {t("reqClickHint")}
@@ -815,6 +823,7 @@ function TriageDrawer({ onOpenDetail: (id: string) => void; }) { const [collapsed, setCollapsed] = useState(false); + const [bulkPending, setBulkPending] = useState(false); const handleApprove = async (id: string) => { await approveRequirement(id); @@ -834,19 +843,57 @@ function TriageDrawer({ } }; + /// Bulk approve fires every visible candidate's approve in parallel. + /// We cap parallelism implicitly — the candidate set is the whole + /// drawer (already filtered to "Backlog + needs triage"), so the + /// browser's native HTTP queueing is enough headroom. Optimistic + /// updates land per-row as each promise resolves; the WS frame + /// reconciliation cleans up if the server rejects any. + const handleApproveAll = async () => { + if (bulkPending) return; + if (!window.confirm(t("triageApproveAllConfirm", candidates.length))) { + return; + } + setBulkPending(true); + const settled = await Promise.allSettled( + candidates.map((c) => approveRequirement(c.id)), + ); + const failed = settled.filter((s) => s.status === "rejected").length; + setBulkPending(false); + onChanged(); + if (failed > 0) { + console.warn(`triage: ${failed}/${candidates.length} approve failed`); + } + }; + return (
{t("triageHeader", candidates.length)} - +
+ {candidates.length >= 2 && ( + + )} + +
{!collapsed && (
    diff --git a/apps/jarvis-web/src/components/Projects/RequirementDetail.tsx b/apps/jarvis-web/src/components/Projects/RequirementDetail.tsx index f02bbd2..b14a68b 100644 --- a/apps/jarvis-web/src/components/Projects/RequirementDetail.tsx +++ b/apps/jarvis-web/src/components/Projects/RequirementDetail.tsx @@ -308,6 +308,8 @@ export function RequirementDetail({ + + ); } + +// ============================================================= +// Acceptance-policy row — v1.0 SubAgent. +// ============================================================= +// +// Lets the user flip Review→Done auto-acceptance between the +// reviewer subagent (default) and human-only. Server treats absence +// of the field as the default (`subagent`), so we coerce undefined +// to "subagent" for display + write the selection through a +// PATCH /v1/requirements/:id with `acceptance_policy`. Activity log +// records the flip. + +function AcceptancePolicyRow({ requirement }: { requirement: Requirement }) { + const current = requirement.acceptance_policy ?? "subagent"; + const onChange = (next: string) => { + if (next !== "subagent" && next !== "human") return; + if (next === current) return; + updateRequirement(requirement.id, { acceptance_policy: next }); + }; + return ( +
    + + {t("reqAcceptancePolicyLabel")} + +