Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions apps/jarvis-cli/src/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _;
Expand All @@ -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 {
Expand Down
243 changes: 237 additions & 6 deletions apps/jarvis-web/src/components/AppSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Link to="/settings">`,
// and react-router-dom's Link blows up without a router ancestor.
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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",
Expand All @@ -116,11 +124,234 @@ describe("AppSidebar search", () => {

renderWithRouter(<AppSidebar />);

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(<AppSidebar />);

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(<AppSidebar />);

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(<AppSidebar />);

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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 9 additions & 24 deletions apps/jarvis-web/src/components/Chat/AgentLoadingFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,13 @@
// Jarvis loading footer.
//
// Pinned to the bottom of `<MessageList>` 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);
Expand Down Expand Up @@ -59,7 +40,11 @@ export function AgentLoadingFooter() {

return (
<div className="agent-loading" role="status" aria-live="polite" aria-label="Jarvis is thinking">
<JarvisThinkingLottie />
<span className="agent-loading-dots" aria-hidden="true">
<span />
<span />
<span />
</span>
<span className="agent-loading-text">
<span className="agent-loading-elapsed">{elapsedLabel}</span>
{tokensIn > 0 ? (
Expand Down
Loading
Loading