diff --git a/CLAUDE.md b/CLAUDE.md index 5b3c572..dd3dbf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ only 2 ever run in parallel. WORKFLOW.md alias: `agent.max_concurrent_agents` (was misnamed in v1.0 to mean 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 +`JARVIS_WORK_RUN_TIMEOUT_MS` (default `600000` — 10 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 diff --git a/Cargo.lock b/Cargo.lock index 3db2faf..0c5daaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2097,6 +2097,7 @@ dependencies = [ "harness-requirement", "harness-skill", "harness-store", + "harness-tools", "include_dir", "portable-pty", "reqwest 0.12.28", diff --git a/apps/jarvis-cli/src/telemetry.rs b/apps/jarvis-cli/src/telemetry.rs index e7b9fd4..a031a69 100644 --- a/apps/jarvis-cli/src/telemetry.rs +++ b/apps/jarvis-cli/src/telemetry.rs @@ -7,8 +7,9 @@ //! //! The CLI defaults its `EnvFilter` baseline to `warn` (vs the //! server's `info`) so streamed assistant text on stdout stays -//! pipe-clean. The baseline is bumped to `info` automatically when -//! OTel is enabled — otherwise the exporter would have nothing to +//! pipe-clean. OTLP export is enabled by default and can be disabled +//! with `JARVIS_OTEL_ENABLED=0` / `false`; when enabled, the baseline +//! is bumped to `info` automatically so the exporter has spans to //! send. use opentelemetry::trace::TracerProvider as _; @@ -32,15 +33,15 @@ impl Drop for TelemetryGuard { } } -fn env_flag(key: &str) -> bool { +fn env_flag_default(key: &str, default: bool) -> bool { match std::env::var(key) { Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"), - Err(_) => false, + Err(_) => default, } } pub fn init() -> TelemetryGuard { - let enabled = env_flag("JARVIS_OTEL_ENABLED"); + let enabled = env_flag_default("JARVIS_OTEL_ENABLED", true); let baseline = if std::env::var("RUST_LOG").is_err() && enabled { "info" } else { diff --git a/apps/jarvis-web/src/components/AppSidebar.test.tsx b/apps/jarvis-web/src/components/AppSidebar.test.tsx index f3ead10..a781479 100644 --- a/apps/jarvis-web/src/components/AppSidebar.test.tsx +++ b/apps/jarvis-web/src/components/AppSidebar.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it } from "vitest"; import { AppSidebar } from "./AppSidebar"; import { useAppStore } from "../store/appStore"; +import { handleFrameForConversation } from "../services/frames"; // AppSidebar embeds AccountMenu which uses ``, // and react-router-dom's Link blows up without a router ancestor. @@ -23,11 +24,18 @@ afterEach(() => { useAppStore.getState().setActiveProjectFilter(null); useAppStore.getState().setDraftProjectId(null); useAppStore.getState().setDraftWorkspace(null); + useAppStore.getState().setConvoLayoutMode("project"); + useAppStore.getState().setConvoSortBy("updated"); + useAppStore.getState().setConvoVisibility("all"); + useAppStore.getState().setConvoSectionOrder("projectsFirst"); useAppStore.setState({ messages: [], + pinned: new Set(), conversationRuns: {}, conversationSurfaces: {}, + conversationUnread: {}, }); + localStorage.removeItem("jarvis.convo.pinned"); }); describe("AppSidebar search", () => { @@ -95,7 +103,7 @@ describe("AppSidebar search", () => { expect(useAppStore.getState().quickOpen).toBe(true); }); - it("surfaces active background turns in the running section", () => { + it("surfaces active background turns in their original list position", () => { useAppStore.getState().setConvoRows([ { id: "run-12345678", @@ -116,11 +124,234 @@ describe("AppSidebar search", () => { renderWithRouter(); - const runningSection = document.querySelector("#running-section")!; - const recentsSection = document.querySelector(".recents-section")!; - expect(within(runningSection as HTMLElement).getByText("Background build")).toBeInTheDocument(); - expect(within(runningSection as HTMLElement).queryByText("Idle notes")).not.toBeInTheDocument(); - expect(within(recentsSection as HTMLElement).queryByText("Background build")).not.toBeInTheDocument(); + expect(document.querySelector("#running-section")).toBeNull(); + expect(screen.getByText("Background build")).toBeInTheDocument(); + expect( + document.querySelector('li[data-id="run-12345678"][data-run-status="running"] .convo-spinner'), + ).not.toBeNull(); + }); + + it("renders project and conversation sections as peers, keeps pinned rows separate, and limits groups to five", () => { + useAppStore.getState().setConvoLayoutMode("project"); + useAppStore.getState().setProjects([ + { + id: "proj-1", + slug: "jarvis", + name: "Jarvis", + instructions: "", + tags: [], + archived: false, + created_at: "2026-04-20T00:00:00Z", + updated_at: "2026-04-20T00:00:00Z", + }, + { + id: "proj-empty", + slug: "empty", + name: "Empty project", + instructions: "", + tags: [], + archived: false, + created_at: "2026-04-19T00:00:00Z", + updated_at: "2026-04-19T00:00:00Z", + }, + ]); + useAppStore.getState().setConvoRows([ + { + id: "free-1", + title: "Free chat row", + message_count: 1, + created_at: "2026-04-26T00:00:00Z", + updated_at: "2026-04-26T00:00:00Z", + }, + { + id: "pinned-project", + title: "Pinned project row", + message_count: 1, + project_id: "proj-1", + created_at: "2026-04-26T00:00:00Z", + updated_at: "2026-04-26T00:00:00Z", + }, + ...Array.from({ length: 6 }, (_, i) => ({ + id: `project-${i + 1}`, + title: `Project row ${i + 1}`, + message_count: 1, + project_id: "proj-1", + created_at: `2026-04-2${i}T00:00:00Z`, + updated_at: `2026-04-2${i}T00:00:00Z`, + })), + ]); + useAppStore.getState().togglePin("pinned-project"); + + renderWithRouter(); + + const pinnedSection = document.querySelector("#pinned-section") as HTMLElement; + const projectSection = document.querySelector(".convo-section-projects") as HTMLElement; + const conversationSection = document.querySelector(".convo-section-conversations") as HTMLElement; + expect(within(pinnedSection).getByText("Pinned project row")).toBeInTheDocument(); + expect(within(projectSection).queryByText("Pinned project row")).not.toBeInTheDocument(); + expect(within(conversationSection).queryByText("Pinned project row")).not.toBeInTheDocument(); + expect(within(projectSection).getByRole("button", { name: /Projects/ })).toBeInTheDocument(); + expect(within(conversationSection).getByRole("button", { name: /Chats/ })).toBeInTheDocument(); + expect( + Array.from(projectSection.querySelectorAll(".convo-group-label span")).some( + (el) => el.textContent === "Jarvis", + ), + ).toBe(true); + expect(within(conversationSection).getByText("Free chat row")).toBeInTheDocument(); + expect(within(projectSection).queryByText("Free chat row")).not.toBeInTheDocument(); + expect(within(projectSection).getByText("Project row 6")).toBeInTheDocument(); + expect(screen.queryByText("Project row 1")).not.toBeInTheDocument(); + expect(within(projectSection).getByText("No conversations")).toBeInTheDocument(); + + fireEvent.click(within(projectSection).getByRole("button", { name: /Projects/ })); + expect(within(projectSection).queryByText("Project row 6")).not.toBeInTheDocument(); + expect(within(projectSection).queryByRole("menu")).not.toBeInTheDocument(); + fireEvent.click(within(projectSection).getByRole("button", { name: /Projects/ })); + expect(within(projectSection).getByText("Project row 6")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Show more" })); + + expect(screen.getByText("Project row 1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show less" })).toBeInTheDocument(); + }); + + it("marks background conversation frames as unread locally", () => { + useAppStore.setState({ activeId: "active-12345678" }); + useAppStore.getState().setConvoRows([ + { + id: "active-12345678", + title: "Active chat", + message_count: 1, + created_at: "2026-04-26T00:00:00Z", + updated_at: "2026-04-26T00:00:00Z", + }, + { + id: "background-12345678", + title: "Background chat", + message_count: 1, + created_at: "2026-04-26T00:00:00Z", + updated_at: "2026-04-26T00:00:00Z", + }, + ]); + handleFrameForConversation("background-12345678", { + type: "assistant_message", + message: { role: "assistant", content: "done" }, + }); + + renderWithRouter(); + + expect( + screen.getByRole("button", { name: /Background chat.*1 unread/ }), + ).toBeInTheDocument(); + }); + + it("applies organize menu layout, sort, visibility, and section order controls", () => { + useAppStore.getState().setActiveProjectFilter("proj-a"); + useAppStore.getState().setDraftWorkspace("/repo/a", null); + useAppStore.getState().setProjects([ + { + id: "proj-a", + slug: "alpha", + name: "Alpha project", + instructions: "", + tags: [], + archived: false, + created_at: "2026-04-10T00:00:00Z", + updated_at: "2026-04-20T00:00:00Z", + }, + { + id: "proj-b", + slug: "beta", + name: "Beta project", + instructions: "", + tags: [], + archived: false, + created_at: "2026-04-11T00:00:00Z", + updated_at: "2026-04-21T00:00:00Z", + }, + { + id: "proj-empty", + slug: "empty", + name: "Empty project", + instructions: "", + tags: [], + archived: false, + created_at: "2026-04-12T00:00:00Z", + updated_at: "2026-04-12T00:00:00Z", + }, + ]); + useAppStore.getState().setConvoRows([ + { + id: "project-a-row", + title: "Alpha updated first", + message_count: 1, + project_id: "proj-a", + workspace_path: "/repo/a", + created_at: "2026-04-20T00:00:00Z", + updated_at: "2026-04-26T00:00:00Z", + }, + { + id: "project-b-row", + title: "Beta created first", + message_count: 1, + project_id: "proj-b", + workspace_path: "/repo/b", + created_at: "2026-04-24T00:00:00Z", + updated_at: "2026-04-25T00:00:00Z", + }, + { + id: "free-related", + title: "Workspace related chat", + message_count: 1, + workspace_path: "/repo/a", + created_at: "2026-04-23T00:00:00Z", + updated_at: "2026-04-23T00:00:00Z", + }, + { + id: "free-other", + title: "Unrelated chat", + message_count: 1, + workspace_path: "/repo/b", + created_at: "2026-04-22T00:00:00Z", + updated_at: "2026-04-22T00:00:00Z", + }, + ]); + + renderWithRouter(); + + let projectSection = document.querySelector(".convo-section-projects") as HTMLElement; + fireEvent.click(within(projectSection).getByRole("button", { name: "Organize" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Recent projects/ })); + projectSection = document.querySelector(".convo-section-projects") as HTMLElement; + expect(within(projectSection).queryByText("Empty project")).not.toBeInTheDocument(); + + fireEvent.click(within(projectSection).getByRole("button", { name: "Organize" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Chronological/ })); + projectSection = document.querySelector(".convo-section-projects") as HTMLElement; + expect(projectSection.querySelector(".convo-project-group")).toBeNull(); + expect(within(projectSection).getByText("Alpha updated first")).toBeInTheDocument(); + expect(within(projectSection).getByText("Beta created first")).toBeInTheDocument(); + + fireEvent.click(within(projectSection).getByRole("button", { name: "Organize" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Created time/ })); + projectSection = document.querySelector(".convo-section-projects") as HTMLElement; + expect(projectSection.textContent?.indexOf("Beta created first")).toBeLessThan( + projectSection.textContent?.indexOf("Alpha updated first") ?? 0, + ); + + fireEvent.click(within(projectSection).getByRole("button", { name: "Organize" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Related/ })); + projectSection = document.querySelector(".convo-section-projects") as HTMLElement; + const conversationSection = document.querySelector(".convo-section-conversations") as HTMLElement; + expect(within(projectSection).getByText("Alpha updated first")).toBeInTheDocument(); + expect(within(projectSection).queryByText("Beta created first")).not.toBeInTheDocument(); + expect(within(conversationSection).getByText("Workspace related chat")).toBeInTheDocument(); + expect(within(conversationSection).queryByText("Unrelated chat")).not.toBeInTheDocument(); + + fireEvent.click(within(conversationSection).getByRole("button", { name: "Organize" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Move up" })); + const firstSection = document.querySelector(".convo-rail-sections .convo-section") as HTMLElement; + expect(firstSection).toHaveClass("convo-section-conversations"); }); it("starts a draft conversation from the sidebar and preserves the current context", () => { diff --git a/apps/jarvis-web/src/components/AutoMode/AutoModeDashboardPage.tsx b/apps/jarvis-web/src/components/AutoMode/AutoModeDashboardPage.tsx index 944c23a..9091429 100644 --- a/apps/jarvis-web/src/components/AutoMode/AutoModeDashboardPage.tsx +++ b/apps/jarvis-web/src/components/AutoMode/AutoModeDashboardPage.tsx @@ -548,7 +548,6 @@ function BlockedRequirements({ // 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); diff --git a/apps/jarvis-web/src/components/Chat/AgentLoadingFooter.tsx b/apps/jarvis-web/src/components/Chat/AgentLoadingFooter.tsx index 963ea7a..8059680 100644 --- a/apps/jarvis-web/src/components/Chat/AgentLoadingFooter.tsx +++ b/apps/jarvis-web/src/components/Chat/AgentLoadingFooter.tsx @@ -1,32 +1,13 @@ // Jarvis loading footer. // // Pinned to the bottom of `` whenever the agent loop -// is running. Visual keeps the compact timer from the previous -// footer, but swaps the generic sparkle for a tiny Lottie mascot -// based on the Jarvis app icon: -// -// [Jarvis thinking] 3m 1s · ↓ 2.4k tokens -// -// where: -// - the mascot wiggles, blinks, and fires little thought sparks -// while the turn is in flight -// - "3m 1s" is the elapsed wall-clock since the user pressed -// Send (sourced from `appStore.turnStartedAt`) -// - "↓ 2.4k tokens" is the cumulative LLM-generated token count -// for the current turn (`completion + reasoning` from -// `appStore.usage`); the down arrow signals "received from -// the model" -// -// The footer covers every silent moment the XMarkdown `▋` cursor -// doesn't: pre-first-delta thinking, in-flight tool execution, -// and the gap between iterations of a multi-step turn. We don't -// re-mention "Thinking" / "Running shell.exec" inline — the bubble -// timeline above already tells that story; this footer is purely -// the "still working, here's the cost" reassurance line. +// is running. The visual is deliberately quiet: three breathing +// dots, elapsed wall-clock, and optional generated-token count. +// It covers silent moments before the first delta, during tool +// execution, and between iterations of a multi-step turn. import { useEffect, useState } from "react"; import { useAppStore } from "../../store/appStore"; -import { JarvisThinkingLottie } from "./JarvisThinkingLottie"; export function AgentLoadingFooter() { const inFlight = useAppStore((s) => s.inFlight); @@ -59,7 +40,11 @@ export function AgentLoadingFooter() { return (
- +
-
- {tx(`customizeNav${capitalize(tab)}`, currentMeta.label)} - {stats[tab].hint} + setQuery(e.target.value)} + placeholder={tx("customizeManageSearch", "Search installed")} + /> +
{tab === "plugins" ? ( - + + ) : null} + {tab === "mcp" ? ( + + ) : null} + {tab === "skills" ? ( + ) : null} - {tab === "mcp" ? : null} - {tab === "skills" ? : null}
@@ -342,6 +358,7 @@ type PluginMarketState = function PluginMarketHome({ onInstalled }: { onInstalled: () => void }) { const [query, setQuery] = useState(""); const [state, setState] = useState({ kind: "loading" }); + const [visibleCount, setVisibleCount] = useState(PLUGIN_MARKET_PAGE_SIZE); const [installing, setInstalling] = useState(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); @@ -379,6 +396,18 @@ function PluginMarketHome({ onInstalled }: { onInstalled: () => void }) { }); }, [query, state]); + useEffect(() => { + setVisibleCount(PLUGIN_MARKET_PAGE_SIZE); + }, [query]); + + const visibleEntries = filtered.slice(0, visibleCount); + const hasMore = filtered.length > visibleCount; + const loadMore = () => { + if (!hasMore) return; + setVisibleCount((count) => Math.min(count + PLUGIN_MARKET_PAGE_SIZE, filtered.length)); + }; + const loadMoreRef = useAutoLoadMore(hasMore, loadMore); + const install = async (entry: MarketplaceEntry) => { setInstalling(entry.value); setMessage(null); @@ -426,7 +455,7 @@ function PluginMarketHome({ onInstalled }: { onInstalled: () => void }) { ) : null} {state.kind === "ready" ? (
    - {filtered.map((entry) => { + {visibleEntries.map((entry) => { const installed = state.installed.has(entry.value); return (
  • @@ -449,12 +478,61 @@ function PluginMarketHome({ onInstalled }: { onInstalled: () => void }) { })}
) : null} + {message &&
{message}
} {error &&
{error}
} ); } +function useAutoLoadMore(enabled: boolean, onLoadMore: () => void) { + const sentinelRef = useRef(null); + const onLoadMoreRef = useRef(onLoadMore); + + useEffect(() => { + onLoadMoreRef.current = onLoadMore; + }, [onLoadMore]); + + useEffect(() => { + if (!enabled) return; + if (typeof IntersectionObserver === "undefined") return; + const node = sentinelRef.current; + if (!node) return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + onLoadMoreRef.current(); + } + }, { rootMargin: "240px 0px" }); + observer.observe(node); + return () => observer.disconnect(); + }, [enabled]); + + return sentinelRef; +} + +function CustomizeLoadMore({ + refEl, + hasMore, + onLoadMore, +}: { + refEl: RefObject; + hasMore: boolean; + onLoadMore: () => void; +}) { + if (!hasMore) return null; + return ( +
+ +
+ ); +} + function activateInstalledSkills(skillNames: string[]) { if (skillNames.length === 0) return; const sent = skillNames.filter((name) => sendFrame({ type: "activate_skill", name })); diff --git a/apps/jarvis-web/src/components/Customize/MarketPanels.tsx b/apps/jarvis-web/src/components/Customize/MarketPanels.tsx index fb59dbf..b719fc0 100644 --- a/apps/jarvis-web/src/components/Customize/MarketPanels.tsx +++ b/apps/jarvis-web/src/components/Customize/MarketPanels.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type FormEvent, type RefObject } from "react"; import { addMcpServer } from "../../services/mcp"; import { installSkillFromMarket, @@ -22,30 +22,56 @@ type MarketState = | { kind: "ready"; entries: T[] } | { kind: "error"; message: string }; +const SKILL_PAGE_SIZE = 30; +const SKILL_MAX_LIMIT = 100; +const MCP_PAGE_SIZE = 20; +const MCP_MAX_LIMIT = 50; + export function SkillMarketPanel({ onInstalled }: { onInstalled?: () => void }) { const [query, setQuery] = useState(""); const [state, setState] = useState>({ kind: "loading" }); + const [limit, setLimit] = useState(SKILL_PAGE_SIZE); + const [loadingMore, setLoadingMore] = useState(false); const [installing, setInstalling] = useState(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); - const refresh = (q = query) => { - setState({ kind: "loading" }); - searchSkillMarket(q) + const refresh = (q = query, nextLimit = limit, mode: "replace" | "append" = "replace") => { + if (mode === "replace") { + setState({ kind: "loading" }); + } else { + setLoadingMore(true); + } + searchSkillMarket(q, nextLimit) .then((entries) => setState({ kind: "ready", entries })) - .catch((e: unknown) => setState({ kind: "error", message: String(e) })); + .catch((e: unknown) => setState({ kind: "error", message: String(e) })) + .finally(() => setLoadingMore(false)); }; useEffect(() => { - refresh(""); + refresh("", SKILL_PAGE_SIZE); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const submit = (e: FormEvent) => { e.preventDefault(); - refresh(query); + setLimit(SKILL_PAGE_SIZE); + refresh(query, SKILL_PAGE_SIZE); + }; + + const hasMore = state.kind === "ready" + && state.entries.length >= limit + && limit < SKILL_MAX_LIMIT; + + const loadMore = () => { + if (!hasMore || loadingMore) return; + const nextLimit = Math.min(limit + SKILL_PAGE_SIZE, SKILL_MAX_LIMIT); + setLimit(nextLimit); + refresh(query, nextLimit, "append"); }; + const loadMoreRef = useAutoLoadMore(hasMore && !loadingMore, loadMore); + const install = async (entry: MarketSkillEntry) => { const key = `${entry.source}/${entry.skillId}`; setInstalling(key); @@ -74,6 +100,12 @@ export function SkillMarketPanel({ onInstalled }: { onInstalled?: () => void }) placeholder={tx("marketSkillSearchPlaceholder", "Search pnpm, docs, git…")} /> {renderSkillMarket(state, installing, (entry) => { void install(entry); })} + {message &&
{message}
} {error &&
{error}
} @@ -83,27 +115,48 @@ export function SkillMarketPanel({ onInstalled }: { onInstalled?: () => void }) export function McpMarketPanel({ onInstalled }: { onInstalled?: () => void }) { const [query, setQuery] = useState(""); const [state, setState] = useState>({ kind: "loading" }); + const [limit, setLimit] = useState(MCP_PAGE_SIZE); + const [loadingMore, setLoadingMore] = useState(false); const [installing, setInstalling] = useState(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); - const refresh = (q = query) => { - setState({ kind: "loading" }); - searchMcpMarket(q) + const refresh = (q = query, nextLimit = limit, mode: "replace" | "append" = "replace") => { + if (mode === "replace") { + setState({ kind: "loading" }); + } else { + setLoadingMore(true); + } + searchMcpMarket(q, nextLimit) .then((entries) => setState({ kind: "ready", entries })) - .catch((e: unknown) => setState({ kind: "error", message: String(e) })); + .catch((e: unknown) => setState({ kind: "error", message: String(e) })) + .finally(() => setLoadingMore(false)); }; useEffect(() => { - refresh(""); + refresh("", MCP_PAGE_SIZE); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const submit = (e: FormEvent) => { e.preventDefault(); - refresh(query); + setLimit(MCP_PAGE_SIZE); + refresh(query, MCP_PAGE_SIZE); }; + const hasMore = state.kind === "ready" + && state.entries.length >= limit + && limit < MCP_MAX_LIMIT; + + const loadMore = () => { + if (!hasMore || loadingMore) return; + const nextLimit = Math.min(limit + MCP_PAGE_SIZE, MCP_MAX_LIMIT); + setLimit(nextLimit); + refresh(query, nextLimit, "append"); + }; + + const loadMoreRef = useAutoLoadMore(hasMore && !loadingMore, loadMore); + const install = async (entry: MarketMcpEntry) => { const cfg = mcpConfigFromMarketEntry(entry); if (!cfg) return; @@ -132,12 +185,69 @@ export function McpMarketPanel({ onInstalled }: { onInstalled?: () => void }) { placeholder={tx("marketMcpSearchPlaceholder", "Search filesystem, git, browser…")} /> {renderMcpMarket(state, installing, (entry) => { void install(entry); })} + {message &&
{message}
} {error &&
{error}
} ); } +function useAutoLoadMore(enabled: boolean, onLoadMore: () => void) { + const sentinelRef = useRef(null); + const onLoadMoreRef = useRef(onLoadMore); + + useEffect(() => { + onLoadMoreRef.current = onLoadMore; + }, [onLoadMore]); + + useEffect(() => { + if (!enabled) return; + if (typeof IntersectionObserver === "undefined") return; + const node = sentinelRef.current; + if (!node) return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + onLoadMoreRef.current(); + } + }, { rootMargin: "240px 0px" }); + observer.observe(node); + return () => observer.disconnect(); + }, [enabled]); + + return sentinelRef; +} + +function MarketLoadMore({ + refEl, + hasMore, + loading, + onLoadMore, +}: { + refEl: RefObject; + hasMore: boolean; + loading: boolean; + onLoadMore: () => void; +}) { + if (!hasMore) return null; + return ( +
+ +
+ ); +} + function MarketHeader({ title, hint, diff --git a/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx b/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx index 733a24a..c715cc0 100644 --- a/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx +++ b/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx @@ -170,21 +170,28 @@ export function ProjectBoard({ return { boardRequirements: board, triageRequirements: triage }; }, [filteredRequirements]); - const grouped = useMemo(() => { + const { grouped, orphans } = useMemo(() => { // Seed with the active column ids so empty lanes still render. // Requirements whose `status` doesn't match any current column id - // (e.g. a column was just removed) land in a synthetic bucket - // keyed by the orphan status — they're not displayed on the - // board, but the data isn't lost on disk and a future column - // edit can restore them. + // (e.g. a column was just removed) collect into a separate + // bucket and surface as a warning above the board so the data + // doesn't silently vanish when columns are edited. const map: Record = {}; for (const c of cols) map[c.id] = []; + const orphans: Requirement[] = []; for (const r of boardRequirements) { if (map[r.status]) map[r.status].push(r); + else orphans.push(r); } - return map; + return { grouped: map, orphans }; }, [boardRequirements, cols]); + const orphanStatuses = useMemo(() => { + const set = new Set(); + for (const r of orphans) set.add(r.status); + return Array.from(set); + }, [orphans]); + return (
@@ -467,6 +474,25 @@ export function ProjectBoard({ /> )} + {orphans.length > 0 && ( +
+ + {t("boardOrphanedHint", orphans.length, orphanStatuses[0] ?? "")} + + +
+ )} + {/* Tailwind utility on the kanban frame: ensures the columns row can shrink against the sidebar without forcing a horizontal scrollbar from the legacy `min-width: 0` plumbing. The @@ -804,14 +830,6 @@ function RequirementCard({ {sessions}× )} - {requirement.acceptance_policy === "human" && ( - - {t("reqAcceptancePolicyHumanBadge")} - - )} {t("reqClickHint")}
diff --git a/apps/jarvis-web/src/components/Projects/RequirementDetail.tsx b/apps/jarvis-web/src/components/Projects/RequirementDetail.tsx index be594a3..edef847 100644 --- a/apps/jarvis-web/src/components/Projects/RequirementDetail.tsx +++ b/apps/jarvis-web/src/components/Projects/RequirementDetail.tsx @@ -8,7 +8,6 @@ import type { RequirementRunStatus, RequirementStatus, RequirementTodo, - RequirementTodoKind, RequirementTodoStatus, VerificationStatus, } from "../../types/frames"; @@ -22,18 +21,15 @@ import { listRunsForRequirement, loadActivitiesForRequirement, loadRunsForRequirement, - createRequirementTodo, - deleteRequirementTodo, rejectRequirement, startRequirementRun, subscribeRequirementActivities, subscribeRequirementRuns, - updateRequirementTodo, updateRequirement, verifyRunByCommands, } from "../../services/requirements"; import { pickedRouting } from "../../services/socket"; -import { Select } from "../ui"; +import { Modal, Select } from "../ui"; import type { BoardColumn } from "./columns"; import { MarkdownLite } from "./MarkdownLite"; import { ActivityList } from "./activityRow"; @@ -92,6 +88,7 @@ export function RequirementDetail({ // React's hooks-order check when the detail panel opens. const [startError, setStartError] = useState(null); const [starting, setStarting] = useState(false); + const [activityOpen, setActivityOpen] = useState(false); useEffect(() => { if (!requirement) return; void loadRunsForRequirement(requirement.id); @@ -217,7 +214,13 @@ export function RequirementDetail({ if (startDisabled && !latestConversationId) return; setStartError(null); setStarting(true); - const content = prompt ?? t("detailStartPromptPrefill", requirement.title); + const content = prompt ?? formatRequirementStartPrompt(requirement, todos); + const verificationCommands = [ + ...(requirement.verification_plan?.commands ?? []), + ...todos + .map((todo) => todo.command?.trim() ?? "") + .filter((command) => command.length > 0), + ]; try { if (inFlightRun) return; if (startDisabled) return; @@ -230,7 +233,7 @@ export function RequirementDetail({ isNew: false, soulPrompt: currentJarvisSoulPrompt(), requirementRunId: run.id, - verificationCommands: requirement.verification_plan?.commands ?? [], + verificationCommands, }); if (ok) seedBackgroundConversationSurface(conversation_id, content); } catch (e) { @@ -276,28 +279,53 @@ export function RequirementDetail({ ariaLabel={t("reqStatusAria", statusLabel)} />
- + + + +
@@ -310,8 +338,6 @@ export function RequirementDetail({ - - void handleAgentWork(formatTodoInjection(requirement, todo)) } /> - -
- {(sessions > 0 || startError || isProposed) && ( -
- {sessions > 0 && ( - - {t("reqSessions", sessions)} - - )} - {startError && ( - - {t("detailStartFailed")} - - )} - - {isProposed && ( - - )} -
- )} +
+ {(sessions > 0 || startError || isProposed) && ( +
+ {sessions > 0 && ( + + {t("reqSessions", sessions)} + + )} + {startError && ( + + {t("detailStartFailed")} + + )} + + {isProposed && ( + + )} +
+ )} + +
+ setActivityOpen(false)} + /> ); } -const TODO_KINDS: RequirementTodoKind[] = [ - "work", - "check", - "ci", - "deploy", - "review", - "manual", -]; - -const TODO_STATUSES: RequirementTodoStatus[] = [ - "pending", - "running", - "passed", - "failed", - "skipped", - "blocked", -]; - function RequirementNextStep({ latestRun, todos, @@ -545,55 +558,18 @@ function RequirementNextStep({ function RequirementTodosSection({ requirement, - onChanged, onHandleTodo, }: { requirement: Requirement; - onChanged: () => void; onHandleTodo: (todo: RequirementTodo) => void; }) { const todos = requirement.todos ?? []; - const [title, setTitle] = useState(""); - const [kind, setKind] = useState("ci"); - const [command, setCommand] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [adding, setAdding] = useState(false); - const [sectionOpen, setSectionOpen] = useState(() => todos.length > 0); - const kindOptions = todoKindOptions(); + const [sectionOpen, setSectionOpen] = useState(true); useEffect(() => { - if (todos.length > 0) setSectionOpen(true); + setSectionOpen(true); }, [todos.length]); - const submit = (e: React.FormEvent) => { - e.preventDefault(); - void createTodo(); - }; - - const createTodo = async () => { - const nextTitle = title.trim(); - if (!nextTitle || busy) return; - setBusy(true); - setError(null); - try { - await createRequirementTodo(requirement.id, { - title: nextTitle, - kind, - command: command.trim() || null, - created_by: "human", - }); - setTitle(""); - setCommand(""); - setAdding(false); - onChanged(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); - } - }; - return (
{t("reqTodoHeading")} -

- {todos.length === 0 - ? t("reqTodoOptionalHint") - : t("reqTodoHeadingHint")} -

+

{t("reqTodoHeadingHint")}

{todos.length} - {!adding && ( - - )} - {adding && ( -
- setTitle(e.target.value)} - placeholder={t("reqTodoAddPlaceholder")} - aria-label={t("reqTodoTitleAria")} - autoFocus - /> - - className="requirement-detail-todo-select" - value={kind} - onChange={setKind} - options={kindOptions} - ariaLabel={t("reqTodoKindAria")} - /> - setCommand(e.target.value)} - placeholder={t("reqTodoCommandPlaceholder")} - aria-label={t("reqTodoCommandAria")} - /> - - - - )} - {error && ( -

- {error} -

- )} {todos.length === 0 ? (

{t("reqTodoEmpty")}

) : ( @@ -683,9 +592,7 @@ function RequirementTodosSection({ {todos.map((todo) => ( ))} @@ -696,75 +603,13 @@ function RequirementTodosSection({ } function RequirementTodoRow({ - requirementId, todo, - onChanged, onHandleTodo, }: { - requirementId: string; todo: RequirementTodo; - onChanged: () => void; onHandleTodo: (todo: RequirementTodo) => void; }) { - const [title, setTitle] = useState(todo.title); - const [kind, setKind] = useState(todo.kind); - const [status, setStatus] = useState(todo.status); - const [command, setCommand] = useState(todo.command ?? ""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); const [injected, setInjected] = useState(false); - const [editing, setEditing] = useState(false); - const kindOptions = todoKindOptions(); - const statusOptions = todoStatusOptions(); - - useEffect(() => { - setTitle(todo.title); - setKind(todo.kind); - setStatus(todo.status); - setCommand(todo.command ?? ""); - }, [todo.id, todo.title, todo.kind, todo.status, todo.command]); - - const changed = - title.trim() !== todo.title || - kind !== todo.kind || - status !== todo.status || - command.trim() !== (todo.command ?? ""); - - const save = async () => { - const nextTitle = title.trim(); - if (!nextTitle || !changed || busy) return; - setBusy(true); - setError(null); - try { - await updateRequirementTodo(requirementId, todo.id, { - title: nextTitle, - kind, - status, - command: command.trim() || null, - }); - onChanged(); - setEditing(false); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); - } - }; - - const remove = async () => { - if (busy) return; - const ok = window.confirm(t("reqTodoDeleteConfirm", todo.title)); - if (!ok) return; - setBusy(true); - setError(null); - try { - await deleteRequirementTodo(requirementId, todo.id); - onChanged(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - setBusy(false); - } - }; const inject = () => { onHandleTodo(todo); @@ -791,44 +636,6 @@ function RequirementTodoRow({ {todo.evidence.note} )} - {editing && ( -
-
- setTitle(e.target.value)} - aria-label={t("reqTodoTitleAria")} - /> - - className="requirement-detail-todo-select" - value={kind} - onChange={setKind} - options={kindOptions} - ariaLabel={t("reqTodoKindAria")} - /> - - className="requirement-detail-todo-select" - value={status} - onChange={setStatus} - options={statusOptions} - ariaLabel={t("reqTodoStatusAria")} - /> -
- setCommand(e.target.value)} - placeholder={t("reqTodoCommandPlaceholder")} - aria-label={t("reqTodoCommandAria")} - /> -
- )} - {error && ( -

- {error} -

- )}
- {!editing && ( - - )} - {editing && ( - - )} - -
); @@ -896,22 +661,6 @@ function todoStatusGlyph(status: RequirementTodoStatus): string { return "○"; } -function todoKindOptions() { - return TODO_KINDS.map((value) => ({ - value, - label: t(`reqTodoKind_${value}`), - searchText: t(`reqTodoKind_${value}`), - })); -} - -function todoStatusOptions() { - return TODO_STATUSES.map((value) => ({ - value, - label: t(`reqTodoStatus_${value}`), - searchText: t(`reqTodoStatus_${value}`), - })); -} - function formatTodoInjection(req: Requirement, todo: RequirementTodo): string { const lines = [ t("reqTodoInjectPromptHeader"), @@ -932,6 +681,30 @@ function formatTodoInjection(req: Requirement, todo: RequirementTodo): string { return lines.join("\n"); } +function formatRequirementStartPrompt( + req: Requirement, + todos: RequirementTodo[], +): string { + const lines = [ + t("detailStartPromptPrefill", req.title), + ]; + if (todos.length > 0) { + lines.push("", t("reqTodoExecutionPromptHeader")); + for (const [idx, todo] of todos.entries()) { + const parts = [ + `${idx + 1}. ${todo.title}`, + `[${t(`reqTodoKind_${todo.kind}`)} / ${t(`reqTodoStatus_${todo.status}`)}]`, + ]; + if (todo.command?.trim()) { + parts.push(t("reqTodoInjectPromptCommand", todo.command.trim())); + } + lines.push(parts.join(" ")); + } + lines.push(t("reqTodoExecutionPromptAsk")); + } + return lines.join("\n"); +} + // ============================================================= // Session records section — RequirementRun history rendering. // ============================================================= @@ -1264,7 +1037,10 @@ function VerifyRunForm({ }; return ( -
+ void submit(e)} + >
-
+ ); } @@ -1388,53 +1175,3 @@ function RequirementLabelsRow({ requirement }: { requirement: Requirement }) { ); } - -// ============================================================= -// 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")} - - setDisplayName(e.target.value)} + className="settings-input" + /> + + {orderedSchemaProps(meta.schema).map(([key, spec]) => ( + + ))} + {err &&
{err}
} +
+ + +
+ + ); +} diff --git a/apps/jarvis-web/src/components/Settings/sections/McpSection.tsx b/apps/jarvis-web/src/components/Settings/sections/McpSection.tsx index f10dcda..6117d79 100644 --- a/apps/jarvis-web/src/components/Settings/sections/McpSection.tsx +++ b/apps/jarvis-web/src/components/Settings/sections/McpSection.tsx @@ -6,8 +6,9 @@ // auditing and is the source of truth across restarts; this one is // the interactive plane. -import { useEffect, useState } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { Row, Section } from "./Section"; +import { Button, Modal, TextField } from "../../ui"; import { t } from "../../../utils/i18n"; import { addMcpServer, @@ -34,12 +35,16 @@ type LoadState = export function McpSection({ embedded, refreshToken = 0, + query = "", }: { embedded?: boolean; refreshToken?: number; + query?: string; } = {}) { const [state, setState] = useState({ kind: "loading" }); const [adding, setAdding] = useState(false); + const [addModalOpen, setAddModalOpen] = useState(false); + const [editingServer, setEditingServer] = useState(null); const [healthByPrefix, setHealthByPrefix] = useState>({}); const [errorByPrefix, setErrorByPrefix] = useState>({}); const [addPrefix, setAddPrefix] = useState(""); @@ -57,21 +62,32 @@ export function McpSection({ refresh(); }, [refreshToken]); - const handleAdd = async (e: React.FormEvent) => { - e.preventDefault(); - if (!addPrefix.trim() || !addCmdline.trim()) { + const submitServer = async ( + prefix: string, + cmdline: string, + current: McpServerInfo | null, + ): Promise => { + const nextPrefix = prefix.trim(); + const nextCmdline = cmdline.trim(); + if (!nextPrefix || !nextCmdline) { setAddError(tx("mcpAddMissing", "Prefix and command are required.")); - return; + return false; } setAdding(true); setAddError(null); try { - await addMcpServer(configFromCommandLine(addPrefix.trim(), addCmdline.trim())); + if (current) { + await removeMcpServer(current.prefix); + } + await addMcpServer(configFromCommandLine(nextPrefix, nextCmdline)); setAddPrefix(""); setAddCmdline(""); refresh(); + return true; } catch (e: unknown) { - setAddError(t("mcpAddFailed", String(e))); + setAddError(current ? t("mcpReloadFailed", String(e)) : t("mcpAddFailed", String(e))); + refresh(); + return false; } finally { setAdding(false); } @@ -128,6 +144,11 @@ export function McpSection({ } }; + const filteredState = + state.kind === "ready" + ? { ...state, servers: filterMcpServers(state.servers, query) } + : state; + return (
+
+ + +
+ {renderList( - state, + filteredState, handleRemove, handleHealth, handleReload, + setEditingServer, healthByPrefix, errorByPrefix, )} -
-
-
{tx("mcpAddTitle", "Add server")}
-
{tx("mcpCommandLineHelp", "e.g. uvx mcp-server-filesystem /tmp")}
-
-
-
{ void handleAdd(e); }}> -
- - setAddPrefix(e.target.value)} - placeholder="github" - disabled={adding} - /> -
-
- - setAddCmdline(e.target.value)} - placeholder="uvx mcp-server-github" - disabled={adding} - /> -
- {addError &&
{addError}
} -
- -
-
-
-
+ {addError &&
{addError}
} -
- -
+ setAddModalOpen(false)} + onSubmit={async (e, prefix, cmdline) => { + e.preventDefault(); + const ok = await submitServer(prefix, cmdline, null); + if (ok) setAddModalOpen(false); + }} + /> + + undefined} + onCmdline={() => undefined} + onClose={() => setEditingServer(null)} + onSubmit={async (e, prefix, cmdline) => { + e.preventDefault(); + if (!editingServer) return; + const ok = await submitServer(prefix, cmdline, editingServer); + if (ok) setEditingServer(null); + }} + />
); } @@ -205,6 +222,7 @@ function renderList( onRemove: (prefix: string) => void | Promise, onHealth: (prefix: string) => void | Promise, onReload: (prefix: string) => void | Promise, + onEdit: (server: McpServerInfo) => void, healthByPrefix: Record, errorByPrefix: Record, ) { @@ -236,21 +254,22 @@ function renderList(
  • - {s.prefix} + {s.prefix} - {s.status} + {statusLabel(s.status)} - {" "} - · {s.config.transport.type} - {" "} - · {t("mcpToolCount", s.tools.length)} + {" · "} + {t("mcpToolCount", s.tools.length)}
    + @@ -269,7 +288,7 @@ function renderList( {s.tools.length > 0 && (
      {s.tools.map((name) => ( -
    • {name}
    • +
    • {name}
    • ))}
    )} @@ -281,6 +300,109 @@ function renderList( ); } +function McpServerModal({ + open, + title, + prefix, + cmdline, + busy, + submitLabel, + submitError, + onPrefix, + onCmdline, + onClose, + onSubmit, +}: { + open: boolean; + title: string; + prefix: string; + cmdline: string; + busy: boolean; + submitLabel: string; + submitError?: string | null; + onPrefix: (value: string) => void; + onCmdline: (value: string) => void; + onClose: () => void; + onSubmit: (e: FormEvent, prefix: string, cmdline: string) => void | Promise; +}) { + const [localPrefix, setLocalPrefix] = useState(prefix); + const [localCmdline, setLocalCmdline] = useState(cmdline); + + useEffect(() => { + if (!open) return; + setLocalPrefix(prefix); + setLocalCmdline(cmdline); + }, [cmdline, open, prefix]); + + return ( + +
    { void onSubmit(e, localPrefix, localCmdline); }} + > +

    + {tx("mcpCommandLineHelp", "e.g. uvx mcp-server-filesystem /tmp")} +

    + { + setLocalPrefix(e.target.value); + onPrefix(e.target.value); + }} + placeholder="github" + disabled={busy} + autoFocus + /> + { + setLocalCmdline(e.target.value); + onCmdline(e.target.value); + }} + placeholder="uvx mcp-server-github" + disabled={busy} + /> + {submitError ?
    {submitError}
    : null} +
    + + +
    + +
    + ); +} + +function commandLineFromConfig(server: McpServerInfo): string { + const transport = server.config.transport; + if (transport.type === "stdio") { + return [transport.command, ...(transport.args ?? [])].filter(Boolean).join(" "); + } + if (transport.type === "http" || transport.type === "streamable-http") { + return transport.url; + } + return ""; +} + +function filterMcpServers(servers: McpServerInfo[], query: string): McpServerInfo[] { + const q = query.trim().toLowerCase(); + if (!q) return servers; + return servers.filter((server) => + [ + server.prefix, + server.status, + server.config.transport.type, + commandLineFromConfig(server), + ...server.tools, + ].join(" ").toLowerCase().includes(q), + ); +} + function renderHealth(state: McpHealth | "checking" | undefined) { if (state === undefined) return null; if (state === "checking") { @@ -310,3 +432,14 @@ function statusTagClass(status: McpServerStatus): string { return "settings-tag-danger"; } } + +function statusLabel(status: McpServerStatus): string { + switch (status) { + case "running": + return tx("mcpStatusRunning", "Running"); + case "stopped": + return tx("mcpStatusStopped", "Stopped"); + case "unhealthy": + return tx("mcpStatusUnhealthy", "Needs attention"); + } +} diff --git a/apps/jarvis-web/src/components/Settings/sections/PluginsSection.tsx b/apps/jarvis-web/src/components/Settings/sections/PluginsSection.tsx index 94fe439..531e8ce 100644 --- a/apps/jarvis-web/src/components/Settings/sections/PluginsSection.tsx +++ b/apps/jarvis-web/src/components/Settings/sections/PluginsSection.tsx @@ -3,8 +3,9 @@ // in-tree fixture packs). Install also accepts an arbitrary local // path so anything with a `plugin.json` works. -import { useEffect, useState } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { Row, Section } from "./Section"; +import { Button, Modal, TextField } from "../../ui"; import { fetchMarketplace, installPlugin, @@ -37,15 +38,19 @@ export function PluginsSection({ embedded, onInstalled, showMarketplace = true, + query = "", }: { embedded?: boolean; onInstalled?: (report: PluginInstallReport) => void; showMarketplace?: boolean; + query?: string; } = {}) { const [installed, setInstalled] = useState({ kind: "loading" }); const [market, setMarket] = useState({ kind: "loading" }); const [pathValue, setPathValue] = useState(""); const [installing, setInstalling] = useState(null); + const [installModalOpen, setInstallModalOpen] = useState(false); + const [editingPlugin, setEditingPlugin] = useState(null); const [actionError, setActionError] = useState(null); const [actionMessage, setActionMessage] = useState(null); @@ -68,7 +73,7 @@ export function PluginsSection({ refreshMarket(); }, []); - const doInstall = async (path: string) => { + const doInstall = async (path: string): Promise => { setInstalling(path); setActionError(null); setActionMessage(null); @@ -80,8 +85,31 @@ export function PluginsSection({ setActionMessage( t("pluginsInstallSucceeded", report.added_skills.length, report.added_mcp.length), ); + return true; } catch (e: unknown) { setActionError(t("pluginsInstallFailed", String(e))); + return false; + } finally { + setInstalling(null); + } + }; + + const doUpdate = async (plugin: InstalledPlugin, path: string): Promise => { + setInstalling(plugin.name); + setActionError(null); + setActionMessage(null); + try { + await uninstallPlugin(plugin.name); + const report = await installPlugin("path", path); + activateInstalledSkills(report.added_skills); + refreshInstalled(); + onInstalled?.(report); + setActionMessage(tx("pluginsUpdateSucceeded", "Plugin updated.")); + return true; + } catch (e: unknown) { + setActionError(tx("pluginsUpdateFailed", "Plugin update failed: ") + String(e)); + refreshInstalled(); + return false; } finally { setInstalling(null); } @@ -106,6 +134,22 @@ export function PluginsSection({ void doRemove(name); }; + const filteredInstalled = + installed.kind === "ready" + ? { + ...installed, + plugins: filterPlugins(installed.plugins, query), + } + : installed; + + const filteredMarket = + market.kind === "ready" + ? { + ...market, + entries: filterMarketplace(market.entries, query), + } + : market; + return (
    - {renderInstalled(installed, removePlugin)} - -
    -
    -
    {tx("pluginsInstallTitle", "Install from path")}
    -
    - {tx("pluginsInstallHint", "Absolute or workspace-relative directory containing plugin.json")} -
    -
    -
    -
    { - e.preventDefault(); - if (pathValue.trim()) void doInstall(pathValue.trim()); - }} - > - setPathValue(e.target.value)} - placeholder="examples/plugins/code-review-pack" - disabled={installing !== null} - /> -
    - -
    -
    -
    +
    + +
    - {showMarketplace ? renderMarket(market, installing, installFromPath) : null} + {renderInstalled(filteredInstalled, removePlugin, setEditingPlugin)} + + {showMarketplace ? renderMarket(filteredMarket, installing, installFromPath) : null} {actionMessage &&
    {actionMessage}
    } {actionError &&
    {actionError}
    } -
    - -
    + setInstallModalOpen(false)} + onSubmit={async (path) => { + setPathValue(path); + const ok = await doInstall(path); + if (ok) setInstallModalOpen(false); + }} + /> + + setEditingPlugin(null)} + onSubmit={async (path) => { + if (!editingPlugin) return; + const ok = path === editingPlugin.source_value + ? true + : await doUpdate(editingPlugin, path); + if (ok) setEditingPlugin(null); + }} + />
    ); } +function filterPlugins(plugins: InstalledPlugin[], query: string): InstalledPlugin[] { + const q = query.trim().toLowerCase(); + if (!q) return plugins; + return plugins.filter((p) => + [ + p.name, + p.version, + p.description, + p.source_kind, + p.source_value, + ...p.skill_names, + ...p.mcp_prefixes, + ].join(" ").toLowerCase().includes(q), + ); +} + +function filterMarketplace(entries: MarketplaceEntry[], query: string): MarketplaceEntry[] { + const q = query.trim().toLowerCase(); + if (!q) return entries; + return entries.filter((entry) => + [ + entry.name, + entry.description, + entry.source, + entry.value, + ...(entry.tags ?? []), + ].join(" ").toLowerCase().includes(q), + ); +} + function activateInstalledSkills(skillNames: string[]) { if (skillNames.length === 0) return; const sent = skillNames.filter((name) => sendFrame({ type: "activate_skill", name })); @@ -172,7 +254,11 @@ function activateInstalledSkills(skillNames: string[]) { appStore.getState().setActiveSkills?.(next); } -function renderInstalled(state: InstalledState, onRemove: (name: string) => void) { +function renderInstalled( + state: InstalledState, + onRemove: (name: string) => void, + onEdit: (plugin: InstalledPlugin) => void, +) { if (state.kind === "loading") { return ; } @@ -202,21 +288,21 @@ function renderInstalled(state: InstalledState, onRemove: (name: string) => void
    - {p.name} - · {p.version} + {p.name}
    {p.description}
    {t("pluginsContributes", p.skill_names.length, p.mcp_prefixes.length)}
    - +
    + + +
  • ))} @@ -226,6 +312,82 @@ function renderInstalled(state: InstalledState, onRemove: (name: string) => void ); } +function PluginPathModal({ + open, + title, + description, + initialPath, + submitLabel, + busy, + submitError, + onClose, + onSubmit, +}: { + open: boolean; + title: string; + description: string; + initialPath: string; + submitLabel: string; + busy: boolean; + submitError?: string | null; + onClose: () => void; + onSubmit: (path: string) => void | Promise; +}) { + const [path, setPath] = useState(initialPath); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setPath(initialPath); + setError(null); + }, [initialPath, open]); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + const trimmed = path.trim(); + if (!trimmed) { + setError(tx("pluginsPathRequired", "Plugin path is required.")); + return; + } + setError(null); + await onSubmit(trimmed); + }; + + return ( + +
    { void submit(e); }}> +

    {description}

    + setPath(e.target.value)} + placeholder="examples/plugins/code-review-pack" + disabled={busy} + error={error !== null} + hint={error ?? tx("pluginsPathHint", "Directory must contain plugin.json.")} + autoFocus + /> + {submitError ?
    {submitError}
    : null} +
    + + +
    + +
    + ); +} + function renderMarket( state: MarketState, installing: string | null, diff --git a/apps/jarvis-web/src/components/Settings/sections/RoutingSection.tsx b/apps/jarvis-web/src/components/Settings/sections/RoutingSection.tsx index 1351cc1..83ac670 100644 --- a/apps/jarvis-web/src/components/Settings/sections/RoutingSection.tsx +++ b/apps/jarvis-web/src/components/Settings/sections/RoutingSection.tsx @@ -69,37 +69,63 @@ function valueToTarget(v: string): ModelTarget | null { return { provider, model: rest.join("|") }; } -const SLOT_DESCRIPTIONS: Record = { +interface SlotDescription { + titleKey: string; + descKey: string; + titleFallback: string; + descFallback: string; +} + +const SLOT_DESCRIPTIONS: Record = { default: { - title: "Default", - desc: "Fallback target every other slot inherits when unset.", + titleKey: "settingsRoutingSlotDefault", + descKey: "settingsRoutingSlotDefaultDesc", + titleFallback: "Default", + descFallback: "Fallback target every other slot inherits when unset.", }, coding: { - title: "Coding", - desc: "Routes the codex SubAgent. Restart required for changes.", + titleKey: "settingsRoutingSlotCoding", + descKey: "settingsRoutingSlotCodingDesc", + titleFallback: "Coding", + descFallback: "Routes the codex SubAgent. Restart required for changes.", }, review: { - title: "Review", - desc: "Routes the reviewer SubAgent. Restart required for changes.", + titleKey: "settingsRoutingSlotReview", + descKey: "settingsRoutingSlotReviewDesc", + titleFallback: "Review", + descFallback: "Routes the reviewer SubAgent. Restart required for changes.", }, summarization: { - title: "Summarization", - desc: "Routes the summarising memory. Live — applies on the next compaction.", + titleKey: "settingsRoutingSlotSummarization", + descKey: "settingsRoutingSlotSummarizationDesc", + titleFallback: "Summarization", + descFallback: "Routes the summarising memory. Live - applies on the next compaction.", }, doc_reader: { - title: "Doc reader", - desc: "Routes the doc-reader SubAgent. Restart required for changes.", + titleKey: "settingsRoutingSlotDocReader", + descKey: "settingsRoutingSlotDocReaderDesc", + titleFallback: "Doc reader", + descFallback: "Routes the doc-reader SubAgent. Restart required for changes.", }, vision: { - title: "Vision", - desc: "Reserved for future per-request resolvers. Stored only.", + titleKey: "settingsRoutingSlotVision", + descKey: "settingsRoutingSlotVisionDesc", + titleFallback: "Vision", + descFallback: "Reserved for future per-request resolvers. Stored only.", }, local_private: { - title: "Local / private", - desc: "Reserved for privacy-sensitive workloads. Stored only.", + titleKey: "settingsRoutingSlotLocalPrivate", + descKey: "settingsRoutingSlotLocalPrivateDesc", + titleFallback: "Local / private", + descFallback: "Reserved for privacy-sensitive workloads. Stored only.", }, }; +function tx(key: string, fallback: string): string { + const v = t(key); + return v === key ? fallback : v; +} + export function RoutingSection() { const providers = useAppStore((s) => s.providers); const [policy, setPolicy] = useState({}); @@ -225,16 +251,18 @@ export function RoutingSection() { return (
    - {meta.title} - {meta.desc} + {tx(meta.titleKey, meta.titleFallback)} + {tx(meta.descKey, meta.descFallback)}
    + + + {/if} + + {#if form?.message} +

    {form.message}

    + {/if} + +
    +
    +

    📚 学习计划

    +
    + 总进度 +
    +
    + {completedTasks}/{totalTasks} ({overallProgress}%) +
    +
    +
    + + {#if plans.length > 0} +
    + {#each plans as plan (plan.id)} + {@const completedCount = plan.tasks.filter((t) => t.completed).length} + {@const totalCount = plan.tasks.length} + {@const progressPercent = totalCount === 0 ? 0 : Math.round((completedCount / totalCount) * 100)} + {@const allCompleted = totalCount > 0 && completedCount === totalCount} + +
    +
    +

    {plan.title}

    +
    { + deletingId = plan.id; + return async ({ update }) => { + await update(); + deletingId = null; + }; + }} + class="inline-form" + > + + +
    +
    + +
    +
    + {completedCount}/{totalCount} ({progressPercent}%) +
    + + {#if plan.tasks.length > 0} +
      + {#each plan.tasks as task (task.id)} +
    • +
      { + return async ({ update }) => { + await update(); + }; + }} + class="inline-form" + > + + + +
      +
    • + {/each} +
    + {:else} +

    暂无任务

    + {/if} + + {#if allCompleted} +
    + 🎉 全部完成 +
    + {/if} +
    + {/each} +
    + {:else} +

    + 暂无学习计划,去添加一个吧! +

    + {/if} +
    + + + diff --git a/svelte-learn/src/routes/api/plans/+server.ts b/svelte-learn/src/routes/api/plans/+server.ts new file mode 100644 index 0000000..81ad6b5 --- /dev/null +++ b/svelte-learn/src/routes/api/plans/+server.ts @@ -0,0 +1,35 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getPlans, createPlan, deletePlan } from '$lib/server/db'; + +export const GET: RequestHandler = async () => { + const plans = getPlans(); + return json({ data: plans }); +}; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json().catch(() => ({})); + const title = typeof body.title === 'string' ? body.title.trim() : ''; + + if (!title) { + throw error(400, '标题不能为空'); + } + + const plan = createPlan(title); + return json({ data: plan }, { status: 201 }); +}; + +export const DELETE: RequestHandler = async ({ url }) => { + const id = url.searchParams.get('id'); + + if (!id) { + throw error(400, '缺少 id 参数'); + } + + const removed = deletePlan(id); + if (!removed) { + throw error(404, '计划不存在'); + } + + return json({ success: true }); +}; diff --git a/svelte-learn/vite.config.ts b/svelte-learn/vite.config.ts index 1823770..bbf8c7d 100644 --- a/svelte-learn/vite.config.ts +++ b/svelte-learn/vite.config.ts @@ -1,6 +1,6 @@ -import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [svelte()] + plugins: [sveltekit()] });