-
-
-
+ {mono && (onToggleMonoOverview || onOpenMonoSettings) ? (
+
+ {onToggleMonoOverview ? (
+
+
+ Overview
+
+ ) : null}
+ {onOpenMonoSettings ? (
+
+
+
+ ) : null}
) : null}
{showTrailingActions ? (
diff --git a/src/app/shell/UsageFooter.test.ts b/src/app/shell/UsageFooter.test.ts
index 2969d1740b..2ba3a0fb84 100644
--- a/src/app/shell/UsageFooter.test.ts
+++ b/src/app/shell/UsageFooter.test.ts
@@ -1,8 +1,28 @@
-import { createElement } from "react";
+// @vitest-environment happy-dom
+import { act, createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it, vi } from "vitest";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { UsageFooter } from "./UsageFooter";
+let container: HTMLDivElement;
+let root: Root | null = null;
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ document.body.append(container);
+});
+
+afterEach(() => {
+ if (root) {
+ act(() => root!.unmount());
+ root = null;
+ }
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
describe("UsageFooter terminal control", () => {
it("replaces the generic terminal button with the live process control", () => {
const markup = renderToStaticMarkup(
@@ -19,7 +39,7 @@ describe("UsageFooter terminal control", () => {
terminalOpen: true,
onToggleTerminal: vi.fn(),
onNewTerminal: vi.fn(),
- onShowTerminal: vi.fn(),
+ onToggleProjectTerminal: vi.fn(),
projectTerminalActive: true,
}),
);
@@ -40,4 +60,98 @@ describe("UsageFooter terminal control", () => {
expect(markup).toContain(">Terminal");
expect(markup.match(/
{
+ const open = renderToStaticMarkup(
+ createElement(UsageFooter, {
+ providers: [],
+ onNewTerminal: vi.fn(),
+ onToggleProjectTerminal: vi.fn(),
+ projectTerminalActive: true,
+ projectTerminalOpen: true,
+ }),
+ );
+ const collapsed = renderToStaticMarkup(
+ createElement(UsageFooter, {
+ providers: [],
+ onNewTerminal: vi.fn(),
+ onToggleProjectTerminal: vi.fn(),
+ projectTerminalActive: true,
+ projectTerminalOpen: false,
+ }),
+ );
+
+ // Label still reflects "has terminals" in both states.
+ expect(open).toContain('aria-label="Terminal"');
+ expect(collapsed).toContain('aria-label="Terminal"');
+ expect(open).toContain('aria-pressed="true"');
+ expect(collapsed).toContain('aria-pressed="false"');
+ });
+
+ it("toggles the dock instead of only showing it", () => {
+ const onToggleProjectTerminal = vi.fn();
+ const onNewTerminal = vi.fn();
+ root = createRoot(container);
+ act(() =>
+ root!.render(
+ createElement(UsageFooter, {
+ providers: [],
+ onNewTerminal,
+ onToggleProjectTerminal,
+ projectTerminalActive: true,
+ projectTerminalOpen: true,
+ }),
+ ),
+ );
+
+ const button = container.querySelector('button[aria-label="Terminal"]');
+ expect(button).not.toBeNull();
+ act(() => (button as HTMLButtonElement).click());
+ expect(onToggleProjectTerminal).toHaveBeenCalledTimes(1);
+ expect(onNewTerminal).not.toHaveBeenCalled();
+ });
+
+ it.each([false, true])(
+ "handles a toggle-only caller when projectTerminalActive is %s",
+ (projectTerminalActive) => {
+ const onToggleProjectTerminal = vi.fn();
+ root = createRoot(container);
+ act(() =>
+ root!.render(
+ createElement(UsageFooter, {
+ providers: [],
+ onToggleProjectTerminal,
+ projectTerminalActive,
+ }),
+ ),
+ );
+
+ const button = container.querySelector("button");
+ expect(button).not.toBeNull();
+ act(() => button!.click());
+ expect(onToggleProjectTerminal).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it("prefers creating a terminal when no project terminal is active", () => {
+ const onNewTerminal = vi.fn();
+ const onToggleProjectTerminal = vi.fn();
+ root = createRoot(container);
+ act(() =>
+ root!.render(
+ createElement(UsageFooter, {
+ providers: [],
+ onNewTerminal,
+ onToggleProjectTerminal,
+ projectTerminalActive: false,
+ }),
+ ),
+ );
+
+ const button = container.querySelector("button");
+ expect(button).not.toBeNull();
+ act(() => button!.click());
+ expect(onNewTerminal).toHaveBeenCalledTimes(1);
+ expect(onToggleProjectTerminal).not.toHaveBeenCalled();
+ });
});
diff --git a/src/app/shell/UsageFooter.tsx b/src/app/shell/UsageFooter.tsx
index ace0564388..3489f56c90 100644
--- a/src/app/shell/UsageFooter.tsx
+++ b/src/app/shell/UsageFooter.tsx
@@ -1,5 +1,11 @@
import { RefreshCw, Terminal } from "../../shared/ui/icons";
-import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
import { HarnessIcon } from "../../features/sessions/ui/HarnessIcon";
import { Popover, type PopoverDismissReason } from "../../shared/ui/Popover";
import { consumeCodexRateLimitResetCredit } from "../../features/providers/model/rateLimitsFetch";
@@ -28,7 +34,11 @@ import {
type RunningTerminal,
} from "../../features/terminal/model/terminalTab";
import { MOD } from "../../platform/tauri/platform";
-import { UsageProviderChip } from "./UsageProviderChip";
+import {
+ USAGE_REFRESH_CLASSES,
+ UsageProviderChip,
+ type UsageChipVariant,
+} from "./UsageProviderChip";
import { PiUsage } from "./PiUsage";
import {
ProviderSignInPanel,
@@ -63,8 +73,9 @@ export function UsageFooter({
terminalOpen = false,
onToggleTerminal,
onNewTerminal,
- onShowTerminal,
+ onToggleProjectTerminal,
projectTerminalActive = false,
+ projectTerminalOpen = false,
onSelectAccount,
onManageAccounts,
}: {
@@ -75,13 +86,117 @@ export function UsageFooter({
terminalOpen?: boolean;
onToggleTerminal?: (fileId: string) => void;
onNewTerminal?: () => void;
- onShowTerminal?: () => void;
+ onToggleProjectTerminal?: () => void;
projectTerminalActive?: boolean;
+ projectTerminalOpen?: boolean;
+ onSelectAccount?: (
+ provider: ProviderAccountProvider,
+ accountId: string,
+ ) => void;
+ onManageAccounts?: (provider: ProviderAccountProvider) => void;
+}) {
+ const opencode = useCachedRateLimits("opencode");
+ const showUsage =
+ providers.some((provider) => provider !== "opencode") ||
+ (providers.includes("opencode") && opencode.status !== "unavailable");
+ const showTerminals = terminals.length > 0;
+ const showTerminalButton = Boolean(
+ onNewTerminal || onToggleProjectTerminal,
+ );
+ const terminalLabel = projectTerminalActive
+ ? "Terminal"
+ : `New Terminal (${MOD}\`)`;
+ const onTerminalClick = projectTerminalActive
+ ? (onToggleProjectTerminal ?? onNewTerminal)
+ : (onNewTerminal ?? onToggleProjectTerminal);
+ const ariaLabel = showUsage || session?.harness === "pi"
+ ? "Provider usage"
+ : showTerminals || showTerminalButton
+ ? "Terminals"
+ : session
+ ? "Session"
+ : undefined;
+
+ return (
+
+ );
+}
+
+/**
+ * The provider usage chips and their refresh button, for the session footer
+ * and anywhere else a conversation shows how much of its plan is left.
+ * `accountPicker` off keeps the chips to usage alone, for a conversation
+ * whose account is not switched from here. `variant` `composer` matches
+ * the tool buttons under a Mono's composer instead of the footer.
+ */
+export function UsageChips({
+ providers,
+ session,
+ project,
+ onSelectAccount,
+ onManageAccounts,
+ accountPicker = true,
+ fallback = null,
+ variant = "footer",
+}: {
+ providers: RateLimitProvider[];
+ session?: UsageFooterSession;
+ project?: string;
onSelectAccount?: (
provider: ProviderAccountProvider,
accountId: string,
) => void;
onManageAccounts?: (provider: ProviderAccountProvider) => void;
+ accountPicker?: boolean;
+ /** Shown when there is no usage to show. */
+ fallback?: ReactNode;
+ variant?: UsageChipVariant;
}) {
const wantClaude = providers.includes("claude");
const wantCodex = providers.includes("codex");
@@ -302,121 +417,97 @@ export function UsageFooter({
const showOpencodeChip = wantOpencode && opencode.status !== "unavailable";
const showUsage = wantClaude || wantCodex || showOpencodeChip || wantDevin;
- const showTerminals = terminals.length > 0;
- const showTerminalButton = Boolean(onNewTerminal || onShowTerminal);
- const terminalLabel = projectTerminalActive
- ? "Terminal"
- : `New Terminal (${MOD}\`)`;
- const onTerminalClick = projectTerminalActive
- ? (onShowTerminal ?? onNewTerminal)
- : (onNewTerminal ?? onShowTerminal);
- const ariaLabel = showUsage || session?.harness === "pi"
- ? "Provider usage"
- : showTerminals || showTerminalButton
- ? "Terminals"
- : session
- ? "Session"
- : undefined;
+ const claudeAccountProps = accountPicker
+ ? {
+ accounts: claudeAccounts,
+ accountId: claudeAccountId,
+ onSelectAccount: (accountId: string) =>
+ selectAccount("claude", accountId),
+ onAddAccount: (label: string) => addAccount("claude", label),
+ onManageAccounts: onManageAccounts
+ ? () => onManageAccounts("claude")
+ : undefined,
+ }
+ : {};
+ const codexAccountProps = accountPicker
+ ? {
+ accounts: codexAccounts,
+ accountId: codexAccountId,
+ onSelectAccount: (accountId: string) =>
+ selectAccount("codex", accountId),
+ onAddAccount: (label: string) => addAccount("codex", label),
+ onManageAccounts: onManageAccounts
+ ? () => onManageAccounts("codex")
+ : undefined,
+ }
+ : {};
+ if (session?.harness === "pi") {
+ return (
+
+ );
+ }
+ if (!showUsage) return <>{fallback}>;
return (
-
- {session?.harness === "pi" ? (
-
- ) : showUsage ? (
- <>
- {wantClaude ? (
-
- selectAccount("claude", accountId)
- }
- onAddAccount={(label) => addAccount("claude", label)}
- onManageAccounts={
- onManageAccounts ? () => onManageAccounts("claude") : undefined
- }
- onReconnect={reconnectClaude}
- />
- ) : null}
- {wantCodex ? (
- selectAccount("codex", accountId)}
- onAddAccount={(label) => addAccount("codex", label)}
- onManageAccounts={
- onManageAccounts ? () => onManageAccounts("codex") : undefined
- }
- onConsumeReset={consumeCodexReset}
- onReconnect={reconnectCodex}
- />
- ) : null}
- {showOpencodeChip ? (
-
- ) : null}
- {wantDevin ? (
- // Devin has one login per machine, so there is no account picker.
-
- ) : null}
- void refresh()}
- >
-
-
- >
- ) : session ? (
-
+ <>
+ {wantClaude ? (
+
) : null}
- {showTerminals || showTerminalButton ? (
-
- {showTerminals ? (
-
- ) : showTerminalButton ? (
-
-
- Terminal
-
- ) : null}
-
+ {wantCodex ? (
+
) : null}
-
+ {showOpencodeChip ? (
+
+ ) : null}
+ {wantDevin ? (
+ // Devin has one login per machine, so there is no account picker.
+
+ ) : null}
+ void refresh()}
+ >
+
+
+ >
);
}
diff --git a/src/app/shell/UsageProviderChip.tsx b/src/app/shell/UsageProviderChip.tsx
index 00e49e070e..773681f35e 100644
--- a/src/app/shell/UsageProviderChip.tsx
+++ b/src/app/shell/UsageProviderChip.tsx
@@ -61,6 +61,23 @@ type UsageWindowEntry = {
window: RateLimitWindow;
};
+export type UsageChipVariant = "footer" | "composer";
+
+const USAGE_CHIP_CLASSES: Record = {
+ footer:
+ "-mx-1 inline-flex h-5 min-w-0 shrink-0 items-center gap-1.5 whitespace-nowrap rounded px-1 text-content/55 transition-[background-color,color,transform] duration-150 ease-out hover:bg-content/10 hover:text-content focus-visible:outline-2 focus-visible:outline-accent active:scale-[0.97]",
+ composer:
+ "inline-flex h-6.5 min-w-0 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 text-content/55 transition-colors hover:bg-content/8 hover:text-content focus-visible:outline-2 focus-visible:outline-accent",
+};
+
+/** The refresh button beside the usage chips, per variant. */
+export const USAGE_REFRESH_CLASSES: Record = {
+ footer:
+ "grid size-4.5 shrink-0 place-items-center rounded text-content/40 hover:bg-content/10 hover:text-content disabled:opacity-50",
+ composer:
+ "grid size-6.5 shrink-0 place-items-center rounded-md text-content/40 transition-colors hover:bg-content/8 hover:text-content disabled:opacity-50",
+};
+
type ResetActionState =
"idle" | "confirming" | "using" | CodexRateLimitResetOutcome | "error";
@@ -76,6 +93,7 @@ export function UsageProviderChip({
onConsumeReset,
onReconnect,
presentation,
+ variant = "footer",
}: {
limits: ProviderRateLimits;
now: number;
@@ -88,6 +106,8 @@ export function UsageProviderChip({
onManageAccounts?: () => void;
onConsumeReset?: (creditId?: string) => Promise;
onReconnect?: () => Promise;
+ /** `composer` matches the quiet tool buttons under a Mono's composer. */
+ variant?: UsageChipVariant;
}) {
const showRemaining = useShowRemainingUsage();
const trigger = useRef(null);
@@ -224,7 +244,7 @@ export function UsageProviderChip({
el instanceof Element && !!el.closest(CHAT);
+const trace = (what: string, el?: Element) =>
+ console.log(`[mono-scroll] ${what}`, el, new Error().stack);
+
+const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!;
+Object.defineProperty(Element.prototype, "scrollTop", {
+ ...scrollTop,
+ set(this: Element, value: number) {
+ if (inChat(this) && Math.abs(value - this.scrollTop) > 40)
+ trace(`scrollTop ${Math.round(this.scrollTop)} -> ${Math.round(value)}`, this);
+ scrollTop.set!.call(this, value);
+ },
+});
+for (const name of ["scrollIntoView", "scrollTo", "scrollBy"] as const) {
+ const original = Element.prototype[name] as (...args: unknown[]) => void;
+ (Element.prototype as unknown as Record)[name] = function (
+ this: Element,
+ ...args: unknown[]
+ ) {
+ if (inChat(this)) trace(name, this);
+ return original.apply(this, args);
+ };
+}
+const originalFocus = HTMLElement.prototype.focus;
+HTMLElement.prototype.focus = function (this: HTMLElement, options?: FocusOptions) {
+ if (inChat(this) && !options?.preventScroll) trace("focus", this);
+ return originalFocus.call(this, options);
+};
+// Anything else (layout, browser focus) shows up as a jump with no trace above.
+const last = new WeakMap();
+document.addEventListener(
+ "scroll",
+ (event) => {
+ const el = event.target;
+ if (!(el instanceof Element) || !el.matches(CHAT)) return;
+ const previous = last.get(el) ?? el.scrollTop;
+ if (Math.abs(el.scrollTop - previous) > 40)
+ console.log(`[mono-scroll] scrolled ${Math.round(previous)} -> ${Math.round(el.scrollTop)}`, document.activeElement);
+ last.set(el, el.scrollTop);
+ },
+ true,
+);
+export {};
diff --git a/src/features/agent-app/model/agentApp.test.ts b/src/features/agent-app/model/agentApp.test.ts
index 830650880c..02c637f421 100644
--- a/src/features/agent-app/model/agentApp.test.ts
+++ b/src/features/agent-app/model/agentApp.test.ts
@@ -142,7 +142,7 @@ describe("agent app commands", () => {
}, host);
const launch = vi.mocked(host.start).mock.calls.at(-1)![0];
expect(launch.sidebarHidden).toBe(
- showStartedSessionsInSidebar === false ? true : undefined,
+ showStartedSessionsInSidebar === true ? undefined : true,
);
}
},
@@ -172,6 +172,7 @@ describe("agent app commands", () => {
host.monoOf = () => ({
id: "mono",
projects: [source.cwd],
+ showStartedSessionsInSidebar: true,
folder: { name: "Captain Jack", color: "#5b8def" },
});
for (const request of ["first", "second"])
@@ -1368,6 +1369,34 @@ describe("agent app commands", () => {
).resolves.toMatchObject({ body: note.body });
});
+ it("saves an html visual with its kind and posts it to the reply", async () => {
+ const { source, host } = fixture();
+ host.isMono = () => true;
+ host.artifact = vi.fn(async () => null);
+ host.saveArtifact = vi.fn(async (artifact) => ({
+ ...artifact,
+ createdAt: 1,
+ updatedAt: 1,
+ }));
+ host.postArtifact = vi.fn();
+ await expect(
+ handleAgentApp(
+ source,
+ "visual",
+ "artifacts.write",
+ { kind: "html", title: "Build times", body: "chart
" },
+ host,
+ ),
+ ).resolves.toMatchObject({ kind: "html", title: "Build times" });
+ expect(host.saveArtifact).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: "html", body: "chart
" }),
+ );
+ expect(host.postArtifact).toHaveBeenCalledWith(
+ source.id,
+ expect.objectContaining({ kind: "html" }),
+ );
+ });
+
it("rejects unsupported artifact kinds before saving or posting a card", async () => {
const { source, host } = fixture();
host.isMono = () => true;
diff --git a/src/features/agent-app/model/agentApp.ts b/src/features/agent-app/model/agentApp.ts
index 196b0142d3..c8ffef8573 100644
--- a/src/features/agent-app/model/agentApp.ts
+++ b/src/features/agent-app/model/agentApp.ts
@@ -29,11 +29,12 @@ import {
type Note,
type NoteUpsert,
} from "../../notes";
-import type {
- Artifact,
- ArtifactCard,
- ArtifactKind,
- ArtifactUpsert,
+import {
+ ARTIFACT_KINDS,
+ type Artifact,
+ type ArtifactCard,
+ type ArtifactKind,
+ type ArtifactUpsert,
} from "../../artifacts/artifacts";
import type { QuickLaunch } from "../../quick-composer/model/quickComposer";
import type { Worktree, Worktrees } from "../../source-control/model/worktrees";
@@ -468,7 +469,7 @@ function startLaunch(
return {
cwd,
prompt,
- ...(host.monoOf?.(source.id)?.showStartedSessionsInSidebar === false
+ ...(host.monoOf?.(source.id)?.showStartedSessionsInSidebar !== true
? { sidebarHidden: true }
: {}),
...(draft ? { draft: true } : {}),
@@ -821,8 +822,10 @@ async function handleHabits(
}
function artifactKind(value: unknown): ArtifactKind {
- if (value === undefined || value === "document") return "document";
- throw new Error('Unsupported artifact kind; only "document" is supported');
+ if (value === undefined) return "document";
+ if (ARTIFACT_KINDS.includes(value as ArtifactKind))
+ return value as ArtifactKind;
+ throw new Error('Unsupported artifact kind; use "document" or "html"');
}
async function handleArtifacts(
diff --git a/src/features/artifacts/artifacts.ts b/src/features/artifacts/artifacts.ts
index c1d94b6dff..6be967314e 100644
--- a/src/features/artifacts/artifacts.ts
+++ b/src/features/artifacts/artifacts.ts
@@ -1,8 +1,9 @@
-import { invoke } from "@tauri-apps/api/core";
+import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import type { Block, Session } from "../sessions/model/session";
/** Extend this union when a renderer and storage support for a new kind exist. */
-export type ArtifactKind = "document";
+export type ArtifactKind = "document" | "html";
+export const ARTIFACT_KINDS: readonly ArtifactKind[] = ["document", "html"];
export type Artifact = {
id: string;
kind: ArtifactKind;
@@ -24,9 +25,16 @@ export function artifactLabel(kind: ArtifactKind): string {
switch (kind) {
case "document":
return "Document";
+ case "html":
+ return "Visual";
}
}
+/** Where the app's own scheme serves an `html` artifact to its sandboxed frame. */
+export function htmlArtifactUrl(id: string): string {
+ return convertFileSrc(id, "monoartifact");
+}
+
export function getArtifact(id: string): Promise {
return invoke("artifacts_get", { id });
}
diff --git a/src/features/artifacts/ui/ArtifactCard.tsx b/src/features/artifacts/ui/ArtifactCard.tsx
index 8ff8827e78..11d1168751 100644
--- a/src/features/artifacts/ui/ArtifactCard.tsx
+++ b/src/features/artifacts/ui/ArtifactCard.tsx
@@ -1,6 +1,22 @@
-import { File, ExternalLink } from "../../../shared/ui/icons";
+import { ChartBreakoutSquare, File } from "../../../shared/ui/icons";
import { artifactLabel, type ArtifactCard as Card } from "../artifacts";
+export function ArtifactIcon({
+ kind,
+ className,
+}: {
+ kind: Card["kind"];
+ className?: string;
+}) {
+ const Icon = kind === "html" ? ChartBreakoutSquare : File;
+ return ;
+}
+
+/**
+ * One line under the reply: an artifact's icon and its title. The icon sets it
+ * apart from the session cards beside it, which lead with their status; it
+ * keeps their height, which their project chip sets.
+ */
export function ArtifactCard({
card,
onOpen,
@@ -14,22 +30,17 @@ export function ArtifactCard({
type="button"
data-artifact-card={card.id}
aria-label={`Open ${label.toLowerCase()} ${card.title}`}
+ title={`Open ${label.toLowerCase()}`}
onClick={() => onOpen?.(card.id)}
disabled={!onOpen}
- className="flex w-full max-w-lg items-start gap-3 rounded-xl border border-content/10 bg-content/5 px-4 py-3.5 text-left font-sans hover:border-content/20 hover:bg-content/8 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:opacity-60"
+ className="flex w-fit max-w-[min(24rem,100%)] min-w-0 items-center gap-2.5 min-h-[38px] rounded-xl border border-content/10 py-2 pr-3.5 pl-3 text-left font-sans transition-colors hover:border-content/20 hover:bg-content/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:opacity-60"
>
-
-
- {label}
-
- {card.title}
-
-
- Open {label.toLowerCase()}
-
+
+ {card.title}
);
diff --git a/src/features/artifacts/ui/ArtifactContent.tsx b/src/features/artifacts/ui/ArtifactContent.tsx
index d561a63477..f38b94639c 100644
--- a/src/features/artifacts/ui/ArtifactContent.tsx
+++ b/src/features/artifacts/ui/ArtifactContent.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { AgentMarkdown } from "../../sessions/ui/AgentMarkdown";
+import { HtmlArtifactFrame } from "./HtmlArtifactFrame";
import {
ARTIFACTS_CHANGED_EVENT,
getArtifact,
@@ -63,5 +64,13 @@ export function ArtifactContent({
hardBreaks
/>
);
+ case "html":
+ return (
+
+ );
}
}
diff --git a/src/features/artifacts/ui/ArtifactPanel.test.ts b/src/features/artifacts/ui/ArtifactPanel.test.ts
index 7a462b2cac..05d855ced4 100644
--- a/src/features/artifacts/ui/ArtifactPanel.test.ts
+++ b/src/features/artifacts/ui/ArtifactPanel.test.ts
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { copyMessage } from "../../../platform/tauri/clipboard";
import { ARTIFACTS_CHANGED_EVENT, type Artifact } from "../artifacts";
-import { ArtifactPanel } from "./ArtifactPanel";
+import { ArtifactReader } from "./ArtifactPanel";
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
vi.mock("../../sessions/ui/AgentMarkdown", () => ({
@@ -46,12 +46,11 @@ it("loads document artifacts through their own command and closes with Escape",
const onClose = vi.fn();
await act(async () =>
root.render(
- createElement(ArtifactPanel, { id: report.id, color: "#aaa", onClose }),
+ createElement(ArtifactReader, { id: report.id, onClose }),
),
);
expect(invoke).toHaveBeenCalledWith("artifacts_get", { id: report.id });
expect(container.textContent).toContain(report.body);
- expect(container.querySelector("[data-mono-artifact]")).not.toBeNull();
const copy = container.querySelector(
'header [aria-label="Copy document"]',
)!;
@@ -79,9 +78,8 @@ it("keeps a revised document when an older read finishes after refresh", async (
);
act(() =>
root.render(
- createElement(ArtifactPanel, {
+ createElement(ArtifactReader, {
id: report.id,
- color: "#aaa",
onClose: vi.fn(),
}),
),
@@ -99,9 +97,8 @@ it("shows missing documents without falling back to Notes", async () => {
vi.mocked(invoke).mockResolvedValue(null);
await act(async () =>
root.render(
- createElement(ArtifactPanel, {
+ createElement(ArtifactReader, {
id: report.id,
- color: "#aaa",
onClose: vi.fn(),
}),
),
@@ -119,9 +116,8 @@ it("can cancel deletion, keeps the reader on failure, and closes after a success
});
await act(async () =>
root.render(
- createElement(ArtifactPanel, {
+ createElement(ArtifactReader, {
id: report.id,
- color: "#aaa",
onClose,
}),
),
diff --git a/src/features/artifacts/ui/ArtifactPanel.tsx b/src/features/artifacts/ui/ArtifactPanel.tsx
index d5d6a7cda9..595aa2cf30 100644
--- a/src/features/artifacts/ui/ArtifactPanel.tsx
+++ b/src/features/artifacts/ui/ArtifactPanel.tsx
@@ -1,24 +1,28 @@
-import { useEffect, useRef, useState, type ReactNode } from "react";
-import { Copy, Trash2 } from "../../../shared/ui/icons";
+import { useEffect, useRef, useState } from "react";
+import { Copy, PanelRightToggle, Trash2 } from "../../../shared/ui/icons";
import { copyMessage } from "../../../platform/tauri/clipboard";
import { IconButton } from "../../../app/shell/TitleBar";
-import { MonoSidebar, MonoSidebarHeader } from "../../monos/ui/MonoSidebar";
+import { MonoSidebarHeader } from "../../monos/ui/MonoSidebar";
+import { PageHeader } from "../../monos/ui/monoPanelParts";
import { artifactLabel, deleteArtifact } from "../artifacts";
import { ArtifactContent, useArtifact } from "./ArtifactContent";
-export function ArtifactPanel({
+/**
+ * A document's title, body and actions. With `onBack` it is a page over a
+ * list, whose back button and Escape return to that list.
+ */
+export function ArtifactReader({
id,
- color,
+ onBack,
onClose,
onOpenFile,
- windowControls,
}: {
id: string;
- color: string;
+ onBack?: () => void;
onClose: () => void;
onOpenFile?: (path: string) => void;
- windowControls?: ReactNode;
}) {
+ const leave = onBack ?? onClose;
const { artifact, loaded, error, setError } = useArtifact(id);
const [copied, setCopied] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -34,12 +38,12 @@ export function ArtifactPanel({
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) {
event.preventDefault();
- onClose();
+ leave();
}
};
window.addEventListener("keydown", escape);
return () => window.removeEventListener("keydown", escape);
- }, [onClose]);
+ }, [leave]);
const label = artifact ? artifactLabel(artifact.kind) : "Document";
const noun = label.toLowerCase();
@@ -50,50 +54,48 @@ export function ArtifactPanel({
setError(null);
try {
await deleteArtifact(artifact.id);
- if (mounted.current) onClose();
+ if (mounted.current) leave();
} catch {
setError(`Could not delete this ${noun}.`);
} finally {
setDeleting(false);
}
};
- return (
-
-
- void onDelete()}
- >
-
-
-
- void copyMessage(artifact.body).then(
- () => setCopied(true),
- () => setError(`Could not copy this ${noun}.`),
- )
- }
- >
-
-
- >
- ) : null
+ const actions = artifact ? (
+ <>
+ void onDelete()}
+ >
+
+
+
+ void copyMessage(artifact.body).then(
+ () => setCopied(true),
+ () => setError(`Could not copy this ${noun}.`),
+ )
}
- />
-
+ >
+
+
+ >
+ ) : null;
+ return (
+ <>
+ {onBack ? (
+
+ {actions}
+
+
+
+
+ ) : (
+
+ )}
+
{!loaded ? (
Loading document…
@@ -108,14 +110,6 @@ export function ArtifactPanel({
) : (
-
-
- {artifact.title}
-
-
- Updated {new Date(artifact.updatedAt).toLocaleString()}
-
-
{error ? (
@@ -128,6 +122,6 @@ export function ArtifactPanel({
)}
-
+ >
);
}
diff --git a/src/features/artifacts/ui/HtmlArtifactFrame.tsx b/src/features/artifacts/ui/HtmlArtifactFrame.tsx
new file mode 100644
index 0000000000..8ba8268f8d
--- /dev/null
+++ b/src/features/artifacts/ui/HtmlArtifactFrame.tsx
@@ -0,0 +1,157 @@
+import { openUrl } from "@tauri-apps/plugin-opener";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { htmlArtifactUrl } from "../artifacts";
+
+type FrameTheme = { scheme: "dark" | "light"; vars: Record
};
+
+// Page-facing names, resolved to concrete colors: a page cannot see the app's
+// own variables, and unresolved `var()` or `color-mix()` would mean nothing there.
+const THEME_COLORS: Record = {
+ "--background": "var(--color-background-base)",
+ "--foreground": "var(--color-content)",
+ "--muted": "color-mix(in srgb, var(--color-content) 55%, transparent)",
+ "--surface": "color-mix(in srgb, var(--color-content) 5%, transparent)",
+ "--border": "color-mix(in srgb, var(--color-content) 12%, transparent)",
+ "--accent": "var(--color-accent)",
+ "--link": "var(--link-color)",
+ "--positive": "var(--color-diff-add)",
+ "--negative": "var(--color-diff-del)",
+};
+const CHART_COLORS = [
+ "var(--color-accent)",
+ "#10b981",
+ "#f59e0b",
+ "#f43f5e",
+ "#a78bfa",
+ "#22d3ee",
+];
+
+function readTheme(): FrameTheme {
+ const root = document.documentElement;
+ const probe = document.createElement("span");
+ probe.style.display = "none";
+ document.body.appendChild(probe);
+ const resolve = (value: string) => {
+ probe.style.color = value;
+ return getComputedStyle(probe).color;
+ };
+ const vars: Record = {};
+ for (const [name, value] of Object.entries(THEME_COLORS))
+ vars[name] = resolve(value);
+ CHART_COLORS.forEach((value, index) => {
+ vars[`--chart-${index + 1}`] = resolve(value);
+ });
+ probe.remove();
+ const style = getComputedStyle(root);
+ vars["--font-sans"] = style.getPropertyValue("--font-sans").trim();
+ vars["--font-mono"] = style.getPropertyValue("--font-mono").trim();
+ vars["--radius"] = "10px";
+ return {
+ scheme: root.classList.contains("theme-light") ? "light" : "dark",
+ vars,
+ };
+}
+
+/** The app theme as page variables, following theme and appearance changes. */
+function useFrameTheme(): FrameTheme {
+ const [theme, setTheme] = useState(readTheme);
+ useEffect(() => {
+ const observer = new MutationObserver(() => setTheme(readTheme()));
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class", "style"],
+ });
+ return () => observer.disconnect();
+ }, []);
+ return theme;
+}
+
+const MIN_HEIGHT = 80;
+const PLACEHOLDER_HEIGHT = 240;
+
+/**
+ * An `html` artifact in a sandboxed frame. It never gets allow-same-origin,
+ * so the page runs in an opaque origin with no reach into the app, and its
+ * served CSP stops it fetching anything. The frame fits the page's reported
+ * height, so the reader around it does the scrolling.
+ */
+export function HtmlArtifactFrame({
+ id,
+ title,
+ version,
+}: {
+ id: string;
+ title: string;
+ /** Changes when the artifact is saved, reloading the page. */
+ version: number;
+}) {
+ const frameRef = useRef(null);
+ const theme = useFrameTheme();
+ const themeRef = useRef(theme);
+ themeRef.current = theme;
+ const [ready, setReady] = useState(false);
+ const [contentHeight, setContentHeight] = useState(null);
+ const postTheme = () =>
+ frameRef.current?.contentWindow?.postMessage(
+ { type: "monocode:theme", ...themeRef.current },
+ "*",
+ );
+ useEffect(postTheme, [theme]);
+ useEffect(() => {
+ setReady(false);
+ setContentHeight(null);
+ }, [version]);
+ // Listen from the commit that inserts the frame, so a fast page's first
+ // messages are not missed.
+ useLayoutEffect(() => {
+ const receive = (event: MessageEvent) => {
+ const frame = frameRef.current;
+ if (!frame || event.source !== frame.contentWindow) return;
+ const data = event.data as { type?: unknown } | null;
+ if (!data || typeof data !== "object") return;
+ if (data.type === "monocode:ready") {
+ postTheme();
+ setReady(true);
+ } else if (data.type === "monocode:height") {
+ const height = (data as { height?: unknown }).height;
+ if (typeof height === "number" && Number.isFinite(height))
+ setContentHeight(Math.max(MIN_HEIGHT, Math.ceil(height)));
+ } else if (data.type === "monocode:open-link") {
+ const url = (data as { url?: unknown }).url;
+ // Only a link the reader just clicked: the frame holds focus then.
+ if (
+ typeof url === "string" &&
+ /^https?:\/\//i.test(url) &&
+ document.activeElement === frame
+ )
+ void openUrl(url).catch(() => undefined);
+ }
+ };
+ window.addEventListener("message", receive);
+ return () => window.removeEventListener("message", receive);
+ }, []);
+ const height = contentHeight ?? PLACEHOLDER_HEIGHT;
+ return (
+
{session.pendingQuestion ? (
@@ -614,7 +614,7 @@ function ArtifactSheet({
-
+
{!loaded ? (
Loading document…
@@ -625,12 +625,6 @@ function ArtifactSheet({
) : (
-
- {artifact.title}
-
-
- Updated {new Date(artifact.updatedAt).toLocaleString()}
-
void action({ kind: "openFile", path })}
diff --git a/src/features/monos/ui/HabitsList.test.ts b/src/features/monos/ui/HabitsList.test.ts
new file mode 100644
index 0000000000..fde7897b89
--- /dev/null
+++ b/src/features/monos/ui/HabitsList.test.ts
@@ -0,0 +1,66 @@
+// @vitest-environment happy-dom
+import { act, createElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import type { Habit } from "../model/monoHabits";
+import { HabitsList, type HabitActions } from "./MonoHabits";
+
+let root: Root;
+let container: HTMLElement;
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(2026, 9, 12, 12, 0));
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ root = createRoot(container);
+});
+afterEach(() => {
+ act(() => root.unmount());
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+const at = (hour: number, day = 12) => new Date(2026, 9, day, hour).getTime();
+const habit = (id: string, extra: Partial): Habit =>
+ ({
+ id,
+ name: id,
+ instructions: "",
+ schedule: { scheduleKind: "daily", time: "09:00", minute: 0, dayOfWeek: 1 },
+ enabled: true,
+ createdAt: 0,
+ nextRunAt: at(9, 13),
+ ...extra,
+ }) as Habit;
+
+it("separates what is still to come today from what ran and the rest", () => {
+ const actions = {} as HabitActions;
+ act(() =>
+ root.render(
+ createElement(HabitsList, {
+ habits: [
+ habit("Weekly report", { nextRunAt: at(9, 16) }),
+ habit("Evening digest", { nextRunAt: at(18) }),
+ habit("Morning digest", { lastRunAt: at(8) }),
+ habit("Paused", { enabled: false, nextRunAt: at(9, 14) }),
+ ],
+ actions,
+ onOpen: () => {},
+ }),
+ ),
+ );
+ const groups = [...container.querySelectorAll("section")].map((section) => [
+ section.getAttribute("aria-label"),
+ [...section.querySelectorAll("li")].map(
+ (row) =>
+ row.textContent?.match(
+ /Weekly report|Evening digest|Morning digest|Paused/,
+ )?.[0],
+ ),
+ ]);
+ expect(groups).toEqual([
+ ["Up next today", ["Evening digest"]],
+ ["Done today", ["Morning digest"]],
+ ["Later", ["Weekly report", "Paused"]],
+ ]);
+});
diff --git a/src/features/monos/ui/LaunchedSessionCard.tsx b/src/features/monos/ui/LaunchedSessionCard.tsx
new file mode 100644
index 0000000000..3c6d62e945
--- /dev/null
+++ b/src/features/monos/ui/LaunchedSessionCard.tsx
@@ -0,0 +1,129 @@
+import type { ReactNode } from "react";
+import { projectName } from "../../../shared/lib/paths";
+import {
+ Chatting,
+ CheckCircle,
+ CircleAlert,
+ CircleDashed,
+ CircleX,
+ Clock,
+ Loader,
+} from "../../../shared/ui/icons";
+import {
+ sessionDisplayTitle,
+ type MonoSpawnedSession,
+} from "../../sessions/model/session";
+import { HarnessIcon } from "../../sessions/ui/HarnessIcon";
+import type {
+ LaunchedSessionState,
+ LaunchedSessionView,
+} from "../model/monoSpawnedSessions";
+
+const STATUS: Record =
+ {
+ working: {
+ label: "Working",
+ icon: (
+
+ ),
+ },
+ input: {
+ label: "Needs input",
+ icon: (
+
+ ),
+ },
+ draft: {
+ label: "Draft",
+ icon: (
+
+ ),
+ },
+ failed: {
+ label: "Failed",
+ icon: (
+
+ ),
+ },
+ stopped: {
+ label: "Stopped",
+ icon: (
+
+ ),
+ },
+ done: {
+ label: "Done",
+ icon: (
+
+ ),
+ },
+ };
+
+/**
+ * A session the Mono started, as a line under its reply like an artifact:
+ * a spinner while it works, a check once it is done, and the project it runs
+ * in. Without `view` (a window that does not track sessions) it shows no
+ * status.
+ */
+export function LaunchedSessionCard({
+ launch,
+ view,
+ open = false,
+ onOpen,
+}: {
+ launch: MonoSpawnedSession;
+ view?: LaunchedSessionView;
+ /** Shown in the sidebar right now. */
+ open?: boolean;
+ onOpen?: () => void;
+}) {
+ const title = sessionDisplayTitle(
+ view?.title ?? launch.title,
+ launch.harness,
+ );
+ const status = view ? STATUS[view.state] : undefined;
+ return (
+
+ {status?.icon ?? (
+
+ )}
+ {title}
+ {/* Squeezed, the title gives way first and the project after it. */}
+
+
+ {projectName(launch.cwd)}
+
+
+ );
+}
diff --git a/src/features/monos/ui/MonoActivityPanel.tsx b/src/features/monos/ui/MonoActivityPanel.tsx
deleted file mode 100644
index 14ff1cbee9..0000000000
--- a/src/features/monos/ui/MonoActivityPanel.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { ComponentProps, ReactNode } from "react";
-import { workSummaryLine } from "../../sessions/model/transcriptActivity";
-import { MonoActivityTrail } from "../../sessions/ui/AgentTranscript";
-import type { MonoLook } from "../model/mono";
-import { MonoSidebar, MonoSidebarHeader } from "./MonoSidebar";
-
-/** A selected turn's trail, in the order it happened. */
-export function MonoActivityPanel({
- agent,
- onClose,
- windowControls,
- ...trail
-}: ComponentProps & {
- agent: MonoLook;
- onClose: () => void;
- windowControls?: ReactNode;
-}) {
- // A settled turn sums up its work; a live one's steps speak for themselves.
- const summary = trail.live ? "" : workSummaryLine(trail.blocks);
- return (
-
-
-
-
-
-
-
- {trail.live ? "Working" : "Finished"}
-
- {summary ? · {summary} : null}
-
-
-
-
-
-
-
- );
-}
diff --git a/src/features/monos/ui/MonoActivityPanel.test.ts b/src/features/monos/ui/MonoActivityView.test.ts
similarity index 85%
rename from src/features/monos/ui/MonoActivityPanel.test.ts
rename to src/features/monos/ui/MonoActivityView.test.ts
index be1a1908bc..591af487b7 100644
--- a/src/features/monos/ui/MonoActivityPanel.test.ts
+++ b/src/features/monos/ui/MonoActivityView.test.ts
@@ -3,18 +3,11 @@ import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Block } from "../../sessions/model/session";
-import { MonoActivityPanel } from "./MonoActivityPanel";
+import { MonoActivityView } from "./MonoActivityView";
let container: HTMLDivElement;
let root: Root;
-const onClose = vi.fn();
const onApproval = vi.fn();
-const agent = {
- name: "Captain Awesome",
- mascot: "cat",
- color: "#6ba",
- projects: [],
-};
beforeEach(() => {
vi.clearAllMocks();
@@ -39,12 +32,10 @@ afterEach(() => {
function render(blocks: Block[], live = false) {
act(() =>
root.render(
- createElement(MonoActivityPanel, {
- agent,
+ createElement(MonoActivityView, {
blocks,
live,
cwd: "/repo",
- onClose,
onApproval,
}),
),
@@ -79,20 +70,11 @@ it("shows the complete chronological trail", () => {
},
{ id: "reply", role: "assistant", text: "Your instructions are updated." },
]);
- expect(container.querySelector("aside")?.getAttribute("aria-label")).toBe(
- "Captain Awesome activity",
- );
expect(
Array.from(container.querySelectorAll("[data-mono-activity-block]")).map(
(el) => el.getAttribute("data-mono-activity-block"),
),
).toEqual(["thinking", "intro", "read", "progress", "write", "reply"]);
- act(() =>
- container
- .querySelector('[aria-label="Hide activity"]')!
- .click(),
- );
- expect(onClose).toHaveBeenCalledOnce();
});
it("updates a live trail and keeps approval controls actionable", () => {
diff --git a/src/features/monos/ui/MonoActivityView.tsx b/src/features/monos/ui/MonoActivityView.tsx
new file mode 100644
index 0000000000..fe43d6e0ab
--- /dev/null
+++ b/src/features/monos/ui/MonoActivityView.tsx
@@ -0,0 +1,32 @@
+import type { ComponentProps } from "react";
+import { workSummaryLine } from "../../sessions/model/transcriptActivity";
+import { MonoActivityTrail } from "../../sessions/ui/AgentTranscript";
+
+/** A selected turn's trail, in the order it happened. */
+export function MonoActivityView(
+ trail: ComponentProps,
+) {
+ // A settled turn sums up its work; a live one's steps speak for themselves.
+ const summary = trail.live ? "" : workSummaryLine(trail.blocks);
+ return (
+ <>
+
+
+
+ {trail.live ? "Working" : "Finished"}
+
+ {summary ? · {summary} : null}
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/monos/ui/MonoChangesPanel.tsx b/src/features/monos/ui/MonoChangesPanel.tsx
deleted file mode 100644
index b157aabd0b..0000000000
--- a/src/features/monos/ui/MonoChangesPanel.tsx
+++ /dev/null
@@ -1,424 +0,0 @@
-import { useCallback, useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react";
-import { lazySurface } from "../../../shared/ui/lazySurface";
-import { ChevronDown, Folder, GitBranch } from "../../../shared/ui/icons";
-import {
- gitLocateFiles,
- subscribeGitChanged,
- type GitDiffIndex,
-} from "../../../platform/tauri/fs";
-import { projectName } from "../../../shared/lib/paths";
-import {
- sessionCheckpointStatus,
- subscribeReviewChanged,
-} from "../../sessions/model/checkpoint";
-import type { HarnessId } from "../../sessions/model/session";
-import { MonoSidebar, MonoSidebarHeader } from "./MonoSidebar";
-import {
- MonoProjectCommit,
- useProjectIndex,
- type MonoProjectFile,
-} from "./MonoProjectCommit";
-
-const SessionChangesDiff = lazySurface(async () => {
- const module = await import("../../source-control/ui/SessionChangesDiff");
- return { default: module.SessionChangesDiff };
-});
-
-export type MonoChangesTab = "changes" | "commit";
-
-/** What the chat asked the panel to show; a new object re-applies it. */
-export type MonoChangesRequest = { path?: string; tab: MonoChangesTab };
-
-/**
- * A Mono's session changes beside the chat, per project they landed in:
- * review the diff on one tab, commit and push it on the other.
- */
-export function MonoChangesPanel({
- sessionId,
- cwd,
- request,
- color,
- textHarness,
- onClose,
- windowControls,
-}: {
- sessionId: string;
- cwd: string;
- request: MonoChangesRequest;
- color: string;
- textHarness?: HarnessId;
- onClose: () => void;
- windowControls?: ReactNode;
-}) {
- const { projects, outside, loaded, error } = useSessionProjects(sessionId, cwd);
- const [tab, setTab] = useState(request.tab);
- const [focusPath, setFocusPath] = useState(request.path);
- const [selectedRoot, setSelectedRoot] = useState(null);
- const project =
- projects.find((entry) => entry.root === selectedRoot) ??
- (focusPath
- ? projects.find((entry) =>
- entry.files.some((item) => item.file.path === focusPath),
- )
- : undefined) ??
- projects[0];
- const [indexes, setIndexes] = useState>(new Map());
- const updateIndex = useCallback((root: string, index: GitDiffIndex | null) => {
- setIndexes((previous) => previous.get(root) === index ? previous : new Map(previous).set(root, index));
- }, []);
- const index = project ? indexes.get(project.root) ?? null : null;
-
- useEffect(() => {
- setTab(request.tab);
- setFocusPath(request.path);
- if (request.path) setSelectedRoot(null);
- }, [request]);
-
- useEffect(() => {
- const escape = (event: KeyboardEvent) => {
- if (event.key === "Escape" && !event.defaultPrevented) {
- event.preventDefault();
- onClose();
- }
- };
- window.addEventListener("keydown", escape);
- return () => window.removeEventListener("keydown", escape);
- }, [onClose]);
-
- // With one repository the diff shows everything; with several, the
- // selected project's files plus anything outside Git stay together.
- const scope =
- projects.length > 1 && project
- ? [...project.files.map((entry) => entry.file.path), ...outside]
- : undefined;
-
- return (
-
- entry.root)}
- onSelect={setSelectedRoot}
- />
- ) : undefined
- }
- onClose={onClose}
- />
-
- setTab("changes")}
- >
- Changes
- {project && project.files.length > 0 ? (
-
- {project.files.length}
-
- ) : null}
-
- setTab("commit")}
- >
- Commit
-
-
-
- {/* Both tabs stay mounted so switching back is instant and the
- commit draft survives a look at the diff. */}
-
-
-
- {projects.map((entry) => (
-
-
{
- setFocusPath(path);
- setTab("changes");
- }}
- />
-
- ))}
-
- {error ? (
-
- Couldn’t load repositories: {error}
-
- ) : null}
- {loaded && !project && outside.length > 0 ? (
-
- These files aren’t in a Git repository, so there’s nothing to commit.
-
- ) : null}
-
- );
-}
-
-/** Each checkout stays mounted so its draft, selection and actions survive a switch. */
-function ProjectCommit({
- onIndex,
- ...props
-}: Omit, "index" | "reloadIndex"> & {
- onIndex: (root: string, index: GitDiffIndex | null) => void;
-}) {
- const { index, reload } = useProjectIndex(props.root);
- useEffect(() => onIndex(props.root, index), [props.root, index, onIndex]);
- return ;
-}
-
-function TabButton({
- selected,
- disabled,
- onClick,
- children,
-}: {
- selected: boolean;
- disabled?: boolean;
- onClick: () => void;
- children: ReactNode;
-}) {
- return (
-
- {children}
-
- );
-}
-
-/** The project the work landed in, switchable when a Mono touched several. */
-function ProjectHeading({
- root,
- index,
- projects,
- onSelect,
-}: {
- root: string;
- index: GitDiffIndex | null;
- projects: string[];
- onSelect: (root: string) => void;
-}) {
- const [open, setOpen] = useState(false);
- const menuRef = useRef(null);
- const switchable = projects.length > 1;
-
- useEffect(() => {
- if (!open) return;
- const onPointer = (event: PointerEvent) => {
- if (!menuRef.current?.contains(event.target as Node)) setOpen(false);
- };
- window.addEventListener("pointerdown", onPointer);
- return () => window.removeEventListener("pointerdown", onPointer);
- }, [open]);
-
- const name = (
- <>
-
- {projectName(root)}
- {switchable ? (
-
- ) : null}
- >
- );
-
- return (
-
-
- {switchable ? (
-
setOpen((value) => !value)}
- className="-ml-1.5 flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-0.5 hover:bg-content/8 aria-expanded:bg-content/8"
- >
- {name}
-
- ) : (
-
- {name}
-
- )}
- {open ? (
-
- {projects.map((entry) => (
- {
- onSelect(entry);
- setOpen(false);
- }}
- className="flex h-7 w-full items-center gap-2 px-3 text-left text-[12px] font-normal text-content hover:bg-content/10 aria-checked:font-medium"
- >
-
- {projectName(entry)}
-
- ))}
-
- ) : null}
-
- {index?.branch ? (
-
-
- {index.branch}
- {index.ahead > 0 ? (
-
- ↑{index.ahead}
-
- ) : null}
- {index.behind > 0 ? (
-
- ↓{index.behind}
-
- ) : null}
-
- ) : null}
-
- );
-}
-
-type SessionProject = { root: string; files: MonoProjectFile[] };
-
-/**
- * Group the session's changed files by the repository that holds them. A
- * project stays listed after its files are committed so it can still be pushed.
- */
-function useSessionProjects(
- sessionId: string,
- cwd: string,
-): { projects: SessionProject[]; outside: string[]; loaded: boolean; error: string | null } {
- const [projects, setProjects] = useState([]);
- const [outside, setOutside] = useState([]);
- const [loaded, setLoaded] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- setProjects([]);
- setOutside([]);
- setLoaded(false);
- setError(null);
- if (!cwd || cwd === "~") return;
- let disposed = false;
- let generation = 0;
- const load = () => {
- const current = ++generation;
- void sessionCheckpointStatus(sessionId, cwd)
- .then(async ({ files }) => {
- const locations = await gitLocateFiles(
- files.map((file) => file.path),
- );
- if (disposed || current !== generation) return;
- setError(null);
- const byRoot = new Map();
- const loose: string[] = [];
- files.forEach((file, index) => {
- const location = locations[index];
- if (!location) {
- loose.push(file.path);
- return;
- }
- const group = byRoot.get(location.root) ?? [];
- group.push({ file, relative: location.relative });
- byRoot.set(location.root, group);
- });
- setOutside(loose);
- setProjects((previous) => {
- const next = previous.map((project) => ({
- root: project.root,
- files: byRoot.get(project.root) ?? [],
- }));
- for (const [root, group] of byRoot) {
- if (!previous.some((project) => project.root === root)) {
- next.push({ root, files: group });
- }
- }
- return next;
- });
- })
- .catch((caught: unknown) => {
- if (!disposed && current === generation)
- setError(caught instanceof Error ? caught.message : String(caught));
- })
- .finally(() => {
- if (!disposed && current === generation) setLoaded(true);
- });
- };
- load();
- const unsubscribeReview = subscribeReviewChanged((changed) => {
- if (!changed || changed === sessionId) load();
- });
- const unsubscribeGit = subscribeGitChanged(load);
- return () => {
- disposed = true;
- unsubscribeReview();
- unsubscribeGit();
- };
- }, [sessionId, cwd]);
-
- return { projects, outside, loaded, error };
-}
diff --git a/src/features/monos/ui/MonoChangesPanel.test.ts b/src/features/monos/ui/MonoChangesView.test.ts
similarity index 50%
rename from src/features/monos/ui/MonoChangesPanel.test.ts
rename to src/features/monos/ui/MonoChangesView.test.ts
index bd1fe21975..e78736813e 100644
--- a/src/features/monos/ui/MonoChangesPanel.test.ts
+++ b/src/features/monos/ui/MonoChangesView.test.ts
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
-import { act, createElement, type ReactNode } from "react";
+import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
@@ -7,13 +7,6 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({ ask: vi.fn(async () => true) }));
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: vi.fn(async () => {}),
}));
-vi.mock("./MonoSidebar", () => ({
- MonoSidebar: ({ children }: { children: ReactNode }) => children,
- MonoSidebarHeader: ({ heading }: { heading: ReactNode }) => heading,
-}));
-vi.mock("../../source-control/ui/SessionChangesDiff", () => ({
- SessionChangesDiff: () => null,
-}));
vi.mock("../../../platform/tauri/fs", () => ({
gitLocateFiles: vi.fn(),
gitDiffIndex: vi.fn(),
@@ -23,7 +16,10 @@ vi.mock("../../../platform/tauri/fs", () => ({
gitPush: vi.fn(async () => {}),
gitSync: vi.fn(async () => {}),
gitPrCreate: vi.fn(),
- gitStageFile: vi.fn(),
+ gitStageFile: vi.fn(async () => {}),
+ gitUnstageFile: vi.fn(async () => {}),
+ gitStageAll: vi.fn(async () => {}),
+ gitUnstageAll: vi.fn(async () => {}),
gitDiscardFile: vi.fn(),
notifyGitChanged: vi.fn(),
subscribeGitChanged: () => () => {},
@@ -46,7 +42,14 @@ vi.mock("../../sessions/model/checkpoint", () => ({
subscribeReviewChanged: () => () => {},
}));
-import { MonoChangesPanel } from "./MonoChangesPanel";
+vi.mock("../../source-control/model/worktrees", () => ({
+ listWorktrees: vi.fn(async (cwd: string) => ({
+ defaultRoot: "",
+ worktrees: [{ path: cwd.replace(/-wt$/, ""), isMain: true }],
+ })),
+}));
+
+import { MonoChangesView } from "./MonoChangesView";
import {
gitCommit,
gitDiffIndex,
@@ -54,6 +57,7 @@ import {
gitPush,
gitStageFile,
type GitDiffIndex,
+ type GitFileLocation,
} from "../../../platform/tauri/fs";
import { generateCommitMessage } from "../../../integrations/harness";
import {
@@ -61,11 +65,23 @@ import {
sessionCheckpointStatus,
} from "../../sessions/model/checkpoint";
+/** Locate the attached projects' folders, then the session's files. */
+function locateAs(
+ files: (GitFileLocation | null)[],
+ attached: (GitFileLocation | null)[] = [],
+) {
+ vi.mocked(gitLocateFiles).mockResolvedValue([...attached, ...files]);
+}
+
let container: HTMLDivElement;
let root: Root;
+// The view caches each session's projects, so every test is its own session.
+let sessionCount = 0;
+let session = "mono";
beforeEach(() => {
vi.clearAllMocks();
+ session = `mono-${++sessionCount}`;
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
@@ -96,7 +112,7 @@ beforeEach(() => {
undoable: true,
})),
});
- vi.mocked(gitLocateFiles).mockResolvedValue(
+ locateAs(
paths.map((path) =>
path.includes("/")
? {
@@ -129,8 +145,8 @@ beforeEach(() => {
status: "modified",
additions: 1,
deletions: 0,
- staged: relative === "other.txt",
- unstaged: relative !== "other.txt",
+ staged: relative === "other.txt" || (cwd.endsWith("/b") && relative === "same.txt"),
+ unstaged: !(relative === "other.txt" || (cwd.endsWith("/b") && relative === "same.txt")),
})),
}) satisfies GitDiffIndex,
);
@@ -143,15 +159,15 @@ afterEach(async () => {
vi.unstubAllGlobals();
});
-async function render() {
+async function render(attachedProjects: string[] = []) {
await act(async () =>
root.render(
- createElement(MonoChangesPanel, {
- sessionId: "mono",
+ createElement(MonoChangesView, {
+ sessionId: session,
cwd: "/home",
- request: { tab: "commit" },
- color: "blue",
- onClose: () => {},
+ request: {},
+ agentName: "Mono",
+ attachedProjects,
}),
),
);
@@ -159,7 +175,7 @@ async function render() {
function active() {
return container.querySelector(
- '[role="tabpanel"][aria-label="Commit"]:not([hidden])',
+ '[role="tabpanel"][aria-label^="Commit"]:not([hidden])',
)!;
}
@@ -170,9 +186,8 @@ async function click(button: HTMLButtonElement | null) {
}
async function switchRepo(name: "a" | "b") {
- await click(container.querySelector('button[aria-haspopup="menu"]'));
await click(
- container.querySelector(`[role="menuitemradio"][title="/home/${name}"]`),
+ container.querySelector(`[role="tab"][data-mono-project="/home/${name}"]`),
);
}
@@ -187,21 +202,16 @@ async function message(value: string) {
});
}
-it("preserves repo drafts and selections and commits and pushes only the chosen repo", async () => {
+it("preserves repo drafts and stages, commits and pushes only in the chosen repo", async () => {
await render();
await message("Draft A");
const extra = active().querySelector('[title="extra.txt"]')!.closest("li")!;
- await click(extra.querySelector('[title="Unstage Changes"]'));
+ await click(extra.querySelector('[title="Stage Changes"]'));
+ expect(gitStageFile).toHaveBeenCalledExactlyOnceWith("/home/a", "extra.txt");
await switchRepo("b");
await message("Draft B");
await switchRepo("a");
expect(active().querySelector("textarea")!.value).toBe("Draft A");
- expect(
- active()
- .querySelector('[title="extra.txt"]')!
- .closest("li")!
- .querySelector('[title="Stage Changes"]'),
- ).not.toBeNull();
await switchRepo("b");
expect(active().querySelector("textarea")!.value).toBe("Draft B");
await click(active().querySelector('[aria-label="Commit options"]'));
@@ -210,24 +220,41 @@ it("preserves repo drafts and selections and commits and pushes only the chosen
active().querySelectorAll('[role="menuitem"]'),
).find((button) => button.textContent === "Commit & Push")!,
);
- expect(gitCommit).toHaveBeenCalledExactlyOnceWith(
- "/home/b",
- "Draft B",
- false,
- ["same.txt"],
- );
+ expect(gitCommit).toHaveBeenCalledExactlyOnceWith("/home/b", "Draft B", false);
expect(keepSessionChanges).toHaveBeenCalledExactlyOnceWith(
- "mono",
+ session,
"/home",
"b/same.txt",
);
expect(gitPush).toHaveBeenCalledExactlyOnceWith("/home/b");
- expect(gitStageFile).not.toHaveBeenCalled();
await switchRepo("a");
expect(active().querySelector("textarea")!.value).toBe("Draft A");
});
-it("generates from only the active repo selection without staging files", async () => {
+it("lists the Git index's staged and unstaged files", async () => {
+ await render();
+ const sections = [...active().querySelectorAll("li")].map((item) =>
+ item.querySelector("[title]")?.getAttribute("title"),
+ );
+ expect(sections).toEqual(
+ expect.arrayContaining(["other.txt", "same.txt", "extra.txt"]),
+ );
+ expect(active().textContent).toContain("Staged Changes");
+ expect(
+ active()
+ .querySelector('[title="other.txt"]')!
+ .closest("li")!
+ .querySelector('[title="Unstage Changes"]'),
+ ).not.toBeNull();
+ expect(
+ active()
+ .querySelector('[title="same.txt"]')!
+ .closest("li")!
+ .querySelector('[title="Stage Changes"]'),
+ ).not.toBeNull();
+});
+
+it("generates from the active repo's staged changes", async () => {
await render();
await switchRepo("b");
await click(active().querySelector('[aria-label="Generate commit message"]'));
@@ -235,15 +262,35 @@ it("generates from only the active repo selection without staging files", async
"/home/b",
undefined,
expect.any(AbortSignal),
- ["same.txt"],
);
- expect(gitStageFile).not.toHaveBeenCalled();
expect(gitCommit).not.toHaveBeenCalled();
expect(active().querySelector("textarea")!.value).toBe("Generated message");
await switchRepo("a");
expect(active().querySelector("textarea")!.value).toBe("");
});
+it("lists every project as a tab, the open one selected", async () => {
+ await render();
+ const tabs = [
+ ...container.querySelectorAll(
+ '[role="tablist"][aria-label="Projects"] [role="tab"]',
+ ),
+ ];
+ expect(tabs.map((tab) => tab.getAttribute("data-mono-project"))).toEqual([
+ "/home/a",
+ "/home/b",
+ ]);
+ expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual([
+ "true",
+ "false",
+ ]);
+ await switchRepo("b");
+ expect(tabs[1].getAttribute("aria-selected")).toBe("true");
+ expect(
+ active().querySelector("[data-mono-commit]")!.getAttribute("data-mono-commit"),
+ ).toBe("/home/b");
+});
+
it("shows repository discovery failures", async () => {
vi.mocked(gitLocateFiles).mockRejectedValueOnce(
new Error("Discovery failed"),
@@ -254,3 +301,90 @@ it("shows repository discovery failures", async () => {
);
expect(gitCommit).not.toHaveBeenCalled();
});
+
+it("groups a worktree under its project and switches checkouts from the chevron", async () => {
+ const paths = ["a/one.txt", "a-wt/two.txt", "a-wt/three.txt"];
+ vi.mocked(sessionCheckpointStatus).mockResolvedValue({
+ files: paths.map((relative) => ({
+ path: `/home/${relative}`,
+ relative,
+ status: "modified",
+ additions: 1,
+ deletions: 0,
+ exact: true,
+ undoable: true,
+ })),
+ });
+ locateAs(
+ paths.map((path) => ({
+ root: `/home/${path.split("/")[0]}`,
+ relative: path.split("/")[1],
+ })),
+ );
+ await render();
+ const tabs = [
+ ...container.querySelectorAll(
+ '[role="tablist"][aria-label="Projects"] [role="tab"]',
+ ),
+ ];
+ expect(tabs.map((tab) => tab.getAttribute("data-mono-project"))).toEqual([
+ "/home/a",
+ ]);
+ expect(tabs[0].textContent).toContain("3");
+ await click(container.querySelector('[role="tablist"] [aria-haspopup="listbox"]'));
+ const options = [
+ ...document.querySelectorAll('[role="option"]'),
+ ];
+ expect(options.map((option) => option.title)).toEqual([
+ "/home/a",
+ "/home/a-wt",
+ ]);
+ await click(options[1]);
+ expect(
+ active().querySelector("[data-mono-commit]")!.getAttribute("data-mono-commit"),
+ ).toBe("/home/a-wt");
+});
+
+it("reopens a session on its cached changes while they refresh", async () => {
+ await render();
+ await act(async () => root.unmount());
+ root = createRoot(container);
+ vi.mocked(sessionCheckpointStatus).mockReturnValue(new Promise(() => {}));
+ vi.mocked(gitDiffIndex).mockReturnValue(new Promise(() => {}));
+ await render();
+ expect(
+ [...container.querySelectorAll('[role="tab"]')].map((tab) =>
+ tab.getAttribute("data-mono-project"),
+ ),
+ ).toEqual(["/home/a", "/home/b"]);
+ expect(active().querySelector('[title="extra.txt"]')).not.toBeNull();
+});
+
+it("offers the Mono's projects before the work changes anything", async () => {
+ vi.mocked(sessionCheckpointStatus).mockResolvedValue({ files: [] });
+ locateAs(
+ [],
+ [{ root: "/home/a", relative: ".monocode-probe" }, null],
+ );
+ await render(["/home/a", "/home/not-git"]);
+ expect(
+ [...container.querySelectorAll('[role="tab"]')].map((tab) =>
+ tab.getAttribute("data-mono-project"),
+ ),
+ ).toEqual(["/home/a"]);
+ expect(
+ active().querySelector("[data-mono-commit]")!.getAttribute("data-mono-commit"),
+ ).toBe("/home/a");
+ expect(active().querySelector("textarea")).not.toBeNull();
+});
+
+it("shows an empty state when the Mono has no projects", async () => {
+ vi.mocked(sessionCheckpointStatus).mockResolvedValue({ files: [] });
+ locateAs([]);
+ await render();
+ expect(container.querySelector("textarea")).toBeNull();
+ expect(container.textContent).toContain(
+ "Give Mono a project to work on",
+ );
+ expect(gitDiffIndex).not.toHaveBeenCalled();
+});
diff --git a/src/features/monos/ui/MonoChangesView.tsx b/src/features/monos/ui/MonoChangesView.tsx
new file mode 100644
index 0000000000..1c1526d0cb
--- /dev/null
+++ b/src/features/monos/ui/MonoChangesView.tsx
@@ -0,0 +1,555 @@
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type ComponentProps,
+} from "react";
+import { Popover } from "../../../shared/ui/Popover";
+import {
+ Check,
+ ChevronDown,
+ FileDiff,
+ FolderTree,
+ GitBranch,
+} from "../../../shared/ui/icons";
+import {
+ basename,
+ gitLocateFiles,
+ subscribeGitChanged,
+ type GitDiffIndex,
+} from "../../../platform/tauri/fs";
+import { prettyCwd, projectKey, projectName } from "../../../shared/lib/paths";
+import {
+ sessionCheckpointStatus,
+ subscribeReviewChanged,
+} from "../../sessions/model/checkpoint";
+import type { HarnessId } from "../../sessions/model/session";
+import { useProjectAppearance } from "../../projects/hooks/useProjectAppearance";
+import { useTabGroupLogos } from "../../projects/hooks/useTabGroupLogos";
+import { ProjectLogoIcon } from "../../projects/ui/ProjectLogoIcon";
+import { ProjectMascot } from "../../projects/ui/ProjectMascot";
+import {
+ resolveTabGroupColor,
+ resolveTabGroupLabel,
+ resolveTabGroupLogo,
+ resolveTabGroupMascot,
+} from "../../workspace/model/tabGroups";
+import { EmptyState, PageHeader } from "./monoPanelParts";
+import { listWorktrees } from "../../source-control/model/worktrees";
+import {
+ MonoProjectCommit,
+ useProjectIndex,
+ type MonoProjectFile,
+} from "./MonoProjectCommit";
+import { lazySurface } from "../../../shared/ui/lazySurface";
+import { isImagePath, isVideoPath } from "../../files/model/filePreview";
+import { BinaryFileView } from "../../files/ui/BinaryFileView";
+
+const FileEditor = lazySurface(async () => {
+ const module = await import("../../files/ui/FileEditor");
+ return { default: module.FileEditor };
+});
+
+/** What the chat asked the view to show; a new object re-applies it. */
+export type MonoChangesRequest = { path?: string };
+
+/**
+ * A Mono's session changes, one tab per project they landed in, each with
+ * its commit and push.
+ */
+export function MonoChangesView({
+ sessionId,
+ cwd,
+ request,
+ textHarness,
+ agentName,
+ attachedProjects,
+}: {
+ sessionId: string;
+ cwd: string;
+ request: MonoChangesRequest;
+ textHarness?: HarnessId;
+ /** The Mono, named in the empty state. */
+ agentName: string;
+ /** Projects given to the Mono; each has a tab even before it has changes. */
+ attachedProjects: readonly string[];
+}) {
+ const { projects, outside, loaded, error } = useSessionProjects(
+ sessionId,
+ cwd,
+ attachedProjects,
+ );
+ const [focusPath, setFocusPath] = useState(request.path);
+ const [selectedRoot, setSelectedRoot] = useState(null);
+ const project =
+ projects.find((entry) => entry.root === selectedRoot) ??
+ (focusPath
+ ? projects.find((entry) =>
+ entry.files.some((item) => item.file.path === focusPath),
+ )
+ : undefined) ??
+ projects[0];
+ const [indexes, setIndexes] = useState>(new Map());
+ const updateIndex = useCallback((root: string, index: GitDiffIndex | null) => {
+ setIndexes((previous) => previous.get(root) === index ? previous : new Map(previous).set(root, index));
+ }, []);
+
+ // A changed file, open with its changes over its checkout's list until Back.
+ const [diff, setDiff] = useState<{ root: string; path: string } | null>(null);
+
+ useEffect(() => {
+ setFocusPath(request.path);
+ setDiff(null);
+ if (request.path) setSelectedRoot(null);
+ }, [request]);
+ useEffect(() => setDiff(null), [sessionId, cwd]);
+
+ return (
+
+ {diff ? (
+ <>
+
setDiff(null)}
+ />
+
+
+ {isImagePath(diff.path) || isVideoPath(diff.path) ? (
+
+ ) : (
+ {}}
+ />
+ )}
+
+
+ >
+ ) : null}
+ {project && !diff ? (
+
+ ) : null}
+ {project ? (
+
+ {/* Every checkout stays mounted so its draft survives a switch. */}
+ {projects.map((entry) => (
+
+
setDiff({ root: entry.root, path })}
+ />
+
+ ))}
+
+ ) : (
+
+ {/* With no repository at all there's nothing to commit to. */}
+ {loaded && !error ? (
+
+ {outside.length > 0
+ ? "These files aren’t in a Git repository, so there’s nothing to commit."
+ : `Give ${agentName} a project to work on, and its changes show up here to commit.`}
+
+ ) : null}
+
+ )}
+ {error ? (
+
+ Couldn’t load repositories: {error}
+
+ ) : null}
+
+ );
+}
+
+/** Each checkout stays mounted so its draft, selection and actions survive a switch. */
+function ProjectCommit({
+ onIndex,
+ ...props
+}: Omit, "index" | "reloadIndex"> & {
+ onIndex: (root: string, index: GitDiffIndex | null) => void;
+}) {
+ const { index, reload } = useProjectIndex(props.root);
+ useEffect(() => onIndex(props.root, index), [props.root, index, onIndex]);
+ return ;
+}
+
+/**
+ * Every project the work landed in, as a tab with its logo or mascot. A
+ * project whose changes span several checkouts gets a chevron that switches
+ * between them.
+ */
+function ProjectTabs({
+ projects,
+ selected,
+ indexes,
+ onSelect,
+}: {
+ projects: SessionProject[];
+ selected: SessionProject;
+ indexes: ReadonlyMap;
+ onSelect: (root: string) => void;
+}) {
+ const { groupLabels, groupColors, groupCustomColors, groupMascots } =
+ useProjectAppearance();
+ const groupLogos = useTabGroupLogos();
+ // The checkout last opened in each project, so its tab returns there.
+ const [lastRoots, setLastRoots] = useState>(
+ new Map(),
+ );
+ useEffect(() => {
+ setLastRoots((previous) =>
+ previous.get(selected.main) === selected.root
+ ? previous
+ : new Map(previous).set(selected.main, selected.root),
+ );
+ }, [selected.main, selected.root]);
+ const groups = new Map();
+ for (const entry of projects) {
+ groups.set(entry.main, [...(groups.get(entry.main) ?? []), entry]);
+ }
+ return (
+
+ {[...groups].map(([main, checkouts]) => {
+ const key = projectKey(main);
+ const seed = projectName(main);
+ const label = resolveTabGroupLabel(key, groupLabels, seed);
+ const logoPath = resolveTabGroupLogo(key, groupLogos);
+ const active = main === selected.main;
+ const count = checkouts.reduce(
+ (total, entry) => total + entry.files.length,
+ 0,
+ );
+ const target =
+ checkouts.find(
+ (entry) => entry.root === (active ? selected.root : lastRoots.get(main)),
+ ) ?? checkouts[0];
+ return (
+
1 ? "pr-1" : ""}`}
+ >
+
onSelect(target.root)}
+ className={`flex h-full items-center gap-1.5 pl-2 ${
+ checkouts.length > 1 ? "" : "pr-2"
+ }`}
+ >
+ {logoPath ? (
+
+ ) : (
+
+ )}
+ {label}
+ {count > 0 ? (
+ {count}
+ ) : null}
+
+ {checkouts.length > 1 ? (
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
+
+/** The checkouts of one project that hold changes, like the sidebar's working-copy switcher. */
+function CheckoutPicker({
+ project,
+ checkouts,
+ selected,
+ indexes,
+ onSelect,
+}: {
+ project: string;
+ checkouts: SessionProject[];
+ selected?: string;
+ indexes: ReadonlyMap;
+ onSelect: (root: string) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const anchor = useRef(null);
+ return (
+ <>
+ setOpen((value) => !value)}
+ className="grid size-4.5 place-items-center rounded text-content/45 hover:bg-content/8 hover:text-content aria-expanded:bg-content/8"
+ >
+
+
+ {open ? (
+ setOpen(false)}
+ role="listbox"
+ aria-label={`${project} working copies`}
+ className="overflow-y-auto overscroll-none p-1.5"
+ >
+ {checkouts.map(({ root, main, files }) => {
+ const branch = indexes.get(root)?.branch;
+ return (
+ {
+ onSelect(root);
+ setOpen(false);
+ }}
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-content/5"
+ >
+ {root === main ? (
+
+ ) : (
+
+ )}
+
+
+ {branch ?? projectName(root)}
+
+
+ {root === main ? "Project folder" : prettyCwd(root)}
+
+
+ {files.length > 0 ? (
+
+ {files.length}
+
+ ) : null}
+ {root === selected ? : null}
+
+ );
+ })}
+
+ ) : null}
+ >
+ );
+}
+
+/**
+ * A checkout with changes, and the project folder it is a worktree of. A
+ * project attached to the Mono is listed too, as a placeholder, while the
+ * work hasn't touched it.
+ */
+type SessionProject = {
+ root: string;
+ main: string;
+ files: MonoProjectFile[];
+ placeholder?: boolean;
+};
+
+/** A path inside `folder`, so locating it finds the checkout `folder` is in. */
+const folderProbe = (folder: string) =>
+ `${folder.replace(/\/+$/, "")}/.monocode-probe`;
+
+const mainCheckouts = new Map>();
+
+/** The project folder a checkout belongs to; a worktree resolves to its main checkout. */
+function mainCheckout(root: string): Promise {
+ let main = mainCheckouts.get(root);
+ if (!main) {
+ main = listWorktrees(root).then(
+ ({ worktrees }) => worktrees.find((tree) => tree.isMain)?.path ?? root,
+ () => root,
+ );
+ mainCheckouts.set(root, main);
+ }
+ return main;
+}
+
+type SessionProjects = { projects: SessionProject[]; outside: string[] };
+
+/** The last grouping per session, so switching back to a Mono opens on it. */
+const sessionProjectsCache = new Map();
+
+/**
+ * Group the session's changed files by the repository that holds them. A
+ * project stays listed after its files are committed so it can still be pushed.
+ */
+function useSessionProjects(
+ sessionId: string,
+ cwd: string,
+ attachedProjects: readonly string[],
+): SessionProjects & { loaded: boolean; error: string | null } {
+ const attachedKey = attachedProjects.join("\n");
+ const key = `${sessionId}\n${cwd}\n${attachedKey}`;
+ const [state, setState] = useState(() => ({
+ key,
+ ...(sessionProjectsCache.get(key) ?? { projects: [], outside: [] }),
+ }));
+ const [loadedKey, setLoadedKey] = useState(() =>
+ sessionProjectsCache.has(key) ? key : null,
+ );
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const cached = sessionProjectsCache.get(key);
+ setState({ key, ...(cached ?? { projects: [], outside: [] }) });
+ setLoadedKey(cached ? key : null);
+ setError(null);
+ if (!cwd || cwd === "~") return;
+ const attached = attachedKey ? attachedKey.split("\n") : [];
+ let disposed = false;
+ let generation = 0;
+ const load = () => {
+ const current = ++generation;
+ void sessionCheckpointStatus(sessionId, cwd)
+ .then(async ({ files }) => {
+ const located = await gitLocateFiles([
+ ...attached.map(folderProbe),
+ ...files.map((file) => file.path),
+ ]);
+ const attachedLocations = located.slice(0, attached.length);
+ const locations = located.slice(attached.length);
+ const roots = [
+ ...new Set(
+ located.flatMap(
+ (location) => location?.root ?? [],
+ ),
+ ),
+ ];
+ const mains = new Map(
+ await Promise.all(
+ roots.map(async (root) => [root, await mainCheckout(root)] as const),
+ ),
+ );
+ if (disposed || current !== generation) return;
+ setError(null);
+ const byRoot = new Map();
+ const outside: string[] = [];
+ files.forEach((file, index) => {
+ const location = locations[index];
+ if (!location) {
+ outside.push(file.path);
+ return;
+ }
+ const group = byRoot.get(location.root) ?? [];
+ group.push({ file, relative: location.relative });
+ byRoot.set(location.root, group);
+ });
+ const previous = (
+ sessionProjectsCache.get(key)?.projects ?? []
+ ).filter((project) => !project.placeholder);
+ const projects = previous.map((project) => ({
+ ...project,
+ files: byRoot.get(project.root) ?? [],
+ }));
+ for (const [root, group] of byRoot) {
+ if (!previous.some((project) => project.root === root)) {
+ projects.push({ root, main: mains.get(root) ?? root, files: group });
+ }
+ }
+ for (const location of attachedLocations) {
+ const root = location?.root;
+ if (!root || projects.some((project) => project.root === root))
+ continue;
+ projects.push({
+ root,
+ main: mains.get(root) ?? root,
+ files: [],
+ placeholder: true,
+ });
+ }
+ sessionProjectsCache.set(key, { projects, outside });
+ setState({ key, projects, outside });
+ })
+ .catch((caught: unknown) => {
+ if (!disposed && current === generation)
+ setError(caught instanceof Error ? caught.message : String(caught));
+ })
+ .finally(() => {
+ if (!disposed && current === generation) setLoadedKey(key);
+ });
+ };
+ load();
+ const unsubscribeReview = subscribeReviewChanged((changed) => {
+ if (!changed || changed === sessionId) load();
+ });
+ const unsubscribeGit = subscribeGitChanged(load);
+ return () => {
+ disposed = true;
+ unsubscribeReview();
+ unsubscribeGit();
+ };
+ }, [key, sessionId, cwd, attachedKey]);
+
+ // Before the effect catches up with a switch, show the new session's cache.
+ const current =
+ state.key === key
+ ? state
+ : (sessionProjectsCache.get(key) ?? { projects: [], outside: [] });
+ return {
+ projects: current.projects,
+ outside: current.outside,
+ loaded: loadedKey === key || sessionProjectsCache.has(key),
+ error,
+ };
+}
diff --git a/src/features/monos/ui/MonoComposer.test.ts b/src/features/monos/ui/MonoComposer.test.ts
index faa5c70127..4bd433540d 100644
--- a/src/features/monos/ui/MonoComposer.test.ts
+++ b/src/features/monos/ui/MonoComposer.test.ts
@@ -14,6 +14,18 @@ vi.mock("../../sessions/model/attachments", async (original) => ({
vi.mock("../../sessions/hooks/useFileDrop", () => ({
useFileDrop: () => false,
}));
+// The picker itself is covered on its own; here it just hands over a pick.
+vi.mock("../../../shared/ui/EmojiPicker", async () => {
+ const { createElement } = await import("react");
+ return {
+ EmojiPickerPopover: ({ onPick }: { onPick: (emoji: string) => void }) =>
+ createElement(
+ "button",
+ { type: "button", "data-pick": "", onClick: () => onPick("😀") },
+ "pick",
+ ),
+ };
+});
let container: HTMLDivElement;
let root: Root;
@@ -188,3 +200,36 @@ it("follows the composer autocorrect setting", () => {
act(() => saveComposerAutocorrect(true));
expect(field().getAttribute("spellcheck")).toBe("true");
});
+
+it("inserts a picked emoji where the cursor was, beside attach", async () => {
+ render({ controls: createElement("span", null, "Model") });
+ const field = container.querySelector("textarea")!;
+ act(() => {
+ const setter = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ "value",
+ )!.set!;
+ setter.call(field, "hi there");
+ field.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ field.setSelectionRange(2, 2);
+ const button = container.querySelector(
+ 'button[aria-label="Add emoji"]',
+ )!;
+ expect(button.previousElementSibling?.getAttribute("aria-label")).toBe(
+ "Attach files",
+ );
+ // The picker loads on first open.
+ await act(async () => button.click());
+ await vi.waitFor(() =>
+ expect(container.querySelector("[data-pick]")).not.toBeNull(),
+ );
+ expect(button.getAttribute("aria-expanded")).toBe("true");
+ act(() => container.querySelector("[data-pick]")!.click());
+ expect(field.value).toBe("hi😀 there");
+ expect(container.querySelector("[data-pick]")).toBeNull();
+
+ // The floating chat keeps its inline input, without the extra row.
+ render();
+ expect(container.querySelector('button[aria-label="Add emoji"]')).toBeNull();
+});
diff --git a/src/features/monos/ui/MonoComposer.tsx b/src/features/monos/ui/MonoComposer.tsx
index 52be27876a..8bd11b0950 100644
--- a/src/features/monos/ui/MonoComposer.tsx
+++ b/src/features/monos/ui/MonoComposer.tsx
@@ -1,6 +1,14 @@
-import { useCallback, useLayoutEffect, useRef, useState } from "react";
+import {
+ lazy,
+ Suspense,
+ useCallback,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
import { isImeComposition } from "../../../shared/lib/keyboard";
-import { ArrowUp, Plus } from "../../../shared/ui/icons";
+import { ArrowUp, Plus, SmilePlus } from "../../../shared/ui/icons";
import { AttachmentChip } from "../../sessions/ui/AttachmentChip";
import type { Attachment } from "../../sessions/model/session";
import {
@@ -22,10 +30,20 @@ import {
type QuoteRequest,
} from "../../sessions/model/quoteDraft";
+// Loaded on first open, with its emoji data, so neither weighs on startup.
+const EmojiPickerPopover = lazy(() =>
+ import("../../../shared/ui/EmojiPicker").then((module) => ({
+ default: module.EmojiPickerPopover,
+ })),
+);
+
const DROP_STATE = { current: { attachmentsSupported: true, remote: false } };
/** Tallest the field grows before it scrolls, in px. */
const MAX_HEIGHT = 160;
+/** The quiet buttons under the input: a glyph that gains a backdrop on hover. */
+const TOOL_BUTTON =
+ "grid size-6.5 shrink-0 place-items-center rounded-md text-content/55 transition-colors hover:bg-content/8 hover:text-content disabled:opacity-50";
const INPUT_COLUMNS = "grid-cols-[1.625rem_minmax(0,1fr)_1.625rem]";
const FIELD_CLASSES =
"scrollbar-none block min-w-0 w-full resize-none bg-transparent py-1 text-[13px] leading-4.5 text-content outline-none placeholder:text-content/35";
@@ -44,13 +62,22 @@ type Props = {
attachments: Attachment[],
) => boolean | void | Promise;
onFocus?: () => void;
+ /**
+ * Sits under the input on the right, like the model and its effort, with
+ * attach across from it. Without any, attach and send share the field's
+ * line, as in the floating chat.
+ */
+ controls?: ReactNode;
+ /** Sits beside attach and emoji, on the left under the input. */
+ tools?: ReactNode;
};
/**
* A messaging app's input for the Mono: attach, type, send, whether or not
* it is mid-reply; Escape stops a reply. Files dropped anywhere on the chat
- * attach here. The model, permissions and checkout
- * live in the details panel instead.
+ * attach here. Attach sits under the input, with any `controls` (the model
+ * and its effort) across from it, or beside the field when there are none;
+ * permissions and checkout live in the details panel.
*/
export function MonoComposer({
sessionId,
@@ -62,6 +89,8 @@ export function MonoComposer({
onDraftChange,
onSubmit,
onFocus,
+ controls,
+ tools,
}: Props) {
const [text, setText] = useState(() => getComposerDraft(sessionId) ?? "");
const [attachments, setAttachments] = useState([]);
@@ -84,24 +113,27 @@ export function MonoComposer({
!submitting &&
pendingReads === 0 &&
(text.trim().length > 0 || attachments.length > 0);
- // Attachments sit above the field like a second line of text, so the field
- // moves above the buttons for them too.
- const stacked = multiline || attachments.length > 0;
+ const inline = !controls;
+ // Attachments sit above the field like a second line of text, so an inline
+ // field moves above the buttons for them too.
+ const stacked = inline && (multiline || attachments.length > 0);
useLayoutEffect(() => {
const el = field.current;
+ if (!el) return;
const measure = inlineMeasure.current;
- if (!el || !measure) return;
const fit = () => {
- if (!measure.clientWidth) return;
- // Always measure at the inline width. Expanding the field above the
- // buttons must not make it collapse again just because it is wider.
- const style = getComputedStyle(measure);
- const singleLineHeight =
- Number.parseFloat(style.lineHeight) +
- Number.parseFloat(style.paddingTop) +
- Number.parseFloat(style.paddingBottom);
- setMultiline(measure.scrollHeight > singleLineHeight + 1);
+ if (!el.clientWidth) return;
+ if (measure?.clientWidth) {
+ // Always measure at the inline width. Expanding the field above the
+ // buttons must not make it collapse again just because it is wider.
+ const style = getComputedStyle(measure);
+ const singleLineHeight =
+ Number.parseFloat(style.lineHeight) +
+ Number.parseFloat(style.paddingTop) +
+ Number.parseFloat(style.paddingBottom);
+ setMultiline(measure.scrollHeight > singleLineHeight + 1);
+ }
// Holds the input's height while measuring, as sessions do, so the
// transcript above never grows for a moment and loses its bottom pin.
resizeComposer(el, MAX_HEIGHT);
@@ -110,9 +142,16 @@ export function MonoComposer({
fit();
// An empty field stays one line at any width, so once measured, resizing
// the panes beside it need not measure it every frame.
- if (!text && measure.clientWidth) return;
- const observer = new ResizeObserver(fit);
- observer.observe(measure);
+ const sized = measure ?? el;
+ if (!text && sized.clientWidth) return;
+ let width = sized.clientWidth;
+ const observer = new ResizeObserver(() => {
+ // Only a new width can rewrap the text; growing taller is our own doing.
+ if (sized.clientWidth === width) return;
+ width = sized.clientWidth;
+ fit();
+ });
+ observer.observe(sized);
return () => observer.disconnect();
}, [text, multiline, stacked]);
@@ -126,6 +165,30 @@ export function MonoComposer({
setComposerDraft(sessionId, next);
onDraftChange?.(next);
};
+ const emojiButton = useRef(null);
+ const [emojiOpen, setEmojiOpen] = useState(false);
+ // Where the pick goes: the field's selection when the picker opened.
+ const emojiSelection = useRef<[number, number] | null>(null);
+ const toggleEmoji = () => {
+ if (emojiOpen) return setEmojiOpen(false);
+ const el = field.current;
+ emojiSelection.current = el ? [el.selectionStart, el.selectionEnd] : null;
+ setEmojiOpen(true);
+ };
+ const insertEmoji = (emoji: string) => {
+ const current = textRef.current;
+ const [start, end] = emojiSelection.current ?? [
+ current.length,
+ current.length,
+ ];
+ update(current.slice(0, start) + emoji + current.slice(end));
+ setEmojiOpen(false);
+ const caret = start + emoji.length;
+ requestAnimationFrame(() => {
+ field.current?.focus();
+ field.current?.setSelectionRange(caret, caret);
+ });
+ };
useLayoutEffect(() => {
const next = consumeQuoteRequest(
textRef.current,
@@ -241,6 +304,68 @@ export function MonoComposer({
} else clear(result);
};
+ // `tool` sizes the plus to match the emoji face beside it: its cross fills
+ // 14 of the glyph's 24 units against the face's 21, so it is drawn larger,
+ // with a thinner stroke so both lines render about 1px wide.
+ const attachButton = (className: string, tool = false) => (
+ readAttachments(pickAttachments)}
+ className={className}
+ >
+
+
+ );
+ const input = (className: string) => (
+
+ )}
);
}
diff --git a/src/features/monos/ui/MonoDetails.test.ts b/src/features/monos/ui/MonoDetails.test.ts
deleted file mode 100644
index bd041d0311..0000000000
--- a/src/features/monos/ui/MonoDetails.test.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-// @vitest-environment happy-dom
-import { act, createElement } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, expect, it, vi } from "vitest";
-import {
- RUNTIME_MODE_LABEL,
- RUNTIME_MODES,
- type RuntimeMode,
-} from "../../sessions/model/session";
-import { MonoDetails } from "./MonoDetails";
-
-vi.mock("../../sessions/ui/ModelPicker", () => ({
- ModelPicker: () => null,
- ModelSettingRows: () => null,
-}));
-
-vi.mock("../model/monoFiles", async (original) => ({
- ...(await original
()),
- loadMonoFiles: async () => ({
- id: "mono-1",
- dir: "/data/mono-1",
- soul: "# Soul",
- soulHash: "soul",
- memory: "",
- memoryHash: "memory",
- memoryPath: "/data/mono-1/MEMORY.md",
- topics: [],
- }),
-}));
-
-vi.mock("../model/monoHabits", async (original) => ({
- ...(await original()),
- loadHabits: async () => [],
-}));
-
-let container: HTMLDivElement;
-let root: Root;
-const onRuntimeModeChange = vi.fn();
-
-beforeEach(() => {
- vi.clearAllMocks();
- const stored = new Map();
- vi.stubGlobal("localStorage", {
- getItem: (key: string) => stored.get(key) ?? null,
- setItem: (key: string, value: string) => stored.set(key, value),
- removeItem: (key: string) => stored.delete(key),
- });
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- vi.stubGlobal(
- "ResizeObserver",
- class {
- observe() {}
- disconnect() {}
- },
- );
- container = document.createElement("div");
- document.body.append(container);
- root = createRoot(container);
-});
-
-afterEach(() => {
- act(() => root.unmount());
- container.remove();
- vi.unstubAllGlobals();
-});
-
-async function render(runtimeMode: RuntimeMode, busy = false) {
- await act(async () =>
- root.render(
- createElement(MonoDetails, {
- open: true,
- monoId: "mono-1",
- cwd: "/home",
- agent: { name: "Broski", mascot: "cat", color: "#9c9", projects: [] },
- state: { status: busy ? "working" : "idle" },
- harness: "codex",
- model: "codex:gpt-5.4",
- modelSettings: {},
- runtimeMode,
- busy,
- onModelChange: vi.fn(),
- onModelSettingsChange: vi.fn(),
- onRuntimeModeChange,
- onClose: vi.fn(),
- }),
- ),
- );
-}
-
-function trigger() {
- return container.querySelector(
- "[data-access-picker-trigger]",
- )!;
-}
-
-it("lets the user choose every permission mode from Details and reflects the saved choice", async () => {
- await render("auto");
- expect(container.querySelector("dl")?.textContent).toContain("Permissions");
- expect(trigger().textContent).toBe("Auto");
-
- for (const mode of RUNTIME_MODES) {
- act(() => trigger().click());
- const options = document.querySelectorAll(
- '[data-access-picker] [role="option"]',
- );
- expect(options).toHaveLength(RUNTIME_MODES.length);
- act(() => options[RUNTIME_MODES.indexOf(mode)].click());
- expect(onRuntimeModeChange).toHaveBeenLastCalledWith(mode);
- expect(document.querySelector("[data-access-picker]")).toBeNull();
- await render(mode);
- expect(trigger().textContent).toBe(RUNTIME_MODE_LABEL[mode]);
- }
-});
-
-it("explains when permissions take effect while the Mono is working", async () => {
- await render("auto", true);
- expect(trigger().title).toContain("Changes apply to the next turn.");
- act(() => trigger().click());
- expect(document.querySelector("[data-access-picker]")?.textContent).toContain(
- "Access changes apply to the next turn. Stop and resend to apply them now.",
- );
-});
diff --git a/src/features/monos/ui/MonoDetails.tsx b/src/features/monos/ui/MonoDetails.tsx
deleted file mode 100644
index c7cdf70885..0000000000
--- a/src/features/monos/ui/MonoDetails.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-import { useEffect, useState, type ReactNode } from "react";
-import type { HarnessId, RuntimeMode } from "../../sessions/model/session";
-import { AccessPicker } from "../../sessions/ui/AccessPicker";
-import { ModelPicker, ModelSettingRows } from "../../sessions/ui/ModelPicker";
-import type { MonoLook, MonoState } from "../model/mono";
-import {
- loadMonoFiles,
- subscribeMonoFiles,
- type MonoFiles,
-} from "../model/monoFiles";
-import { IconButton } from "../../../app/shell/TitleBar";
-import { Plus } from "../../../shared/ui/icons";
-import { HABITS_MAX } from "../model/monoHabits";
-import { memoryLines } from "../model/monoMemory";
-import { HabitPage } from "./HabitPage";
-import { NewHabitPage } from "./NewHabitPage";
-import { MonoProjects } from "./MonoProjects";
-import { MonoPreferencesPage } from "./MonoPreferencesPage";
-import { MonoSettingsPage } from "./MonoSettingsPage";
-import { habitActions, HabitsList, useHabits } from "./MonoHabits";
-import { MemoryPage, SoulPage } from "./MonoFilePages";
-import { PageHeader, Property } from "./monoPanelParts";
-import { PanelStack, type StackPage } from "./PanelStack";
-import { MonoSidebar, MonoSidebarHeader } from "./MonoSidebar";
-
-/** A page opened directly from Details, or one habit inside its list. */
-type Route =
- | { kind: "habits" | "soul" | "memory" | "settings" | "new-habit" }
- | { kind: "habit"; id: string };
-
-type Props = {
- open: boolean;
- monoId: string;
- /** Its conversation's folder, which the model picker reads settings from. */
- cwd: string;
- agent: MonoLook;
- state: MonoState;
- harness: HarnessId;
- model: string;
- modelSettings: Record;
- runtimeMode: RuntimeMode;
- busy?: boolean;
- onModelChange: (harness: HarnessId, model: string) => void;
- onModelSettingsChange: (settings: Record) => void;
- onRuntimeModeChange: (mode: RuntimeMode) => void;
- onClose: () => void;
- onReset?: () => Promise;
- windowControls?: ReactNode;
-};
-
-/**
- * The Mono's profile, model, permissions and projects in one panel. Its habits,
- * soul, memory and settings open directly as pages that slide over it.
- */
-export function MonoDetails({
- open,
- monoId,
- cwd,
- agent,
- state,
- harness,
- model,
- modelSettings,
- runtimeMode,
- busy = false,
- onModelChange,
- onModelSettingsChange,
- onRuntimeModeChange,
- onClose,
- onReset,
- windowControls,
-}: Props) {
- const files = useMonoFiles(monoId, state.status);
- const habits = useHabits(monoId, state.status);
- const actions = habitActions(monoId);
- const [routes, setRoutes] = useState([]);
- // Another Mono starts at its own front page.
- useEffect(() => setRoutes([]), [monoId]);
- const push = (route: Route) => setRoutes((current) => [...current, route]);
- const back = () => setRoutes((current) => current.slice(0, -1));
-
- const pages: StackPage[] = routes.flatMap((route, depth): StackPage[] => {
- if (route.kind === "habits")
- return [
- {
- key: "habits",
- node: (
-
-
- = HABITS_MAX
- ? `At most ${HABITS_MAX} habits`
- : "New habit"
- }
- disabled={!habits || habits.length >= HABITS_MAX}
- onClick={() => push({ kind: "new-habit" })}
- >
-
-
-
-
- push({ kind: "habit", id })}
- />
-
-
- ),
- },
- ];
- if (route.kind === "soul")
- return [
- {
- key: "soul",
- node: (
-
- ),
- },
- ];
- if (route.kind === "memory")
- return [
- {
- key: "memory",
- node: ,
- },
- ];
- if (route.kind === "settings")
- return [
- {
- key: "settings",
- node: (
-
- ),
- },
- ];
- if (route.kind === "new-habit")
- return [
- {
- key: "new-habit",
- node: ,
- },
- ];
- if (route.kind !== "habit") return [];
- // A habit removed while open closes its page.
- const habit = habits?.find((entry) => entry.id === route.id);
- if (!habit) return [];
- return [
- {
- key: `habit:${route.id}:${depth}`,
- node: (
- actions.runNow(habit.id)}
- onToggle={() => actions.toggle(habit.id)}
- onRemove={() => {
- actions.remove(habit.id);
- back();
- }}
- />
- ),
- },
- ];
- });
-
- return (
-
-
- {/* Pages replace this header too, keeping their back button at the top. */}
-
- push({ kind: page })}
- counts={{
- habits: habits?.length,
- memory: files ? memoryLines(files.memory).length : undefined,
- }}
- >
-
-
-
-
- (
- {control}
- )}
- />
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-/** The agent's files, reloaded when it finishes a turn or the app regains focus. */
-function useMonoFiles(
- monoId: string,
- status: MonoState["status"],
-): MonoFiles | undefined {
- const [files, setFiles] = useState();
- const working = status === "working";
- useEffect(() => {
- let live = true;
- const refresh = () => {
- void loadMonoFiles(monoId)
- .then((next) => {
- if (live) setFiles(next);
- })
- .catch((error) =>
- console.warn("Could not load the Mono's files", error),
- );
- };
- refresh();
- const stop = subscribeMonoFiles(refresh);
- window.addEventListener("focus", refresh);
- return () => {
- live = false;
- stop();
- window.removeEventListener("focus", refresh);
- };
- // A turn ending is when the agent may have written to its memory.
- }, [monoId, working]);
- return files;
-}
diff --git a/src/features/monos/ui/MonoFilePages.tsx b/src/features/monos/ui/MonoFilePages.tsx
index c76d77c637..344104a351 100644
--- a/src/features/monos/ui/MonoFilePages.tsx
+++ b/src/features/monos/ui/MonoFilePages.tsx
@@ -1,11 +1,13 @@
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { isImeComposition } from "../../../shared/lib/keyboard";
import { IconButton } from "../../../app/shell/TitleBar";
import { Pencil, Plus, StickyNote, Trash2 } from "../../../shared/ui/icons";
-import type { MonoLook } from "../model/mono";
+import type { MonoLook, MonoState } from "../model/mono";
import {
MonoFileConflict,
editMonoFile,
+ loadMonoFiles,
+ subscribeMonoFiles,
type MonoFiles,
} from "../model/monoFiles";
import {
@@ -60,7 +62,7 @@ export function SoulPage({
monoId: string;
agent: MonoLook;
files: MonoFiles | undefined;
- onBack: () => void;
+ onBack?: () => void;
}) {
return (
@@ -93,7 +95,7 @@ export function MemoryPage({
}: {
monoId: string;
files: MonoFiles | undefined;
- onBack: () => void;
+ onBack?: () => void;
}) {
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState();
@@ -391,3 +393,34 @@ function FactEditor({
);
}
+
+/** The agent's files, reloaded when it finishes a turn or the app regains focus. */
+export function useMonoFiles(
+ monoId: string,
+ status: MonoState["status"],
+): MonoFiles | undefined {
+ const [files, setFiles] = useState();
+ const working = status === "working";
+ useEffect(() => {
+ let live = true;
+ const refresh = () => {
+ void loadMonoFiles(monoId)
+ .then((next) => {
+ if (live) setFiles(next);
+ })
+ .catch((error) =>
+ console.warn("Could not load the Mono's files", error),
+ );
+ };
+ refresh();
+ const stop = subscribeMonoFiles(refresh);
+ window.addEventListener("focus", refresh);
+ return () => {
+ live = false;
+ stop();
+ window.removeEventListener("focus", refresh);
+ };
+ // A turn ending is when the agent may have written to its memory.
+ }, [monoId, working]);
+ return files;
+}
diff --git a/src/features/monos/ui/MonoHabits.tsx b/src/features/monos/ui/MonoHabits.tsx
index a3dba1e981..1d54bf2d68 100644
--- a/src/features/monos/ui/MonoHabits.tsx
+++ b/src/features/monos/ui/MonoHabits.tsx
@@ -17,18 +17,24 @@ import {
import { RunningFor, useHabitRunning } from "./HabitPage";
import { Empty, Section } from "./monoPanelParts";
+/** Each Mono's habits as last loaded, so switching back shows them at once. */
+const loadedHabits = new Map();
+
/** The Mono's habits, reloaded whenever its folder changes or a run ends. */
export function useHabits(
monoId: string,
status: MonoState["status"],
): Habit[] | undefined {
- const [habits, setHabits] = useState();
+ const [habits, setHabits] = useState(() =>
+ loadedHabits.get(monoId),
+ );
const working = status === "working";
useEffect(() => {
let live = true;
const refresh = () => {
void loadHabits(monoId)
.then((next) => {
+ loadedHabits.set(monoId, next);
if (live) setHabits(next);
})
.catch(() => {});
@@ -203,24 +209,63 @@ export function HabitsList({
habits,
actions,
onOpen,
+ empty = No habits yet ,
}: {
habits: Habit[] | undefined;
actions: HabitActions;
onOpen: (id: string) => void;
+ /** Shown once loaded with none. */
+ empty?: ReactNode;
}) {
- if (!habits) return Loading… ;
- if (habits.length === 0) return No habits yet ;
+ const now = useTodayTick();
+ // Blank for the moment it loads, rather than a line the list replaces.
+ if (!habits) return null;
+ if (habits.length === 0) return <>{empty}>;
+ // The day in three parts: what is still to come today, what already ran,
+ // and the rest, soonest first with paused ones last.
+ const today = habitsToday(habits, now);
+ const upcoming = today.filter((item) => item.state !== "done");
+ const done = today.filter((item) => item.state === "done");
+ const shown = new Set(today.map((item) => item.habit.id));
+ const later = habits
+ .filter((habit) => !shown.has(habit.id))
+ .sort(
+ (a, b) =>
+ Number(b.enabled) - Number(a.enabled) || a.nextRunAt - b.nextRunAt,
+ );
+ const group = (title: string, items: Habit[]) =>
+ items.length ? (
+
+
+ {title}
+
+ {items.length}
+
+
+
+ {items.map((habit) => (
+ onOpen(habit.id)}
+ />
+ ))}
+
+
+ ) : null;
return (
-
- {habits.map((habit) => (
- onOpen(habit.id)}
- />
- ))}
-
+
+ {group(
+ "Up next today",
+ upcoming.map((item) => item.habit),
+ )}
+ {group(
+ "Done today",
+ done.map((item) => item.habit),
+ )}
+ {group(today.length ? "Later" : "Scheduled", later)}
+
);
}
diff --git a/src/features/monos/ui/MonoHeader.tsx b/src/features/monos/ui/MonoHeader.tsx
index 27ed4323f7..612080b48b 100644
--- a/src/features/monos/ui/MonoHeader.tsx
+++ b/src/features/monos/ui/MonoHeader.tsx
@@ -44,7 +44,7 @@ export function MonoHeader({
{agent.projects.length
? `I can see every session, note and folder in ${monoProjectsPhrase(agent.projects)}. Ask what’s going on, or hand me work.`
- : "Add the projects I should work on from my details, or just ask me something."}
+ : "Add the projects I should work on in my settings, or just ask me something."}
) : (
{
expect(crowd.map((mascot) => mascot.color)).not.toContain(look.color);
});
-it("creates the first mono or puts it off", () => {
+it("creates the first mono or hides monos", () => {
const onCreate = vi.fn();
- const onLater = vi.fn();
+ const onHide = vi.fn();
act(() =>
root.render(
- createElement(MonoIntroPopover, {
- anchor: container,
+ createElement(MonoIntroCard, {
look,
onCreate,
- onLater,
+ onHide,
}),
),
);
@@ -54,17 +53,10 @@ it("creates the first mono or puts it off", () => {
document.body.querySelectorAll("[data-mono-intro-stage] svg"),
).toHaveLength(PROJECT_MASCOTS.length);
- act(() => button("Create your mono").click());
+ act(() => button("Create mono").click());
expect(onCreate).toHaveBeenCalledOnce();
- act(() => button("Not now").click());
- expect(onLater).toHaveBeenCalledOnce();
-
- // Only a choice puts it away.
- act(() => {
- window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
- document.body.dispatchEvent(
- new PointerEvent("pointerdown", { bubbles: true }),
- );
- });
- expect(document.body.textContent).toContain("Meet Monos");
+ act(() =>
+ document.body.querySelector('[role="switch"]')!.click(),
+ );
+ expect(onHide).toHaveBeenCalledOnce();
});
diff --git a/src/features/monos/ui/MonoIntro.tsx b/src/features/monos/ui/MonoIntro.tsx
index 4982156e3b..9e5cea6964 100644
--- a/src/features/monos/ui/MonoIntro.tsx
+++ b/src/features/monos/ui/MonoIntro.tsx
@@ -2,22 +2,20 @@ import type { CSSProperties } from "react";
import { PROJECT_MASCOTS } from "../../projects/model/projectMascots";
import { PixelMascot } from "../../projects/ui/PixelMascot";
import { TAB_GROUP_COLORS } from "../../workspace/model/tabGroups";
-import { Popover } from "../../../shared/ui/Popover";
+import { playCue } from "../../settings/model/sounds";
type Look = { mascot: string; color: string };
type Props = {
- /** The rail's add-a-Mono button, which the intro opens beside. */
- anchor: HTMLElement | null;
/** The mascot and color the user's first Mono will have. */
look: Look;
/** Creates the first Mono and opens it. */
onCreate: () => void;
- /** Passes on the intro for good; the rail still offers a new Mono. */
- onLater: () => void;
+ /** Turns Monos off, as Settings › Monos › Show monos does. */
+ onHide: () => void;
};
/** Gap between the mascots lining up at the back. */
-const CROWD_STEP = 36;
+const CROWD_STEP = 17;
/** The rest of the mascots, each in its own color, to line up behind the mono. */
export function monoIntroCrowd(look: Look) {
@@ -30,29 +28,43 @@ export function monoIntroCrowd(look: Look) {
}
/**
- * Meets a user who has no Mono yet, beside the rail's add button, inviting
- * them to make their first. It shows once ever and stays until they choose:
- * clicking away or Escape does not put it away.
+ * Stands in for the rail's Monos while the user has none, inviting them to
+ * make their first. The switch hides Monos altogether.
*/
-export function MonoIntroPopover({ anchor, look, onCreate, onLater }: Props) {
+export function MonoIntroCard({ look, onCreate, onHide }: Props) {
const crowd = monoIntroCrowd(look);
const middle = (crowd.length - 1) / 2;
return (
-
+
+
+ Meet Monos
+
+ {
+ onHide();
+ playCue("switch");
+ }}
+ className="relative h-4 w-7 shrink-0 rounded-full bg-accent transition-colors"
+ >
+
+
+
+
{crowd.map((mascot, i) => {
const offset = (i - middle) * CROWD_STEP;
@@ -62,12 +74,12 @@ export function MonoIntroPopover({ anchor, look, onCreate, onLater }: Props) {
return (
@@ -75,55 +87,39 @@ export function MonoIntroPopover({ anchor, look, onCreate, onLater }: Props) {
);
})}
-
+
- {/* The user's mono drops into the spotlight once the rest are in. */}
-
+ {/* The user's mono drops in front once the rest are in. */}
+
-
-
- Meet Monos
-
- Experimental
-
-
-
- Agents of your own that live on the rail and work across your
- projects. They remember what matters and pick up habits they run on
- their own.
-
-
-
- Create your mono
-
-
- Not now
-
-
+
+ Agents of your own that work across your projects and run habits on
+ their own.
+
+
+
+
+ Create mono
+
-
+
);
}
diff --git a/src/features/monos/ui/MonoMemoryPage.test.ts b/src/features/monos/ui/MonoMemoryPage.test.ts
new file mode 100644
index 0000000000..15ff45b6cc
--- /dev/null
+++ b/src/features/monos/ui/MonoMemoryPage.test.ts
@@ -0,0 +1,86 @@
+// @vitest-environment happy-dom
+import { act, createElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+
+const saveMonoFile = vi.fn(async (..._args: unknown[]) => "next-hash");
+vi.mock("../model/monoFiles", async (original) => ({
+ ...(await original
()),
+ saveMonoFile: (...args: unknown[]) => saveMonoFile(...args),
+ editMonoFile: async (monoId: string, file: string, edit: (text: string) => string) =>
+ saveMonoFile(monoId, file, edit(files.memory), files.memoryHash),
+}));
+
+const { MemoryPage } = await import("./MonoFilePages");
+
+let root: Root;
+let container: HTMLElement;
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const stored = new Map();
+ vi.stubGlobal("localStorage", {
+ getItem: (key: string) => stored.get(key) ?? null,
+ setItem: (key: string, value: string) => stored.set(key, value),
+ });
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+const files = {
+ id: "a",
+ dir: "/d",
+ soul: "# Soul\n\n## Role\nThe resident agent for this project.",
+ soulHash: "s",
+ memory: "i am a cool pirate\n- 2026-10-04 · The user's name is Nick\n",
+ memoryHash: "m",
+ memoryPath: "/d/MEMORY.md",
+ topics: [],
+};
+
+it("shows memory as facts and forgets one without touching the rest", async () => {
+ act(() =>
+ root.render(
+ createElement(MemoryPage, { monoId: "mono-1", files, onBack: () => {} }),
+ ),
+ );
+ const facts = [...container.querySelectorAll("[data-memory-line]")].map((row) => row.textContent);
+ expect(facts[0]).toBe("i am a cool pirate");
+ expect(facts[1]).toContain("The user's name is Nick");
+ await act(async () => (container.querySelector('[aria-label="Forget: i am a cool pirate"]') as HTMLElement).click());
+ expect(saveMonoFile).toHaveBeenCalledWith(
+ "mono-1",
+ "memory",
+ "- 2026-10-04 · The user's name is Nick\n",
+ "m",
+ );
+});
+
+it("adds a fact typed in the Memory page as a dated entry", async () => {
+ saveMonoFile.mockClear();
+ act(() =>
+ root.render(
+ createElement(MemoryPage, { monoId: "mono-1", files, onBack: () => {} }),
+ ),
+ );
+ const add = container.querySelector('[aria-label="Add memory"]') as HTMLElement;
+ act(() => add.click());
+ const input = container.querySelector('[aria-label="New memory"]') as HTMLTextAreaElement;
+ act(() => {
+ const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
+ set.call(input, "Deploys go through Fly");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await act(async () => {
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ });
+ const [, , text, hash] = saveMonoFile.mock.calls[0] as unknown as string[];
+ expect(text).toMatch(/- \d{4}-\d{2}-\d{2} · Deploys go through Fly\n$/);
+ expect(text.startsWith(files.memory)).toBe(true);
+ expect(hash).toBe("m");
+});
diff --git a/src/features/monos/ui/MonoOverview.test.ts b/src/features/monos/ui/MonoOverview.test.ts
new file mode 100644
index 0000000000..492e84e291
--- /dev/null
+++ b/src/features/monos/ui/MonoOverview.test.ts
@@ -0,0 +1,268 @@
+// @vitest-environment happy-dom
+import { act, createElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { MonoOverview, type MonoOverviewTab } from "./MonoOverview";
+
+vi.mock("../model/monoHabits", async (original) => ({
+ ...(await original()),
+ loadHabits: async () => [],
+}));
+
+vi.mock("./MonoChangesView", () => ({
+ MonoChangesView: () => createElement("p", { "data-changes-view": "" }),
+}));
+
+vi.mock("../../sessions/data/sessionStore", async (original) => ({
+ ...(await original()),
+ // Both launches are still saved; deleted ones are hidden by default.
+ listSessionsByProject: async (cwd: string) =>
+ ["a", "b"].map((id) => ({
+ id,
+ cwd,
+ harness: "codex",
+ model: "codex:gpt-5.4",
+ runtimeMode: "auto",
+ title: id,
+ createdAt: 1,
+ updatedAt: 1,
+ })),
+ getSession: async () => null,
+}));
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+async function render(
+ tab: MonoOverviewTab,
+ onTabChange = vi.fn(),
+ extra: Record = {},
+) {
+ await act(async () =>
+ root.render(
+ createElement(MonoOverview, {
+ open: true,
+ monoId: "mono-1",
+ cwd: "/home",
+ agent: { name: "Broski", mascot: "cat", color: "#9c9", projects: [] },
+ state: { status: "idle" },
+ launches: [
+ {
+ sessionId: "a",
+ cwd: "/p",
+ title: "First",
+ harness: "codex",
+ model: "codex:gpt-5.4",
+ },
+ {
+ sessionId: "b",
+ cwd: "/p",
+ title: "Second",
+ harness: "codex",
+ model: "codex:gpt-5.4",
+ },
+ ],
+ sessions: [],
+ documents: [{ id: "d1", kind: "document", title: "Plan" }],
+ tab,
+ onTabChange,
+ onOpenSession: vi.fn(),
+ onOpenDocument: vi.fn(),
+ onClose: vi.fn(),
+ ...extra,
+ }),
+ ),
+ );
+ return onTabChange;
+}
+
+function tabs() {
+ return [...container.querySelectorAll('[role="tab"]')];
+}
+
+it("names only the open tab, with its count, and switches tabs", async () => {
+ const onTabChange = await render("sessions");
+ const labels = tabs().map((tab) =>
+ tab.querySelector("[data-tab-label]")!.getAttribute("data-tab-label"),
+ );
+ expect(labels).toEqual(["open", "closed", "closed", "closed"]);
+ expect(tabs().map((tab) => tab.getAttribute("aria-label"))).toEqual([
+ "Sessions",
+ "Documents",
+ "Habits",
+ "Changes",
+ ]);
+ expect(tabs()[0].textContent).toBe("Sessions2");
+ expect(tabs()[0].getAttribute("aria-selected")).toBe("true");
+ act(() => tabs()[1].click());
+ expect(onTabChange).toHaveBeenCalledWith("documents");
+});
+
+it("toggles activity from beside the panel toggle", async () => {
+ const onTabChange = await render("documents");
+ const button = () =>
+ container.querySelector(
+ '[aria-label="Show activity"], [aria-label="Hide activity"]',
+ )!;
+ expect(button().getAttribute("aria-pressed")).toBe("false");
+ expect(
+ button().querySelector("[data-tab-label]")!.getAttribute("data-tab-label"),
+ ).toBe("closed");
+ act(() => button().click());
+ expect(onTabChange).toHaveBeenLastCalledWith("activity");
+ await render("activity", onTabChange);
+ expect(button().getAttribute("aria-label")).toBe("Hide activity");
+ expect(button().getAttribute("aria-pressed")).toBe("true");
+ expect(
+ button().querySelector("[data-tab-label]")!.getAttribute("data-tab-label"),
+ ).toBe("open");
+ expect(button().textContent).toBe("Activity");
+ act(() => button().click());
+ expect(onTabChange).toHaveBeenLastCalledWith("documents");
+});
+
+it("shows the picked turn's activity, or how to pick one", async () => {
+ await render("activity");
+ const panel = container.querySelector('[data-mono-overview="activity"]')!;
+ expect(panel.textContent).toContain(
+ "Select the activity on one of Broski's messages",
+ );
+ await render("activity", vi.fn(), {
+ activity: createElement("p", { "data-trail": "" }, "Steps"),
+ });
+ expect(panel.querySelector("[data-trail]")?.textContent).toBe("Steps");
+ expect(panel.textContent).not.toContain("Select the activity");
+});
+
+const changes = (totals: unknown) => ({
+ changes: {
+ sessionId: "mono",
+ cwd: "/home",
+ totals,
+ request: {},
+ projects: [],
+ },
+});
+
+it("always offers a Changes tab and keeps its view mounted once opened", async () => {
+ await render("sessions", vi.fn(), changes(null));
+ expect(tabs()[3].getAttribute("aria-label")).toBe("Changes");
+ expect(container.querySelector("[data-changes-view]")).toBeNull();
+ const onTabChange = vi.fn();
+ await render(
+ "changes",
+ onTabChange,
+ changes({ files: 3, additions: 12, deletions: 4 }),
+ );
+ expect(tabs()[3].textContent).toBe("Changes3");
+ expect(container.querySelector("[data-changes-view]")).not.toBeNull();
+ // Another tab keeps the view mounted, hidden, so a commit draft survives.
+ await render("sessions", onTabChange, changes(null));
+ expect(tabs()[3].getAttribute("aria-label")).toBe("Changes");
+ expect(
+ container.querySelector("[data-changes-view]")!.closest("[hidden]"),
+ ).not.toBeNull();
+});
+
+it("dots the folded Changes icon while there are changes", async () => {
+ await render("sessions", vi.fn(), changes(null));
+ expect(tabs()[3].querySelector("[data-changes-dot]")).toBeNull();
+ await render(
+ "sessions",
+ vi.fn(),
+ changes({ files: 2, additions: 3, deletions: 1 }),
+ );
+ expect(tabs()[3].querySelector("[data-changes-dot]")).not.toBeNull();
+ // Open, the tab names its count instead.
+ await render(
+ "changes",
+ vi.fn(),
+ changes({ files: 2, additions: 3, deletions: 1 }),
+ );
+ expect(tabs()[3].querySelector("[data-changes-dot]")).toBeNull();
+});
+
+it("lists every session it started, newest first", async () => {
+ await render("sessions");
+ const rows = [...container.querySelectorAll("[data-mono-session]")];
+ expect(rows.map((row) => row.getAttribute("data-mono-session"))).toEqual([
+ "b",
+ "a",
+ ]);
+});
+
+it("shows documents in their own tab, and a new-habit button only on Habits", async () => {
+ await render("documents");
+ expect(container.querySelector('[data-mono-document="d1"]')).not.toBeNull();
+ expect(container.querySelector('[aria-label="New habit"]')).toBeNull();
+ await render("habits");
+ expect(container.querySelector('[aria-label="New habit"]')).not.toBeNull();
+});
+
+it("opens a session over the list, at the same width, with back, workspace and hide", async () => {
+ const onBackFromSession = vi.fn();
+ const onOpenSessionInWorkspace = vi.fn();
+ const onClose = vi.fn();
+ await render("sessions", vi.fn(), {
+ shownSession: {
+ sessionId: "a",
+ title: "First",
+ pane: createElement("p", { "data-pane": "" }, "Session pane"),
+ },
+ onBackFromSession,
+ onOpenSessionInWorkspace,
+ onClose,
+ });
+ // The same panel, not another sidebar beside or instead of it.
+ expect(container.querySelectorAll("aside")).toHaveLength(1);
+ const page = container.querySelector("[data-mono-launched-session]")!;
+ expect(page.querySelector("[data-pane]")).not.toBeNull();
+ // The list stays beneath, out of reach while the session covers it.
+ expect(
+ container.querySelector("[data-mono-session]")!.closest("[inert]"),
+ ).not.toBeNull();
+ const button = (label: string) =>
+ page.parentElement!.querySelector(
+ `button[aria-label="${label}"]`,
+ )!;
+ act(() => button("Back").click());
+ expect(onBackFromSession).toHaveBeenCalledOnce();
+ await act(async () => button("Open in workspace").click());
+ expect(onOpenSessionInWorkspace).toHaveBeenCalledWith("a");
+ act(() => button("Hide overview").click());
+ expect(onClose).toHaveBeenCalledOnce();
+});
+
+it("opens a document over its tab with a way back", async () => {
+ const onBackFromDocument = vi.fn();
+ await render("documents", vi.fn(), {
+ shownDocumentId: "d1",
+ onBackFromDocument,
+ });
+ const page = container.querySelector("[data-mono-document-page]")!;
+ expect(page).not.toBeNull();
+ act(() =>
+ page.querySelector('button[aria-label="Back"]')!.click(),
+ );
+ expect(onBackFromDocument).toHaveBeenCalledOnce();
+});
diff --git a/src/features/monos/ui/MonoOverview.tsx b/src/features/monos/ui/MonoOverview.tsx
new file mode 100644
index 0000000000..dfbdd8177a
--- /dev/null
+++ b/src/features/monos/ui/MonoOverview.tsx
@@ -0,0 +1,598 @@
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { IconButton } from "../../../app/shell/TitleBar";
+import {
+ Clock,
+ File,
+ FileDiff,
+ ListBullet,
+ ListFilter,
+ MessageMultiple,
+ Plus,
+ Search,
+} from "../../../shared/ui/icons";
+import { useLockOverscroll } from "../../../shared/hooks/useLockOverscroll";
+import { artifactLabel, type ArtifactCard } from "../../artifacts/artifacts";
+import type { SessionSummary } from "../../sessions/data/sessionStore";
+import type { MonoSpawnedSession, Session } from "../../sessions/model/session";
+import type { MonoLook, MonoState } from "../model/mono";
+import { HABITS_MAX } from "../model/monoHabits";
+import { HabitPage } from "./HabitPage";
+import { NewHabitPage } from "./NewHabitPage";
+import { habitActions, HabitsList, useHabits } from "./MonoHabits";
+import { ArtifactReader } from "../../artifacts/ui/ArtifactPanel";
+import { ArtifactIcon } from "../../artifacts/ui/ArtifactCard";
+import {
+ LaunchedSessionPage,
+ MonoSessionList,
+ type ShownMonoSession,
+} from "./MonoSessionList";
+import { EmptyState } from "./monoPanelParts";
+import { PanelStack, type StackPage } from "./PanelStack";
+import { SessionFiltersMenu } from "../../sessions/ui/SessionFiltersMenu";
+import {
+ DEFAULT_SESSION_SIDEBAR_FILTERS,
+ hasActiveSessionFilters,
+ type SessionSidebarFilters,
+} from "../../sessions/model/sessionFilters";
+import { HARNESSES, type HarnessId } from "../../sessions/model/session";
+import { MonoSidebar, MonoSidebarHeader } from "./MonoSidebar";
+import type { SessionChangeTotals } from "../../sessions/ui/SessionReview";
+import { MonoChangesView, type MonoChangesRequest } from "./MonoChangesView";
+
+export type MonoOverviewTab =
+ "activity" | "sessions" | "habits" | "documents" | "changes";
+
+// Activity sits apart, beside the panel toggle, since it follows the chat.
+const TABS: {
+ id: Exclude;
+ label: string;
+ icon: typeof Clock;
+}[] = [
+ { id: "sessions", label: "Sessions", icon: MessageMultiple },
+ { id: "documents", label: "Documents", icon: File },
+ { id: "habits", label: "Habits", icon: Clock },
+ { id: "changes", label: "Changes", icon: FileDiff },
+];
+
+/** The conversation's recorded changes, reviewed and committed on their tab. */
+export type MonoOverviewChanges = {
+ sessionId: string;
+ cwd: string;
+ /** Null while the conversation has no changes. */
+ totals: SessionChangeTotals | null;
+ request: MonoChangesRequest;
+ textHarness?: HarnessId;
+ /** The projects the Mono works on. */
+ projects: readonly string[];
+};
+
+type Route = { kind: "new-habit" } | { kind: "habit"; id: string };
+
+type Props = {
+ open: boolean;
+ monoId: string;
+ /** Its conversation's folder, which habits run in. */
+ cwd: string;
+ agent: MonoLook;
+ state: MonoState;
+ /** Every session it has started, oldest first. */
+ launches: readonly MonoSpawnedSession[];
+ sessions: readonly Session[];
+ history?: readonly SessionSummary[];
+ /** Documents it has written, oldest first. */
+ documents: readonly ArtifactCard[];
+ changes?: MonoOverviewChanges;
+ /** The steps of the turn picked from the chat, if one is. */
+ activity?: ReactNode;
+ tab: MonoOverviewTab;
+ onTabChange: (tab: MonoOverviewTab) => void;
+ onOpenSession: (sessionId: string) => void | Promise;
+ /** A session it started, open over the list until Back. */
+ shownSession?: ShownMonoSession;
+ onBackFromSession?: () => void;
+ onOpenSessionInWorkspace?: (sessionId: string) => void | Promise;
+ /** A document it wrote, open over the list until Back. */
+ shownDocumentId?: string;
+ onBackFromDocument?: () => void;
+ onOpenFile?: (path: string) => void;
+ onOpenDocument: (id: string) => void;
+ onClose: () => void;
+ windowControls?: ReactNode;
+};
+
+/**
+ * The Mono's work at a glance: the sessions it started, its habits and the
+ * documents it wrote. Who it is and how it works live in its settings.
+ */
+export function MonoOverview({
+ open,
+ monoId,
+ cwd,
+ agent,
+ state,
+ launches,
+ sessions,
+ history,
+ documents,
+ changes,
+ activity,
+ tab,
+ onTabChange,
+ onOpenSession,
+ shownSession,
+ onBackFromSession = () => {},
+ onOpenSessionInWorkspace = () => {},
+ shownDocumentId,
+ onBackFromDocument = () => {},
+ onOpenFile,
+ onOpenDocument,
+ onClose,
+ windowControls,
+}: Props) {
+ const lock = useLockOverscroll();
+ const habits = useHabits(monoId, state.status);
+ const actions = habitActions(monoId);
+ const [routes, setRoutes] = useState([]);
+ const [query, setQuery] = useState("");
+ const [filters, setFilters] = useState(
+ DEFAULT_SESSION_SIDEBAR_FILTERS,
+ );
+ // Once opened, the changes stay mounted so a commit draft survives a look
+ // at another tab.
+ const [changesVisited, setChangesVisited] = useState(tab === "changes");
+ if (tab === "changes" && !changesVisited) setChangesVisited(true);
+ // The activity button toggles back to the tab it was opened from.
+ const lastTab = useRef(
+ tab === "activity" ? "sessions" : tab,
+ );
+ if (tab !== "activity") lastTab.current = tab;
+ // Another Mono starts at its own front page, with all its sessions.
+ useEffect(() => {
+ setRoutes([]);
+ setQuery("");
+ setFilters(DEFAULT_SESSION_SIDEBAR_FILTERS);
+ }, [monoId]);
+ const push = (route: Route) => setRoutes((current) => [...current, route]);
+ const back = () => setRoutes((current) => current.slice(0, -1));
+
+ const pages: StackPage[] = routes.flatMap((route, depth): StackPage[] => {
+ if (route.kind === "new-habit")
+ return [
+ {
+ key: "new-habit",
+ node: ,
+ },
+ ];
+ // A habit removed while open closes its page.
+ const habit = habits?.find((entry) => entry.id === route.id);
+ if (!habit) return [];
+ return [
+ {
+ key: `habit:${route.id}:${depth}`,
+ node: (
+ actions.runNow(habit.id)}
+ onToggle={() => actions.toggle(habit.id)}
+ onRemove={() => {
+ actions.remove(habit.id);
+ back();
+ }}
+ />
+ ),
+ },
+ ];
+ });
+
+ // What the overview opened goes on top of everything, the way it would
+ // from the list beneath.
+ if (shownSession)
+ pages.push({
+ key: `session:${shownSession.sessionId}`,
+ node: (
+
+ onOpenSessionInWorkspace(shownSession.sessionId)
+ }
+ onClose={onClose}
+ >
+ {shownSession.pane}
+
+ ),
+ });
+ if (shownDocumentId)
+ pages.push({
+ key: `document:${shownDocumentId}`,
+ node: (
+
+ ),
+ });
+
+ const habitsFull = !habits || habits.length >= HABITS_MAX;
+ const counts: Record<(typeof TABS)[number]["id"], number | undefined> = {
+ sessions: launches.length,
+ habits: habits?.length,
+ documents: documents.length,
+ changes: changes?.totals?.files,
+ };
+
+ return (
+
+
+ {/* Pages replace this header too, keeping their back button at the top. */}
+
+ }
+ onClose={onClose}
+ actions={
+ <>
+ {tab === "habits" ? (
+ = HABITS_MAX
+ ? `At most ${HABITS_MAX} habits`
+ : "New habit"
+ }
+ disabled={habitsFull}
+ onClick={() => push({ kind: "new-habit" })}
+ >
+
+
+ ) : null}
+
+ onTabChange(tab === "activity" ? lastTab.current : "activity")
+ }
+ />
+ >
+ }
+ />
+ {tab === "sessions" && launches.length ? (
+
+ launches.some((launch) => launch.harness === harness),
+ )}
+ />
+ ) : null}
+ {changes && changesVisited ? (
+
+
+
+ ) : null}
+ entry.id === tab)?.label
+ }
+ hidden={tab === "changes"}
+ className="min-h-0 flex-1 overflow-y-auto overscroll-none px-2 py-2"
+ data-mono-overview={tab === "changes" ? undefined : tab}
+ >
+ {tab === "activity" ? (
+ (activity ?? (
+
+ Select the activity on one of {agent.name}'s messages to
+ see each step it took.
+
+ ))
+ ) : tab === "sessions" ? (
+ launches.length ? (
+ // Cards carry their own inset; this lines their icons up with
+ // the tabs above, as the habit and document rows do.
+
+
+
+ ) : (
+
+ Ask {agent.name} to start a session on one of your projects,
+ like a bug fix or a review, and it shows up here.
+
+ )
+ ) : tab === "habits" ? (
+
push({ kind: "habit", id })}
+ empty={
+
+ Ask {agent.name} to put recurring work on a schedule, like a
+ morning digest or a weekly report.
+
+ }
+ />
+ ) : documents.length ? (
+
+ {[...documents].reverse().map((card) => (
+
+
+
+ ))}
+
+ ) : (
+
+ Ask {agent.name} to write something up, like a plan or a summary,
+ and it shows up here.
+
+ )}
+
+
+
+ );
+}
+
+/** The sidebar's session search and filters, over the Mono's sessions. */
+function SessionSearchBar({
+ query,
+ onQueryChange,
+ filters,
+ onFiltersChange,
+ harnesses,
+}: {
+ query: string;
+ onQueryChange: (query: string) => void;
+ filters: SessionSidebarFilters;
+ onFiltersChange: (filters: SessionSidebarFilters) => void;
+ harnesses: HarnessId[];
+}) {
+ const filterButton = useRef(null);
+ const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
+ const active = hasActiveSessionFilters(filters);
+ return (
+
+
+
+ onQueryChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key !== "Escape" || !query) return;
+ event.preventDefault();
+ event.stopPropagation();
+ onQueryChange("");
+ }}
+ className="h-full w-full min-w-0 rounded-md bg-transparent py-0 pl-7 pr-2 text-[12px] text-content outline-none placeholder:text-content/35"
+ />
+
+
{
+ if (menu) return setMenu(null);
+ const rect = filterButton.current?.getBoundingClientRect();
+ if (rect) setMenu({ x: rect.right - 228, y: rect.bottom + 2 });
+ }}
+ className={`grid size-6 place-items-center rounded-md text-content/50 hover:bg-content/10 hover:text-content ${
+ menu || active ? "bg-selection text-content" : ""
+ }`}
+ >
+
+
+ {menu ? (
+
setMenu(null)}
+ />
+ ) : null}
+
+ );
+}
+
+/**
+ * The overview's tabs: the open one names itself and how many it holds, the
+ * others are just their icons.
+ */
+function OverviewTabs({
+ tab,
+ counts,
+ onChange,
+}: {
+ tab: MonoOverviewTab;
+ counts: Record<(typeof TABS)[number]["id"], number | undefined>;
+ onChange: (tab: MonoOverviewTab) => void;
+}) {
+ return (
+
+ {TABS.map(({ id, label, icon }) => {
+ const selected = id === tab;
+ return (
+ onChange(id)}
+ />
+ );
+ })}
+
+ );
+}
+
+/**
+ * An icon that, while selected, opens out into its name and count, and folds
+ * back into the icon when it is not.
+ */
+function TabButton({
+ label,
+ icon: Icon,
+ selected,
+ count,
+ dot = false,
+ onClick,
+ ...aria
+}: {
+ label: string;
+ icon: typeof Clock;
+ selected: boolean;
+ count?: number;
+ dot?: boolean;
+ onClick: () => void;
+ role?: "tab";
+ "aria-selected"?: boolean;
+ "aria-pressed"?: boolean;
+ "aria-label": string;
+}) {
+ return (
+
+ {dot ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+ {label}
+ {count ? (
+
+ {count}
+
+ ) : null}
+
+
+
+ );
+}
+
+/** A document it wrote, laid out like a habit; opens in the reader. */
+function DocumentRow({
+ card,
+ onOpen,
+}: {
+ card: ArtifactCard;
+ onOpen: (id: string) => void;
+}) {
+ const label = artifactLabel(card.kind);
+ return (
+ onOpen(card.id)}
+ className="flex w-full min-w-0 items-center gap-2.5 rounded-md px-2 py-2 text-left hover:bg-content/5"
+ >
+
+
+
+
+
+ {card.title}
+
+
+ {card.summary || label}
+
+
+
+ );
+}
diff --git a/src/features/monos/ui/MonoPreferencesPage.tsx b/src/features/monos/ui/MonoPreferencesPage.tsx
deleted file mode 100644
index 22e22f319c..0000000000
--- a/src/features/monos/ui/MonoPreferencesPage.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useSyncExternalStore } from "react";
-import { useLockOverscroll } from "../../../shared/hooks/useLockOverscroll";
-import {
- findMono,
- subscribeMonos,
- updateMono,
- type MonoLook,
-} from "../model/mono";
-import { ConfirmReset } from "./ConfirmReset";
-import { PageHeader, SwitchRow } from "./monoPanelParts";
-
-/**
- * Where the sessions a Mono starts go, and resetting its conversation.
- * Opens from the Settings row in its details.
- */
-export function MonoPreferencesPage({
- monoId,
- agent,
- onBack,
- onReset,
-}: {
- monoId: string;
- agent: MonoLook;
- onBack: () => void;
- onReset?: () => Promise;
-}) {
- const lock = useLockOverscroll();
- const shown = useSyncExternalStore(
- subscribeMonos,
- () => findMono(monoId)?.showStartedSessionsInSidebar !== false,
- );
- const folders = useSyncExternalStore(
- subscribeMonos,
- () => findMono(monoId)?.useSidebarFolders === true,
- );
- return (
-
-
-
-
-
- updateMono(monoId, (mono) => ({
- ...mono,
- showStartedSessionsInSidebar: on,
- }))
- }
- />
- {/* Hidden sessions have no sidebar folder to go in. */}
-
- updateMono(monoId, ({ useSidebarFolders: _, ...mono }) =>
- on ? { ...mono, useSidebarFolders: true } : mono,
- )
- }
- />
-
- {onReset ? (
-
-
- {(open, ref) => (
-
-
- Reset conversation
-
-
- Clear all messages and start fresh
-
-
- )}
-
-
- ) : null}
-
-
- );
-}
diff --git a/src/features/monos/ui/MonoProjectCommit.tsx b/src/features/monos/ui/MonoProjectCommit.tsx
index ceea670037..801abc9282 100644
--- a/src/features/monos/ui/MonoProjectCommit.tsx
+++ b/src/features/monos/ui/MonoProjectCommit.tsx
@@ -17,7 +17,11 @@ import {
gitDiscardFile,
gitPrCreate,
gitPush,
+ gitStageAll,
+ gitStageFile,
gitSync,
+ gitUnstageAll,
+ gitUnstageFile,
notifyGitChanged,
subscribeGitChanged,
type GitChangedFile,
@@ -42,39 +46,54 @@ import {
type ChangesView,
} from "../../settings/model/appearance";
import {
+ cachedIndex,
ChangeList,
FileSection,
GitSyncActions,
+ rememberIndex,
+ sameIndex,
usePrStatus,
} from "../../source-control/ui/GitChangesPanel";
/** One session file, with its path inside the repository that holds it. */
export type MonoProjectFile = { file: CheckpointFile; relative: string };
-/** Live Git status for a project a Mono touched. */
+/**
+ * Live Git status for a project a Mono touched. It opens on the last status
+ * read for that checkout, here or in the project's Changes panel, and
+ * refreshes behind it.
+ */
export function useProjectIndex(root: string | undefined): {
index: GitDiffIndex | null;
reload: () => void;
} {
- const [loaded, setLoaded] = useState<{ root: string; index: GitDiffIndex } | null>(null);
+ const [loaded, setLoaded] = useState<{
+ root: string | undefined;
+ index: GitDiffIndex | null;
+ }>(() => ({ root, index: cachedIndex(root) }));
const [nonce, setNonce] = useState(0);
const reload = useCallback(() => setNonce((value) => value + 1), []);
useEffect(() => {
- if (!root) {
- setLoaded(null);
- return;
- }
+ if (!root) return;
let cancelled = false;
let generation = 0;
const load = () => {
const current = ++generation;
void gitDiffIndex(root)
.then((next) => {
- if (!cancelled && current === generation) setLoaded({ root, index: next });
+ if (cancelled || current !== generation) return;
+ rememberIndex(root, next);
+ setLoaded((previous) =>
+ previous.root === root && sameIndex(previous.index, next)
+ ? previous
+ : { root, index: next },
+ );
})
.catch(() => {
- if (!cancelled && current === generation) setLoaded(null);
+ if (cancelled || current !== generation) return;
+ rememberIndex(root, null);
+ setLoaded({ root, index: null });
});
};
load();
@@ -87,7 +106,10 @@ export function useProjectIndex(root: string | undefined): {
};
}, [root, nonce]);
- return { index: loaded && loaded.root === root ? loaded.index : null, reload };
+ return {
+ index: loaded.root === root ? loaded.index : cachedIndex(root),
+ reload,
+ };
}
function confirmNative(message: string): Promise {
@@ -99,8 +121,8 @@ function fail(error: unknown) {
}
/**
- * Commit a Mono's work in one project. The session's files start selected;
- * anything else pending in the checkout is listed but left out unless chosen.
+ * Commit a Mono's work in one project. Staging goes through the Git index,
+ * so it matches what the project's own Changes panel shows.
*/
export function MonoProjectCommit({
sessionId,
@@ -120,6 +142,7 @@ export function MonoProjectCommit({
index: GitDiffIndex | null;
reloadIndex: () => void;
textHarness?: HarnessId;
+ /** The file, opened with its changes over the list. */
onOpenFile: (path: string) => void;
}) {
const { pr, reload: reloadPr } = usePrStatus(root, index?.branch);
@@ -127,9 +150,6 @@ export function MonoProjectCommit({
const [busy, setBusy] = useState(null);
const [status, setStatus] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
- // Session files are in by default and other changes out; keep exceptions.
- const [excluded, setExcluded] = useState>(new Set());
- const [included, setIncluded] = useState>(new Set());
const [view, setView] = useState(loadChangesView);
const [commitOpen, setCommitOpen] = useState(true);
const [restOpen, setRestOpen] = useState(true);
@@ -164,13 +184,10 @@ export function MonoProjectCommit({
useEffect(() => () => generateAbortRef.current?.abort(), []);
- const own = new Set(files.map((entry) => entry.relative));
const changed = index?.files ?? [];
- const isSelected = (relative: string) =>
- own.has(relative) ? !excluded.has(relative) : included.has(relative);
- const inCommit = changed.filter((file) => isSelected(file.relative));
- const rest = changed.filter((file) => !isSelected(file.relative));
- const selected = inCommit.map((file) => file.relative);
+ // The Git index itself, as in the project's Changes panel.
+ const staged = changed.filter((file) => file.staged);
+ const unstaged = changed.filter((file) => file.unstaged);
const hasRemote = Boolean(index?.remote);
const hasOpenPr = pr?.state === "open";
const diverged = (index?.ahead ?? 0) > 0 && (index?.behind ?? 0) > 0;
@@ -178,7 +195,7 @@ export function MonoProjectCommit({
!!index?.branch &&
!!index.defaultBranch &&
index.branch === index.defaultBranch;
- const canCommit = selected.length > 0 && message.trim().length > 0 && !busy;
+ const canCommit = staged.length > 0 && message.trim().length > 0 && !busy;
const canCommitPush = canCommit && hasRemote && !diverged;
const canCommitPushPr = canCommitPush && !hasOpenPr && !onDefault;
const canPublish = hasRemote && !!index && !index.upstream;
@@ -196,19 +213,22 @@ export function MonoProjectCommit({
(index?.aheadOfDefault ?? 0) > 0 &&
(index?.behind ?? 0) === 0;
- const choose = (relatives: readonly string[], include: boolean) => {
- const apply =
- (keep: (relative: string) => boolean, add: boolean) =>
- (previous: ReadonlySet) => {
- const next = new Set(previous);
- for (const relative of relatives.filter(keep)) {
- if (add) next.add(relative);
- else next.delete(relative);
- }
- return next;
- };
- setExcluded(apply((relative) => own.has(relative), !include));
- setIncluded(apply((relative) => !own.has(relative), include));
+ const runStaging = async (
+ key: string,
+ change: () => Promise,
+ paths?: string[],
+ ) => {
+ if (busy) return;
+ setBusy(key);
+ try {
+ await change();
+ if (paths) invalidateWatchedFiles(paths);
+ } catch (error) {
+ fail(error);
+ } finally {
+ settle();
+ setBusy(null);
+ }
};
const discard = async (file: GitChangedFile) => {
@@ -239,31 +259,26 @@ export function MonoProjectCommit({
}
};
- const openFile = (relative: string) => {
- const entry = files.find((item) => item.relative === relative);
- if (entry) onOpenFile(entry.file.path);
- };
-
const changeListProps = {
view,
busy,
- onOpenFile: (path: string) => {
- const file = changed.find((item) => item.path === path);
- if (file) openFile(file.relative);
- },
+ onOpenFile,
onAction: (
file: GitChangedFile,
action: "stage" | "unstage" | "discard",
) => {
if (action === "discard") void discard(file);
- else choose([file.relative], action === "stage");
+ else
+ void runStaging(file.relative, () =>
+ (action === "stage" ? gitStageFile : gitUnstageFile)(
+ root,
+ file.relative,
+ ),
+ );
},
onFolderAction: (folder: string, action: "stage" | "unstage") =>
- choose(
- changed
- .filter((file) => file.relative.startsWith(`${folder}/`))
- .map((file) => file.relative),
- action === "stage",
+ void runStaging(`${action}:${folder}`, () =>
+ (action === "stage" ? gitStageFile : gitUnstageFile)(root, folder),
),
};
@@ -308,7 +323,7 @@ export function MonoProjectCommit({
};
const generate = async () => {
- if (busy || selected.length === 0 || generateAbortRef.current) return;
+ if (busy || staged.length === 0 || generateAbortRef.current) return;
const controller = new AbortController();
generateAbortRef.current = controller;
setBusy("generate");
@@ -317,7 +332,6 @@ export function MonoProjectCommit({
root,
textHarness,
controller.signal,
- selected,
);
if (!controller.signal.aborted) setMessage(generated);
} catch (error) {
@@ -346,9 +360,14 @@ export function MonoProjectCommit({
return;
}
setBusy(createPr ? "pr" : "commit");
- const committed = files.filter((entry) => selected.includes(entry.relative));
+ // Only files committed whole leave the session's review.
+ const committed = files.filter((entry) =>
+ staged.some(
+ (file) => file.relative === entry.relative && !file.unstaged,
+ ),
+ );
try {
- await gitCommit(root, message, false, selected);
+ await gitCommit(root, message, false);
// Committed work is accepted work: clear it from the session's review.
for (const entry of committed) {
await keepSessionChanges(sessionId, cwd, entry.file.relative);
@@ -356,7 +375,6 @@ export function MonoProjectCommit({
notifyReviewChanged(sessionId);
invalidateWatchedFiles(committed.map((entry) => entry.file.path));
setMessage("");
- setIncluded(new Set());
if (push || createPr) {
await gitPush(root);
recordPrActivity();
@@ -448,7 +466,7 @@ export function MonoProjectCommit({
: "Generate commit message"
}
disabled={
- busy === "generate" ? false : !!busy || selected.length === 0
+ busy === "generate" ? false : !!busy || staged.length === 0
}
onClick={() =>
busy === "generate" ? cancelGenerate() : void generate()
@@ -566,50 +584,52 @@ export function MonoProjectCommit({
{status}
) : null}
- {inCommit.length > 0 ? (
+ {staged.length > 0 ? (
setCommitOpen((open) => !open)}
view={view}
onToggleView={toggleView}
headerActions={[
{
- title: "Leave All Out",
+ title: "Unstage All Changes",
icon: ,
- onClick: () => choose(selected, false),
+ onClick: () =>
+ void runStaging("unstage", () => gitUnstageAll(root)),
},
]}
>
) : null}
- {rest.length > 0 ? (
+ {unstaged.length > 0 ? (
setRestOpen((open) => !open)}
view={view}
onToggleView={toggleView}
headerActions={[
{
- title: "Add All to Commit",
+ title: "Stage All Changes",
icon: ,
onClick: () =>
- choose(
- rest.map((file) => file.relative),
- true,
- ),
+ void runStaging("stage", () => gitStageAll(root)),
},
]}
>
-
+
) : null}
>
diff --git a/src/features/monos/ui/MonoProjects.tsx b/src/features/monos/ui/MonoProjects.tsx
index 5a788283d1..c06696db16 100644
--- a/src/features/monos/ui/MonoProjects.tsx
+++ b/src/features/monos/ui/MonoProjects.tsx
@@ -1,6 +1,6 @@
import { useRef, useState } from "react";
import { Plus, X } from "../../../shared/ui/icons";
-import { projectKey, projectName } from "../../../shared/lib/paths";
+import { prettyCwd, projectKey, projectName } from "../../../shared/lib/paths";
import { loadRecents, projectRailItems } from "../../projects/model/recents";
import {
loadTabGroupColors,
@@ -31,14 +31,14 @@ function ProjectFace({ path }: { path: string }) {
seed,
)}
name={resolveTabGroupMascot(key, loadTabGroupMascots())}
- className="size-3 shrink-0"
+ className="size-4 shrink-0"
/>
);
}
/**
- * The projects a Mono works on, under its model in the details panel. Each
- * can be taken away; more are added from the projects on the rail.
+ * The projects a Mono works on, one row each in its settings: face, name and
+ * folder. Each can be taken away; more are added from the projects on the rail.
*/
export function MonoProjects({
monoId,
@@ -56,23 +56,37 @@ export function MonoProjects({
)
: [];
return (
-
+
{projects.map((project) => (
-
-
{project.name}
+
+
+
+
+
+ {project.name}
+
+
+ {prettyCwd(project.path)}
+
+
removeMonoProject(monoId, project.path)}
- className="grid size-5 shrink-0 place-items-center rounded text-content/45 opacity-0 hover:bg-content/8 hover:text-content focus-visible:opacity-100 group-hover:opacity-100"
+ className="grid size-6 shrink-0 place-items-center rounded-md text-content/45 opacity-0 hover:bg-content/8 hover:text-content focus-visible:opacity-100 group-hover:opacity-100"
>
-
+
))}
@@ -82,9 +96,11 @@ export function MonoProjects({
aria-haspopup="dialog"
aria-expanded={adding}
onClick={() => setAdding((open) => !open)}
- className="-ml-1 flex h-7 w-fit items-center gap-1.5 rounded-md px-1 text-content/45 hover:bg-content/6 hover:text-content aria-expanded:bg-content/6 aria-expanded:text-content"
+ className="flex h-11 w-full items-center gap-3 px-3 text-left text-[13px] text-content/55 first:rounded-t-xl last:rounded-b-xl hover:bg-content/4 hover:text-content aria-expanded:bg-content/4 aria-expanded:text-content"
>
-
+
+
+
Add project
{adding ? (
diff --git a/src/features/monos/ui/MonoSessionList.test.ts b/src/features/monos/ui/MonoSessionList.test.ts
new file mode 100644
index 0000000000..1e259b4bde
--- /dev/null
+++ b/src/features/monos/ui/MonoSessionList.test.ts
@@ -0,0 +1,334 @@
+// @vitest-environment happy-dom
+import { act, createElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import {
+ getSession,
+ listSessionsByProject,
+ type SessionSummary,
+} from "../../sessions/data/sessionStore";
+import {
+ newSession,
+ type MonoSpawnedSession,
+ type Session,
+} from "../../sessions/model/session";
+import { MonoSessionList } from "./MonoSessionList";
+
+vi.mock("../../sessions/data/sessionStore", () => ({
+ getSession: vi.fn(),
+ listSessionsByProject: vi.fn(),
+}));
+
+let container: HTMLDivElement;
+let root: Root;
+const onOpenSession = vi.fn();
+const agent = {
+ name: "MonoInvader",
+ mascot: "invader",
+ color: "#6ba",
+ projects: [],
+};
+const launch: MonoSpawnedSession = {
+ sessionId: "app-review",
+ cwd: "/repo",
+ title: "Review latest PR",
+ harness: "codex",
+ model: "gpt-6",
+};
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(listSessionsByProject).mockResolvedValue([]);
+ vi.mocked(getSession).mockResolvedValue(null);
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+async function render(
+ sessions: Session[] = [],
+ launches = [launch],
+ props: Record
= {},
+) {
+ await act(async () =>
+ root.render(
+ createElement(MonoSessionList, {
+ agent,
+ launches,
+ sessions,
+ onOpenSession,
+ ...props,
+ }),
+ ),
+ );
+}
+
+it("shows live cards, updates their status and opens the selected session", async () => {
+ const child = {
+ ...newSession("codex", "/repo"),
+ id: launch.sessionId,
+ title: "PR #799 review",
+ busy: true,
+ };
+ const unrelated = { ...child, id: "other", title: "Unrelated" };
+ await render([child, unrelated]);
+ expect(container.querySelectorAll("[data-mono-session]")).toHaveLength(1);
+ expect(container.textContent).toContain("PR #799 review");
+ expect(container.textContent).toContain("Working");
+ expect(container.textContent).not.toContain("Unrelated");
+ await render([{ ...child, busy: false }]);
+ expect(container.textContent).not.toContain("Working");
+ // The sidebar's own card, naming the project its sessions span.
+ expect(container.querySelector("[data-session-card]")?.textContent).toContain(
+ "repo",
+ );
+ await act(async () =>
+ container.querySelector("[data-session-card]")!.click(),
+ );
+ expect(onOpenSession).toHaveBeenCalledWith(launch.sessionId);
+});
+
+it("loads closed sessions across projects", async () => {
+ vi.mocked(listSessionsByProject).mockImplementation(async (cwd) => [
+ {
+ id: cwd === "/repo" ? launch.sessionId : "app-second",
+ cwd,
+ harness: "codex",
+ model: "gpt-6",
+ runtimeMode: "default",
+ title: cwd === "/repo" ? "Saved review" : "Another project",
+ archived: cwd === "/repo",
+ createdAt: 1,
+ updatedAt: 2,
+ } as SessionSummary,
+ ]);
+ await render(
+ [],
+ [launch, { ...launch, sessionId: "app-second", cwd: "/other" }],
+ );
+ expect(listSessionsByProject).toHaveBeenCalledWith("/repo");
+ expect(listSessionsByProject).toHaveBeenCalledWith("/other");
+ expect(container.textContent).toContain("Saved review");
+ expect(container.textContent).toContain("Another project");
+ expect(container.querySelectorAll("[data-session-card]")).toHaveLength(2);
+});
+
+it("keeps the link usable after a session moves to another project", async () => {
+ vi.mocked(getSession).mockResolvedValue({
+ ...newSession("codex", "/moved"),
+ id: launch.sessionId,
+ title: "Moved review",
+ });
+ await render();
+ expect(getSession).toHaveBeenCalledWith(launch.sessionId);
+ expect(container.textContent).toContain("Moved review");
+ expect(container.textContent).toContain("moved");
+ await act(async () =>
+ container.querySelector("[data-session-card]")!.click(),
+ );
+ expect(onOpenSession).toHaveBeenCalledWith(launch.sessionId);
+});
+
+it("hides deleted launches until filtered to, then shows them as cards that do not open", async () => {
+ await render();
+ expect(container.querySelector("[data-session-card]")).toBeNull();
+ expect(container.textContent).toContain("have since been deleted");
+ await render([], [launch], {
+ filters: {
+ showArchived: false,
+ showUnavailable: true,
+ hiddenHarnesses: [],
+ time: "all",
+ status: { working: false, needsApproval: false, done: false },
+ },
+ });
+ const card = container.querySelector("[data-session-card]")!;
+ expect(card.textContent).toContain(launch.title);
+ expect(card.textContent).toContain("Unavailable");
+ expect(card.closest("[inert]")).not.toBeNull();
+ act(() => card.click());
+ expect(onOpenSession).not.toHaveBeenCalled();
+});
+
+it("allows opening when status lookup fails and reports navigation errors", async () => {
+ vi.mocked(listSessionsByProject).mockRejectedValue(new Error("Offline"));
+ onOpenSession.mockRejectedValue(new Error("Could not restore session"));
+ await render();
+ expect(container.textContent).toContain("Status unavailable");
+ await act(async () =>
+ container.querySelector("[data-session-card]")!.click(),
+ );
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe(
+ "Could not restore session",
+ );
+});
+
+it("shows a draft and then an approval without treating either as completed", async () => {
+ const child = {
+ ...newSession("codex", "/repo"),
+ id: launch.sessionId,
+ blocks: [
+ { id: "draft", role: "user" as const, text: "Review", draft: true },
+ ],
+ };
+ await render([child]);
+ expect(container.textContent).toContain("Draft");
+ await render([
+ {
+ ...child,
+ blocks: [
+ {
+ id: "approval",
+ role: "tool",
+ text: "Run command",
+ approval: { requestId: 42 },
+ },
+ ],
+ },
+ ]);
+ expect(container.textContent).toContain("Need approval");
+});
+
+it("holds a loading card's place invisibly, and shows a known one at once when remounted", async () => {
+ let resolve!: (rows: SessionSummary[]) => void;
+ vi.mocked(listSessionsByProject).mockReturnValue(
+ new Promise((done) => (resolve = done)),
+ );
+ const cached = { ...launch, sessionId: "app-cached" };
+ await render([], [cached]);
+ // No stand-in card that the real one would swap in for.
+ expect(container.textContent).not.toContain("Loading");
+ expect(
+ container.querySelector('[data-mono-session-loading="app-cached"]'),
+ ).not.toBeNull();
+ await act(async () =>
+ resolve([
+ {
+ id: "app-cached",
+ cwd: "/repo",
+ harness: "codex",
+ model: "gpt-6",
+ runtimeMode: "auto",
+ title: "Cached review",
+ createdAt: 1,
+ updatedAt: 2,
+ },
+ ]),
+ );
+ expect(container.textContent).toContain("Cached review");
+ // Switching Monos remounts the list; the card comes straight back.
+ act(() => root.unmount());
+ root = createRoot(container);
+ vi.mocked(listSessionsByProject).mockReturnValue(new Promise(() => {}));
+ await render([], [cached]);
+ expect(container.querySelector("[data-session-card]")?.textContent).toContain(
+ "Cached review",
+ );
+});
+
+it("narrows its cards by search, project and the sidebar's filters", async () => {
+ vi.mocked(listSessionsByProject).mockImplementation(async (cwd) => [
+ {
+ id: cwd === "/repo" ? launch.sessionId : "app-second",
+ cwd,
+ harness: cwd === "/repo" ? "codex" : "claude",
+ model: "gpt-6",
+ runtimeMode: "default",
+ title: cwd === "/repo" ? "Saved review" : "Fix login",
+ archived: cwd === "/repo",
+ createdAt: 1,
+ updatedAt: 2,
+ } as SessionSummary,
+ ]);
+ const launches = [
+ launch,
+ { ...launch, sessionId: "app-second", cwd: "/other", harness: "claude" },
+ ] as MonoSpawnedSession[];
+ const show = (props: Record) =>
+ act(async () =>
+ root.render(
+ createElement(MonoSessionList, {
+ agent,
+ launches,
+ sessions: [],
+ onOpenSession,
+ ...props,
+ }),
+ ),
+ );
+ const cards = () =>
+ [...container.querySelectorAll("[data-session-card]")].map(
+ (card) => card.textContent ?? "",
+ );
+
+ await show({ query: "login" });
+ expect(cards()).toHaveLength(1);
+ expect(cards()[0]).toContain("Fix login");
+ // The project's name is on the card, so it matches too.
+ await show({ query: "other" });
+ expect(cards()[0]).toContain("Fix login");
+ await show({
+ filters: {
+ showArchived: true,
+ hiddenHarnesses: [],
+ time: "all",
+ status: { working: false, needsApproval: false, done: false },
+ },
+ });
+ expect(cards()).toHaveLength(1);
+ expect(cards()[0]).toContain("Saved review");
+ await show({ query: "nothing like it" });
+ expect(cards()).toHaveLength(0);
+ expect(container.textContent).toContain("No sessions match this search.");
+});
+
+it("mounts a page of cards at a time and the next as the list nears its end", async () => {
+ let intersect: ((entries: { isIntersecting: boolean }[]) => void) | undefined;
+ vi.stubGlobal(
+ "IntersectionObserver",
+ class {
+ constructor(callback: typeof intersect) {
+ intersect = callback;
+ }
+ observe() {}
+ disconnect() {}
+ },
+ );
+ const launches = Array.from({ length: 40 }, (_, index) => ({
+ ...launch,
+ sessionId: `app-${index}`,
+ title: `Session ${index}`,
+ }));
+ vi.mocked(listSessionsByProject).mockResolvedValue(
+ launches.map(
+ (entry) =>
+ ({
+ id: entry.sessionId,
+ cwd: entry.cwd,
+ harness: entry.harness,
+ model: entry.model,
+ runtimeMode: "default",
+ title: entry.title,
+ createdAt: 1,
+ updatedAt: 2,
+ }) as SessionSummary,
+ ),
+ );
+ await render([], launches);
+ expect(container.querySelectorAll("[data-mono-session]")).toHaveLength(32);
+ await act(async () => intersect?.([{ isIntersecting: true }]));
+ expect(container.querySelectorAll("[data-mono-session]")).toHaveLength(40);
+});
diff --git a/src/features/monos/ui/MonoSessionList.tsx b/src/features/monos/ui/MonoSessionList.tsx
new file mode 100644
index 0000000000..4817cc1e29
--- /dev/null
+++ b/src/features/monos/ui/MonoSessionList.tsx
@@ -0,0 +1,451 @@
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { LIST_PAGE_SIZE, listWindowSize } from "../../../shared/lib/listWindow";
+import { projectName } from "../../../shared/lib/paths";
+import { IconButton } from "../../../app/shell/TitleBar";
+import {
+ ExternalLink,
+ PanelRightToggle,
+ Search,
+} from "../../../shared/ui/icons";
+import {
+ getSession,
+ listSessionsByProject,
+ type SessionSummary,
+} from "../../sessions/data/sessionStore";
+import {
+ filterSessionsByArchive,
+ filterSessionsByQuery,
+ summaryFromSession,
+} from "../../sessions/data/sessionHistory";
+import {
+ DEFAULT_SESSION_SIDEBAR_FILTERS,
+ filterSessionsByHarness,
+ hasActiveSessionFilters,
+ filterSessionsByStatus,
+ filterSessionsByTime,
+ type SessionSidebarFilters,
+} from "../../sessions/model/sessionFilters";
+import { fuzzyMatch } from "../../../shared/lib/fuzzy";
+import {
+ hasPendingApproval,
+ type MonoSpawnedSession,
+ type Session,
+} from "../../sessions/model/session";
+import { SessionCard } from "../../../app/shell/Sidebar";
+import type { MonoLook } from "../model/mono";
+import { EmptyState, PageHeader } from "./monoPanelParts";
+
+/** A launched session open over the overview, live and fully usable. */
+export type ShownMonoSession = {
+ sessionId: string;
+ title: string;
+ /** The session's pane. */
+ pane: ReactNode;
+};
+
+/**
+ * Saved sessions already looked up, kept across lists. Switching Monos
+ * remounts the overview, and its cards should come back as they were rather
+ * than reload from blank.
+ */
+const storedSessions = new Map();
+
+/** Cards for launched sessions, with where each stands, open or saved. */
+export function MonoSessionList({
+ agent,
+ launches,
+ sessions,
+ history = [],
+ query = "",
+ filters = DEFAULT_SESSION_SIDEBAR_FILTERS,
+ onOpenSession,
+}: {
+ agent: MonoLook;
+ launches: readonly MonoSpawnedSession[];
+ sessions: readonly Session[];
+ history?: readonly SessionSummary[];
+ /** Narrows the cards as the sidebar's session search does. */
+ query?: string;
+ filters?: SessionSidebarFilters;
+ onOpenSession: (sessionId: string) => void | Promise;
+}) {
+ const launchesKey = JSON.stringify(
+ launches.map((entry) => [entry.sessionId, entry.cwd]),
+ );
+ // Refresh saved status when a launched session closes, archives or moves.
+ const liveSessionsKey = JSON.stringify(
+ launches.map((launch) => {
+ const session = sessions.find((entry) => entry.id === launch.sessionId);
+ return session ? [session.id, session.cwd] : null;
+ }),
+ );
+ const [stored, setStored] = useState<{
+ byId: Map;
+ loaded: Set;
+ failed: Set;
+ }>(() => ({
+ byId: new Map(
+ launches.flatMap((launch) => {
+ const known = storedSessions.get(launch.sessionId);
+ return known ? [[launch.sessionId, known] as const] : [];
+ }),
+ ),
+ loaded: new Set(),
+ failed: new Set(),
+ }));
+ const [opening, setOpening] = useState();
+ const [error, setError] = useState();
+ useEffect(() => {
+ let cancelled = false;
+ const refs: [string, string][] = JSON.parse(launchesKey);
+ const projects = [...new Set(refs.map(([, cwd]) => cwd))];
+ void Promise.allSettled(
+ projects.map((cwd) => listSessionsByProject(cwd)),
+ ).then(async (results) => {
+ if (cancelled) return;
+ const byId = new Map();
+ const loaded = new Set();
+ const failed = new Set();
+ results.forEach((result, index) => {
+ if (result.status !== "fulfilled") {
+ failed.add(projects[index]);
+ return;
+ }
+ loaded.add(projects[index]);
+ result.value.forEach((session) => byId.set(session.id, session));
+ });
+ // Project membership can change after launch; the session id stays stable.
+ const missing = refs.filter(
+ ([id, cwd]) => !byId.has(id) && loaded.has(cwd),
+ );
+ const restored = await Promise.allSettled(
+ missing.map(([id]) => getSession(id)),
+ );
+ if (cancelled) return;
+ restored.forEach((result, index) => {
+ if (result.status === "fulfilled" && result.value) {
+ byId.set(result.value.id, summaryFromSession(result.value));
+ } else if (result.status === "rejected") {
+ loaded.delete(missing[index][1]);
+ failed.add(missing[index][1]);
+ }
+ });
+ for (const [id, summary] of byId) storedSessions.set(id, summary);
+ setStored({ byId, loaded, failed });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [launchesKey, liveSessionsKey]);
+
+ const open = async (id: string) => {
+ setOpening(id);
+ setError(undefined);
+ try {
+ await onOpenSession(id);
+ } catch (cause) {
+ setError(
+ cause instanceof Error ? cause.message : "Could not open this session.",
+ );
+ } finally {
+ setOpening(undefined);
+ }
+ };
+
+ const projectLabel = (cwd: string) =>
+ agent.projects.find((project) => project.path === cwd)?.name ??
+ projectName(cwd);
+ // Each launch as the sidebar would list it, so its search and filters
+ // apply as they do there. One not yet looked up stands in from its launch.
+ const rows = launches.map((launch): SessionSummary => {
+ const live = sessions.find((entry) => entry.id === launch.sessionId);
+ const saved =
+ history.find((entry) => entry.id === launch.sessionId) ??
+ stored.byId.get(launch.sessionId);
+ if (live)
+ return {
+ ...saved,
+ ...summaryFromSession(live, saved),
+ updatedAt: saved?.updatedAt ?? Date.now(),
+ };
+ return (
+ saved ?? {
+ id: launch.sessionId,
+ cwd: launch.cwd,
+ harness: launch.harness,
+ model: launch.model,
+ runtimeMode: "auto",
+ title: launch.title,
+ createdAt: 0,
+ updatedAt: 0,
+ }
+ );
+ });
+ // Launched, then deleted: its project's sessions are known, and it is not
+ // among them.
+ const unavailableIds = new Set(
+ launches
+ .filter(
+ (launch) =>
+ stored.loaded.has(launch.cwd) &&
+ !sessions.some((entry) => entry.id === launch.sessionId) &&
+ !history.some((entry) => entry.id === launch.sessionId) &&
+ !stored.byId.has(launch.sessionId),
+ )
+ .map((launch) => launch.sessionId),
+ );
+ const busyIds = new Set();
+ const approvalIds = new Set();
+ const doneIds = new Set();
+ for (const row of rows) {
+ const live = sessions.find((entry) => entry.id === row.id);
+ if (live?.busy) busyIds.add(row.id);
+ else if (live && (hasPendingApproval(live.blocks) || live.pendingQuestion))
+ approvalIds.add(row.id);
+ else if (live || row.createdAt) doneIds.add(row.id);
+ }
+ const filtered = filterSessionsByStatus(
+ filterSessionsByTime(
+ filterSessionsByHarness(
+ // Archived launches stay with the rest; the filter narrows to them.
+ // Deleted ones are hidden unless the filter asks for just them.
+ (filters.showArchived
+ ? filterSessionsByArchive(rows, true)
+ : rows
+ ).filter(
+ (row) => unavailableIds.has(row.id) === !!filters.showUnavailable,
+ ),
+ filters.hiddenHarnesses,
+ ),
+ filters.time,
+ Date.now(),
+ ),
+ filters.status,
+ busyIds,
+ approvalIds,
+ doneIds,
+ );
+ // The project a session runs in is on its card, so it is searched too.
+ const needle = query.trim();
+ const matched = new Set(
+ needle
+ ? [
+ ...filterSessionsByQuery(filtered, needle),
+ ...filtered.filter(
+ (row) => fuzzyMatch(needle, projectLabel(row.cwd)) != null,
+ ),
+ ].map((row) => row.id)
+ : filtered.map((row) => row.id),
+ );
+ const matching = launches.filter((launch) => matched.has(launch.sessionId));
+ // A page of cards mounts at a time, as in the sidebar; the sentinel below
+ // asks for the next one as it nears the panel's bottom. Searching or
+ // filtering starts over from the first page.
+ const [limit, setLimit] = useState(LIST_PAGE_SIZE);
+ const list = useRef(null);
+ const loadMore = useRef(null);
+ const listKey = `${needle}\0${JSON.stringify(filters)}`;
+ useEffect(() => {
+ setLimit(LIST_PAGE_SIZE);
+ const scroller = scrollParent(list.current);
+ if (scroller) scroller.scrollTop = 0;
+ }, [listKey]);
+ const shown = matching.slice(0, listWindowSize(matching.length, limit));
+ const hasMore = shown.length < matching.length;
+ useEffect(() => {
+ const sentinel = loadMore.current;
+ if (!hasMore || !sentinel) return;
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry?.isIntersecting)
+ setLimit((current) => current + LIST_PAGE_SIZE);
+ },
+ { root: scrollParent(sentinel), rootMargin: "240px" },
+ );
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [hasMore, shown.length]);
+
+ if (launches.length > 0 && matching.length === 0)
+ return (
+
+ {needle
+ ? "No sessions match this search."
+ : hasActiveSessionFilters(filters)
+ ? "No sessions match these filters."
+ : // Every one it started is gone; the filter still lists them.
+ `The sessions ${agent.name} started have since been deleted.`}
+
+ );
+
+ return (
+
+
+ {shown.map((launch) => {
+ const live = sessions.find((entry) => entry.id === launch.sessionId);
+ const saved =
+ history.find((entry) => entry.id === launch.sessionId) ??
+ stored.byId.get(launch.sessionId);
+ // Once it is known, it gets the sidebar's own card: model, status,
+ // title, branch or worktree, and any linked PR or issue.
+ const summary: SessionSummary | undefined = live
+ ? {
+ ...saved,
+ ...summaryFromSession(live, saved),
+ updatedAt: saved?.updatedAt ?? Date.now(),
+ }
+ : saved;
+ if (summary)
+ return (
+
+ project.path === summary.cwd,
+ )?.name ?? projectName(summary.cwd)
+ }
+ now={Date.now()}
+ onSelect={(id) => {
+ if (!opening) void open(id);
+ }}
+ />
+
+ );
+ // Not known, either deleted or not looked up: the sidebar's card
+ // still, from what the launch recorded.
+ const unavailable = unavailableIds.has(launch.sessionId);
+ const failed = !unavailable && stored.failed.has(launch.cwd);
+ const loading = !unavailable && !failed;
+ return (
+
+ {
+ if (failed && !opening) void open(id);
+ }}
+ />
+
+ );
+ })}
+
+ {hasMore ?
: null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+/** The nearest ancestor that scrolls, which the list pages against. */
+function scrollParent(node: HTMLElement | null): HTMLElement | null {
+ for (let el = node?.parentElement; el; el = el.parentElement) {
+ const { overflowY } = getComputedStyle(el);
+ if (overflowY === "auto" || overflowY === "scroll") return el;
+ }
+ return null;
+}
+
+/** A launched session open over the overview, live and fully usable. */
+export function LaunchedSessionPage({
+ title,
+ onBack,
+ onOpenInWorkspace,
+ onClose,
+ children,
+}: {
+ title: string;
+ onBack: () => void;
+ onOpenInWorkspace: () => void | Promise;
+ onClose: () => void;
+ children: ReactNode;
+}) {
+ const [error, setError] = useState();
+ return (
+ <>
+
+ {
+ setError(undefined);
+ void Promise.resolve(onOpenInWorkspace()).catch((cause) =>
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "Could not open this session.",
+ ),
+ );
+ }}
+ >
+
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {children}
+
+ >
+ );
+}
diff --git a/src/features/monos/ui/MonoSessionsPanel.test.ts b/src/features/monos/ui/MonoSessionsPanel.test.ts
deleted file mode 100644
index 6a437c240c..0000000000
--- a/src/features/monos/ui/MonoSessionsPanel.test.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-// @vitest-environment happy-dom
-import { act, createElement } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, expect, it, vi } from "vitest";
-import {
- getSession,
- listSessionsByProject,
- type SessionSummary,
-} from "../../sessions/data/sessionStore";
-import {
- newSession,
- type MonoSpawnedSession,
- type Session,
-} from "../../sessions/model/session";
-import { MonoSessionsPanel } from "./MonoSessionsPanel";
-
-vi.mock("../../sessions/data/sessionStore", () => ({
- getSession: vi.fn(),
- listSessionsByProject: vi.fn(),
-}));
-
-let container: HTMLDivElement;
-let root: Root;
-const onOpenSession = vi.fn();
-const onClose = vi.fn();
-const agent = {
- name: "MonoInvader",
- mascot: "invader",
- color: "#6ba",
- projects: [],
-};
-const launch: MonoSpawnedSession = {
- sessionId: "app-review",
- cwd: "/repo",
- title: "Review latest PR",
- harness: "codex",
- model: "gpt-6",
-};
-
-beforeEach(() => {
- vi.resetAllMocks();
- vi.mocked(listSessionsByProject).mockResolvedValue([]);
- vi.mocked(getSession).mockResolvedValue(null);
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- vi.stubGlobal(
- "ResizeObserver",
- class {
- observe() {}
- disconnect() {}
- },
- );
- container = document.createElement("div");
- document.body.append(container);
- root = createRoot(container);
-});
-afterEach(() => {
- act(() => root.unmount());
- container.remove();
- vi.unstubAllGlobals();
-});
-
-async function render(sessions: Session[] = [], launches = [launch]) {
- await act(async () =>
- root.render(
- createElement(MonoSessionsPanel, {
- agent,
- launches,
- sessions,
- onOpenSession,
- onClose,
- }),
- ),
- );
-}
-
-it("shows live cards, updates their status and opens the selected session", async () => {
- const child = {
- ...newSession("codex", "/repo"),
- id: launch.sessionId,
- title: "PR #799 review",
- busy: true,
- };
- const unrelated = { ...child, id: "other", title: "Unrelated" };
- await render([child, unrelated]);
- expect(container.querySelector("aside")?.getAttribute("aria-label")).toBe(
- "MonoInvader sessions",
- );
- expect(container.querySelectorAll("[data-mono-session]")).toHaveLength(1);
- expect(container.textContent).toContain("PR #799 review");
- expect(container.textContent).toContain("Working");
- expect(container.textContent).not.toContain("Unrelated");
- await render([{ ...child, busy: false }]);
- expect(container.textContent).toContain("Ready");
- await act(async () =>
- container.querySelector("[data-mono-session]")!.click(),
- );
- expect(onOpenSession).toHaveBeenCalledWith(launch.sessionId);
- act(() =>
- container
- .querySelector('[aria-label="Hide sessions"]')!
- .click(),
- );
- expect(onClose).toHaveBeenCalledOnce();
-});
-
-it("loads closed sessions across projects and preserves archived cards", async () => {
- vi.mocked(listSessionsByProject).mockImplementation(async (cwd) => [
- {
- id: cwd === "/repo" ? launch.sessionId : "app-second",
- cwd,
- harness: "codex",
- model: "gpt-6",
- runtimeMode: "default",
- title: cwd === "/repo" ? "Saved review" : "Another project",
- archived: cwd === "/repo",
- createdAt: 1,
- updatedAt: 2,
- } as SessionSummary,
- ]);
- await render(
- [],
- [launch, { ...launch, sessionId: "app-second", cwd: "/other" }],
- );
- expect(listSessionsByProject).toHaveBeenCalledWith("/repo");
- expect(listSessionsByProject).toHaveBeenCalledWith("/other");
- expect(container.textContent).toContain("Saved review");
- expect(container.textContent).toContain("Archived");
- expect(container.textContent).toContain("Another project");
- expect(
- container.querySelector("[data-mono-session]")!.disabled,
- ).toBe(false);
-});
-
-it("keeps the link usable after a session moves to another project", async () => {
- vi.mocked(getSession).mockResolvedValue({
- ...newSession("codex", "/moved"),
- id: launch.sessionId,
- title: "Moved review",
- });
- await render();
- expect(getSession).toHaveBeenCalledWith(launch.sessionId);
- expect(container.textContent).toContain("Moved review");
- expect(container.textContent).toContain("moved");
- expect(
- container.querySelector("[data-mono-session]")!.disabled,
- ).toBe(false);
-});
-
-it("keeps deleted launches visible as unavailable and does not offer to open them", async () => {
- await render();
- expect(container.textContent).toContain(launch.title);
- expect(container.textContent).toContain("No longer available");
- const button = container.querySelector(
- "[data-mono-session]",
- )!;
- expect(button.disabled).toBe(true);
- act(() => button.click());
- expect(onOpenSession).not.toHaveBeenCalled();
-});
-
-it("allows opening when status lookup fails and reports navigation errors", async () => {
- vi.mocked(listSessionsByProject).mockRejectedValue(new Error("Offline"));
- onOpenSession.mockRejectedValue(new Error("Could not restore session"));
- await render();
- expect(container.textContent).toContain("Status unavailable");
- await act(async () =>
- container.querySelector("[data-mono-session]")!.click(),
- );
- expect(container.querySelector('[role="alert"]')?.textContent).toBe(
- "Could not restore session",
- );
-});
-
-it("shows a draft and then an approval without treating either as completed", async () => {
- const child = {
- ...newSession("codex", "/repo"),
- id: launch.sessionId,
- blocks: [
- { id: "draft", role: "user" as const, text: "Review", draft: true },
- ],
- };
- await render([child]);
- expect(container.textContent).toContain("Draft");
- await render([
- {
- ...child,
- blocks: [
- {
- id: "approval",
- role: "tool",
- text: "Run command",
- approval: { requestId: 42 },
- },
- ],
- },
- ]);
- expect(container.textContent).toContain("Needs input");
-});
diff --git a/src/features/monos/ui/MonoSessionsPanel.tsx b/src/features/monos/ui/MonoSessionsPanel.tsx
deleted file mode 100644
index 91d7573f13..0000000000
--- a/src/features/monos/ui/MonoSessionsPanel.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-import { useEffect, useState, type ReactNode } from "react";
-import { projectName } from "../../../shared/lib/paths";
-import { ChevronRight } from "../../../shared/ui/icons";
-import {
- getSession,
- listSessionsByProject,
- type SessionSummary,
-} from "../../sessions/data/sessionStore";
-import { summaryFromSession } from "../../sessions/data/sessionHistory";
-import { resolveModel } from "../../sessions/model/models";
-import {
- hasPendingApproval,
- sessionDisplayTitle,
- sessionDraftBlock,
- type MonoSpawnedSession,
- type Session,
-} from "../../sessions/model/session";
-import { HarnessIcon } from "../../sessions/ui/HarnessIcon";
-import type { MonoLook } from "../model/mono";
-import { MonoSidebar, MonoSidebarHeader } from "./MonoSidebar";
-
-/** Sessions launched by the selected turn, including those no longer open. */
-export function MonoSessionsPanel({
- agent,
- launches,
- sessions,
- history = [],
- onOpenSession,
- onClose,
- windowControls,
-}: {
- agent: MonoLook;
- launches: readonly MonoSpawnedSession[];
- sessions: readonly Session[];
- history?: readonly SessionSummary[];
- onOpenSession: (sessionId: string) => void | Promise;
- onClose: () => void;
- windowControls?: ReactNode;
-}) {
- const launchesKey = JSON.stringify(
- launches.map((entry) => [entry.sessionId, entry.cwd]),
- );
- // Refresh saved status when a launched session closes, archives or moves.
- const liveSessionsKey = JSON.stringify(
- launches.map((launch) => {
- const session = sessions.find((entry) => entry.id === launch.sessionId);
- return session ? [session.id, session.cwd] : null;
- }),
- );
- const [stored, setStored] = useState<{
- byId: Map;
- loaded: Set;
- failed: Set;
- }>({ byId: new Map(), loaded: new Set(), failed: new Set() });
- const [opening, setOpening] = useState();
- const [error, setError] = useState();
- useEffect(() => {
- let cancelled = false;
- const refs: [string, string][] = JSON.parse(launchesKey);
- const projects = [...new Set(refs.map(([, cwd]) => cwd))];
- void Promise.allSettled(
- projects.map((cwd) => listSessionsByProject(cwd)),
- ).then(async (results) => {
- if (cancelled) return;
- const byId = new Map();
- const loaded = new Set();
- const failed = new Set();
- results.forEach((result, index) => {
- if (result.status !== "fulfilled") {
- failed.add(projects[index]);
- return;
- }
- loaded.add(projects[index]);
- result.value.forEach((session) => byId.set(session.id, session));
- });
- // Project membership can change after launch; the session id stays stable.
- const missing = refs.filter(
- ([id, cwd]) => !byId.has(id) && loaded.has(cwd),
- );
- const restored = await Promise.allSettled(
- missing.map(([id]) => getSession(id)),
- );
- if (cancelled) return;
- restored.forEach((result, index) => {
- if (result.status === "fulfilled" && result.value) {
- byId.set(result.value.id, summaryFromSession(result.value));
- } else if (result.status === "rejected") {
- loaded.delete(missing[index][1]);
- failed.add(missing[index][1]);
- }
- });
- setStored({ byId, loaded, failed });
- });
- return () => {
- cancelled = true;
- };
- }, [launchesKey, liveSessionsKey]);
-
- const open = async (id: string) => {
- setOpening(id);
- setError(undefined);
- try {
- await onOpenSession(id);
- } catch (cause) {
- setError(
- cause instanceof Error ? cause.message : "Could not open this session.",
- );
- } finally {
- setOpening(undefined);
- }
- };
-
- return (
-
-
-
-
- {launches.length} {launches.length === 1 ? "session" : "sessions"}{" "}
- launched in this turn
-
-
- {launches.map((launch) => {
- const live = sessions.find(
- (entry) => entry.id === launch.sessionId,
- );
- const saved =
- history.find((entry) => entry.id === launch.sessionId) ??
- stored.byId.get(launch.sessionId);
- const unavailable =
- !live && !saved && stored.loaded.has(launch.cwd);
- const session = live ?? saved;
- const harness = session?.harness ?? launch.harness;
- const model = resolveModel(
- harness,
- session?.model ?? launch.model,
- ).name;
- const title = sessionDisplayTitle(
- session?.title ?? launch.title,
- harness,
- );
- const status = unavailable
- ? "No longer available"
- : live &&
- (hasPendingApproval(live.blocks) || live.pendingQuestion)
- ? "Needs input"
- : live?.busy
- ? "Working"
- : saved?.archived
- ? "Archived"
- : (live ? !!sessionDraftBlock(live) : saved?.draft)
- ? "Draft"
- : session
- ? "Ready"
- : stored.failed.has(launch.cwd)
- ? "Status unavailable"
- : "Loading…";
- return (
-
void open(launch.sessionId)}
- className="group flex w-full items-center gap-2 rounded-lg border border-stroke bg-content/3 px-3 py-2.5 text-left outline-none hover:border-content/20 hover:bg-content/6 focus-visible:ring-1 focus-visible:ring-accent disabled:cursor-default disabled:opacity-50"
- >
-
-
- {title}
-
-
-
- {model}
- ·
-
- {agent.projects.find(
- (project) =>
- project.path === (session?.cwd ?? launch.cwd),
- )?.name ?? projectName(session?.cwd ?? launch.cwd)}
-
-
-
-
- {opening === launch.sessionId ? "Opening…" : status}
-
-
-
-
- );
- })}
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
- );
-}
diff --git a/src/features/monos/ui/MonoSettingsDialog.test.ts b/src/features/monos/ui/MonoSettingsDialog.test.ts
new file mode 100644
index 0000000000..9055120490
--- /dev/null
+++ b/src/features/monos/ui/MonoSettingsDialog.test.ts
@@ -0,0 +1,202 @@
+// @vitest-environment happy-dom
+import { act, createElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import {
+ RUNTIME_MODE_LABEL,
+ RUNTIME_MODES,
+ type RuntimeMode,
+} from "../../sessions/model/session";
+import { MonoSettingsDialog } from "./MonoSettingsDialog";
+
+vi.mock("../../sessions/ui/ModelPicker", () => ({
+ ModelPicker: () => null,
+ ModelSettingRows: () => null,
+}));
+
+vi.mock("../model/monoFiles", async (original) => ({
+ ...(await original()),
+ loadMonoFiles: async () => ({
+ id: "mono-1",
+ dir: "/data/mono-1",
+ soul: "# Soul",
+ soulHash: "soul",
+ memory: "",
+ memoryHash: "memory",
+ memoryPath: "/data/mono-1/MEMORY.md",
+ topics: [],
+ }),
+}));
+
+let container: HTMLDivElement;
+let root: Root;
+const onRuntimeModeChange = vi.fn();
+const onClose = vi.fn();
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ const stored = new Map();
+ vi.stubGlobal("localStorage", {
+ getItem: (key: string) => stored.get(key) ?? null,
+ setItem: (key: string, value: string) => stored.set(key, value),
+ removeItem: (key: string) => stored.delete(key),
+ });
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+async function render(
+ runtimeMode: RuntimeMode = "auto",
+ busy = false,
+ onReset?: () => Promise,
+) {
+ await act(async () =>
+ root.render(
+ createElement(MonoSettingsDialog, {
+ monoId: "mono-1",
+ agent: { name: "Broski", mascot: "cat", color: "#9c9", projects: [] },
+ status: busy ? "working" : "idle",
+ cwd: "/home",
+ harness: "codex",
+ model: "codex:gpt-5.4",
+ modelSettings: {},
+ runtimeMode,
+ busy,
+ onModelChange: vi.fn(),
+ onModelSettingsChange: vi.fn(),
+ onRuntimeModeChange,
+ onReset,
+ onClose,
+ }),
+ ),
+ );
+}
+
+function trigger() {
+ return document.querySelector(
+ "[data-access-picker-trigger]",
+ )!;
+}
+
+function dialog() {
+ return document.querySelector("[data-mono-settings]")!;
+}
+
+function button(label: string, root: ParentNode = document) {
+ return [...root.querySelectorAll("button")].find(
+ (entry) => entry.textContent === label,
+ )!;
+}
+
+function escape() {
+ act(() =>
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ ),
+ );
+}
+
+it("opens on General and moves between its sections", async () => {
+ await render();
+ const nav = dialog().querySelector('nav[aria-label="Settings sections"]')!;
+ expect([...nav.querySelectorAll("button")].map((b) => b.textContent)).toEqual(
+ ["General", "Soul", "Memory"],
+ );
+ expect(button("General", nav).getAttribute("aria-current")).toBe("page");
+ expect(dialog().querySelector("input[aria-label=\"Name\"]")).not.toBeNull();
+ expect(dialog().textContent).toContain("Permissions");
+ await act(async () => button("Memory", nav).click());
+ expect(button("Memory", nav).getAttribute("aria-current")).toBe("page");
+ expect(dialog().querySelector("[data-mono-memory]")).not.toBeNull();
+ await act(async () => button("Soul", nav).click());
+ expect(dialog().querySelector("[data-mono-soul]")).not.toBeNull();
+});
+
+it("keeps the mascots and colors in a popover opened from its icon", async () => {
+ await render();
+ expect(document.querySelector('[aria-label="Mascot"]')).toBeNull();
+ const face = dialog().querySelector(
+ '[aria-label="Change mascot and color"]',
+ )!;
+ act(() => face.click());
+ const popover = document.querySelector('[aria-label="Mascot and color"]')!;
+ expect(popover.querySelector('[aria-label="Mascot"]')).not.toBeNull();
+ expect(popover.querySelector('[aria-label="Color"]')).not.toBeNull();
+ // Escape closes the popover first, then the dialog.
+ escape();
+ expect(document.querySelector('[aria-label="Mascot and color"]')).toBeNull();
+ expect(onClose).not.toHaveBeenCalled();
+ escape();
+ expect(onClose).toHaveBeenCalledOnce();
+});
+
+function confirmButton(label: string) {
+ return [
+ ...document.querySelectorAll(
+ '[role="dialog"]:not(:has([data-mono-settings])) button',
+ ),
+ ].find((entry) => entry.textContent === label)!;
+}
+
+it("requires confirmation for reset and cancels without deleting anything", async () => {
+ const reset = vi.fn(async () => {});
+ await render("auto", false, reset);
+ act(() => button("Reset", dialog()).click());
+ const confirm = confirmButton("Cancel").closest('[role="dialog"]')!;
+ expect(confirm.textContent).toContain("Reset Broski's conversation?");
+ expect(confirm.textContent).toContain("soul, memory and habits will be kept");
+ act(() => confirmButton("Cancel").click());
+ expect(confirmButton("Cancel")).toBeUndefined();
+ expect(reset).not.toHaveBeenCalled();
+});
+
+it("resets once after confirmation", async () => {
+ const reset = vi.fn(async () => {});
+ await render("auto", false, reset);
+ act(() => button("Reset", dialog()).click());
+ await act(async () => confirmButton("Reset conversation").click());
+ expect(reset).toHaveBeenCalledOnce();
+ expect(confirmButton("Cancel")).toBeUndefined();
+});
+
+it("lets the user choose every permission mode and reflects the saved choice", async () => {
+ await render("auto");
+ expect(trigger().textContent).toBe("Auto");
+
+ for (const mode of RUNTIME_MODES) {
+ act(() => trigger().click());
+ const options = document.querySelectorAll(
+ '[data-access-picker] [role="option"]',
+ );
+ expect(options).toHaveLength(RUNTIME_MODES.length);
+ act(() => options[RUNTIME_MODES.indexOf(mode)].click());
+ expect(onRuntimeModeChange).toHaveBeenLastCalledWith(mode);
+ expect(document.querySelector("[data-access-picker]")).toBeNull();
+ await render(mode);
+ expect(trigger().textContent).toBe(RUNTIME_MODE_LABEL[mode]);
+ }
+});
+
+it("explains when permissions take effect while the Mono is working", async () => {
+ await render("auto", true);
+ expect(trigger().title).toContain("Changes apply to the next turn.");
+ act(() => trigger().click());
+ expect(document.querySelector("[data-access-picker]")?.textContent).toContain(
+ "Access changes apply to the next turn. Stop and resend to apply them now.",
+ );
+});
diff --git a/src/features/monos/ui/MonoSettingsDialog.tsx b/src/features/monos/ui/MonoSettingsDialog.tsx
new file mode 100644
index 0000000000..667bde1982
--- /dev/null
+++ b/src/features/monos/ui/MonoSettingsDialog.tsx
@@ -0,0 +1,438 @@
+import {
+ useEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+ type CSSProperties,
+ type ReactNode,
+} from "react";
+import { isImeComposition } from "../../../shared/lib/keyboard";
+import { useLockOverscroll } from "../../../shared/hooks/useLockOverscroll";
+import { Modal } from "../../../shared/ui/Modal";
+import { Popover } from "../../../shared/ui/Popover";
+import { Settings, Sparkles, StickyNote } from "../../../shared/ui/icons";
+import type { HarnessId, RuntimeMode } from "../../sessions/model/session";
+import { AccessPicker } from "../../sessions/ui/AccessPicker";
+import { ModelPicker, ModelSettingRows } from "../../sessions/ui/ModelPicker";
+import { PixelMascot } from "../../projects/ui/PixelMascot";
+import {
+ defaultMonoName,
+ findMono,
+ saveMonoMascot,
+ saveMonoName,
+ subscribeMonos,
+ updateMono,
+ type MonoLook,
+ type MonoState,
+} from "../model/mono";
+import { ConfirmReset } from "./ConfirmReset";
+import { MemoryPage, SoulPage, useMonoFiles } from "./MonoFilePages";
+import { MonoProjects } from "./MonoProjects";
+import { ColorPicker, MascotPicker, SwitchRow } from "./monoPanelParts";
+
+export type MonoSettingsSection = "general" | "soul" | "memory";
+
+const SECTIONS: {
+ id: MonoSettingsSection;
+ label: string;
+ icon: typeof Settings;
+}[] = [
+ { id: "general", label: "General", icon: Settings },
+ { id: "soul", label: "Soul", icon: Sparkles },
+ { id: "memory", label: "Memory", icon: StickyNote },
+];
+
+type Props = {
+ monoId: string;
+ agent: MonoLook;
+ /** Its soul and memory reload when a turn ends. */
+ status: MonoState["status"];
+ /** Its conversation's folder, which the model picker reads settings from. */
+ cwd: string;
+ harness: HarnessId;
+ model: string;
+ modelSettings: Record;
+ runtimeMode: RuntimeMode;
+ busy?: boolean;
+ initialSection?: MonoSettingsSection;
+ onModelChange: (harness: HarnessId, model: string) => void;
+ onModelSettingsChange: (settings: Record) => void;
+ onRuntimeModeChange: (mode: RuntimeMode) => void;
+ onReset?: () => Promise;
+ onClose: () => void;
+};
+
+/**
+ * Who the Mono is and how it works, in a dialog of its own: its name, face,
+ * model and projects under General, then its soul and memory.
+ */
+export function MonoSettingsDialog({
+ monoId,
+ agent,
+ status,
+ initialSection = "general",
+ onClose,
+ ...general
+}: Props) {
+ const [section, setSection] = useState(initialSection);
+ const files = useMonoFiles(monoId, status);
+ return (
+
+
+
+
+ Settings
+
+ {SECTIONS.map(({ id, label, icon: Icon }) => (
+ setSection(id)}
+ className={`flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-[13px] ${
+ section === id
+ ? "bg-content/8 text-content"
+ : "text-content/65 hover:bg-content/5 hover:text-content"
+ }`}
+ >
+
+ {label}
+
+ ))}
+
+
+ {section === "general" ? (
+
+ ) : section === "soul" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function GeneralSection({
+ monoId,
+ agent,
+ cwd,
+ harness,
+ model,
+ modelSettings,
+ runtimeMode,
+ busy = false,
+ onModelChange,
+ onModelSettingsChange,
+ onRuntimeModeChange,
+ onReset,
+}: Omit) {
+ const lock = useLockOverscroll();
+ const shown = useSyncExternalStore(
+ subscribeMonos,
+ () => findMono(monoId)?.showStartedSessionsInSidebar === true,
+ );
+ const folders = useSyncExternalStore(
+ subscribeMonos,
+ () => findMono(monoId)?.useSidebarFolders === true,
+ );
+ return (
+
+
General
+
+
+
+
+
+
+
+
+
+
+
+ (
+ {control}
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ updateMono(monoId, (mono) => ({
+ ...mono,
+ showStartedSessionsInSidebar: on,
+ }))
+ }
+ />
+ {/* Hidden sessions have no sidebar folder to go in. */}
+
+ updateMono(monoId, ({ useSidebarFolders: _, ...mono }) =>
+ on ? { ...mono, useSidebarFolders: true } : mono,
+ )
+ }
+ />
+
+
+ {onReset ? (
+ <>
+
+
+ {(open, ref) => (
+
+
+
+ Reset
+
+
+
+ )}
+
+ >
+ ) : null}
+
+ );
+}
+
+/** A titled group of settings, with what it covers under the title. */
+function GroupHeading({
+ title,
+ description,
+}: {
+ title: string;
+ description?: string;
+}) {
+ return (
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ );
+}
+
+/** Settings that belong together, in one card with a line between rows. */
+function SettingGroup({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * A setting's name and what it does on the left, its control on the right.
+ * Controls get a column of their own, wide enough that a value is never cut.
+ */
+function SettingRow({
+ label,
+ description,
+ children,
+}: {
+ label: string;
+ description?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {label}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+ {children}
+
+
+ );
+}
+
+/** The Mono's face, which opens its mascots and colors in a popover. */
+function LookButton({ monoId, agent }: { monoId: string; agent: MonoLook }) {
+ const [open, setOpen] = useState(false);
+ const anchor = useRef(null);
+ return (
+ <>
+ setOpen((value) => !value)}
+ className={`grid size-20 shrink-0 place-items-center rounded-2xl border border-stroke hover:bg-content/8 ${
+ open ? "bg-content/8" : "bg-content/4"
+ }`}
+ >
+
+
+ {open ? (
+ setOpen(false)}
+ role="dialog"
+ aria-label="Mascot and color"
+ className="flex flex-col gap-3 p-3"
+ >
+ saveMonoMascot(monoId, mascot)}
+ />
+
+
+ updateMono(monoId, (mono) => ({ ...mono, color }))
+ }
+ />
+
+ ) : null}
+ >
+ );
+}
+
+/** The Mono's name, edited in place; empty goes back to its mascot's name. */
+function NameField({ monoId, fallback }: { monoId: string; fallback: string }) {
+ const saved = () => findMono(monoId)?.name ?? "";
+ const [draft, setDraft] = useState(saved);
+ const editing = useRef(false);
+ // A reset clears the name while this dialog is open.
+ useEffect(
+ () =>
+ subscribeMonos(() => {
+ if (!editing.current) setDraft(saved());
+ }),
+ [monoId],
+ );
+ const placeholder = defaultMonoName(fallback);
+ const save = () => {
+ const name = draft.trim().slice(0, 40);
+ if (name !== saved()) saveMonoName(monoId, name);
+ setDraft(name);
+ };
+ return (
+ setDraft(event.target.value)}
+ onFocus={() => (editing.current = true)}
+ onBlur={() => {
+ editing.current = false;
+ save();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && !isImeComposition(event.nativeEvent)) {
+ event.currentTarget.blur();
+ }
+ if (event.key === "Escape") {
+ setDraft(saved());
+ event.currentTarget.blur();
+ }
+ }}
+ className="mt-3 h-8 w-full max-w-64 rounded-md bg-transparent px-2 text-center text-[15px] font-medium text-content outline-none placeholder:text-content/60 hover:bg-content/5 focus:bg-content/5"
+ />
+ );
+}
diff --git a/src/features/monos/ui/MonoSettingsPage.test.ts b/src/features/monos/ui/MonoSettingsPage.test.ts
deleted file mode 100644
index 0672c29dcf..0000000000
--- a/src/features/monos/ui/MonoSettingsPage.test.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-// @vitest-environment happy-dom
-import { act, createElement } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, expect, it, vi } from "vitest";
-
-const saveMonoFile = vi.fn(async (..._args: unknown[]) => "next-hash");
-vi.mock("../model/monoFiles", async (original) => ({
- ...(await original()),
- saveMonoFile: (...args: unknown[]) => saveMonoFile(...args),
- editMonoFile: async (monoId: string, file: string, edit: (text: string) => string) =>
- saveMonoFile(monoId, file, edit(files.memory), files.memoryHash),
-}));
-
-const { MonoSettingsPage } = await import("./MonoSettingsPage");
-const { MemoryPage } = await import("./MonoFilePages");
-const { MonoPreferencesPage } = await import("./MonoPreferencesPage");
-
-let root: Root;
-let container: HTMLElement;
-beforeEach(() => {
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- const stored = new Map();
- vi.stubGlobal("localStorage", {
- getItem: (key: string) => stored.get(key) ?? null,
- setItem: (key: string, value: string) => stored.set(key, value),
- });
- container = document.createElement("div");
- document.body.append(container);
- root = createRoot(container);
-});
-afterEach(() => {
- act(() => root.unmount());
- container.remove();
- vi.unstubAllGlobals();
-});
-
-const agent = { name: "Broski", mascot: "cat", color: "#9c9", project: "monocode" };
-const files = {
- id: "a",
- dir: "/d",
- soul: "# Soul\n\n## Role\nThe resident agent for this project.",
- soulHash: "s",
- memory: "i am a cool pirate\n- 2026-10-04 · The user's name is Nick\n",
- memoryHash: "m",
- memoryPath: "/d/MEMORY.md",
- topics: [],
-};
-
-it("lists what lives behind each page, and opens it", () => {
- const onOpen = vi.fn();
- act(() =>
- root.render(
- createElement(MonoSettingsPage, {
- monoId: "mono-1",
- agent,
- onOpen,
- onBack: () => {},
- }),
- ),
- );
- const rows = [...container.querySelectorAll("nav button")].map((row) => row.textContent);
- expect(rows).toEqual([
- "SoulDefines who this bot is and the rules it follows. Always included in its context.",
- "HabitsRecurring tasks this bot runs on its own.",
- "MemoryFacts and preferences this bot remembers.",
- ]);
- act(() => (container.querySelectorAll("nav button")[2] as HTMLElement).click());
- expect(onOpen).toHaveBeenCalledWith("memory");
-});
-
-it("shows how many habits and facts it has once they load", () => {
- act(() =>
- root.render(
- createElement(MonoSettingsPage, {
- monoId: "mono-1",
- agent,
- onOpen: vi.fn(),
- counts: { habits: 3, memory: 12 },
- }),
- ),
- );
- const rows = [...container.querySelectorAll("nav button")].map((row) => row.textContent);
- expect(rows[0]).toMatch(/context\.$/);
- expect(rows[1]).toBe("HabitsRecurring tasks this bot runs on its own.3");
- expect(rows[2]).toBe("MemoryFacts and preferences this bot remembers.12");
-});
-
-function renderReset(onReset: () => Promise) {
- act(() => root.render(createElement(MonoPreferencesPage, {
- monoId: "mono-1",
- agent,
- onBack: vi.fn(),
- onReset,
- })));
- return [...container.querySelectorAll("button")].find(
- (button) => button.textContent?.startsWith("Reset conversation"),
- )!;
-}
-
-function dialogButton(label: string) {
- return [...document.querySelectorAll('[role="dialog"] button')].find(
- (button) => button.textContent === label,
- )!;
-}
-
-it("requires confirmation for reset and cancels without deleting anything", () => {
- const reset = vi.fn(async () => {});
- const open = renderReset(reset);
- act(() => open.click());
- const dialog = document.querySelector('[role="dialog"]')!;
- expect(dialog.textContent).toContain("Reset Broski's conversation?");
- expect(dialog.textContent).toContain("All messages");
- expect(dialog.textContent).toContain("soul, memory and habits will be kept");
- expect(reset).not.toHaveBeenCalled();
- act(() => dialogButton("Cancel").click());
- expect(document.querySelector('[role="dialog"]')).toBeNull();
- expect(document.activeElement).toBe(open);
- act(() => open.click());
- act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
- expect(document.querySelector('[role="dialog"]')).toBeNull();
- expect(reset).not.toHaveBeenCalled();
-});
-
-it("resets once after confirmation and keeps the dialog while deletion is pending", async () => {
- let finish!: () => void;
- const reset = vi.fn(() => new Promise((resolve) => { finish = resolve; }));
- const open = renderReset(reset);
- act(() => open.click());
- act(() => dialogButton("Reset conversation").click());
- expect(reset).toHaveBeenCalledOnce();
- expect(dialogButton("Resetting…").disabled).toBe(true);
- expect(dialogButton("Cancel").disabled).toBe(true);
- act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
- expect(document.querySelector('[role="dialog"]')).not.toBeNull();
- await act(async () => finish());
- expect(document.querySelector('[role="dialog"]')).toBeNull();
- expect(reset).toHaveBeenCalledOnce();
-});
-
-it("shows a failed reset and lets the user retry", async () => {
- const reset = vi.fn()
- .mockRejectedValueOnce(new Error("Storage unavailable"))
- .mockResolvedValueOnce(undefined);
- const open = renderReset(reset);
- act(() => open.click());
- await act(async () => dialogButton("Reset conversation").click());
- expect(document.querySelector('[role="alert"]')?.textContent).toContain("Storage unavailable");
- expect(dialogButton("Reset conversation").disabled).toBe(false);
- await act(async () => dialogButton("Reset conversation").click());
- expect(document.querySelector('[role="dialog"]')).toBeNull();
- expect(reset).toHaveBeenCalledTimes(2);
-});
-
-it("shows memory as facts and forgets one without touching the rest", async () => {
- act(() =>
- root.render(
- createElement(MemoryPage, { monoId: "mono-1", files, onBack: () => {} }),
- ),
- );
- const facts = [...container.querySelectorAll("[data-memory-line]")].map((row) => row.textContent);
- expect(facts[0]).toBe("i am a cool pirate");
- expect(facts[1]).toContain("The user's name is Nick");
- await act(async () => (container.querySelector('[aria-label="Forget: i am a cool pirate"]') as HTMLElement).click());
- expect(saveMonoFile).toHaveBeenCalledWith(
- "mono-1",
- "memory",
- "- 2026-10-04 · The user's name is Nick\n",
- "m",
- );
-});
-
-it("adds a fact typed in the Memory page as a dated entry", async () => {
- saveMonoFile.mockClear();
- act(() =>
- root.render(
- createElement(MemoryPage, { monoId: "mono-1", files, onBack: () => {} }),
- ),
- );
- const add = container.querySelector('[aria-label="Add memory"]') as HTMLElement;
- act(() => add.click());
- const input = container.querySelector('[aria-label="New memory"]') as HTMLTextAreaElement;
- act(() => {
- const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
- set.call(input, "Deploys go through Fly");
- input.dispatchEvent(new Event("input", { bubbles: true }));
- });
- await act(async () => {
- input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
- });
- const [, , text, hash] = saveMonoFile.mock.calls[0] as unknown as string[];
- expect(text).toMatch(/- \d{4}-\d{2}-\d{2} · Deploys go through Fly\n$/);
- expect(text.startsWith(files.memory)).toBe(true);
- expect(hash).toBe("m");
-});
diff --git a/src/features/monos/ui/MonoSettingsPage.tsx b/src/features/monos/ui/MonoSettingsPage.tsx
deleted file mode 100644
index caaea37712..0000000000
--- a/src/features/monos/ui/MonoSettingsPage.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import { useEffect, useRef, useState, type ReactNode } from "react";
-import { isImeComposition } from "../../../shared/lib/keyboard";
-import { ChevronRight } from "../../../shared/ui/icons";
-import { PixelMascot } from "../../projects/ui/PixelMascot";
-import { useLockOverscroll } from "../../../shared/hooks/useLockOverscroll";
-import {
- defaultMonoName,
- findMono,
- saveMonoMascot,
- saveMonoName,
- subscribeMonos,
- updateMono,
- type MonoLook,
-} from "../model/mono";
-import { ColorPicker, MascotPicker, PageHeader } from "./monoPanelParts";
-
-export type SettingsPage = "habits" | "soul" | "memory" | "settings";
-
-/**
- * Who the Mono is: its face and name up top, then what it does, who it is,
- * what it remembers and its settings, each a page of its own.
- */
-export function MonoSettingsPage({
- monoId,
- agent,
- onOpen,
- onBack,
- counts,
- children,
-}: {
- monoId: string;
- agent: MonoLook;
- /** How many habits and facts it has, beside their rows once loaded. */
- counts?: { habits?: number; memory?: number };
- onOpen: (page: SettingsPage) => void;
- onBack?: () => void;
- /** Model and project controls, alongside the profile on the front panel. */
- children?: ReactNode;
-}) {
- const lock = useLockOverscroll();
- return (
-
- {onBack ?
: null}
-
-
-
-
- saveMonoMascot(monoId, mascot)}
- />
-
- updateMono(monoId, (mono) => ({ ...mono, color }))
- }
- />
-
-
- {children}
-
-
- onOpen("soul")}
- />
- onOpen("habits")}
- />
- onOpen("memory")}
- />
-
-
- onOpen("settings")}
- />
-
-
-
- );
-}
-
-/** A row that opens a page: its name, a description, and a chevron. */
-function NavRow({
- label,
- description,
- count,
- onClick,
-}: {
- label: string;
- /** An explanation of the page, clamped to one line under the label. */
- description: string;
- /** How many entries the page holds, left of the chevron. */
- count?: number;
- onClick: () => void;
-}) {
- return (
-
-
- {label}
-
- {description}
-
-
- {count !== undefined ? (
-
- {count}
-
- ) : null}
-
-
- );
-}
-
-/** The Mono's name, edited in place; empty goes back to its mascot's name. */
-function NameField({ monoId, fallback }: { monoId: string; fallback: string }) {
- const saved = () => findMono(monoId)?.name ?? "";
- const [draft, setDraft] = useState(saved);
- const editing = useRef(false);
- // A reset from Settings clears the name while this panel is open.
- useEffect(
- () =>
- subscribeMonos(() => {
- if (!editing.current) setDraft(saved());
- }),
- [monoId],
- );
- const placeholder = defaultMonoName(fallback);
- const save = () => {
- const name = draft.trim().slice(0, 40);
- if (name !== saved()) saveMonoName(monoId, name);
- setDraft(name);
- };
- return (
- setDraft(event.target.value)}
- onFocus={() => (editing.current = true)}
- onBlur={() => {
- editing.current = false;
- save();
- }}
- onKeyDown={(event) => {
- if (event.key === "Enter" && !isImeComposition(event.nativeEvent)) {
- event.currentTarget.blur();
- }
- if (event.key === "Escape") {
- setDraft(saved());
- event.currentTarget.blur();
- }
- }}
- className="mt-3 h-8 w-full rounded-md bg-transparent px-2 text-center text-[15px] font-medium text-content outline-none placeholder:text-content/60 hover:bg-content/5 focus:bg-content/5"
- />
- );
-}
diff --git a/src/features/monos/ui/MonoSidebar.tsx b/src/features/monos/ui/MonoSidebar.tsx
index 17ebaa3858..40bfd346b7 100644
--- a/src/features/monos/ui/MonoSidebar.tsx
+++ b/src/features/monos/ui/MonoSidebar.tsx
@@ -4,8 +4,8 @@ import { useDragResize } from "../../../shared/hooks/useDragResize";
import { PanelRightToggle } from "../../../shared/ui/icons";
const MIN_WIDTH = 340;
-let rememberedWidth = MIN_WIDTH;
-let rememberedArtifactWidth = 560;
+const DEFAULT_WIDTH = 400;
+let rememberedWidth = DEFAULT_WIDTH;
/** The shared frame for a Mono's details and turn sidebars. */
export function MonoSidebar({
@@ -17,26 +17,22 @@ export function MonoSidebar({
children,
}: {
open: boolean;
- kind: "details" | "activity" | "sessions" | "artifact" | "changes";
+ kind: "details" | "sessions";
label: string;
color: string;
windowControls?: ReactNode;
children: ReactNode;
}) {
- // Readers (documents, diffs) need room; the turn sidebars stay narrow.
- const reader = kind === "artifact" || kind === "changes";
const resize = useDragResize({
- min: reader ? 360 : MIN_WIDTH,
- max: () =>
- reader
- ? Math.min(840, Math.round(window.innerWidth * 0.58))
- : Math.min(440, Math.round(window.innerWidth * 0.4)),
- defaultWidth: reader ? 560 : MIN_WIDTH,
- initial: reader ? rememberedArtifactWidth : rememberedWidth,
+ min: MIN_WIDTH,
+ // The overview hosts whole sessions and documents, so it may grow wide;
+ // either way the chat keeps a good share.
+ max: () => Math.min(960, Math.round(window.innerWidth * 0.62)),
+ defaultWidth: DEFAULT_WIDTH,
+ initial: rememberedWidth,
direction: "left",
onCommit: (width) => {
- if (reader) rememberedArtifactWidth = width;
- else rememberedWidth = width;
+ rememberedWidth = width;
},
});
return (
@@ -46,10 +42,7 @@ export function MonoSidebar({
aria-hidden={!open || undefined}
inert={!open || undefined}
data-mono-details={kind === "details" ? "" : undefined}
- data-mono-activity={kind === "activity" ? "" : undefined}
data-mono-sessions={kind === "sessions" ? "" : undefined}
- data-mono-artifact={kind === "artifact" ? "" : undefined}
- data-mono-changes={kind === "changes" ? "" : undefined}
data-open={open}
style={
{
diff --git a/src/features/monos/ui/PanelStack.tsx b/src/features/monos/ui/PanelStack.tsx
index cf3f820b92..8ab2450946 100644
--- a/src/features/monos/ui/PanelStack.tsx
+++ b/src/features/monos/ui/PanelStack.tsx
@@ -74,7 +74,15 @@ export function PanelStack({
const content = new Map(pages.map((page) => [page.key, page.node]));
const open = layers.filter((layer) => !layer.leaving);
return (
-
+
{
+ event.currentTarget.scrollLeft = 0;
+ event.currentTarget.scrollTop = 0;
+ }}
+ >
0}>{children}
{layers.map((layer) => {
const index = open.findIndex((entry) => entry.key === layer.key);
diff --git a/src/features/monos/ui/monoPanelParts.tsx b/src/features/monos/ui/monoPanelParts.tsx
index 152bf72797..30fec23cd7 100644
--- a/src/features/monos/ui/monoPanelParts.tsx
+++ b/src/features/monos/ui/monoPanelParts.tsx
@@ -5,7 +5,7 @@ import {
type ReactNode,
type TextareaHTMLAttributes,
} from "react";
-import { ChevronLeft } from "../../../shared/ui/icons";
+import { ChevronLeft, type IconComponent } from "../../../shared/ui/icons";
import { IconButton } from "../../../app/shell/TitleBar";
import { PROJECT_MASCOTS } from "../../projects/model/projectMascots";
import { MONO_COLORS } from "../model/mono";
@@ -27,28 +27,35 @@ import {
type MonoFile,
} from "../model/monoFiles";
-/** The top of a page in the Mono's panel: back, a title, and its actions. */
+/**
+ * The top of a page in the Mono's panel: back, a title, and its actions.
+ * Without `onBack` it heads a section of the settings dialog instead.
+ */
export function PageHeader({
title,
onBack,
children,
}: {
title: ReactNode;
- onBack: () => void;
+ onBack?: () => void;
children?: ReactNode;
}) {
return (
// Same height as the panel's own header, so a page does not jump.
-
-
-
+ {onBack ? (
+
+
+
+ ) : null}
{title}
@@ -181,20 +188,49 @@ export function Empty({ children }: { children: ReactNode }) {
);
}
+/**
+ * An empty tab: its icon over a line on how it fills, centered in the space
+ * the list would take.
+ */
+export function EmptyState({
+ icon: Icon,
+ children,
+}: {
+ icon: IconComponent;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
export function MascotPicker({
current,
color,
onPick,
+ columns = 10,
}: {
current: string;
color: string;
onPick: (mascot: string) => void;
+ /** Five lays them out as larger tiles, for a popover of their own. */
+ columns?: 5 | 10;
}) {
+ const large = columns === 5;
return (
{PROJECT_MASCOTS.map(({ name }) => {
const selected = name === current;
@@ -207,7 +243,9 @@ export function MascotPicker({
aria-label={name}
title={name.charAt(0).toUpperCase() + name.slice(1)}
onClick={() => onPick(name)}
- className={`grid h-8 min-w-0 place-items-center rounded-md ${
+ className={`grid min-w-0 place-items-center ${
+ large ? "h-12 rounded-lg" : "h-8 rounded-md"
+ } ${
selected
? "bg-selection ring-1 ring-content/20"
: "hover:bg-content/6"
@@ -217,7 +255,7 @@ export function MascotPicker({
name={name}
color={color}
still={!selected}
- className="size-5"
+ className={large ? "size-8" : "size-5"}
/>
);
@@ -231,11 +269,14 @@ export function ColorPicker({
current,
onPick,
inline = false,
+ columns = 10,
}: {
current: string;
onPick: (color: string) => void;
/** Opens the custom picker in place, for use inside another popover. */
inline?: boolean;
+ /** Five lays them out as larger swatches, under the larger mascots. */
+ columns?: 5 | 10;
}) {
const [open, setOpen] = useState(false);
const anchor = useRef
(null);
@@ -243,7 +284,10 @@ export function ColorPicker({
return (
}),
);
vi.mock("@tauri-apps/api/core", () => ({ invoke }));
+const play = vi.hoisted(() => vi.fn());
+vi.mock("cuelume", () => ({ play, setEnabled: vi.fn(), setVolume: vi.fn() }));
function Notifications({
sessions,
@@ -77,6 +79,7 @@ describe("input notification delivery", () => {
},
});
invoke.mockClear();
+ play.mockClear();
saveNotificationsEnabled(true);
setWindowFocused(false);
await probeNotificationPermission();
@@ -178,6 +181,7 @@ describe("input notification delivery", () => {
session = { ...session, blocks: [approval(1, "Visible request")] };
await render([session], "first");
expect(banners()).toEqual([]);
+ expect(play.mock.calls).toEqual([["arrival"]]);
session = {
...session,
pendingQuestion: question(2, "Hidden session question"),
@@ -192,5 +196,6 @@ describe("input notification delivery", () => {
"other",
);
expect(banners()).toHaveLength(1);
+ expect(play).toHaveBeenCalledTimes(2);
});
});
diff --git a/src/features/notifications/hooks/useInputNotifications.ts b/src/features/notifications/hooks/useInputNotifications.ts
index 58a111a2e9..69a1fdfdae 100644
--- a/src/features/notifications/hooks/useInputNotifications.ts
+++ b/src/features/notifications/hooks/useInputNotifications.ts
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef } from "react";
-import { notifySession, pendingInputNotifications } from "../model/notifications";
+import { announceSessionInput, pendingInputNotifications } from "../model/notifications";
import type { Session } from "../../sessions/model/session";
export function useInputNotifications(
@@ -23,7 +23,7 @@ export function useInputNotifications(
// Coalesce this render's banners per session, but leave other requests
// eligible for the next update (including resolution of the first one).
notified.add(key);
- void notifySession(
+ void announceSessionInput(
session,
event,
session.id === activeSessionId,
diff --git a/src/features/notifications/model/notificationDelivery.test.ts b/src/features/notifications/model/notificationDelivery.test.ts
index 2c2ba27ed9..de9f59393f 100644
--- a/src/features/notifications/model/notificationDelivery.test.ts
+++ b/src/features/notifications/model/notificationDelivery.test.ts
@@ -2,6 +2,7 @@
import { beforeEach, expect, it, vi } from "vitest";
import {
announceSessionFinished,
+ announceSessionInput,
notifySession,
saveNotificationsEnabled,
setWindowFocused,
@@ -84,3 +85,18 @@ it("does not deliver an input event observed during a mute after expiry", async
vi.useRealTimers();
}
});
+
+it("plays the input cue only when the question banner is not sent", async () => {
+ const session = newSession("claude", "/work");
+ const event = { kind: "question" as const, requestId: 1 };
+ await announceSessionInput(session, event, false);
+ expect(play).not.toHaveBeenCalled();
+
+ invoke.mockRejectedValue(new Error("No native bridge"));
+ await announceSessionInput(session, event, false);
+ expect(play.mock.calls).toEqual([["arrival"]]);
+
+ updateNotificationPreferences(["local:/work"], { mutedUntil: null });
+ await announceSessionInput(session, event, false);
+ expect(play).toHaveBeenCalledTimes(1);
+});
diff --git a/src/features/notifications/model/notifications.ts b/src/features/notifications/model/notifications.ts
index 82ba4517b3..f72d0e793e 100644
--- a/src/features/notifications/model/notifications.ts
+++ b/src/features/notifications/model/notifications.ts
@@ -241,27 +241,44 @@ export async function notifySession(
});
}
+export function announceSessionFinished(
+ session: Session,
+ sessionVisible: boolean,
+): Promise {
+ return announceSession(session, "finished", sessionVisible);
+}
+
+export function announceSessionInput(
+ session: Session,
+ event: InputNotificationEvent,
+ sessionVisible: boolean,
+): Promise {
+ return announceSession(session, event, sessionVisible);
+}
+
/** One policy decision covers both the OS banner and its in-app sound fallback. */
-export async function announceSessionFinished(
+async function announceSession(
session: Session,
+ event: NotificationEvent,
sessionVisible: boolean,
): Promise {
if (session.inboxAsk) return;
const occurredAt = Date.now();
const project = knownNotificationProject(session.cwd);
if (!project) return;
+ const finished = event === "finished";
const subject: NotificationSubject = {
projectId: project.id,
- category: "agentFinished",
+ category: finished ? "agentFinished" : "agentInput",
occurredAt,
};
const sent = await notifyProjectSession(
session,
- "finished",
+ event,
sessionVisible,
subject,
);
- if (!sent) playCue("turnFinished", subject);
+ if (!sent) playCue(finished ? "turnFinished" : "inputRequested", subject);
}
async function notifyProjectSession(
diff --git a/src/features/projects/hooks/useProjectAppearance.ts b/src/features/projects/hooks/useProjectAppearance.ts
new file mode 100644
index 0000000000..e9b94b485d
--- /dev/null
+++ b/src/features/projects/hooks/useProjectAppearance.ts
@@ -0,0 +1,27 @@
+import { useEffect, useState } from "react";
+import { subscribeProjectPathsChanged } from "../model/recents";
+import {
+ loadTabGroupColors,
+ loadTabGroupCustomColors,
+ loadTabGroupLabels,
+ loadTabGroupMascots,
+} from "../../workspace/model/tabGroups";
+
+function loadAppearance() {
+ return {
+ groupLabels: loadTabGroupLabels(),
+ groupColors: loadTabGroupColors(),
+ groupCustomColors: loadTabGroupCustomColors(),
+ groupMascots: loadTabGroupMascots(),
+ };
+}
+
+/** Projects' names, colors and mascots as picked in the project menu. */
+export function useProjectAppearance() {
+ const [appearance, setAppearance] = useState(loadAppearance);
+ useEffect(
+ () => subscribeProjectPathsChanged(() => setAppearance(loadAppearance())),
+ [],
+ );
+ return appearance;
+}
diff --git a/src/features/projects/model/projectGroups.test.ts b/src/features/projects/model/projectGroups.test.ts
index 21b3cc0a94..4256336abf 100644
--- a/src/features/projects/model/projectGroups.test.ts
+++ b/src/features/projects/model/projectGroups.test.ts
@@ -6,6 +6,7 @@ import {
loadProjectGroupAssignments,
loadProjectGroups,
nextProjectGroupName,
+ reorderProjectGroups,
saveProjectGroupAssignments,
saveProjectGroups,
setProjectGroupAssignment,
@@ -14,6 +15,18 @@ import {
beforeEach(() => localStorage.clear());
describe("project groups", () => {
+ it("reorders groups and keeps ones missing from the new order", () => {
+ saveProjectGroups(
+ ["a", "b", "c"].map((id) => ({ id, name: id, collapsed: false })),
+ );
+ reorderProjectGroups(["c", "a"]);
+ expect(loadProjectGroups().map((group) => group.id)).toEqual([
+ "c",
+ "a",
+ "b",
+ ]);
+ });
+
it("persists ordered appearance and collapsed state", () => {
expect(
saveProjectGroups([
diff --git a/src/features/projects/model/projectGroups.ts b/src/features/projects/model/projectGroups.ts
index 05d3f75b29..7873fe0695 100644
--- a/src/features/projects/model/projectGroups.ts
+++ b/src/features/projects/model/projectGroups.ts
@@ -168,6 +168,18 @@ export function updateProjectGroup(
);
}
+/** Reorders groups to match `ids`; groups missing from `ids` keep their place after it. */
+export function reorderProjectGroups(ids: string[]): void {
+ const current = loadProjectGroups();
+ const byId = new Map(current.map((group) => [group.id, group]));
+ const ordered = ids.flatMap((id) => {
+ const group = byId.get(id);
+ byId.delete(id);
+ return group ? [group] : [];
+ });
+ saveProjectGroups([...ordered, ...byId.values()]);
+}
+
/** Removes the group; its projects become ungrouped. */
export function deleteProjectGroup(id: string): boolean {
const nextGroups = loadProjectGroups().filter((group) => group.id !== id);
diff --git a/src/features/projects/ui/ProjectGroups.test.ts b/src/features/projects/ui/ProjectGroups.test.ts
index de2d0812a7..af25074b5c 100644
--- a/src/features/projects/ui/ProjectGroups.test.ts
+++ b/src/features/projects/ui/ProjectGroups.test.ts
@@ -1,11 +1,12 @@
// @vitest-environment happy-dom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pathKey } from "../../../shared/lib/paths";
import {
loadProjectGroupAssignments,
loadProjectGroups,
+ reorderProjectGroups,
saveProjectGroupAssignments,
saveProjectGroups,
} from "../model/projectGroups";
@@ -17,6 +18,14 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(async () => null),
convertFileSrc: (path: string) => path,
}));
+vi.mock("../model/projectGroups", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ reorderProjectGroups: vi.fn(actual.reorderProjectGroups),
+ };
+});
vi.mock("../../source-control/hooks/useProjectDiffStats", () => ({
useProjectDiffStats: vi.fn(() => null),
}));
@@ -27,6 +36,7 @@ let root: Root;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.mocked(useProjectDiffStats).mockClear();
+ vi.mocked(reorderProjectGroups).mockClear();
localStorage.clear();
container = document.createElement("div");
document.body.append(container);
@@ -39,16 +49,20 @@ afterEach(() => {
vi.unstubAllGlobals();
});
-async function renderRail(visible = true) {
+/** Renders the project rail with the mocked project groups. */
+async function renderRail(
+ visible = true,
+ recents = [
+ { path: "/work/client", openedAt: 1 },
+ { path: "/work/personal", openedAt: 2 },
+ ],
+) {
await act(async () =>
root.render(
createElement(ProjectRail, {
visible,
cwd: "/work/personal",
- recents: [
- { path: "/work/client", openedAt: 1 },
- { path: "/work/personal", openedAt: 2 },
- ],
+ recents,
onSelectProject: vi.fn(),
onOpenProject: vi.fn(),
}),
@@ -58,12 +72,16 @@ async function renderRail(visible = true) {
it("suspends project Git stats while the rail is hidden", async () => {
await renderRail();
- expect(vi.mocked(useProjectDiffStats).mock.calls.some(([, enabled]) => enabled)).toBe(true);
+ expect(
+ vi.mocked(useProjectDiffStats).mock.calls.some(([, enabled]) => enabled),
+ ).toBe(true);
vi.mocked(useProjectDiffStats).mockClear();
await renderRail(false);
expect(vi.mocked(useProjectDiffStats).mock.calls.length).toBeGreaterThan(0);
- expect(vi.mocked(useProjectDiffStats).mock.calls.every(([, enabled]) => !enabled)).toBe(true);
+ expect(
+ vi.mocked(useProjectDiffStats).mock.calls.every(([, enabled]) => !enabled),
+ ).toBe(true);
expect(container.querySelector('nav[aria-label="Projects"]')).not.toBeNull();
});
@@ -207,3 +225,178 @@ it("creates, styles, assigns, and deletes a group from the rail", async () => {
document.querySelector('button[aria-label="New project group"]'),
).toBeNull();
});
+
+describe("dragging groups of different heights", () => {
+ const groupTop = 100;
+ const tops = new Map();
+
+ // happy-dom does not lay out CSS, so stand in for the rail's spacing
+ // contract: groups stack in a `gap-px` column and expanded groups add
+ // `mb-1.5`. Heights and margins come from the rendered markup, so a change
+ // to either side of that contract shows up here.
+ /** Stubs measured rects so groups stack with their expanded/collapsed heights. */
+ function layoutGroups() {
+ const groups = [
+ ...container.querySelectorAll("[data-project-group]"),
+ ];
+ const gap = groups[0].parentElement!.classList.contains("gap-px") ? 1 : 0;
+ let top = groupTop;
+ tops.clear();
+ for (const group of groups) {
+ const rows = group.querySelectorAll(
+ "[data-project-group-items] > *",
+ ).length;
+ const height = 32 + (rows ? 8 + rows * 33 - 1 : 0);
+ const marginBottom = group.classList.contains("mb-1.5") ? 6 : 0;
+ const groupStart = top;
+ tops.set(group.dataset.projectGroup!, groupStart);
+ group.getBoundingClientRect = () =>
+ new DOMRect(0, groupStart, 200, height);
+ const captured = new Set();
+ group.setPointerCapture = (pointerId) => void captured.add(pointerId);
+ group.hasPointerCapture = (pointerId) => captured.has(pointerId);
+ group.releasePointerCapture = (pointerId) =>
+ void captured.delete(pointerId);
+ top += height + marginBottom + gap;
+ }
+ return groups;
+ }
+
+ /** The group element for `id`. */
+ function group(id: string) {
+ return container.querySelector(
+ `[data-project-group="${id}"]`,
+ )!;
+ }
+
+ /** Current translateY, in pixels, of the group element for `id`. */
+ function translateY(id: string) {
+ const match = /translate3d\(0, (-?[\d.]+)px, 0\)/.exec(
+ group(id).style.transform,
+ );
+ return match ? Number(match[1]) : 0;
+ }
+
+ /** Dispatches a pointer event at vertical position `clientY`. */
+ function pointer(target: EventTarget, type: string, clientY: number) {
+ act(() => {
+ target.dispatchEvent(
+ new PointerEvent(type, {
+ bubbles: true,
+ button: 0,
+ pointerId: 1,
+ clientX: 20,
+ clientY,
+ }),
+ );
+ });
+ }
+
+ beforeEach(async () => {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation(
+ (callback) => setTimeout(() => callback(0), 16) as unknown as number,
+ );
+ vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frame) =>
+ clearTimeout(frame),
+ );
+ const getComputedStyle = window.getComputedStyle.bind(window);
+ vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
+ const style = getComputedStyle(element);
+ if (!(element as HTMLElement).dataset?.projectGroup) return style;
+ const marginBottom = element.classList.contains("mb-1.5") ? "6px" : "0px";
+ return {
+ marginTop: "0px",
+ marginBottom,
+ getPropertyValue: (name: string) => style.getPropertyValue(name),
+ } as CSSStyleDeclaration;
+ });
+ document.documentElement.style.setProperty(
+ "--motion-reorder-duration",
+ "160ms",
+ );
+ saveProjectGroups([
+ { id: "a", name: "Alpha", collapsed: false },
+ { id: "b", name: "Beta", collapsed: true },
+ ]);
+ saveProjectGroupAssignments({
+ [pathKey("/work/a1")]: "a",
+ [pathKey("/work/a2")]: "a",
+ [pathKey("/work/a3")]: "a",
+ [pathKey("/work/b1")]: "b",
+ });
+ await renderRail(
+ true,
+ ["/work/a1", "/work/a2", "/work/a3", "/work/b1"].map((path, index) => ({
+ path,
+ openedAt: index + 1,
+ })),
+ );
+ layoutGroups();
+ });
+
+ afterEach(() => {
+ document.documentElement.style.removeProperty("--motion-reorder-duration");
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ /** Presses the group header of `id` and moves it `by` pixels; returns the pointer position. */
+ function drag(id: string, by: number) {
+ const start = tops.get(id)! + 16;
+ pointer(group(id).firstElementChild!, "pointerdown", start);
+ pointer(window, "pointermove", start + by);
+ act(() => vi.advanceTimersByTime(16));
+ return start + by;
+ }
+
+ it("uses the expanded/collapsed spacing contract", () => {
+ expect(group("a").classList).toContain("mb-1.5");
+ expect(group("b").classList).not.toContain("mb-1.5");
+ expect(group("a").parentElement!.classList).toContain("gap-px");
+ });
+
+ it("moves a tall expanded group below a short collapsed one", () => {
+ const end = drag("a", 400);
+ expect(translateY("b")).toBe(groupTop - tops.get("b")!);
+ pointer(window, "pointerup", end);
+ act(() => vi.runAllTimers());
+
+ expect(reorderProjectGroups).toHaveBeenCalledExactlyOnceWith(
+ ["b", "a"],
+ "a",
+ );
+ expect(loadProjectGroups().map(({ id }) => id)).toEqual(["b", "a"]);
+ expect(
+ [...container.querySelectorAll("[data-project-group]")].map(
+ (element) => (element as HTMLElement).dataset.projectGroup,
+ ),
+ ).toEqual(["b", "a"]);
+ });
+
+ it("previews and settles a collapsed last group where the commit puts it", () => {
+ const before = new Map(tops);
+ const end = drag("b", -400);
+ const previewTops = () =>
+ new Map(["a", "b"].map((id) => [id, before.get(id)! + translateY(id)]));
+ const preview = previewTops();
+ // Pausing does not shift the preview.
+ act(() => vi.advanceTimersByTime(500));
+ expect(previewTops()).toEqual(preview);
+ pointer(window, "pointerup", end);
+ const settled = previewTops();
+ act(() => vi.runAllTimers());
+
+ expect(reorderProjectGroups).toHaveBeenCalledExactlyOnceWith(
+ ["b", "a"],
+ "b",
+ );
+ expect(loadProjectGroups().map(({ id }) => id)).toEqual(["b", "a"]);
+ expect(group("a").style.transform).toBe("");
+ expect(group("b").style.transform).toBe("");
+ layoutGroups();
+ expect(preview).toEqual(tops);
+ expect(settled).toEqual(tops);
+ });
+});
diff --git a/src/features/projects/ui/SearchableProjectPicker.tsx b/src/features/projects/ui/SearchableProjectPicker.tsx
index dbcad4fd28..548b286129 100644
--- a/src/features/projects/ui/SearchableProjectPicker.tsx
+++ b/src/features/projects/ui/SearchableProjectPicker.tsx
@@ -5,6 +5,7 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useTabGroupLogos } from "../hooks/useTabGroupLogos";
+import { useProjectAppearance } from "../hooks/useProjectAppearance";
import { basename } from "../../../platform/tauri/fs";
import {
prettyParent,
@@ -15,14 +16,9 @@ import {
looksLikeProject,
projectRailItems,
sameProjectPath,
- subscribeProjectPathsChanged,
type RecentProject,
} from "../model/recents";
import {
- loadTabGroupColors,
- loadTabGroupCustomColors,
- loadTabGroupLabels,
- loadTabGroupMascots,
resolveTabGroupColor,
resolveTabGroupLabel,
resolveTabGroupLogo,
@@ -54,24 +50,6 @@ export type PickerMonos = {
onCreate?: () => void;
};
-function loadAppearance() {
- return {
- groupLabels: loadTabGroupLabels(),
- groupColors: loadTabGroupColors(),
- groupCustomColors: loadTabGroupCustomColors(),
- groupMascots: loadTabGroupMascots(),
- };
-}
-
-function useProjectAppearance() {
- const [appearance, setAppearance] = useState(loadAppearance);
- useEffect(
- () => subscribeProjectPathsChanged(() => setAppearance(loadAppearance())),
- [],
- );
- return appearance;
-}
-
type Props = {
cwd: string;
recents: RecentProject[];
@@ -152,11 +130,24 @@ export function SearchableProjectPicker({
{
- it("keeps document references on the source turn after saving and reopening", async () => {
+ it("keeps document and visual references on the source turn after saving and reopening", async () => {
let session = await load();
const card = {
id: "artifact-report",
@@ -60,16 +60,22 @@ describe("Mono database pagination and writes", () => {
title: "PR review",
summary: "Merge queue",
};
+ const visual = {
+ id: "artifact-chart",
+ kind: "html" as const,
+ title: "Build times",
+ };
const before = persistFingerprint(session);
session = recordArtifactCard(session, session.blocks[0].id, card);
+ session = recordArtifactCard(session, session.blocks[0].id, visual);
expect(persistFingerprint(session)).not.toBe(before);
call.mockResolvedValueOnce(saved(session));
await upsertSession(session);
const payload = (call.mock.calls.at(-1)![1] as { session: Session }).session;
- expect(payload.blocks[0].artifactCards).toEqual([card]);
+ expect(payload.blocks[0].artifactCards).toEqual([card, visual]);
call.mockResolvedValueOnce(saved(payload));
const restored = (await getSession(session.id))!;
- expect(restored.blocks[0].artifactCards).toEqual([card]);
+ expect(restored.blocks[0].artifactCards).toEqual([card, visual]);
});
it("preserves all results and the session count in a combined report across queue and transcript saves", async () => {
const session = await load();
diff --git a/src/features/sessions/data/sessionStore.ts b/src/features/sessions/data/sessionStore.ts
index f565a35e48..5e8c769c3a 100644
--- a/src/features/sessions/data/sessionStore.ts
+++ b/src/features/sessions/data/sessionStore.ts
@@ -8,6 +8,7 @@ import {
import { codexCommandPresentation } from "../../../integrations/harness/providers/codex/codexProtocol";
import { recoverCursorSubagents } from "../../../integrations/harness/providers/cursor/cursorSubagents";
import { persistableAttachment } from "../model/attachments";
+import { ARTIFACT_KINDS } from "../../artifacts/artifacts";
import type { ContextUsage } from "../model/contextUsage";
import {
isRemoteProjectPath,
@@ -986,7 +987,7 @@ function sanitizeBlock(
const cards = block.artifactCards.flatMap((card) => {
if (
!card ||
- card.kind !== "document" ||
+ !ARTIFACT_KINDS.includes(card.kind) ||
typeof card.id !== "string" ||
!isPersistableId(card.id) ||
typeof card.title !== "string" ||
@@ -996,7 +997,7 @@ function sanitizeBlock(
return [
{
id: card.id,
- kind: "document" as const,
+ kind: card.kind,
title: card.title.slice(0, 200),
...(typeof card.summary === "string" && card.summary.trim()
? { summary: card.summary.slice(0, 280) }
diff --git a/src/features/sessions/hooks/useBottomChatMotion.test.ts b/src/features/sessions/hooks/useBottomChatMotion.test.ts
index 051618d878..c4dd6c14ec 100644
--- a/src/features/sessions/hooks/useBottomChatMotion.test.ts
+++ b/src/features/sessions/hooks/useBottomChatMotion.test.ts
@@ -3,7 +3,11 @@ import { act, createElement, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Block } from "../model/session";
-import { useBottomChatMotion } from "./useBottomChatMotion";
+import {
+ followSpring,
+ stepFollowSpring,
+ useBottomChatMotion,
+} from "./useBottomChatMotion";
let container: HTMLDivElement;
let root: Root;
@@ -28,11 +32,13 @@ function Harness({
enabled = true,
busy = true,
historicalBlockIds,
+ smoothReveals = false,
}: {
blocks: Block[];
enabled?: boolean;
busy?: boolean;
historicalBlockIds?: ReadonlySet;
+ smoothReveals?: boolean;
}) {
const [scroller, setScroller] = useState(null);
useBottomChatMotion(
@@ -43,6 +49,9 @@ function Harness({
false,
busy,
historicalBlockIds,
+ true,
+ undefined,
+ smoothReveals,
);
return createElement(
"div",
@@ -84,10 +93,17 @@ function render(
enabled = true,
busy = true,
historicalBlockIds?: ReadonlySet,
+ smoothReveals = false,
) {
act(() =>
root.render(
- createElement(Harness, { blocks, enabled, busy, historicalBlockIds }),
+ createElement(Harness, {
+ blocks,
+ enabled,
+ busy,
+ historicalBlockIds,
+ smoothReveals,
+ }),
),
);
}
@@ -190,6 +206,44 @@ afterEach(() => {
});
describe("bottom chat motion", () => {
+ it("glides through a whole Mono reply without skipping most of its height", () => {
+ const blocks: Block[] = [{ id: "user", role: "user", text: "Hello" }];
+ render(blocks, true, true, undefined, true);
+ const view = viewport();
+ scrollHeight += 940;
+ render(blocks, true, false, undefined, true);
+ expect(offset(view.content)).toBe(940);
+
+ // An answer landing in one commit must not be treated as a fast stream.
+ for (let frame = 0; frame < 12; frame++) advance(1000 / 60);
+ expect(offset(view.content)).toBeGreaterThan(600);
+ expect(offset(view.content)).toBeLessThan(940);
+ for (let frame = 0; frame < 120; frame++) advance(1000 / 60);
+ expect(view.content.style.transform).toBe("");
+ expect(top).toBe(940);
+ });
+
+ it("hands off a large Mono reveal at its visible position when scrolling up", () => {
+ scrollHeight = 1000;
+ const blocks: Block[] = [{ id: "user", role: "user", text: "Hello" }];
+ render(blocks, true, true, undefined, true);
+ const view = viewport();
+ scrollHeight += 940;
+ render(blocks, true, false, undefined, true);
+ advance(40);
+ const visibleTop = top - offset(view.content);
+ act(() =>
+ view.scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })),
+ );
+ expect(top).toBeCloseTo(visibleTop);
+ expect(following.current).toBe(false);
+ expect(view.content.style.transform).toBe("");
+ scrollHeight += 100;
+ view.resize();
+ expect(top).toBeCloseTo(visibleTop);
+ expect(frames.size).toBe(0);
+ });
+
it.each(["short", "scrolling"])(
"eases growing %s conversations upward",
(length) => {
@@ -464,3 +518,36 @@ describe("bottom chat motion", () => {
);
});
});
+
+describe("follow spring", () => {
+ it("settles a single jump without overshooting", () => {
+ const spring = followSpring();
+ let offset = stepFollowSpring(spring, 60, 60, 1);
+ let previous = 60;
+ for (let frame = 0; frame < 40; frame++) {
+ expect(offset).toBeLessThanOrEqual(previous);
+ expect(offset).toBeGreaterThanOrEqual(0);
+ previous = offset;
+ offset = stepFollowSpring(spring, offset, 0, 1);
+ }
+ expect(offset).toBe(0);
+ });
+
+ it("glides at the stream's speed instead of jolting at each update", () => {
+ const spring = followSpring();
+ let offset = 0;
+ const travelled: number[] = [];
+ // 12px every other frame: an update lands, then a frame with none.
+ for (let frame = 0; frame < 120; frame++) {
+ const grew = frame % 2 === 0 ? 12 : 0;
+ const before = offset + grew;
+ offset = stepFollowSpring(spring, before, grew, 1, 1);
+ if (frame >= 80) travelled.push(before - offset);
+ }
+ // Settled into a steady trail behind the end, moving every frame.
+ expect(offset).toBeGreaterThan(0);
+ expect(offset).toBeLessThanOrEqual(32);
+ expect(Math.min(...travelled)).toBeGreaterThan(3);
+ expect(Math.max(...travelled)).toBeLessThan(9);
+ });
+});
diff --git a/src/features/sessions/hooks/useBottomChatMotion.ts b/src/features/sessions/hooks/useBottomChatMotion.ts
index cc3bc2935b..998e19278d 100644
--- a/src/features/sessions/hooks/useBottomChatMotion.ts
+++ b/src/features/sessions/hooks/useBottomChatMotion.ts
@@ -1,8 +1,84 @@
import { useCallback, useLayoutEffect, useRef, type RefObject } from "react";
import type { Block } from "../model/session";
+import { innerScrollerTakes } from "../../../shared/hooks/useLockOverscroll";
const FOLLOW_TIME_MS = 85;
const SETTLED_PX = 0.25;
+/*
+ * Following streamed growth is a velocity spring with a feed-forward term
+ * Transcript updates land in bursts; easing each one out on its own moves the
+ * reply in jolts. The spring instead learns how fast the reply is growing and
+ * travels at that speed, so a steady stream reads as one continuous glide.
+ * Units are px per 60fps frame.
+ */
+/** Velocity kept from one frame to the next (higher glides longer). */
+const SPRING_DAMPING = 0.7;
+/** Pull toward the end (higher is snappier). */
+const SPRING_STIFFNESS = 0.05;
+/** Inertia (higher is slower to start and stop). */
+const SPRING_MASS = 1.25;
+const SPRING_FRAME_MS = 1000 / 60;
+/** Rate the growth estimate follows the observed growth. */
+const SPRING_GROWTH_EMA = 0.12;
+/**
+ * While the reply grows, trail the end by up to this much rather than hug a
+ * moving edge. The transcript's bottom padding covers it.
+ */
+const SPRING_MAX_LEAD_PX = 32;
+/** Keep the spring's state this long after it lands, so a pause in the stream resumes at speed. */
+const SPRING_SETTLE_GRACE_MS = 500;
+/** Longest stretch of time one growth sample is spread over. */
+const SPRING_MAX_SAMPLE_FRAMES = 60;
+/** Mono replies can appear together; keep their glide below 1080px/second. */
+const CHAT_FOLLOW_MAX_SPEED = 18;
+
+export type FollowSpring = { velocity: number; growth: number };
+
+export function followSpring(): FollowSpring {
+ return { velocity: 0, growth: 0 };
+}
+
+/**
+ * Advance the spring over `frames` 60fps frames and return the distance still
+ * left to travel. `offset` is how far the content trails the end, `grew` how
+ * much the end moved over the last `sampleFrames`. Never overshoots the end.
+ */
+export function stepFollowSpring(
+ spring: FollowSpring,
+ offset: number,
+ grew: number,
+ frames: number,
+ sampleFrames = frames,
+ maxSpeed = Infinity,
+): number {
+ if (grew < -1) spring.growth = 0;
+ else {
+ const observed = Math.min(
+ maxSpeed,
+ Math.max(0, grew) / Math.max(0.25, sampleFrames),
+ );
+ spring.growth += SPRING_GROWTH_EMA * (observed - spring.growth);
+ }
+ const lead = Math.min(spring.growth * 9, SPRING_MAX_LEAD_PX);
+ let velocity = spring.velocity;
+ while (frames > 0 && offset > 0) {
+ const h = Math.min(1, frames);
+ frames -= h;
+ const diff = Math.max(0, offset - lead);
+ velocity +=
+ h *
+ ((SPRING_DAMPING * velocity + SPRING_STIFFNESS * diff) / SPRING_MASS -
+ velocity);
+ velocity = Math.min(velocity, maxSpeed - spring.growth);
+ offset = Math.max(0, offset - (velocity + spring.growth) * h);
+ }
+ spring.velocity = velocity;
+ return offset;
+}
+
+function springIdle(spring: FollowSpring): boolean {
+ return spring.velocity < 0.05 && spring.growth < 0.05;
+}
/**
* How long growth keeps easing after a reply ends. Its last words are still
* revealed at pace, and the turn's footer settles, after `busy` clears.
@@ -23,9 +99,23 @@ export function useBottomChatMotion(
firstSend: boolean,
animateGrowth = true,
historicalBlockIds?: ReadonlySet,
+ /** Animate new messages in, as the chat layout does. */
+ animateEntrances = true,
+ /**
+ * Until this time a wheel gesture of unknown direction may be scrolling
+ * off the main thread; pinning would snap it back to the end.
+ */
+ holdUntil?: RefObject,
+ /** Preserve the full movement and bound its speed for whole Mono replies. */
+ smoothReveals = false,
) {
- const latest = useRef({ blocks, firstSend, historicalBlockIds });
- latest.current = { blocks, firstSend, historicalBlockIds };
+ const latest = useRef({
+ blocks,
+ firstSend,
+ historicalBlockIds,
+ animateEntrances,
+ });
+ latest.current = { blocks, firstSend, historicalBlockIds, animateEntrances };
const update = useRef<(() => void) | null>(null);
const introduced = useRef(false);
const growthUntil = useRef(animateGrowth ? Infinity : 0);
@@ -73,6 +163,11 @@ export function useBottomChatMotion(
let offset = 0;
let frame = 0;
let lastFrame = 0;
+ let spring = followSpring();
+ // Growth since the spring last stepped, the feed-forward's sample.
+ let grown = 0;
+ let lastStep = 0;
+ let settledAt: number | null = null;
// Content coordinates exclude native scrolling and movement of the pane.
// Only a layout change may contribute to the eased message motion.
@@ -86,8 +181,19 @@ export function useBottomChatMotion(
if (frame) cancelAnimationFrame(frame);
frame = 0;
offset = 0;
+ spring = followSpring();
+ grown = 0;
+ settledAt = null;
if (content.style.transform) content.style.removeProperty("transform");
};
+ // Landed: stop drawing frames, but remember how fast the reply was going.
+ const settle = (now: number) => {
+ const kept = spring;
+ reset();
+ spring = kept;
+ settledAt = now;
+ lastStep = now;
+ };
const tick = (now: number) => {
frame = 0;
if (!scroller.isConnected) {
@@ -100,9 +206,28 @@ export function useBottomChatMotion(
}
const elapsed = Math.min(64, Math.max(0, now - lastFrame));
lastFrame = now;
- offset *= Math.exp(-elapsed / FOLLOW_TIME_MS);
+ // Growth rides the spring. Shrinking (a fold closing at the end) has
+ // nothing to track and just eases out.
+ if (offset > 0) {
+ offset = stepFollowSpring(
+ spring,
+ offset,
+ grown,
+ elapsed / SPRING_FRAME_MS,
+ Math.min(
+ SPRING_MAX_SAMPLE_FRAMES,
+ Math.max(0, now - lastStep) / SPRING_FRAME_MS,
+ ),
+ smoothReveals ? CHAT_FOLLOW_MAX_SPEED : Infinity,
+ );
+ } else {
+ offset *= Math.exp(-elapsed / FOLLOW_TIME_MS);
+ }
+ grown = 0;
+ lastStep = now;
if (Math.abs(offset) < SETTLED_PX) {
- reset();
+ if (offset > 0 || !springIdle(spring)) settle(now);
+ else reset();
return;
}
content.style.transform = `translateY(${offset}px)`;
@@ -110,7 +235,31 @@ export function useBottomChatMotion(
};
const measure = () => {
if (!scroller.isConnected) return;
+ // Markdown can reflow several times between transcript updates. Only
+ // inspect message IDs when the blocks change, not on every resize.
+ let sentPrompt = false;
+ if (
+ latest.current.blocks !== scannedBlocks ||
+ latest.current.blocks.length !== scannedCount
+ ) {
+ scannedBlocks = latest.current.blocks;
+ scannedCount = scannedBlocks.length;
+ for (const block of scannedBlocks) {
+ if (seen.has(block.id)) continue;
+ seen.add(block.id);
+ if (
+ (block.role === "user" || block.role === "assistant") &&
+ !block.draft &&
+ !latest.current.historicalBlockIds?.has(block.id)
+ ) {
+ entering.add(block.id);
+ if (block.role === "user") sentPrompt = true;
+ }
+ }
+ }
+ const { animateEntrances } = latest.current;
const following = stickToBottom.current;
+ if (following && performance.now() < (holdUntil?.current ?? 0)) return;
if (!following) {
pause();
} else {
@@ -134,41 +283,43 @@ export function useBottomChatMotion(
wasFollowing = true;
const animated = performance.now() < growthUntil.current;
- if (resized || reducedMotion?.matches || !animated) {
+ // Without entrances the transcript brings a sent prompt in itself.
+ if (
+ resized ||
+ reducedMotion?.matches ||
+ !animated ||
+ (!animateEntrances && sentPrompt)
+ ) {
reset();
} else {
const limit = Math.max(0, height * 0.75);
- offset = Math.max(-limit, Math.min(limit, offset + shift));
+ // Capping a whole reply's offset skips the rest of its movement
+ // immediately. Mono keeps that distance in the bounded glide.
+ offset = Math.max(
+ -limit,
+ smoothReveals ? offset + shift : Math.min(limit, offset + shift),
+ );
+ grown += shift;
if (Math.abs(offset) >= SETTLED_PX) {
content.style.transform = `translateY(${offset}px)`;
if (!frame) {
- lastFrame = performance.now();
+ const now = performance.now();
+ if (
+ settledAt === null ||
+ now - settledAt > SPRING_SETTLE_GRACE_MS
+ ) {
+ spring = followSpring();
+ lastStep = now;
+ }
+ settledAt = null;
+ lastFrame = now;
frame = requestAnimationFrame(tick);
}
}
}
}
- // Markdown can reflow several times between transcript updates. Only
- // inspect message IDs when the blocks change, not on every resize.
- if (
- latest.current.blocks !== scannedBlocks ||
- latest.current.blocks.length !== scannedCount
- ) {
- scannedBlocks = latest.current.blocks;
- scannedCount = scannedBlocks.length;
- for (const block of scannedBlocks) {
- if (seen.has(block.id)) continue;
- seen.add(block.id);
- if (
- (block.role === "user" || block.role === "assistant") &&
- !block.draft &&
- !latest.current.historicalBlockIds?.has(block.id)
- )
- entering.add(block.id);
- }
- }
- if (!following || reducedMotion?.matches) {
+ if (!following || reducedMotion?.matches || !animateEntrances) {
entering.clear();
return;
}
@@ -219,7 +370,8 @@ export function useBottomChatMotion(
if (visibleTop !== null) scroller.scrollTop = Math.max(0, visibleTop);
};
const onWheel = (event: WheelEvent) => {
- if (event.deltaY < 0) pause();
+ // A nested code block scrolling up leaves the reader at the end.
+ if (event.deltaY < 0 && !innerScrollerTakes(scroller, event)) pause();
};
const onScroll = () => {
if (!stickToBottom.current && offset) pause();
@@ -246,7 +398,7 @@ export function useBottomChatMotion(
reducedMotion?.removeEventListener?.("change", stop);
stop();
};
- }, [scroller, enabled, stickToBottom]);
+ }, [scroller, enabled, stickToBottom, holdUntil, smoothReveals]);
// Run after the transcript's pin/restore effects, before this commit paints.
useLayoutEffect(() => update.current?.());
diff --git a/src/features/sessions/model/models.test.ts b/src/features/sessions/model/models.test.ts
index 1c943786c8..36d973639d 100644
--- a/src/features/sessions/model/models.test.ts
+++ b/src/features/sessions/model/models.test.ts
@@ -3,6 +3,7 @@ import { newSession, type HarnessId } from "./session";
import {
MODELS,
coerceModelPickerTab,
+ customOpenCodeModel,
defaultModelId,
defaultSessionChoice,
encodeModelLaunchId,
@@ -486,6 +487,33 @@ describe("live catalog overlays", () => {
expect(encodeModelLaunchId("claude:opus-5-5")).toBe("claude-opus-5-5");
});
+ it("gives a saved version the settings of the alias that launches it", () => {
+ // Claude lists Opus 5.5 only as the `opus` alias. The saved concrete id
+ // must keep its effort and fast options in the picker.
+ const effort = {
+ id: "effort",
+ label: "Reasoning",
+ kind: "select" as const,
+ value: "high",
+ options: [{ value: "high", label: "High" }],
+ };
+ setHarnessModels("claude", [
+ {
+ id: "claude:opus",
+ harness: "claude" as const,
+ name: "Opus 5.5",
+ nativeId: "opus",
+ aliasTarget: "claude-opus-5-5",
+ settings: [effort],
+ },
+ ]);
+
+ const saved = resolveModel("claude", "claude:opus-5-5");
+ expect(saved.id).toBe("claude:opus-5-5");
+ expect(saved.settings).toEqual([effort]);
+ expect(resolveModel("claude", "claude:opus-5").settings).toBeUndefined();
+ });
+
it("prefers the bundled versioned model over a singleton fuzzy match", () => {
// Live catalog has only Opus 5, so `opus-5-5` prefix-matches that one
// row. Returning it would give ModelSettings the wrong AgentModel for a
@@ -717,3 +745,38 @@ describe("every bundled model resolves to its own native id", () => {
expect(wrong).toEqual([]);
});
});
+
+describe("custom OpenCode models", () => {
+ afterEach(() => resetHarnessModelOverlays());
+
+ it("keeps a typed provider/model the catalog does not list", () => {
+ setHarnessModels("opencode", [
+ {
+ id: "opencode:anthropic/claude-sonnet-4-6",
+ harness: "opencode",
+ name: "Claude Sonnet 4.6",
+ nativeId: "anthropic/claude-sonnet-4-6",
+ provider: { id: "anthropic", name: "Anthropic" },
+ },
+ ]);
+ const model = resolveModel(
+ "opencode",
+ "opencode:anthropic/claude-sonnet-4-6-preview",
+ );
+ expect(model.id).toBe("opencode:anthropic/claude-sonnet-4-6-preview");
+ expect(model.name).toBe("claude-sonnet-4-6-preview");
+ expect(nativeModelId(model.id)).toBe("anthropic/claude-sonnet-4-6-preview");
+ });
+
+ it("only treats provider/model text as a custom model", () => {
+ expect(customOpenCodeModel("openrouter/qwen/qwen3-coder")).toMatchObject({
+ id: "opencode:openrouter/qwen/qwen3-coder",
+ nativeId: "openrouter/qwen/qwen3-coder",
+ provider: { id: "openrouter" },
+ });
+ expect(customOpenCodeModel("qwen3-coder")).toBeNull();
+ expect(customOpenCodeModel("/qwen3")).toBeNull();
+ expect(customOpenCodeModel("openrouter/")).toBeNull();
+ expect(customOpenCodeModel("open router/qwen")).toBeNull();
+ });
+});
diff --git a/src/features/sessions/model/models.ts b/src/features/sessions/model/models.ts
index 3f5469b76c..24e74dcfbd 100644
--- a/src/features/sessions/model/models.ts
+++ b/src/features/sessions/model/models.ts
@@ -25,6 +25,8 @@ export type AgentModel = {
harness: HarnessId;
name: string;
nativeId?: string;
+ /** Concrete native id a moving alias (`opus`) currently launches. */
+ aliasTarget?: string;
/** Upstream provider inside a multi-provider harness such as OpenCode. */
provider?: {
id: string;
@@ -366,6 +368,12 @@ export function resolveModel(harness: HarnessId, id?: string): AgentModel {
(model) => (model.nativeId ?? nativeIdFrom(model.id)) === slug,
);
if (byNative) return byNative;
+ // A typed `provider/model` the OpenCode catalog does not list is kept
+ // as is rather than prefix-matched onto a different model.
+ if (harness === "opencode") {
+ const custom = customOpenCodeModel(slug);
+ if (custom) return custom;
+ }
// Keep moving Claude aliases as aliases until the CLI advertises them.
if (harness === "claude" && /^(opus|sonnet|haiku)$/.test(slug)) {
return {
@@ -412,14 +420,19 @@ export function resolveModel(harness: HarnessId, id?: string): AgentModel {
return hits[0];
}
const bundled = bundledById.get(id);
- if (bundled && bundled.harness === harness) return bundled;
+ if (bundled && bundled.harness === harness) {
+ return withAliasSettings(bundled, available);
+ }
// A saved concrete Claude version may be absent from both catalogs.
// Keep the requested id so a new session does not silently switch models.
const requested = id.trim();
if (harness === "claude" && /^claude:[a-z][a-z0-9-]*-\d/.test(requested)) {
const nativeId = nativeIdForUnknownKey(requested);
- return { id: requested, harness, name: nativeId, nativeId };
+ return withAliasSettings(
+ { id: requested, harness, name: nativeId, nativeId },
+ available,
+ );
}
}
// Codex has no built-in catalog. During startup, retain the saved model
@@ -450,6 +463,50 @@ export function resolveModel(harness: HarnessId, id?: string): AgentModel {
return (fallbackId ? findModel(fallbackId) : undefined) ?? available[0];
}
+/**
+ * An OpenCode model typed by hand as `provider/model`, for models the CLI
+ * catalog does not list (custom providers, new releases). `null` when the
+ * text is not a provider/model pair.
+ */
+export function customOpenCodeModel(text: string): AgentModel | null {
+ const slug = text.trim().replace(/^opencode:/, "");
+ const separator = slug.indexOf("/");
+ if (separator <= 0 || separator === slug.length - 1 || /\s/.test(slug)) {
+ return null;
+ }
+ const providerId = slug.slice(0, separator);
+ return {
+ id: `opencode:${slug}`,
+ harness: "opencode",
+ name: slug.slice(separator + 1),
+ nativeId: slug,
+ provider: { id: providerId, name: providerId },
+ };
+}
+
+/**
+ * A concrete version missing from the live catalog borrows the settings of the
+ * alias that launches it. Claude lists Opus 5.5 only as `opus`, so a saved
+ * `claude:opus-5-5` would otherwise lose its effort and fast options.
+ */
+function withAliasSettings(
+ model: AgentModel,
+ available: AgentModel[],
+): AgentModel {
+ if (model.settings) return model;
+ const native = comparableNativeId(
+ model.harness,
+ model.nativeId ?? nativeIdFrom(model.id),
+ );
+ const alias = available.find(
+ (item) =>
+ item.settings &&
+ item.aliasTarget != null &&
+ comparableNativeId(item.harness, item.aliasTarget) === native,
+ );
+ return alias ? { ...model, settings: alias.settings } : model;
+}
+
/** Catalog-reported context window for a model id, when known. */
export function modelContextWindow(id: string): number | undefined {
const window = findModel(id)?.contextWindow;
diff --git a/src/features/sessions/model/sessionFilters.ts b/src/features/sessions/model/sessionFilters.ts
index 6e8e13bbc7..4727f8fb02 100644
--- a/src/features/sessions/model/sessionFilters.ts
+++ b/src/features/sessions/model/sessionFilters.ts
@@ -12,6 +12,8 @@ export type SessionStatusFilter = {
export type SessionSidebarFilters = {
showArchived: boolean;
+ /** Narrows a Mono's sessions to launches since deleted. */
+ showUnavailable?: boolean;
hiddenHarnesses: HarnessId[];
time: SessionTimeFilter;
status: SessionStatusFilter;
@@ -79,6 +81,7 @@ export function hasActiveSessionFilters(
): boolean {
return (
filters.showArchived ||
+ !!filters.showUnavailable ||
filters.hiddenHarnesses.length > 0 ||
filters.time !== "all" ||
filters.status.working ||
diff --git a/src/features/sessions/model/transcriptActivity.test.ts b/src/features/sessions/model/transcriptActivity.test.ts
index 7e2b0bd13f..9c9d962902 100644
--- a/src/features/sessions/model/transcriptActivity.test.ts
+++ b/src/features/sessions/model/transcriptActivity.test.ts
@@ -907,6 +907,11 @@ describe("activityPhaseTitle", () => {
).toBe("Found it — the tokens live in globals.css.");
});
+ it("titles a group by its calls when the agent's line is only punctuation", () => {
+ expect(title([note("n1", "."), read("r1", "a.ts")])).toBe("Read a.ts");
+ expect(title([note("n1", " … "), read("r1", "a.ts")])).toBe("Read a.ts");
+ });
+
it("says what the calls add up to, in the tense of the moment", () => {
expect(title([read("r1", "a.ts"), read("r2", "b.ts")], true)).toBe(
"Reading 2 files",
diff --git a/src/features/sessions/model/transcriptActivity.ts b/src/features/sessions/model/transcriptActivity.ts
index 682d7e9c13..fd15cb6770 100644
--- a/src/features/sessions/model/transcriptActivity.ts
+++ b/src/features/sessions/model/transcriptActivity.ts
@@ -178,6 +178,10 @@ export function isProseBlock(block: Block): boolean {
return block.role === "assistant" && !!block.text.trim();
}
+export function hasReadableText(text: string): boolean {
+ return /[^\s\p{P}]/u.test(text);
+}
+
/** First paragraph of a folded prose block, stripped to one plain line. */
export function proseSummary(text: string): string {
const body = text.replace(/```[\s\S]*?(?:```|$)/g, " ");
@@ -1084,8 +1088,9 @@ export function activityPhaseTitle(phase: ActivityPhase, live = false): string {
if (failure) return failure;
if (phase.headline) {
const summary = proseSummary(phase.headline.text);
- if (summary) return summary;
- return phase.headline.role === "reasoning" ? "Thinking" : "Working";
+ if (hasReadableText(summary)) return summary;
+ if (!summary)
+ return phase.headline.role === "reasoning" ? "Thinking" : "Working";
}
return workSummaryLine(phase.steps, live);
}
diff --git a/src/features/sessions/ui/AgentMarkdown.test.ts b/src/features/sessions/ui/AgentMarkdown.test.ts
index 67251179bf..57c1017951 100644
--- a/src/features/sessions/ui/AgentMarkdown.test.ts
+++ b/src/features/sessions/ui/AgentMarkdown.test.ts
@@ -171,3 +171,29 @@ describe("AgentMarkdown note images", () => {
expect(markup).toContain('alt="Diagram"');
});
});
+
+describe("AgentMarkdown local media", () => {
+ const text = [
+ "![Demo]()",
+ "",
+ "[Open the screenshot](/Users/me/shot.png) and [the notes](/Users/me/notes.md)",
+ ].join("\n");
+
+ it("shows local images and videos in place of their links when enabled", () => {
+ const markup = renderToStaticMarkup(
+ createElement(AgentMarkdown, { text, localMedia: true }),
+ );
+
+ expect(markup).toContain('data-local-media="/Users/me/My Clips/demo.mp4"');
+ expect(markup).toContain('data-local-media="/Users/me/shot.png"');
+ expect(markup).not.toContain('data-local-media="/Users/me/notes.md"');
+ expect(markup).toContain(">the notes");
+ });
+
+ it("keeps links and drops local images elsewhere", () => {
+ const markup = renderToStaticMarkup(createElement(AgentMarkdown, { text }));
+
+ expect(markup).not.toContain("data-local-media");
+ expect(markup).toContain(">Open the screenshot");
+ });
+});
diff --git a/src/features/sessions/ui/AgentMarkdown.tsx b/src/features/sessions/ui/AgentMarkdown.tsx
index 7828e5b20e..52fe425504 100644
--- a/src/features/sessions/ui/AgentMarkdown.tsx
+++ b/src/features/sessions/ui/AgentMarkdown.tsx
@@ -11,6 +11,7 @@ import {
useRef,
useState,
type ComponentProps,
+ type CSSProperties,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
@@ -47,8 +48,17 @@ import { INBOX_MEDIA_PREFIXES, isInboxMediaUrl } from "../../inbox/model/inboxMe
import { isNoteImagePath } from "../../notes";
import { IS_MAC, IS_WIN } from "../../../platform/tauri/platform";
import { InboxMedia } from "../../inbox/ui/InboxMedia";
+import { isLocalMediaPath, LocalMedia } from "./LocalMedia";
import { rehypeHardBreaks } from "./hardBreaks";
-import { rehypeWordFade, usePacedText, useWordFading } from "./wordFade";
+import {
+ pacedRevealPreferred,
+ rehypeWordFade,
+ useCadenceFade,
+ usePacedText,
+ useStreamedText,
+ useWordFading,
+ WORD_FADE_MS,
+} from "./wordFade";
import { isFenceBlock, parseStreamingMarkdown } from "./streamingMarkdown";
import { HighlightedCodeBlock } from "./HighlightedCodeBlock";
@@ -127,6 +137,8 @@ const FileOpenContext = createContext<{
}>({});
const RemoteMediaContext = createContext(false);
+/** Show local images and videos a reply links to, rather than the links. */
+const LocalMediaContext = createContext(false);
const MarkdownFadeContext = createContext(false);
const REVEAL_LABEL = IS_MAC
@@ -241,11 +253,12 @@ function MarkdownLink({
const { cwd, onOpenFile, onFileContextMenu } = useContext(FileOpenContext);
const file = href ? resolveWorkspaceFileReference(href, cwd) : undefined;
const label = textContent(children);
+ const localMedia = useContext(LocalMediaContext);
if (allowRemoteMedia && href && isInboxMediaUrl(href)) {
return ;
}
- return (
+ const link = (
);
+ if (localMedia && file && isLocalMediaPath(file.path)) {
+ return (
+ onOpenFile(file.path) : undefined}
+ fallback={link}
+ />
+ );
+ }
+ return link;
}
type MarkdownCodeProps = ComponentProps<"code"> & { node?: unknown };
@@ -468,6 +492,8 @@ function MarkdownImage({
...props
}: MarkdownImageProps) {
const allowRemoteMedia = useContext(RemoteMediaContext);
+ const localMedia = useContext(LocalMediaContext);
+ const { cwd, onOpenFile } = useContext(FileOpenContext);
const url = typeof src === "string" ? src.trim() : "";
if (url.startsWith("data:image/")) {
return ;
@@ -475,6 +501,20 @@ function MarkdownImage({
if (isNoteImagePath(url)) {
return ;
}
+ const file =
+ localMedia && url && !/^[a-z][a-z0-9+.-]*:\/\//i.test(url)
+ ? resolveWorkspaceFileReference(url, cwd)
+ : undefined;
+ if (file && isLocalMediaPath(file.path)) {
+ return (
+ onOpenFile(file.path) : undefined}
+ fallback={null}
+ />
+ );
+ }
if (!allowRemoteMedia || !url || !isInboxMediaUrl(url)) return null;
return ;
}
@@ -519,6 +559,7 @@ export const AgentMarkdown = memo(function AgentMarkdown({
cwd,
onOpenFile,
allowRemoteMedia,
+ localMedia,
hardBreaks,
}: {
text: string;
@@ -529,6 +570,8 @@ export const AgentMarkdown = memo(function AgentMarkdown({
cwd?: string;
onOpenFile?: OpenFileFn;
allowRemoteMedia?: boolean;
+ /** Show local images and videos the text links to, in place of the links. */
+ localMedia?: boolean;
/** Show a newline inside a block as a line break, as a document does (#591). */
hardBreaks?: boolean;
}) {
@@ -554,8 +597,19 @@ export const AgentMarkdown = memo(function AgentMarkdown({
[cwd],
);
const remoteMedia = !!allowRemoteMedia;
- const paced = usePacedText(text, !!streaming, revealOnMount);
- const fading = useWordFading(!!streaming || paced.revealing);
+ // Read once: switching reveal hooks while mounted would break hook order.
+ const [pacedReveal] = useState(pacedRevealPreferred);
+ const useReveal = pacedReveal ? usePacedText : useStreamedText;
+ const paced = useReveal(text, !!streaming, revealOnMount);
+ const cadence = useCadenceFade(paced.text);
+ const fadeMs = pacedReveal ? WORD_FADE_MS : cadence.durationMs;
+ const fading = useWordFading(!!streaming || paced.revealing, fadeMs);
+ const fadeStyle = pacedReveal
+ ? undefined
+ : ({
+ "--word-fade-ms": `${fadeMs}ms`,
+ "--word-fade-ease": cadence.easing,
+ } as CSSProperties);
// Spans stay while words are fading so a word already on screen keeps its
// element. Dropping one mid-fade would remount it and fade it again. Once
// the fade is over they come off, or a finished reply would keep a span per
@@ -614,43 +668,48 @@ export const AgentMarkdown = memo(function AgentMarkdown({
return (
-
- <>
-
-
- {paced.text}
-
-
- {fileMenu ? (
- setFileMenu(null)}
- />
- ) : null}
- {fileActionError ? (
- setFileActionError(null)}
- />
- ) : null}
- >
-
+
+
+ <>
+
+ {/* Streamdown takes no style; a contents box passes the fade timing down. */}
+
+
+ {paced.text}
+
+
+
+ {fileMenu ? (
+ setFileMenu(null)}
+ />
+ ) : null}
+ {fileActionError ? (
+ setFileActionError(null)}
+ />
+ ) : null}
+ >
+
+
);
});
diff --git a/src/features/sessions/ui/AgentTranscript.inlineWork.test.ts b/src/features/sessions/ui/AgentTranscript.inlineWork.test.ts
index 061d7999da..7633921d65 100644
--- a/src/features/sessions/ui/AgentTranscript.inlineWork.test.ts
+++ b/src/features/sessions/ui/AgentTranscript.inlineWork.test.ts
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { copyMessage } from "../../../platform/tauri/clipboard";
import type { Block } from "../model/session";
import { AgentTranscript, MonoActivityTrail } from "./AgentTranscript";
-import { WORD_FADE_MS } from "./wordFade";
+import { PACED_REVEAL_KEY, WORD_FADE_MS } from "./wordFade";
vi.mock("../../../platform/tauri/clipboard", () => ({
copyMessage: vi.fn().mockResolvedValue(undefined),
@@ -47,14 +47,19 @@ beforeEach(() => {
document.body.append(container);
// Exercise the shipped visibility rule with the real Markdown reveal.
resultStyles = document.createElement("style");
- resultStyles.textContent = readFileSync("src/styles/index.css", "utf8").match(
- /\.transcript-turn:has\(\[data-artifact-results\]\)[^{]+\{[^}]+\}/,
- )![0];
+ resultStyles.textContent = [
+ ...readFileSync("src/styles/index.css", "utf8").matchAll(
+ /\.transcript-turn(?::has\(\[data-artifact-results\]\)| > :has\(\.word-fading\) ~ \[data-launched-sessions\])[^{]*\{[^}]+\}/g,
+ ),
+ ]
+ .map((rule) => rule[0])
+ .join("\n");
document.head.append(resultStyles);
root = createRoot(container);
});
afterEach(() => {
+ localStorage.removeItem(PACED_REVEAL_KEY);
act(() => root.unmount());
container.remove();
resultStyles.remove();
@@ -134,16 +139,23 @@ it("shows a document below its reply in the originating turn and opens it", () =
expect(onOpenArtifact).toHaveBeenCalledWith(card.id);
});
-it("shows sessions beside the footer actions only for the turn that launched them", () => {
- const onShowSessions = vi.fn();
+it("shows launched sessions as cards with live status, only in their turn", () => {
+ const onOpenSession = vi.fn();
const launches = [
{
sessionId: "app-mono-review",
- cwd: "/repo",
+ cwd: "/repo/web",
title: "PR review",
harness: "codex" as const,
model: "gpt-6",
},
+ {
+ sessionId: "app-mono-fix",
+ cwd: "/repo/api",
+ title: "Fix the build",
+ harness: "claude" as const,
+ model: "opus",
+ },
];
const blocks: Block[] = [
{ id: "old", role: "user", text: "Earlier", durationMs: 1000 },
@@ -157,40 +169,264 @@ it("shows sessions beside the footer actions only for the turn that launched the
},
{ id: "answer", role: "assistant", text: "Started your reviewer." },
];
- render(blocks, { onShowSessions, onShowWork: vi.fn(), onSaveNote: vi.fn() });
- const button = container.querySelector(
- '[aria-label="Show sessions"]',
- )!;
- expect(
- container.querySelectorAll('[aria-label="Show sessions"]'),
- ).toHaveLength(1);
+ const launchedSessions = new Map([
+ ["app-mono-review", { state: "working" as const, title: "Review #42" }],
+ ["app-mono-fix", { state: "done" as const, title: "Fix the build" }],
+ ]);
+ render(blocks, {
+ onOpenSession,
+ launchedSessions,
+ onShowWork: vi.fn(),
+ onSaveNote: vi.fn(),
+ });
+ const cards = [
+ ...container.querySelectorAll("[data-launched-session]"),
+ ];
+ expect(cards).toHaveLength(2);
expect(
- button
+ cards[0]
.closest("[data-transcript-turn]")
?.getAttribute("data-transcript-turn"),
).toBe("user");
+ // The live title, its state and project; the rail no longer lists them.
+ expect(cards.map((card) => card.dataset.state)).toEqual(["working", "done"]);
+ expect(cards[0].textContent).toContain("Review #42");
+ expect(cards[0].textContent).toContain("web");
+ expect(cards[0].querySelector(".animate-spin")).not.toBeNull();
+ expect(cards[1].getAttribute("aria-label")).toBe(
+ "Open session Fix the build, done",
+ );
+ expect(container.querySelector('[aria-label="Show sessions"]')).toBeNull();
+ act(() => cards[1].click());
+ expect(onOpenSession).toHaveBeenCalledWith(
+ "user",
+ blocks.slice(2),
+ "app-mono-fix",
+ );
+ render(blocks, {
+ onOpenSession,
+ launchedSessions,
+ openSessionId: "app-mono-fix",
+ });
expect(
- [...button.parentElement!.querySelectorAll("button")].map((entry) =>
- entry.getAttribute("aria-label"),
+ container
+ .querySelector('[data-launched-session="app-mono-fix"]')
+ ?.getAttribute("aria-pressed"),
+ ).toBe("true");
+});
+
+it("keeps launched sessions under the reply that started them when the Mono reports back", () => {
+ const launch = {
+ sessionId: "app-mono-article",
+ cwd: "/repo",
+ title: "Write an article",
+ harness: "codex" as const,
+ model: "gpt-6",
+ };
+ const blocks: Block[] = [
+ {
+ id: "user",
+ role: "user",
+ text: "Write two articles",
+ startedAt: 1000,
+ durationMs: 1000,
+ monoSpawnedSessions: [launch],
+ },
+ tool("start"),
+ { id: "started", role: "assistant", text: "Both are running." },
+ // The app's hidden prompt when the session finishes; the Mono's report
+ // back joins the same message.
+ {
+ id: "completion",
+ role: "user",
+ text: "Session finished",
+ internal: true,
+ appRequestId: "mono-completion-app-mono-article",
+ startedAt: 5000,
+ },
+ tool("read"),
+ { id: "report", role: "assistant", text: "Both finished." },
+ ];
+ render(blocks, { onOpenSession: vi.fn(), agentName: "MonoSkull" });
+ expect(container.querySelectorAll("[data-transcript-turn]")).toHaveLength(1);
+ const started = container.querySelector('[data-chat-message="started"]')!;
+ const cards = container.querySelector("[data-launched-sessions]")!;
+ const report = container.querySelector('[data-chat-message="report"]')!;
+ expect(container.querySelectorAll("[data-launched-sessions]")).toHaveLength(
+ 1,
+ );
+ expect(
+ started.compareDocumentPosition(cards) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ cards.compareDocumentPosition(report) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+});
+
+it("keeps launched session cards in place while the Mono's report back arrives", () => {
+ const launch = {
+ sessionId: "app-mono-article",
+ cwd: "/repo",
+ title: "Write an article",
+ harness: "codex" as const,
+ model: "gpt-6",
+ };
+ const user: Block = {
+ id: "user",
+ role: "user",
+ text: "Write two articles",
+ startedAt: 1000,
+ monoSpawnedSessions: [launch],
+ };
+ const started: Block = {
+ id: "started",
+ role: "assistant",
+ text: "Both are running.",
+ };
+ const completion: Block = {
+ id: "completion",
+ role: "user",
+ text: "Session finished",
+ internal: true,
+ appRequestId: "mono-completion-app-mono-article",
+ startedAt: 5000,
+ };
+ const props = { onOpenSession: vi.fn(), agentName: "MonoCrab" };
+ const cards = () => container.querySelector("[data-launched-sessions]");
+
+ // While the reply streams, the cards wait under it.
+ render([user, tool("start"), { ...started, streaming: true }], {
+ ...props,
+ busy: true,
+ });
+ expect(cards()).toBeNull();
+ render([{ ...user, durationMs: 2000 }, tool("start"), started], props);
+ const shown = cards();
+ expect(shown).not.toBeNull();
+
+ // The session finishing reopens the same message: the cards stay put
+ // through the hidden prompt, the Mono's work and its report.
+ const reopened = [{ ...user, durationMs: 2000 }, tool("start"), started];
+ render([...reopened, completion], { ...props, busy: true });
+ expect(cards()).toBe(shown);
+ render([...reopened, completion, tool("read", "in_progress")], {
+ ...props,
+ busy: true,
+ });
+ expect(cards()).toBe(shown);
+ render(
+ [
+ ...reopened,
+ { ...completion, durationMs: 3000 },
+ tool("read"),
+ { id: "report", role: "assistant", text: "Both finished." },
+ ],
+ props,
+ );
+ expect(cards()).toBe(shown);
+ expect(
+ cards()!.compareDocumentPosition(
+ container.querySelector('[data-chat-message="report"]')!,
+ ) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+});
+
+it("shows reused sessions in the requesting Mono after its handoff reply, before its completion report", () => {
+ const launches = [
+ {
+ id: "app-other-mono-one",
+ title: "Meaning in everyday life",
+ harness: "codex",
+ model: "gpt-6",
+ },
+ {
+ id: "app-other-mono-two",
+ title: "Growth through change",
+ harness: "codex",
+ model: "gpt-6",
+ },
+ ];
+ const receipt = (id: string, action: string, result: unknown): Block => ({
+ ...tool(id),
+ text: `monocode app ${action}`,
+ tool: { status: "completed", detail: JSON.stringify({ ok: true, result }) },
+ });
+ const blocks: Block[] = [
+ { id: "old", role: "user", text: "Earlier task", durationMs: 1000 },
+ { id: "old-answer", role: "assistant", text: "Earlier answer" },
+ {
+ id: "user",
+ role: "user",
+ text: "Write two articles",
+ startedAt: 1000,
+ durationMs: 1000,
+ },
+ receipt("list", "sessions.list", {
+ cwd: "/repo/formshare",
+ sessions: launches,
+ }),
+ // This prose splits the listing and sends into separate folded groups.
+ {
+ id: "assigning",
+ role: "assistant",
+ text: "Assigning two existing sessions.",
+ },
+ ...launches.map((entry) =>
+ receipt(`send-${entry.id}`, "sessions.send", {
+ sessionId: entry.id,
+ submitted: true,
+ notifyOnComplete: true,
+ }),
),
- ).toEqual([
- "Copy response",
- "Save as note",
- "Show activity",
- "Show sessions",
- ]);
- expect(button.title).toBe("Show sessions (1)");
- act(() => button.click());
- expect(onShowSessions).toHaveBeenCalledWith("user", blocks.slice(2));
- render(blocks, { onShowSessions, activeSessionsTurnId: "user" });
+ {
+ id: "started",
+ role: "assistant",
+ text: "Both article sessions are running.",
+ },
+ {
+ id: "completion",
+ role: "user",
+ text: "Both finished",
+ internal: true,
+ appRequestId: "mono-completion-batch-articles",
+ startedAt: 5000,
+ durationMs: 1000,
+ },
+ { id: "report", role: "assistant", text: "Both articles are finished." },
+ ];
+ const onOpenSession = vi.fn();
+ render(blocks, { onOpenSession, agentName: "MonoShroom" });
+ expect(container.querySelectorAll("[data-transcript-turn]")).toHaveLength(2);
+ const cards = container.querySelector("[data-launched-sessions]")!;
+ expect(cards).not.toBeNull();
+ expect(container.querySelectorAll("[data-launched-session]")).toHaveLength(2);
+ expect(
+ cards
+ .closest("[data-transcript-turn]")
+ ?.getAttribute("data-transcript-turn"),
+ ).toBe("user");
expect(
container
- .querySelector('[aria-label="Hide sessions"]')
- ?.getAttribute("aria-expanded"),
- ).toBe("true");
+ .querySelector('[data-chat-message="started"]')!
+ .compareDocumentPosition(cards) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ cards.compareDocumentPosition(
+ container.querySelector('[data-chat-message="report"]')!,
+ ) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ act(() =>
+ cards.querySelector("[data-launched-session]")!.click(),
+ );
+ expect(onOpenSession).toHaveBeenCalledWith(
+ "user",
+ blocks.slice(2),
+ launches[0].id,
+ );
});
it("waits for the final reply's reveal, then shows its artifact before the actions", () => {
+ localStorage.setItem(PACED_REVEAL_KEY, "1");
const user: Block = {
id: "user",
role: "user",
@@ -208,14 +444,15 @@ it("waits for the final reply's reveal, then shows its artifact before the actio
],
};
const actions = {
- onShowSessions: vi.fn(),
+ onOpenSession: vi.fn(),
onShowWork: vi.fn(),
agentName: "MonoInvader",
};
render([user, tool("call", "in_progress")], { ...actions, busy: true });
expect(container.querySelector("[data-turn-actions]")).toBeNull();
expect(container.querySelector("[data-artifact-results]")).toBeNull();
- expect(container.querySelector('[aria-label="Show sessions"]')).toBeNull();
+ // Session cards wait for the reply, so it never lands above them.
+ expect(container.querySelector("[data-launched-sessions]")).toBeNull();
expect(
container.querySelector('[data-mono-work] [aria-label="Show activity"]'),
).not.toBeNull();
@@ -245,9 +482,11 @@ it("waits for the final reply's reveal, then shows its artifact before the actio
);
const answer = container.querySelector('[data-chat-message="answer"]')!;
const cards = container.querySelector("[data-artifact-results]")!;
+ const sessions = container.querySelector("[data-launched-sessions]")!;
const footer = container.querySelector("[data-turn-actions]")!;
expect(answer.textContent).toBe("");
expect(getComputedStyle(cards).display).toBe("none");
+ expect(getComputedStyle(sessions).display).toBe("none");
expect(getComputedStyle(footer).display).toBe("none");
act(() => vi.advanceTimersByTime(100));
expect(answer.textContent!.length).toBeGreaterThan(0);
@@ -266,11 +505,21 @@ it("waits for the final reply's reveal, then shows its artifact before the actio
getComputedStyle(finished.querySelector("[data-artifact-results]")!)
.display,
).not.toBe("none");
+ expect(
+ getComputedStyle(finished.querySelector("[data-launched-sessions]")!)
+ .display,
+ ).not.toBe("none");
expect(
getComputedStyle(finished.querySelector("[data-turn-actions]")!).display,
).not.toBe("none");
finished.remove();
- expect(footer.querySelector('[aria-label="Show sessions"]')).not.toBeNull();
+ // Reply, then its sessions, then its artifacts.
+ expect(
+ answer.compareDocumentPosition(sessions) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ sessions.compareDocumentPosition(cards) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
expect(footer.querySelector('[aria-label="Show activity"]')).not.toBeNull();
expect(
answer.compareDocumentPosition(cards) & Node.DOCUMENT_POSITION_FOLLOWING,
@@ -658,6 +907,34 @@ it("keeps interrupted Mono messages together above one working indicator", () =>
expect(container.querySelectorAll(".transcript-turn")).toHaveLength(2);
});
+it("stacks the next message onto one the Mono only reacted to", () => {
+ const props = {
+ agentName: "Captain",
+ agentMascot: { mascot: "cat" as const, color: "#6ba" },
+ };
+ const blocks: Block[] = [
+ { id: "thanks", role: "user", text: "thanks :)", startedAt: 1000 },
+ { id: "reaction", role: "assistant", text: "😊 " },
+ { id: "next", role: "user", text: "one more thing", startedAt: 2000 },
+ ];
+ render(blocks, props);
+ const stacked = container.querySelectorAll(
+ '[data-message-stack="true"]',
+ );
+ expect([...stacked].map((row) => row.dataset.promptAnchor)).toEqual([
+ "thanks",
+ ]);
+ expect(container.querySelector('[data-mono-reaction="😊"]')).not.toBeNull();
+ // It opens a turn, but sits as close as a message sent mid-turn.
+ expect(
+ container
+ .querySelector('[data-prompt-anchor="next"]')!
+ .classList.contains("pt-1"),
+ ).toBe(true);
+ render(blocks.slice(0, 2), props);
+ expect(container.querySelector('[data-message-stack="true"]')).toBeNull();
+});
+
it("shows answers to mid-turn follow-ups immediately and retains them during later work", () => {
const props = { busy: true, agentName: "MonoInvader" };
const blocks: Block[] = [
diff --git a/src/features/sessions/ui/AgentTranscript.test.ts b/src/features/sessions/ui/AgentTranscript.test.ts
index 40badb8a9e..daf47092cd 100644
--- a/src/features/sessions/ui/AgentTranscript.test.ts
+++ b/src/features/sessions/ui/AgentTranscript.test.ts
@@ -760,46 +760,6 @@ describe("AgentTranscript collapsed work", () => {
);
});
- it("keeps a Mono's changes action on the turn that edited files", () => {
- const blocks: Block[] = [
- {
- id: "edit-user",
- role: "user",
- text: "Write the report",
- startedAt: 1_000,
- durationMs: 500,
- },
- {
- id: "edit",
- role: "tool",
- text: "Write report.md",
- tool: { kind: "edit", status: "completed" },
- },
- { id: "edit-answer", role: "assistant", text: "Report written." },
- {
- id: "later-user",
- role: "user",
- text: "Rerun the checks",
- startedAt: 2_000,
- durationMs: 500,
- },
- { id: "later-answer", role: "assistant", text: "Checks rerunning." },
- ];
- const markup = renderToStaticMarkup(
- createElement(AgentTranscript, {
- blocks,
- editTurnAction: createElement("button", {}, "+7 -0"),
- }),
- );
- const action = markup.indexOf("+7 -0");
- expect(markup.match(/\+7 -0/g)).toHaveLength(1);
- expect(action).toBeGreaterThan(markup.indexOf("Report written."));
- expect(action).toBeLessThan(markup.indexOf("Rerun the checks"));
- expect(markup.lastIndexOf("data-turn-actions", action)).toBeGreaterThan(
- markup.indexOf("Report written."),
- );
- });
-
it("renders an advisor interjection between answered work phases", () => {
// Live: the interjection lands on its own labeled row. Once the turn
// settles it folds into the work trail — covered below.
diff --git a/src/features/sessions/ui/AgentTranscript.tsx b/src/features/sessions/ui/AgentTranscript.tsx
index ada8c26618..01e3a448e9 100644
--- a/src/features/sessions/ui/AgentTranscript.tsx
+++ b/src/features/sessions/ui/AgentTranscript.tsx
@@ -1,6 +1,5 @@
import {
ArrowUp,
- Chatting,
Check,
ChevronRight,
CircleDashed,
@@ -55,7 +54,12 @@ import { Popover } from "../../../shared/ui/Popover";
import { ProjectMascot } from "../../projects/ui/ProjectMascot";
import { PixelMascot } from "../../projects/ui/PixelMascot";
import type { MonoLook } from "../../monos/model/mono";
-import { monoSpawnedSessions } from "../../monos/model/monoSpawnedSessions";
+import {
+ monoSpawnedSessions,
+ type LaunchedSessionView,
+} from "../../monos/model/monoSpawnedSessions";
+import type { MonoSpawnedSession } from "../model/session";
+import { LaunchedSessionCard } from "../../monos/ui/LaunchedSessionCard";
import type { MessageDelivery } from "../../monos/model/monoMessaging";
import { monoReactionBlocks } from "../../monos/model/monoReaction";
import type { ApprovalDecision } from "../../../integrations/harness";
@@ -207,8 +211,12 @@ type Props = {
/** Inspect a Mono turn in the activity sidebar. */
onShowWork?: (turnId: string, blocks: Block[]) => void;
activeWorkTurnId?: string;
- onShowSessions?: (turnId: string, blocks: Block[]) => void;
- activeSessionsTurnId?: string;
+ /** Open one of a Mono turn's launched sessions in the sidebar. */
+ onOpenSession?: (turnId: string, blocks: Block[], sessionId: string) => void;
+ /** Launched sessions' live status by id, where this window tracks it. */
+ launchedSessions?: ReadonlyMap;
+ /** The launched session open in the sidebar. */
+ openSessionId?: string;
/** Marks when each message was sent, for one conversation kept over days. */
daySeparators?: boolean;
/** Leaves token and speed details out of each turn's footer. */
@@ -245,11 +253,6 @@ type Props = {
) => void;
/** Session-level output shown after the latest reply and before its action row. */
latestTurnAccessory?: ReactNode;
- /**
- * Session-level control in the action row of the latest turn that edited
- * files. A Mono's chat never ends, so its review stays where it began.
- */
- editTurnAction?: ReactNode;
/** False while another tab is in front; local transcript state is retained. */
visible?: boolean;
/** Kept mounted after its pane closed. Showing it again counts as a new visit. */
@@ -278,8 +281,9 @@ function AgentTranscriptComponent({
onRetryMessage,
onShowWork,
activeWorkTurnId,
- onShowSessions,
- activeSessionsTurnId,
+ onOpenSession,
+ launchedSessions,
+ openSessionId,
daySeparators = false,
hideTurnMetrics = false,
harness,
@@ -308,7 +312,6 @@ function AgentTranscriptComponent({
onRevealReady,
onNavigateReady,
latestTurnAccessory,
- editTurnAction,
visible = true,
parked = false,
@@ -628,13 +631,7 @@ function AgentTranscriptComponent({
scrollerEl.removeEventListener("touchstart", onTouchStart);
scrollerEl.removeEventListener("touchmove", onTouchMove);
};
- }, [
- scrollerEl,
- followTranscript,
- setShowJump,
- syncPinned,
- visible,
- ]);
+ }, [scrollerEl, followTranscript, setShowJump, syncPinned, visible]);
useLayoutEffect(() => {
stickToBottom.current = true;
@@ -734,11 +731,6 @@ function AgentTranscriptComponent({
turn[0].monoHabit || turn[0].role === "handoff" ? latest : index,
-1,
);
- const hasEditTurnAction = editTurnAction != null;
- const editTurnIndex = useMemo(
- () => (hasEditTurnAction ? lastEditTurnIndex(turns) : -1),
- [hasEditTurnAction, turns],
- );
const firstVisibleTurn = Math.max(0, turns.length - visibleTurnCount);
const visibleTurns = turns.slice(firstVisibleTurn);
const turnsRef = useRef(turns);
@@ -1001,12 +993,15 @@ function AgentTranscriptComponent({
refreshChatMotion.current = useBottomChatMotion(
scrollerEl,
- visible && bottomAligned,
+ visible,
stickToBottom,
blocks,
- !!busy && userTurnCount(blocks, managed) === 1,
+ bottomAligned && !!busy && userTurnCount(blocks, managed) === 1,
!!busy,
historicalBlockIds,
+ bottomAligned,
+ wheelHold,
+ bottomAligned,
);
return (
@@ -1142,6 +1137,32 @@ function AgentTranscriptComponent({
// leaving the prompt and the answer to it.
const turnId = turn[0].id;
const spawnedSessions = inlineWork ? monoSpawnedSessions(turn) : [];
+ const launchCards = launchAnchors(items, spawnedSessions);
+ const renderLaunches = (
+ launches: readonly MonoSpawnedSession[],
+ key: string,
+ spacing: string,
+ ) => (
+
+ {launches.map((launch) => (
+ onOpenSession(turnId, turn, launch.sessionId)
+ : undefined
+ }
+ />
+ ))}
+
+ );
const fold = inlineWork ? undefined : foldableWork(items);
const folded = fold ? foldedBlocks(items, fold) : [];
const summarizedWork = inlineWork
@@ -1150,11 +1171,6 @@ function AgentTranscriptComponent({
)
: folded;
const workOpen = openWork[turnId] ?? false;
- // The action keeps the pane's props, which go stale once parked.
- const turnEditAction =
- firstVisibleTurn + turnIndex === editTurnIndex && !parked
- ? editTurnAction
- : undefined;
// The fold line is the turn's status line from the first token to
// the last: the mark, and the clock beside it. It never moves, so a
// turn settling does not shuffle the layout around the answer.
@@ -1237,14 +1253,29 @@ function AgentTranscriptComponent({
item.type === "block"
? item.block.id === searchCurrent
: item.blocks.some((block) => block.id === searchCurrent);
+ // A reaction hides the reply between two messages, so the next
+ // turn's message stacks onto this one as if nothing came between.
+ const stacksIntoNextTurn =
+ reactedOnly &&
+ stacksAfterReaction(
+ turn,
+ turns[firstVisibleTurn + turnIndex + 1],
+ daySeparators,
+ );
+ const stackedOnPreviousTurn =
+ inlineWork &&
+ !!previousTurn &&
+ previousTurn.every((block) => block.role === "user") &&
+ stacksAfterReaction(previousTurn, turn, daySeparators);
const isCompactFollowUp = (item: TurnItem, index: number) => {
const next = items[index + 1];
return (
inlineWork &&
item.type === "block" &&
item.block.role === "user" &&
- next?.type === "block" &&
- next.block.role === "user"
+ (next
+ ? next.type === "block" && next.block.role === "user"
+ : stacksIntoNextTurn)
);
};
const renderItem = (item: TurnItem, itemIndex: number) =>
@@ -1300,7 +1331,13 @@ function AgentTranscriptComponent({
}
layout={transcriptLayout}
bubbleTail={!!agentMascot}
+ localMedia={isMonoChat}
compactFollowUp={isCompactFollowUp(item, itemIndex)}
+ stackedOnPrevious={
+ itemIndex === 0
+ ? stackedOnPreviousTurn
+ : isCompactFollowUp(items[itemIndex - 1], itemIndex - 1)
+ }
delivery={messageDeliveries?.get(item.block.id)}
onRetryMessage={onRetryMessage}
visible={item.block.role === "user" ? visible : undefined}
@@ -1376,15 +1413,14 @@ function AgentTranscriptComponent({
workExpanded={activeWorkTurnId === turnId}
searchCurrent={
turn.some((block) => block.id === searchCurrent) &&
- !items.some(
- (item) =>
- item.type === "block"
- ? item.block.id === searchCurrent
- : item.blocks.some(
- (block) =>
- block.id === searchCurrent &&
- needsApproval(block),
- ),
+ !items.some((item) =>
+ item.type === "block"
+ ? item.block.id === searchCurrent
+ : item.blocks.some(
+ (block) =>
+ block.id === searchCurrent &&
+ needsApproval(block),
+ ),
)
}
/>
@@ -1406,7 +1442,9 @@ function AgentTranscriptComponent({
);
- if (itemIndex !== foldLineAt) return row;
- return [foldLineRow, row];
+ // A launch's cards follow the reply after it once that reply
+ // has finished streaming (CSS holds them through its fade).
+ // That never reverts, so a report back reopening the turn
+ // does not take them away again.
+ const launches = launchCards.afterItem.get(itemIndex);
+ const cards =
+ launches &&
+ (settled ||
+ itemIndex < items.length - 1 ||
+ (item.type === "block" && !item.block.streaming))
+ ? [
+ renderLaunches(
+ launches,
+ `launches-${turnItemKey(item)}`,
+ "pb-3",
+ ),
+ ]
+ : [];
+ if (itemIndex !== foldLineAt) return [row, ...cards];
+ return [foldLineRow, row, ...cards];
})
.concat(foldLineAt >= items.length ? [foldLineRow] : [])}
{settled &&
@@ -1505,6 +1561,14 @@ function AgentTranscriptComponent({
))}
+ {/* Launches no reply followed wait for the turn to end. */}
+ {settled && launchCards.atEnd.length > 0
+ ? renderLaunches(
+ launchCards.atEnd,
+ "launches-end",
+ artifacts.length > 0 ? "pb-1" : "pb-3",
+ )
+ : null}
{settled && artifacts.length > 0 ? (
= 0) ||
- (spawnedSessions.length > 0 && onShowSessions)) ? (
+ (inlineWork && firstWork >= 0)) ? (
onShowSessions(turnId, turn)
- : undefined
- }
- sessionsExpanded={activeSessionsTurnId === turnId}
- sessionCount={spawnedSessions.length}
harness={turnHarness}
fromHarness={turnHarness}
fromModel={turnModel?.id}
@@ -1578,7 +1634,6 @@ function AgentTranscriptComponent({
onHandoff={
onHandoff ? (target) => onHandoff(target, turn) : undefined
}
- extraAction={turnEditAction}
/>
) : null}
@@ -1604,13 +1659,16 @@ function TranscriptContent({
bottomAligned: boolean;
children: ReactNode;
}) {
+ // The clip keeps the follow motion's in-flight offset out of scrollHeight.
if (!bottomAligned) {
return (
-
- {children}
+
);
}
@@ -1752,14 +1810,11 @@ function TurnDuration({
onSaveNote,
onShowWork,
workExpanded,
- onShowSessions,
- sessionsExpanded,
- sessionCount,
fromHarness,
fromModel,
onSecondOpinion,
onHandoff,
- extraAction,
+ revealOnHover = false,
}: {
elapsedMs: number | null;
label?: string;
@@ -1773,15 +1828,13 @@ function TurnDuration({
onSaveNote?: (text: string) => void | Promise
;
onShowWork?: () => void;
workExpanded?: boolean;
- onShowSessions?: () => void;
- sessionsExpanded?: boolean;
- sessionCount?: number;
fromHarness?: HarnessId;
/** The turn's own model, so a same-harness second opinion can hide it. */
fromModel?: string;
onSecondOpinion?: (target: ModelTarget) => void;
onHandoff?: (target: ModelTarget) => void;
- extraAction?: ReactNode;
+ /** Sit in the gap under the turn and show on hover, as a sent message's do. */
+ revealOnHover?: boolean;
}) {
const label =
completionLabel ?? formatWorkingDuration(elapsedMs, modelName, true);
@@ -1795,7 +1848,7 @@ function TurnDuration({
{output ? (
@@ -1820,19 +1873,6 @@ function TurnDuration({
) : null}
- {onShowSessions ? (
-
-
- {sessionCount}
-
- ) : null}
{fromHarness && onHandoff ? (
) : null}
@@ -1845,7 +1885,6 @@ function TurnDuration({
excludeFromModel
/>
) : null}
- {extraAction}
{labelHidden ? null : (
@@ -2135,6 +2174,7 @@ const TranscriptBlock = memo(function TranscriptBlock({
layout,
bubbleTail = false,
compactFollowUp = false,
+ stackedOnPrevious = false,
delivery,
onRetryMessage,
visible,
@@ -2143,6 +2183,7 @@ const TranscriptBlock = memo(function TranscriptBlock({
underLine = false,
embedded = false,
cwd,
+ localMedia = false,
onApproval,
onSaveNote,
onSendDraft,
@@ -2162,6 +2203,8 @@ const TranscriptBlock = memo(function TranscriptBlock({
layout: TranscriptLayout;
bubbleTail?: boolean;
compactFollowUp?: boolean;
+ /** True when this message stacks onto the one above it. */
+ stackedOnPrevious?: boolean;
delivery?: MessageDelivery;
onRetryMessage?: (blockId: string) => void;
visible?: boolean;
@@ -2172,6 +2215,8 @@ const TranscriptBlock = memo(function TranscriptBlock({
/** True when the parent surface already provides the horizontal gutter. */
embedded?: boolean;
cwd?: string;
+ /** Show local images and videos a reply links to, as Mono chats do. */
+ localMedia?: boolean;
onApproval?: (requestId: number, decision: ApprovalDecision) => void;
onSaveNote?: (text: string) => void | Promise;
onSendDraft?: (block: Block) => boolean | void;
@@ -2194,6 +2239,7 @@ const TranscriptBlock = memo(function TranscriptBlock({
layout={layout}
bubbleTail={bubbleTail}
compactFollowUp={compactFollowUp}
+ stackedOnPrevious={stackedOnPrevious}
delivery={delivery}
onRetryMessage={onRetryMessage}
visible={visible ?? true}
@@ -2315,6 +2361,7 @@ const TranscriptBlock = memo(function TranscriptBlock({
revealOnMount={revealOnMount}
cwd={cwd}
onOpenFile={onOpenFile}
+ localMedia={localMedia}
/>
);
@@ -2322,7 +2369,7 @@ const TranscriptBlock = memo(function TranscriptBlock({
/**
* A Mono's emoji answer, on the corner of the message it answers. The top
- * left keeps it clear of the bubble's tail and the hover actions below.
+ * left keeps it clear of the hover actions below.
*/
function MonoReactionBadge({ emoji, live }: { emoji: string; live: boolean }) {
return (
@@ -2330,11 +2377,9 @@ function MonoReactionBadge({ emoji, live }: { emoji: string; live: boolean }) {
role="img"
aria-label={`Reacted ${emoji}`}
data-mono-reaction={emoji}
- className={`${live ? "mono-reaction-in " : ""}absolute -top-4 -left-2 z-[1] rounded-full bg-background-base p-0.5 leading-none`}
+ className={`${live ? "mono-reaction-in " : ""}mono-reaction-badge absolute -top-2.5 -left-2.5 z-[1] grid h-[22px] place-items-center rounded-[8px] px-[7px] font-sans text-[11px] leading-none`}
>
-
- {emoji}
-
+ {emoji}
);
}
@@ -2344,6 +2389,7 @@ function UserMessageBlock({
layout,
bubbleTail = false,
compactFollowUp = false,
+ stackedOnPrevious = false,
delivery,
onRetryMessage,
visible,
@@ -2359,6 +2405,7 @@ function UserMessageBlock({
layout: TranscriptLayout;
bubbleTail?: boolean;
compactFollowUp?: boolean;
+ stackedOnPrevious?: boolean;
delivery?: MessageDelivery;
onRetryMessage?: (blockId: string) => void;
visible: boolean;
@@ -2391,7 +2438,7 @@ function UserMessageBlock({
const messageAt = block.sentAt ?? block.startedAt;
const chatSpacing = compactFollowUp
? "pt-1 pb-0"
- : bubbleTail && block.sentAt != null
+ : bubbleTail && (block.sentAt != null || stackedOnPrevious)
? "pt-1 pb-5"
: "pt-1.5 pb-5";
// A Mono's chat shows sent images above the bubble, like photos in a
@@ -2582,10 +2629,6 @@ function UserMessageBlock({
? "border border-dashed border-content/30 bg-content/4"
: "bg-content/10"
} ${editing ? "edit-last-turn-bubble" : ""} ${
- bubbleTail && chat && !block.draft && !compactFollowUp
- ? `agent-chat-bubble-tail-right ${singleLine ? "[--agent-bubble-radius:1.125rem]" : "[--agent-bubble-radius:0.75rem]"}`
- : ""
- } ${
chat
? `w-fit max-w-[min(100%,36rem)] ${singleLine ? "rounded-full" : "rounded-xl"}`
: "rounded-lg border border-content/10"
@@ -2811,6 +2854,49 @@ function TurnRow({
}
/** A turn item's identity, stable as the group it names grows. */
+/**
+ * Where a turn's launched sessions go: each after the first reply that came
+ * after its launch, so a Mono reporting back later in the same turn writes
+ * below them, as it happened. A launch no reply has followed goes at the end.
+ */
+function launchAnchors(
+ items: readonly TurnItem[],
+ launches: readonly MonoSpawnedSession[],
+): {
+ afterItem: Map;
+ atEnd: MonoSpawnedSession[];
+} {
+ const afterItem = new Map();
+ const atEnd: MonoSpawnedSession[] = [];
+ if (launches.length === 0) return { afterItem, atEnd };
+ const launched = items.map((item) =>
+ monoSpawnedSessions(
+ item.type === "block" ? [item.block] : item.blocks,
+ launches,
+ ).map((entry) => entry.sessionId),
+ );
+ for (const launch of launches) {
+ // The tool call that started it; the prompt also lists it, as a fallback.
+ const isPrompt = (index: number) => {
+ const item = items[index];
+ return item.type === "block" && item.block.role === "user";
+ };
+ const has = (index: number) => launched[index].includes(launch.sessionId);
+ let at = items.findIndex((_, index) => has(index) && !isPrompt(index));
+ if (at < 0) at = items.findIndex((_, index) => has(index));
+ const reply =
+ at < 0
+ ? -1
+ : items.findIndex(
+ (item, index) =>
+ index > at && item.type === "block" && isProseBlock(item.block),
+ );
+ if (reply < 0) atEnd.push(launch);
+ else afterItem.set(reply, [...(afterItem.get(reply) ?? []), launch]);
+ }
+ return { afterItem, atEnd };
+}
+
function turnItemKey(item: TurnItem): string {
return item.type === "block" ? item.block.id : item.blocks[0].id;
}
@@ -3095,6 +3181,7 @@ function useLivePhaseScroll(
);
if (stickToBottom.current) el.scrollTop = el.scrollHeight;
lastScrollTop.current = el.scrollTop;
+ markScrollEdges(el);
}, [el]);
useLayoutEffect(() => {
@@ -3120,6 +3207,7 @@ function useLivePhaseScroll(
stickToBottom.current,
);
lastScrollTop.current = el.scrollTop;
+ markScrollEdges(el);
};
const onWheel = (e: WheelEvent) => {
if (!nestedScrollAbsorbsWheel(el, e.deltaY)) return;
@@ -3141,6 +3229,13 @@ function useLivePhaseScroll(
}, [el, enabled, pin]);
}
+/** Flags the edges with more to scroll to, so only those fade. */
+function markScrollEdges(el: HTMLElement) {
+ const below = el.scrollHeight - el.clientHeight - el.scrollTop;
+ el.dataset.fadeTop = String(el.scrollTop > 1);
+ el.dataset.fadeBottom = String(below > 1);
+}
+
/** The live ticker shares the identity line instead of adding a work row. */
function MonoTurnHeader({
blocks,
@@ -3173,7 +3268,10 @@ function MonoTurnHeader({
() => blocks.filter((block) => !block.internal && !block.draft),
[blocks],
);
- let status = useMemo(() => monoWorkStatus(activity, active), [activity, active]);
+ let status = useMemo(
+ () => monoWorkStatus(activity, active),
+ [activity, active],
+ );
if (active && waitingForAnswers && !activity.some(needsApproval)) {
status = { ...status, key: "question", label: "Waiting for answers…" };
} else if (active && backgroundTasks?.length && status.kind === "think") {
@@ -3342,7 +3440,7 @@ export function MonoActivityTrail({
!!block.monoReaction) &&
+ start.role === "user" &&
+ !start.internal &&
+ !start.monoHabit &&
+ !(
+ daySeparators &&
+ start.startedAt != null &&
+ opensNewStretch(start.startedAt, monoTurnLatestStart(turn))
+ )
+ );
+}
+
function monoTurnUserBlock(
blocks: Block[],
deliveries?: ReadonlyMap,
@@ -5013,23 +5136,6 @@ function monoTurnUserBlock(
return blocks.find((block) => block.role === "user");
}
-/** Matches the edit tools whose files the session's review records. */
-function lastEditTurnIndex(turns: Block[][]): number {
- for (let index = turns.length - 1; index >= 0; index--) {
- const edited = turns[index].some(
- (block) =>
- block.role === "tool" &&
- isEditTool(
- block.tool?.kind,
- block.text || block.tool?.title,
- block.tool?.preview,
- ),
- );
- if (edited) return index;
- }
- return -1;
-}
-
function sumDurations(durations: (number | undefined)[]): number | undefined {
const known = durations.filter((ms): ms is number => ms != null);
return known.length ? known.reduce((total, ms) => total + ms, 0) : undefined;
@@ -5118,7 +5224,7 @@ function pinToBottom(el: HTMLElement | null) {
/** Keep the live turn's min-height in lockstep with the visible transcript. */
function syncTranscriptViewport(el: HTMLElement | null) {
if (!el || el.clientHeight <= 0) return;
- const inner = el.firstElementChild as HTMLElement | null;
+ const inner = el.querySelector("[data-transcript-content]");
const pad = inner
? Number.parseFloat(getComputedStyle(inner).paddingBottom) || 0
: 0;
diff --git a/src/features/sessions/ui/AgentTranscriptPacing.test.ts b/src/features/sessions/ui/AgentTranscriptPacing.test.ts
index 8c95e4a3f2..61cb538a96 100644
--- a/src/features/sessions/ui/AgentTranscriptPacing.test.ts
+++ b/src/features/sessions/ui/AgentTranscriptPacing.test.ts
@@ -4,7 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Block } from "../model/session";
import { AgentTranscript } from "./AgentTranscript";
-import { WORD_FADE_MS } from "./wordFade";
+import { PACED_REVEAL_KEY, WORD_FADE_MS } from "./wordFade";
let container: HTMLDivElement;
let root: Root;
@@ -27,12 +27,15 @@ beforeEach(() => {
disconnect() {}
},
);
+ // These cover the opt-in paced reveal unless a test says otherwise.
+ localStorage.setItem(PACED_REVEAL_KEY, "1");
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
+ localStorage.removeItem(PACED_REVEAL_KEY);
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
@@ -217,3 +220,21 @@ it("shows output received in a hidden tab immediately when switching to it", ()
expect(answerText()).toBe(reply);
expect(container.querySelector(".word-fading")).toBeNull();
});
+
+it("shows a new reply at once and fades it in without the paced reveal", () => {
+ localStorage.removeItem(PACED_REVEAL_KEY);
+ render([prompt]);
+ render(
+ [
+ prompt,
+ { id: "answer", role: "assistant", text: reply, streaming: false },
+ ],
+ false,
+ );
+ expect(answerText()).toBe(reply);
+ expect(container.querySelector(".word-fading")).not.toBeNull();
+
+ act(() => vi.advanceTimersByTime(400));
+ expect(answerText()).toBe(reply);
+ expect(container.querySelector(".word-fading")).toBeNull();
+});
diff --git a/src/features/sessions/ui/ContextMeter.tsx b/src/features/sessions/ui/ContextMeter.tsx
index 4b0eec42e1..0f53e5b663 100644
--- a/src/features/sessions/ui/ContextMeter.tsx
+++ b/src/features/sessions/ui/ContextMeter.tsx
@@ -23,22 +23,32 @@ function ringClass(ratio: number): string {
*
* Renders nothing until the harness reports both halves — Cursor's ACP stream
* carries no usage at all, and a ring guessing at a number is worse than no
- * ring.
+ * ring. A conversation with nothing in it yet is the exception: its context
+ * is known to be empty, so `empty` shows a bare ring.
*/
export function ContextMeter({
usage,
onCompact,
compactDisabled = false,
+ empty = false,
}: {
usage?: ContextUsage;
onCompact?: () => void;
compactDisabled?: boolean;
+ /** The conversation has no messages yet. */
+ empty?: boolean;
}) {
const [hovered, setHovered] = useState(false);
const [open, setOpen] = useState(false);
const root = useRef(null);
const ratio = contextRatio(usage);
- if (!usage || ratio === null) return null;
+ if (!usage || ratio === null) {
+ return empty ? (
+
+
+
+ ) : null;
+ }
const { headline, detail } = contextTooltip(usage);
const actionsOpen = open && onCompact != null;
@@ -118,18 +128,21 @@ function MeterRing({ ratio, label }: { ratio: number; label?: string }) {
strokeWidth={STROKE}
className="opacity-25"
/>
-
+ {/* A round cap would leave a dot on an empty ring. */}
+ {ratio > 0 ? (
+
+ ) : null}
);
}
diff --git a/src/features/sessions/ui/LocalMedia.tsx b/src/features/sessions/ui/LocalMedia.tsx
new file mode 100644
index 0000000000..3b73792033
--- /dev/null
+++ b/src/features/sessions/ui/LocalMedia.tsx
@@ -0,0 +1,127 @@
+import { convertFileSrc, invoke } from "@tauri-apps/api/core";
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { readBinaryFile, REMOTE_PATH_PREFIX } from "../../../platform/tauri/fs";
+import {
+ isImagePath,
+ sniffImageMime,
+ videoMimeForPath,
+} from "../../files/model/filePreview";
+
+/** Whether a path names a file `LocalMedia` can show. */
+export function isLocalMediaPath(path: string): boolean {
+ return isImagePath(path) || videoMimeForPath(path) !== null;
+}
+
+type Media = { url: string; kind: "image" | "video" };
+
+/**
+ * A local image or video, shown in place of the reply's link to it. Files load
+ * once they scroll near view, so a long history doesn't read every file up
+ * front. Local videos stream through the asset protocol, which allows just that
+ * file, so their size doesn't matter; images, and files on a connected
+ * machine, are read whole within the preview limit. Images are sniffed and
+ * videos take only an explicit video MIME, as in the file viewer, so neither
+ * can become a document. Anything unreadable falls back to the link it
+ * replaced.
+ */
+export function LocalMedia({
+ path,
+ label,
+ onOpen,
+ fallback,
+}: {
+ path: string;
+ label: string;
+ onOpen?: () => void;
+ fallback: ReactNode;
+}) {
+ const boxRef = useRef(null);
+ const [near, setNear] = useState(false);
+ const [media, setMedia] = useState(null);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ const box = boxRef.current;
+ if (!box || near) return;
+ if (typeof IntersectionObserver === "undefined") {
+ setNear(true);
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) setNear(true);
+ },
+ { rootMargin: "400px" },
+ );
+ observer.observe(box);
+ return () => observer.disconnect();
+ }, [near]);
+
+ useEffect(() => {
+ if (!near) return;
+ let cancelled = false;
+ let created: string | null = null;
+ setMedia(null);
+ setFailed(false);
+ const fail = () => {
+ if (!cancelled) setFailed(true);
+ };
+ if (videoMimeForPath(path) && !path.startsWith(REMOTE_PATH_PREFIX)) {
+ invoke("allow_video_stream", { path }).then((allowed) => {
+ if (!cancelled)
+ setMedia({ url: convertFileSrc(allowed), kind: "video" });
+ }, fail);
+ return () => {
+ cancelled = true;
+ };
+ }
+ readBinaryFile(path).then((bytes) => {
+ if (cancelled) return;
+ const video = videoMimeForPath(path);
+ const mime = video ?? sniffImageMime(bytes);
+ if (!mime) {
+ setFailed(true);
+ return;
+ }
+ created = URL.createObjectURL(new Blob([bytes], { type: mime }));
+ setMedia({ url: created, kind: video ? "video" : "image" });
+ }, fail);
+ return () => {
+ cancelled = true;
+ if (created) URL.revokeObjectURL(created);
+ };
+ }, [near, path]);
+
+ if (failed) return <>{fallback}>;
+ // One fixed 16:9 box from placeholder to playback, so loading never moves
+ // the chat; media fits inside it, letterboxed.
+ return (
+
+ {!media ? null : media.kind === "video" ? (
+ setFailed(true)}
+ />
+ ) : (
+ setFailed(true)}
+ className={`block size-full object-contain ${onOpen ? "cursor-zoom-in" : ""}`}
+ />
+ )}
+
+ );
+}
diff --git a/src/features/sessions/ui/MarkdownModeToggle.tsx b/src/features/sessions/ui/MarkdownModeToggle.tsx
index a3e7bda062..e1ead0bc16 100644
--- a/src/features/sessions/ui/MarkdownModeToggle.tsx
+++ b/src/features/sessions/ui/MarkdownModeToggle.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, type ReactNode } from "react";
+import { useEffect, useRef, useState, type ReactNode } from "react";
export type MarkdownViewMode = "preview" | "source";
@@ -11,8 +11,12 @@ export function useMarkdownMode(
const [mode, setMode] = useState(
() => remembered.get(key) ?? fallback,
);
+ // A changed fallback applies to the next key, not the item already shown.
+ const shownKey = useRef(key);
useEffect(() => {
+ if (shownKey.current === key) return;
+ shownKey.current = key;
setMode(remembered.get(key) ?? fallback);
}, [key, fallback]);
diff --git a/src/features/sessions/ui/ModelPicker.test.ts b/src/features/sessions/ui/ModelPicker.test.ts
index 317e4267cf..26f156c15c 100644
--- a/src/features/sessions/ui/ModelPicker.test.ts
+++ b/src/features/sessions/ui/ModelPicker.test.ts
@@ -224,6 +224,36 @@ describe("model picker", () => {
expect(onChange).not.toHaveBeenCalled();
});
+ it.each([
+ ["claude:opus-5", "Claude Opus 5 (1M context)", "Opus 5 (1M context)"],
+ [
+ "claude:opus-5-5",
+ "Claude Opus 5.5 (1M context)",
+ "Opus 5.5 (1M context)",
+ ],
+ ["claude:opus-5-5", "Opus 5.5 (1M context)", "Opus 5.5 (1M context)"],
+ ])("keeps %s version and context qualifier visible", (id, name, visible) => {
+ setHarnessModels("claude", [{ id, harness: "claude", name }]);
+ act(() =>
+ root.render(
+ createElement(ModelPicker, {
+ harness: "claude",
+ model: id,
+ values: {},
+ onChange: vi.fn(),
+ onSettingsChange: vi.fn(),
+ }),
+ ),
+ );
+
+ const trigger = container.querySelector(
+ 'button[aria-haspopup="menu"]',
+ )!;
+ expect(trigger.querySelector("span")?.textContent).toBe(visible);
+ expect(trigger.getAttribute("aria-label")).toContain(name);
+ expect(trigger.title).toContain(name);
+ });
+
it("lists Claude Opus 5.5 after Opus 5 in the built-in Claude catalog", () => {
act(() =>
root.render(
@@ -240,7 +270,9 @@ describe("model picker", () => {
const trigger = container.querySelector(
'button[aria-haspopup="menu"]',
)!;
- expect(trigger.textContent).toContain("Claude Opus 5.5");
+ expect(trigger.querySelector("span")?.textContent).toBe("Opus 5.5");
+ expect(trigger.getAttribute("aria-label")).toContain("Claude Opus 5.5");
+ expect(trigger.title).toContain("Claude Opus 5.5");
act(() => trigger.click());
const modelRow = [
@@ -256,6 +288,59 @@ describe("model picker", () => {
);
});
+ it("shows a saved Claude version's settings when only its alias is live", () => {
+ setHarnessModels("claude", [
+ {
+ id: "claude:opus",
+ harness: "claude",
+ name: "Opus 5.5",
+ nativeId: "opus",
+ aliasTarget: "claude-opus-5-5",
+ settings: [
+ {
+ id: "effort",
+ label: "Reasoning",
+ kind: "select",
+ value: "high",
+ options: [{ value: "high", label: "High" }],
+ },
+ {
+ id: "fast",
+ label: "Fast",
+ kind: "toggle",
+ value: "false",
+ options: [
+ { value: "true", label: "On" },
+ { value: "false", label: "Off" },
+ ],
+ },
+ ],
+ },
+ ]);
+ act(() =>
+ root.render(
+ createElement(ModelPicker, {
+ harness: "claude",
+ model: "claude:opus-5-5",
+ values: {},
+ onChange: vi.fn(),
+ onSettingsChange: vi.fn(),
+ }),
+ ),
+ );
+
+ act(() =>
+ container
+ .querySelector('button[aria-haspopup="menu"]')!
+ .click(),
+ );
+ const rows = [
+ ...container.querySelectorAll("button"),
+ ].map((button) => button.textContent ?? "");
+ expect(rows.some((row) => row.startsWith("Effort"))).toBe(true);
+ expect(rows.some((row) => row.startsWith("Fast"))).toBe(true);
+ });
+
it("groups OpenCode models by provider and searches provider names", () => {
setHarnessModels("opencode", [
{
@@ -338,6 +423,131 @@ describe("model picker", () => {
expect(container.querySelectorAll('[role="option"]')).toHaveLength(2);
});
+ it("groups omp models by provider in keyboard order", () => {
+ // omp sorts by model name, so providers arrive interleaved.
+ setHarnessModels("omp", [
+ {
+ id: "omp:anthropic/claude-sonnet-5",
+ harness: "omp",
+ name: "Claude Sonnet 5",
+ nativeId: "anthropic/claude-sonnet-5",
+ provider: { id: "anthropic", name: "Anthropic" },
+ },
+ {
+ id: "omp:github-copilot/claude-sonnet-5",
+ harness: "omp",
+ name: "Claude Sonnet 5",
+ nativeId: "github-copilot/claude-sonnet-5",
+ provider: { id: "github-copilot", name: "GitHub Copilot" },
+ },
+ {
+ id: "omp:anthropic/claude-opus-5",
+ harness: "omp",
+ name: "Claude Opus 5",
+ nativeId: "anthropic/claude-opus-5",
+ provider: { id: "anthropic", name: "Anthropic" },
+ },
+ ]);
+ const onChange = vi.fn();
+
+ act(() =>
+ root.render(
+ createElement(ModelPicker, {
+ harness: "omp",
+ model: "omp:anthropic/claude-sonnet-5",
+ values: {},
+ hideSettings: true,
+ onChange,
+ onSettingsChange: vi.fn(),
+ }),
+ ),
+ );
+
+ act(() =>
+ container
+ .querySelector('button[aria-haspopup="dialog"]')!
+ .click(),
+ );
+ expect(
+ [...container.querySelectorAll('[role="group"]')].map((group) =>
+ group.getAttribute("aria-label"),
+ ),
+ ).toEqual(["Anthropic", "GitHub Copilot"]);
+ const options = [
+ ...container.querySelectorAll('[role="option"]'),
+ ];
+ expect(options.map((option) => option.getAttribute("aria-label"))).toEqual(
+ [
+ "Claude Sonnet 5, Anthropic",
+ "Claude Opus 5, Anthropic",
+ "Claude Sonnet 5, GitHub Copilot",
+ ],
+ );
+ // The next row down is the next visible row, not the next catalog entry.
+ options[0].focus();
+ keyDown(options[0], "ArrowDown");
+ keyDown(options[0], "Enter");
+ expect(onChange).toHaveBeenCalledWith("omp", "omp:anthropic/claude-opus-5");
+ });
+
+ it("offers a typed provider/model the OpenCode catalog does not list", () => {
+ setHarnessModels("opencode", [
+ {
+ id: "opencode:openai/gpt-5.6-luna",
+ harness: "opencode",
+ name: "GPT-5.6 Luna",
+ nativeId: "openai/gpt-5.6-luna",
+ provider: { id: "openai", name: "OpenAI" },
+ },
+ ]);
+ const onChange = vi.fn();
+ act(() =>
+ root.render(
+ createElement(ModelPicker, {
+ harness: "opencode",
+ model: "opencode:openai/gpt-5.6-luna",
+ values: {},
+ onChange,
+ onSettingsChange: vi.fn(),
+ }),
+ ),
+ );
+ act(() =>
+ container
+ .querySelector('button[aria-haspopup="menu"]')!
+ .click(),
+ );
+ hover(
+ [...container.querySelectorAll("button")].find(
+ (button) => button.textContent?.startsWith("Model"),
+ )!,
+ );
+ const search = container.querySelector(
+ 'input[aria-label="Search models"]',
+ )!;
+
+ inputText(search, "openai/gpt-5.6-luna");
+ expect(
+ container.querySelector('[role="group"][aria-label="Custom model"]'),
+ ).toBeNull();
+
+ inputText(search, "qwen3");
+ expect(container.textContent).toContain(
+ "Type provider/model to use one OpenCode doesn't list.",
+ );
+
+ inputText(search, "openrouter/qwen/qwen3-coder");
+ const custom = container.querySelector(
+ '[role="group"][aria-label="Custom model"] [role="option"]',
+ )!;
+ expect(custom.textContent).toBe("Use openrouter/qwen/qwen3-coder");
+ act(() => custom.click());
+ expect(onChange).toHaveBeenCalledWith(
+ "opencode",
+ "opencode:openrouter/qwen/qwen3-coder",
+ );
+ });
+
it("names the source of same-name favorites from different providers", () => {
setHarnessModels("cursor", [
{
@@ -763,9 +973,7 @@ describe("model picker", () => {
);
const effortRow = [
...container.querySelectorAll("button"),
- ].find((button) =>
- button.textContent?.startsWith(settingLabel),
- )!;
+ ].find((button) => button.textContent?.startsWith(settingLabel))!;
hover(effortRow);
expect(
diff --git a/src/features/sessions/ui/ModelPicker.tsx b/src/features/sessions/ui/ModelPicker.tsx
index 6671bd9357..4c8d9e6f4f 100644
--- a/src/features/sessions/ui/ModelPicker.tsx
+++ b/src/features/sessions/ui/ModelPicker.tsx
@@ -20,6 +20,7 @@ import {
} from "react";
import {
coerceModelPickerTab,
+ customOpenCodeModel,
getModelSnapshot,
getPickerVisibilitySnapshot,
isEffortSettingId,
@@ -66,15 +67,26 @@ type Props = {
hotkeys?: boolean;
/** Which way the menus open; the composer sits low, so they open up. */
side?: "top" | "bottom";
- /** `plain` drops the pill for rows like a details panel's property list. */
- variant?: "pill" | "plain";
+ /**
+ * `plain` drops the pill for rows like a details panel's property list;
+ * `text` is just the provider and name, for a quiet row beneath a chat
+ * input.
+ */
+ variant?: Variant;
/** Action label for recovery surfaces that open the same model chooser. */
triggerLabel?: string;
+ /**
+ * Classes for the trigger's text, so a narrow surface can hide it and keep
+ * just the icon; the button's label still names the choice.
+ */
+ labelClassName?: string;
onChange: (harness: HarnessId, model: string) => void;
onSettingsChange: (settings: Record) => void;
onClose?: () => void;
};
+type Variant = "pill" | "plain" | "text";
+
type MenuEntry = { kind: "setting"; setting: ModelSetting } | { kind: "model" };
type Submenu = { kind: "setting"; setting: ModelSetting } | { kind: "models" };
@@ -243,8 +255,41 @@ function recentMenuModels(
return models.slice(0, 6);
}
+/** Group id for the typed `provider/model` row, kept apart from real providers. */
+const CUSTOM_OPENCODE_GROUP = "__custom_model__";
+
+/** Harness tabs whose catalog names upstream providers list models under them. */
+function groupsByProvider(tab: ModelPickerTab, models: AgentModel[]): boolean {
+ return tab !== "favorites" && models.some((item) => item.provider);
+}
+
+/** Use the upstream provider when present, otherwise the model's harness. */
+function modelProvider(item: AgentModel): { id: string; name: string } {
+ return item.provider ?? { id: item.harness, name: HARNESS_TITLE[item.harness] };
+}
+
+/**
+ * Keeps each provider's models contiguous (in first-seen provider order) so
+ * keyboard navigation, which walks this list, matches the grouped headings.
+ */
+function inProviderOrder(
+ tab: ModelPickerTab,
+ models: AgentModel[],
+): AgentModel[] {
+ if (!groupsByProvider(tab, models)) return models;
+ const groups = new Map();
+ for (const item of models) {
+ const id = modelProvider(item).id;
+ const group = groups.get(id);
+ if (group) group.push(item);
+ else groups.set(id, [item]);
+ }
+ return [...groups.values()].flat();
+}
+
+/** Build provider headings while retaining each model's keyboard navigation index. */
function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] {
- if (tab !== "opencode") {
+ if (!groupsByProvider(tab, models)) {
return [
{
id: "models",
@@ -255,7 +300,7 @@ function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] {
const groups = new Map();
models.forEach((item, index) => {
- const provider = item.provider ?? { id: "opencode", name: "OpenCode" };
+ const provider = modelProvider(item);
let group = groups.get(provider.id);
if (!group) {
group = { id: provider.id, name: provider.name, models: [] };
@@ -266,6 +311,7 @@ function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] {
return [...groups.values()];
}
+/** Model selection and settings control shared by the composer and session toolbar. */
export function ModelPicker({
harness,
model,
@@ -277,6 +323,7 @@ export function ModelPicker({
side = "top",
variant = "pill",
triggerLabel,
+ labelClassName = "",
onChange,
onSettingsChange,
onClose,
@@ -350,6 +397,10 @@ export function ModelPicker({
const triggerEffortLabel = triggerEffortSetting
? settingValueLabel(triggerEffortSetting, values)
: undefined;
+ const triggerModelName =
+ current.harness === "claude"
+ ? current.name.replace(/^Claude /, "")
+ : current.name;
const triggerTitle = [
HARNESS_TITLE[current.harness],
current.provider?.name,
@@ -387,18 +438,39 @@ export function ModelPicker({
const pool =
visibleTab === "favorites"
? favorites
- .map((id) => source.find(id))
+ // Hand-typed OpenCode models are not in any catalog.
+ .map((id) => source.find(id) ?? customOpenCodeModel(id))
.filter(
(item): item is AgentModel =>
item != null && pickerHarnesses.includes(item.harness),
)
: source.modelsFor(visibleTab);
- if (!needle) return pool;
- return pool.filter((item) =>
- `${item.name} ${HARNESS_TITLE[item.harness]} ${item.provider?.name ?? ""} ${item.provider?.id ?? ""}`
- .toLowerCase()
- .includes(needle),
- );
+ const matches = needle
+ ? pool.filter((item) =>
+ `${item.name} ${HARNESS_TITLE[item.harness]} ${item.provider?.name ?? ""} ${item.provider?.id ?? ""} ${item.nativeId ?? ""}`
+ .toLowerCase()
+ .includes(needle),
+ )
+ : pool;
+ // OpenCode can run models its catalog does not list: offer the typed
+ // `provider/model` as its own row unless the catalog already has it.
+ const custom =
+ needle && visibleTab === "opencode" ? customOpenCodeModel(query) : null;
+ if (
+ custom &&
+ !pool.some(
+ (item) =>
+ (item.nativeId ?? "").toLowerCase() ===
+ custom.nativeId!.toLowerCase(),
+ )
+ ) {
+ matches.push({
+ ...custom,
+ name: `Use ${custom.nativeId}`,
+ provider: { id: CUSTOM_OPENCODE_GROUP, name: "Custom model" },
+ });
+ }
+ return inProviderOrder(visibleTab, matches);
}, [source, catalogVersion, favorites, providerKey, query, visibleTab]);
const dismiss = (restore: boolean) => {
@@ -691,11 +763,14 @@ export function ModelPicker({
? `${triggerLabel} · ${triggerTitle}`
: `${triggerTitle} · Recent models: right-click or ${MOD}.`
}
- aria-label={triggerLabel ?? `${HARNESS_TITLE[current.harness]}${
- current.provider ? `, ${current.provider.name},` : ""
- } ${current.name}${
- triggerEffortLabel ? `, effort ${triggerEffortLabel}` : ""
- }`}
+ aria-label={
+ triggerLabel ??
+ `${HARNESS_TITLE[current.harness]}${
+ current.provider ? `, ${current.provider.name},` : ""
+ } ${current.name}${
+ triggerEffortLabel ? `, effort ${triggerEffortLabel}` : ""
+ }`
+ }
aria-keyshortcuts={`${MOD}.`}
aria-expanded={open || recentMenu != null}
aria-haspopup={hideSettings ? "dialog" : "menu"}
@@ -707,37 +782,41 @@ export function ModelPicker({
}}
onClick={() => togglePicker()}
className={
- variant === "plain"
- ? `-mx-1.5 flex h-7 shrink-0 items-center gap-2 rounded-md px-1.5 text-[12px] text-content/85 ${
- open ? "bg-content/8" : "hover:bg-content/6"
- }`
- : `flex h-6.5 shrink-0 items-center gap-1 rounded-md px-1.5 ${
- open
- ? "bg-selection text-content"
- : "bg-selection text-content hover:bg-selection-hover"
- }`
+ variant === "text"
+ ? textControlClass(open, "shrink-0 gap-1.5")
+ : variant === "plain"
+ ? `-mx-1.5 flex h-7 shrink-0 items-center gap-2 rounded-md px-1.5 text-[12px] text-content/85 ${
+ open ? "bg-content/8" : "hover:bg-content/6"
+ }`
+ : `flex h-6.5 shrink-0 items-center gap-1 rounded-md px-1.5 ${
+ open
+ ? "bg-selection text-content"
+ : "bg-selection text-content hover:bg-selection-hover"
+ }`
}
>
- {triggerLabel ?? current.name}
+ {triggerLabel ?? triggerModelName}
{!triggerLabel && triggerEffortLabel ? (
{triggerEffortLabel}
) : null}
-
+ {variant === "text" ? null : (
+
+ )}
{open && hideSettings ? (
@@ -1033,11 +1112,19 @@ export function ModelControlPills({
harness,
model,
values,
+ variant = "pill",
+ labelClassName,
onSettingsChange,
onClose,
}: Pick<
Props,
- "harness" | "model" | "values" | "onSettingsChange" | "onClose"
+ | "harness"
+ | "model"
+ | "values"
+ | "variant"
+ | "labelClassName"
+ | "onSettingsChange"
+ | "onClose"
>) {
const catalogVersion = useSyncExternalStore(
subscribeModels,
@@ -1067,6 +1154,7 @@ export function ModelControlPills({
key={setting.id}
setting={setting}
values={values}
+ variant={variant}
onSettingsChange={onSettingsChange}
/>
) : (
@@ -1074,6 +1162,8 @@ export function ModelControlPills({
key={setting.id}
setting={setting}
values={values}
+ variant={variant}
+ labelClassName={labelClassName}
onSettingsChange={onSettingsChange}
onClose={onClose}
additionalSettings={
@@ -1086,8 +1176,18 @@ export function ModelControlPills({
);
}
-/** Trigger classes for a setting control in the pill or plain look. */
-function controlClass(variant: "pill" | "plain", open = false): string {
+/** Trigger classes for a text-only control: a label that brightens on hover. */
+function textControlClass(open: boolean, extra = ""): string {
+ return `flex h-6.5 min-w-0 items-center rounded-md px-1.5 text-[12px] transition-colors ${extra} ${
+ open
+ ? "bg-content/8 text-content"
+ : "text-content/55 hover:bg-content/8 hover:text-content"
+ }`;
+}
+
+/** Trigger classes for a setting control in each look. */
+function controlClass(variant: Variant, open = false): string {
+ if (variant === "text") return textControlClass(open, "max-w-28 gap-1.5");
return variant === "plain"
? `-mx-1.5 flex h-7 min-w-0 max-w-full items-center gap-2 rounded-md px-1.5 text-[12px] text-content/85 ${
open ? "bg-content/8" : "hover:bg-content/6"
@@ -1163,7 +1263,7 @@ function TogglePill({
}: {
setting: ModelSetting;
values: Record;
- variant?: "pill" | "plain";
+ variant?: Variant;
onSettingsChange: (settings: Record) => void;
}) {
const on = settingValue(setting, values) === "true";
@@ -1205,7 +1305,9 @@ function TogglePill({
className={controlClass(variant)}
>
{setting.label}
@@ -1218,14 +1320,17 @@ function SelectPill({
values,
variant = "pill",
side = "top",
+ labelClassName = "",
onSettingsChange,
onClose,
additionalSettings,
}: {
setting: ModelSetting;
values: Record;
- variant?: "pill" | "plain";
+ variant?: Variant;
side?: "top" | "bottom";
+ /** Applies only beside an icon, so a hidden label never leaves a blank. */
+ labelClassName?: string;
onSettingsChange: (settings: Record) => void;
onClose?: () => void;
additionalSettings?: ModelSetting[];
@@ -1238,6 +1343,7 @@ function SelectPill({
const value = settingValue(setting, values);
const valueLabel = settingValueLabel(setting, values);
const label = settingLabel(setting);
+ const iconic = isEffortSetting(setting) || setting.id === "serviceTier";
const menuSettings = [setting, ...(additionalSettings ?? [])];
const grouped = menuSettings.length > 1;
const menuOptions = menuSettings.flatMap((menuSetting) =>
@@ -1284,14 +1390,16 @@ function SelectPill({
) : null}
{valueLabel}
-
+ {variant === "text" ? null : (
+
+ )}
{open ? (
@@ -1575,7 +1683,9 @@ function ModelFlyout({
? harnessUnavailableHint(tab)
: tab === "codex" && !query.trim()
? "Loading Codex models…"
- : "No matching models"}
+ : tab === "opencode"
+ ? "No matching models. Type provider/model to use one OpenCode doesn't list."
+ : "No matching models"}
) : (
groups.map((group) => (
diff --git a/src/features/sessions/ui/SessionFiltersMenu.tsx b/src/features/sessions/ui/SessionFiltersMenu.tsx
index b7cd8a3f10..a2b5015fc5 100644
--- a/src/features/sessions/ui/SessionFiltersMenu.tsx
+++ b/src/features/sessions/ui/SessionFiltersMenu.tsx
@@ -16,6 +16,8 @@ type Props = {
x: number;
y: number;
harnesses: HarnessId[];
+ /** Offers launches since deleted, which only a Mono's sessions keep. */
+ unavailableOption?: boolean;
filters: SessionSidebarFilters;
onChange: (filters: SessionSidebarFilters) => void;
onClose: () => void;
@@ -32,6 +34,7 @@ export function SessionFiltersMenu({
x,
y,
harnesses,
+ unavailableOption = false,
filters,
onChange,
onClose,
@@ -61,6 +64,11 @@ export function SessionFiltersMenu({
onClose();
};
+ const toggleUnavailable = () => {
+ onChange({ ...filters, showUnavailable: !filters.showUnavailable });
+ onClose();
+ };
+
return (
+ {unavailableOption ? (
+
+ ) : null}
Status
void;
monoActivityTurnId?: string;
- onShowMonoSessions?: (
+ /** Open a session a Mono's turn launched, in the Mono's sidebar. */
+ onOpenMonoSession?: (
sessionId: string,
turnId: string,
blocks: Block[],
+ launchedId: string,
) => void;
- monoSessionsTurnId?: string;
+ /** Live status of the sessions this Mono launched, by id. */
+ monoLaunchedSessions?: ReadonlyMap;
+ /** The launched session open in the Mono's sidebar. */
+ openMonoSessionId?: string;
recents: RecentProject[];
hideProjectPicker?: boolean;
onFocus: (sessionId: string) => void;
@@ -344,8 +355,9 @@ const LocalSessionPane = memo(function LocalSessionPane({
onCommitChanges,
onShowMonoActivity,
monoActivityTurnId,
- onShowMonoSessions,
- monoSessionsTurnId,
+ onOpenMonoSession,
+ monoLaunchedSessions,
+ openMonoSessionId,
onOpenPlan,
onBuildPlan,
onSecondOpinion,
@@ -965,13 +977,19 @@ const LocalSessionPane = memo(function LocalSessionPane({
: undefined
}
activeWorkTurnId={monoActivityTurnId}
- onShowSessions={
- agent && onShowMonoSessions
- ? (turnId, blocks) =>
- onShowMonoSessions(session.id, turnId, blocks)
+ onOpenSession={
+ agent && onOpenMonoSession
+ ? (turnId, blocks, launchedId) =>
+ onOpenMonoSession(
+ session.id,
+ turnId,
+ blocks,
+ launchedId,
+ )
: undefined
}
- activeSessionsTurnId={monoSessionsTurnId}
+ launchedSessions={monoLaunchedSessions}
+ openSessionId={openMonoSessionId}
// A Mono's turn keeps copy, save as note and the time.
daySeparators={!!agent}
hideTurnMetrics={!!agent}
@@ -1045,18 +1063,6 @@ const LocalSessionPane = memo(function LocalSessionPane({
}
: undefined
}
- editTurnAction={
- reviewHidden || !agent ? undefined : (
-
- )
- }
latestTurnAccessory={
reviewHidden || agent ? undefined : (
!!message.monoSessionCompletion,
- )}
+ messages={visibleMonoCompletionMessages(session)}
status={session.queueStatus}
sendingId={session.sendingQueuedMessageId}
onDelete={(messageId) =>
@@ -1188,6 +1192,63 @@ const LocalSessionPane = memo(function LocalSessionPane({
onSubmit={(text, attachments) =>
onSubmit(session.id, text, attachments)
}
+ // The plan's usage, as the session footer shows it,
+ // which a Mono in full view covers. Its account moves
+ // with the usage-limit notice, not here. A narrow chat
+ // drops it first, then shows the model controls as icons.
+ tools={
+
+
+
+ }
+ controls={
+ <>
+
+ onModelChange(session.id, harness, model)
+ }
+ onSettingsChange={(settings) =>
+ onModelSettingsChange(session.id, settings)
+ }
+ />
+
+ onModelSettingsChange(session.id, settings)
+ }
+ />
+ {/* A Mono is one long conversation; this is how full
+ its current thread is. It rotates threads itself,
+ so there is nothing to compact here. */}
+
+
+
+ >
+ }
/>
>
) : (
@@ -1213,3 +1274,13 @@ const LocalSessionPane = memo(function LocalSessionPane({
);
});
+
+/** The providers whose plan usage a Mono on `harness` shows by its composer. */
+function monoUsageProviders(harness: HarnessId): RateLimitProvider[] {
+ return harness === "claude" ||
+ harness === "codex" ||
+ harness === "opencode" ||
+ harness === "devin"
+ ? [harness]
+ : [];
+}
diff --git a/src/features/sessions/ui/SessionPaneMessaging.test.ts b/src/features/sessions/ui/SessionPaneMessaging.test.ts
index c7a7ded15b..d708294147 100644
--- a/src/features/sessions/ui/SessionPaneMessaging.test.ts
+++ b/src/features/sessions/ui/SessionPaneMessaging.test.ts
@@ -277,6 +277,69 @@ it("keeps a completion notification queued beside the Mono's composer", () => {
expect(field()).not.toBeNull();
});
+it("keeps ready completion notifications out of the composer while they deliver", () => {
+ const pane = props();
+ const notification = {
+ id: "notification",
+ text: "Hidden completion review prompt",
+ attachments: [],
+ monoSessionCompletion: {
+ sessionId: "worker",
+ title: "API fix",
+ status: "completed" as const,
+ },
+ };
+ pane.session = {
+ ...pane.session,
+ busy: false,
+ queueStatus: "active",
+ queuedMessages: [notification],
+ };
+ render(pane);
+ expect(container.querySelector("[data-message-queue]")).toBeNull();
+ expect(container.textContent).not.toContain(
+ "Hidden completion review prompt",
+ );
+ expect(pane.session.queuedMessages).toEqual([notification]);
+ expect(probes.transcript.mock.calls.at(-1)![0].messageDeliveries.size).toBe(
+ 0,
+ );
+ expect(field()).not.toBeNull();
+});
+
+it("keeps failed completion notifications visible and lets the user retry them", () => {
+ const pane = props();
+ pane.onResumeQueue = vi.fn();
+ pane.session = {
+ ...pane.session,
+ busy: false,
+ queueStatus: "paused",
+ queuedMessages: [
+ {
+ id: "notification",
+ text: "Hidden completion review prompt",
+ attachments: [],
+ error: "Connection lost",
+ monoSessionCompletion: {
+ sessionId: "worker",
+ title: "API fix",
+ status: "completed",
+ },
+ },
+ ],
+ };
+ render(pane);
+ const queue = container.querySelector("[data-message-queue]")!;
+ expect(queue.textContent).toContain("Session completed: API fix");
+ expect(queue.textContent).toContain("Connection lost");
+ expect(queue.textContent).not.toContain("Hidden completion review prompt");
+ const retry = [...queue.querySelectorAll("button")].find(
+ (button) => button.textContent?.trim() === "Retry",
+ )!;
+ act(() => retry.click());
+ expect(pane.onResumeQueue).toHaveBeenCalledWith("chat");
+});
+
it("keeps rapid sends out of the queue and steer interface", () => {
const pane = props();
pane.session = {
diff --git a/src/features/sessions/ui/SessionPaneScroll.test.ts b/src/features/sessions/ui/SessionPaneScroll.test.ts
index 006129329b..bfc6fe1472 100644
--- a/src/features/sessions/ui/SessionPaneScroll.test.ts
+++ b/src/features/sessions/ui/SessionPaneScroll.test.ts
@@ -18,7 +18,6 @@ vi.mock("../../monos/ui/MonoComposer", () => ({
}));
vi.mock("./SessionReview", () => ({
SessionReview: probes.review,
- SessionChangesButton: probes.review,
}));
vi.mock("../data/sessionStore", async (original) => ({
...(await original()),
@@ -154,18 +153,47 @@ it("routes a Mono footer activity click to its session and selected turn", () =>
const pane = props();
pane.session.blocks = [
{ id: "user", role: "user", text: "Inspect", durationMs: 23000 },
- { id: "call", role: "tool", text: "ls", tool: { kind: "shell", status: "completed" } },
+ {
+ id: "call",
+ role: "tool",
+ text: "ls",
+ tool: { kind: "shell", status: "completed" },
+ },
{ id: "reply", role: "assistant", text: "Done" },
];
const onShowMonoActivity = vi.fn();
- act(() => root.render(createElement(SessionPane, { ...pane, onShowMonoActivity })));
- act(() => container.querySelector('[data-turn-actions] [aria-label="Show activity"]')!.click());
- expect(onShowMonoActivity).toHaveBeenCalledWith("chat", "user", pane.session.blocks);
- act(() => root.render(createElement(SessionPane, { ...pane, onShowMonoActivity, monoActivityTurnId: "user" })));
- expect(container.querySelector('[data-turn-actions] [aria-label="Hide activity"]')?.getAttribute("aria-expanded")).toBe("true");
+ act(() =>
+ root.render(createElement(SessionPane, { ...pane, onShowMonoActivity })),
+ );
+ act(() =>
+ container
+ .querySelector(
+ '[data-turn-actions] [aria-label="Show activity"]',
+ )!
+ .click(),
+ );
+ expect(onShowMonoActivity).toHaveBeenCalledWith(
+ "chat",
+ "user",
+ pane.session.blocks,
+ );
+ act(() =>
+ root.render(
+ createElement(SessionPane, {
+ ...pane,
+ onShowMonoActivity,
+ monoActivityTurnId: "user",
+ }),
+ ),
+ );
+ expect(
+ container
+ .querySelector('[data-turn-actions] [aria-label="Hide activity"]')
+ ?.getAttribute("aria-expanded"),
+ ).toBe("true");
});
-it("routes launched sessions from a Mono footer to its session and turn", () => {
+it("routes a launched session's card to the Mono, its turn and the session", () => {
const pane = props();
pane.session.blocks[0] = {
...pane.session.blocks[0],
@@ -179,35 +207,44 @@ it("routes launched sessions from a Mono footer to its session and turn", () =>
},
],
};
- const onShowMonoSessions = vi.fn();
+ const onOpenMonoSession = vi.fn();
+ const monoLaunchedSessions = new Map([
+ ["app-review", { state: "working" as const, title: "Review" }],
+ ]);
act(() =>
- root.render(createElement(SessionPane, { ...pane, onShowMonoSessions })),
- );
- act(() =>
- container
- .querySelector(
- '[data-turn-actions] [aria-label="Show sessions"]',
- )!
- .click(),
+ root.render(
+ createElement(SessionPane, {
+ ...pane,
+ onOpenMonoSession,
+ monoLaunchedSessions,
+ }),
+ ),
);
- expect(onShowMonoSessions).toHaveBeenCalledWith(
+ const card = container.querySelector(
+ '[data-launched-session="app-review"]',
+ )!;
+ expect(card.dataset.state).toBe("working");
+ act(() => card.click());
+ expect(onOpenMonoSession).toHaveBeenCalledWith(
"chat",
"user",
pane.session.blocks,
+ "app-review",
);
act(() =>
root.render(
createElement(SessionPane, {
...pane,
- onShowMonoSessions,
- monoSessionsTurnId: "user",
+ onOpenMonoSession,
+ monoLaunchedSessions,
+ openMonoSessionId: "app-review",
}),
),
);
expect(
container
- .querySelector('[aria-label="Hide sessions"]')
- ?.getAttribute("aria-expanded"),
+ .querySelector('[data-launched-session="app-review"]')
+ ?.getAttribute("aria-pressed"),
).toBe("true");
});
@@ -223,7 +260,8 @@ it("renders older replies immediately when scrolling up loads a Mono page", asyn
hasNewer: true,
});
act(() => root.render(createElement(SessionPane, pane)));
- const scroller = container.querySelector(".agent-transcript")!;
+ const scroller =
+ container.querySelector(".agent-transcript")!;
await act(async () => {
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
});
diff --git a/src/features/sessions/ui/SessionReview.test.ts b/src/features/sessions/ui/SessionReview.test.ts
index ec772563fb..ffd5374e48 100644
--- a/src/features/sessions/ui/SessionReview.test.ts
+++ b/src/features/sessions/ui/SessionReview.test.ts
@@ -3,7 +3,11 @@ import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { CheckpointStatus } from "../model/checkpoint";
-import { SessionChangesButton, SessionReview } from "./SessionReview";
+import {
+ SessionReview,
+ useSessionChangeTotals,
+ type SessionChangeTotals,
+} from "./SessionReview";
import { notifyGitChanged } from "../../../platform/tauri/fs";
const probes = vi.hoisted(() => ({
@@ -323,7 +327,7 @@ it("updates the cache directly after Keep and reuses the cleared result on reope
expect(probes.status).toHaveBeenCalledTimes(1);
});
-it("keeps the card visible during a refresh and shares its cache with the compact button", async () => {
+it("keeps the card visible during a refresh and shares its cache with the change totals", async () => {
vi.useFakeTimers();
probes.status.mockResolvedValue(status("before.ts"));
await render();
@@ -343,15 +347,28 @@ it("keeps the card visible during a refresh and shares its cache with the compac
await act(async () => complete(status("after.ts")));
act(() => root.unmount());
root = createRoot(container);
- await act(async () =>
- root.render(
- createElement(SessionChangesButton, {
- sessionId: sessionKey(),
- cwd: "/repo",
- onOpenDiff: vi.fn(),
- }),
- ),
- );
- expect(container.querySelector("[data-session-changes]")).not.toBeNull();
+ let totals: SessionChangeTotals | null = null;
+ function Totals() {
+ totals = useSessionChangeTotals(sessionKey(), "/repo");
+ return null;
+ }
+ await act(async () => root.render(createElement(Totals)));
+ expect(totals).toEqual({ files: 1, additions: 3, deletions: 1 });
expect(probes.status).toHaveBeenCalledTimes(2);
});
+
+it("keeps the settled change totals while a turn runs", async () => {
+ probes.status.mockResolvedValue(status("before.ts"));
+ let totals: SessionChangeTotals | null = null;
+ function Totals({ busy }: { busy: boolean }) {
+ totals = useSessionChangeTotals(sessionKey(), "/repo", { busy });
+ return null;
+ }
+ await act(async () => root.render(createElement(Totals, { busy: false })));
+ expect(totals).toEqual({ files: 1, additions: 3, deletions: 1 });
+ await act(async () => root.render(createElement(Totals, { busy: true })));
+ expect(totals).toEqual({ files: 1, additions: 3, deletions: 1 });
+ probes.status.mockResolvedValue({ files: [] });
+ await act(async () => root.render(createElement(Totals, { busy: false })));
+ expect(totals).toBeNull();
+});
diff --git a/src/features/sessions/ui/SessionReview.tsx b/src/features/sessions/ui/SessionReview.tsx
index 8035fa8f29..1816026040 100644
--- a/src/features/sessions/ui/SessionReview.tsx
+++ b/src/features/sessions/ui/SessionReview.tsx
@@ -261,18 +261,25 @@ function useSessionReviewFiles(
return { files, setFiles, error, load, filesRef };
}
-/** A Mono's compact review: the recorded line counts, opening the diff. */
-export function SessionChangesButton({
- sessionId,
- cwd,
- enabled = true,
- busy = false,
- editedPaths,
- onOpenDiff,
-}: Pick<
- Props,
- "sessionId" | "cwd" | "enabled" | "busy" | "editedPaths" | "onOpenDiff"
->) {
+export type SessionChangeTotals = {
+ files: number;
+ additions: number;
+ deletions: number;
+};
+
+/**
+ * A session's recorded changes as one summary. A running turn keeps the
+ * last settled summary, so the count does not drop out and back each turn.
+ */
+export function useSessionChangeTotals(
+ sessionId: string,
+ cwd: string,
+ {
+ enabled = true,
+ busy = false,
+ editedPaths,
+ }: Pick = {},
+): SessionChangeTotals | null {
const { files } = useSessionReviewFiles(
sessionId,
cwd,
@@ -280,26 +287,25 @@ export function SessionChangesButton({
busy,
editedPaths,
);
- if (busy || files.length === 0) return null;
- const totals = sumChanges(files);
- const label = `Changed ${files.length} ${files.length === 1 ? "file" : "files"}`;
- return (
- onOpenDiff(undefined, { sessionId, cwd })}
- className="flex items-center gap-1 rounded-md p-1 font-sans text-[11px] font-semibold leading-none tabular-nums outline-none hover:bg-content/8 focus-visible:ring-1 focus-visible:ring-accent"
- data-session-changes
- >
-
- +{formatInteger(totals.additions)}
-
-
- -{formatInteger(totals.deletions)}
-
-
- );
+ const settled = useRef<{
+ key: string;
+ totals: SessionChangeTotals | null;
+ } | null>(null);
+ const key = JSON.stringify([sessionId, cwd]);
+ if (settled.current?.key !== key) settled.current = { key, totals: null };
+ if (!busy) {
+ const previous = settled.current.totals;
+ const totals = sumChanges(files);
+ settled.current.totals =
+ files.length === 0
+ ? null
+ : previous?.files === files.length &&
+ previous.additions === totals.additions &&
+ previous.deletions === totals.deletions
+ ? previous
+ : { files: files.length, ...totals };
+ }
+ return settled.current.totals;
}
function sumChanges(files: CheckpointFile[]) {
diff --git a/src/features/sessions/ui/wordFade.test.ts b/src/features/sessions/ui/wordFade.test.ts
index 8ea0b83c45..e32f243625 100644
--- a/src/features/sessions/ui/wordFade.test.ts
+++ b/src/features/sessions/ui/wordFade.test.ts
@@ -3,7 +3,13 @@ import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentMarkdown } from "./AgentMarkdown";
-import { revealEnd, WORD_FADE_MS } from "./wordFade";
+import {
+ cadenceFadeMs,
+ nextCadence,
+ PACED_REVEAL_KEY,
+ revealEnd,
+ WORD_FADE_MS,
+} from "./wordFade";
describe("revealEnd", () => {
it("stops at the end of the word the reveal has reached", () => {
@@ -22,11 +28,25 @@ describe("revealEnd", () => {
});
});
+describe("cadence fade", () => {
+ it("fades over three gaps of the stream, within bounds", () => {
+ expect(cadenceFadeMs(100)).toBe(300);
+ expect(cadenceFadeMs(10)).toBe(120);
+ expect(cadenceFadeMs(1000)).toBe(400);
+ });
+
+ it("follows the gaps between bursts without one stall taking over", () => {
+ expect(nextCadence(160, 60)).toBeCloseTo(130);
+ expect(nextCadence(160, 60_000)).toBeCloseTo(412);
+ });
+});
+
describe("paced streaming", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
+ localStorage.setItem(PACED_REVEAL_KEY, "1");
vi.useFakeTimers({
toFake: [
"setTimeout",
@@ -43,6 +63,7 @@ describe("paced streaming", () => {
});
afterEach(() => {
+ localStorage.removeItem(PACED_REVEAL_KEY);
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
@@ -183,3 +204,104 @@ describe("paced streaming", () => {
expect(word("now")).toBeDefined();
});
});
+
+describe("streamed reveal", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ vi.useFakeTimers({
+ toFake: [
+ "setTimeout",
+ "clearTimeout",
+ "requestAnimationFrame",
+ "cancelAnimationFrame",
+ "performance",
+ ],
+ });
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ });
+
+ function render(text: string, streaming: boolean) {
+ act(() => root.render(createElement(AgentMarkdown, { text, streaming })));
+ }
+
+ function shown() {
+ return container.textContent ?? "";
+ }
+
+ function fadeMs() {
+ const box = container.querySelector(".agent-markdown")
+ ?.parentElement;
+ return Number.parseFloat(box?.style.getPropertyValue("--word-fade-ms") ?? "");
+ }
+
+ it("shows a burst as soon as it arrives, fading its words in", () => {
+ render("", true);
+ render("I will review the diff ", true);
+ expect(shown()).toBe("I will review the diff");
+ expect(container.querySelector(".agent-markdown.word-fading")).not.toBeNull();
+ expect(container.querySelectorAll("[data-word-fade]")).toHaveLength(5);
+ });
+
+ it("holds back a word still being written until the stream pauses on it", () => {
+ render("", true);
+ render("Hello wor", true);
+ expect(shown()).toBe("Hello");
+ act(() => vi.advanceTimersByTime(200));
+ expect(shown()).toBe("Hello wor");
+ render("Hello world and mo", true);
+ expect(shown()).toBe("Hello world and");
+ });
+
+ it("shortens the fade for a fast stream and lengthens it for a slow one", () => {
+ render("", true);
+ let text = "";
+ for (let index = 0; index < 20; index++) {
+ text += `word${index} `;
+ render(text, true);
+ act(() => vi.advanceTimersByTime(16));
+ }
+ expect(fadeMs()).toBe(120);
+
+ for (let index = 0; index < 20; index++) {
+ text += `slow${index} `;
+ render(text, true);
+ act(() => vi.advanceTimersByTime(300));
+ }
+ expect(fadeMs()).toBe(400);
+ });
+
+ it("stops fading once the last burst has faded in", () => {
+ render("", true);
+ render("Done reviewing.", true);
+ render("Done reviewing.", false);
+ expect(shown()).toBe("Done reviewing.");
+ act(() => vi.advanceTimersByTime(400));
+ expect(container.querySelector(".word-fading")).toBeNull();
+ expect(container.querySelector("[data-word-fade]")).toBeNull();
+ });
+
+ it("shows output already streaming when it opens whole and still", () => {
+ act(() =>
+ root.render(
+ createElement(AgentMarkdown, {
+ text: "Half way through a wor",
+ streaming: true,
+ revealOnMount: false,
+ }),
+ ),
+ );
+ expect(shown()).toBe("Half way through a wor");
+ });
+});
diff --git a/src/features/sessions/ui/wordFade.tsx b/src/features/sessions/ui/wordFade.tsx
index f7f2f18489..aa8300e4af 100644
--- a/src/features/sessions/ui/wordFade.tsx
+++ b/src/features/sessions/ui/wordFade.tsx
@@ -2,16 +2,88 @@ import type { Element, ElementContent, Root } from "hast";
import { useEffect, useReducer, useRef, useState } from "react";
/*
- * Streaming prose, paced. Tokens land in uneven bursts; read straight off the
- * wire, whole clauses pop in at once and then nothing for a beat. Instead the
- * text is let out a word at a time at a steady rate that closes on whatever
- * has arrived, and each word fades in as it is let out (the fade itself is
- * `.word-fading [data-word-fade]` in index.css), so the reply grows with a
- * soft leading edge rather than a ragged one.
+ * Streaming prose. Tokens land in uneven bursts. By default each burst goes
+ * on screen as it arrives, so layout is always the real reply, and its words
+ * fade in over a time that follows the stream cadance
+ * veil): a fast stream gets short fades, a slow one long fades, and either
+ * way consecutive bursts overlap into one rolling edge instead of popping in
+ * clause by clause. The fade itself is `.word-fading [data-word-fade]` in
+ * index.css.
+ *
+ * The older paced reveal, which lets text out a word at a time at a steady
+ * rate, stays available behind `localStorage["monocode.pacedReveal"] = "1"`.
*/
-/** How long one word takes to fade in; matches `word-fade-in` in index.css. */
+/** How long one word takes to fade in while paced. */
export const WORD_FADE_MS = 320;
+
+/** Opt back into the paced word-at-a-time reveal. */
+export const PACED_REVEAL_KEY = "monocode.pacedReveal";
+
+/**
+ * Fade length tracks an average of the gaps between bursts: three gaps long,
+ * within these bounds.
+ */
+const CADENCE_FADE_MIN_MS = 120;
+const CADENCE_FADE_MAX_MS = 400;
+/** The gap average a fresh stream starts from. */
+const CADENCE_SEED_MS = 160;
+/** A stall longer than this counts as this long, so one pause can't drag every fade out. */
+const CADENCE_GAP_CAP_MS = 1000;
+
+const CADENCE_FADE_EASE = "cubic-bezier(0.33, 0.53, 0.67, 1)";
+
+export function pacedRevealPreferred(): boolean {
+ try {
+ return localStorage.getItem(PACED_REVEAL_KEY) === "1";
+ } catch {
+ return false;
+ }
+}
+
+/** The next gap average once a burst lands `gapMs` after the one before. */
+export function nextCadence(averageMs: number, gapMs: number): number {
+ return averageMs * 0.7 + Math.min(gapMs, CADENCE_GAP_CAP_MS) * 0.3;
+}
+
+/** How long a burst's words fade, given the current gap average. */
+export function cadenceFadeMs(averageMs: number): number {
+ return Math.min(
+ CADENCE_FADE_MAX_MS,
+ Math.max(CADENCE_FADE_MIN_MS, averageMs * 3),
+ );
+}
+
+/**
+ * The fade length and curve for words appearing in `text` now. Each change
+ * that adds to the text is one burst; the gap since the previous one feeds
+ * the average. A render that repeats the same text (strict mode, an unrelated
+ * parent update) leaves it alone.
+ */
+export function useCadenceFade(text: string): {
+ durationMs: number;
+ easing: string;
+} {
+ const state = useRef({
+ text,
+ average: CADENCE_SEED_MS,
+ lastBurst: null as number | null,
+ });
+ const current = state.current;
+ if (text !== current.text) {
+ if (text.length > current.text.length) {
+ const now = performance.now();
+ if (current.lastBurst !== null)
+ current.average = nextCadence(current.average, now - current.lastBurst);
+ current.lastBurst = now;
+ }
+ current.text = text;
+ }
+ return {
+ durationMs: Math.round(cadenceFadeMs(current.average)),
+ easing: CADENCE_FADE_EASE,
+ };
+}
/**
* The slowest the reveal goes, in characters a second, so the tail of a
* finished reply never crawls out.
@@ -51,6 +123,42 @@ function isSpace(code: number): boolean {
return code === 32 || code === 10 || code === 9 || code === 13;
}
+/**
+ * Show streamed output as it arrives, holding back only a word still being
+ * written until it is whole or the stream pauses on it. `revealing` stays on
+ * for the first render of new output so it fades in, including a reply that
+ * completed before its first paint; callers opening existing output opt out.
+ */
+export function useStreamedText(
+ text: string,
+ streaming: boolean,
+ revealOnMount = streaming,
+): { text: string; revealing: boolean } {
+ const [mountReveal, setMountReveal] = useState(revealOnMount);
+ // Output already there when it opens is shown whole, half-written word too.
+ const [heldText, setHeldText] = useState(
+ revealOnMount ? null : text,
+ );
+ const end =
+ heldText === text ? text.length : revealEnd(text, text.length, streaming);
+ const behind = end < text.length;
+
+ useEffect(() => {
+ if (mountReveal) setMountReveal(false);
+ }, [mountReveal]);
+
+ useEffect(() => {
+ if (!behind) return;
+ const timer = window.setTimeout(() => setHeldText(text), REVEAL_HOLD_MS);
+ return () => window.clearTimeout(timer);
+ }, [behind, text]);
+
+ return {
+ text: behind ? text.slice(0, end) : text,
+ revealing: behind || mountReveal,
+ };
+}
+
/**
* Pace new output, including its first chunk and a reply that completed before
* its first paint. Callers opening existing output opt out of the mount reveal.
@@ -124,15 +232,20 @@ export function usePacedText(
* — an animation replays whenever its element is hidden and shown again, and
* a finished reply folded away and reopened must not fade in all over again.
*/
-export function useWordFading(active: boolean): boolean {
+export function useWordFading(active: boolean, fadeMs = WORD_FADE_MS): boolean {
const [lingering, setLingering] = useState(false);
+ const lastFadeMs = useRef(fadeMs);
+ lastFadeMs.current = fadeMs;
useEffect(() => {
if (active) {
setLingering(true);
return;
}
- const timer = window.setTimeout(() => setLingering(false), WORD_FADE_MS);
+ const timer = window.setTimeout(
+ () => setLingering(false),
+ lastFadeMs.current,
+ );
return () => window.clearTimeout(timer);
}, [active]);
diff --git a/src/features/settings/model/settings.test.ts b/src/features/settings/model/settings.test.ts
index 70ebc562c8..34139836af 100644
--- a/src/features/settings/model/settings.test.ts
+++ b/src/features/settings/model/settings.test.ts
@@ -449,6 +449,22 @@ describe("workspace navigation keybindings", () => {
),
).toBe(true);
});
+ it("documents Escape stopping a busy session as a fixed binding", () => {
+ expect(KEYBINDINGS.find((row) => row.command === "Session: Stop")).toEqual({
+ command: "Session: Stop",
+ keys: "Esc",
+ when: "sessionBusy && !terminalFocus",
+ fixed: true,
+ });
+ });
+ it("ignores stored overrides for fixed bindings", () => {
+ localStorage.setItem(
+ KEYBINDING_OVERRIDES_KEY,
+ '{"Session: Stop":{"disabled":true}}',
+ );
+ expect(loadKeybindingOverrides()).toEqual({});
+ localStorage.removeItem(KEYBINDING_OVERRIDES_KEY);
+ });
it("documents same-tab session switching", () => {
expect(
KEYBINDINGS.filter((row) => row.command.includes("in Current Tab")),
diff --git a/src/features/settings/model/settings.ts b/src/features/settings/model/settings.ts
index a772ddf178..3825252260 100644
--- a/src/features/settings/model/settings.ts
+++ b/src/features/settings/model/settings.ts
@@ -1085,6 +1085,7 @@ export type KeybindingRow = {
command: string;
keys: string;
when: string;
+ fixed?: true;
};
/**
@@ -1146,6 +1147,12 @@ export const KEYBINDINGS: KeybindingRow[] = [
keys: `${MOD}${SHIFT}A`,
when: "sessionFocus && !overlay",
},
+ {
+ command: "Session: Stop",
+ keys: "Esc",
+ when: "sessionBusy && !terminalFocus",
+ fixed: true,
+ },
{
command: "Session: Previous",
keys: `${MOD}${SHIFT}↑`,
@@ -1204,7 +1211,9 @@ export type KeybindingOverride = {
export type KeybindingOverrides = Record;
-const VALID_COMMANDS = new Set(KEYBINDINGS.map((row) => row.command));
+const VALID_COMMANDS = new Set(
+ KEYBINDINGS.filter((row) => !row.fixed).map((row) => row.command),
+);
const KEY_CODES: Record = {
" ": "Space",
diff --git a/src/features/settings/model/sounds.ts b/src/features/settings/model/sounds.ts
index ee151cb134..72a26a6a3a 100644
--- a/src/features/settings/model/sounds.ts
+++ b/src/features/settings/model/sounds.ts
@@ -18,6 +18,7 @@ export const SOUNDS_CHANGE_EVENT = "monocode:sounds-change";
export type SoundCue =
| "turnFinished"
+ | "inputRequested"
| "inboxUnseen"
| "linkedActivity"
| "updateAvailable"
@@ -26,6 +27,7 @@ export type SoundCue =
const CUES: Record = {
turnFinished: "success",
+ inputRequested: "arrival",
inboxUnseen: "bloom",
linkedActivity: "chime",
updateAvailable: "arrival",
@@ -68,7 +70,11 @@ export function initSounds() {
applySoundEngine();
}
-type ProjectSoundCue = "turnFinished" | "inboxUnseen" | "linkedActivity";
+type ProjectSoundCue =
+ | "turnFinished"
+ | "inputRequested"
+ | "inboxUnseen"
+ | "linkedActivity";
/** Project cues require their subject so new sources cannot bypass project policy. */
export function playCue(cue: Exclude): boolean;
diff --git a/src/features/settings/ui/SettingsView.test.ts b/src/features/settings/ui/SettingsView.test.ts
index 79c983e8ba..270728e517 100644
--- a/src/features/settings/ui/SettingsView.test.ts
+++ b/src/features/settings/ui/SettingsView.test.ts
@@ -128,20 +128,21 @@ describe("settings pages", () => {
`[role="switch"][aria-label="Show sessions started by ${monoLook(findMono(id)!).name} in sidebar"]`,
)!;
await render("monos");
- expect(toggle(mono.id).getAttribute("aria-checked")).toBe("true");
- expect(toggle(other.id).getAttribute("aria-checked")).toBe("true");
- await act(async () => toggle(mono.id).click());
+ // Off until turned on: a Mono's sessions stay in its overview.
expect(toggle(mono.id).getAttribute("aria-checked")).toBe("false");
- expect(findMono(mono.id)?.showStartedSessionsInSidebar).toBe(false);
- expect(toggle(other.id).getAttribute("aria-checked")).toBe("true");
+ expect(toggle(other.id).getAttribute("aria-checked")).toBe("false");
+ await act(async () => toggle(mono.id).click());
+ expect(toggle(mono.id).getAttribute("aria-checked")).toBe("true");
+ expect(findMono(mono.id)?.showStartedSessionsInSidebar).toBe(true);
+ expect(toggle(other.id).getAttribute("aria-checked")).toBe("false");
await render("general");
await render("monos");
- expect(toggle(mono.id).getAttribute("aria-checked")).toBe("false");
+ expect(toggle(mono.id).getAttribute("aria-checked")).toBe("true");
await act(async () => updateMono(mono.id, (entry) => ({
...entry,
- showStartedSessionsInSidebar: true,
+ showStartedSessionsInSidebar: false,
})));
- expect(toggle(mono.id).getAttribute("aria-checked")).toBe("true");
+ expect(toggle(mono.id).getAttribute("aria-checked")).toBe("false");
});
it("blurs account emails by default and hides them when settings reopen", async () => {
@@ -927,6 +928,17 @@ describe("settings search", () => {
expect(onSelectSection).not.toHaveBeenCalled();
});
+ it("lists Escape stopping a session without a recorder", async () => {
+ await render("keybindings");
+ const row = [...container.querySelectorAll("span")].find(
+ (span) => span.textContent === "Session: Stop",
+ )?.parentElement;
+ expect(row?.textContent).toContain("Esc");
+ expect(
+ container.querySelector('[aria-label="Change Session: Stop shortcut"]'),
+ ).toBeNull();
+ });
+
it("records a custom keybinding from the key cell", async () => {
await render("keybindings");
const input = container.querySelector(
diff --git a/src/features/settings/ui/SettingsView.tsx b/src/features/settings/ui/SettingsView.tsx
index b41a09858b..1a35a3b3a3 100644
--- a/src/features/settings/ui/SettingsView.tsx
+++ b/src/features/settings/ui/SettingsView.tsx
@@ -2793,7 +2793,13 @@ function KeybindingsPage() {
>
{row.command}
- {row.command === "App: Quick Composer" ? (
+ {row.fixed ? (
+
+
+ {row.keys}
+
+
+ ) : row.command === "App: Quick Composer" ? (
) : (
updateMono(mono.id, (entry) => ({
...entry,
diff --git a/src/features/source-control/model/worktrees.test.ts b/src/features/source-control/model/worktrees.test.ts
index 248f246775..a5f47b66d8 100644
--- a/src/features/source-control/model/worktrees.test.ts
+++ b/src/features/source-control/model/worktrees.test.ts
@@ -1,14 +1,21 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { newFileTab, newTerminalFile } from "../../workspace/model/layout";
import { newSession, sessionWorkCwd } from "../../sessions/model/session";
import {
sessionInWorktree,
detachSessionWorktree,
assertWorktreeFilesClosed,
+ worktreeDeletedExternally,
worktreeSessionIds,
type Worktree,
} from "./worktrees";
+const dirs = vi.hoisted(() => new Set());
+vi.mock("@tauri-apps/api/core", () => ({
+ invoke: async (_command: string, args: { path: string }) =>
+ dirs.has(args.path) ? { path: args.path, identity: "dir" } : null,
+}));
+
const tree: Worktree = {
path: "/repo-worktrees/feature",
branch: "feature",
@@ -217,3 +224,17 @@ describe("sessions kept after worktree deletion", () => {
},
);
});
+
+describe("worktrees removed outside the app", () => {
+ it("detects a deleted worktree only while its parent folder remains", async () => {
+ dirs.clear();
+ dirs.add("/repo-worktrees");
+ dirs.add(tree.path);
+ expect(await worktreeDeletedExternally(tree.path)).toBe(false);
+ dirs.delete(tree.path);
+ expect(await worktreeDeletedExternally(tree.path)).toBe(true);
+ // An unmounted volume takes the parent with it; keep the session bound.
+ dirs.delete("/repo-worktrees");
+ expect(await worktreeDeletedExternally(tree.path)).toBe(false);
+ });
+});
diff --git a/src/features/source-control/model/worktrees.ts b/src/features/source-control/model/worktrees.ts
index 0b41021460..93c3ef4cae 100644
--- a/src/features/source-control/model/worktrees.ts
+++ b/src/features/source-control/model/worktrees.ts
@@ -1,8 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import { appendReadyHandoff, buildDeterministicHandoff } from "../../sessions/model/handoff";
-import { invokeWorkspace, notifyGitChanged } from "../../../platform/tauri/fs";
+import {
+ invokeWorkspace,
+ notifyGitChanged,
+ resolveProjectLocation,
+} from "../../../platform/tauri/fs";
import { isFilesystemTab, type FilePaneTab } from "../../workspace/model/layout";
-import { isEqualOrInside, pathKey } from "../../../shared/lib/paths";
+import { isEqualOrInside, parentPath, pathKey } from "../../../shared/lib/paths";
import { isBlankSession } from "../../projects/model/projectReturn";
import { newSession, sessionWorkCwd, type Session } from "../../sessions/model/session";
@@ -126,6 +130,15 @@ export async function removeOrchestrationBranch(
export const checkWorktreeRemoval: RemoveWorktree = (cwd, path, force) =>
invoke("git_worktree_check_remove", { cwd, path, force });
+/** Removed outside the app. A missing parent is more likely an unmounted volume. */
+export async function worktreeDeletedExternally(path: string) {
+ const [tree, parent] = await Promise.all([
+ resolveProjectLocation(path),
+ resolveProjectLocation(parentPath(path)),
+ ]);
+ return !tree && !!parent;
+}
+
export function assertWorktreeFilesClosed(
path: string,
files: readonly FilePaneTab[],
diff --git a/src/features/source-control/ui/GitChangesPanel.tsx b/src/features/source-control/ui/GitChangesPanel.tsx
index 6e41b68f61..ba70e69d72 100644
--- a/src/features/source-control/ui/GitChangesPanel.tsx
+++ b/src/features/source-control/ui/GitChangesPanel.tsx
@@ -29,6 +29,7 @@ import {
useState,
type ReactNode,
} from "react";
+import { Popover } from "../../../shared/ui/Popover";
import { FileTypeIcon } from "../../files/ui/FileTypeIcon";
import {
GitHistoryGraph,
@@ -127,7 +128,7 @@ export function GitChangesPanel({
const { index, reload } = useDiffIndex(cwd, enabled);
const files = index?.files ?? [];
const paneRef = useRef(null);
- const branchMenuRef = useRef(null);
+ const branchMenuRef = useRef(null);
const [branchMenuOpen, setBranchMenuOpen] = useState(false);
// Shared across the header and the changed-files list so no two Git
// mutations ever run against the same checkout at once.
@@ -142,17 +143,6 @@ export function GitChangesPanel({
return () => window.clearTimeout(timer);
}, [status]);
- useEffect(() => {
- if (!branchMenuOpen) return;
- const onPointer = (event: PointerEvent) => {
- if (!branchMenuRef.current?.contains(event.target as Node)) {
- setBranchMenuOpen(false);
- }
- };
- window.addEventListener("pointerdown", onPointer);
- return () => window.removeEventListener("pointerdown", onPointer);
- }, [branchMenuOpen]);
-
const canPull = Boolean(index?.remote) && Boolean(index?.upstream);
const pull = async () => {
@@ -202,10 +192,7 @@ export function GitChangesPanel({
) : null}
{index?.branch ? (
-
+
{index.branch}
@@ -221,6 +208,7 @@ export function GitChangesPanel({
) : null}
{branchMenuOpen ? (
- setBranchMenuOpen(false)}
role="menu"
aria-label="Branch actions"
- className="absolute top-full right-0 z-30 mt-1 min-w-36 rounded-md border border-content/10 bg-background-base py-1 shadow-lg"
+ className="min-w-36 p-1"
>
void pull()}
- className="flex h-7 w-full items-center gap-2 px-3 text-left text-[12px] text-content hover:bg-content/10 disabled:opacity-40"
+ className="flex h-7 w-full items-center gap-2 rounded-lg px-2 text-left text-[12px] text-content hover:bg-content/10 disabled:opacity-40"
>
{busy === "pull" ? (
-
+
) : null}
) : (
@@ -436,15 +427,6 @@ function ChangedFiles({
[cwd, setBusy],
);
- useEffect(() => {
- if (!menuOpen) return;
- const onPointer = (event: PointerEvent) => {
- if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false);
- };
- window.addEventListener("pointerdown", onPointer);
- return () => window.removeEventListener("pointerdown", onPointer);
- }, [menuOpen]);
-
const toggleView = () => {
changesView = view === "tree" ? "list" : "tree";
saveChangesView(changesView);
@@ -785,17 +767,20 @@ function ChangedFiles({
{menuOpen ? (
-
setMenuOpen(false)}
role="menu"
aria-label="Commit options"
- className="absolute top-full right-0 z-30 mt-1 min-w-48 rounded-md border border-content/10 bg-background-base py-1 shadow-lg"
+ className="min-w-48 p-1"
>
void commit(true)}
- className="flex h-7 w-full items-center px-3 text-left text-[12px] text-content hover:bg-content/10 disabled:opacity-40"
+ className="flex h-7 w-full items-center rounded-lg px-2 text-left text-[12px] text-content hover:bg-content/10 disabled:opacity-40"
>
Commit & Push
@@ -804,17 +789,17 @@ function ChangedFiles({
role="menuitem"
disabled={!canCommitPushPr}
onClick={() => void commit(true, true)}
- className="flex h-7 w-full items-center px-3 text-left text-[12px] text-content hover:bg-content/10 disabled:opacity-40"
+ className="flex h-7 w-full items-center rounded-lg px-2 text-left text-[12px] text-content hover:bg-content/10 disabled:opacity-40"
>
Commit, Push & Create PR
-
+
void toggleAmend()}
- className="flex h-7 w-full items-center justify-between gap-2 px-3 text-left text-[12px] text-content hover:bg-content/10"
+ className="flex h-7 w-full items-center justify-between gap-2 rounded-lg px-2 text-left text-[12px] text-content hover:bg-content/10"
>
Amend Last Commit
@@ -823,7 +808,7 @@ function ChangedFiles({
) : null}
-
+
) : null}
{index ? (
@@ -1696,7 +1681,8 @@ function useDiffIndex(
return { index, reload };
}
-function cachedIndex(cwd: string | undefined): GitDiffIndex | null {
+/** The last Git status read for a checkout, shared so other views open on it. */
+export function cachedIndex(cwd: string | undefined): GitDiffIndex | null {
if (!cwd || cwd === "~") return null;
return indexByCwd.get(cwd) ?? null;
}
@@ -1728,7 +1714,12 @@ function changedFilePaths(prev: GitDiffIndex, next: GitDiffIndex): string[] {
return paths;
}
-function sameIndex(prev: GitDiffIndex | null, next: GitDiffIndex): boolean {
+export function rememberIndex(cwd: string, index: GitDiffIndex | null) {
+ if (index) indexByCwd.set(cwd, index);
+ else indexByCwd.delete(cwd);
+}
+
+export function sameIndex(prev: GitDiffIndex | null, next: GitDiffIndex): boolean {
if (!prev) return false;
if (
prev.branch !== next.branch ||
diff --git a/src/features/source-control/ui/SessionChangesDiff.tsx b/src/features/source-control/ui/SessionChangesDiff.tsx
index 894928924c..0770553dd9 100644
--- a/src/features/source-control/ui/SessionChangesDiff.tsx
+++ b/src/features/source-control/ui/SessionChangesDiff.tsx
@@ -1,4 +1,10 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentProps,
+} from "react";
import { AlertCircle, Loader } from "../../../shared/ui/icons";
import {
sessionCheckpointFileDiff,
@@ -16,6 +22,8 @@ type Props = {
focusPath?: string;
/** Limit the review to these session file paths. */
paths?: readonly string[];
+ /** Which files start open; the focused one always does. */
+ initialExpansion?: ComponentProps["initialExpansion"];
};
type LoadedDiff = {
@@ -33,6 +41,7 @@ export function SessionChangesDiff({
sessionId,
focusPath,
paths,
+ initialExpansion,
}: Props) {
const scope = paths == null ? undefined : JSON.stringify(paths);
// Only the load order uses the focus; moving it must not restart the load.
@@ -194,7 +203,12 @@ export function SessionChangesDiff({
}
return (
-
+
);
}
diff --git a/src/features/source-control/ui/UnifiedDiffView.expansion.test.ts b/src/features/source-control/ui/UnifiedDiffView.expansion.test.ts
new file mode 100644
index 0000000000..6bfcdb9fd3
--- /dev/null
+++ b/src/features/source-control/ui/UnifiedDiffView.expansion.test.ts
@@ -0,0 +1,60 @@
+// @vitest-environment happy-dom
+import { act, createElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+
+vi.mock("../../files/editor/syntaxTokens", () => ({
+ highlightDiffFile: vi.fn(() => Promise.resolve(null)),
+}));
+
+import { buildUnifiedFile } from "../model/unifiedDiff";
+import { UnifiedDiffView } from "./UnifiedDiffView";
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+const diff = buildUnifiedFile("alpha\n", "beta\n");
+const files = ["a.ts", "b.ts", "c.ts"].map((id) => ({
+ id,
+ path: `/repo/${id}`,
+ label: id,
+ additions: diff.additions,
+ deletions: diff.deletions,
+ blocks: diff.blocks,
+}));
+
+async function render(focusPath?: string) {
+ await act(async () =>
+ root.render(
+ createElement(UnifiedDiffView, {
+ files,
+ focusPath,
+ initialExpansion: "first",
+ }),
+ ),
+ );
+ return [...container.querySelectorAll("[aria-expanded]")].map((toggle) =>
+ toggle.getAttribute("aria-expanded"),
+ );
+}
+
+it("opens only the first file", async () => {
+ expect(await render()).toEqual(["true", "false", "false"]);
+});
+
+it("also opens a focused file the others fold away from", async () => {
+ expect(await render("/repo/c.ts")).toEqual(["true", "false", "true"]);
+});
diff --git a/src/features/source-control/ui/UnifiedDiffView.tsx b/src/features/source-control/ui/UnifiedDiffView.tsx
index bb3c5021d8..042b0afd43 100644
--- a/src/features/source-control/ui/UnifiedDiffView.tsx
+++ b/src/features/source-control/ui/UnifiedDiffView.tsx
@@ -126,15 +126,16 @@ export function UnifiedDiffView({
if (!resolvedFocusId) return;
const node = fileRefs.current.get(resolvedFocusId);
if (!node) return;
+ // A file asked for opens, even where the others start folded.
+ setOpen((current) =>
+ current.has(resolvedFocusId)
+ ? current
+ : new Set(current).add(resolvedFocusId),
+ );
const scroller = scrollerRef.current;
if (!scroller) {
- // Embedded review surfaces jump in from a file list, so open the file
- // and let the ancestor that owns scrolling bring it up.
- setOpen((current) =>
- current.has(resolvedFocusId)
- ? current
- : new Set(current).add(resolvedFocusId),
- );
+ // Embedded review surfaces jump in from a file list; let the ancestor
+ // that owns scrolling bring it up.
node.scrollIntoView({ block: "start" });
return;
}
diff --git a/src/features/terminal/ui/TerminalView.test.ts b/src/features/terminal/ui/TerminalView.test.ts
index d2eccbfe16..aee230f833 100644
--- a/src/features/terminal/ui/TerminalView.test.ts
+++ b/src/features/terminal/ui/TerminalView.test.ts
@@ -2,6 +2,13 @@
import { act, createElement, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
+import type { WebLinksAddon } from "@xterm/addon-web-links";
+import type { ILink, ITerminalOptions } from "@xterm/xterm";
+import { openUrl } from "@tauri-apps/plugin-opener";
+
+vi.mock("@tauri-apps/plugin-opener", () => ({
+ openUrl: vi.fn(async () => {}),
+}));
const pty = vi.hoisted(() => ({
spawnPty: vi.fn(async () => {}),
@@ -12,7 +19,10 @@ const pty = vi.hoisted(() => ({
getPtyStatus: vi.fn(async () => ({ foreground: null })),
}));
vi.mock("../../../platform/tauri/pty", () => pty);
-const xterm = vi.hoisted(() => ({ options: [] as { fontFamily?: string }[] }));
+const xterm = vi.hoisted(() => ({
+ options: [] as ITerminalOptions[],
+ loadAddon: vi.fn(),
+}));
vi.mock("../model/terminalLayout", () => ({
fitTerminal: () => null,
applyTerminalChrome: () => {},
@@ -20,7 +30,7 @@ vi.mock("../model/terminalLayout", () => ({
}));
vi.mock("@xterm/xterm", () => ({
Terminal: class {
- constructor(options: { fontFamily?: string }) {
+ constructor(options: ITerminalOptions) {
xterm.options.push(options);
}
cols = 80;
@@ -32,6 +42,8 @@ vi.mock("@xterm/xterm", () => ({
onBufferChange: () => ({ dispose() {} }),
};
open() {}
+ /** Accept addon registration without activating addons in the terminal mock. */
+ loadAddon = xterm.loadAddon;
focus() {}
dispose() {}
writeln() {}
@@ -67,6 +79,105 @@ function setup() {
return { host, root: createRoot(host) };
}
+it("detects plain HTTP(S) URLs and opens them through the addon callback", async () => {
+ const { host, root } = setup();
+ const { Terminal } =
+ await vi.importActual("@xterm/xterm");
+ const terminal = new Terminal({ cols: 200, rows: 1 });
+ const registerLinkProvider = vi.spyOn(terminal, "registerLinkProvider");
+ let addon: WebLinksAddon | undefined;
+ try {
+ await act(async () => {
+ root.render(
+ createElement(TerminalView, {
+ id: "plain-links",
+ cwd: "/tmp",
+ active: true,
+ }),
+ );
+ });
+ expect(xterm.loadAddon).toHaveBeenCalledTimes(1);
+ addon = xterm.loadAddon.mock.calls[0][0] as WebLinksAddon;
+ addon.activate(terminal);
+ const urls = [
+ "http://example.com",
+ "https://example.com/path",
+ "HTTPS://example.com",
+ ];
+ const blocked = [
+ "file:///tmp/test",
+ "javascript:alert(1)",
+ "mailto:test@example.com",
+ "ftp://example.com",
+ ];
+ await new Promise((resolve) => {
+ terminal.write([...urls, ...blocked].join(" "), resolve);
+ });
+ const provider = registerLinkProvider.mock.calls[0][0];
+ const links = await new Promise((resolve) => {
+ provider.provideLinks(1, resolve);
+ });
+ expect(links?.map((link) => link.text)).toEqual(urls);
+ for (const link of links!) {
+ link.activate(new MouseEvent("click"), link.text);
+ }
+ expect(vi.mocked(openUrl).mock.calls).toEqual(urls.map((url) => [url]));
+ vi.mocked(openUrl).mockClear();
+ // Exercise the callback's guard even if a provider dispatches another scheme.
+ for (const uri of blocked) {
+ links![0].activate(new MouseEvent("click"), uri);
+ }
+ expect(openUrl).not.toHaveBeenCalled();
+ } finally {
+ addon?.dispose();
+ terminal.dispose();
+ await act(async () => {
+ root.unmount();
+ });
+ host.remove();
+ }
+});
+
+it("opens only HTTP(S) URLs dispatched by the OSC 8 link handler", async () => {
+ const { host, root } = setup();
+ try {
+ await act(async () => {
+ root.render(
+ createElement(TerminalView, {
+ id: "osc-links",
+ cwd: "/tmp",
+ active: true,
+ }),
+ );
+ });
+ const handler = xterm.options[0].linkHandler!;
+ const urls = [
+ "http://example.com",
+ "https://example.com/path",
+ "HTTPS://example.com",
+ ];
+ for (const uri of urls) {
+ handler.activate(new MouseEvent("click"), uri, undefined);
+ }
+ expect(vi.mocked(openUrl).mock.calls).toEqual(urls.map((url) => [url]));
+ vi.mocked(openUrl).mockClear();
+ for (const uri of [
+ "file:///tmp/test",
+ "javascript:alert(1)",
+ "mailto:test@example.com",
+ "ftp://example.com",
+ ]) {
+ handler.activate(new MouseEvent("click"), uri, undefined);
+ }
+ expect(openUrl).not.toHaveBeenCalled();
+ } finally {
+ await act(async () => {
+ root.unmount();
+ });
+ host.remove();
+ }
+});
+
it("does not let StrictMode cleanup kill the replacement shell", async () => {
const { host, root } = setup();
try {
diff --git a/src/features/terminal/ui/TerminalView.tsx b/src/features/terminal/ui/TerminalView.tsx
index 58ad227111..ac03da1c67 100644
--- a/src/features/terminal/ui/TerminalView.tsx
+++ b/src/features/terminal/ui/TerminalView.tsx
@@ -1,4 +1,6 @@
+import { WebLinksAddon } from "@xterm/addon-web-links";
import { Terminal } from "@xterm/xterm";
+import { openUrl } from "@tauri-apps/plugin-opener";
import { useEffect, useRef } from "react";
import {
getPtyStatus,
@@ -145,6 +147,15 @@ function oscColors() {
};
}
+/**
+ * Open an HTTP(S) terminal link in the default browser, ignoring other schemes
+ * and opener failures.
+ */
+function openTerminalLink(uri: string) {
+ if (!/^https?:\/\//i.test(uri)) return;
+ void openUrl(uri).catch(() => undefined);
+}
+
export function TerminalView({ id, cwd, active, onMetaChange }: Props) {
const outerRef = useRef(null);
const hostRef = useRef(null);
@@ -172,7 +183,9 @@ export function TerminalView({ id, cwd, active, onMetaChange }: Props) {
smoothScrollDuration: 0,
theme: terminalTheme(isLightScheme()),
macOptionIsMeta: IS_MAC,
+ linkHandler: { activate: (_event, uri) => openTerminalLink(uri) },
});
+ term.loadAddon(new WebLinksAddon((_event, uri) => openTerminalLink(uri)));
term.open(host);
termRef.current = term;
let closed = false;
diff --git a/src/integrations/harness/core/apply.test.ts b/src/integrations/harness/core/apply.test.ts
index 979dfd244d..dc2a99ed96 100644
--- a/src/integrations/harness/core/apply.test.ts
+++ b/src/integrations/harness/core/apply.test.ts
@@ -597,6 +597,51 @@ describe("usage limits", () => {
});
});
+describe("punctuation-only prose", () => {
+ it("drops a fragment that holds no words once the next block starts", () => {
+ let session = appendUser(newSession("omp", "/tmp"), "go");
+ session = applyHarnessEvent(session, { type: "message.delta", text: "." });
+ session = applyHarnessEvent(session, {
+ type: "tool.started",
+ callId: "read-1",
+ title: "Read README.md",
+ });
+ session = applyHarnessEvent(session, { type: "message.delta", text: "…" });
+ session = applyHarnessEvent(session, {
+ type: "tool.started",
+ callId: "read-2",
+ title: "Read index.ts",
+ });
+ expect(session.blocks.map((block) => block.role)).toEqual([
+ "user",
+ "tool",
+ "tool",
+ ]);
+ });
+
+ it("drops it when the message completes or the turn stops", () => {
+ let session = appendUser(newSession("omp", "/tmp"), "go");
+ session = applyHarnessEvent(session, { type: "message.delta", text: "." });
+ session = applyHarnessEvent(session, { type: "message.completed" });
+ session = applyHarnessEvent(session, { type: "message.delta", text: " . " });
+ session = stopStreaming(session);
+ expect(session.blocks.map((block) => block.role)).toEqual(["user"]);
+ });
+
+ it("keeps short messages with words, numbers or emoji", () => {
+ let session = appendUser(newSession("omp", "/tmp"), "go");
+ for (const text of ["OK.", "42", "👍"]) {
+ session = applyHarnessEvent(session, { type: "message.delta", text });
+ session = applyHarnessEvent(session, { type: "message.completed" });
+ }
+ expect(
+ session.blocks
+ .filter((block) => block.role === "assistant")
+ .map((block) => block.text),
+ ).toEqual(["OK.", "42", "👍"]);
+ });
+});
+
describe("status blocks", () => {
it("keeps one row when the same status repeats", () => {
let session = appendUser(newSession("claude", "/tmp"), "go");
diff --git a/src/integrations/harness/core/apply.ts b/src/integrations/harness/core/apply.ts
index cc6a3a83c5..70e0956124 100644
--- a/src/integrations/harness/core/apply.ts
+++ b/src/integrations/harness/core/apply.ts
@@ -17,6 +17,7 @@ import {
stubFilePreview,
} from "./preview";
import { joinStreamText } from "./streamText";
+import { hasReadableText } from "../../../features/sessions/model/transcriptActivity";
import { taskListText } from "../../../features/sessions/model/taskList";
import { isReviewablePlan } from "../../../features/sessions/model/plan";
import { resolveModel } from "../../../features/sessions/model/models";
@@ -533,7 +534,12 @@ export function stopStreaming(session: Session, endedAt = Date.now()): Session {
busy: false,
turnReady: false,
pendingQuestion: undefined,
- blocks: stampTurnDuration(settled.blocks.map(stopBlockProgress), endedAt),
+ blocks: stampTurnDuration(
+ settled.blocks
+ .filter((block) => !isBareProseStream(block))
+ .map(stopBlockProgress),
+ endedAt,
+ ),
};
}
@@ -1230,10 +1236,19 @@ function sealLastStream(blocks: Block[]): Block[] {
return blocks.slice();
}
const next = blocks.slice();
- next[index] = { ...last, streaming: false };
+ if (isBareProseStream(last)) next.splice(index, 1);
+ else next[index] = { ...last, streaming: false };
return next;
}
+function isBareProseStream(block: Block): boolean {
+ return (
+ block.role === "assistant" &&
+ !!block.streaming &&
+ !hasReadableText(block.text)
+ );
+}
+
function displayLabel(
patch: { title?: string; kind?: string },
prev?: Block,
@@ -1336,10 +1351,9 @@ function isCallId(value: string): boolean {
function finishRole(session: Session, role: Block["role"]): Session {
return {
...session,
- blocks: session.blocks.map((block) =>
- block.role === role && block.streaming
- ? { ...block, streaming: false }
- : block,
- ),
+ blocks: session.blocks.flatMap((block) => {
+ if (block.role !== role || !block.streaming) return [block];
+ return isBareProseStream(block) ? [] : [{ ...block, streaming: false }];
+ }),
};
}
diff --git a/src/integrations/harness/core/providerNames.ts b/src/integrations/harness/core/providerNames.ts
new file mode 100644
index 0000000000..7852e59cc3
--- /dev/null
+++ b/src/integrations/harness/core/providerNames.ts
@@ -0,0 +1,33 @@
+// Display names for upstream providers that multi-provider harnesses
+// (OpenCode, Pi/omp, Hermes) report only as slugs.
+const PROVIDER_NAMES: Record = {
+ anthropic: "Anthropic",
+ deepseek: "DeepSeek",
+ "github-copilot": "GitHub Copilot",
+ "google-antigravity": "Antigravity",
+ "google-gemini-cli": "Gemini CLI",
+ "lm-studio": "LM Studio",
+ ollama: "Ollama",
+ openai: "OpenAI",
+ "openai-codex": "OpenAI Codex",
+ opencode: "OpenCode",
+ "opencode-go": "OpenCode Go",
+ openrouter: "OpenRouter",
+ xai: "xAI",
+};
+
+/** Resolve a provider slug to its display name, title-casing unknown providers. */
+export function upstreamProviderName(providerID: string): string {
+ return PROVIDER_NAMES[providerID] ?? titleCaseSlug(providerID);
+}
+
+/** Capitalize slug segments separated by hyphens, underscores, or slashes. */
+export function titleCaseSlug(value: string): string {
+ const segments: string[] = [];
+ for (const segment of value.split(/[-_/]+/)) {
+ if (segment.length > 0) {
+ segments.push(segment.charAt(0).toUpperCase() + segment.slice(1));
+ }
+ }
+ return segments.join(" ");
+}
diff --git a/src/integrations/harness/providers/claude/claudeCatalog.ts b/src/integrations/harness/providers/claude/claudeCatalog.ts
index 1e26b2c3db..dfef38310c 100644
--- a/src/integrations/harness/providers/claude/claudeCatalog.ts
+++ b/src/integrations/harness/providers/claude/claudeCatalog.ts
@@ -388,6 +388,9 @@ function modelFromListRow(raw: unknown): AgentModel | null {
harness: "claude",
name,
nativeId,
+ ...(fromResolved.id && fromResolved.id !== nativeId
+ ? { aliasTarget: fromResolved.id }
+ : {}),
...(settings.length > 0 ? { settings } : {}),
};
}
diff --git a/src/integrations/harness/providers/claude/claudeProtocol.test.ts b/src/integrations/harness/providers/claude/claudeProtocol.test.ts
index d6cfa17832..3602d92ce0 100644
--- a/src/integrations/harness/providers/claude/claudeProtocol.test.ts
+++ b/src/integrations/harness/providers/claude/claudeProtocol.test.ts
@@ -524,6 +524,22 @@ describe("list_models catalog", () => {
expect(haiku?.settings).toBeUndefined();
});
+ it("records the concrete model behind live-catalog aliases", () => {
+ const models = modelsFromClaudeListModels([
+ { value: "opus", resolvedModel: "claude-opus-5-5", displayName: "Opus" },
+ {
+ value: "claude-fable-5-1[1m]",
+ resolvedModel: "claude-fable-5-1",
+ displayName: "Fable 5.1",
+ },
+ ]);
+
+ expect(models.map((model) => model.aliasTarget)).toEqual([
+ "claude-opus-5-5",
+ undefined,
+ ]);
+ });
+
it("adds resolved versions to generic live-catalog alias labels", () => {
const models = modelsFromClaudeListModels([
{
diff --git a/src/integrations/harness/providers/codex/codex.ts b/src/integrations/harness/providers/codex/codex.ts
index b9d294affd..bec3cf89d2 100644
--- a/src/integrations/harness/providers/codex/codex.ts
+++ b/src/integrations/harness/providers/codex/codex.ts
@@ -19,6 +19,7 @@ import {
watchChild,
} from "../../core/child";
import {
+ addCodexTurnUsage,
asRecord,
buildThreadStartParams,
buildTurnStartParams,
@@ -32,6 +33,7 @@ import {
stringField,
toCodexApprovalDecision,
type CodexApprovalKind,
+ type CodexTurnUsage,
} from "./codexProtocol";
import { JsonRpcClient, type JsonRpcId } from "../../core/jsonRpc";
import {
@@ -125,6 +127,7 @@ type Live = {
rateLimits: Map>;
/** The active turn failed on a spent usage limit. */
usageLimited: boolean;
+ turnUsage: CodexTurnUsage;
};
function trackNotificationQueue(live: Live, queued: Promise): void {
@@ -785,6 +788,7 @@ async function ensureLive(input: HarnessSessionInput): Promise {
openAgentRows: new Map(),
rateLimits: new Map(),
usageLimited: false,
+ turnUsage: { turn: {} },
};
liveRef.current = live;
liveByThread.set(input.sessionId, live);
@@ -833,6 +837,7 @@ async function runTurn(live: Live, input: SendTurnInput): Promise {
live.emittedReasoningByItem.clear();
live.emittedGeneratedImages.clear();
live.emittedAsyncQuestions.clear();
+ live.turnUsage.turn = {};
live.turnGeneration += 1;
const turnPromise = new Promise((resolve, reject) => {
@@ -873,6 +878,7 @@ async function runCompaction(live: Live): Promise {
live.emittedAssistantByItem.clear();
live.emittedReasoningByItem.clear();
live.emittedGeneratedImages.clear();
+ live.turnUsage.turn = {};
const turnPromise = new Promise((resolve, reject) => {
live.turnDone = resolve;
live.turnFailed = reject;
@@ -970,6 +976,15 @@ function handleNotification(
}
live.onEvent(event);
}
+ // A resumed thread can replay an earlier turn's reading; count only this turn's.
+ const usageTurnId = stringField(rec, "turnId");
+ if (
+ method === "thread/tokenUsage/updated" &&
+ (!usageTurnId || usageTurnId === live.activeTurnId)
+ ) {
+ const metrics = addCodexTurnUsage(live.turnUsage, params);
+ if (metrics) live.onEvent({ type: "turn.metrics", ...metrics });
+ }
if (method === "item/completed") showCodexAsyncQuestion(live, rec);
// Metadata and steps can arrive before the spawn. Create its row first.
let replay: Promise | undefined;
diff --git a/src/integrations/harness/providers/codex/codexLive.test.ts b/src/integrations/harness/providers/codex/codexLive.test.ts
index 278c932bb7..5d24d4e909 100644
--- a/src/integrations/harness/providers/codex/codexLive.test.ts
+++ b/src/integrations/harness/providers/codex/codexLive.test.ts
@@ -204,6 +204,38 @@ describe("codex live turn sequence", () => {
await turn;
});
+ it("reports the whole turn's tokens, not just its last request", async () => {
+ const { events, turn } = await startTurn("codex-live");
+ const usage = (
+ last: Record,
+ totalTokens: number,
+ turnId = "turn_1",
+ ) =>
+ notify("thread/tokenUsage/updated", {
+ threadId: "thr_1",
+ turnId,
+ tokenUsage: { last, total: { totalTokens }, modelContextWindow: null },
+ });
+ // A reading replayed from an earlier turn stays out of this one.
+ usage(
+ { totalTokens: 550, inputTokens: 500, outputTokens: 50 },
+ 550,
+ "turn_0",
+ );
+ usage({ totalTokens: 1_100, inputTokens: 1_000, outputTokens: 100 }, 1_100);
+ usage({ totalTokens: 2_200, inputTokens: 2_000, outputTokens: 200 }, 3_300);
+ notify("turn/completed", { turn: { id: "turn_1", status: "completed" } });
+ await turn;
+
+ expect(
+ events.filter((event) => event.type === "turn.metrics").at(-1),
+ ).toEqual({
+ type: "turn.metrics",
+ inputTokens: 3_000,
+ outputTokens: 300,
+ });
+ });
+
it("keeps ephemeral helper threads out of saved Codex history", async () => {
const first = await startTurn("codex-live", { ephemeral: true });
expect(
diff --git a/src/integrations/harness/providers/codex/codexProtocol.test.ts b/src/integrations/harness/providers/codex/codexProtocol.test.ts
index 6aacb3e3f8..e996c1fd28 100644
--- a/src/integrations/harness/providers/codex/codexProtocol.test.ts
+++ b/src/integrations/harness/providers/codex/codexProtocol.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ addCodexTurnUsage,
buildThreadStartParams,
buildTurnStartParams,
buildTurnSteerParams,
@@ -979,13 +980,6 @@ describe("mapCodexNotification thread/tokenUsage/updated", () => {
});
expect(mapped.events).toEqual([
{ type: "context", used: 42_000, window: 272_000 },
- {
- type: "turn.metrics",
- inputTokens: 40_000,
- cacheReadTokens: 30_000,
- outputTokens: 2_000,
- cacheHitPercent: 75,
- },
]);
});
@@ -1022,6 +1016,55 @@ describe("mapCodexNotification thread/tokenUsage/updated", () => {
});
});
+describe("addCodexTurnUsage", () => {
+ const reading = (
+ last: Record,
+ total: Record,
+ ) => ({
+ threadId: "t1",
+ turnId: "turn1",
+ tokenUsage: { last, total, modelContextWindow: 272_000 },
+ });
+
+ it("adds up every request in the turn, once each", () => {
+ const spent = { threadTotal: 100_000, turn: {} };
+ const first = {
+ totalTokens: 41_000,
+ inputTokens: 40_000,
+ cachedInputTokens: 30_000,
+ cacheWriteInputTokens: 0,
+ outputTokens: 1_000,
+ };
+ const second = {
+ totalTokens: 52_000,
+ inputTokens: 50_000,
+ cachedInputTokens: 0,
+ cacheWriteInputTokens: 0,
+ outputTokens: 2_000,
+ };
+ expect(
+ addCodexTurnUsage(spent, reading(first, { totalTokens: 141_000 })),
+ ).toEqual({
+ inputTokens: 40_000,
+ outputTokens: 1_000,
+ cacheReadTokens: 30_000,
+ cacheHitPercent: 75,
+ });
+ // Codex re-sends an unchanged reading after compaction or a usage limit.
+ expect(
+ addCodexTurnUsage(spent, reading(first, { totalTokens: 141_000 })),
+ ).toBeNull();
+ expect(
+ addCodexTurnUsage(spent, reading(second, { totalTokens: 193_000 })),
+ ).toEqual({
+ inputTokens: 90_000,
+ outputTokens: 3_000,
+ cacheReadTokens: 30_000,
+ cacheHitPercent: (30_000 / 90_000) * 100,
+ });
+ });
+});
+
describe("mapCodexSubagentSteps", () => {
const subagentBash = (status: string, output?: string) =>
mapCodexSubagentSteps("agent-1", "item/completed", {
diff --git a/src/integrations/harness/providers/codex/codexProtocol.ts b/src/integrations/harness/providers/codex/codexProtocol.ts
index 691e178c2e..8723b4b2a6 100644
--- a/src/integrations/harness/providers/codex/codexProtocol.ts
+++ b/src/integrations/harness/providers/codex/codexProtocol.ts
@@ -535,12 +535,59 @@ function mapTokenUsage(rec: Record): MappedCodexNotification {
if (!last) return { events: [] };
const used = numberField(last, "totalTokens");
const window = numberField(usage, "modelContextWindow");
- const inputTokens = numberField(last, "inputTokens");
- const cacheReadTokens = numberField(last, "cachedInputTokens");
- const cacheWriteTokens = numberField(last, "cacheWriteInputTokens");
- const outputTokens = numberField(last, "outputTokens");
+ if (!used && !window) return { events: [] };
+ return {
+ events: [
+ {
+ type: "context",
+ ...(used > 0 ? { used } : {}),
+ ...(window > 0 ? { window } : {}),
+ },
+ ],
+ };
+}
+
+/** What the current Codex turn has spent, summed request by request. */
+export type CodexTurnUsage = {
+ /** `total.totalTokens` of the last reading, to spot a repeated one. */
+ threadTotal?: number;
+ turn: Record;
+};
+
+const CODEX_USAGE_KEYS = [
+ "inputTokens",
+ "cachedInputTokens",
+ "cacheWriteInputTokens",
+ "outputTokens",
+] as const;
+
+/**
+ * `last` in `thread/tokenUsage/updated` covers a single model request, and a
+ * turn with tool calls makes several. Codex also re-sends an unchanged reading
+ * with no new request behind it (after compaction, on a usage limit), so `last`
+ * is only added when the thread's `total` has moved.
+ */
+export function addCodexTurnUsage(
+ spent: CodexTurnUsage,
+ params: unknown,
+): TurnMetrics | null {
+ const usage = asRecord(asRecord(params)?.tokenUsage);
+ const last = asRecord(usage?.last);
+ if (!last) return null;
+ const threadTotal = numberField(asRecord(usage?.total), "totalTokens");
+ if (threadTotal > 0 && threadTotal === spent.threadTotal) return null;
+ spent.threadTotal = threadTotal;
+ for (const key of CODEX_USAGE_KEYS) {
+ if (key in last) {
+ spent.turn[key] = (spent.turn[key] ?? 0) + numberField(last, key);
+ }
+ }
+ const inputTokens = numberField(spent.turn, "inputTokens");
+ const cacheReadTokens = numberField(spent.turn, "cachedInputTokens");
+ const cacheWriteTokens = numberField(spent.turn, "cacheWriteInputTokens");
+ const outputTokens = numberField(spent.turn, "outputTokens");
const cacheReported =
- "cachedInputTokens" in last || "cacheWriteInputTokens" in last;
+ "cachedInputTokens" in spent.turn || "cacheWriteInputTokens" in spent.turn;
const metrics: TurnMetrics = {
...(inputTokens ? { inputTokens } : {}),
...(outputTokens ? { outputTokens } : {}),
@@ -553,22 +600,7 @@ function mapTokenUsage(rec: Record): MappedCodexNotification {
}
: {}),
};
- const hasMetrics = Object.keys(metrics).length > 0;
- if (!used && !window && !hasMetrics) return { events: [] };
- return {
- events: [
- ...(used || window
- ? [
- {
- type: "context" as const,
- ...(used > 0 ? { used } : {}),
- ...(window > 0 ? { window } : {}),
- },
- ]
- : []),
- ...(hasMetrics ? [{ type: "turn.metrics" as const, ...metrics }] : []),
- ],
- };
+ return Object.keys(metrics).length > 0 ? metrics : null;
}
function mapTurnTerminal(
diff --git a/src/integrations/harness/providers/cursor/cursorCatalog.test.ts b/src/integrations/harness/providers/cursor/cursorCatalog.test.ts
new file mode 100644
index 0000000000..66e57ff656
--- /dev/null
+++ b/src/integrations/harness/providers/cursor/cursorCatalog.test.ts
@@ -0,0 +1,174 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { JsonRpcMessage } from "../../core/acp";
+
+const mocks = vi.hoisted(() => ({
+ execChild: vi.fn<(...args: unknown[]) => Promise>(),
+ killChild: vi.fn(async () => undefined),
+ resolveCursorBinary: vi.fn(async () => ({ path: "/fake/cursor-agent" })),
+ setHarnessModels: vi.fn(),
+ spawnChild: vi.fn<(...args: unknown[]) => Promise>(
+ async () => undefined,
+ ),
+ unwatchChild: vi.fn(),
+}));
+
+const sent: JsonRpcMessage[] = [];
+let onLine: ((line: string) => void) | undefined;
+let responses: Record>;
+
+vi.mock("../../../../platform/tauri/fs", () => ({
+ homeDir: async () => "/home/test",
+}));
+vi.mock("../../../../features/sessions/model/models", () => ({
+ setHarnessModels: mocks.setHarnessModels,
+}));
+vi.mock("../../core/child", () => ({
+ ...mocks,
+ watchChild: (_id: string, line: (value: string) => void) => {
+ onLine = line;
+ },
+ writeChild: async (_id: string, line: string) => {
+ const request = JSON.parse(line) as JsonRpcMessage;
+ sent.push(request);
+ const response = responses[request.method!];
+ if (!response) throw new Error(`Unexpected request: ${request.method}`);
+ onLine!(JSON.stringify({ jsonrpc: "2.0", id: request.id, ...response }));
+ },
+}));
+
+import { discoverCursorModels, refreshCursorCatalog } from "./cursorCatalog";
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ sent.length = 0;
+ onLine = undefined;
+ responses = {
+ initialize: {
+ result: { authMethods: [{ id: "cursor_login", name: "Cursor Login" }] },
+ },
+ "cursor/list_available_models": {
+ result: {
+ models: [{ value: "composer-test", name: "Composer Test" }],
+ },
+ },
+ };
+ mocks.execChild.mockReset().mockResolvedValue("");
+ vi.spyOn(console, "debug").mockImplementation(() => undefined);
+});
+
+afterEach(() => {
+ expect(vi.getTimerCount()).toBe(0);
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe("Cursor catalog discovery", () => {
+ it("refreshes models using existing CLI credentials without requesting browser sign-in", async () => {
+ await refreshCursorCatalog();
+
+ expect(mocks.setHarnessModels).toHaveBeenCalledWith("cursor", [
+ {
+ id: "cursor:composer-test",
+ harness: "cursor",
+ name: "Composer Test",
+ nativeId: "composer-test",
+ settings: undefined,
+ },
+ ]);
+ expect(sent.map((request) => request.method)).toEqual([
+ "initialize",
+ "cursor/list_available_models",
+ ]);
+ expect(mocks.execChild).not.toHaveBeenCalled();
+ expect(mocks.spawnChild).toHaveBeenCalledWith(
+ expect.any(String),
+ "/fake/cursor-agent",
+ ["acp"],
+ "/home/test",
+ undefined,
+ "cursor",
+ );
+ const probeId = mocks.spawnChild.mock.calls[0][0];
+ expect(mocks.unwatchChild).toHaveBeenCalledWith(probeId);
+ expect(mocks.killChild).toHaveBeenCalledWith(probeId);
+ });
+
+ it("still discovers models from session setup without an authentication request", async () => {
+ responses["cursor/list_available_models"] = { result: { models: [] } };
+ responses["session/new"] = {
+ result: {
+ models: {
+ availableModels: [{ modelId: "gpt-test", name: "GPT Test" }],
+ },
+ },
+ };
+
+ expect(await discoverCursorModels("/workspace")).toEqual([
+ {
+ id: "cursor:gpt-test",
+ harness: "cursor",
+ name: "GPT Test",
+ nativeId: "gpt-test",
+ },
+ ]);
+ expect(sent.map((request) => request.method)).toEqual([
+ "initialize",
+ "cursor/list_available_models",
+ "session/new",
+ ]);
+ expect(sent.at(-1)?.params).toEqual({ cwd: "/workspace", mcpServers: [] });
+ });
+
+ it("falls back to CLI models when ACP requires login without starting a login flow", async () => {
+ responses["cursor/list_available_models"] = {
+ error: { code: -32000, message: "Authentication required" },
+ };
+ mocks.execChild.mockResolvedValue(
+ "gpt-test-low - GPT Test Low\ngpt-test-high - GPT Test High\n",
+ );
+
+ expect(await discoverCursorModels("/workspace")).toMatchObject([
+ {
+ id: "cursor:gpt-test",
+ name: "GPT Test",
+ nativeId: "gpt-test",
+ settings: [
+ {
+ id: "reasoning",
+ options: [
+ { value: "low", label: "Low" },
+ { value: "high", label: "High" },
+ ],
+ },
+ ],
+ },
+ ]);
+ expect(sent.map((request) => request.method)).toEqual([
+ "initialize",
+ "cursor/list_available_models",
+ ]);
+ expect(mocks.execChild).toHaveBeenCalledWith(
+ "/fake/cursor-agent",
+ ["--list-models"],
+ "/workspace",
+ "cursor",
+ );
+ expect(mocks.killChild).toHaveBeenCalled();
+ });
+
+ it("keeps the bundled catalog when both discovery paths require sign-in", async () => {
+ responses["cursor/list_available_models"] = {
+ error: { code: -32000, message: "Authentication required" },
+ };
+ mocks.execChild.mockRejectedValue(new Error("Not authenticated"));
+
+ await refreshCursorCatalog();
+
+ expect(mocks.setHarnessModels).not.toHaveBeenCalled();
+ expect(sent.some((request) => request.method === "authenticate")).toBe(
+ false,
+ );
+ expect(mocks.killChild).toHaveBeenCalled();
+ });
+});
diff --git a/src/integrations/harness/providers/cursor/cursorCatalog.ts b/src/integrations/harness/providers/cursor/cursorCatalog.ts
index 91ee4e21fa..911a624df6 100644
--- a/src/integrations/harness/providers/cursor/cursorCatalog.ts
+++ b/src/integrations/harness/providers/cursor/cursorCatalog.ts
@@ -88,9 +88,9 @@ async function discoverViaAcp(workingDirectory?: string): Promise
},
REQUEST_TIMEOUT_MS,
);
- await acp
- .request("authenticate", { methodId: "cursor_login" }, REQUEST_TIMEOUT_MS)
- .catch(() => undefined);
+ // ACP reads existing CLI credentials at startup. `cursor_login` can
+ // launch a browser, so a catalog refresh must never request it. Missing
+ // credentials fall back to CLI discovery and the bundled model list.
const listed = await acp.request(
"cursor/list_available_models",
{},
diff --git a/src/integrations/harness/providers/hermes/hermesProtocol.test.ts b/src/integrations/harness/providers/hermes/hermesProtocol.test.ts
index f558d8acd8..a2ffb533a6 100644
--- a/src/integrations/harness/providers/hermes/hermesProtocol.test.ts
+++ b/src/integrations/harness/providers/hermes/hermesProtocol.test.ts
@@ -77,9 +77,22 @@ describe("Hermes ACP protocol", () => {
models: {
currentModelId: "nous:hermes-4",
availableModels: [
- { modelId: "openrouter:gpt-5", name: "OpenRouter · GPT-5" },
- { modelId: "nous:hermes-4", name: "Nous · Hermes 4" },
+ {
+ modelId: "openrouter:gpt-5",
+ name: "OpenRouter · gpt-5",
+ description: "Provider: OpenRouter",
+ },
+ {
+ modelId: "nous:hermes-4",
+ name: "Nous Portal · hermes-4",
+ description: "Provider: Nous Portal • current",
+ },
{ modelId: "nous:hermes-4", name: "duplicate" },
+ {
+ modelId: "custom:ollama:llama3:8b",
+ name: "llama3:8b",
+ description: "Provider: Ollama • Local • current",
+ },
],
},
};
@@ -90,14 +103,23 @@ describe("Hermes ACP protocol", () => {
{
id: "hermes:nous:hermes-4",
harness: "hermes",
- name: "Nous · Hermes 4",
+ name: "hermes-4",
nativeId: "nous:hermes-4",
+ provider: { id: "nous", name: "Nous Portal" },
},
{
id: "hermes:openrouter:gpt-5",
harness: "hermes",
- name: "OpenRouter · GPT-5",
+ name: "gpt-5",
nativeId: "openrouter:gpt-5",
+ provider: { id: "openrouter", name: "OpenRouter" },
+ },
+ {
+ id: "hermes:custom:ollama:llama3:8b",
+ harness: "hermes",
+ name: "llama3:8b",
+ nativeId: "custom:ollama:llama3:8b",
+ provider: { id: "custom:ollama", name: "Ollama" },
},
]);
});
diff --git a/src/integrations/harness/providers/hermes/hermesProtocol.ts b/src/integrations/harness/providers/hermes/hermesProtocol.ts
index 974c65e56c..db80aa75ec 100644
--- a/src/integrations/harness/providers/hermes/hermesProtocol.ts
+++ b/src/integrations/harness/providers/hermes/hermesProtocol.ts
@@ -1,6 +1,7 @@
import { promptBlocks, type PromptContentBlock } from "../../../../features/sessions/model/attachments";
import type { AgentModel } from "../../../../features/sessions/model/models";
import type { Attachment, RuntimeMode } from "../../../../features/sessions/model/session";
+import { upstreamProviderName } from "../../core/providerNames";
export type HermesBackgroundDispatch = {
callId: string;
@@ -146,12 +147,15 @@ export function modelsFromHermesSession(result: unknown): AgentModel[] {
).trim();
if (!nativeId || seen.has(nativeId)) continue;
seen.add(nativeId);
- const name = String(model.name ?? model.title ?? nativeId).trim();
+ const rawName = String(model.name ?? model.title ?? nativeId).trim();
+ const provider = hermesModelProvider(nativeId, rawName, model.description);
+ const name = provider ? stripProviderPrefix(rawName, provider.name) : rawName;
models.push({
id: `hermes:${nativeId}`,
harness: "hermes",
name: name || displayName(nativeId),
nativeId,
+ ...(provider ? { provider } : {}),
});
}
@@ -164,6 +168,40 @@ export function modelsFromHermesSession(result: unknown): AgentModel[] {
return models;
}
+/**
+ * Hermes encodes picker ids as `provider:model` (`custom::model` for
+ * user-defined endpoints) and labels rows `Provider: ` in their
+ * description, with the inventory rows also named ` · `.
+ */
+function hermesModelProvider(
+ nativeId: string,
+ name: string,
+ description: unknown,
+): AgentModel["provider"] {
+ const parts = nativeId.split(":");
+ if (parts.length < 2) return undefined;
+ const id = parts[0] === "custom" && parts.length > 2
+ ? `${parts[0]}:${parts[1]}`
+ : parts[0];
+ const described =
+ typeof description === "string"
+ ? /^Provider:\s*([^•]+)/.exec(description)?.[1]?.trim()
+ : undefined;
+ const prefixed = name.includes(" · ")
+ ? name.slice(0, name.indexOf(" · ")).trim()
+ : undefined;
+ return {
+ id,
+ name: described || prefixed || upstreamProviderName(id.replace(/^custom:/, "")),
+ };
+}
+
+/** Remove a matching provider label now shown by the picker's group heading. */
+function stripProviderPrefix(name: string, provider: string): string {
+ const prefix = `${provider} · `;
+ return name.startsWith(prefix) ? name.slice(prefix.length).trim() : name;
+}
+
function displayName(nativeId: string): string {
const slug = nativeId.includes(":")
? nativeId.slice(nativeId.lastIndexOf(":") + 1)
diff --git a/src/integrations/harness/providers/opencode/opencodeCatalog.ts b/src/integrations/harness/providers/opencode/opencodeCatalog.ts
index cbf6b29f91..2fad8e0fe9 100644
--- a/src/integrations/harness/providers/opencode/opencodeCatalog.ts
+++ b/src/integrations/harness/providers/opencode/opencodeCatalog.ts
@@ -20,6 +20,7 @@ import {
stringField,
titleCaseSlug,
} from "./opencodeProtocol";
+import { upstreamProviderName } from "../../core/providerNames";
const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/;
const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/;
@@ -37,14 +38,6 @@ type ParsedProvider = {
models: Record;
};
-const PROVIDER_NAMES: Record = {
- opencode: "OpenCode",
- "opencode-go": "OpenCode Go",
- openai: "OpenAI",
- xai: "xAI",
- "github-copilot": "GitHub Copilot",
-};
-
export type OpenCodeAgent = {
name: string;
mode: string;
@@ -322,8 +315,9 @@ export function flattenOpenCodeModels(
return models.sort((left, right) => left.name.localeCompare(right.name));
}
+/** Resolve OpenCode provider slugs using the shared upstream display names. */
export function openCodeProviderName(providerID: string): string {
- return PROVIDER_NAMES[providerID] ?? titleCaseSlug(providerID);
+ return upstreamProviderName(providerID);
}
function openCodeModelSettings(
diff --git a/src/integrations/harness/providers/opencode/opencodeProtocol.ts b/src/integrations/harness/providers/opencode/opencodeProtocol.ts
index 9c7621e49d..d15a1f444b 100644
--- a/src/integrations/harness/providers/opencode/opencodeProtocol.ts
+++ b/src/integrations/harness/providers/opencode/opencodeProtocol.ts
@@ -12,6 +12,7 @@ import {
} from "../../../../features/sessions/model/attachments";
import { isTaskListToolName } from "../../../../features/sessions/model/taskList";
import { extractToolPreview } from "../../core/preview";
+import { titleCaseSlug } from "../../core/providerNames";
import type { HarnessEvent } from "../../core/types";
export const MINIMUM_OPENCODE_VERSION = "1.14.19";
@@ -368,15 +369,7 @@ function commonPrefixLength(left: string, right: string): number {
return index;
}
-export function titleCaseSlug(value: string): string {
- const segments: string[] = [];
- for (const segment of value.split(/[-_/]+/)) {
- if (segment.length > 0) {
- segments.push(segment.charAt(0).toUpperCase() + segment.slice(1));
- }
- }
- return segments.join(" ");
-}
+export { titleCaseSlug };
export function inferDefaultVariant(
providerID: string,
diff --git a/src/integrations/harness/providers/pi/piCatalog.test.ts b/src/integrations/harness/providers/pi/piCatalog.test.ts
index 4774e6d32a..b70e622923 100644
--- a/src/integrations/harness/providers/pi/piCatalog.test.ts
+++ b/src/integrations/harness/providers/pi/piCatalog.test.ts
@@ -1,20 +1,20 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
- close: vi.fn(),
- killChild: vi.fn(async () => {}),
- request: vi.fn(async () => ({ data: [] })),
+ killChild: vi.fn(async (_id: string) => {}),
resolveBinary: vi.fn(async () => ({ path: "/fake/pi" })),
+ setHarnessModels: vi.fn(),
spawnChild: vi.fn(async () => {}),
unwatchChild: vi.fn(),
watchChild: vi.fn(),
+ writeChild: vi.fn(async (_id: string, _line: string) => {}),
}));
vi.mock("../../../../platform/tauri/fs", () => ({
homeDir: vi.fn(async () => "/home/test"),
}));
vi.mock("../../../../features/sessions/model/models", () => ({
- setHarnessModels: vi.fn(),
+ setHarnessModels: mocks.setHarnessModels,
}));
vi.mock("../../core/child", () => ({
killChild: mocks.killChild,
@@ -23,27 +23,72 @@ vi.mock("../../core/child", () => ({
spawnChild: mocks.spawnChild,
unwatchChild: mocks.unwatchChild,
watchChild: mocks.watchChild,
+ writeChild: mocks.writeChild,
}));
-vi.mock("./piClient", () => ({
- PiRpc: class {
- close = mocks.close;
- pushLine = vi.fn();
- request = mocks.request;
- },
-}));
-import { discoverOmpModels, discoverPiModels } from "./piCatalog";
+import {
+ discoverOmpModels,
+ discoverPiModels,
+ refreshPiCatalog,
+} from "./piCatalog";
+
+const builtin = { provider: "meta", id: "shared-id", name: "Built-in" };
+const extension = {
+ provider: "antigravity",
+ id: "shared-id",
+ name: "Extension",
+ contextWindow: 200_000,
+};
+
+/**
+ * Answer through the watched child output so tests exercise the real PiRpc
+ * multiplexer, including request ID and command matching, rather than mocking it.
+ */
+function reply(probeId: string, line: string, models: unknown[]) {
+ const request = JSON.parse(line);
+ const onLine = mocks.watchChild.mock.calls.find(([id]) => id === probeId)![1];
+ onLine(
+ JSON.stringify({
+ type: "response",
+ id: request.id,
+ command: request.type,
+ success: true,
+ data: { models },
+ }),
+ );
+}
+
+/**
+ * Check probe teardown and RPC/poll timer cleanup before afterEach clears timers
+ * and could otherwise hide a leak from a successful or failed discovery.
+ */
+function expectStopped() {
+ const probeId = mocks.watchChild.mock.calls[0][0];
+ expect(mocks.unwatchChild).toHaveBeenCalledExactlyOnceWith(probeId);
+ expect(mocks.killChild).toHaveBeenCalledExactlyOnceWith(probeId);
+ expect(vi.getTimerCount()).toBe(0);
+}
beforeEach(() => {
vi.useFakeTimers();
+ vi.setSystemTime(0);
vi.clearAllMocks();
+ mocks.resolveBinary.mockResolvedValue({ path: "/fake/pi" });
+ mocks.spawnChild.mockResolvedValue();
+ mocks.killChild.mockResolvedValue();
+ mocks.writeChild.mockImplementation(async (id, line) => {
+ reply(id, line, [builtin]);
+ });
});
afterEach(() => {
+ vi.clearAllTimers();
vi.useRealTimers();
});
it("loads Pi extensions when discovering package-provided models", async () => {
- await discoverPiModels("/workspace");
+ const result = discoverPiModels("/workspace");
+ await vi.runAllTimersAsync();
+ await result;
expect(mocks.spawnChild).toHaveBeenCalledWith(
expect.any(String),
"/fake/pi",
@@ -52,26 +97,250 @@ it("loads Pi extensions when discovering package-provided models", async () => {
undefined,
"pi",
);
- expect(mocks.request).toHaveBeenCalledWith(
- { type: "get_available_models" },
- 45_000,
+ expect(JSON.parse(mocks.writeChild.mock.calls[0][1]).type).toBe(
+ "get_available_models",
);
+ expectStopped();
});
-it("preserves extension isolation for omp catalog probes", async () => {
- await discoverOmpModels("/workspace");
+it("includes models registered three seconds after RPC starts answering", async () => {
+ mocks.writeChild.mockImplementation(async (id, line) => {
+ reply(id, line, Date.now() < 3_000 ? [builtin] : [builtin, extension]);
+ });
+ let settled = false;
+ const result = discoverPiModels("/workspace").then((models) => {
+ settled = true;
+ return models;
+ });
+ await vi.advanceTimersByTimeAsync(2_999);
+ expect(settled).toBe(false);
+ await vi.runAllTimersAsync();
+ expect((await result).map((model) => model.nativeId)).toEqual([
+ "meta/shared-id",
+ "antigravity/shared-id",
+ ]);
+ expectStopped();
+});
+
+it("loads extensions and preserves immediate discovery for omp", async () => {
+ const models = await discoverOmpModels("/workspace");
+ expect(models.map((model) => model.id)).toEqual(["omp:meta/shared-id"]);
expect(mocks.spawnChild).toHaveBeenCalledWith(
expect.any(String),
"/fake/pi",
- expect.arrayContaining(["--no-extensions"]),
+ ["--mode", "rpc", "--no-session"],
"/workspace",
undefined,
"omp",
);
+ expect(mocks.writeChild).toHaveBeenCalledTimes(1);
+ expect(Date.now()).toBe(0);
+ expectStopped();
+});
+
+it("keeps polling unchanged early snapshots through the startup window", async () => {
+ let settled = false;
+ const result = discoverPiModels("/workspace").then((models) => {
+ settled = true;
+ return models;
+ });
+ await vi.advanceTimersByTimeAsync(4_999);
+ expect(settled).toBe(false);
+ expect(mocks.writeChild).toHaveBeenCalledTimes(10);
+ await vi.advanceTimersByTimeAsync(1);
+ expect((await result).map((model) => model.nativeId)).toEqual([
+ "meta/shared-id",
+ ]);
+ expect(mocks.writeChild).toHaveBeenCalledTimes(11);
+ expectStopped();
+});
+
+it("waits for a quiet period after a late catalog change", async () => {
+ mocks.writeChild.mockImplementation(async (id, line) => {
+ reply(id, line, Date.now() < 4_500 ? [builtin] : [builtin, extension]);
+ });
+ let settled = false;
+ const result = discoverPiModels("/workspace").then((models) => {
+ settled = true;
+ return models;
+ });
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(settled).toBe(false);
+ await vi.advanceTimersByTimeAsync(500);
+ expect(await result).toHaveLength(2);
+ expect(Date.now()).toBe(5_500);
+ expectStopped();
+});
+
+it("merges snapshots by provider/model id and keeps the latest metadata", async () => {
+ mocks.writeChild.mockImplementation(async (id, line) => {
+ reply(
+ id,
+ line,
+ Date.now() < 500
+ ? [builtin, extension]
+ : [{ ...extension, contextWindow: 1_000_000 }],
+ );
+ });
+ const result = discoverPiModels("/workspace");
+ await vi.runAllTimersAsync();
+ const models = await result;
+ expect(models.map((model) => model.nativeId)).toEqual([
+ "meta/shared-id",
+ "antigravity/shared-id",
+ ]);
+ expect(models[1].contextWindow).toBe(1_000_000);
+ expectStopped();
+});
+
+it("discovers extension-only catalogs after initially empty responses", async () => {
+ mocks.writeChild.mockImplementation(async (id, line) => {
+ reply(id, line, Date.now() < 3_000 ? [] : [extension]);
+ });
+ const result = discoverPiModels("/workspace");
+ await vi.runAllTimersAsync();
+ expect((await result).map((model) => model.nativeId)).toEqual([
+ "antigravity/shared-id",
+ ]);
+ expectStopped();
+});
+
+it("finishes an empty catalog without replacing existing models", async () => {
+ mocks.writeChild.mockImplementation(async (id, line) => reply(id, line, []));
+ const result = refreshPiCatalog();
+ await vi.runAllTimersAsync();
+ await result;
+ expect(Date.now()).toBe(5_000);
+ expect(mocks.setHarnessModels).not.toHaveBeenCalled();
+ expectStopped();
+});
+
+it("deduplicates concurrent refreshes and publishes the settled catalog", async () => {
+ mocks.writeChild.mockImplementation(async (id, line) => {
+ reply(id, line, Date.now() < 3_000 ? [builtin] : [builtin, extension]);
+ });
+ const first = refreshPiCatalog();
+ const second = refreshPiCatalog();
+ expect(first).toBe(second);
+ await vi.runAllTimersAsync();
+ await first;
+ expect(mocks.spawnChild).toHaveBeenCalledTimes(1);
+ expect(mocks.setHarnessModels).toHaveBeenCalledExactlyOnceWith(
+ "pi",
+ expect.arrayContaining([
+ expect.objectContaining({ nativeId: "antigravity/shared-id" }),
+ ]),
+ );
+ expectStopped();
+});
+
+it("times out and cleans up a probe that never answers", async () => {
+ mocks.writeChild.mockImplementation(async () => {});
+ const result = expect(discoverPiModels("/workspace")).rejects.toThrow(
+ "Pi model discovery timed out",
+ );
+ await vi.advanceTimersByTimeAsync(45_000);
+ await result;
+ expect(mocks.writeChild).toHaveBeenCalledTimes(1);
+ expectStopped();
});
-it("clears the outer discovery timeout after a successful probe", async () => {
- await discoverPiModels("/workspace");
+it("returns collected models at the deadline if a later request stalls", async () => {
+ mocks.writeChild.mockImplementation(async (id, line) => {
+ if (Date.now() === 0) reply(id, line, [builtin]);
+ });
+ const result = discoverPiModels("/workspace");
+ await vi.advanceTimersByTimeAsync(45_000);
+ expect((await result).map((model) => model.nativeId)).toEqual([
+ "meta/shared-id",
+ ]);
+ expect(mocks.writeChild).toHaveBeenCalledTimes(2);
+ expectStopped();
+ // A reply arriving after cleanup cannot restart discovery or leave RPC timers.
+ const [probeId, line] = mocks.writeChild.mock.calls[1];
+ reply(probeId, line, [builtin, extension]);
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(mocks.writeChild).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
- expect(mocks.killChild).toHaveBeenCalled();
+});
+
+it("keeps collected models when the clock advances during timer setup", async () => {
+ let clock: ReturnType | undefined;
+ mocks.writeChild.mockImplementation(async (id, line) => {
+ if (mocks.writeChild.mock.calls.length === 1) {
+ // Model a millisecond passing between recording startup and installing
+ // the deadline timer. A per-RPC remaining-time timer would expire first.
+ clock = vi
+ .spyOn(Date, "now")
+ .mockImplementation(() => vi.getMockedSystemTime()!.getTime() + 1);
+ reply(id, line, [builtin]);
+ }
+ });
+ try {
+ const result = discoverPiModels("/workspace");
+ await vi.advanceTimersByTimeAsync(45_000);
+ expect((await result).map((model) => model.nativeId)).toEqual([
+ "meta/shared-id",
+ ]);
+ expectStopped();
+ } finally {
+ clock?.mockRestore();
+ }
+});
+
+it("bounds discovery even when the catalog changes on every response", async () => {
+ mocks.writeChild.mockImplementation(async (id, line) => {
+ reply(id, line, [{ ...extension, contextWindow: 200_000 + Date.now() }]);
+ });
+ const result = discoverPiModels("/workspace");
+ await vi.runAllTimersAsync();
+ expect(await result).toHaveLength(1);
+ expect(Date.now()).toBe(45_000);
+ expect(mocks.writeChild).toHaveBeenCalledTimes(90);
+ expectStopped();
+});
+
+it("cancels polling immediately when the process exits between requests", async () => {
+ const result = expect(discoverPiModels("/workspace")).rejects.toThrow(
+ "Pi catalog probe exited",
+ );
+ await vi.advanceTimersByTimeAsync(250);
+ mocks.watchChild.mock.calls[0][2]();
+ await result;
+ expect(Date.now()).toBe(250);
+ expectStopped();
+ await vi.advanceTimersByTimeAsync(45_000);
+ expect(mocks.writeChild).toHaveBeenCalledTimes(1);
+});
+
+it("cancels an outstanding request when the process exits", async () => {
+ mocks.writeChild.mockImplementation(async () => {});
+ const result = expect(discoverPiModels("/workspace")).rejects.toThrow(
+ "Pi catalog probe exited",
+ );
+ await vi.advanceTimersByTimeAsync(250);
+ mocks.watchChild.mock.calls[0][2]();
+ await result;
+ expectStopped();
+});
+
+it("handles process exit before spawn resolves", async () => {
+ mocks.spawnChild.mockImplementationOnce(async () => {
+ mocks.watchChild.mock.calls[0][2]();
+ });
+ await expect(discoverPiModels("/workspace")).rejects.toThrow(
+ "Pi catalog probe exited",
+ );
+ expect(mocks.writeChild).not.toHaveBeenCalled();
+ expectStopped();
+});
+
+it("cleans up when spawning or writing to the process fails", async () => {
+ mocks.spawnChild.mockRejectedValueOnce(new Error("spawn failed"));
+ await expect(discoverPiModels("/workspace")).rejects.toThrow("spawn failed");
+ expectStopped();
+ vi.clearAllMocks();
+ mocks.writeChild.mockRejectedValueOnce(new Error("write failed"));
+ await expect(discoverPiModels("/workspace")).rejects.toThrow("write failed");
+ expectStopped();
});
diff --git a/src/integrations/harness/providers/pi/piCatalog.ts b/src/integrations/harness/providers/pi/piCatalog.ts
index fc8a0d2434..26aaa6b700 100644
--- a/src/integrations/harness/providers/pi/piCatalog.ts
+++ b/src/integrations/harness/providers/pi/piCatalog.ts
@@ -1,5 +1,8 @@
import { homeDir } from "../../../../platform/tauri/fs";
-import { setHarnessModels } from "../../../../features/sessions/model/models";
+import {
+ setHarnessModels,
+ type AgentModel,
+} from "../../../../features/sessions/model/models";
import {
killChild,
spawnChild,
@@ -11,6 +14,12 @@ import { OMP_FLAVOR, PI_FLAVOR, type PiFlavor } from "./piFlavor";
import { buildPiSpawnArgs, modelsFromRpcData } from "./piProtocol";
const DISCOVERY_TIMEOUT_MS = 45_000;
+// Pi can answer RPC before asynchronous extension providers register models.
+// Identical early responses are not a readiness signal: observe startup for at
+// least five seconds, then require a quiet second, within the overall deadline.
+const PI_STARTUP_WINDOW_MS = 5_000;
+const PI_CATALOG_QUIET_MS = 1_000;
+const PI_CATALOG_POLL_MS = 500;
const inflight = new Map>();
@@ -31,13 +40,36 @@ function refreshCatalog(flavor: PiFlavor): Promise {
return run;
}
+/**
+ * Probe models in the supplied directory, or the user's home directory.
+ * Load extensions for both flavors: they can register providers and models,
+ * and stripping them can prevent a configured default provider from loading.
+ * Pi merges startup snapshots until the five-second minimum and one-second
+ * quiet period are met; this heuristic can miss providers registering later.
+ * omp returns its first snapshot. The 45-second deadline starts after spawn
+ * and returns collected models if any; other failures reject. Every outcome
+ * closes RPC requests, cancels polling, and tears down the child probe.
+ */
async function discoverModels(flavor: PiFlavor, workingDirectory?: string) {
const { path } = await flavor.resolveBinary();
const cwd = workingDirectory ?? (await homeDir());
const probeId = `${flavor.probeChildId}-${crypto.randomUUID()}`;
const rpc = new PiRpc(probeId, () => undefined, flavor.label);
+ const models = new Map();
+ const collectedModels = () =>
+ [...models.values()].sort((left, right) =>
+ left.name.localeCompare(right.name),
+ );
+ let stopped = false;
+ let pollTimer: ReturnType | undefined;
+ let wakePoll: (() => void) | undefined;
+ let exitError: Error | undefined;
+ let rejectExit: ((error: Error) => void) | undefined;
const stop = async () => {
+ stopped = true;
+ if (pollTimer !== undefined) clearTimeout(pollTimer);
+ wakePoll?.();
rpc.close();
unwatchChild(probeId);
await killChild(probeId).catch(() => undefined);
@@ -46,34 +78,72 @@ async function discoverModels(flavor: PiFlavor, workingDirectory?: string) {
watchChild(
probeId,
(line) => rpc.pushLine(line),
- () => rpc.close(new Error(`${flavor.label} catalog probe exited`)),
+ () => {
+ exitError = new Error(`${flavor.label} catalog probe exited`);
+ rpc.close(exitError);
+ rejectExit?.(exitError);
+ },
);
let timeout: ReturnType | undefined;
+ const timeoutError = new Error(`${flavor.label} model discovery timed out`);
try {
await spawnChild(
probeId,
path,
buildPiSpawnArgs(flavor, {
noSession: true,
- noExtensions: flavor.id !== "pi",
}),
cwd,
undefined,
flavor.id,
);
- const response = await Promise.race([
- rpc.request({ type: "get_available_models" }, DISCOVERY_TIMEOUT_MS),
- new Promise((_, reject) => {
- timeout = setTimeout(
- () => reject(new Error(`${flavor.label} model discovery timed out`)),
+ if (exitError) throw exitError;
+ const started = Date.now();
+ const timedOut = new Promise((_, reject) => {
+ timeout = setTimeout(() => reject(timeoutError), DISCOVERY_TIMEOUT_MS);
+ });
+ const exited = new Promise((_, reject) => {
+ rejectExit = reject;
+ });
+ const pollModels = async () => {
+ let previousSnapshot = "";
+ let lastChange = started;
+ while (!stopped) {
+ const response = await rpc.request(
+ { type: "get_available_models" },
+ // The outer timer owns the discovery deadline. A shorter RPC timer
+ // could win the race and discard models collected by earlier polls.
DISCOVERY_TIMEOUT_MS,
);
- }),
- ]);
- return modelsFromRpcData(flavor, response.data);
+ if (stopped) break;
+ const snapshot = modelsFromRpcData(flavor, response.data);
+ const signature = JSON.stringify(snapshot);
+ if (signature !== previousSnapshot) lastChange = Date.now();
+ previousSnapshot = signature;
+ for (const model of snapshot) models.set(model.id, model);
+ if (
+ flavor.id !== "pi" ||
+ (Date.now() - started >= PI_STARTUP_WINDOW_MS &&
+ Date.now() - lastChange >= PI_CATALOG_QUIET_MS)
+ ) {
+ break;
+ }
+ await new Promise((resolve) => {
+ wakePoll = resolve;
+ pollTimer = setTimeout(resolve, PI_CATALOG_POLL_MS);
+ });
+ }
+ return collectedModels();
+ };
+ return await Promise.race([pollModels(), timedOut, exited]);
+ } catch (error) {
+ // A slow or continually changing extension must not discard useful models
+ // already received, or keep the probe alive beyond the discovery deadline.
+ if (error === timeoutError && models.size > 0) return collectedModels();
+ throw error;
} finally {
- if (timeout) clearTimeout(timeout);
+ if (timeout !== undefined) clearTimeout(timeout);
await stop();
}
}
diff --git a/src/integrations/harness/providers/pi/piFamily.ts b/src/integrations/harness/providers/pi/piFamily.ts
index 8faaca35be..041a644494 100644
--- a/src/integrations/harness/providers/pi/piFamily.ts
+++ b/src/integrations/harness/providers/pi/piFamily.ts
@@ -21,6 +21,7 @@ import type { PiFlavor } from "./piFlavor";
import { PiRpc } from "./piClient";
import { piSubagentEvents } from "./piSubagents";
import {
+ addPiTurnUsage,
agentEndWillRetry,
asRecord,
assistantDeltaFromEvent,
@@ -29,7 +30,6 @@ import {
buildPiSteer,
contextFromSessionStats,
contextFromUsage,
- turnMetricsFromUsage,
extensionUiResponse,
extensionUiTitle,
forkMessagesFromRpcData,
@@ -57,6 +57,7 @@ import {
tryParseJsonRecord,
turnErrorFromEvent,
type PiExtensionUiRequest,
+ type PiTurnUsage,
} from "./piProtocol";
import type {
ApprovalDecision,
@@ -119,6 +120,7 @@ type Live = {
emittedReasoning: string;
/** Reason the turn failed, held until we know it is not being retried. */
turnError: string | null;
+ turnUsage: PiTurnUsage;
};
type Resume = {
@@ -526,6 +528,7 @@ async function startLive(
emittedAssistant: "",
emittedReasoning: "",
turnError: null,
+ turnUsage: { done: {}, open: {} },
};
liveRef.current = live;
@@ -601,6 +604,7 @@ async function runTurn(
live.emittedAssistant = "";
live.emittedReasoning = "";
live.turnError = null;
+ live.turnUsage = { done: {}, open: {} };
live.toolsByIndex.clear();
live.toolsById.clear();
live.compacting = false;
@@ -865,7 +869,7 @@ function handleFrame(
const context = contextFromUsage(rec, live.contextWindow);
if (context) live.onEvent({ type: "context", ...context });
- const metrics = turnMetricsFromUsage(rec);
+ const metrics = addPiTurnUsage(live.turnUsage, rec);
if (metrics) live.onEvent({ type: "turn.metrics", ...metrics });
const delta = assistantDeltaFromEvent(rec);
diff --git a/src/integrations/harness/providers/pi/piProtocol.test.ts b/src/integrations/harness/providers/pi/piProtocol.test.ts
index cca4e56934..98a1f71619 100644
--- a/src/integrations/harness/providers/pi/piProtocol.test.ts
+++ b/src/integrations/harness/providers/pi/piProtocol.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ addPiTurnUsage,
agentEndWillRetry,
assistantDeltaFromEvent,
buildPiPrompt,
@@ -397,6 +398,10 @@ describe("tools and models", () => {
]);
expect(models[0]?.settings?.[0]?.id).toBe("thinking");
expect(models[0]?.contextWindow).toBe(200000);
+ expect(models.map((model) => model.provider)).toEqual([
+ { id: "anthropic", name: "Anthropic" },
+ { id: "openai", name: "OpenAI" },
+ ]);
expect(models[1]?.settings).toBeUndefined();
});
@@ -544,6 +549,35 @@ describe("tools and models", () => {
});
});
+describe("addPiTurnUsage", () => {
+ it("adds up every response in the turn, once each", () => {
+ const spent = { done: {}, open: {} };
+ const first = { input: 100, output: 20, cacheRead: 300, cacheWrite: 50 };
+ const second = { input: 200, output: 40, cacheRead: 0, cacheWrite: 0 };
+ const frames = [
+ { type: "message_start", message: { role: "assistant" } },
+ { type: "message_update", usage: { ...first, output: 5 } },
+ { type: "message_end", message: { role: "assistant", usage: first } },
+ { type: "turn_end", message: { role: "assistant", usage: first } },
+ { type: "message_update", usage: { ...second, output: 10 } },
+ { type: "message_end", message: { role: "assistant", usage: second } },
+ { type: "turn_end", message: { role: "assistant", usage: second } },
+ ];
+ const metrics = frames.map((frame) => addPiTurnUsage(spent, frame));
+ expect(metrics[1]).toMatchObject({ inputTokens: 100, outputTokens: 5 });
+ expect(metrics[3]).toBeNull();
+ expect(metrics[4]).toMatchObject({ inputTokens: 300, outputTokens: 30 });
+ expect(metrics[5]).toEqual({
+ inputTokens: 300,
+ outputTokens: 60,
+ cacheReadTokens: 300,
+ cacheWriteTokens: 50,
+ cacheHitPercent: (300 / 650) * 100,
+ });
+ expect(metrics[6]).toBeNull();
+ });
+});
+
describe("turnErrorFromEvent", () => {
it("reads the reason a turn failed with no content", () => {
expect(
diff --git a/src/integrations/harness/providers/pi/piProtocol.ts b/src/integrations/harness/providers/pi/piProtocol.ts
index 7c5590ea24..f067532876 100644
--- a/src/integrations/harness/providers/pi/piProtocol.ts
+++ b/src/integrations/harness/providers/pi/piProtocol.ts
@@ -7,6 +7,7 @@ import type { AgentModel, ModelSetting } from "../../../../features/sessions/mod
import { isTaskListToolName } from "../../../../features/sessions/model/taskList";
import type { PiFlavor } from "./piFlavor";
import { extractToolPreview, titleFromToolInput } from "../../core/preview";
+import { upstreamProviderName } from "../../core/providerNames";
import { streamTextDelta } from "../../core/streamText";
/** Images Pi RPC accepts on `prompt` / `steer`. */
@@ -144,7 +145,7 @@ export function buildPiSpawnArgs(
resume?: string;
/** Catalog probes and isolated jobs: do not write a session file. */
noSession?: boolean;
- /** Catalog probes and throwaway text jobs — never for live chat. */
+ /** Throwaway text jobs — never for live chat or catalog probes. */
noExtensions?: boolean;
/** Titles and other one-shot prompts: no tools, skills, or project context. */
isolated?: boolean;
@@ -396,10 +397,7 @@ export function contextFromUsage(
rec: Record