Merge branch 'master' of https://github.com/paperclipai/paperclip into fix/runner-paid-matrix-integrity

* 'master' of https://github.com/paperclipai/paperclip:
  feat(ui): refine streamlined task experience (#12748)
  feat(ui): refine streamlined workspace surfaces (#12747)
  feat(ui): add streamlined navigation foundation (#12746)
This commit is contained in:
Dotta committed 2026-09-03 02:56:24 -05:00
commit cb9f13e6df
192 files changed
+38922 -3786

No files matched your search

+8
View File
@@ -81,6 +81,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: true,
selfHostedDefault: true,
},
enableStreamlinedUi: {
title: "Streamlined UI",
description:
"Use the streamlined application shell, shared task collections, focused task detail layout, contextual navigation, and simplified main sidebar.",
tier: "preference",
cloudDefault: true,
selfHostedDefault: true,
},
enableApps: {
title: "Apps (compatibility)",
description:
+6
View File
@@ -54,6 +54,12 @@ export interface InstanceExperimentalSettings {
enableManagedSandboxOnly: boolean;
enableIsolatedWorkspaces: boolean;
enableStreamlinedLeftNavigation: boolean;
/**
* Use the streamlined shell, navigation, and contextual-sidebar experience.
* Missing legacy values default on; the retired left-navigation preference
* remains separate so an old opt-out cannot disable the broader UI.
*/
enableStreamlinedUi: boolean;
/** @deprecated Compatibility key only. Apps is always enabled. */
enableApps: boolean;
enablePipelines: boolean;
@@ -5,6 +5,13 @@ import {
} from "./instance.js";
describe("instance experimental settings validators", () => {
it("defaults the streamlined UI on and accepts an explicit patch", () => {
expect(instanceExperimentalSettingsSchema.parse({}).enableStreamlinedUi).toBe(true);
expect(
patchInstanceExperimentalSettingsSchema.parse({ enableStreamlinedUi: false }),
).toEqual({ enableStreamlinedUi: false });
});
it("defaults the server info debug view off", () => {
const settings = instanceExperimentalSettingsSchema.parse({});
@@ -45,6 +45,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableManagedSandboxOnly: z.boolean().default(false),
enableIsolatedWorkspaces: z.boolean().default(false),
enableStreamlinedLeftNavigation: z.boolean().default(true),
enableStreamlinedUi: z.boolean().default(true),
// Deprecated compatibility key. Apps is a standard product surface and is
// always enabled; this remains accepted so older stored rows and managed
// configs continue to load during upgrades.
@@ -270,6 +270,24 @@ describe("instance settings routes", () => {
.expect(404);
});
it("accepts the instance-wide Streamlined UI preference", async () => {
const app = await createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableStreamlinedUi: false })
.expect(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
enableStreamlinedUi: false,
});
});
it("strips server-managed worktree run execution fields before updating experimental settings", async () => {
const app = await createApp({
type: "board",
@@ -29,6 +29,7 @@ describe("instance settings service", () => {
enableManagedSandboxOnly: false,
enableIsolatedWorkspaces: true,
enableStreamlinedLeftNavigation: true,
enableStreamlinedUi: true,
enableApps: true,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
@@ -59,6 +60,15 @@ describe("instance settings service", () => {
});
});
it("defaults streamlined UI on without inheriting the retired navigation preference", () => {
expect(normalizeExperimentalSettings(undefined).enableStreamlinedUi).toBe(true);
expect(normalizeExperimentalSettings({}).enableStreamlinedUi).toBe(true);
expect(
normalizeExperimentalSettings({ enableStreamlinedLeftNavigation: false }).enableStreamlinedUi,
).toBe(true);
expect(normalizeExperimentalSettings({ enableStreamlinedUi: false }).enableStreamlinedUi).toBe(false);
});
it("keeps Apps on for empty, legacy, and explicitly disabled stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableApps).toBe(true);
expect(normalizeExperimentalSettings({}).enableApps).toBe(true);
+2
View File
@@ -224,6 +224,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
enableStreamlinedUi: parsed.data.enableStreamlinedUi ?? true,
// Apps graduated from Experimental. Ignore historical off values while
// continuing to accept the compatibility key in stored settings.
enableApps: true,
@@ -262,6 +263,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableManagedSandboxOnly: false,
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
enableStreamlinedUi: true,
enableApps: true,
enablePipelines: false,
enableCases: false,
+1 -1
View File
@@ -161,7 +161,7 @@ test.describe.serial("dark-mode Apps surfaces", () => {
await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toHaveCount(0);
await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toHaveCount(0);
await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toHaveCount(0);
await expect(page.locator('a[href$="/activity"]', { hasText: "Activity" })).toBeVisible();
await expect(page.locator('a[href$="/activity"]', { hasText: "Audit" })).toBeVisible();
await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0);
// Apps section lives in the same sidebar now.
await expect(page.locator('a[href$="/apps"]', { hasText: "Connectors" })).toBeVisible();
+31 -82
View File
@@ -1,37 +1,20 @@
import { test, expect, request as pwRequest, type APIRequestContext } from "@playwright/test";
/**
* E2E: Sidebar takeover model (PAP-10695).
* E2E: contextual sidebar companion model.
*
* Takeover routes (company settings, plugin `routeSidebar`) no longer *replace*
* the main app sidebar. Instead the host collapses the app `<Sidebar/>` to its
* 64px rail (still peek-able) and renders the contextual sidebar in a second
* pane → `[ app rail ][ secondary ~240px ][ content ]`.
* Contextual routes render their navigation beside the stable global sidebar.
* The global company navigation and account menu remain available, and leaving
* the surface restores the user's global sidebar preference.
*
* These specs assert the rail + secondary pane coexist on a company settings
* route, and that an explicit user pin (expanded) wins over the route-driven
* collapse (pin precedence).
*
* The plugin `routeSidebar` half of this behavior shares the exact same Layout
* code path (one `secondarySidebar`/`hasSecondarySidebar` resolver drives both
* company-settings and plugin routes) and is covered by the unit tests in
* `ui/src/components/Layout.test.tsx`. A live plugin-route e2e requires the
* `plugin-llm-wiki` plugin to be installed in the throwaway e2e instance, which
* is out of scope for this default local_trusted run; visual QA of both panes
* is delegated to the QA child issue.
* Plugin route sidebars share the same Layout path. A live plugin-route test
* requires a plugin fixture, so that branch remains covered by Layout tests.
*/
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
const BASE_URL = `http://127.0.0.1:${PORT}`;
const COMPANY_NAME_PREFIX = "E2E-SidebarTakeover";
const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed";
// The sidebar header's "Collapse sidebar" toggle only renders when the app
// sidebar is expanded (pinned, desktop, not takeover-locked); in the collapsed
// rail and on takeover routes it is hidden. Its presence/absence is therefore
// a stable proxy for the app sidebar's collapsed state (see Sidebar.tsx).
// (Previously "Open search", but the header search icon moved into the nav,
// where it renders in the rail too.)
const APP_SIDEBAR_EXPANDED_MARKER = "Collapse sidebar";
async function createCompany(board: APIRequestContext): Promise<{ id: string; prefix: string }> {
@@ -53,7 +36,7 @@ async function createCompany(board: APIRequestContext): Promise<{ id: string; pr
};
}
test.describe("Sidebar takeover (collapse + secondary pane)", () => {
test.describe("Contextual sidebar companion", () => {
let board: APIRequestContext;
let companyId: string;
let prefix: string;
@@ -71,93 +54,59 @@ test.describe("Sidebar takeover (collapse + secondary pane)", () => {
});
test.beforeEach(async ({ page }) => {
// Start each test from a clean (unpinned) sidebar state so the route-driven
// collapse is the only thing acting on it.
await page.addInitScript((key) => {
window.localStorage.removeItem(key);
window.sessionStorage.clear();
}, COLLAPSED_STORAGE_KEY);
});
test("collapses the app sidebar to its rail and shows the settings sidebar beside it", async ({ page }) => {
test("shows Settings beside the global navigation", async ({ page }) => {
await page.goto(`/${prefix}/company/settings`);
// The contextual (secondary) pane is present...
const secondary = page.locator("[data-secondary-sidebar]");
await expect(secondary).toBeVisible();
await expect(secondary).toHaveCount(1);
const contextual = page.locator('[data-contextual-sidebar="settings"]');
await expect(contextual).toBeVisible();
await expect(contextual).toHaveCount(1);
await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(1);
// ...and it is ~240px wide (w-60), distinct from the 64px app rail.
const secondaryBox = await secondary.boundingBox();
expect(secondaryBox).not.toBeNull();
expect(secondaryBox!.width).toBeGreaterThan(180);
await expect(contextual.getByRole("link", { name: "General" })).toBeVisible();
await expect(contextual.getByText("Environments", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Back from Settings" })).toBeVisible();
await expect(page.getByRole("button", { name: "Open account menu" })).toBeVisible();
// The app sidebar is NOT replaced — its company nav still renders...
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
// ...but it is collapsed to its rail: the expanded-only "Open search"
// header control is hidden.
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
});
test("renders the secondary pane nav labels at full width despite the app rail collapse", async ({ page }) => {
// Regression (PAP-10700): the secondary pane is 240px wide, but its
// SidebarNavItem children read the *global* collapsed state and used to
// render icon-only (label `w-0 text-transparent`), making the settings nav
// unreadable in the default takeover state. The pane must force full labels.
test("renders contextual labels at full width", async ({ page }) => {
await page.goto(`/${prefix}/company/settings`);
const secondary = page.locator("[data-secondary-sidebar]");
await expect(secondary).toBeVisible();
// App sidebar is collapsed to its rail (default unpinned takeover state)...
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
// ...yet a settings nav label renders at its full text width, not clipped to
// zero. "Environments" is unique to the company-settings nav.
const envLabel = secondary.getByText("Environments", { exact: true });
const contextual = page.locator('[data-contextual-sidebar="settings"]');
const envLabel = contextual.getByText("Environments", { exact: true });
await expect(envLabel).toBeVisible();
const labelBox = await envLabel.boundingBox();
expect(labelBox).not.toBeNull();
expect(labelBox!.width).toBeGreaterThan(20);
});
test("settings force-collapse overrides an expanded pin without mutating it", async ({ page }) => {
// User has pinned the sidebar expanded ("0"). Company settings is a hard
// secondary-sidebar takeover route, so forceCollapsed wins while the route is
// active (force > pin > route request > default) but must not mutate the pin.
await page.addInitScript(
({ key }) => {
window.localStorage.setItem(key, "0");
},
{ key: COLLAPSED_STORAGE_KEY },
);
test("keeps the retired collapse control absent across contextual navigation", async ({ page }) => {
await page.goto(`/${prefix}/company/settings`);
// Secondary pane still shows on the takeover route.
await expect(page.locator("[data-secondary-sidebar]")).toBeVisible();
// The app sidebar is hard-collapsed despite the stored expanded pin.
await expect(page.locator('[data-contextual-sidebar="settings"]')).toBeVisible();
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
await page.goto(`/${prefix}/dashboard`);
// Leaving the takeover route clears the force and restores the user's
// persisted expanded pin.
await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0);
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible();
await expect(page.locator("[data-contextual-sidebar]")).toHaveCount(0);
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
});
test("leaving the takeover route removes the secondary pane and restores the sidebar", async ({ page }) => {
test("uses Dashboard as the safe fallback for a direct Settings link", async ({ page }) => {
await page.goto(`/${prefix}/company/settings`);
await expect(page.locator("[data-secondary-sidebar]")).toBeVisible();
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
await page.getByRole("button", { name: "Back from Settings" }).click();
// Navigate to a plain (non-takeover) route.
await page.goto(`/${prefix}/dashboard`);
// No secondary pane, and the app sidebar is no longer force-collapsed.
await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0);
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${prefix}/dashboard$`));
await expect(page.locator("[data-contextual-sidebar]")).toHaveCount(0);
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
});
});
+124 -6
View File
@@ -1,11 +1,10 @@
// @vitest-environment jsdom
// Regression guard for PAP-16302: `/audit` was merged into the single rich
// Activity page. Both the company-prefixed `/:company/audit` and the bare
// `/audit` (PAP-16300's unprefixed redirect) must keep resolving — as redirects
// into `/:company/activity?mode=agents`, so old deep links land on the
// agent-actions scope instead of 404ing. This drives the real <App> route table
// so removing either registration fails loudly.
// Regression guard for the Audit hub: Activity remains the canonical root,
// while Runs, Costs, and Budgets live beneath it. Company-prefixed and bare
// legacy routes must keep resolving with their query filters intact; `/audit`
// specifically retains the historical Agent Actions preset. This drives the
// real <App> route table so removing either registration fails loudly.
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
@@ -14,6 +13,12 @@ import { MemoryRouter, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
const streamlinedUiState = vi.hoisted(() => ({ enabled: true, loaded: true }));
vi.mock("./hooks/useStreamlinedUiEnabled", () => ({
useStreamlinedUiEnabled: () => streamlinedUiState,
}));
// jsdom's CSS parser rejects the custom-property marker rule stitches inserts
// (`--sxs{--sxs:N}`), pulled into <App>'s eager import graph transitively via
// @codesandbox/sandpack-react. Substitute a benign, valid rule on parse failure
@@ -48,6 +53,11 @@ vi.mock("./components/Layout", async () => {
return { Layout: () => <Outlet /> };
});
vi.mock("./components/Layout.production", async () => {
const { Outlet } = await import("react-router-dom");
return { Layout: () => <Outlet /> };
});
// Rendered by <App> outside <Routes> and needs DialogProvider; irrelevant here.
vi.mock("./components/OnboardingWizardVariant", () => ({
OnboardingWizardVariant: () => null,
@@ -69,6 +79,13 @@ vi.mock("./pages/audit/CompanyActivity", () => ({
},
}));
vi.mock("./pages/audit/AuditHub", () => ({
AuditHub: ({ section }: { section: string }) => {
const location = useLocation();
return <div>{`AUDIT_${section.toUpperCase()}@${location.pathname}${location.search}`}</div>;
},
}));
vi.mock("./pages/Issues", () => ({
Issues: () => {
const location = useLocation();
@@ -76,6 +93,28 @@ vi.mock("./pages/Issues", () => ({
},
}));
vi.mock("./pages/audit/CompanyActivity.production", () => ({
CompanyActivity: () => {
const location = useLocation();
return <div>{`PRODUCTION_ACTIVITY@${location.pathname}${location.search}`}</div>;
},
}));
vi.mock("./pages/Costs.production", () => ({
Costs: () => {
const location = useLocation();
return <div>{`PRODUCTION_COSTS@${location.pathname}${location.search}`}</div>;
},
}));
vi.mock("./pages/NotFound", () => ({
NotFoundPage: ({ scope }: { scope: string }) => <div>{`NOT_FOUND:${scope}`}</div>,
}));
vi.mock("./pages/PluginPage", () => ({
PluginPage: () => <div>PRODUCTION_PLUGIN_FALLBACK</div>,
}));
const PAP_COMPANY = {
id: "company-1",
name: "Paperclip",
@@ -137,6 +176,8 @@ describe("App Activity routing (PAP-16302)", () => {
container = document.createElement("div");
document.body.appendChild(container);
companyState = { companies: [PAP_COMPANY], selected: PAP_COMPANY };
streamlinedUiState.enabled = true;
streamlinedUiState.loaded = true;
});
afterEach(() => {
@@ -152,12 +193,45 @@ describe("App Activity routing (PAP-16302)", () => {
flushSync(() => root.unmount());
});
it("serves organization and entity-scoped run history beneath Activity", async () => {
const root = renderAppAt(
container,
"/PAP/activity/runs?entityType=routine&entityId=routine-1",
);
await waitForRoute(
container,
"AUDIT_RUNS@/PAP/activity/runs?entityType=routine&entityId=routine-1",
);
flushSync(() => root.unmount());
});
it("redirects /:company/audit to Activity with the agent-actions mode preset", async () => {
const root = renderAppAt(container, "/PAP/audit");
await waitForRoute(container, "ACTIVITY_PAGE@/PAP/activity?mode=agents");
flushSync(() => root.unmount());
});
it("serves each canonical Audit section under Activity", async () => {
const root = renderAppAt(container, "/PAP/activity/runs?agentId=agent-1");
await waitForRoute(container, "AUDIT_RUNS@/PAP/activity/runs?agentId=agent-1");
flushSync(() => root.unmount());
});
it("redirects legacy costs and preserves its deep-link filters", async () => {
const root = renderAppAt(container, "/PAP/costs?range=30d#usage");
await waitForRoute(container, "AUDIT_COSTS@/PAP/activity/costs?range=30d");
flushSync(() => root.unmount());
});
it("redirects legacy Audit sections to their canonical destination", async () => {
const root = renderAppAt(container, "/PAP/audit/budgets?projectId=project-1");
await waitForRoute(
container,
"AUDIT_BUDGETS@/PAP/activity/budgets?projectId=project-1",
);
flushSync(() => root.unmount());
});
it("keeps the company from the URL when /:company/audit is not the selected company", async () => {
// A shared /ACME/audit link opened by someone whose selected company is PAP
// must still show ACME's activity. The redirect target is written absolute
@@ -186,4 +260,48 @@ describe("App Activity routing (PAP-16302)", () => {
expect(container.textContent).not.toContain("/tasks/dashboard");
flushSync(() => root.unmount());
});
it("redirects a bare legacy section through to the prefixed Audit hub", async () => {
const root = renderAppAt(container, "/runs?runStatus=succeeded");
await waitForRoute(container, "AUDIT_RUNS@/PAP/activity/runs?runStatus=succeeded");
flushSync(() => root.unmount());
});
it("uses the production Activity and Costs routes when Streamlined UI is disabled", async () => {
streamlinedUiState.enabled = false;
const activityRoot = renderAppAt(container, "/PAP/activity");
await waitForRoute(container, "PRODUCTION_ACTIVITY@/PAP/activity");
flushSync(() => activityRoot.unmount());
const costsRoot = renderAppAt(container, "/PAP/costs?range=30d");
await waitForRoute(container, "PRODUCTION_COSTS@/PAP/costs?range=30d");
flushSync(() => costsRoot.unmount());
});
it("omits streamlined-only Audit routes when Streamlined UI is disabled", async () => {
streamlinedUiState.enabled = false;
const root = renderAppAt(container, "/PAP/activity/runs");
await waitForRoute(container, "PRODUCTION_PLUGIN_FALLBACK");
expect(container.textContent).not.toContain("AUDIT_RUNS");
flushSync(() => root.unmount());
});
it("does not mount streamlined routes before the experiment setting loads", async () => {
streamlinedUiState.loaded = false;
const root = renderAppAt(container, "/PAP/activity");
expect(container.textContent).not.toContain("ACTIVITY_PAGE");
expect(container.textContent).not.toContain("PRODUCTION_ACTIVITY");
streamlinedUiState.enabled = false;
streamlinedUiState.loaded = true;
flushSync(() => {
root.render(
<MemoryRouter initialEntries={["/PAP/activity"]}>
<App />
</MemoryRouter>,
);
});
await waitForRoute(container, "PRODUCTION_ACTIVITY@/PAP/activity");
flushSync(() => root.unmount());
});
});
+109 -22
View File
@@ -1,9 +1,10 @@
import { lazy, Suspense } from "react";
import { lazy, Suspense, type ReactNode } from "react";
import type { ToolConnectionCredentialSource } from "@paperclipai/shared";
import { Navigate, Outlet, Route, Routes, useActiveCompanyPrefix, useLocation, useParams } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n";
import { Layout } from "./components/Layout";
import { Layout as ProductionLayout } from "./components/Layout.production";
import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate";
import { TaskChatLab } from "./pages/TaskChatLab";
import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate";
@@ -48,8 +49,8 @@ import { Artifacts } from "./pages/Artifacts";
import { GoalDetail } from "./pages/GoalDetail";
import { Approvals } from "./pages/Approvals";
import { ApprovalDetail } from "./pages/ApprovalDetail";
import { Costs } from "./pages/Costs";
import { CompanyActivity } from "./pages/audit/CompanyActivity";
import { AuditHub } from "./pages/audit/AuditHub";
import { Inbox } from "./pages/Inbox";
import { WhatNeedsMe } from "./pages/WhatNeedsMe";
import { DecisionQueuePage } from "./pages/DecisionQueuePage";
@@ -84,7 +85,6 @@ import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
import { AdapterManager } from "./pages/AdapterManager";
import { PluginPage } from "./pages/PluginPage";
import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim";
@@ -102,6 +102,7 @@ import {
} from "./lib/onboarding-route";
import { filterHiddenInstanceSettingsPath, normalizeRememberedInstanceSettingsPath } from "./lib/instance-settings";
import { useCloudInstance } from "./hooks/useCloudInstance";
import { useStreamlinedUiEnabled } from "./hooks/useStreamlinedUiEnabled";
import { cloudStackCreateUrl } from "./lib/cloudLinks";
import { navigateTopLevel } from "@/lib/browserNavigation";
@@ -109,13 +110,45 @@ const CompanyExport = lazy(() =>
import("./pages/CompanyExport").then((module) => ({ default: module.CompanyExport })),
);
function boardRoutes() {
const ProductionAgents = lazy(() =>
import("./pages/Agents.production").then((module) => ({ default: module.Agents })),
);
const ProductionAgentDetail = lazy(() =>
import("./pages/AgentDetail.production").then((module) => ({ default: module.AgentDetail })),
);
const ProductionRoutines = lazy(() =>
import("./pages/Routines.production").then((module) => ({ default: module.Routines })),
);
const ProductionRoutineDetail = lazy(() =>
import("./pages/RoutineDetail.production").then((module) => ({ default: module.RoutineDetail })),
);
const ProductionCompanySkills = lazy(() =>
import("./pages/CompanySkills.production").then((module) => ({ default: module.CompanySkills })),
);
const ProductionCompanyActivity = lazy(() =>
import("./pages/audit/CompanyActivity.production").then((module) => ({ default: module.CompanyActivity })),
);
const ProductionCosts = lazy(() =>
import("./pages/Costs.production").then((module) => ({ default: module.Costs })),
);
const ProductionOrgChart = lazy(() =>
import("./pages/OrgChart.production").then((module) => ({ default: module.OrgChart })),
);
function ProductionSurface({ children }: { children: ReactNode }) {
return <Suspense fallback={<PaperclipLoading />}>{children}</Suspense>;
}
function boardRoutes(streamlinedUiEnabled: boolean) {
return (
<>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="dashboard/live" element={<DashboardLive />} />
<Route path="timeline" element={<Timeline />} />
<Route
path="timeline"
element={streamlinedUiEnabled ? <AuditCompatibilityRedirect to="/activity/timeline" /> : <Timeline />}
/>
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
@@ -213,19 +246,29 @@ function boardRoutes() {
<Route path="skills/studio/new" element={<SkillStudio />} />
<Route path="skills/studio/:skillId" element={<SkillStudio />} />
<Route path="skills/:skillId/studio" element={<LegacySkillStudioRedirect />} />
<Route path="skills/*" element={<CompanySkills />} />
<Route
path="skills/*"
element={streamlinedUiEnabled ? <CompanySkills /> : <ProductionSurface><ProductionCompanySkills /></ProductionSurface>}
/>
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="plugins/:pluginId" element={<PluginPage />} />
<Route path="org" element={<OrgChart />} />
<Route
path="org"
element={streamlinedUiEnabled ? <Navigate to="/agents/all" replace /> : <ProductionSurface><ProductionOrgChart /></ProductionSurface>}
/>
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
{AGENT_FILTER_TABS.map((tab) => (
<Route key={tab} path={`agents/${tab}`} element={<Agents />} />
<Route
key={tab}
path={`agents/${tab}`}
element={streamlinedUiEnabled ? <Agents /> : <ProductionSurface><ProductionAgents /></ProductionSurface>}
/>
))}
<Route path="agents/new" element={<NewAgent />} />
<Route path="agents/:agentId" element={<AgentDetail />} />
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
<Route path="agents/:agentId" element={streamlinedUiEnabled ? <AgentDetail /> : <ProductionSurface><ProductionAgentDetail /></ProductionSurface>} />
<Route path="agents/:agentId/:tab" element={streamlinedUiEnabled ? <AgentDetail /> : <ProductionSurface><ProductionAgentDetail /></ProductionSurface>} />
<Route path="agents/:agentId/runs/:runId" element={streamlinedUiEnabled ? <AgentDetail /> : <ProductionSurface><ProductionAgentDetail /></ProductionSurface>} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:projectId" element={<ProjectDetail />} />
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
@@ -252,7 +295,7 @@ function boardRoutes() {
{import.meta.env.DEV ? (
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
) : null}
<Route path="routines" element={<Routines />} />
<Route path="routines" element={streamlinedUiEnabled ? <Routines /> : <ProductionSurface><ProductionRoutines /></ProductionSurface>} />
<Route
path="cases"
element={<CasesExperimentalGate><Cases /></CasesExperimentalGate>}
@@ -304,8 +347,8 @@ function boardRoutes() {
path="pipelines/:pipelineId/cases/:caseId"
element={<PipelinesExperimentalGate><PipelineItemLegacyRedirect /></PipelinesExperimentalGate>}
/>
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="routines/:routineId/:section" element={<RoutineDetail />} />
<Route path="routines/:routineId" element={streamlinedUiEnabled ? <RoutineDetail /> : <ProductionSurface><ProductionRoutineDetail /></ProductionSurface>} />
<Route path="routines/:routineId/:section" element={streamlinedUiEnabled ? <RoutineDetail /> : <ProductionSurface><ProductionRoutineDetail /></ProductionSurface>} />
<Route element={<IsolatedWorkspacesRouteGate />}>
<Route element={<ExecutionWorkspaceCompanyGate />}>
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
@@ -323,11 +366,29 @@ function boardRoutes() {
<Route path="approvals/pending" element={<Approvals />} />
<Route path="approvals/all" element={<Approvals />} />
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<CompanyActivity />} />
{/* `/audit` merged into the single Activity page (PAP-16302). Existing deep
links keep working, preset to the agent-actions scope. */}
<Route path="audit" element={<Navigate to="/activity?mode=agents" replace />} />
<Route path="activity" element={streamlinedUiEnabled ? <CompanyActivity /> : <ProductionSurface><ProductionCompanyActivity /></ProductionSurface>} />
{streamlinedUiEnabled ? (
<>
<Route path="activity/runs" element={<AuditHub section="runs" />} />
<Route path="activity/costs" element={<AuditHub section="costs" />} />
<Route path="activity/budgets" element={<AuditHub section="budgets" />} />
<Route path="activity/timeline" element={<AuditHub section="timeline" />} />
<Route path="audit" element={<AuditCompatibilityRedirect to="/activity" forceAgentMode />} />
<Route path="audit/activity" element={<AuditCompatibilityRedirect to="/activity" />} />
<Route path="audit/runs" element={<AuditCompatibilityRedirect to="/activity/runs" />} />
<Route path="audit/costs" element={<AuditCompatibilityRedirect to="/activity/costs" />} />
<Route path="audit/budgets" element={<AuditCompatibilityRedirect to="/activity/budgets" />} />
<Route path="audit/timeline" element={<AuditCompatibilityRedirect to="/activity/timeline" />} />
<Route path="runs" element={<AuditCompatibilityRedirect to="/activity/runs" />} />
<Route path="costs" element={<AuditCompatibilityRedirect to="/activity/costs" />} />
<Route path="budgets" element={<AuditCompatibilityRedirect to="/activity/budgets" />} />
</>
) : (
<>
<Route path="costs" element={<ProductionSurface><ProductionCosts /></ProductionSurface>} />
<Route path="audit" element={<Navigate to="/activity?mode=agents" replace />} />
</>
)}
{/* Conference Room Chat surfaces (PAP-136/PAP-137): routes stay
registered but redirect to the company home while the experimental
flag is off. The board-level `artifacts` mount below is the new
@@ -575,6 +636,20 @@ function StatusCardsLegacyRedirect() {
return <Navigate to={`${base}/status${cardId ? `/${cardId}` : ""}`} replace />;
}
function AuditCompatibilityRedirect({
to,
forceAgentMode = false,
}: {
to: string;
forceAgentMode?: boolean;
}) {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
if (forceAgentMode) searchParams.set("mode", "agents");
const search = searchParams.toString();
return <Navigate to={`${to}${search ? `?${search}` : ""}${location.hash}`} replace />;
}
function UnprefixedBoardRedirect() {
const location = useLocation();
const { companies, selectedCompany, loading } = useCompany();
@@ -649,6 +724,8 @@ function NoCompaniesStartPage() {
}
export function App() {
const { enabled: streamlinedUiEnabled, loaded: streamlinedUiLoaded } = useStreamlinedUiEnabled();
return (
<>
<Routes>
@@ -661,7 +738,7 @@ export function App() {
<Route path="ux-lab/responsible-user-denial" element={<ResponsibleUserDenialUxLab />} />
<Route path="ux-lab/cross-issue-collaboration" element={<CrossIssueCollaborationUxLab />} />
<Route element={<CloudAccessGate />}>
<Route element={streamlinedUiLoaded ? <CloudAccessGate /> : <PaperclipLoading />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<LegacySettingsRedirect />} />
@@ -689,6 +766,16 @@ export function App() {
<Route path="pipelines/:pipelineId/cases/:caseId" element={<UnprefixedBoardRedirect />} />
<Route path="artifacts" element={<UnprefixedBoardRedirect />} />
<Route path="audit" element={<UnprefixedBoardRedirect />} />
{streamlinedUiEnabled ? (
<>
<Route path="audit/*" element={<UnprefixedBoardRedirect />} />
<Route path="activity" element={<UnprefixedBoardRedirect />} />
<Route path="activity/*" element={<UnprefixedBoardRedirect />} />
<Route path="runs" element={<UnprefixedBoardRedirect />} />
<Route path="costs" element={<UnprefixedBoardRedirect />} />
<Route path="budgets" element={<UnprefixedBoardRedirect />} />
</>
) : null}
<Route path="decisions" element={<UnprefixedBoardRedirect />} />
<Route path="u/:userSlug" element={<UnprefixedBoardRedirect />} />
<Route path="skills/studio" element={<UnprefixedBoardRedirect />} />
@@ -721,8 +808,8 @@ export function App() {
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}
<Route path=":companyPrefix" element={streamlinedUiEnabled ? <Layout /> : <ProductionLayout />}>
{boardRoutes(streamlinedUiEnabled)}
</Route>
<Route path="*" element={<NotFoundPage scope="global" />} />
</Route>
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { AgentContextualSidebar } from "./AgentContextualSidebar";
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("./ContextualSidebarFrame", () => ({
ContextualSidebarFrame: ({
title,
showHeader,
className,
children,
}: {
title: string;
showHeader?: boolean;
className?: string;
children: React.ReactNode;
}) => (
<aside data-title={title} data-show-header={String(showHeader)} className={className}>
{children}
</aside>
),
}));
vi.mock("./SidebarNavItem", () => ({
SidebarNavItem: ({ to, label }: { to: string; label: string }) => <a href={to}>{label}</a>,
}));
describe("AgentContextualSidebar", () => {
it("renders local definition/runtime/governance links and scoped Audit links", () => {
const queryClient = new QueryClient();
const markup = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/agents/codexcoder/runtime"]}>
<AgentContextualSidebar agentRef="codexcoder" agentId="agent-1" agentName="Codex Coder" />
</MemoryRouter>
</QueryClientProvider>,
);
expect(markup).toContain("Codex Coder");
expect(markup).toContain('data-show-header="false"');
expect(markup).toContain("border-r border-border bg-background");
expect(markup).toContain('data-slot="contextual-sidebar-nav"');
expect(markup).toContain('href="/agents/codexcoder/overview"');
expect(markup).toContain('href="/agents/codexcoder/permissions"');
expect(markup).toContain('href="/agents/codexcoder/api-keys"');
expect(markup).toContain('href="/activity?mode=agents&amp;agentId=agent-1"');
expect(markup).toContain('href="/activity/runs?agentId=agent-1"');
expect(markup).toContain("Harness / Runtime");
expect(markup).toContain("Permissions / Trust");
});
});
@@ -0,0 +1,131 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
BadgeDollarSign,
BookOpenText,
History,
KeyRound,
Library,
PlayCircle,
ReceiptText,
Settings2,
ShieldCheck,
Sparkles,
Wrench,
} from "lucide-react";
import { agentsApi } from "@/api/agents";
import { useCompany } from "@/context/CompanyContext";
import { queryKeys } from "@/lib/queryKeys";
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
import { SidebarNavItem } from "./SidebarNavItem";
import { contextualSidebarStyles } from "./contextual-sidebar-styles";
import {
AGENT_DETAIL_NAVIGATION,
agentDetailHref,
agentScopedAuditHref,
type AgentLocalDetailView,
} from "@/pages/agent-detail-navigation";
const localIcons = {
overview: Sparkles,
instructions: BookOpenText,
skills: Library,
runtime: Settings2,
secrets: ShieldCheck,
tools: Wrench,
permissions: ShieldCheck,
"api-keys": KeyRound,
revisions: History,
} satisfies Record<AgentLocalDetailView, typeof Sparkles>;
const auditItems = [
{ section: "activity", label: "Activity", icon: Activity },
{ section: "runs", label: "Runs", icon: PlayCircle },
{ section: "costs", label: "Costs", icon: ReceiptText },
{ section: "budgets", label: "Budgets", icon: BadgeDollarSign },
] as const;
export function AgentContextualSidebar({
agentRef,
agentId,
agentName,
}: {
agentRef: string;
agentId?: string;
agentName?: string;
}) {
const { selectedCompanyId } = useCompany();
const shouldResolveAgent = !agentId || !agentName;
const { data: resolvedAgent } = useQuery({
queryKey: [...queryKeys.agents.detail(agentRef), selectedCompanyId ?? null, "contextual-sidebar"],
queryFn: () => agentsApi.get(agentRef, selectedCompanyId ?? undefined),
enabled: shouldResolveAgent && Boolean(agentRef && selectedCompanyId),
});
const resolvedId = agentId ?? resolvedAgent?.id;
const resolvedName = agentName ?? resolvedAgent?.name ?? "Agent";
return (
<ContextualSidebarFrame
surface="agent"
title={resolvedName}
fallbackTo="/agents/all"
showHeader={false}
className="border-r border-border bg-background"
>
<nav
aria-label={`${resolvedName} navigation`}
data-slot="contextual-sidebar-nav"
className={contextualSidebarStyles.nav}
>
{AGENT_DETAIL_NAVIGATION.map((section) => (
<div
key={section.label}
data-slot="contextual-sidebar-section"
className={contextualSidebarStyles.section}
>
<p
data-slot="contextual-sidebar-section-label"
className={contextualSidebarStyles.sectionLabel}
>
{section.label}
</p>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
{section.items.map((item) => {
const href = agentDetailHref(agentRef, item.value);
return (
<SidebarNavItem
key={item.value}
to={href}
label={item.label}
icon={localIcons[item.value]}
/>
);
})}
</div>
</div>
))}
<div data-slot="contextual-sidebar-section" className={contextualSidebarStyles.section}>
<p
data-slot="contextual-sidebar-section-label"
className={contextualSidebarStyles.sectionLabel}
>
Audit
</p>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
{resolvedId ? auditItems.map((item) => (
<SidebarNavItem
key={item.section}
to={agentScopedAuditHref(resolvedId, item.section)}
label={item.label}
icon={item.icon}
/>
)) : (
<p className="px-2 py-1.5 text-xs text-muted-foreground">Loading audit links…</p>
)}
</div>
</div>
</nav>
</ContextualSidebarFrame>
);
}
@@ -0,0 +1,154 @@
import { ChevronLeft } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import type { ToolApplication, ToolConnection } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { toolsApi } from "@/api/tools";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { queryKeys } from "@/lib/queryKeys";
import {
APP_TABS,
CONNECTED_ONLY_APP_TABS,
appApplicationTabHref,
appTabHref,
type AppTabKey,
} from "@/pages/apps/app-tabs";
import { AppLogo } from "@/pages/apps/AppLogo";
import {
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
type AppGalleryDisplayEntry,
} from "@/pages/apps/app-definition-display";
import { SidebarNavItem } from "./SidebarNavItem.production";
type AppDetailSidebarProps =
| { kind: "connection"; connectionId: string }
| { kind: "application"; applicationId: string };
export function AppDetailSidebar(props: AppDetailSidebarProps) {
const { selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const connectionQuery = useQuery({
queryKey: queryKeys.tools.connection(props.kind === "connection" ? props.connectionId : "__none__"),
queryFn: () => toolsApi.getConnection(props.kind === "connection" ? props.connectionId : ""),
enabled: props.kind === "connection" && !!props.connectionId,
});
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: props.kind === "application" && !!selectedCompanyId,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: props.kind === "application" && !!selectedCompanyId,
});
const galleryQuery = useQuery({
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const attentionQuery = useQuery({
queryKey: queryKeys.apps.attention(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listAppsAttention(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 30_000,
});
const connection = connectionQuery.data;
const application = props.kind === "application"
? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId)
: null;
const appConnections = props.kind === "application"
? (connectionsQuery.data?.connections ?? []).filter((candidate) => candidate.applicationId === props.applicationId)
: [];
const previousConnection = latestArchivedConnection(appConnections);
const appName = connection ? humanizeConnectionDisplayName(connection) : application?.name ?? "App";
const logoEntry = galleryEntryFor(
(galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[],
connection,
application ?? undefined,
);
const reviewConnectionId = connection?.id ?? previousConnection?.id ?? null;
const attentionItem = reviewConnectionId
? attentionQuery.data?.apps.find((app) => app.connection.id === reviewConnectionId)
: null;
const reviewCount =
(attentionItem?.pendingActionRequestCount ?? 0) +
((attentionItem?.quarantinedCatalogEntryCount ?? 0) > 0 ? 1 : 0);
return (
<aside className="flex h-full min-h-0 w-full flex-col border-r border-border bg-background">
<div className="flex shrink-0 flex-col gap-3 px-3 py-3">
<Link
to="/apps/connections"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">All apps</span>
</Link>
<div className="flex min-w-0 items-center gap-2 px-2 py-1">
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(logoEntry)} size={28} />
<span className="flex-1 truncate text-sm font-bold text-foreground">{appName}</span>
</div>
</div>
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-0.5">
{APP_TABS.filter(
(tab) => props.kind === "connection" || !CONNECTED_ONLY_APP_TABS.has(tab.key),
).map((tab) => (
<SidebarNavItem
key={tab.key}
to={tabHref(props, tab.key)}
label={tab.label}
icon={tab.icon}
end
badge={tab.key === "review" && reviewCount > 0 ? reviewCount : undefined}
badgeTone="danger"
badgeLabel="needing review"
/>
))}
</div>
</nav>
</aside>
);
}
function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string {
return props.kind === "connection"
? appTabHref(props.connectionId, tab)
: appApplicationTabHref(props.applicationId, tab);
}
function galleryEntryFor(
apps: AppGalleryDisplayEntry[],
connection: ToolConnection | undefined,
application: ToolApplication | undefined,
): AppGalleryDisplayEntry | null {
if (application?.applicationKey) {
const keyed = apps.find((app) => appDefinitionSlug(app) === application.applicationKey);
if (keyed) return keyed;
}
const name = (connection?.name ?? application?.name)?.toLowerCase();
if (!name) return null;
return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ??
apps.find((app) => appDefinitionSlug(app) === name) ??
null;
}
function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null {
const archived = connections.filter((connection) => connection.status === "archived");
if (archived.length === 0) return null;
return archived.reduce((latest, connection) => {
const latestTime = new Date(latest.updatedAt ?? latest.createdAt ?? 0).getTime();
const connectionTime = new Date(connection.updatedAt ?? connection.createdAt ?? 0).getTime();
return connectionTime > latestTime ? connection : latest;
});
}
@@ -0,0 +1,92 @@
import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react";
import { Link } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs";
import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled";
import { useReviewCount } from "@/pages/apps/useReviewCount";
import { SidebarNavItem } from "./SidebarNavItem.production";
/**
* Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA
* PAP-13254 / U3).
*
* ← Back · APPS: Browse / Review (n)
* DEVELOPER: Connections / Gateways / Profiles / Rules / Health / Activity
*
* "Browse" is the store and "Review" holds decisions waiting on the user's
* OK. Connection management lives with the Developer tools.
* "Needs attention" is no longer a door: health/error triage folds into
* Connections as a status filter + banner, so approvals are never buried
* behind an error label. The Developer section was folded in from the retired
* ToolsSidebar (PAP-10915) so the whole Apps area shares one sidebar; a
* one-line caption frames who it's for (Finding A). "Run your own" and "Paste a
* config" moved out of the sidebar into rows on the Connect-an-app page
* (PAP-10922).
*/
export function AppsSidebar() {
const { selectedCompany } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const reviewCount = useReviewCount();
const { enabled: smokeLabEnabled } = useSmokeLabEnabled();
const developerTabs = DEVELOPER_TABS.filter(
(tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled,
);
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
<Link
to="/dashboard"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedCompany?.name ?? "Company"}</span>
</Link>
<div className="flex items-center gap-2 px-2 py-1">
<AppWindow className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 truncate text-sm font-bold text-foreground">Apps</span>
</div>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="px-3 pb-1 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Apps
</div>
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/apps" label="Browse" icon={Store} end />
<SidebarNavItem
to="/apps/review"
label="Review"
icon={ShieldQuestion}
badge={reviewCount > 0 ? reviewCount : undefined}
badgeTone="warning"
badgeLabel="waiting for your OK"
/>
</div>
<div className="px-3 pb-1 pt-4 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Developer
</div>
<p className="px-3 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70">
Advanced setup for developers. Most teams never open this.
</p>
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/apps/connections" label="Connections" icon={AppWindow} end />
{developerTabs.map((tab) => (
<SidebarNavItem
key={tab.key}
to={advancedTabHref(tab.key)}
label={tab.label}
icon={tab.icon}
end
/>
))}
</div>
</nav>
</aside>
);
}
+11 -2
View File
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppsSidebar } from "./AppsSidebar";
import { contextualSidebarStyles } from "./contextual-sidebar-styles";
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockToolsApi = vi.hoisted(() => ({
@@ -97,7 +98,7 @@ describe("AppsSidebar", () => {
vi.clearAllMocks();
});
it("renders the consolidated connector and review doors without retired developer links", async () => {
it("renders the consolidated connector and review doors without a redundant contextual heading", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -112,7 +113,7 @@ describe("AppsSidebar", () => {
});
await flushReact();
expect(container.textContent).toContain("Apps");
expect(container.textContent).not.toContain("Apps");
expect(container.textContent).not.toContain("Developer");
expect(container.textContent).not.toContain("Advanced setup for developers");
expect(container.textContent).not.toContain("Most teams");
@@ -152,6 +153,14 @@ describe("AppsSidebar", () => {
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/advanced/audit" }),
);
expect(container.querySelector('[data-slot="contextual-sidebar-nav"]')?.className).toBe(
contextualSidebarStyles.nav,
);
expect(
Array.from(container.querySelectorAll('[data-slot="contextual-sidebar-group"]')).every(
(group) => group.className === contextualSidebarStyles.group,
),
).toBe(true);
await act(async () => {
root.unmount();
+22 -39
View File
@@ -1,25 +1,18 @@
import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react";
import { Link } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { Store, ShieldQuestion } from "lucide-react";
import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs";
import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled";
import { useReviewCount } from "@/pages/apps/useReviewCount";
import { SidebarNavItem } from "./SidebarNavItem";
import { contextualSidebarStyles } from "./contextual-sidebar-styles";
/**
* Secondary sidebar for the Apps area.
*
* ← Back · APPS: Connectors / Review (n)
*
* The landing page combines connector discovery, account management, and
* connection health. Review keeps governed actions waiting on the user's OK.
* Advanced developer surfaces remain hidden unless one is explicitly enabled.
* Connectors combines discovery, account management, and connection health.
* Review keeps governed actions waiting on the user's approval. Advanced
* developer surfaces remain hidden unless one is explicitly enabled.
*/
export function AppsSidebar() {
const { selectedCompany } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const reviewCount = useReviewCount();
const { enabled: smokeLabEnabled } = useSmokeLabEnabled();
const developerTabs = DEVELOPER_TABS.filter((tab) => {
@@ -31,28 +24,12 @@ export function AppsSidebar() {
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
<Link
to="/dashboard"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedCompany?.name ?? "Organization"}</span>
</Link>
<div className="flex items-center gap-2 px-2 py-1">
<AppWindow className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 truncate text-sm font-bold text-foreground">Apps</span>
</div>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="px-3 pb-1 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Apps
</div>
<div className="flex flex-col gap-0.5">
<nav
aria-label="Apps"
data-slot="contextual-sidebar-nav"
className={contextualSidebarStyles.nav}
>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
<SidebarNavItem to="/apps" label="Connectors" icon={Store} end />
<SidebarNavItem
to="/apps/review"
@@ -64,14 +41,20 @@ export function AppsSidebar() {
/>
</div>
{developerTabs.length > 0 ? (
<>
<div className="px-3 pb-1 pt-4 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<div data-slot="contextual-sidebar-section" className={contextualSidebarStyles.section}>
<div
data-slot="contextual-sidebar-section-label"
className={contextualSidebarStyles.sectionLabel}
>
Developer
</div>
<p className="px-3 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70">
<p
data-slot="contextual-sidebar-section-description"
className={contextualSidebarStyles.sectionDescription}
>
Advanced setup for developers.
</p>
<div className="flex flex-col gap-0.5">
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
{developerTabs.map((tab) => (
<SidebarNavItem
key={tab.key}
@@ -82,7 +65,7 @@ export function AppsSidebar() {
/>
))}
</div>
</>
</div>
) : null}
</nav>
</aside>
@@ -164,6 +164,7 @@ describe("BlockedInboxView", () => {
const { root } = renderWithClient(
<BlockedInboxView
{...blockedViewProps}
presentation="task"
/>,
container,
);
@@ -258,6 +259,7 @@ describe("BlockedInboxView", () => {
const { root } = renderWithClient(
<BlockedInboxView
{...blockedViewProps}
presentation="task"
/>,
container,
);
@@ -269,6 +271,40 @@ describe("BlockedInboxView", () => {
expect(rowText.indexOf("Board")).toBeGreaterThan(rowText.indexOf("Needs decision"));
expect(rowText).not.toContain("Accept or reject");
expect(container.querySelector('[data-testid="blocked-row-reason-column"]')?.textContent).toContain("Needs decision");
const taskRow = container.querySelector('[data-slot="task-row"]');
const identifier = container.querySelector('[data-slot="task-row-identifier"]');
const timestamp = container.querySelector('[data-slot="task-row-timestamp"]');
expect(taskRow).not.toBeNull();
expect(taskRow?.className).not.toContain("border-b");
expect(identifier).not.toBeNull();
expect(timestamp).not.toBeNull();
if (!identifier || !timestamp) throw new Error("Expected canonical identifier and timestamp columns");
expect(identifier.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
act(() => root.unmount());
});
it("restores the legacy status/id prefix, timestamp column, and row divider", async () => {
mockIssuesApi.list.mockResolvedValue([
makeIssue(
"issue-legacy",
"PAP-41",
"Legacy blocked row",
attention({ owner: { type: "board", agentId: null, userId: null, label: "Board" } }),
),
]);
const { root } = renderWithClient(
<BlockedInboxView {...blockedViewProps} presentation="legacy" />,
container,
);
await waitFor(() => container.textContent?.includes("Legacy blocked row") === true);
expect(container.querySelector('[data-slot="task-row"]')).toBeNull();
expect(container.querySelector('[data-testid="blocked-row-age"]')).not.toBeNull();
const row = container.querySelector("a")?.parentElement;
expect(row?.className).toContain("border-b");
expect(row?.textContent).toContain("PAP-41");
act(() => root.unmount());
});
+24 -7
View File
@@ -19,7 +19,7 @@ import {
} from "../lib/blockedInbox";
import { BlockedReasonChip } from "./BlockedReasonChip";
import { IssueGroupHeader } from "./IssueGroupHeader";
import { IssueRow } from "./IssueRow";
import { IssueRow, type IssueRowPresentation } from "./IssueRow";
import { Identity } from "./Identity";
import { StatusIcon } from "./StatusIcon";
import { Button } from "@/components/ui/button";
@@ -41,6 +41,7 @@ interface BlockedInboxViewProps {
showStatusColumn: boolean;
showIdentifierColumn: boolean;
showUpdatedColumn: boolean;
presentation?: IssueRowPresentation;
}
const BLOCKED_LIST_LIMIT = 200;
@@ -61,6 +62,7 @@ export function BlockedInboxView({
showStatusColumn,
showIdentifierColumn,
showUpdatedColumn,
presentation = "legacy",
}: BlockedInboxViewProps) {
const [collapsedVariants, setCollapsedVariants] = useState<Set<string>>(() => new Set());
@@ -124,7 +126,10 @@ export function BlockedInboxView({
{Array.from({ length: 2 }).map((__, rowIdx) => (
<div
key={rowIdx}
className="flex items-center gap-3 border-b border-border/60 px-3 py-2.5 sm:px-4"
className={cn(
"flex items-center gap-3 px-3 py-2.5 sm:px-4",
presentation === "legacy" && "border-b border-border/60",
)}
>
<div className="h-3.5 w-3.5 animate-pulse rounded-full bg-muted" />
<div className="h-4 w-16 animate-pulse rounded bg-muted/70" />
@@ -219,6 +224,7 @@ export function BlockedInboxView({
showStatusColumn={showStatusColumn}
showIdentifierColumn={showIdentifierColumn}
showUpdatedColumn={showUpdatedColumn}
presentation={presentation}
/>
))
) : (
@@ -226,7 +232,7 @@ export function BlockedInboxView({
const isCollapsed = collapsedVariants.has(group.variant);
return (
<div key={group.variant} data-testid={`blocked-inbox-group-${group.variant}`}>
<div className="px-3 sm:px-4">
<div className={presentation === "task" ? "rounded-lg px-3 sm:pl-0 sm:pr-4" : "px-3 sm:px-4"}>
<IssueGroupHeader
label={`${group.label} · ${group.rows.length}`}
collapsible
@@ -248,6 +254,7 @@ export function BlockedInboxView({
showStatusColumn={showStatusColumn}
showIdentifierColumn={showIdentifierColumn}
showUpdatedColumn={showUpdatedColumn}
presentation={presentation}
/>
))}
</div>
@@ -271,6 +278,7 @@ interface BlockedInboxRowProps {
showStatusColumn: boolean;
showIdentifierColumn: boolean;
showUpdatedColumn: boolean;
presentation: IssueRowPresentation;
}
function resolveOwnerName(
@@ -299,6 +307,7 @@ function BlockedInboxRow({
showStatusColumn,
showIdentifierColumn,
showUpdatedColumn,
presentation,
}: BlockedInboxRowProps) {
const { label: ownerName, isAgent } = resolveOwnerName(row, agentNameById, userLabelById);
const stoppedAge = formatStoppedAge(row.attention.stoppedSinceAt);
@@ -330,7 +339,7 @@ function BlockedInboxRow({
) : (
<span className="hidden w-(--sz-150px) shrink-0 sm:inline-flex" aria-hidden="true" />
)}
{showUpdatedColumn ? (
{presentation === "legacy" && showUpdatedColumn ? (
<span className="hidden w-(--sz-5_75rem) text-right text-muted-foreground sm:inline" data-testid="blocked-row-age">
{stoppedAge}
</span>
@@ -359,15 +368,22 @@ function BlockedInboxRow({
<IssueRow
issue={row.issue}
issueLinkState={issueLinkState}
showDivider
desktopMetaLeading={
presentation={presentation}
showDivider={presentation === "legacy"}
statusSlot={presentation === "task"
? showStatusColumn
? <StatusIcon status={row.issue.status} blockerAttention={blockerAttention} />
: <span className="inline-flex size-4" aria-hidden="true" />
: undefined}
showIdentifier={presentation === "task" ? showIdentifierColumn : undefined}
desktopMetaLeading={presentation === "legacy" ? (
<BlockedRowDesktopMeta
row={row}
blockerAttention={blockerAttention}
showStatusColumn={showStatusColumn}
showIdentifierColumn={showIdentifierColumn}
/>
}
) : undefined}
mobileLeading={
<span className="flex shrink-0 items-center gap-1.5 pt-px">
<StatusIcon status={row.issue.status} blockerAttention={blockerAttention} />
@@ -382,6 +398,7 @@ function BlockedInboxRow({
}
mobileMeta={mobileMeta}
desktopTrailing={desktopTrailing}
trailingMeta={presentation === "task" && showUpdatedColumn ? stoppedAge : null}
/>
);
}
@@ -0,0 +1,160 @@
import { Link } from "@/lib/router";
import { Menu } from "lucide-react";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
import { Button } from "@/components/ui/button";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Fragment, useMemo } from "react";
import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers";
type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null };
/** Task identifier rendered in gray monospace between the glyph and the title. */
function CrumbIdentifier({ identifier }: { identifier?: string }) {
if (!identifier) return null;
return <span className="shrink-0 font-mono text-muted-foreground">{identifier}</span>;
}
function GlobalToolbar({ context }: { context: GlobalToolbarContext }) {
const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId });
const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId });
return (
<div className="ml-auto flex shrink-0 items-center gap-1 pl-2 empty:hidden">
{slots.length > 0 ? (
<PluginSlotOutlet slotTypes={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
) : null}
{launchers.length > 0 ? (
<PluginLauncherOutlet placementZones={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
) : null}
</div>
);
}
export function BreadcrumbBar() {
const { breadcrumbs, mobileToolbar } = useBreadcrumbs();
const { toggleSidebar, isMobile } = useSidebar();
const { selectedCompanyId, selectedCompany } = useCompany();
const globalToolbarSlotContext = useMemo(
() => ({
companyId: selectedCompanyId ?? null,
companyPrefix: selectedCompany?.issuePrefix ?? null,
}),
[selectedCompanyId, selectedCompany?.issuePrefix],
);
const globalToolbarSlots = <GlobalToolbar context={globalToolbarSlotContext} />;
if (isMobile && mobileToolbar) {
return (
<div className="border-b border-border px-2 h-12 shrink-0 flex items-center">
{mobileToolbar}
</div>
);
}
if (breadcrumbs.length === 0) {
return (
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
{globalToolbarSlots}
</div>
);
}
const menuButton = isMobile && (
<Button
variant="ghost"
size="icon-sm"
className="mr-2 shrink-0"
onClick={toggleSidebar}
aria-label="Open sidebar"
>
<Menu className="h-5 w-5" />
</Button>
);
// Single breadcrumb = page title (uppercase)
if (breadcrumbs.length === 1) {
return (
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
{menuButton}
<div className="min-w-0 overflow-hidden flex-1">
{breadcrumbs[0].leading || breadcrumbs[0].identifier ? (
<h1 className="flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wider">
{breadcrumbs[0].leading && (
<span className="flex shrink-0 items-center">{breadcrumbs[0].leading}</span>
)}
<CrumbIdentifier identifier={breadcrumbs[0].identifier} />
<span className="truncate">{breadcrumbs[0].label}</span>
</h1>
) : (
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
{breadcrumbs[0].label}
</h1>
)}
</div>
{globalToolbarSlots}
</div>
);
}
// Multiple breadcrumbs = breadcrumb trail
return (
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
{menuButton}
<div className="min-w-0 overflow-hidden flex-1">
<Breadcrumb className="min-w-0 overflow-hidden">
<BreadcrumbList className="flex-nowrap">
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.length - 1;
return (
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
crumb.leading || crumb.identifier ? (
<BreadcrumbPage className="flex min-w-0 items-center gap-1.5">
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
)}
<CrumbIdentifier identifier={crumb.identifier} />
<span className="truncate">{crumb.label}</span>
</BreadcrumbPage>
) : (
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
)
) : (
<BreadcrumbLink asChild>
{crumb.leading || crumb.identifier ? (
<Link to={crumb.href} className="flex items-center gap-1.5">
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
)}
<CrumbIdentifier identifier={crumb.identifier} />
<span className="truncate">{crumb.label}</span>
</Link>
) : (
<Link to={crumb.href}>{crumb.label}</Link>
)}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
</div>
{globalToolbarSlots}
</div>
);
}
+152 -15
View File
@@ -7,17 +7,26 @@ import { BreadcrumbProvider, useBreadcrumbs } from "../context/BreadcrumbContext
import { BreadcrumbBar } from "./BreadcrumbBar";
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children: ReactNode; to: string }) => (
<a href={to}>{children}</a>
Link: ({ children, className, to }: { children: ReactNode; className?: string; to: string }) => (
<a className={className} href={to}>{children}</a>
),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false, toggleSidebar: vi.fn() }),
useSidebar: () => ({
collapsed: false,
isMobile: false,
toggleCollapsed: vi.fn(),
toggleSidebar: vi.fn(),
}),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { issuePrefix: "PAP" } }),
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { issuePrefix: "TES" } }),
}));
vi.mock("../context/PanelContext", () => ({
usePanel: () => ({ panelVisible: true, togglePanelVisible: vi.fn() }),
}));
vi.mock("@/plugins/slots", () => ({
@@ -30,25 +39,59 @@ vi.mock("@/plugins/launchers", () => ({
PluginLauncherOutlet: () => null,
}));
function TaskBreadcrumbs({ onOpen }: { onOpen: () => void }) {
const { setBreadcrumbs, setBreadcrumbToolbar } = useBreadcrumbs();
function TaskBreadcrumbs({
onOpen,
panelControl,
taskDetailLayout = false,
identifier = "PAP-16679",
}: {
onOpen?: () => void;
panelControl?: { open: boolean; onToggle: () => void };
taskDetailLayout?: boolean;
identifier?: string;
}) {
const {
setBreadcrumbs,
setBreadcrumbToolbar,
setBreadcrumbPanelControl,
} = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([
{ label: "Tasks", href: "/tasks" },
{ label: "Keep the panel launcher available", identifier: "PAP-16679" },
{ label: "Tasks", href: "/issues" },
{
label: "Hire your first engineer and create a hiring plan",
identifier,
leading: "status",
},
]);
setBreadcrumbToolbar(
<button type="button" aria-label="Show task side panel" onClick={onOpen}>
Show panel
</button>,
onOpen ? (
<button type="button" aria-label="Show task side panel" onClick={onOpen}>
Show panel
</button>
) : null,
);
return () => setBreadcrumbToolbar(null);
}, [onOpen, setBreadcrumbToolbar, setBreadcrumbs]);
setBreadcrumbPanelControl(panelControl ?? null);
return () => {
setBreadcrumbToolbar(null);
setBreadcrumbPanelControl(null);
};
}, [
identifier,
onOpen,
panelControl,
setBreadcrumbPanelControl,
setBreadcrumbToolbar,
setBreadcrumbs,
]);
return <BreadcrumbBar />;
return <BreadcrumbBar taskDetailLayout={taskDetailLayout} />;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("BreadcrumbBar", () => {
let container: HTMLDivElement;
let root: Root;
@@ -78,9 +121,103 @@ describe("BreadcrumbBar", () => {
'button[aria-label="Show task side panel"]',
);
expect(launcher).not.toBeNull();
expect(launcher?.closest(".h-12")?.textContent).toContain("PAP-16679");
expect(launcher?.closest(".h-\\(--sz-60px\\)")?.textContent).toContain("PAP-16679");
act(() => launcher?.click());
expect(onOpen).toHaveBeenCalledOnce();
});
it("appends the task identifier to the task title in the task-detail header", async () => {
await act(async () => {
root.render(
<BreadcrumbProvider>
<TaskBreadcrumbs taskDetailLayout identifier="TES-1" />
</BreadcrumbProvider>,
);
});
const identifier = container.querySelector('[data-slot="task-title-identifier"]');
expect(identifier).toBeTruthy();
expect(identifier?.className).not.toContain("absolute");
expect(identifier?.closest(".relative")?.className).toContain("border-b");
expect(identifier?.closest(".relative")?.className).toContain("border-border");
expect(identifier?.closest(".relative")?.className).toContain("h-(--sz-60px)");
const title = Array.from(container.querySelectorAll("span"))
.find((element) => element.textContent === "Hire your first engineer and create a hiring plan");
const tasksLink = container.querySelector<HTMLAnchorElement>('a[href="/issues"]');
const header = tasksLink?.closest(".relative");
expect(header?.classList).toContain("px-4");
expect(header?.classList).toContain("md:px-6");
expect(header?.classList).not.toContain("px-3");
expect(tasksLink?.classList).toContain("font-semibold");
expect(tasksLink?.classList).toContain("tracking-wider");
expect(tasksLink?.classList).toContain("text-muted-foreground");
expect(tasksLink?.classList).toContain("hover:text-foreground");
expect(tasksLink?.classList).not.toContain("font-bold");
expect(title?.className).toContain("truncate");
expect(title?.nextElementSibling).toBe(identifier);
expect(identifier?.textContent).toBe("TES-1");
});
it("styles the root crumb as an uppercase muted header on every detail view", async () => {
await act(async () => {
root.render(
<BreadcrumbProvider>
<TaskBreadcrumbs />
</BreadcrumbProvider>,
);
});
const rootCrumb = container.querySelector<HTMLAnchorElement>('a[href="/issues"]');
expect(rootCrumb?.classList).toContain("font-semibold");
expect(rootCrumb?.classList).toContain("uppercase");
expect(rootCrumb?.classList).toContain("tracking-wider");
expect(rootCrumb?.classList).toContain("text-muted-foreground");
expect(rootCrumb?.classList).toContain("hover:text-foreground");
});
it("routes the single task-detail panel button through a page override", async () => {
const onToggle = vi.fn();
const panelControl = { open: false, onToggle };
await act(async () => {
root.render(
<BreadcrumbProvider>
<TaskBreadcrumbs taskDetailLayout panelControl={panelControl} />
</BreadcrumbProvider>,
);
});
const launcher = container.querySelector<HTMLButtonElement>(
'button[aria-label="Show properties"]',
);
expect(launcher).not.toBeNull();
expect(container.querySelector('button[aria-label="Hide properties"]')).toBeNull();
act(() => launcher?.click());
expect(onToggle).toHaveBeenCalledOnce();
});
it("omits the retired sidebar control and keeps the properties control", async () => {
await act(async () => {
root.render(
<BreadcrumbProvider>
<TaskBreadcrumbs taskDetailLayout />
</BreadcrumbProvider>,
);
});
const leftControl = container.querySelector<HTMLButtonElement>(
'button[aria-label="Collapse sidebar"]',
);
const rightControl = container.querySelector<HTMLButtonElement>(
'button[aria-label="Hide properties"]',
);
expect(leftControl).toBeNull();
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
expect(rightControl?.className).toContain("size-9");
expect(rightControl?.className).not.toContain("rounded-none");
expect(rightControl?.className).not.toContain("h-full");
});
});
+103 -52
View File
@@ -1,8 +1,9 @@
import { Link } from "@/lib/router";
import { Menu } from "lucide-react";
import { Menu, PanelRightClose, PanelRightOpen } from "lucide-react";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
import { Button } from "@/components/ui/button";
import {
Breadcrumb,
@@ -15,13 +16,18 @@ import {
import { Fragment, useMemo, type ReactNode } from "react";
import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers";
import { cn } from "../lib/utils";
type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null };
/** Task identifier rendered in gray monospace between the glyph and the title. */
/** Task identifier rendered in gray monospace beside its breadcrumb label. */
function CrumbIdentifier({ identifier }: { identifier?: string }) {
if (!identifier) return null;
return <span className="shrink-0 font-mono text-muted-foreground">{identifier}</span>;
return (
<span data-slot="task-title-identifier" className="shrink-0 font-mono text-muted-foreground">
{identifier}
</span>
);
}
function GlobalToolbar({
@@ -46,10 +52,18 @@ function GlobalToolbar({
);
}
export function BreadcrumbBar() {
const { breadcrumbs, breadcrumbToolbar, mobileToolbar } = useBreadcrumbs();
export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
const {
breadcrumbs,
breadcrumbToolbar,
breadcrumbPanelControl,
mobileToolbar,
} = useBreadcrumbs();
const { toggleSidebar, isMobile } = useSidebar();
const { panelVisible, togglePanelVisible } = usePanel();
const { selectedCompanyId, selectedCompany } = useCompany();
const taskPanelOpen = breadcrumbPanelControl?.open ?? panelVisible;
const toggleTaskPanel = breadcrumbPanelControl?.onToggle ?? togglePanelVisible;
const globalToolbarSlotContext = useMemo(
() => ({
@@ -68,7 +82,7 @@ export function BreadcrumbBar() {
if (isMobile && mobileToolbar) {
return (
<div className="border-b border-border px-2 h-12 shrink-0 flex items-center">
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-2">
{mobileToolbar}
</div>
);
@@ -76,7 +90,7 @@ export function BreadcrumbBar() {
if (breadcrumbs.length === 0) {
return (
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
<div className="h-(--sz-60px) shrink-0 flex items-center justify-end border-b border-border px-4 md:px-6">
{globalToolbarSlots}
</div>
);
@@ -94,10 +108,72 @@ export function BreadcrumbBar() {
</Button>
);
const breadcrumbTrail = (
<div className="min-w-0 overflow-hidden flex-1">
<Breadcrumb className="min-w-0 overflow-hidden">
<BreadcrumbList className="flex-nowrap">
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.length - 1;
return (
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
crumb.leading || crumb.identifier ? (
<BreadcrumbPage className="flex min-w-0 items-center gap-1.5">
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{crumb.label}</span>
{taskDetailLayout && isLast ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
</BreadcrumbPage>
) : (
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
)
) : (
<BreadcrumbLink asChild>
{crumb.leading || crumb.identifier ? (
<Link
to={crumb.href}
className={cn(
"flex min-w-0 items-center gap-1.5",
i === 0 && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
)}
>
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{crumb.label}</span>
{taskDetailLayout && isLast ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
</Link>
) : (
<Link
to={crumb.href}
className={cn(
"min-w-0 truncate",
i === 0 && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
)}
>
{crumb.label}
</Link>
)}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
</div>
);
// Single breadcrumb = page title (uppercase)
if (breadcrumbs.length === 1) {
return (
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-4 md:px-6">
{menuButton}
<div className="min-w-0 overflow-hidden flex-1">
{breadcrumbs[0].leading || breadcrumbs[0].identifier ? (
@@ -121,52 +197,27 @@ export function BreadcrumbBar() {
// Multiple breadcrumbs = breadcrumb trail
return (
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
<div
className={cn(
"relative h-(--sz-60px) shrink-0 flex items-center border-b border-border",
"px-4 md:px-6",
)}
>
{menuButton}
<div className="min-w-0 overflow-hidden flex-1">
<Breadcrumb className="min-w-0 overflow-hidden">
<BreadcrumbList className="flex-nowrap">
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.length - 1;
return (
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
crumb.leading || crumb.identifier ? (
<BreadcrumbPage className="flex min-w-0 items-center gap-1.5">
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
)}
<CrumbIdentifier identifier={crumb.identifier} />
<span className="truncate">{crumb.label}</span>
</BreadcrumbPage>
) : (
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
)
) : (
<BreadcrumbLink asChild>
{crumb.leading || crumb.identifier ? (
<Link to={crumb.href} className="flex items-center gap-1.5">
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
)}
<CrumbIdentifier identifier={crumb.identifier} />
<span className="truncate">{crumb.label}</span>
</Link>
) : (
<Link to={crumb.href}>{crumb.label}</Link>
)}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
</div>
{breadcrumbTrail}
{globalToolbarSlots}
{taskDetailLayout ? (
<Button
variant="ghost"
size="icon-sm"
className="ml-5 size-9 shrink-0 text-muted-foreground"
onClick={toggleTaskPanel}
aria-label={taskPanelOpen ? "Hide properties" : "Show properties"}
title={taskPanelOpen ? "Hide properties" : "Show properties"}
>
{taskPanelOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it } from "vitest";
import { CollectionToolbar } from "./CollectionToolbar";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("CollectionToolbar", () => {
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
afterEach(() => {
if (root) flushSync(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
it("keeps collection controls in stable semantic slots", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
flushSync(() => {
root?.render(
<CollectionToolbar
ariaLabel="Task list controls"
context={<span>Mine</span>}
search={<input aria-label="Search tasks" />}
controls={<button type="button">Filter</button>}
actions={<button type="button">New task</button>}
feedback={<span>Status: active</span>}
/>,
);
});
const toolbar = container.querySelector('[role="toolbar"]');
expect(toolbar?.getAttribute("aria-label")).toBe("Task list controls");
expect(toolbar?.querySelector('[data-slot="collection-toolbar-context"]')?.textContent).toBe("Mine");
expect(toolbar?.querySelector('[data-slot="collection-toolbar-search"] input')).not.toBeNull();
expect(toolbar?.querySelector('[data-slot="collection-toolbar-controls"]')?.textContent).toBe("Filter");
expect(toolbar?.querySelector('[data-slot="collection-toolbar-actions"]')?.textContent).toBe("New task");
expect(toolbar?.querySelector('[data-slot="collection-toolbar-feedback"]')?.textContent).toBe("Status: active");
});
it("omits empty optional slots", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
flushSync(() => root?.render(<CollectionToolbar search={<span>Search</span>} />));
expect(container.querySelector('[data-slot="collection-toolbar-context"]')).toBeNull();
expect(container.querySelector('[data-slot="collection-toolbar-controls"]')).toBeNull();
expect(container.querySelector('[data-slot="collection-toolbar-actions"]')).toBeNull();
expect(container.querySelector('[data-slot="collection-toolbar-feedback"]')).toBeNull();
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface CollectionToolbarProps {
/** Primary context such as tabs, a view title, or a result count. */
context?: ReactNode;
/** The collection's canonical search control. */
search?: ReactNode;
/** Filter, sort, column, density, and view controls. */
controls?: ReactNode;
/** Collection-specific actions such as create or bulk operations. */
actions?: ReactNode;
/** Optional second row for active-filter chips or selection feedback. */
feedback?: ReactNode;
className?: string;
ariaLabel?: string;
}
/**
* Presentation-only shell for list and board controls.
*
* State, queries, and control behavior stay with the consuming surface. Keeping
* this component slot-based lets Inbox, Tasks, routine runs, and scoped task
* lists share geometry without coupling their data models.
*/
export function CollectionToolbar({
context,
search,
controls,
actions,
feedback,
className,
ariaLabel = "Collection controls",
}: CollectionToolbarProps) {
return (
<div
data-slot="collection-toolbar"
className={cn("flex flex-col gap-2", className)}
role="toolbar"
aria-label={ariaLabel}
>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
{context ? (
<div data-slot="collection-toolbar-context" className="min-w-0 shrink-0">
{context}
</div>
) : null}
{search ? (
<div data-slot="collection-toolbar-search" className="min-w-0 flex-1">
{search}
</div>
) : null}
{(controls || actions) ? (
<div className="flex min-w-0 flex-wrap items-center gap-1 sm:ml-auto sm:flex-nowrap">
{controls ? (
<div data-slot="collection-toolbar-controls" className="flex min-w-0 flex-wrap items-center gap-1">
{controls}
</div>
) : null}
{actions ? (
<div data-slot="collection-toolbar-actions" className="flex shrink-0 items-center gap-1">
{actions}
</div>
) : null}
</div>
) : null}
</div>
{feedback ? (
<div data-slot="collection-toolbar-feedback" className="min-w-0">
{feedback}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,214 @@
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft,
Clock3,
Cpu,
Download,
FlaskConical,
KeyRound,
MailPlus,
MonitorCog,
Puzzle,
Shield,
SlidersHorizontal,
Upload,
UserRoundPen,
Users,
} from "lucide-react";
import type { PluginRecord } from "@paperclipai/shared";
import { sidebarBadgesApi } from "@/api/sidebarBadges";
import { pluginsApi } from "@/api/plugins";
import { ApiError } from "@/api/client";
import { Link, NavLink } from "@/lib/router";
import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings";
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { usePluginSlots } from "@/plugins/slots";
import { SidebarNavItem } from "./SidebarNavItem.production";
/**
* Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin
* settings page, so a sidebar entry would lead nowhere useful. Filter them out
* here. Plugins that mix a sandbox provider with other contributions still
* appear.
*/
function isSandboxProviderOnly(plugin: PluginRecord): boolean {
const drivers = plugin.manifestJson.environmentDrivers ?? [];
if (drivers.length === 0) return false;
return drivers.every((d) => d.kind === "sandbox_provider");
}
export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const { hidden: hiddenSettings } = useHiddenSettings();
const showPage = (pageKey: string) => !hiddenSettings.has(pageKey);
const showPlugins = showPage("instance.plugins");
// Import is floored server-side on cloud-managed instances (403 cloud_managed), so the
// nav entry is hidden rather than dead-ending. Export stays available.
const isCloud = Boolean(useCloudInstance());
const { slots: companySettingsPluginSlots } = usePluginSlots({
slotTypes: ["companySettingsPage"],
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
const { data: badges } = useQuery({
queryKey: selectedCompanyId
? queryKeys.sidebarBadges(selectedCompanyId)
: ["sidebar-badges", "__disabled__"] as const,
queryFn: async () => {
try {
return await sidebarBadgesApi.get(selectedCompanyId!);
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return null;
}
throw error;
}
},
enabled: !!selectedCompanyId,
retry: false,
refetchInterval: 15_000,
});
const { data: plugins } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
// The listing only feeds the per-plugin subtree below; skip it when the
// operator hides the Plugins surface.
enabled: showPlugins,
});
const sidebarPlugins = (plugins ?? []).filter((plugin) => !isSandboxProviderOnly(plugin));
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
<Link
to="/dashboard"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedCompany?.name ?? "Company"}</span>
</Link>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
{showPage("instance.profile") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`}
label="Profile"
icon={UserRoundPen}
end
/>
)}
{showPage("company.members") && (
<SidebarNavItem
to="/company/settings/members"
label="Members"
icon={Users}
badge={badges?.joinRequests ?? 0}
end
/>
)}
{companySettingsPluginSlots
.filter((slot) => slot.routePath)
.map((slot) => (
<SidebarNavItem
key={`${slot.pluginKey}:${slot.id}`}
to={`/company/settings/${slot.routePath}`}
label={slot.displayName}
icon={Puzzle}
end
/>
))}
{showPage("company.invites") && (
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
)}
{showPage("company.secrets") && (
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
)}
{showPage("instance.environments") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`}
label="Environments"
icon={MonitorCog}
end
/>
)}
{showPage("instance.access") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`}
label="Access"
icon={Shield}
end
/>
)}
{showPage("instance.heartbeats") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`}
label="Heartbeats"
icon={Clock3}
end
/>
)}
{showPage("company.export") && (
<SidebarNavItem to="/company/export" label="Export" icon={Download} />
)}
{!isCloud && showPage("company.import") && (
<SidebarNavItem to="/company/import" label="Import" icon={Upload} end />
)}
{showPage("instance.experimental") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`}
label="Experimental"
icon={FlaskConical}
/>
)}
{showPlugins && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/plugins`}
label="Plugins"
icon={Puzzle}
/>
)}
{showPlugins && sidebarPlugins.length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
{sidebarPlugins.map((plugin) => (
<NavLink
key={plugin.id}
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/plugins/${plugin.id}`}
state={SIDEBAR_SCROLL_RESET_STATE}
className={({ isActive }) =>
[
"rounded-md px-2 py-1.5 text-xs transition-colors",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
].join(" ")
}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</NavLink>
))}
</div>
) : null}
{showPage("instance.adapters") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/adapters`}
label="Adapters"
icon={Cpu}
/>
)}
</div>
</nav>
</aside>
);
}
@@ -16,6 +16,7 @@ const mockPluginsApi = vi.hoisted(() => ({
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
useNavigate: () => vi.fn(),
Link: ({
children,
to,
@@ -41,7 +42,7 @@ vi.mock("@/lib/router", () => ({
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
}),
}));
@@ -53,6 +54,7 @@ vi.mock("@/context/SidebarContext", () => ({
}));
vi.mock("./SidebarNavItem", () => ({
SidebarNavExpandedProvider: ({ children }: { children: React.ReactNode }) => children,
SidebarNavItem: (props: {
to: string;
label: string;
+5 -20
View File
@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft,
Cpu,
Download,
FlaskConical,
@@ -17,16 +16,16 @@ import type { PluginRecord } from "@paperclipai/shared";
import { sidebarBadgesApi } from "@/api/sidebarBadges";
import { pluginsApi } from "@/api/plugins";
import { ApiError } from "@/api/client";
import { Link, NavLink } from "@/lib/router";
import { NavLink } from "@/lib/router";
import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings";
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { usePluginSlots } from "@/plugins/slots";
import { SidebarNavItem } from "./SidebarNavItem";
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
/**
* Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin
@@ -41,8 +40,7 @@ function isSandboxProviderOnly(plugin: PluginRecord): boolean {
}
export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const { selectedCompanyId } = useCompany();
const { hidden: hiddenSettings } = useHiddenSettings();
const showPage = (pageKey: string) => !hiddenSettings.has(pageKey);
const showPlugins = showPage("instance.plugins");
@@ -82,20 +80,7 @@ export function CompanySettingsSidebar() {
const sidebarPlugins = (plugins ?? []).filter((plugin) => !isSandboxProviderOnly(plugin));
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
<Link
to="/dashboard"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedCompany?.name ?? "Organization"}</span>
</Link>
</div>
<ContextualSidebarFrame surface="settings" title="Settings" icon={SlidersHorizontal}>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
@@ -196,6 +181,6 @@ export function CompanySettingsSidebar() {
)}
</div>
</nav>
</aside>
</ContextualSidebarFrame>
);
}
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Settings } from "lucide-react";
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
import { rememberContextualSidebarOrigin } from "@/lib/shell-navigation";
const mockNavigate = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate }));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompany: { name: "Paperclip", issuePrefix: "PAP" } }),
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn(), collapsed: true, peeking: false }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("ContextualSidebarFrame", () => {
let container: HTMLDivElement;
beforeEach(() => {
window.sessionStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
vi.clearAllMocks();
});
function render() {
const root = createRoot(container);
act(() => {
root.render(
<ContextualSidebarFrame surface="settings" title="Settings" icon={Settings}>
<nav>Settings links</nav>
</ContextualSidebarFrame>,
);
});
return root;
}
it("renders the contextual identity and uses a safe fallback", () => {
const root = render();
expect(container.textContent).toContain("Paperclip");
expect(container.textContent).toContain("Settings");
act(() => {
container.querySelector<HTMLButtonElement>('button[aria-label="Back from Settings"]')?.click();
});
expect(mockNavigate).toHaveBeenCalledWith("/dashboard");
act(() => root.unmount());
});
it("returns to the same-company route that opened the contextual surface", () => {
rememberContextualSidebarOrigin({
surface: "settings",
companyPrefix: "PAP",
previousPathname: "/PAP/issues/task-1",
});
const root = render();
act(() => {
container.querySelector<HTMLButtonElement>('button[aria-label="Back from Settings"]')?.click();
});
expect(mockNavigate).toHaveBeenCalledWith("/PAP/issues/task-1");
act(() => root.unmount());
});
it("can omit contextual identity when the breadcrumb already provides it", () => {
const root = createRoot(container);
act(() => {
root.render(
<ContextualSidebarFrame surface="skills" title="Skills" showHeader={false}>
<nav>Skill links</nav>
</ContextualSidebarFrame>,
);
});
expect(container.textContent).toBe("Skill links");
expect(container.querySelector('[aria-label="Back from Skills"]')).toBeNull();
act(() => root.unmount());
});
});
@@ -0,0 +1,70 @@
import { ChevronLeft, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { useNavigate } from "@/lib/router";
import {
readContextualSidebarOrigin,
type ContextualSidebarSurface,
} from "@/lib/shell-navigation";
import { cn } from "@/lib/utils";
import { SidebarNavExpandedProvider } from "./SidebarNavItem";
export function ContextualSidebarFrame({
surface,
title,
icon: Icon,
fallbackTo = "/dashboard",
showHeader = true,
className,
children,
}: {
surface: ContextualSidebarSurface;
title: string;
icon?: LucideIcon;
fallbackTo?: string;
showHeader?: boolean;
className?: string;
children: ReactNode;
}) {
const navigate = useNavigate();
const { selectedCompany } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
function goBack() {
navigate(readContextualSidebarOrigin({
surface,
companyPrefix: selectedCompany?.issuePrefix,
fallbackTo,
}));
if (isMobile) setSidebarOpen(false);
}
return (
<SidebarNavExpandedProvider>
<aside
data-contextual-sidebar={surface}
className={cn("flex h-full min-h-0 w-full flex-col bg-muted", className)}
>
{showHeader ? (
<div className="flex shrink-0 flex-col gap-1 px-3 py-3">
<button
type="button"
onClick={goBack}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-left text-xs text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Back from ${title}`}
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{selectedCompany?.name ?? "Organization"}</span>
</button>
<div className="flex min-w-0 items-center gap-2 px-2 py-1">
{Icon ? <Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" /> : null}
<span className="min-w-0 flex-1 truncate text-sm font-bold text-foreground">{title}</span>
</div>
</div>
) : null}
{children}
</aside>
</SidebarNavExpandedProvider>
);
}
+42 -2
View File
@@ -2,9 +2,14 @@
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./IssueColumns";
import {
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
IssueColumnPicker,
issueColumnDescription,
} from "./IssueColumns";
import { TooltipProvider } from "@/components/ui/tooltip";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -42,7 +47,42 @@ function renderLeading(element: React.ReactElement): string {
return container.textContent ?? "";
}
describe("IssueColumnPicker presentation options", () => {
it("offers date group separators as a toggle in the Columns menu", () => {
const onToggleDateGroupSeparators = vi.fn();
renderLeading(
<IssueColumnPicker
availableColumns={["status", "id", "updated"]}
visibleColumnSet={new Set(["status", "id", "updated"] as const)}
onToggleColumn={() => undefined}
showDateGroupSeparators
onToggleDateGroupSeparators={onToggleDateGroupSeparators}
onResetColumns={() => undefined}
title="Choose which task columns stay visible"
iconOnly
rowPresentation="task"
/>,
);
const columnsButton = container?.querySelector<HTMLButtonElement>('button[title="Columns"]');
expect(columnsButton).not.toBeNull();
act(() => columnsButton?.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })));
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitemcheckbox"]'))
.find((item) => item.textContent?.includes("Date group separators"));
expect(option?.getAttribute("aria-checked")).toBe("true");
act(() => option?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onToggleDateGroupSeparators).toHaveBeenCalledWith(false);
});
});
describe("InboxIssueMetaLeading live state", () => {
it("describes the canonical identifier at the trailing edge", () => {
expect(issueColumnDescription("id", "task")).toContain("trailing edge");
expect(issueColumnDescription("id", "legacy")).not.toContain("trailing edge");
});
it("shows the own Live chip for a running issue and never the subtree chip", () => {
const text = renderLeading(
<InboxIssueMetaLeading
+43 -3
View File
@@ -47,8 +47,25 @@ const issueColumnDescriptions: Record<InboxIssueColumn, string> = {
updated: "Latest visible activity time.",
};
export function issueColumnDescription(
column: InboxIssueColumn,
presentation: "legacy" | "task" = "legacy",
): string {
if (column === "id" && presentation === "task") {
return "Task identifier like PAP-1009 on the trailing edge.";
}
if (column === "status" && presentation === "task") {
return "Task state icon on the leading edge.";
}
return issueColumnDescriptions[column];
}
export function issueActivityTimestamp(issue: Issue): string {
return timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
}
export function issueActivityText(issue: Issue): string {
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
return `Updated ${issueActivityTimestamp(issue)}`;
}
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
@@ -69,16 +86,22 @@ export function IssueColumnPicker({
availableColumns,
visibleColumnSet,
onToggleColumn,
showDateGroupSeparators,
onToggleDateGroupSeparators,
onResetColumns,
title,
iconOnly = false,
rowPresentation = "legacy",
}: {
availableColumns: InboxIssueColumn[];
visibleColumnSet: ReadonlySet<InboxIssueColumn>;
onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
showDateGroupSeparators?: boolean;
onToggleDateGroupSeparators?: (enabled: boolean) => void;
onResetColumns: () => void;
title: string;
iconOnly?: boolean;
rowPresentation?: "legacy" | "task";
}) {
return (
<DropdownMenu>
@@ -119,11 +142,28 @@ export function IssueColumnPicker({
{issueColumnLabels[column]}
</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{issueColumnDescriptions[column]}
{issueColumnDescription(column, rowPresentation)}
</span>
</span>
</DropdownMenuCheckboxItem>
))}
{showDateGroupSeparators !== undefined && onToggleDateGroupSeparators ? (
<DropdownMenuCheckboxItem
checked={showDateGroupSeparators}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => onToggleDateGroupSeparators(checked === true)}
className="items-start rounded-lg px-3 py-2.5 pl-8"
>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
Date group separators
</span>
<span className="text-xs leading-relaxed text-muted-foreground">
Show Today, Yesterday, and Earlier rules on newest-first task lists.
</span>
</span>
</DropdownMenuCheckboxItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={onResetColumns}
@@ -262,7 +302,7 @@ export function InboxIssueTrailingColumns({
assigneeContent?: ReactNode;
onFilterWorkspace?: (workspaceId: string) => void;
}) {
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const activityText = issueActivityTimestamp(issue);
const userLabel = assigneeUserName ?? formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
const originatingActor = deriveOriginatingActor(issue);
const originatingUserId = originatingActor?.kind === "user" ? originatingActor.id : null;
@@ -58,6 +58,7 @@ describe("IssueFiltersPopover", () => {
act(() => {
root.render(
<IssueFiltersPopover
presentation="streamlined"
state={defaultIssueFilterState}
onChange={vi.fn()}
activeFilterCount={0}
@@ -74,6 +75,7 @@ describe("IssueFiltersPopover", () => {
expect(popoverContent).not.toBeNull();
expect(popoverContent?.className).toContain("overflow-y-auto");
expect(popoverContent?.className).toContain("max-h-(--sz-calc-9)");
expect(popoverContent?.querySelectorAll(".overflow-y-auto").length).toBe(0);
const layoutGrid = Array.from(popoverContent?.querySelectorAll("div") ?? []).find((element) =>
element.className.includes("md:grid-cols-3"),
@@ -88,6 +90,7 @@ describe("IssueFiltersPopover", () => {
act(() => {
root.render(
<IssueFiltersPopover
presentation="streamlined"
state={defaultIssueFilterState}
onChange={vi.fn()}
activeFilterCount={0}
@@ -106,4 +109,124 @@ describe("IssueFiltersPopover", () => {
expect(popoverContent?.textContent).toContain("Status");
expect(popoverContent?.textContent).not.toContain("Priority");
});
it("searches long option lists while the popover remains the only scroll owner", () => {
const root = createRoot(container);
const agents = Array.from({ length: 7 }, (_, index) => ({
id: `agent-${index + 1}`,
name: `Agent ${index + 1}`,
}));
act(() => {
root.render(
<IssueFiltersPopover
presentation="streamlined"
state={defaultIssueFilterState}
onChange={vi.fn()}
activeFilterCount={0}
agents={agents}
enableExternalObjectFilters={false}
/>,
);
});
const input = container.querySelector<HTMLInputElement>('input[aria-label="Search responsible"]');
expect(input).not.toBeNull();
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, "Agent 7");
input?.dispatchEvent(new Event("input", { bubbles: true }));
});
const responsibleOptions = container.querySelector('[data-filter-options="responsible"]');
expect(responsibleOptions?.textContent).toContain("Agent 7");
expect(responsibleOptions?.textContent).not.toContain("Agent 1");
expect(container.querySelector('[data-testid="popover-content"]')?.querySelectorAll(".overflow-y-auto").length).toBe(0);
act(() => root.unmount());
});
it("restores per-section scrolling and hides added option searches in legacy presentation", () => {
const root = createRoot(container);
const agents = Array.from({ length: 7 }, (_, index) => ({
id: `agent-${index + 1}`,
name: `Agent ${index + 1}`,
}));
act(() => {
root.render(
<IssueFiltersPopover
state={defaultIssueFilterState}
onChange={vi.fn()}
activeFilterCount={0}
agents={agents}
projects={Array.from({ length: 7 }, (_, index) => ({
id: `project-${index + 1}`,
name: `Project ${index + 1}`,
}))}
presentation="legacy"
enableExternalObjectFilters={false}
/>,
);
});
const popoverContent = container.querySelector("[data-testid='popover-content']");
expect(popoverContent?.className).not.toContain("overflow-y-auto");
expect(container.querySelector('input[aria-label="Search responsible"]')).toBeNull();
expect(container.querySelector('[data-filter-options="responsible"]')?.className).toContain("overflow-y-auto");
expect(container.querySelector('[data-filter-options="projects"]')?.className).toContain("overflow-y-auto");
act(() => root.unmount());
});
it("integrates Inbox category and approval status into the filter menu", () => {
const root = createRoot(container);
const onChange = vi.fn();
const onCategoryChange = vi.fn();
const onApprovalStatusChange = vi.fn();
const onClear = vi.fn();
act(() => {
root.render(
<IssueFiltersPopover
presentation="streamlined"
state={defaultIssueFilterState}
onChange={onChange}
activeFilterCount={2}
enableExternalObjectFilters={false}
inboxScopeFilters={{
category: "approvals",
approvalStatus: "actionable",
showApprovalStatus: true,
onCategoryChange,
onApprovalStatusChange,
onClear,
}}
/>,
);
});
const categoryOptions = container.querySelector('[data-filter-options="inbox-category"]');
const approvalOptions = container.querySelector('[data-filter-options="inbox-approval-status"]');
expect(categoryOptions?.textContent).toContain("All categories");
expect(categoryOptions?.querySelector('button[aria-pressed="true"]')?.textContent).toContain("Approvals");
expect(approvalOptions?.querySelector('button[aria-pressed="true"]')?.textContent).toContain("Needs action");
const allCategoriesButton = Array.from(categoryOptions?.querySelectorAll("button") ?? [])
.find((button) => button.textContent?.includes("All categories"));
const resolvedButton = Array.from(approvalOptions?.querySelectorAll("button") ?? [])
.find((button) => button.textContent?.includes("Resolved"));
act(() => allCategoriesButton?.click());
act(() => resolvedButton?.click());
expect(onCategoryChange).toHaveBeenCalledWith("everything");
expect(onApprovalStatusChange).toHaveBeenCalledWith("resolved");
const clearButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Clear");
act(() => clearButton?.click());
expect(onChange).toHaveBeenCalledWith(defaultIssueFilterState);
expect(onClear).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
});
+170 -13
View File
@@ -4,7 +4,7 @@ import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Bot, Filter, HardDrive, Search, User, X } from "lucide-react";
import { Bot, Check, Filter, HardDrive, Search, User, X } from "lucide-react";
import { PriorityIcon } from "./PriorityIcon";
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
import { StatusIcon } from "./StatusIcon";
@@ -17,12 +17,15 @@ import {
issuePriorityOrder,
issueQuickFilterPresets,
issueStatusOrder,
searchIssueFilterOptions,
toggleIssueFilterValue,
type IssueFilterState,
} from "../lib/issue-filters";
import { externalObjectIconForCategory } from "../lib/external-objects";
import { externalObjectStatusIcon } from "../lib/status-colors";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { InboxApprovalFilter, InboxCategoryFilter } from "../lib/inbox";
import { cn } from "../lib/utils";
type AgentOption = {
id: string;
@@ -52,6 +55,55 @@ type CreatorOption = {
searchText?: string;
};
type InboxScopeFilters = {
category: InboxCategoryFilter;
approvalStatus: InboxApprovalFilter;
showApprovalStatus: boolean;
onCategoryChange: (value: InboxCategoryFilter) => void;
onApprovalStatusChange: (value: InboxApprovalFilter) => void;
onClear: () => void;
};
const INBOX_CATEGORY_OPTIONS: ReadonlyArray<[InboxCategoryFilter, string]> = [
["everything", "All categories"],
["issues_i_touched", "My recent tasks"],
["join_requests", "Join requests"],
["approvals", "Approvals"],
["failed_runs", "Failed runs"],
["alerts", "Alerts"],
];
const INBOX_APPROVAL_STATUS_OPTIONS: ReadonlyArray<[InboxApprovalFilter, string]> = [
["all", "All approval statuses"],
["actionable", "Needs action"],
["resolved", "Resolved"],
];
const SEARCHABLE_FILTER_THRESHOLD = 6;
function FilterOptionSearch({
value,
onChange,
label,
}: {
value: string;
onChange: (value: string) => void;
label: string;
}) {
return (
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={`Search ${label.toLowerCase()}...`}
aria-label={`Search ${label.toLowerCase()}`}
className="h-8 pl-7 text-xs"
/>
</div>
);
}
export function IssueFiltersPopover({
state,
onChange,
@@ -66,6 +118,8 @@ export function IssueFiltersPopover({
iconOnly = false,
workspaces,
creators,
presentation = "legacy",
inboxScopeFilters,
}: {
state: IssueFilterState;
onChange: (patch: Partial<IssueFilterState>) => void;
@@ -80,8 +134,31 @@ export function IssueFiltersPopover({
iconOnly?: boolean;
workspaces?: WorkspaceOption[];
creators?: CreatorOption[];
presentation?: "legacy" | "streamlined";
inboxScopeFilters?: InboxScopeFilters;
}) {
const streamlined = presentation === "streamlined";
const [creatorSearch, setCreatorSearch] = useState("");
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectSearch, setProjectSearch] = useState("");
const [labelSearch, setLabelSearch] = useState("");
const [workspaceSearch, setWorkspaceSearch] = useState("");
const visibleAgents = useMemo(
() => searchIssueFilterOptions(agents, assigneeSearch, (option) => option.name),
[agents, assigneeSearch],
);
const visibleProjects = useMemo(
() => searchIssueFilterOptions(projects, projectSearch, (option) => option.name),
[projects, projectSearch],
);
const visibleLabels = useMemo(
() => searchIssueFilterOptions(labels, labelSearch, (option) => option.name),
[labels, labelSearch],
);
const visibleWorkspaces = useMemo(
() => searchIssueFilterOptions(workspaces, workspaceSearch, (option) => option.name),
[workspaces, workspaceSearch],
);
const creatorOptions = creators ?? [];
const creatorOptionById = useMemo(
() => new Map(creatorOptions.map((option) => [option.id, option])),
@@ -111,6 +188,10 @@ export function IssueFiltersPopover({
}),
[creatorOptionById, currentUserId, state.creators],
);
const clearFilters = () => {
onChange(defaultIssueFilterState);
inboxScopeFilters?.onClear();
};
return (
<Popover>
@@ -125,7 +206,7 @@ export function IssueFiltersPopover({
className="ml-1 hidden h-3 w-3 sm:block"
onClick={(event) => {
event.stopPropagation();
onChange(defaultIssueFilterState);
clearFilters();
}}
/>
) : null}
@@ -133,7 +214,9 @@ export function IssueFiltersPopover({
</PopoverTrigger>
<PopoverContent
align="end"
className="w-(--sz-calc-10) max-h-(--sz-calc-9) overflow-y-auto overscroll-contain p-0"
className={streamlined
? "w-(--sz-calc-10) max-h-(--sz-calc-9) overflow-y-auto overscroll-contain p-0"
: "w-(--sz-calc-10) p-0"}
>
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
@@ -142,13 +225,75 @@ export function IssueFiltersPopover({
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => onChange(defaultIssueFilterState)}
onClick={clearFilters}
>
Clear
</button>
) : null}
</div>
{inboxScopeFilters ? (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1" data-filter-options="inbox-category">
<span className="text-xs text-muted-foreground">Category</span>
<div className="space-y-0.5">
{INBOX_CATEGORY_OPTIONS.map(([value, label]) => {
const selected = inboxScopeFilters.category === value;
return (
<button
key={value}
type="button"
aria-pressed={selected}
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1 text-left text-sm",
selected
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
onClick={() => inboxScopeFilters.onCategoryChange(value)}
>
<span>{label}</span>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
</button>
);
})}
</div>
</div>
{inboxScopeFilters.showApprovalStatus ? (
<div className="space-y-1" data-filter-options="inbox-approval-status">
<span className="text-xs text-muted-foreground">Approval status</span>
<div className="space-y-0.5">
{INBOX_APPROVAL_STATUS_OPTIONS.map(([value, label]) => {
const selected = inboxScopeFilters.approvalStatus === value;
return (
<button
key={value}
type="button"
aria-pressed={selected}
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1 text-left text-sm",
selected
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
onClick={() => inboxScopeFilters.onApprovalStatusChange(value)}
>
<span>{label}</span>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
</button>
);
})}
</div>
</div>
) : null}
</div>
<div className="border-t border-border" />
</>
) : null}
<div className="space-y-1.5">
<span className="text-xs text-muted-foreground">Quick filters</span>
<div className="flex flex-wrap gap-1.5">
@@ -215,7 +360,10 @@ export function IssueFiltersPopover({
<div className="min-w-0 space-y-3">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Responsible</span>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
{streamlined && (agents?.length ?? 0) + (currentUserId ? 2 : 1) > SEARCHABLE_FILTER_THRESHOLD ? (
<FilterOptionSearch value={assigneeSearch} onChange={setAssigneeSearch} label="Responsible" />
) : null}
<div data-filter-options="responsible" className={streamlined ? "space-y-0.5" : "max-h-32 space-y-0.5 overflow-y-auto"}>
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.assignees.includes("__unassigned")}
@@ -233,7 +381,7 @@ export function IssueFiltersPopover({
<span className="text-sm">Me</span>
</label>
) : null}
{(agents ?? []).map((agent) => (
{(streamlined ? visibleAgents : agents ?? []).map((agent) => (
<label key={agent.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.assignees.includes(agent.id)}
@@ -275,7 +423,7 @@ export function IssueFiltersPopover({
className="h-8 pl-7 text-xs"
/>
</div>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
<div data-filter-options="creators" className={streamlined ? "space-y-0.5" : "max-h-32 space-y-0.5 overflow-y-auto"}>
{visibleCreatorOptions.length > 0 ? visibleCreatorOptions.map((creator) => {
const selected = state.creators.includes(creator.id);
return (
@@ -302,8 +450,11 @@ export function IssueFiltersPopover({
{projects && projects.length > 0 ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Project</span>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
{projects.map((project) => (
{streamlined && projects.length > SEARCHABLE_FILTER_THRESHOLD ? (
<FilterOptionSearch value={projectSearch} onChange={setProjectSearch} label="Projects" />
) : null}
<div data-filter-options="projects" className={streamlined ? "space-y-0.5" : "max-h-32 space-y-0.5 overflow-y-auto"}>
{(streamlined ? visibleProjects : projects).map((project) => (
<label key={project.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.projects.includes(project.id)}
@@ -321,8 +472,11 @@ export function IssueFiltersPopover({
{labels && labels.length > 0 ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Labels</span>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
{labels.map((label) => (
{streamlined && labels.length > SEARCHABLE_FILTER_THRESHOLD ? (
<FilterOptionSearch value={labelSearch} onChange={setLabelSearch} label="Labels" />
) : null}
<div data-filter-options="labels" className={streamlined ? "space-y-0.5" : "max-h-32 space-y-0.5 overflow-y-auto"}>
{(streamlined ? visibleLabels : labels).map((label) => (
<label key={label.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.labels.includes(label.id)}
@@ -339,8 +493,11 @@ export function IssueFiltersPopover({
{workspaces && workspaces.length > 0 ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Workspace</span>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
{workspaces.map((workspace) => (
{streamlined && workspaces.length > SEARCHABLE_FILTER_THRESHOLD ? (
<FilterOptionSearch value={workspaceSearch} onChange={setWorkspaceSearch} label="Workspaces" />
) : null}
<div data-filter-options="workspaces" className={streamlined ? "space-y-0.5" : "max-h-32 space-y-0.5 overflow-y-auto"}>
{(streamlined ? visibleWorkspaces : workspaces).map((workspace) => (
<label key={workspace.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.workspaces.includes(workspace.id)}
+432 -145
View File
@@ -148,7 +148,14 @@ vi.mock("./AgentIconPicker", () => ({
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
Link: ({
children,
to,
state: _state,
disableIssueQuicklook: _disableIssueQuicklook,
issuePrefetch: _issuePrefetch,
...props
}: { children: ReactNode; to: string; state?: unknown; disableIssueQuicklook?: boolean; issuePrefetch?: unknown } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
useCaseHref: () => (caseId: string) => `/cases/${caseId}`,
useLocation: () => ({ hash: "", pathname: "/", search: "", state: null, key: "test" }),
}));
@@ -497,6 +504,8 @@ describe("IssueProperties", () => {
],
});
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
enableStreamlinedUi: true,
});
});
@@ -504,6 +513,249 @@ describe("IssueProperties", () => {
document.body.innerHTML = "";
});
it("marks the task-detail property typography and section rhythm", () => {
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
const surface = container.querySelector(".task-detail-properties");
expect(surface).not.toBeNull();
expect(surface?.classList).toContain("pl-4");
expect(surface?.querySelectorAll('[data-property-section="true"]').length).toBeGreaterThan(1);
expect(surface?.querySelector('[data-property-value="true"]')).not.toBeNull();
expect(surface?.querySelector('[data-property-section="true"] > div')?.classList)
.toContain("text-muted-foreground/70");
const projectLabel = surface?.querySelector('[data-property-label="Project"]');
const labelsLabel = surface?.querySelector('[data-property-label="Labels"]');
if (!projectLabel || !labelsLabel) throw new Error("Expected Project and Labels rows");
expect(projectLabel.compareDocumentPosition(labelsLabel) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
act(() => root.unmount());
});
it("stacks property chips vertically and aligns each label with the first chip", async () => {
const blockedBy = Array.from({ length: 3 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
title: `Blocker ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blockedBy"]>;
const blocks = Array.from({ length: 3 }, (_, index) => ({
id: `blocked-${index + 1}`,
identifier: `BLOCKED-${index + 1}`,
title: `Blocked task ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blocks"]>;
const labels = Array.from({ length: 3 }, (_, index) => createLabel({
id: `label-${index + 1}`,
name: `Label ${index + 1}`,
}));
const childIssues = Array.from({ length: 3 }, (_, index) => createIssue({
id: `child-${index + 1}`,
identifier: `SUB-${index + 1}`,
title: `Subtask ${index + 1}`,
}));
const root = renderProperties(container, {
issue: createIssue({
blockedBy,
blocks,
labels,
labelIds: labels.map((label) => label.id),
}),
childIssues,
onUpdate: vi.fn(),
sidePanelContentOnly: true,
});
await flush();
for (const label of ["Labels", "Blocked by", "Blocking", "Subtasks"]) {
const labelNode = container.querySelector(`[data-property-label="${label}"]`);
const row = labelNode?.closest('[data-property-row="true"]');
const value = row?.querySelector('[data-property-value="true"]');
expect(row?.classList).toContain("items-start");
expect(labelNode?.classList).toContain("mt-0.5");
expect(value?.classList).toContain("flex-col");
expect(value?.classList).toContain("items-start");
}
for (const label of ["Labels", "Blocked by", "Subtasks"]) {
const trigger = findRowTrigger(container, label);
const chipStack = trigger?.querySelector("div");
expect(chipStack?.classList).toContain("flex-col");
expect(chipStack?.classList).toContain("items-start");
}
const blockingValue = container
.querySelector('[data-property-label="Blocking"]')
?.closest('[data-property-row="true"]')
?.querySelector('[data-property-value="true"] > div');
expect(blockingValue?.classList).toContain("flex-col");
act(() => root.unmount());
});
it("renders the lone Properties header as the active filled tab", async () => {
const headerSlot = document.createElement("div");
headerSlot.id = "properties-pane-header-slot";
document.body.appendChild(headerSlot);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const tab = headerSlot.querySelector<HTMLButtonElement>('[role="tab"]');
expect(tab?.textContent).toBe("Properties");
expect(tab?.getAttribute("aria-selected")).toBe("true");
expect(tab?.classList).toContain("bg-muted");
expect(tab?.classList).toContain("px-3");
expect(tab?.classList).toContain("inline-flex");
act(() => root.unmount());
});
it("uses the same filled active-tab treatment in a multi-tab pane header", async () => {
const headerSlot = document.createElement("div");
headerSlot.id = "properties-pane-header-slot";
document.body.appendChild(headerSlot);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [createIssue({ id: "child-1", identifier: "PAP-2", title: "Child task" })],
onUpdate: vi.fn(),
});
await flush();
const tabs = Array.from(headerSlot.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
const propertiesTab = tabs.find((tab) => tab.textContent === "Properties");
const subtasksTab = tabs.find((tab) => tab.textContent?.includes("Subtasks"));
const tabItems = Array.from(headerSlot.querySelectorAll<HTMLElement>('[data-slot="task-detail-pane-tab"]'));
const tabDividers = Array.from(headerSlot.querySelectorAll<HTMLElement>('[data-slot="task-detail-pane-tab-divider"]'));
const subtasksLabel = subtasksTab?.querySelector<HTMLElement>('[title="Subtasks"]');
const subtasksCount = Array.from(subtasksTab?.querySelectorAll<HTMLElement>("span") ?? [])
.find((span) => span.textContent === "1");
const closeSubtasks = headerSlot.querySelector<HTMLButtonElement>('[aria-label="Close Subtasks tab"]');
const openClosedTab = headerSlot.querySelector<HTMLButtonElement>('[aria-label="Open closed sidebar tab"]');
expect(propertiesTab?.getAttribute("data-state")).toBe("active");
expect(subtasksTab?.getAttribute("data-state")).toBe("inactive");
expect(tabItems).toHaveLength(2);
expect(tabDividers).toHaveLength(1);
expect(tabDividers[0]?.classList).toContain("h-4");
expect(propertiesTab?.classList).toContain("mx-1.5");
expect(propertiesTab?.classList).toContain("px-3");
expect(subtasksTab?.classList).toContain("px-3");
expect(subtasksTab?.classList).toContain("hover:bg-accent/50");
expect(subtasksLabel?.classList).toContain("task-detail-pane-tab-label");
expect(subtasksLabel?.classList).toContain("flex-1");
expect(subtasksLabel?.classList).toContain("overflow-hidden");
expect(subtasksLabel?.classList).toContain("whitespace-nowrap");
expect(subtasksCount?.classList).toContain("group-hover/pane-tab:opacity-0");
expect(closeSubtasks?.classList).toContain("opacity-0");
expect(closeSubtasks?.classList).toContain("right-2.5");
expect(closeSubtasks?.classList).toContain("group-hover/pane-tab:opacity-100");
expect(openClosedTab?.classList).toContain("size-6");
expect(openClosedTab?.classList).not.toContain("ml-1");
expect(openClosedTab?.disabled).toBe(true);
for (const tab of tabs) {
expect(tab.classList).toContain("task-detail-pane-tab");
expect(tab.classList).toContain("h-7");
expect(tab.classList).toContain("rounded-md");
}
await act(async () => {
subtasksTab?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
});
expect(propertiesTab?.getAttribute("data-state")).toBe("inactive");
expect(subtasksTab?.getAttribute("data-state")).toBe("active");
await act(async () => closeSubtasks?.click());
expect(headerSlot.querySelector('[role="tab"][data-state="active"]')?.textContent).toBe("Properties");
expect(headerSlot.textContent).not.toContain("Subtasks");
expect(openClosedTab?.disabled).toBe(false);
await act(async () => {
openClosedTab?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
openClosedTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
let reopenSubtasks: HTMLElement | undefined;
await waitForAssertion(() => {
reopenSubtasks = Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => item.textContent === "Subtasks");
expect(reopenSubtasks).not.toBeUndefined();
});
await act(async () => {
reopenSubtasks?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
reopenSubtasks?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(headerSlot.querySelector('[role="tab"][data-state="active"]')?.textContent).toContain("Subtasks");
act(() => root.unmount());
});
it("restores master's production property styling when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
enableStreamlinedUi: false,
enableClassicTaskInterface: false,
});
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await waitForAssertion(() => {
expect(container.querySelector(".task-detail-properties")).toBeNull();
expect(container.querySelector('[role="tab"]')).toBeNull();
});
expect(container.textContent).toContain("Status");
expect(container.textContent).toContain("Triage");
act(() => root.unmount());
});
it("restores master's plain Properties pane header when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
enableStreamlinedUi: false,
enableClassicTaskInterface: false,
});
const headerSlot = document.createElement("div");
headerSlot.id = "properties-pane-header-slot";
document.body.appendChild(headerSlot);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
});
await waitForAssertion(() => {
expect(headerSlot.textContent).toBe("Properties");
expect(headerSlot.querySelector('[role="tab"]')).toBeNull();
expect(container.querySelector(".task-detail-properties")).toBeNull();
});
act(() => root.unmount());
});
it("does not show a Plan tab for a planning-mode issue without a plan document", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableClassicTaskInterface: false,
@@ -852,9 +1104,7 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("exposes the classic-layout add sub-issue pill action", async () => {
// The chat shell hosts the full tree in the center pane; the slim pill row
// + its Add sub-task button only render in the classic layout (PAP-496).
it("exposes the add-subtask action from the relationships picker", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableClassicTaskInterface: true,
});
@@ -865,16 +1115,14 @@ describe("IssueProperties", () => {
onAddSubIssue,
onUpdate: vi.fn(),
});
// Wait for the classic-layout settings query to resolve (the pane starts in
// the chat shell until it does).
await waitForAssertion(() => {
expect(container.textContent).toContain("Add sub-task");
expect(container.textContent).toContain("Add subtask");
});
expect(container.textContent).toContain("Sub-tasks");
expect(container.textContent).toContain("Subtasks");
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add sub-task"));
.find((button) => button.textContent?.includes("Add subtask"));
expect(addButton).not.toBeUndefined();
await act(async () => {
@@ -886,16 +1134,31 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("does not duplicate sub-tasks in the properties pane in the chat shell", async () => {
it("gives sub-tasks a dedicated chat-shell panel tab", async () => {
const onAddSubIssue = vi.fn();
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
childIssues: [createIssue({ id: "child-2", identifier: "PAP-2", title: "Panel child" })],
onAddSubIssue,
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(container.textContent).not.toContain("Add sub-task");
expect(container.textContent).not.toContain("Sub-tasks");
const subtasksTab = Array.from(container.querySelectorAll<HTMLButtonElement>('button[role="tab"]'))
.find((button) => button.textContent?.includes("Subtasks"));
expect(subtasksTab).not.toBeUndefined();
await act(async () => {
subtasksTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
});
expect(container.textContent).toContain("Panel child");
expect(container.textContent).toContain("0 of 1 complete");
expect(container.textContent).toContain("Next action");
expect(container.querySelector('[data-slot="task-row"]')).not.toBeNull();
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add subtask"));
await act(async () => addButton!.click());
expect(onAddSubIssue).toHaveBeenCalledOnce();
act(() => root.unmount());
});
@@ -943,7 +1206,7 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("renders blocked-by issues as direct chips and edits them from an add action", async () => {
it("edits blockers from the blocked-by relationship flyout", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([
createIssue({ id: "issue-3", identifier: "PAP-3", title: "New blocker", status: "todo" }),
@@ -969,24 +1232,13 @@ describe("IssueProperties", () => {
});
await flush();
const blockerLink = container.querySelector('a[href="/issues/PAP-2"]');
expect(blockerLink).not.toBeNull();
expect(blockerLink?.textContent).toContain("PAP-2");
expect(blockerLink?.closest("button")).toBeNull();
expect(blockerLink?.className).toContain("px-2");
expect(blockerLink?.className).toContain("py-0.5");
expect(blockerLink?.className).toContain("text-xs");
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
expect(removeButton?.className).toContain("absolute");
expect(container.textContent).toContain("Add blocker");
const blockerTrigger = findRowTrigger(container, "Blocked by");
expect(blockerTrigger?.textContent).toContain("PAP-2");
expect(container.textContent).not.toContain("Add blocker");
expect(container.querySelector('input[placeholder="Search tasks..."]')).toBeNull();
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add blocker"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
blockerTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
@@ -1022,12 +1274,11 @@ describe("IssueProperties", () => {
});
await flush();
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add blocker"));
expect(addButton).not.toBeUndefined();
const blockerTrigger = findRowTrigger(container, "Blocked by");
expect(blockerTrigger).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
blockerTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
@@ -1059,7 +1310,7 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("removes a blocked-by issue from the chip remove action after confirmation", async () => {
it("removes a blocked-by issue by toggling it in the relationship flyout", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
@@ -1090,21 +1341,20 @@ describe("IssueProperties", () => {
});
await flush();
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
expect(removeButton).not.toBeNull();
const blockerTrigger = findRowTrigger(container, "Blocked by");
expect(blockerTrigger).not.toBeUndefined();
await act(async () => {
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
blockerTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this task.");
const confirmButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove blocker"));
expect(confirmButton).not.toBeUndefined();
const selectedBlocker = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Existing blocker"));
expect(selectedBlocker).not.toBeUndefined();
await act(async () => {
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
selectedBlocker!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] });
@@ -1112,7 +1362,7 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("opens visit and remove actions when a blocked-by chip is tapped on mobile", async () => {
it("uses the same blocked-by relationship picker on mobile", async () => {
mockSidebarState.isMobile = true;
const onUpdate = vi.fn();
const root = renderProperties(container, {
@@ -1144,36 +1394,20 @@ describe("IssueProperties", () => {
});
await flush();
expect(container.querySelector('a[href="/issues/PAP-2"]')).toBeNull();
expect(container.querySelector('button[aria-label="Remove PAP-2 as blocker"]')).toBeNull();
const blockerActions = container.querySelector('button[aria-label="Actions for blocker PAP-2"]');
expect(blockerActions).not.toBeNull();
const blockerTrigger = findRowTrigger(container, "Blocked by");
expect(blockerTrigger?.textContent).toContain("PAP-2");
await act(async () => {
blockerActions!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
blockerActions!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
blockerTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const visitLink = Array.from(document.body.querySelectorAll('a[href="/issues/PAP-2"]'))
.find((link) => link.textContent?.includes("Visit task"));
expect(visitLink).not.toBeUndefined();
const removeMenuItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((item) => item.textContent?.includes("Remove blocker"));
expect(removeMenuItem).not.toBeUndefined();
const selectedBlocker = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Existing blocker"));
expect(selectedBlocker).not.toBeUndefined();
await act(async () => {
removeMenuItem!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this task.");
const confirmButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove blocker"));
expect(confirmButton).not.toBeUndefined();
await act(async () => {
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
selectedBlocker!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] });
@@ -1181,12 +1415,7 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("collapses long blocked-by and sub-task lists until the more button is clicked", async () => {
// The sub-task pill row (with its collapse control) is classic-layout only
// now — the chat shell promotes sub-tasks to their own pane tab (PAP-496).
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableClassicTaskInterface: true,
});
it("summarizes long blocked-by and subtask relationships in their picker triggers", async () => {
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
@@ -1207,71 +1436,46 @@ describe("IssueProperties", () => {
onUpdate: vi.fn(),
inline: true,
});
// Wait for the classic-layout settings query to resolve so the sub-task
// pill row renders (the pane starts in the chat shell until it does).
await waitForAssertion(() => {
expect(container.textContent).toContain("SUB-5");
});
await flush();
expect(container.textContent).toContain("BLOCK-5");
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).not.toContain("SUB-6");
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "Show 2 more",
),
).toHaveLength(2);
const blockedByTrigger = findRowTrigger(container, "Blocked by");
expect(blockedByTrigger?.textContent).toContain("BLOCK-1");
expect(blockedByTrigger?.textContent).toContain("BLOCK-2");
expect(blockedByTrigger?.textContent).toContain("+5 more");
expect(blockedByTrigger?.textContent).not.toContain("BLOCK-7");
const subtasksTrigger = findRowTrigger(container, "Subtasks");
expect(subtasksTrigger?.textContent).toContain("SUB-1");
expect(subtasksTrigger?.textContent).toContain("SUB-2");
expect(subtasksTrigger?.textContent).toContain("+5 more");
expect(subtasksTrigger?.textContent).not.toContain("SUB-7");
const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show 2 more",
);
expect(expandBlockedBy).not.toBeUndefined();
await act(async () => {
expandBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
blockedByTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.textContent).toContain("BLOCK-6");
expect(container.textContent).toContain("BLOCK-7");
expect(container.textContent).not.toContain("SUB-6");
expect(container.textContent).toContain("Show less");
const expandSubTasks = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show 2 more",
);
expect(expandSubTasks).not.toBeUndefined();
await act(async () => {
expandSubTasks!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
blockedByTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
await act(async () => {
subtasksTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.textContent).toContain("SUB-6");
expect(container.textContent).toContain("SUB-7");
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "Show 2 more",
),
).toHaveLength(0);
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "Show less",
),
).toHaveLength(2);
const collapseBlockedBy = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show less",
);
expect(collapseBlockedBy).not.toBeUndefined();
await act(async () => {
collapseBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).toContain("SUB-6");
expect(container.textContent).toContain("Show 2 more");
act(() => root.unmount());
});
it("collapses long blocking and related task lists until the more button is clicked", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
enableClassicTaskInterface: true,
});
const blocking = Array.from({ length: 7 }, (_, index) => ({
id: `blocking-${index + 1}`,
identifier: `BLOCKING-${index + 1}`,
@@ -1308,9 +1512,11 @@ describe("IssueProperties", () => {
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("RELATED-5");
});
expect(container.textContent).toContain("BLOCKING-5");
expect(container.textContent).not.toContain("BLOCKING-6");
expect(container.textContent).toContain("RELATED-5");
expect(container.textContent).not.toContain("RELATED-6");
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
@@ -1395,7 +1601,7 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("resets expanded relation previews when the issue changes", async () => {
it("updates the blocked-by relationship summary when the issue changes", async () => {
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
@@ -1426,21 +1632,24 @@ describe("IssueProperties", () => {
});
await flush();
const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show 2 more",
);
expect(expandBlockedBy).not.toBeUndefined();
await act(async () => {
expandBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(findRowTrigger(container, "Blocked by")?.textContent).toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.textContent).toContain("+5 more");
expect(container.textContent).toContain("BLOCK-6");
const nextBlockedBy = [{
id: "next-blocker",
identifier: "NEXT-1",
title: "Next blocker",
status: "todo" as const,
priority: "medium" as const,
assigneeAgentId: null,
assigneeUserId: null,
}];
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties
issue={createIssue({ id: "issue-b", blockedBy })}
issue={createIssue({ id: "issue-b", blockedBy: nextBlockedBy })}
childIssues={[]}
onUpdate={vi.fn()}
inline
@@ -1450,8 +1659,9 @@ describe("IssueProperties", () => {
});
await flush();
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).toContain("Show 2 more");
expect(findRowTrigger(container, "Blocked by")?.textContent).toContain("NEXT-1");
expect(findRowTrigger(container, "Blocked by")?.textContent).not.toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.textContent).not.toContain("more");
act(() => root.unmount());
});
@@ -1481,6 +1691,33 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("uses the configured project color and icon in the project property", async () => {
mockProjectsApi.list.mockResolvedValue([
createProject({
name: "Configured Project",
color: "#0ea5e9",
icon: "rocket",
}),
]);
const root = renderProperties(container, {
issue: createIssue({ projectId: "project-1" }),
childIssues: [],
onUpdate: vi.fn(),
sidePanelContentOnly: true,
});
await waitForAssertion(() => {
expect(findRowTrigger(container, "Project")?.textContent).toContain("Configured Project");
});
const projectTrigger = findRowTrigger(container, "Project");
const projectTile = projectTrigger?.querySelector<HTMLElement>('span.inline-flex[aria-hidden="true"]');
expect(projectTile?.style.backgroundColor).toBe("rgb(14, 165, 233)");
expect(projectTile?.querySelector("svg")?.classList).toContain("lucide-rocket");
act(() => root.unmount());
});
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
@@ -1560,6 +1797,15 @@ describe("IssueProperties", () => {
expect(container.textContent).toMatch(/StartedApr 6, 2026, \d{1,2}:35 (AM|PM)/);
expect(container.textContent).toMatch(/CompletedApr 6, 2026, \d{1,2}:36 (AM|PM)/);
for (const label of ["Started", "Completed", "Created"]) {
const labelNode = container.querySelector(`[data-property-label="${label}"]`);
const value = labelNode?.parentElement?.querySelector<HTMLElement>('[data-property-value="true"] > span');
expect(value?.classList).toContain("min-w-0");
expect(value?.classList).toContain("truncate");
expect(value?.classList).toContain("whitespace-nowrap");
expect(value?.title).toBe(value?.textContent);
}
act(() => root.unmount());
});
@@ -1608,11 +1854,18 @@ describe("IssueProperties", () => {
onUpdate: vi.fn(),
});
await flush();
await flush();
const branchCopyButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy pap-1-workspace to clipboard"]',
);
const folderCopyButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy /tmp/paperclip/PAP-1 to clipboard"]',
);
expect(branchCopyButton).not.toBeNull();
expect(folderCopyButton?.querySelector('[data-middle-truncate="true"]')?.textContent)
.toBe("/tmp/paperclip/PAP-1");
expect(folderCopyButton?.title).toBe("/tmp/paperclip/PAP-1");
await act(async () => {
branchCopyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -1653,7 +1906,7 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("shows related task references below sub-issues", async () => {
it("separates referenced tasks from tasks that mention this task", async () => {
const root = renderProperties(container, {
issue: createIssue({
relatedWork: {
@@ -1672,22 +1925,49 @@ describe("IssueProperties", () => {
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
},
],
inbound: [],
inbound: [
{
issue: {
id: "issue-23",
identifier: "PAP-23",
title: "Mentions this task",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-1" }],
},
],
},
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(container.textContent).not.toContain("Task ids");
expect(container.textContent).toContain("Related tasks");
expect(container.textContent).not.toContain("Related tasks");
const referencesTab = Array.from(container.querySelectorAll<HTMLButtonElement>('button[role="tab"]'))
.find((button) => button.textContent === "References");
await act(async () => {
referencesTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
});
expect(container.textContent).toContain("Referenced");
expect(container.textContent).toContain("PAP-22");
expect(container.textContent).toContain("Mentioned in");
expect(container.textContent).toContain("PAP-23");
act(() => root.unmount());
});
it("hides related task references already covered by blockers, blocking, and sub-issues", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
enableClassicTaskInterface: true,
});
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
@@ -1768,12 +2048,14 @@ describe("IssueProperties", () => {
});
await flush();
expect(container.textContent).not.toContain("Related tasks");
await waitForAssertion(() => {
expect(container.textContent).not.toContain("Referenced");
});
act(() => root.unmount());
});
it("shows an add-label button when labels already exist and opens the picker", async () => {
it("opens the label picker from its relationship value without a redundant add button", async () => {
const root = renderProperties(container, {
issue: createIssue({
labels: [{ id: "label-1", companyId: "company-1", name: "Bug", color: "#ef4444", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z") }],
@@ -1785,12 +2067,17 @@ describe("IssueProperties", () => {
});
await flush();
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
expect(addLabelButton).not.toBeNull();
expect(container.querySelector('button[aria-label="Add label"]')).toBeNull();
expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull();
const labelsTrigger = findRowTrigger(container, "Labels");
expect(labelsTrigger?.textContent).toContain("Bug");
const labelChip = labelsTrigger?.querySelector<HTMLElement>('[title="Bug"]');
expect(labelChip?.classList).toContain("border-0");
expect(labelChip?.style.borderColor).toBe("");
expect(labelChip?.style.color).not.toBe("");
await act(async () => {
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
labelsTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
@@ -1978,10 +2265,10 @@ describe("IssueProperties", () => {
});
await flush();
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
expect(addLabelButton).not.toBeNull();
const labelsTrigger = findRowTrigger(container, "Labels");
expect(labelsTrigger?.textContent).toContain("Bug");
await act(async () => {
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
labelsTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
+194
View File
@@ -122,6 +122,159 @@ describe("IssueRow", () => {
});
});
it("uses stable canonical identifier and timestamp columns at the trailing edge", () => {
const root = createRoot(container);
act(() => {
root.render(
<IssueRow
issue={createIssue({ identifier: "PAP-42", title: "Canonical task" })}
presentation="task"
metadata={<span>Live</span>}
actions={<button type="button">More</button>}
trailingMeta="Updated now"
/>,
);
});
const row = container.querySelector('[data-slot="task-row"]');
const leading = row?.querySelector('[data-slot="task-row-leading"]');
const title = row?.querySelector('[data-slot="task-row-title"]');
const metadata = row?.querySelector('[data-slot="task-row-metadata"]');
const identifier = row?.querySelector('[data-slot="task-row-identifier"]');
const timestamp = row?.querySelector('[data-slot="task-row-timestamp"]');
const actions = row?.querySelector('[data-slot="task-row-actions"]');
const link = row?.querySelector('[data-inbox-issue-link]');
expect(leading?.querySelector("svg")).not.toBeNull();
expect(title?.textContent).toContain("Canonical task");
expect(metadata?.textContent).toBe("Live");
expect(identifier?.textContent).toBe("PAP-42");
expect(timestamp?.textContent).toBe("Updated now");
expect(actions?.textContent).toBe("More");
expect(identifier?.className).toContain("w-20");
expect(timestamp?.className).toContain("w-24");
if (!link || !metadata || !identifier || !timestamp || !actions) throw new Error("Expected canonical task row slots");
expect(link.contains(actions)).toBe(false);
expect(metadata.compareDocumentPosition(actions) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(actions.compareDocumentPosition(identifier) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(identifier.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(timestamp.nextElementSibling).toBeNull();
act(() => root.unmount());
});
it("keeps the canonical archive action within the shared task-row height", () => {
const root = createRoot(container);
act(() => {
root.render(
<IssueRow
issue={createIssue()}
presentation="task"
onArchive={() => undefined}
/>,
);
});
const archiveButton = container.querySelector<HTMLButtonElement>('button[aria-label="Archive"]');
expect(archiveButton?.className).toContain("h-5");
expect(archiveButton?.className).toContain("py-0");
expect(archiveButton?.className).not.toContain("py-1");
act(() => root.unmount());
});
it("preserves the legacy archive action density", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} onArchive={() => undefined} />);
});
const archiveButton = container.querySelector<HTMLButtonElement>('button[aria-label="Archive"]');
expect(archiveButton?.className).toContain("py-1");
expect(archiveButton?.className).not.toContain("h-5");
act(() => root.unmount());
});
it("emphasizes unread canonical titles and overlays the accessible mark-read control", () => {
const root = createRoot(container);
const onMarkRead = vi.fn();
act(() => {
root.render(
<IssueRow
issue={createIssue()}
presentation="task"
unreadState="visible"
onMarkRead={onMarkRead}
/>,
);
});
const row = container.querySelector('[data-slot="task-row"]');
const title = row?.querySelector('[data-slot="task-row-title"]');
const unreadSlot = row?.querySelector('[data-testid="issue-row-unread-slot"]');
const markReadButton = unreadSlot?.querySelector<HTMLButtonElement>('button[aria-label="Mark as read"]');
expect(row?.getAttribute("data-unread")).toBe("true");
expect(title?.className).toContain("font-semibold");
expect(unreadSlot).not.toBeNull();
expect(unreadSlot?.className).toContain("absolute");
expect(markReadButton).not.toBeNull();
expect(markReadButton?.closest("a")).toBeNull();
act(() => markReadButton?.click());
expect(onMarkRead).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("keeps canonical leading geometry independent of unread state", () => {
const root = createRoot(container);
act(() => {
root.render(
<>
<IssueRow issue={createIssue({ id: "read" })} presentation="task" unreadState="hidden" />
<IssueRow issue={createIssue({ id: "plain" })} presentation="task" />
</>,
);
});
const rows = Array.from(container.querySelectorAll('[data-slot="task-row"]'));
const unreadSlot = rows[0]?.querySelector('[data-testid="issue-row-unread-slot"]');
expect(rows).toHaveLength(2);
expect(rows[0]?.className).toBe(rows[1]?.className);
expect(unreadSlot).not.toBeNull();
expect(unreadSlot?.className).toContain("absolute");
expect(unreadSlot?.querySelector('button[aria-label="Mark as read"]')).toBeNull();
expect(rows[1]?.querySelector('[data-testid="issue-row-unread-slot"]')).toBeNull();
act(() => root.unmount());
});
it("preserves task-tree indentation slots in the canonical layout", () => {
const root = createRoot(container);
act(() => {
root.render(
<IssueRow
issue={createIssue()}
presentation="task"
treeGuides={2}
chevronInGuide
leadingControl={<button type="button">Expand</button>}
/>,
);
});
expect(container.querySelectorAll('[data-slot="task-row-tree-guide"]')).toHaveLength(2);
expect(container.querySelector('[data-slot="task-row-leading"]')?.textContent).toContain("Expand");
for (const connector of container.querySelectorAll('[data-slot="task-row-tree-connector"]')) {
expect(connector.className).toContain("left-7");
}
act(() => root.unmount());
});
it("keeps editable row controls keyboard-accessible and outside the navigation link", () => {
const root = createRoot(container);
@@ -509,6 +662,19 @@ describe("IssueRow", () => {
});
});
it("never renders a horizontal divider in canonical task presentation", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} presentation="task" showDivider />);
});
const row = container.querySelector('[data-slot="task-row"]');
expect(row?.className).not.toContain("border-b");
act(() => root.unmount());
});
it("keeps the hover wash on the row root while the overlay link stays a bare positioning layer", () => {
const root = createRoot(container);
@@ -620,6 +786,34 @@ describe("IssueRow", () => {
expect(label).not.toContain("next try");
});
it.each(["task", "legacy"] as const)(
"places the recovery chip immediately after the title in %s list rows",
(presentation) => {
const root = createRoot(container);
act(() => {
root.render(
<IssueRow
issue={recoveryIssue(at(-5 * 60_000))}
presentation={presentation}
/>,
);
});
const titleCluster = container.querySelector('[data-slot="task-row-title-cluster"]');
const title = titleCluster?.querySelector('[data-slot="task-row-title"]');
const chip = titleCluster?.querySelector('[data-testid="issue-row-recovery-indicator"]');
expect(titleCluster).not.toBeNull();
expect(title).not.toBeNull();
expect(chip).not.toBeNull();
if (!title || !chip) throw new Error("Expected the title and recovery chip");
expect(title.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
act(() => {
root.unmount();
});
},
);
it("stays calm when the overdue attempt is a verified live run", () => {
const chip = renderChip(
recoveryIssue(at(-5 * 60_000), {
+171 -17
View File
@@ -24,12 +24,25 @@ import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
import { Badge } from "@/components/ui/badge";
type UnreadState = "hidden" | "visible" | "fading";
export type IssueRowUnreadState = "hidden" | "visible" | "fading";
export type IssueRowPresentation = "legacy" | "task";
interface IssueRowProps {
export interface IssueRowProps {
issue: Issue;
issueLinkState?: unknown;
selected?: boolean;
/** Opt-in canonical collection layout. Legacy remains the default until each surface migrates. */
presentation?: IssueRowPresentation;
/** Interactive disclosure or selection control before the canonical status glyph. */
leadingControl?: ReactNode;
/** Optional status override; defaults to the task's shared StatusIcon. */
statusSlot?: ReactNode;
/** Stable metadata slot before the task's optional collection columns. */
metadata?: ReactNode;
/** Stable interactive action slot before the identifier and timestamp columns. */
actions?: ReactNode;
/** Controls the canonical trailing identifier without affecting legacy layouts. */
showIdentifier?: boolean;
mobileLeading?: ReactNode;
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
@@ -47,7 +60,7 @@ interface IssueRowProps {
checklistCurrentStep?: boolean;
checklistDependencyChips?: ReactNode;
checklistRowId?: string;
unreadState?: UnreadState | null;
unreadState?: IssueRowUnreadState | null;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
@@ -57,21 +70,22 @@ interface IssueRowProps {
/** Ancestor levels; renders that many vertical tree-guide slots (desktop). */
treeGuides?: number;
/**
* This row has its own collapse chevron sitting in the innermost guide
* column (a nested parent). Breaks the guide line there so the chevron is
* not crossed out by it.
* This nested row has its own collapse chevron aligned with the innermost
* guide. Breaks the guide line there so the chevron is not crossed out.
*/
chevronInGuide?: boolean;
/** Opt in to a bottom divider on this row (default off; used by views that intentionally keep separators). */
/** Legacy-only opt in to a bottom divider; canonical task rows stay divider-free. */
showDivider?: boolean;
}
export function InboxArchiveButton({
onArchive,
disabled,
compact = false,
}: {
onArchive: () => void;
disabled?: boolean;
compact?: boolean;
}) {
return (
<button
@@ -89,7 +103,10 @@ export function InboxArchiveButton({
onArchive();
}}
disabled={disabled}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-30"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-30",
compact ? "h-5 py-0" : "py-1",
)}
aria-label="Archive"
>
<Archive className="h-3.5 w-3.5" />
@@ -102,6 +119,12 @@ export function IssueRow({
issue,
issueLinkState,
selected = false,
presentation = "legacy",
leadingControl,
statusSlot,
metadata,
actions,
showIdentifier = true,
mobileLeading,
desktopMetaLeading,
desktopLeadingSpacer = false,
@@ -127,10 +150,9 @@ export function IssueRow({
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
// A row participates in the unread system whenever `unreadState` is supplied
// (inbox rows). It then reserves a fixed leading dot slot on all rows — read
// and unread alike — so the mark-read dot sits in the far-left gutter without
// shifting content, matching the sibling non-issue inbox rows.
// A row participates in the unread system whenever `unreadState` is supplied.
// Canonical rows overlay this affordance in their shared gutter, while legacy
// inbox rows retain their reserved slot until that presentation is migrated.
const showUnreadSlot = unreadState != null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const unreadDotButton = (
@@ -202,6 +224,134 @@ export function IssueRow({
</Badge>
) : null;
if (presentation === "task") {
const isUnread = unreadState === "visible" || unreadState === "fading";
return (
<div
onMouseEnter={onMouseEnter}
data-slot="task-row"
data-unread={isUnread ? "true" : undefined}
className={cn(
"group relative flex min-w-0 items-start gap-2 rounded-lg py-2.5 pl-4 pr-2 text-sm no-underline text-inherit sm:items-center sm:py-2",
"[&_button]:relative [&_button]:z-10",
selected ? "bg-accent/50 hover:bg-accent/50" : "hover:bg-accent/50",
checklistCurrentStep && "bg-primary/5",
className,
)}
>
<Link
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
data-inbox-issue-link
id={checklistRowId}
aria-current={checklistCurrentStep ? "step" : undefined}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
className="absolute inset-0 rounded-lg no-underline text-inherit focus-visible:z-10 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring"
>
<span className="sr-only">Open {identifier}: {issue.title}</span>
</Link>
{showUnreadSlot ? (
<span
data-testid="issue-row-unread-slot"
className="absolute left-0 top-3 inline-flex h-4 w-4 items-center justify-center sm:top-1/2 sm:-translate-y-1/2"
>
{showUnreadDot ? unreadDotButton : null}
</span>
) : null}
<span data-slot="task-row-leading" className="flex shrink-0 items-center gap-1 pt-px sm:pt-0">
{treeGuides > 0
? Array.from({ length: treeGuides }, (_, level) => {
const gapForChevron = chevronInGuide && level === treeGuides - 1;
return (
<span
key={`task-guide-${level}`}
data-slot="task-row-tree-guide"
aria-hidden="true"
className="relative hidden w-4 shrink-0 self-stretch sm:block"
>
<span
data-slot="task-row-tree-connector"
className="absolute -inset-y-3 left-7 w-px bg-background"
>
{gapForChevron ? (
<span className="absolute inset-0 flex flex-col">
<span className="flex-1 bg-border" />
<span className="h-3.5 shrink-0" />
<span className="flex-1 bg-border" />
</span>
) : (
<span className="absolute inset-0 bg-border" />
)}
</span>
</span>
);
})
: null}
{leadingControl}
{statusSlot ?? (
<StatusIcon
status={issue.status}
blockerAttention={issue.blockerAttention}
size="md"
className={selectedStatusClass}
/>
)}
{productivityReviewIndicator}
{parkedBlockerIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span data-slot="task-row-title-cluster" className="flex min-w-0 flex-1 items-start gap-1.5 sm:items-center">
<span
data-slot="task-row-title"
className={cn(
"min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none",
isUnread && "font-semibold",
titleClassName,
)}
>
{issue.title}{titleSuffix}
</span>
{recoveryIndicator}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1">{checklistDependencyChips}</span>
) : null}
{mobileMeta ? (
<span className="text-xs text-muted-foreground sm:hidden">{mobileMeta}</span>
) : null}
</span>
<span
data-slot="task-row-trailing"
className="ml-auto hidden min-w-0 shrink-0 items-center gap-2 sm:flex"
>
{externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{metadata ? <span data-slot="task-row-metadata" className="min-w-0">{metadata}</span> : null}
{desktopTrailing}
{actions ? <span data-slot="task-row-actions" className="flex shrink-0 items-center gap-1">{actions}</span> : null}
{onArchive ? <InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} compact /> : null}
{showIdentifier ? (
<span data-slot="task-row-identifier" className="w-20 shrink-0 text-right font-mono text-xs text-muted-foreground">
{identifier}
</span>
) : null}
{trailingMeta ? (
<span data-slot="task-row-timestamp" className="w-24 shrink-0 truncate text-right text-xs text-muted-foreground">
{trailingMeta}
</span>
) : null}
</span>
</div>
);
}
return (
<div
onMouseEnter={onMouseEnter}
@@ -243,11 +393,16 @@ export function IssueRow({
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
{productivityReviewIndicator}
{parkedBlockerIndicator}
{recoveryIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
{issue.title}{titleSuffix}
<span data-slot="task-row-title-cluster" className="flex min-w-0 items-start gap-1.5 sm:order-2 sm:flex-1 sm:items-center">
<span
data-slot="task-row-title"
className={cn("min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none", titleClassName)}
>
{issue.title}{titleSuffix}
</span>
{recoveryIndicator}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1 sm:order-3 sm:ml-(--sz-calc-13)">
@@ -317,7 +472,6 @@ export function IssueRow({
{identifier}
</span>
{parkedBlockerIndicator}
{recoveryIndicator}
</>
)}
{mobileMeta ? (
@@ -377,7 +531,7 @@ function renderRecoveryChip(
role="status"
aria-label={detail ? `${label} — ${detail}` : label}
className={cn(
"ml-1.5 gap-0.5 text-(length:--text-nano)",
"shrink-0 gap-0.5 text-(length:--text-nano)",
tone.className,
selected ? "!border-muted-foreground !text-muted-foreground" : null,
)}
+230 -34
View File
@@ -13,6 +13,7 @@ import {
issueAgeSeparatorLabel,
} from "./IssuesList";
import { TooltipProvider } from "@/components/ui/tooltip";
import { taskCollectionPreferencesStorageKey } from "../lib/task-collection-preferences";
const companyState = vi.hoisted(() => ({
selectedCompanyId: "company-1",
@@ -28,6 +29,7 @@ const mockIssuesApi = vi.hoisted(() => ({
}));
const mockKanbanBoard = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
@@ -61,7 +63,7 @@ vi.mock("../context/DialogContext", () => ({
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => vi.fn(),
useNavigate: () => mockNavigate,
Link: ({
children,
to,
@@ -130,6 +132,11 @@ vi.mock("./IssueRow", () => ({
checklistDependencyChips,
checklistRowId,
externalObjectSummary,
presentation,
leadingControl,
treeGuides,
showIdentifier,
trailingMeta,
}: {
issue: Issue;
desktopMetaLeading?: ReactNode;
@@ -140,20 +147,32 @@ vi.mock("./IssueRow", () => ({
checklistDependencyChips?: ReactNode;
checklistRowId?: string;
externalObjectSummary?: { total: number } | null;
presentation?: "legacy" | "task";
leadingControl?: ReactNode;
treeGuides?: number;
showIdentifier?: boolean;
trailingMeta?: ReactNode;
}) => (
<div
data-testid="issue-row"
data-presentation={presentation}
data-tree-guides={treeGuides ?? 0}
data-show-identifier={showIdentifier ? "true" : "false"}
data-trailing-meta={typeof trailingMeta === "string" ? trailingMeta : undefined}
data-has-desktop-trailing={desktopTrailing ? "true" : "false"}
id={checklistRowId}
data-step={checklistStepNumber ?? undefined}
data-current-step={checklistCurrentStep ? "true" : undefined}
data-title-class={titleClassName ?? undefined}
>
<span>{issue.title}</span>
{leadingControl}
{externalObjectSummary ? (
<span data-testid="external-object-summary">{externalObjectSummary.total}</span>
) : null}
{desktopMetaLeading}
{desktopTrailing}
{trailingMeta}
{checklistDependencyChips}
</div>
),
@@ -324,6 +343,7 @@ describe("IssuesList", () => {
document.body.appendChild(container);
dialogState.openNewIssue.mockReset();
mockKanbanBoard.mockReset();
mockNavigate.mockReset();
mockIssuesApi.list.mockReset();
mockIssuesApi.listLabels.mockReset();
mockAuthApi.getSession.mockReset();
@@ -343,6 +363,7 @@ describe("IssuesList", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableExternalObjects: false,
enableStreamlinedUi: true,
});
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 });
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {} });
@@ -354,6 +375,50 @@ describe("IssuesList", () => {
container.remove();
});
it("uses the master list and legacy persistence when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableExternalObjects: false,
enableStreamlinedUi: false,
});
localStorage.setItem(
taskCollectionPreferencesStorageKey({
companyId: "company-1",
collectionKey: "paperclip:test-issues",
}),
JSON.stringify({
version: 1,
companyId: "company-1",
collectionKey: "paperclip:test-issues",
viewState: { viewMode: "board" },
columns: [],
}),
);
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue({ id: "legacy-issue", title: "Master task row" })]}
isLoading={false}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
toolbarPresentation="collection"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const row = container.querySelector("[data-testid='issue-row']");
expect(row).not.toBeNull();
expect(row?.getAttribute("data-presentation")).toBeNull();
expect(container.querySelector("[data-testid='kanban-board']")).toBeNull();
});
act(() => root.unmount());
});
it("forwards external-object summaries into issue rows", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
@@ -380,6 +445,7 @@ describe("IssuesList", () => {
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
@@ -395,6 +461,80 @@ describe("IssuesList", () => {
});
});
it("keeps the canonical task-row presentation opt in per collection", async () => {
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const row = container.querySelector("[data-testid='issue-row']");
expect(row?.getAttribute("data-presentation")).toBe("task");
expect(row?.getAttribute("data-show-identifier")).toBe("true");
expect(row?.getAttribute("data-trailing-meta")).toMatch(/\S/);
expect(row?.getAttribute("data-trailing-meta")).not.toContain("Updated");
expect(row?.getAttribute("data-has-desktop-trailing")).toBe("false");
expect(row?.querySelector('[data-slot="task-row-disclosure-spacer"]')).not.toBeNull();
});
act(() => root.unmount());
});
it("reserves a disclosure column after each ancestor guide in canonical task trees", async () => {
const parent = createIssue({ id: "parent", identifier: "PAP-1", title: "Parent task" });
const child = createIssue({ id: "child", identifier: "PAP-2", parentId: parent.id, title: "Child task" });
const { root } = renderWithQueryClient(
<IssuesList
issues={[parent, child]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]'));
const parentRow = rows.find((row) => row.textContent?.includes("Parent task"));
const childRow = rows.find((row) => row.textContent?.includes("Child task"));
expect(parentRow?.getAttribute("data-tree-guides")).toBe("0");
expect(parentRow?.querySelector('button[aria-label="Collapse sub-tasks"]')).not.toBeNull();
expect(childRow?.getAttribute("data-tree-guides")).toBe("1");
expect(childRow?.querySelector('[data-slot="task-row-disclosure-spacer"]')).not.toBeNull();
});
act(() => root.unmount());
});
it("keeps the shared collection toolbar opt in per surface", async () => {
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
toolbarPresentation="collection"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.querySelector("[role='toolbar'][aria-label='Task controls']")).not.toBeNull();
});
act(() => root.unmount());
});
it("does not load external-object summaries when the experimental flag is disabled", async () => {
const { root } = renderWithQueryClient(
<IssuesList
@@ -402,6 +542,7 @@ describe("IssuesList", () => {
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
@@ -458,6 +599,7 @@ describe("IssuesList", () => {
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
@@ -780,6 +922,7 @@ describe("IssuesList", () => {
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
@@ -2013,11 +2156,11 @@ describe("IssuesList", () => {
});
});
it("draws day and week separators between recency-sorted rows", async () => {
const now = Date.now();
const hourAgo = new Date(now - 60 * 60 * 1000);
const threeDaysAgo = new Date(now - 3 * 24 * 60 * 60 * 1000);
const tenDaysAgo = new Date(now - 10 * 24 * 60 * 60 * 1000);
it("draws local-calendar separators between date-sorted rows", async () => {
const now = new Date();
const hourAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12);
const threeDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 3, 12);
const tenDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 10, 12);
const { root } = renderWithQueryClient(
<IssuesList
@@ -2029,6 +2172,7 @@ describe("IssuesList", () => {
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
@@ -2037,7 +2181,13 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
const separators = Array.from(container.querySelectorAll("[data-issues-date-separator]"));
const labels = separators.map((el) => el.getAttribute("aria-label"));
expect(labels).toEqual(["Older than a day", "Older than a week"]);
expect(labels).toEqual(["Today", "Earlier"]);
expect(separators.every((separator) => (
separator.querySelectorAll("[data-date-group-rule]").length === 2
))).toBe(true);
expect(separators.every((separator) => (
separator.querySelector("[data-date-group-label]")?.classList.contains("text-muted-foreground/70")
))).toBe(true);
});
act(() => {
@@ -2045,18 +2195,65 @@ describe("IssuesList", () => {
});
});
it("draws both separators when adjacent rows skip the middle age bucket", async () => {
const now = Date.now();
it("can hide date group separators from the persisted Columns option", async () => {
const collectionKey = "paperclip:test-issues";
localStorage.setItem(
taskCollectionPreferencesStorageKey({
companyId: "company-1",
collectionKey,
}),
JSON.stringify({
version: 1,
companyId: "company-1",
collectionKey,
viewState: { showDateGroupSeparators: false },
columns: ["status", "id", "updated"],
}),
);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12);
const threeDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 3, 12);
const { root } = renderWithQueryClient(
<IssuesList
issues={[
createIssue({ id: "issue-recent", identifier: "PAP-1", title: "Just updated", updatedAt: new Date(now - 60 * 60 * 1000) }),
createIssue({ id: "issue-old", identifier: "PAP-2", title: "Over a week old", updatedAt: new Date(now - 10 * 24 * 60 * 60 * 1000) }),
createIssue({ id: "issue-recent", title: "Just updated", updatedAt: today }),
createIssue({ id: "issue-old", title: "Earlier task", updatedAt: threeDaysAgo }),
]}
agents={[]}
projects={[]}
viewStateKey={collectionKey}
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.textContent).toContain("Earlier task");
expect(container.querySelector("[data-issues-date-separator]")).toBeNull();
});
act(() => {
root.unmount();
});
});
it("does not synthesize an empty Yesterday group", async () => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12);
const tenDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 10, 12);
const { root } = renderWithQueryClient(
<IssuesList
issues={[
createIssue({ id: "issue-recent", identifier: "PAP-1", title: "Just updated", updatedAt: today }),
createIssue({ id: "issue-old", identifier: "PAP-2", title: "Over a week old", updatedAt: tenDaysAgo }),
]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
@@ -2065,7 +2262,7 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
const labels = Array.from(container.querySelectorAll("[data-issues-date-separator]"))
.map((el) => el.getAttribute("aria-label"));
expect(labels).toEqual(["Older than a day", "Older than a week"]);
expect(labels).toEqual(["Today", "Earlier"]);
});
act(() => {
@@ -2074,18 +2271,22 @@ describe("IssuesList", () => {
});
it("places separators around expanded nested rows in visible order", async () => {
const now = Date.now();
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12);
const threeDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 3, 12);
const tenDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 10, 12);
const { root } = renderWithQueryClient(
<IssuesList
issues={[
createIssue({ id: "issue-parent", identifier: "PAP-1", title: "Recent parent", updatedAt: new Date(now - 60 * 60 * 1000) }),
createIssue({ id: "issue-child", identifier: "PAP-2", parentId: "issue-parent", title: "Older child", updatedAt: new Date(now - 3 * 24 * 60 * 60 * 1000) }),
createIssue({ id: "issue-old", identifier: "PAP-3", title: "Old root", updatedAt: new Date(now - 10 * 24 * 60 * 60 * 1000) }),
createIssue({ id: "issue-parent", identifier: "PAP-1", title: "Recent parent", updatedAt: today }),
createIssue({ id: "issue-child", identifier: "PAP-2", parentId: "issue-parent", title: "Older child", updatedAt: threeDaysAgo }),
createIssue({ id: "issue-old", identifier: "PAP-3", title: "Old root", updatedAt: tenDaysAgo }),
]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
@@ -2096,10 +2297,10 @@ describe("IssuesList", () => {
container.querySelectorAll("[data-testid='issue-row'], [data-issues-date-separator]"),
).map((element) => element.getAttribute("aria-label") ?? element.firstElementChild?.textContent);
expect(visibleOrder).toEqual([
"Today",
"Recent parent",
"Older than a day",
"Earlier",
"Older child",
"Older than a week",
"Old root",
]);
});
@@ -2109,18 +2310,21 @@ describe("IssuesList", () => {
});
});
it("omits date separators when all rows share a recency bucket", async () => {
const now = Date.now();
it("emits one Today heading when all rows share today's calendar group", async () => {
const now = new Date();
const todayMorning = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9);
const todayNoon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12);
const { root } = renderWithQueryClient(
<IssuesList
issues={[
createIssue({ id: "issue-a", identifier: "PAP-1", title: "One", updatedAt: new Date(now - 60 * 60 * 1000) }),
createIssue({ id: "issue-b", identifier: "PAP-2", title: "Two", updatedAt: new Date(now - 2 * 60 * 60 * 1000) }),
createIssue({ id: "issue-a", identifier: "PAP-1", title: "One", updatedAt: todayNoon }),
createIssue({ id: "issue-b", identifier: "PAP-2", title: "Two", updatedAt: todayMorning }),
]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
rowPresentation="task"
onUpdateIssue={() => undefined}
/>,
container,
@@ -2129,7 +2333,8 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
expect(container.querySelector("[data-testid='issue-row']")).not.toBeNull();
});
expect(container.querySelectorAll("[data-issues-date-separator]").length).toBe(0);
expect(Array.from(container.querySelectorAll("[data-issues-date-separator]"))
.map((element) => element.getAttribute("aria-label"))).toEqual(["Today"]);
act(() => {
root.unmount();
@@ -2137,24 +2342,15 @@ describe("IssuesList", () => {
});
});
describe("issueAgeBucket", () => {
describe("legacy issue age separators", () => {
const now = new Date("2026-04-10T12:00:00.000Z").getTime();
it("buckets by day and week boundaries", () => {
it("retains the rolling day and week boundaries used by the legacy list", () => {
expect(issueAgeBucket(new Date(now - 60 * 60 * 1000), now)).toBe(0);
expect(issueAgeBucket(new Date(now - 3 * 24 * 60 * 60 * 1000), now)).toBe(1);
expect(issueAgeBucket(new Date(now - 10 * 24 * 60 * 60 * 1000), now)).toBe(2);
});
it("labels the day and week separators", () => {
expect(issueAgeSeparatorLabel(1)).toBe("Older than a day");
expect(issueAgeSeparatorLabel(2)).toBe("Older than a week");
});
it("returns every boundary crossed between adjacent rows", () => {
expect(issueAgeBucketsCrossed(0, 1)).toEqual([1]);
expect(issueAgeBucketsCrossed(1, 2)).toEqual([2]);
expect(issueAgeBucketsCrossed(0, 2)).toEqual([1, 2]);
expect(issueAgeBucketsCrossed(2, 1)).toEqual([]);
});
});
+235 -115
View File
@@ -53,6 +53,7 @@ import {
InboxIssueTrailingColumns,
IssueColumnPicker,
issueActivityText,
issueActivityTimestamp,
issueTrailingColumns,
} from "./IssueColumns";
import { StatusIcon } from "./StatusIcon";
@@ -60,7 +61,10 @@ import { EmptyState } from "./EmptyState";
import { Identity } from "./Identity";
import { IssueGroupHeader } from "./IssueGroupHeader";
import { IssueFiltersPopover } from "./IssueFiltersPopover";
import { IssueRow } from "./IssueRow";
import { IssueRow, type IssueRowPresentation } from "./IssueRow";
import { CollectionToolbar, type CollectionToolbarProps } from "./CollectionToolbar";
import { IssuesList as LegacyIssuesList } from "./LegacyIssuesList";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { PageSkeleton } from "./PageSkeleton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -85,6 +89,12 @@ import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import { statusBadge } from "../lib/status-colors";
import { workflowSort } from "../lib/workflow-sort";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import {
loadTaskCollectionPreferences,
saveTaskCollectionPreferences,
type TaskCollectionPreferenceLocation,
} from "../lib/task-collection-preferences";
import { taskDateGroup, taskDateGroupSeparator, type TaskDateGroup } from "../lib/task-date-groups";
import { deriveOriginatingActor, ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
@@ -154,6 +164,7 @@ export type IssueViewState = IssueFilterState & {
groupBy: "status" | "priority" | "assignee" | "project" | "workspace" | "parent" | "none";
viewMode: "list" | "board";
nestingEnabled: boolean;
showDateGroupSeparators: boolean;
collapsedGroups: string[];
collapsedParents: string[];
boardCardDensity: BoardCardDensity;
@@ -168,6 +179,7 @@ const defaultViewState: IssueViewState = {
groupBy: "none",
viewMode: "list",
nestingEnabled: true,
showDateGroupSeparators: true,
collapsedGroups: [],
collapsedParents: [],
boardCardDensity: "auto",
@@ -189,38 +201,42 @@ function normalizeBoardColumnPageSize(value: unknown): BoardColumnPageSize {
: KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
}
function getViewState(key: string): IssueViewState {
try {
const raw = localStorage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw);
return {
...defaultViewState,
...parsed,
...normalizeIssueFilterState(parsed),
boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity),
boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode),
boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize),
};
}
} catch { /* ignore */ }
return { ...defaultViewState };
}
function saveViewState(key: string, state: IssueViewState) {
localStorage.setItem(key, JSON.stringify(state));
function normalizeIssueViewState(value: unknown): IssueViewState {
const parsed = value && typeof value === "object" ? value as Partial<IssueViewState> : {};
return {
...defaultViewState,
...parsed,
...normalizeIssueFilterState(parsed),
sortField: ["status", "priority", "title", "created", "updated", "workflow"].includes(parsed.sortField ?? "")
? parsed.sortField as IssueSortField
: defaultViewState.sortField,
sortDir: parsed.sortDir === "asc" ? "asc" : "desc",
groupBy: ["status", "priority", "assignee", "project", "workspace", "parent", "none"].includes(parsed.groupBy ?? "")
? parsed.groupBy as IssueViewState["groupBy"]
: defaultViewState.groupBy,
viewMode: parsed.viewMode === "board" ? "board" : "list",
nestingEnabled: parsed.nestingEnabled !== false,
showDateGroupSeparators: parsed.showDateGroupSeparators !== false,
collapsedGroups: Array.isArray(parsed.collapsedGroups)
? parsed.collapsedGroups.filter((entry): entry is string => typeof entry === "string")
: [],
collapsedParents: Array.isArray(parsed.collapsedParents)
? parsed.collapsedParents.filter((entry): entry is string => typeof entry === "string")
: [],
boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity),
boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode),
boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize),
};
}
function getInitialViewState(
key: string,
stored: { viewState: IssueViewState; source: "current" | "legacy" | "default" },
initialAssignees?: string[],
defaultSortField?: IssueSortField,
): IssueViewState {
const hasStored = hasStoredViewState(key);
const stored = getViewState(key);
const base = !hasStored && defaultSortField
? { ...stored, sortField: defaultSortField, sortDir: "asc" as const }
: stored;
const base = stored.source === "default" && defaultSortField
? { ...stored.viewState, sortField: defaultSortField, sortDir: "asc" as const }
: stored.viewState;
if (!initialAssignees) return base;
return {
...base,
@@ -230,53 +246,34 @@ function getInitialViewState(
}
function getInitialWorkspaceViewState(
key: string,
stored: { viewState: IssueViewState; source: "current" | "legacy" | "default" },
initialAssignees?: string[],
initialWorkspaces?: string[],
defaultSortField?: IssueSortField,
): IssueViewState {
const stored = getInitialViewState(key, initialAssignees, defaultSortField);
if (!initialWorkspaces) return stored;
const initial = getInitialViewState(stored, initialAssignees, defaultSortField);
if (!initialWorkspaces) return initial;
return {
...stored,
...initial,
workspaces: initialWorkspaces,
statuses: [],
};
}
function hasStoredViewState(key: string): boolean {
try {
return localStorage.getItem(key) !== null;
} catch {
return false;
}
}
function getIssueColumnsStorageKey(key: string): string {
return `${key}:issue-columns`;
}
function loadIssueColumns(key: string): InboxIssueColumn[] {
try {
const raw = localStorage.getItem(getIssueColumnsStorageKey(key));
if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS;
return normalizeInboxIssueColumns(parsed);
} catch {
return DEFAULT_INBOX_ISSUE_COLUMNS;
}
}
function saveIssueColumns(key: string, columns: InboxIssueColumn[]) {
try {
localStorage.setItem(
getIssueColumnsStorageKey(key),
JSON.stringify(normalizeInboxIssueColumns(columns)),
);
} catch {
// Ignore localStorage failures.
}
function loadIssueCollectionPreferences(
location: TaskCollectionPreferenceLocation,
) {
return loadTaskCollectionPreferences<IssueViewState, InboxIssueColumn>({
...location,
defaultViewState: defaultViewState,
defaultColumns: DEFAULT_INBOX_ISSUE_COLUMNS,
normalizeViewState: normalizeIssueViewState,
normalizeColumns: (value) => normalizeInboxIssueColumns(Array.isArray(value) ? value : []),
});
}
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
@@ -305,13 +302,18 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
return sorted;
}
// Only recency sorts (newest first) get date separators — for any other
// sort/direction the boundaries would be meaningless.
function issueDateSeparatorField(state: IssueViewState): "createdAt" | "updatedAt" | null {
if (state.sortDir !== "desc") return null;
if (state.sortField === "created") return "createdAt";
if (state.sortField === "updated") return "updatedAt";
return null;
}
const AGE_BUCKET_DAY_MS = 24 * 60 * 60 * 1000;
const AGE_BUCKET_WEEK_MS = 7 * AGE_BUCKET_DAY_MS;
// Recency buckets for the date separators shown in date-sorted lists:
// 0 = within the last day, 1 = within the last week, 2 = older than a week.
// A separator is drawn between rows whenever the bucket increases, so the
// "1 day" and "1 week" boundaries appear as the list ages downward.
export function issueAgeBucket(date: Date | string, now: number = Date.now()): 0 | 1 | 2 {
const age = now - new Date(date).getTime();
if (age < AGE_BUCKET_DAY_MS) return 0;
@@ -333,28 +335,22 @@ export function issueAgeBucketsCrossed(
return crossedBuckets;
}
// Only recency sorts (newest first) get date separators — for any other
// sort/direction the boundaries would be meaningless.
function issueDateSeparatorField(state: IssueViewState): "createdAt" | "updatedAt" | null {
if (state.sortDir !== "desc") return null;
if (state.sortField === "created") return "createdAt";
if (state.sortField === "updated") return "updatedAt";
return null;
}
function IssueDateSeparator({ label }: { label: string }) {
return (
<div
className="flex items-center gap-2 px-3 py-1.5 sm:pl-0 sm:pr-4"
className="flex items-center gap-3 px-3 py-1.5 sm:pl-0 sm:pr-4"
role="separator"
aria-label={label}
data-issues-date-separator=""
>
<div className="h-px flex-1 bg-border" />
<span className="text-(length:--text-nano) font-medium uppercase tracking-wider text-muted-foreground">
<span className="h-px min-w-0 flex-1 bg-border/80" aria-hidden="true" data-date-group-rule="" />
<span
className="shrink-0 text-(length:--text-nano) font-medium uppercase tracking-wider text-muted-foreground/70"
data-date-group-label=""
>
{label}
</span>
<div className="h-px flex-1 bg-border" />
<span className="h-px min-w-0 flex-1 bg-border/80" aria-hidden="true" data-date-group-rule="" />
</div>
);
}
@@ -495,9 +491,27 @@ interface IssuesListProps {
issueBadgeById?: Map<string, string>;
onLoadMoreIssues?: () => void;
onSearchChange?: (search: string) => void;
/** Opt in per surface while the canonical task row rolls out across collections. */
rowPresentation?: IssueRowPresentation;
/** Opt in per surface while the shared collection toolbar rolls out. */
toolbarPresentation?: "legacy" | "collection";
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
function LegacyIssuesToolbar({ context, search, controls }: CollectionToolbarProps) {
return (
<div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
{context}
{search}
</div>
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
{controls}
</div>
</div>
);
}
function IssueSearchInput({
value,
onDebouncedChange,
@@ -679,7 +693,12 @@ function SubIssueProgressSummaryStrip({
// Mobile-only indent for nested task rows (desktop uses IssueRow treeGuides).
const MOBILE_TREE_INDENT = ["", "pl-4 sm:pl-0", "pl-8 sm:pl-0", "pl-12 sm:pl-0", "pl-16 sm:pl-0"];
export function IssuesList({
export function IssuesList(props: IssuesListProps) {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
return streamlinedUiEnabled ? <StreamlinedIssuesList {...props} /> : <LegacyIssuesList {...props} />;
}
function StreamlinedIssuesList({
issues,
isLoading,
error,
@@ -706,6 +725,8 @@ export function IssuesList({
issueBadgeById,
onLoadMoreIssues,
onSearchChange,
rowPresentation = "legacy",
toolbarPresentation = "legacy",
onUpdateIssue,
}: IssuesListProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
@@ -760,17 +781,28 @@ export function IssuesList({
// Scope the storage key per company so folding/view state is independent across companies.
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
const preferenceLocation: TaskCollectionPreferenceLocation = {
companyId: selectedCompanyId ?? "__unscoped__",
collectionKey: viewStateKey,
legacyViewStorageKey: scopedKey,
legacyColumnsStorageKey: getIssueColumnsStorageKey(scopedKey),
};
const initialAssigneesKey = initialAssignees?.join("|") ?? "";
const initialWorkspacesKey = initialWorkspaces?.join("|") ?? "";
const initialPreferencesRef = useRef<ReturnType<typeof loadIssueCollectionPreferences> | null>(null);
if (initialPreferencesRef.current === null) {
initialPreferencesRef.current = loadIssueCollectionPreferences(preferenceLocation);
}
const initialPreferences = initialPreferencesRef.current;
const [viewState, setViewState] = useState<IssueViewState>(() =>
getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField),
getInitialWorkspaceViewState(initialPreferences, initialAssignees, initialWorkspaces, defaultSortField),
);
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT);
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(initialPreferences.columns);
const renderedIssueIdsRef = useRef("");
const initialServerFillRequestedRef = useRef(false);
const deferredIssueSearch = useDeferredValue(issueSearch);
@@ -786,25 +818,39 @@ export function IssuesList({
const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`;
if (prevViewStateContextKey.current !== nextContextKey) {
prevViewStateContextKey.current = nextContextKey;
setViewState(getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField));
const preferences = loadIssueCollectionPreferences(preferenceLocation);
setViewState(getInitialWorkspaceViewState(preferences, initialAssignees, initialWorkspaces, defaultSortField));
setVisibleIssueColumns(preferences.columns);
}
}, [scopedKey, initialAssignees, initialAssigneesKey, initialWorkspaces, initialWorkspacesKey, defaultSortField]);
const prevColumnsScopedKey = useRef(scopedKey);
useEffect(() => {
if (prevColumnsScopedKey.current !== scopedKey) {
prevColumnsScopedKey.current = scopedKey;
setVisibleIssueColumns(loadIssueColumns(scopedKey));
}
}, [scopedKey]);
}, [
scopedKey,
initialAssignees,
initialAssigneesKey,
initialWorkspaces,
initialWorkspacesKey,
defaultSortField,
preferenceLocation.companyId,
preferenceLocation.collectionKey,
preferenceLocation.legacyViewStorageKey,
preferenceLocation.legacyColumnsStorageKey,
]);
const updateView = useCallback((patch: Partial<IssueViewState>) => {
setViewState((prev) => {
const next = { ...prev, ...patch };
saveViewState(scopedKey, next);
saveTaskCollectionPreferences(preferenceLocation, {
viewState: next,
columns: visibleIssueColumns,
});
return next;
});
}, [scopedKey]);
}, [
preferenceLocation.companyId,
preferenceLocation.collectionKey,
preferenceLocation.legacyColumnsStorageKey,
preferenceLocation.legacyViewStorageKey,
visibleIssueColumns,
]);
useEffect(() => {
if (!experimentalSettingsLoaded || externalObjectsEnabled || viewState.externalObjectStatuses.length === 0) return;
@@ -1047,8 +1093,12 @@ export function IssuesList({
[issues, liveIssueIds],
);
const visibleTrailingIssueColumns = useMemo(
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
[availableIssueColumnSet, visibleIssueColumnSet],
() => issueTrailingColumns.filter((column) =>
visibleIssueColumnSet.has(column)
&& availableIssueColumnSet.has(column)
&& (rowPresentation === "legacy" || column !== "updated"),
),
[availableIssueColumnSet, rowPresentation, visibleIssueColumnSet],
);
const issueById = useMemo(() => {
@@ -1635,8 +1685,17 @@ export function IssuesList({
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
const normalized = normalizeInboxIssueColumns(next);
setVisibleIssueColumns(normalized);
saveIssueColumns(scopedKey, normalized);
}, [scopedKey]);
saveTaskCollectionPreferences(preferenceLocation, {
viewState,
columns: normalized,
});
}, [
preferenceLocation.companyId,
preferenceLocation.collectionKey,
preferenceLocation.legacyColumnsStorageKey,
preferenceLocation.legacyViewStorageKey,
viewState,
]);
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
if (enabled) {
@@ -1653,6 +1712,7 @@ export function IssuesList({
}, [onUpdateIssue]);
let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY;
const IssuesToolbar = toolbarPresentation === "collection" ? CollectionToolbar : LegacyIssuesToolbar;
return (
<div ref={rootRef} className="space-y-4">
@@ -1665,12 +1725,15 @@ export function IssuesList({
) : null}
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<IssuesToolbar
ariaLabel={toolbarPresentation === "collection" ? "Task controls" : undefined}
context={(
<Button size="sm" variant="outline" onClick={() => openCreateIssueDialog()}>
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">{createButtonLabel}</span>
</Button>
)}
search={(
<IssueSearchInput
value={issueSearch}
onDebouncedChange={(nextSearch) => {
@@ -1678,9 +1741,9 @@ export function IssuesList({
onSearchChange?.(nextSearch);
}}
/>
</div>
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
)}
controls={(
<>
{/* View mode toggle */}
<div className="flex items-center border border-border rounded-md overflow-hidden mr-1" role="group" aria-label="View mode">
<button
@@ -1797,9 +1860,12 @@ export function IssuesList({
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
onToggleColumn={toggleIssueColumn}
showDateGroupSeparators={viewState.showDateGroupSeparators}
onToggleDateGroupSeparators={(enabled) => updateView({ showDateGroupSeparators: enabled })}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which task columns stay visible"
iconOnly
rowPresentation={rowPresentation}
/>
<IssueFiltersPopover
@@ -1816,6 +1882,7 @@ export function IssuesList({
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
iconOnly
workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined}
presentation={rowPresentation === "task" ? "streamlined" : "legacy"}
/>
{/* Sort (list view only) */}
@@ -1902,8 +1969,9 @@ export function IssuesList({
</PopoverContent>
</Popover>
)}
</div>
</div>
</>
)}
/>
{(isLoading || externalObjectFilterLoading) && <PageSkeleton variant="issues-list" />}
{error && <p className="text-sm text-destructive">{error.message}</p>}
@@ -2099,6 +2167,7 @@ export function IssuesList({
>
<IssueRow
issue={issue}
presentation={rowPresentation}
issueLinkState={issueLinkState}
selected={selectedNavKey === `issue:${issue.id}`}
onMouseEnter={() => setNavSelectionFromPointer(`issue:${issue.id}`)}
@@ -2146,6 +2215,37 @@ export function IssuesList({
</>
)}
className={cn(isMutedIssue && "opacity-70", selectedNavKey === `issue:${issue.id}` && "bg-accent/50 hover:bg-accent/50")}
leadingControl={rowPresentation === "task" ? (
hasChildren ? (
<button
type="button"
data-slot="icon-button"
className="inline-flex h-4 w-4 shrink-0 items-center justify-center"
aria-label={isExpanded ? "Collapse sub-tasks" : "Expand sub-tasks"}
onClick={toggleCollapse}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span data-slot="task-row-disclosure-spacer" className="h-4 w-4 shrink-0" aria-hidden="true" />
)
) : undefined}
statusSlot={rowPresentation === "task" ? (
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
) : undefined}
metadata={rowPresentation === "task" ? (
<InboxIssueMetaLeading
issue={issue}
isLive={liveIssueIds?.has(issue.id) === true}
subtreeLiveCount={subtreeLiveCounts.get(issue.id) ?? 0}
showStatus={false}
showIdentifier={false}
checklistStepNumber={checklistStepNumber}
/>
) : undefined}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
mobileLeading={
hasChildren ? (
<button type="button" data-slot="icon-button" onClick={toggleCollapse}>
@@ -2157,7 +2257,7 @@ export function IssuesList({
</span>
)
}
desktopMetaLeading={(
desktopMetaLeading={rowPresentation === "legacy" ? (
<>
{hasChildren ? (
<button
@@ -2185,8 +2285,13 @@ export function IssuesList({
)}
/>
</>
)}
) : undefined}
mobileMeta={issueActivityText(issue).toLowerCase()}
trailingMeta={rowPresentation === "task"
&& visibleIssueColumnSet.has("updated")
&& availableIssueColumnSet.has("updated")
? issueActivityTimestamp(issue)
: undefined}
desktopTrailing={(
visibleTrailingIssueColumns.length > 0 ? (
<InboxIssueTrailingColumns
@@ -2319,28 +2424,43 @@ export function IssuesList({
);
};
const separatorField = issueDateSeparatorField(viewState);
const separatorNow = Date.now();
const separatorField = viewState.showDateGroupSeparators
? issueDateSeparatorField(viewState)
: null;
const separatorNow = new Date();
const nodes: ReactNode[] = [];
let prevBucket: 0 | 1 | 2 | null = null;
let previousDateGroup: TaskDateGroup | null = null;
let previousAgeBucket: 0 | 1 | 2 | null = null;
const appendIssueRow = (issue: Issue, depth: number) => {
const node = renderIssueRow(issue, depth);
// Skip rows the render budget dropped so separators never
// dangle above an unrendered (or absent) row.
if (node === null) return;
if (separatorField) {
const bucket = issueAgeBucket(issue[separatorField], separatorNow);
for (const crossedBucket of prevBucket === null
? []
: issueAgeBucketsCrossed(prevBucket, bucket)) {
if (separatorField && rowPresentation === "task") {
const currentDateGroup = taskDateGroup(issue[separatorField], separatorNow);
const separatorLabel = taskDateGroupSeparator(previousDateGroup, currentDateGroup);
if (separatorLabel) {
nodes.push(
<IssueDateSeparator
key={`age-sep-${issue.id}-${crossedBucket}`}
key={`date-sep-${issue.id}-${currentDateGroup}`}
label={separatorLabel}
/>,
);
}
previousDateGroup = currentDateGroup;
} else if (separatorField) {
const currentAgeBucket = issueAgeBucket(issue[separatorField], separatorNow.getTime());
for (const crossedBucket of previousAgeBucket === null
? []
: issueAgeBucketsCrossed(previousAgeBucket, currentAgeBucket)) {
nodes.push(
<IssueDateSeparator
key={`date-sep-${issue.id}-${crossedBucket}`}
label={issueAgeSeparatorLabel(crossedBucket)}
/>,
);
}
prevBucket = bucket;
previousAgeBucket = currentAgeBucket;
}
nodes.push(node);
if (!viewState.collapsedParents.includes(issue.id)) {
@@ -18,24 +18,16 @@ describe("KeyboardShortcutsCheatsheet", () => {
document.body.innerHTML = "";
});
it("lists the re-pointed Cmd/Ctrl+B sidebar collapse shortcut as a chord", () => {
it("does not advertise the retired sidebar collapse shortcut", () => {
const root = createRoot(container);
flushSync(() => {
root.render(<KeyboardShortcutsCheatsheetContent />);
});
// The collapse/expand row exists with its label.
const row = [...container.querySelectorAll("span")].find(
(node) => node.textContent?.trim() === "Collapse or expand sidebar",
)?.parentElement;
expect(row).toBeTruthy();
// Rendered as a "+" chord (B + a Cmd/Ctrl cap), not a "then" sequence.
const caps = [...(row?.querySelectorAll("kbd") ?? [])].map((kbd) => kbd.textContent);
expect(caps).toContain("B");
expect(caps.some((cap) => cap === "⌘" || cap === "Ctrl")).toBe(true);
expect(row?.textContent).toContain("+");
expect(row?.textContent).not.toContain("then");
expect(row).toBeUndefined();
flushSync(() => {
root.unmount();
@@ -8,17 +8,6 @@ interface ShortcutEntry {
combo?: boolean;
}
// Platform-appropriate label for the Cmd/Ctrl modifier so the cheatsheet shows
// the same key the user actually presses (re-pointed in the collapsible sidebar
// work — Cmd/Ctrl+B toggles the rail).
function getPlatformLabel() {
if (typeof navigator === "undefined") return "";
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
return nav.userAgentData?.platform || navigator.userAgent || "";
}
const META_KEY = /Mac|iPhone|iPad|iPod/.test(getPlatformLabel()) ? "⌘" : "Ctrl";
interface ShortcutSection {
title: string;
shortcuts: ShortcutEntry[];
@@ -66,7 +55,6 @@ const sections: ShortcutSection[] = [
{ keys: ["/"], label: "Search current page or quick search" },
{ keys: ["c"], label: "New task" },
{ keys: ["["], label: "Toggle sidebar" },
{ keys: [META_KEY, "B"], label: "Collapse or expand sidebar", combo: true },
{ keys: ["]"], label: "Toggle panel" },
{ keys: ["?"], label: "Show keyboard shortcuts" },
],
+706
View File
@@ -0,0 +1,706 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { Sidebar } from "./Sidebar.production";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar.production";
import { CompanySettingsNav } from "./access/CompanySettingsNav";
import { AppsSidebar } from "./AppsSidebar.production";
import { AppDetailSidebar } from "./AppConnectionSidebar.production";
import { BreadcrumbBar } from "./BreadcrumbBar.production";
import { PropertiesPanel } from "./PropertiesPanel";
import { CommandPalette } from "./CommandPalette";
import { NewIssueDialog } from "./NewIssueDialog";
import { NewProjectDialog } from "./NewProjectDialog";
import { NewGoalDialog } from "./NewGoalDialog";
import { NewAgentDialog } from "./NewAgentDialog";
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
import { SidebarShell } from "./SidebarShell.production";
import { SecondarySidebar } from "./SecondarySidebar.production";
import { SidebarAccountMenu } from "./SidebarAccountMenu.production";
import { useDialogActions } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { instanceSettingsApi } from "../api/instanceSettings";
import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import { useOptionalToastActions } from "../context/ToastContext";
import {
applyMainContentScrollTop,
NavigationScrollMemory,
resetNavigationScroll,
shouldResetScrollOnNavigation,
} from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { pinDocumentScrollToZero } from "../lib/pin-document-scroll";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots";
function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null {
return getCompanyPathSegments(pathname, companyPrefix)[0]?.toLowerCase() ?? null;
}
function getCompanyPathSegments(pathname: string, companyPrefix: string | undefined): string[] {
if (!companyPrefix) return [];
const segments = pathname.split("/").filter(Boolean);
if (segments.length < 2) return [];
if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return [];
return segments.slice(1);
}
const RESERVED_APP_SUBPATHS = new Set([
"browse",
"connections",
"connect",
"review",
"attention",
"gateways",
"advanced",
"app",
]);
function isSkillsStoreRoute(pathname: string, companyPrefix: string | undefined) {
const segments = pathname.split("/").filter(Boolean);
if (segments[0]?.toLowerCase() === "skills") return true;
if (!companyPrefix) return false;
return (
segments[0]?.toUpperCase() === companyPrefix.toUpperCase() &&
segments[1]?.toLowerCase() === "skills"
);
}
export function Layout() {
const {
sidebarOpen,
setSidebarOpen,
toggleSidebar,
collapsed,
peeking,
setPeeking,
isMobile,
setForceCollapsed,
} = useSidebar();
const { openNewIssue, openOnboarding } = useDialogActions();
const { togglePanelVisible } = usePanel();
// Optional: Layout also renders in harnesses without a ToastProvider.
const pushToast = useOptionalToastActions()?.pushToast ?? null;
const {
companies,
loading: companiesLoading,
selectedCompany,
selectedCompanyId,
selectionSource,
setSelectedCompanyId,
} = useCompany();
const {
companyPrefix,
pluginRoutePath: matchedPluginRoutePath,
} = useParams<{ companyPrefix: string; pluginRoutePath?: string }>();
const navigate = useNavigate();
const location = useLocation();
const navigationType = useNavigationType();
const isCompanySettingsRoute = [
"/company/settings",
"/company/export",
"/company/import",
].some((settingsPath) => location.pathname.includes(settingsPath));
const companyPathSegments = getCompanyPathSegments(location.pathname, companyPrefix);
const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools";
const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps";
const appDetailConnectionId =
isAppsRoute && companyPathSegments[1] && !RESERVED_APP_SUBPATHS.has(companyPathSegments[1].toLowerCase())
? companyPathSegments[1]
: null;
const appDetailApplicationId =
isAppsRoute && companyPathSegments[1]?.toLowerCase() === "app" && companyPathSegments[2]
? companyPathSegments[2]
: null;
// The Skills Store renders its own secondary (category) sidebar, so the main
// app nav collapses to its rail throughout the Skills Store section (PAP-10879).
const isSkillsRoute = isSkillsStoreRoute(location.pathname, companyPrefix);
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const previousPathname = useRef<string | null>(null);
const mainContentRef = useRef<HTMLElement | null>(null);
const scrollMemory = useRef(new NavigationScrollMemory());
const activeScrollKey = useRef<string>(location.key);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null;
}, [companies, companyPrefix]);
const hasUnknownCompanyPrefix =
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
const pluginRoutePath = useMemo(
() => matchedPluginRoutePath?.toLowerCase() ?? getCompanyRouteSegment(location.pathname, companyPrefix),
[companyPrefix, location.pathname, matchedPluginRoutePath],
);
const routeSidebarCompanyId = matchedCompany?.id ?? null;
const routeSidebarCompanyPrefix = matchedCompany?.issuePrefix ?? null;
const { slots: routeSidebarSlots } = usePluginSlots({
slotTypes: ["page", "routeSidebar"],
companyId: routeSidebarCompanyId,
enabled: Boolean(routeSidebarCompanyId && pluginRoutePath),
});
const routeSidebarSlot = useMemo(
() => resolveRouteSidebarSlot(routeSidebarSlots, pluginRoutePath),
[pluginRoutePath, routeSidebarSlots],
);
const sidebarContext = useMemo(
() => ({
companyId: routeSidebarCompanyId,
companyPrefix: routeSidebarCompanyPrefix,
}),
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
);
// Takeover routes (company settings, plugin `routeSidebar`) no longer replace
// the app `<Sidebar/>`. Instead the host collapses it to its rail and renders
// the contextual sidebar in a second pane (PAP-10695). One resolver drives
// both desktop (SecondarySidebar) and mobile (off-canvas drawer).
const secondarySidebar = isCompanySettingsRoute ? (
<CompanySettingsSidebar />
) : appDetailConnectionId ? (
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
) : appDetailApplicationId ? (
<AppDetailSidebar kind="application" applicationId={appDetailApplicationId} />
) : isAppsRoute || isToolsRoute ? (
<AppsSidebar />
) : routeSidebarSlot ? (
<PluginSlotMount
slot={routeSidebarSlot}
context={sidebarContext}
className="h-full w-full"
missingBehavior="placeholder"
/>
) : null;
const hasSecondarySidebar = secondarySidebar != null;
const { data: health } = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
return data?.devServer?.enabled ? 2000 : false;
},
refetchIntervalInBackground: true,
});
const keyboardShortcutsEnabled = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
// A secondary sidebar always collapses the app sidebar to its rail (still
// peek-able) — a hard invariant that overrides the user pin while the route
// is active, but does NOT mutate the persisted preference. Clearing the force
// on cleanup restores the user's expanded/collapsed choice when navigating
// off the takeover route (PAP-10694).
const forceRailCollapsed = hasSecondarySidebar || isSkillsRoute;
useLayoutEffect(() => {
setForceCollapsed(forceRailCollapsed);
return () => setForceCollapsed(false);
}, [forceRailCollapsed, setForceCollapsed]);
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
if (health?.deploymentMode === "authenticated") return;
// Cloud provisions the single company for a stack, and POST /companies is a
// 403 floor there — auto-opening the wizard could only dead-end.
if (health?.cloud) return;
if (companies.length === 0) {
onboardingTriggered.current = true;
openOnboarding();
}
}, [companies, companiesLoading, openOnboarding, health?.cloud, health?.deploymentMode]);
useEffect(() => {
if (!companyPrefix || companiesLoading || companies.length === 0) return;
if (!matchedCompany) {
const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
?? companies[0]
?? null;
if (fallback && selectedCompanyId !== fallback.id) {
setSelectedCompanyId(fallback.id, { source: "route_sync" });
}
return;
}
if (companyPrefix !== matchedCompany.issuePrefix) {
const suffix = location.pathname.replace(/^\/[^/]+/, "");
navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true });
return;
}
// Stale state (remembered paths, history, bookmarks, restored tabs)
// deposits users into archived companies long after archiving; a cold
// arrival bounces to an active company instead of dwelling there.
// Deliberate visits (the company is already the selection) stay put.
const bounce = resolveArchivedCompanyBounce({
matchedCompany,
selectedCompanyId,
companies,
});
if (bounce) {
pushToast?.({
title: `${matchedCompany.name} is archived`,
body: `Switched to ${bounce.name}.`,
tone: "info",
dedupeKey: `archived-company-bounce:${matchedCompany.id}`,
});
setSelectedCompanyId(bounce.id, { source: "route_sync" });
navigate(`/${bounce.issuePrefix}/dashboard`, { replace: true });
return;
}
if (
shouldSyncCompanySelectionFromRoute({
selectionSource,
selectedCompanyId,
routeCompanyId: matchedCompany.id,
})
) {
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
}
}, [
companyPrefix,
companies,
companiesLoading,
matchedCompany,
location.pathname,
location.search,
navigate,
pushToast,
selectionSource,
selectedCompanyId,
setSelectedCompanyId,
]);
const togglePanel = togglePanelVisible;
const openSearch = useCallback(() => {
document.dispatchEvent(new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
bubbles: true,
cancelable: true,
}));
}, []);
// Peek (hover flyout) triggers for the collapsed rail. Opening has a tiny
// delay so a pointer merely sweeping across the rail doesn't flash it open;
// closing is debounced to avoid flicker on the rail→overlay seam. Keyboard
// focus opens immediately so tabbing reaches the full nav. Context gates the
// effective `peeking` to desktop + collapsed + hover-capable pointers, so
// these handlers are inert otherwise.
const peekTimer = useRef<number | null>(null);
// Whether the pointer is currently over the peek panel. Used to keep the peek
// open across focus changes (e.g. navigation steals focus to <main>) as long as
// the user is still hovering — it should only close when they actually mouse off
// (PAP-10676).
const pointerInsidePanel = useRef(false);
// When the user explicitly collapses while the pointer is still over the panel,
// suppress re-peeking until the pointer actually leaves — otherwise the lingering
// hover immediately re-expands the rail and the collapse "doesn't take" until the
// mouse moves away (PAP-10676). Re-armed on the next genuine pointer-leave.
const suppressPeekRef = useRef(false);
const clearPeekTimer = useCallback(() => {
if (peekTimer.current !== null) {
window.clearTimeout(peekTimer.current);
peekTimer.current = null;
}
}, []);
const openPeek = useCallback(() => {
clearPeekTimer();
peekTimer.current = window.setTimeout(() => setPeeking(true), 50);
}, [clearPeekTimer, setPeeking]);
const openPeekImmediate = useCallback(() => {
clearPeekTimer();
setPeeking(true);
}, [clearPeekTimer, setPeeking]);
const closePeek = useCallback(() => {
clearPeekTimer();
peekTimer.current = window.setTimeout(() => setPeeking(false), 120);
}, [clearPeekTimer, setPeeking]);
// Tracked even while expanded so that, at the moment of collapse, we know
// whether the pointer is over the panel and should suppress the re-peek.
const handlePanelPointerEnter = useCallback(() => {
pointerInsidePanel.current = true;
if (collapsed && !suppressPeekRef.current) openPeek();
}, [collapsed, openPeek]);
const handlePanelPointerLeave = useCallback(() => {
pointerInsidePanel.current = false;
suppressPeekRef.current = false; // pointer left — re-arm peek for the next hover
closePeek();
}, [closePeek]);
const handlePanelFocus = useCallback(() => {
if (suppressPeekRef.current) return;
openPeekImmediate();
}, [openPeekImmediate]);
// Close on focus leaving the panel only when the pointer isn't hovering it.
// Clicking a rail/peek nav item moves focus to <main> on navigation; if the
// mouse is still over the flyout we keep it open until the pointer leaves.
const handlePanelBlur = useCallback(() => {
if (pointerInsidePanel.current) return;
closePeek();
}, [closePeek]);
// Tidy up any pending peek timer on unmount.
useEffect(() => clearPeekTimer, [clearPeekTimer]);
// An explicit collapse must be atomic: cancel any in-flight/active peek, and if
// the pointer is still over the panel suppress re-peeking until it leaves, so the
// rail doesn't immediately re-expand under the lingering hover (PAP-10676).
const wasCollapsed = useRef(collapsed);
useEffect(() => {
if (collapsed !== wasCollapsed.current) {
if (collapsed) {
clearPeekTimer();
setPeeking(false);
suppressPeekRef.current = pointerInsidePanel.current;
} else {
suppressPeekRef.current = false;
}
wasCollapsed.current = collapsed;
}
}, [collapsed, clearPeekTimer, setPeeking]);
// Intentionally do NOT close the peek on navigation: clicking a nav item means
// the pointer is still over the flyout, so it should stay open until the user
// actually mouses off (handled by onPanelMouseLeave) or blurs out / hits Escape
// (PAP-10676). Auto-closing here made the sidebar collapse on every page change.
// Escape closes an open peek without trapping the pointer.
useEffect(() => {
if (!peeking) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
clearPeekTimer();
setPeeking(false);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [peeking, clearPeekTimer, setPeeking]);
useCompanyPageMemory();
useKeyboardShortcuts({
enabled: keyboardShortcutsEnabled,
onNewIssue: () => openNewIssue(),
onSearch: openSearch,
onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel,
onShowShortcuts: () => setShortcutsOpen(true),
onGoToInbox: () => navigate("/inbox"),
});
useEffect(() => {
if (!isMobile) {
setMobileNavVisible(true);
return;
}
lastMainScrollTop.current = 0;
setMobileNavVisible(true);
}, [isMobile]);
// Swipe gesture to open/close sidebar on mobile
useEffect(() => {
if (!isMobile) return;
const EDGE_ZONE = 30; // px from left edge to start open-swipe
const MIN_DISTANCE = 50; // minimum horizontal swipe distance
const MAX_VERTICAL = 75; // max vertical drift before we ignore
let startX = 0;
let startY = 0;
const onTouchStart = (e: TouchEvent) => {
const t = e.touches[0]!;
startX = t.clientX;
startY = t.clientY;
};
const onTouchEnd = (e: TouchEvent) => {
const t = e.changedTouches[0]!;
const dx = t.clientX - startX;
const dy = Math.abs(t.clientY - startY);
if (dy > MAX_VERTICAL) return; // vertical scroll, ignore
// Swipe right from left edge → open
if (!sidebarOpen && startX < EDGE_ZONE && dx > MIN_DISTANCE) {
setSidebarOpen(true);
return;
}
// Swipe left when open → close
if (sidebarOpen && dx < -MIN_DISTANCE) {
setSidebarOpen(false);
}
};
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchend", onTouchEnd);
};
}, [isMobile, sidebarOpen, setSidebarOpen]);
const updateMobileNavVisibility = useCallback((currentTop: number) => {
const delta = currentTop - lastMainScrollTop.current;
if (currentTop <= 24) {
setMobileNavVisible(true);
} else if (delta > 8) {
setMobileNavVisible(false);
} else if (delta < -8) {
setMobileNavVisible(true);
}
lastMainScrollTop.current = currentTop;
}, []);
useEffect(() => {
if (!isMobile) {
setMobileNavVisible(true);
lastMainScrollTop.current = 0;
return;
}
const onScroll = () => {
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [isMobile, updateMobileNavVisibility]);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = isMobile ? "visible" : "clip";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile]);
// `scrollIntoView` walks every ancestor scroll container. On a long thread
// the post-submit `scrollIntoView` on the new comment reaches `<html>` and
// animates `documentElement.scrollTop` via the browser's internal scroll
// algorithm, which bypasses the CSS `overflow` on the root element and
// visually shifts the entire shell (sidebar included) off-screen. Pin
// both roots to scrollTop=0 on every scroll tick.
useEffect(() => {
if (isMobile) return;
return pinDocumentScrollToZero();
}, [isMobile]);
useEffect(() => {
if (typeof document === "undefined") return;
const mainContent = mainContentRef.current;
return scheduleMainContentFocus(mainContent);
}, [location.pathname]);
// Continuously record the scroll offset of the active history entry so a
// later back/forward navigation can restore it (see NavigationScrollMemory).
useEffect(() => {
const main = mainContentRef.current;
if (!main) return;
const recordScroll = () => {
scrollMemory.current.remember(activeScrollKey.current, main.scrollTop);
};
main.addEventListener("scroll", recordScroll, { passive: true });
return () => main.removeEventListener("scroll", recordScroll);
}, []);
useLayoutEffect(() => {
const main = mainContentRef.current;
const shouldResetScroll = shouldResetScrollOnNavigation({
previousPathname: previousPathname.current,
pathname: location.pathname,
navigationType,
state: location.state,
});
previousPathname.current = location.pathname;
const isHistoryPop = navigationType === "POP";
const restoredScrollTop = isHistoryPop ? scrollMemory.current.recall(location.key) : 0;
activeScrollKey.current = location.key;
if (isHistoryPop) {
applyMainContentScrollTop(main, restoredScrollTop);
// Cached page content can finish laying out a frame after commit; re-apply
// once it has so the restored offset isn't clamped to a shorter interim height.
const raf = requestAnimationFrame(() => applyMainContentScrollTop(main, restoredScrollTop));
return () => cancelAnimationFrame(raf);
}
if (shouldResetScroll) {
resetNavigationScroll(main);
}
}, [location.key, location.pathname, location.state, navigationType]);
return (
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
// overflow-x-clip on mobile keeps a stray wide descendant from making the
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
// computed as visible, so native body scroll + the sticky breadcrumb keep
// working.
isMobile ? "min-h-dvh overflow-x-clip" : "flex h-dvh flex-col overflow-clip",
)}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-(--z-200) focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to Main Content
</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
{isMobile && sidebarOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/50"
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/>
)}
{isMobile ? (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-(--sz-safe-top) transition-transform duration-100 ease-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="w-60 shrink-0 overflow-hidden">
{hasSecondarySidebar ? secondarySidebar : <Sidebar />}
</div>
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
/>
</div>
) : (
<SidebarShell
open={sidebarOpen}
collapsed={collapsed}
peeking={peeking}
resizable
onPanelMouseEnter={handlePanelPointerEnter}
onPanelMouseLeave={handlePanelPointerLeave}
onPanelFocusCapture={collapsed ? handlePanelFocus : undefined}
onPanelBlurCapture={collapsed ? handlePanelBlur : undefined}
>
<div className="flex flex-1 min-h-0">
<Sidebar />
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
/>
</SidebarShell>
)}
{!isMobile && hasSecondarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : null}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<StandaloneBrowserControls mobile={isMobile} />
<BreadcrumbBar />
{isMobile && isCompanySettingsRoute ? (
<div className="border-b border-border px-4 pb-3">
<CompanySettingsNav />
</div>
) : null}
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
ref={mainContentRef}
tabIndex={-1}
// Publish the pinned-composer bottom offset to descendants
// (PAP-495): while the auto-hiding mobile nav is on screen, raise
// it to the nav height so a sticky composer clears the nav; drop
// it back to the safe-area dock when the nav hides. Desktop leaves
// the token at its :root default.
style={
isMobile
? ({
"--tc-composer-bottom": mobileNavVisible
? "var(--sz-calc-14)"
: "var(--sz-calc-8)",
} as CSSProperties)
: undefined
}
className={cn(
"flex-1 p-4 outline-none md:p-6",
// Reserve the scrollbar gutter on desktop so pages whose height
// changes (e.g. switching skill-detail tabs) don't widen/shift
// when the vertical scrollbar appears or disappears (PAP-10907).
isMobile
? "overflow-visible pb-(--sz-calc-14)"
: "overflow-auto [scrollbar-gutter:stable]",
)}
>
{hasUnknownCompanyPrefix ? (
<NotFoundPage
scope="invalid_company_prefix"
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
/>
) : (
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
)}
</main>
<PropertiesPanel />
</div>
</div>
</div>
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
<CommandPalette />
<NewIssueDialog />
<NewProjectDialog />
<NewGoalDialog />
<NewAgentDialog />
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
<ToastViewport />
</div>
</GeneralSettingsProvider>
);
}
+202 -38
View File
@@ -18,7 +18,6 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
const mockSetForceCollapsed = vi.hoisted(() => vi.fn());
const mockCompanyState = vi.hoisted(() => ({
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
@@ -30,6 +29,7 @@ const mockPluginSlots = vi.hoisted(() => ({
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockSetPeeking = vi.hoisted(() => vi.fn());
const mockSetForceCollapsed = vi.hoisted(() => vi.fn());
const mockSidebarState = vi.hoisted(() => ({
sidebarOpen: true,
isMobile: false,
@@ -44,16 +44,20 @@ vi.mock("@/lib/router", () => ({
useNavigate: () => mockNavigate,
useNavigationType: () => "PUSH",
useParams: () => {
const [firstSegment, secondSegment] = currentPathname.split("/").filter(Boolean);
const [firstSegment, secondSegment, entityId] = currentPathname.split("/").filter(Boolean);
return {
companyPrefix: firstSegment ?? "PAP",
pluginRoutePath: secondSegment,
agentId: secondSegment === "agents" ? entityId : undefined,
routineId: secondSegment === "routines" ? entityId : undefined,
};
},
}));
vi.mock("./Sidebar", () => ({
Sidebar: () => <div>Main company nav</div>,
Sidebar: ({ contentHeaderControls }: { contentHeaderControls?: boolean }) => (
<div data-content-header-controls={String(contentHeaderControls ?? false)}>Main company nav</div>
),
}));
vi.mock("./CompanySettingsSidebar", () => ({
@@ -64,6 +68,18 @@ vi.mock("./AppsSidebar", () => ({
AppsSidebar: () => <div>Apps sidebar</div>,
}));
vi.mock("./AgentContextualSidebar", () => ({
AgentContextualSidebar: ({ agentRef }: { agentRef: string }) => <div>Agent sidebar {agentRef}</div>,
}));
vi.mock("./RoutineContextualSidebar", () => ({
RoutineContextualSidebar: ({ routineId }: { routineId: string }) => <div>Routine sidebar {routineId}</div>,
}));
vi.mock("./SkillsContextualSidebar", () => ({
SkillsContextualSidebar: () => <div>Skills sidebar</div>,
}));
vi.mock("./AppConnectionSidebar", () => ({
AppDetailSidebar: (props: { kind: "connection"; connectionId: string } | { kind: "application"; applicationId: string }) => (
<div>
@@ -188,12 +204,10 @@ vi.mock("../context/SidebarContext", () => ({
toggleSidebar: vi.fn(),
toggleCollapsed: vi.fn(),
collapsed: mockSidebarState.collapsed,
collapseLocked: false,
peeking: mockSidebarState.peeking,
setPeeking: mockSetPeeking,
isMobile: mockSidebarState.isMobile,
forceCollapsed: false,
setForceCollapsed: mockSetForceCollapsed,
isMobile: mockSidebarState.isMobile,
routeRequestsCollapsed: false,
setRouteRequestsCollapsed: vi.fn(),
}),
@@ -272,6 +286,7 @@ describe("Layout", () => {
mockSidebarState.collapsed = false;
mockSidebarState.peeking = false;
mockSetPeeking.mockClear();
mockSetForceCollapsed.mockClear();
});
afterEach(() => {
@@ -310,6 +325,36 @@ describe("Layout", () => {
});
});
it("scopes the Streamlined task-detail surface to the main pane and right sidebar row", async () => {
currentPathname = "/PAP/issues/PAP-1";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableApps: true,
enableStreamlinedUi: true,
});
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.querySelector(".streamlined-task-detail-surface")).not.toBeNull();
expect(container.querySelector("#main-content")?.classList.contains("pt-0")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("md:pt-0")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("pr-0")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("md:pr-0")).toBe(true);
await act(async () => {
root.unmount();
});
});
it("collapses atomically when the pointer is still over the sidebar (no re-peek) — PAP-10676", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -384,7 +429,7 @@ describe("Layout", () => {
await act(async () => { root.unmount(); });
});
it("keeps the app sidebar and shows the settings sidebar in the secondary pane on settings routes", async () => {
it("keeps the app sidebar beside settings navigation on settings routes", async () => {
currentPathname = "/PAP/company/settings/access";
mockPluginSlots.slots = [
{
@@ -425,14 +470,41 @@ describe("Layout", () => {
await flushReact();
await flushReact();
// Takeover model (PAP-10695): the app sidebar is kept (collapsed to its
// rail) AND the settings sidebar renders in the secondary pane.
expect(container.textContent).toContain("Company settings sidebar");
expect(container.textContent).toContain("Main company nav");
const secondaryRail = container.querySelector("[data-secondary-sidebar]");
expect(secondaryRail?.classList.contains("w-60")).toBe(true);
expect(secondaryRail?.classList.contains("bg-background")).toBe(true);
expect(container.textContent).not.toContain("Company rail");
expect(container.textContent).not.toContain("Instance sidebar");
expect(container.textContent).not.toContain("Plugin route sidebar");
// The route asks the host to collapse the app sidebar to its rail.
await act(async () => {
root.unmount();
});
});
it("keeps the global sidebar beside legacy settings navigation when Streamlined UI is off", async () => {
currentPathname = "/PAP/company/settings/access";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableApps: true,
enableStreamlinedUi: false,
});
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).toContain("Company settings sidebar");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
@@ -481,7 +553,7 @@ describe("Layout", () => {
});
});
it("renders the company settings sidebar on instance settings routes", async () => {
it("keeps the company nav beside settings on instance settings routes", async () => {
currentPathname = "/PAP/company/settings/instance/general";
const root = createRoot(container);
const queryClient = new QueryClient({
@@ -502,7 +574,6 @@ describe("Layout", () => {
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Company rail");
expect(container.textContent).not.toContain("Plugin route sidebar");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -510,7 +581,7 @@ describe("Layout", () => {
});
it.each(["/PAP/company/export", "/PAP/company/import"])(
"renders the shared settings sidebar on %s",
"keeps the company nav beside the shared settings sidebar on %s",
async (pathname) => {
currentPathname = pathname;
const root = createRoot(container);
@@ -530,7 +601,6 @@ describe("Layout", () => {
expect(container.textContent).toContain("Company settings sidebar");
expect(container.textContent).toContain("Main company nav");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -538,7 +608,7 @@ describe("Layout", () => {
},
);
it("keeps the app sidebar and shows the Apps sidebar in the secondary pane on legacy tools routes", async () => {
it("keeps the company nav beside Apps navigation on legacy tools routes", async () => {
currentPathname = "/PAP/tools/runtime";
const root = createRoot(container);
const queryClient = new QueryClient({
@@ -558,7 +628,6 @@ describe("Layout", () => {
expect(container.textContent).toContain("Apps sidebar");
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Company settings sidebar");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -585,7 +654,9 @@ describe("Layout", () => {
expect(container.textContent).toContain("Apps sidebar");
expect(container.textContent).toContain("Main company nav");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
const secondaryRail = container.querySelector("[data-secondary-sidebar]");
expect(secondaryRail?.classList.contains("w-60")).toBe(true);
expect(secondaryRail?.classList.contains("shrink-0")).toBe(true);
await act(async () => {
root.unmount();
@@ -750,7 +821,7 @@ describe("Layout", () => {
});
});
it("forces the app sidebar rail only for the Skills Store route", async () => {
it("keeps global navigation beside Skills, Agent, and Routine details", async () => {
async function renderAt(pathname: string) {
currentPathname = pathname;
const root = createRoot(container);
@@ -770,24 +841,123 @@ describe("Layout", () => {
return root;
}
let root = await renderAt("/PAP/skills/studio");
for (const [pathname, sidebarText] of [
["/PAP/skills/studio", "Skills sidebar"],
["/PAP/agents/briefing-analyst/skills", "Agent sidebar briefing-analyst"],
["/PAP/agents/briefing-analyst/runs/run-1", "Agent sidebar briefing-analyst"],
["/PAP/routines/routine-1/overview", "Routine sidebar routine-1"],
] as const) {
const root = await renderAt(pathname);
expect(container.textContent).toContain(sidebarText);
expect(container.textContent).toContain("Main company nav");
const secondaryRail = container.querySelector("[data-secondary-sidebar]");
expect(secondaryRail?.classList.contains("w-60")).toBe(true);
expect(secondaryRail?.classList.contains("bg-background")).toBe(true);
const breadcrumb = Array.from(container.querySelectorAll("div"))
.find((element) => element.textContent === "Breadcrumbs");
expect(breadcrumb).toBeDefined();
expect(
breadcrumb && secondaryRail
? breadcrumb.compareDocumentPosition(secondaryRail) & Node.DOCUMENT_POSITION_FOLLOWING
: 0,
).not.toBe(0);
await act(async () => {
root.unmount();
});
container.innerHTML = "";
}
});
it("keeps global navigation on legacy Agent and Routine detail routes", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableApps: true,
enableStreamlinedUi: false,
});
for (const pathname of [
"/PAP/agents/briefing-analyst/skills",
"/PAP/routines/routine-1/overview",
]) {
currentPathname = pathname;
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Agent sidebar");
expect(container.textContent).not.toContain("Routine sidebar");
await act(async () => {
root.unmount();
});
container.innerHTML = "";
}
});
it("keeps the global rail beside Skills navigation in the legacy shell", async () => {
currentPathname = "/PAP/skills/studio";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableApps: true,
enableStreamlinedUi: false,
});
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).toContain("Skills sidebar");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
});
mockSetForceCollapsed.mockClear();
container.innerHTML = "";
root = await renderAt("/PAP/agents/briefing-analyst/skills");
expect(mockSetForceCollapsed).not.toHaveBeenCalledWith(true);
expect(mockSetForceCollapsed).toHaveBeenCalledWith(false);
await act(async () => {
root.unmount();
});
});
it("keeps Agent and Routine collection routes in global navigation", async () => {
for (const pathname of ["/PAP/agents/all", "/PAP/agents/new", "/PAP/routines"]) {
currentPathname = pathname;
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Agent sidebar");
expect(container.textContent).not.toContain("Routine sidebar");
await act(async () => {
root.unmount();
});
container.innerHTML = "";
}
});
it("renders a route-scoped plugin sidebar for a matching plugin page route", async () => {
currentPathname = "/PAP/wiki";
mockPluginSlots.slots = [
@@ -829,14 +999,11 @@ describe("Layout", () => {
await flushReact();
await flushReact();
// Takeover model (PAP-10695): the app sidebar coexists with the plugin's
// route sidebar, which renders in the secondary pane.
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
expect(container.querySelector("[data-plugin-slot-class='h-full w-full']")).not.toBeNull();
expect(container.textContent).toContain("Main company nav");
expect(container.querySelector("[data-plugin-slot-class='min-h-0 flex-1']")).not.toBeNull();
expect(container.textContent).not.toContain("Main company nav");
expect(container.textContent).not.toContain("Company settings sidebar");
expect(container.textContent).not.toContain("Instance sidebar");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -891,7 +1058,7 @@ describe("Layout", () => {
}),
);
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Main company nav");
await act(async () => {
root.unmount();
@@ -1019,9 +1186,6 @@ describe("Layout", () => {
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Plugin route sidebar");
// No secondary pane, so the route must not force the sidebar collapsed.
expect(mockSetForceCollapsed).not.toHaveBeenCalledWith(true);
expect(mockSetForceCollapsed).toHaveBeenCalledWith(false);
await act(async () => {
root.unmount();
+145 -72
View File
@@ -6,6 +6,9 @@ import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
import { CompanySettingsNav } from "./access/CompanySettingsNav";
import { AppsSidebar } from "./AppsSidebar";
import { AppDetailSidebar } from "./AppConnectionSidebar";
import { AgentContextualSidebar } from "./AgentContextualSidebar";
import { RoutineContextualSidebar } from "./RoutineContextualSidebar";
import { SkillsContextualSidebar } from "./SkillsContextualSidebar";
import { BreadcrumbBar } from "./BreadcrumbBar";
import { PropertiesPanel } from "./PropertiesPanel";
import { CommandPalette } from "./CommandPalette";
@@ -22,6 +25,7 @@ import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
import { SidebarShell } from "./SidebarShell";
import { SecondarySidebar } from "./SecondarySidebar";
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
import { SidebarAccountMenu } from "./SidebarAccountMenu";
import { useDialogActions } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
@@ -29,6 +33,7 @@ import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { instanceSettingsApi } from "../api/instanceSettings";
@@ -43,6 +48,12 @@ import {
import { queryKeys } from "../lib/queryKeys";
import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { pinDocumentScrollToZero } from "../lib/pin-document-scroll";
import {
classifyShellRoute,
getCompanyPathSegments,
rememberContextualSidebarOrigin,
type ContextualSidebarSurface,
} from "../lib/shell-navigation";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots";
@@ -51,14 +62,6 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi
return getCompanyPathSegments(pathname, companyPrefix)[0]?.toLowerCase() ?? null;
}
function getCompanyPathSegments(pathname: string, companyPrefix: string | undefined): string[] {
if (!companyPrefix) return [];
const segments = pathname.split("/").filter(Boolean);
if (segments.length < 2) return [];
if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return [];
return segments.slice(1);
}
const RESERVED_APP_SUBPATHS = new Set([
"browse",
"connections",
@@ -71,22 +74,11 @@ const RESERVED_APP_SUBPATHS = new Set([
"app",
]);
function isSkillsStoreRoute(pathname: string, companyPrefix: string | undefined) {
const segments = pathname.split("/").filter(Boolean);
if (segments[0]?.toLowerCase() === "skills") return true;
if (!companyPrefix) return false;
return (
segments[0]?.toUpperCase() === companyPrefix.toUpperCase() &&
segments[1]?.toLowerCase() === "skills"
);
}
export function Layout() {
const {
sidebarOpen,
setSidebarOpen,
toggleSidebar,
toggleCollapsed,
collapsed,
peeking,
setPeeking,
@@ -108,16 +100,23 @@ export function Layout() {
const {
companyPrefix,
pluginRoutePath: matchedPluginRoutePath,
} = useParams<{ companyPrefix: string; pluginRoutePath?: string }>();
agentId,
routineId,
} = useParams<{
companyPrefix: string;
pluginRoutePath?: string;
agentId?: string;
routineId?: string;
}>();
const navigate = useNavigate();
const location = useLocation();
const navigationType = useNavigationType();
const isCompanySettingsRoute = [
"/company/settings",
"/company/export",
"/company/import",
].some((settingsPath) => location.pathname.includes(settingsPath));
const companyPathSegments = getCompanyPathSegments(location.pathname, companyPrefix);
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const shellRoute = classifyShellRoute(location.pathname, companyPrefix);
const isCompanySettingsRoute = shellRoute.builtInContextualSurface === "settings";
const companyPathSegments = shellRoute.companySegments;
const isTaskDetailRoute = shellRoute.isTaskDetail;
const useStreamlinedTaskDetailShell = streamlinedUiEnabled && isTaskDetailRoute;
const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools";
const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps";
const appDetailConnectionId =
@@ -128,9 +127,6 @@ export function Layout() {
isAppsRoute && companyPathSegments[1]?.toLowerCase() === "app" && companyPathSegments[2]
? companyPathSegments[2]
: null;
// The Skills Store renders its own secondary (category) sidebar, so the main
// app nav collapses to its rail throughout the Skills Store section (PAP-10879).
const isSkillsRoute = isSkillsStoreRoute(location.pathname, companyPrefix);
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const previousPathname = useRef<string | null>(null);
@@ -168,12 +164,14 @@ export function Layout() {
}),
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
);
// Takeover routes (company settings, plugin `routeSidebar`) no longer replace
// the app `<Sidebar/>`. Instead the host collapses it to its rail and renders
// the contextual sidebar in a second pane (PAP-10695). One resolver drives
// both desktop (SecondarySidebar) and mobile (off-canvas drawer).
const secondarySidebar = isCompanySettingsRoute ? (
// Most contextual routes replace the global navigation inside the same
// sidebar shell. Skills, Apps, Agent details, and Routine details are the
// exceptions in Streamlined UI: their local navigation is a second rail
// beside the persistent company nav.
const sharedSecondarySidebar = isCompanySettingsRoute ? (
<CompanySettingsSidebar />
) : !streamlinedUiEnabled && shellRoute.builtInContextualSurface === "skills" ? (
<SkillsContextualSidebar />
) : appDetailConnectionId ? (
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
) : appDetailApplicationId ? (
@@ -181,14 +179,54 @@ export function Layout() {
) : isAppsRoute || isToolsRoute ? (
<AppsSidebar />
) : routeSidebarSlot ? (
<PluginSlotMount
slot={routeSidebarSlot}
context={sidebarContext}
className="h-full w-full"
missingBehavior="placeholder"
/>
streamlinedUiEnabled ? (
<ContextualSidebarFrame
surface={`plugin:${routeSidebarSlot.id}`}
title={routeSidebarSlot.displayName}
>
<PluginSlotMount
slot={routeSidebarSlot}
context={sidebarContext}
className="min-h-0 flex-1"
missingBehavior="placeholder"
/>
</ContextualSidebarFrame>
) : (
<PluginSlotMount
slot={routeSidebarSlot}
context={sidebarContext}
className="h-full w-full"
missingBehavior="placeholder"
/>
)
) : null;
const secondarySidebar = streamlinedUiEnabled && shellRoute.builtInContextualSurface === "agent" && agentId ? (
<AgentContextualSidebar agentRef={agentId} />
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
<RoutineContextualSidebar routineId={routineId} />
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "skills" ? (
<SkillsContextualSidebar />
) : sharedSecondarySidebar;
const hasSecondarySidebar = secondarySidebar != null;
const keepsPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && (
shellRoute.builtInContextualSurface === "settings"
|| shellRoute.builtInContextualSurface === "skills"
|| shellRoute.builtInContextualSurface === "agent"
|| shellRoute.builtInContextualSurface === "routine"
|| isAppsRoute
|| isToolsRoute
);
const replacesPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && !keepsPrimarySidebar;
const showsAdjacentSecondarySidebar = hasSecondarySidebar && (!streamlinedUiEnabled || keepsPrimarySidebar);
const contextualSurface: ContextualSidebarSurface | null = !streamlinedUiEnabled || !hasSecondarySidebar
? null
: routeSidebarSlot && !shellRoute.builtInContextualSurface
? `plugin:${routeSidebarSlot.id}`
: shellRoute.builtInContextualSurface;
const previousShellRoute = useRef<{
pathname: string;
surface: ContextualSidebarSurface | null;
} | null>(null);
const { data: health } = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
@@ -204,16 +242,28 @@ export function Layout() {
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
// A secondary sidebar always collapses the app sidebar to its rail (still
// peek-able) — a hard invariant that overrides the user pin while the route
// is active, but does NOT mutate the persisted preference. Clearing the force
// on cleanup restores the user's expanded/collapsed choice when navigating
// off the takeover route (PAP-10694).
const forceRailCollapsed = hasSecondarySidebar || isSkillsRoute;
useLayoutEffect(() => {
setForceCollapsed(forceRailCollapsed);
setForceCollapsed(!streamlinedUiEnabled && hasSecondarySidebar);
return () => setForceCollapsed(false);
}, [forceRailCollapsed, setForceCollapsed]);
}, [hasSecondarySidebar, setForceCollapsed, streamlinedUiEnabled]);
useEffect(() => {
const previous = previousShellRoute.current;
if (
contextualSurface
&& companyPrefix
&& previous
&& previous.surface !== contextualSurface
&& previous.pathname !== location.pathname
) {
rememberContextualSidebarOrigin({
surface: contextualSurface,
companyPrefix,
previousPathname: previous.pathname,
});
}
previousShellRoute.current = { pathname: location.pathname, surface: contextualSurface };
}, [companyPrefix, contextualSurface, location.pathname]);
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
@@ -291,15 +341,6 @@ export function Layout() {
]);
const togglePanel = togglePanelVisible;
// Cmd/Ctrl+B: collapse/expand the pinned rail on desktop; on mobile keep
// toggling the off-canvas drawer.
const toggleCollapse = useCallback(() => {
if (isMobile) {
toggleSidebar();
} else {
toggleCollapsed();
}
}, [isMobile, toggleSidebar, toggleCollapsed]);
const openSearch = useCallback(() => {
document.dispatchEvent(new KeyboardEvent("keydown", {
key: "k",
@@ -412,7 +453,6 @@ export function Layout() {
onNewIssue: () => openNewIssue(),
onSearch: openSearch,
onToggleSidebar: toggleSidebar,
onToggleCollapse: toggleCollapse,
onTogglePanel: togglePanel,
onShowShortcuts: () => setShortcutsOpen(true),
onGoToInbox: () => navigate("/inbox"),
@@ -611,44 +651,57 @@ export function Layout() {
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="w-60 shrink-0 overflow-hidden">
{hasSecondarySidebar ? secondarySidebar : <Sidebar />}
{hasSecondarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : (
<Sidebar />
)}
</div>
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
forceExpanded={replacesPrimarySidebar}
/>
</div>
) : (
<SidebarShell
open={sidebarOpen}
collapsed={collapsed}
peeking={peeking}
collapsed={replacesPrimarySidebar ? false : collapsed}
peeking={replacesPrimarySidebar ? false : peeking}
resizable
onPanelMouseEnter={handlePanelPointerEnter}
onPanelMouseLeave={handlePanelPointerLeave}
onPanelFocusCapture={collapsed ? handlePanelFocus : undefined}
onPanelBlurCapture={collapsed ? handlePanelBlur : undefined}
onPanelMouseEnter={replacesPrimarySidebar ? undefined : handlePanelPointerEnter}
onPanelMouseLeave={replacesPrimarySidebar ? undefined : handlePanelPointerLeave}
onPanelFocusCapture={!replacesPrimarySidebar && collapsed ? handlePanelFocus : undefined}
onPanelBlurCapture={!replacesPrimarySidebar && collapsed ? handlePanelBlur : undefined}
>
<div className="flex flex-1 min-h-0">
<Sidebar />
{replacesPrimarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : (
<Sidebar />
)}
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
forceExpanded={replacesPrimarySidebar}
/>
</SidebarShell>
)}
{!isMobile && hasSecondarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
{!isMobile && showsAdjacentSecondarySidebar && !keepsPrimarySidebar ? (
<SecondarySidebar className="w-60 shrink-0">
{secondarySidebar}
</SecondarySidebar>
) : null}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
!isMobile && useStreamlinedTaskDetailShell && "hidden",
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
@@ -660,8 +713,23 @@ export function Layout() {
</div>
) : null}
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
<div className={cn(
isMobile ? "block" : "flex flex-1 min-h-0",
!isMobile && useStreamlinedTaskDetailShell && "streamlined-task-detail-surface",
)}>
{!isMobile && keepsPrimarySidebar ? (
<SecondarySidebar className="w-60 shrink-0 bg-background">
{secondarySidebar}
</SecondarySidebar>
) : null}
<div className={cn(!isMobile && useStreamlinedTaskDetailShell ? "flex min-w-0 flex-1 flex-col" : "contents")}>
{!isMobile && useStreamlinedTaskDetailShell ? (
<>
<StandaloneBrowserControls mobile={false} />
<BreadcrumbBar taskDetailLayout />
</>
) : null}
<main
id="main-content"
ref={mainContentRef}
tabIndex={-1}
@@ -681,6 +749,10 @@ export function Layout() {
}
className={cn(
"flex-1 p-4 outline-none md:p-6",
// The task thread owns its scrollable top spacing. Leaving the
// page shell's top padding in place creates a stationary dark
// strip below the breadcrumb while messages scroll behind it.
!isMobile && useStreamlinedTaskDetailShell && "pt-0 pr-0 md:pt-0 md:pr-0",
// Reserve the scrollbar gutter on desktop so pages whose height
// changes (e.g. switching skill-detail tabs) don't widen/shift
// when the vertical scrollbar appears or disappears (PAP-10907).
@@ -699,8 +771,9 @@ export function Layout() {
<Outlet />
</RouteErrorBoundary>
)}
</main>
<PropertiesPanel />
</main>
</div>
<PropertiesPanel taskDetailLayout={!isMobile && useStreamlinedTaskDetailShell} />
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff
+4
View File
@@ -21,8 +21,10 @@ import {
issueExecutionWorkspaceModeForExistingWorkspace,
} from "../lib/project-workspace-defaults";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { recordRecentTask } from "../lib/recent-tasks";
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
import { isIssueWorkMode, nextWorkMode, workModeMetaFor, workModeMetaList } from "../lib/work-mode-meta";
import { useToastActions } from "../context/ToastContext";
@@ -472,6 +474,7 @@ export function NewIssueDialog() {
const statuses = useMemo(() => buildStatusOptions(), []);
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const titleRef = useRef("");
@@ -641,6 +644,7 @@ export function NewIssueDialog() {
return { issue, companyId, failures };
},
onSuccess: ({ issue, companyId, failures }) => {
if (streamlinedUiEnabled) recordRecentTask(issue, currentUserId);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
+76 -12
View File
@@ -45,8 +45,12 @@ async function flushReact() {
describe("PropertiesPanel", () => {
let container: HTMLDivElement;
let root: Root | null = null;
let originalInnerWidth: number;
async function renderPanel({ panelVisible = true }: { panelVisible?: boolean } = {}) {
async function renderPanel({
panelVisible = true,
taskDetailLayout = false,
}: { panelVisible?: boolean; taskDetailLayout?: boolean } = {}) {
mockPanelState.panelContent = <div data-testid="panel-content">content</div>;
mockPanelState.panelVisible = panelVisible;
root = createRoot(container);
@@ -55,7 +59,7 @@ describe("PropertiesPanel", () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<PropertiesPanel />
<PropertiesPanel taskDetailLayout={taskDetailLayout} />
</TooltipProvider>
</QueryClientProvider>,
);
@@ -64,6 +68,8 @@ describe("PropertiesPanel", () => {
}
beforeEach(() => {
originalInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1440 });
container = document.createElement("div");
document.body.appendChild(container);
window.localStorage.clear();
@@ -76,12 +82,14 @@ describe("PropertiesPanel", () => {
});
root = null;
container.remove();
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
vi.clearAllMocks();
});
describe("classic task interface on (legacy panel)", () => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableStreamlinedUi: true,
enableClassicTaskInterface: true,
});
});
@@ -108,32 +116,59 @@ describe("PropertiesPanel", () => {
describe("classic task interface off (default resizable pane)", () => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableStreamlinedUi: true,
enableClassicTaskInterface: false,
});
});
it("renders the default 322px width with a drag grip and a maximize button", async () => {
await renderPanel();
it("renders the Paper task-detail rail width with a drag grip and a maximize button", async () => {
await renderPanel({ taskDetailLayout: true });
const aside = container.querySelector("aside");
expect(aside).not.toBeNull();
expect(aside!.style.width).toBe("322px");
expect(aside!.style.width).toBe("434px");
expect(aside!.querySelector('[role="separator"][aria-label="Resize panel"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Maximize side panel"]')).not.toBeNull();
const inner = aside!.querySelector<HTMLDivElement>(":scope > div:not([role])");
expect(inner!.style.width).toBe("322px");
expect(inner!.style.minWidth).toBe("322px");
expect(inner!.style.width).toBe("434px");
expect(inner!.style.minWidth).toBe("434px");
expect(aside!.querySelector("header")?.className).toContain(
"h-(--side-panel-header-height)",
);
});
it("uses the pressed side-panel toggle instead of an X to hide the open pane", async () => {
it("removes the left divider only while the sidebar is maximized", async () => {
await renderPanel({ taskDetailLayout: true });
const aside = container.querySelector("aside")!;
const maximize = container.querySelector<HTMLButtonElement>(
'[aria-label="Maximize side panel"]',
)!;
expect(aside.className).toContain("border-l");
maximize.click();
await flushReact();
expect(aside.className).not.toContain("border-l");
expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true");
});
it("uses an X to close the Streamlined task-detail sidebar", async () => {
await renderPanel({ taskDetailLayout: true });
const close = container.querySelector<HTMLButtonElement>('[aria-label="Close side panel"]');
expect(close).not.toBeNull();
expect(close!.querySelector(".lucide-x")).not.toBeNull();
expect(container.querySelector('[aria-label="Toggle side panel"]')).toBeNull();
close!.click();
expect(mockSetPanelVisible).toHaveBeenCalledWith(false);
});
it("keeps the production toggle control outside Streamlined task detail", async () => {
await renderPanel();
const toggle = container.querySelector<HTMLButtonElement>('[aria-label="Toggle side panel"]');
expect(toggle).not.toBeNull();
expect(toggle!.getAttribute("aria-pressed")).toBe("true");
expect(toggle!.querySelector(".lucide-panel-right-close")).not.toBeNull();
expect(container.querySelector('[aria-label="Hide side panel"]')).toBeNull();
toggle!.click();
expect(mockSetPanelVisible).toHaveBeenCalledWith(false);
expect(container.querySelector('[aria-label="Close side panel"]')).toBeNull();
});
it("restores a remembered width from localStorage (clamped to the minimum)", async () => {
@@ -163,4 +198,33 @@ describe("PropertiesPanel", () => {
expect(aside!.querySelector('[role="separator"]')).toBeNull();
});
});
describe("Streamlined UI off", () => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableStreamlinedUi: false,
enableClassicTaskInterface: false,
});
});
it("restores master's default resizable panel rather than the classic panel", async () => {
await renderPanel({ taskDetailLayout: true });
const aside = container.querySelector("aside");
expect(aside).not.toBeNull();
expect(aside!.style.width).toBe("322px");
expect(aside!.querySelector('[role="separator"][aria-label="Resize panel"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Maximize side panel"]')).not.toBeNull();
});
it("still honors the independent Classic Task Interface preference", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableStreamlinedUi: false,
enableClassicTaskInterface: true,
});
await renderPanel({ taskDetailLayout: true });
const aside = container.querySelector("aside");
expect(aside!.style.width).toBe("320px");
expect(aside!.querySelector('[role="separator"]')).toBeNull();
});
});
});
+43 -21
View File
@@ -2,14 +2,17 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"
import { X } from "lucide-react";
import { usePanel } from "../context/PanelContext";
import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SidePanelFrame, SidePanelWindowControls } from "@/components/side-panel";
export function PropertiesPanel() {
export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
const { panelContent, panelContentMode, panelVisible, setPanelVisible } = usePanel();
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
if (!panelContent) return null;
@@ -36,23 +39,27 @@ export function PropertiesPanel() {
return (
<ResizablePropertiesPanel
key={streamlinedTaskDetailLayout ? "task-detail" : "default"}
panelContent={panelContent}
panelContentMode={panelContentMode}
panelVisible={panelVisible}
setPanelVisible={setPanelVisible}
taskDetailLayout={streamlinedTaskDetailLayout}
/>
);
}
/* ------------------------------------------------------------------------- *
* Chat-style (default) resizable/maximizable variant. Everything below
* renders only when the Classic Task Interface flag is OFF.
* Production chat-style resizable/maximizable variant. Streamlined UI only
* changes its task-detail dimensions; Classic Task Interface selects the
* fixed panel above.
* ------------------------------------------------------------------------- */
/**
* Portal target in the redesigned pane's header bar: hosted content (the
* Properties | Plan | Artifacts tab strip) renders here, left of the window
* controls. See IssueProperties' flag-ON shell.
* Properties | Plan | Artifacts tab strip, including the single active
* Properties tab) renders here, left of the window controls. See
* IssueProperties' flag-ON shell.
*/
export const PROPERTIES_PANE_HEADER_SLOT_ID = "properties-pane-header-slot";
/**
@@ -63,7 +70,9 @@ export const PROPERTIES_PANE_HEADER_SLOT_ID = "properties-pane-header-slot";
export const PROPERTIES_PANE_FOOTER_SLOT_ID = "properties-pane-footer-slot";
const WIDTH_STORAGE_KEY = "taskChatRedesign.propertiesPaneWidth";
const TASK_DETAIL_WIDTH_STORAGE_KEY = "taskChatRedesign.taskDetailPropertiesPaneWidth";
const DEFAULT_PANE_WIDTH = 322;
const TASK_DETAIL_DEFAULT_PANE_WIDTH = 434;
const MIN_PANE_WIDTH = 260;
/** ~236px sidebar + ~420px minimum center column stay usable while resizing. */
const RESERVED_LAYOUT_WIDTH = 656;
@@ -84,30 +93,30 @@ function clampPaneWidth(width: number): number {
return Math.min(Math.max(Math.round(width), MIN_PANE_WIDTH), max);
}
function readStoredPaneWidth(): number {
if (typeof window === "undefined") return DEFAULT_PANE_WIDTH;
function readStoredPaneWidth(storageKey: string, defaultWidth: number): number {
if (typeof window === "undefined") return defaultWidth;
try {
const raw = window.localStorage.getItem(WIDTH_STORAGE_KEY);
const raw = window.localStorage.getItem(storageKey);
const parsed = raw === null ? Number.NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : DEFAULT_PANE_WIDTH;
return Number.isFinite(parsed) ? parsed : defaultWidth;
} catch {
return DEFAULT_PANE_WIDTH;
return defaultWidth;
}
}
function persistPaneWidth(width: number) {
function persistPaneWidth(storageKey: string, width: number) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(WIDTH_STORAGE_KEY, String(width));
window.localStorage.setItem(storageKey, String(width));
} catch {
// Ignore storage failures.
}
}
function clearStoredPaneWidth() {
function clearStoredPaneWidth(storageKey: string) {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(WIDTH_STORAGE_KEY);
window.localStorage.removeItem(storageKey);
} catch {
// Ignore storage failures.
}
@@ -134,6 +143,7 @@ interface ResizablePropertiesPanelProps {
panelContentMode: "padded" | "prose" | "full-bleed";
panelVisible: boolean;
setPanelVisible: (visible: boolean) => void;
taskDetailLayout: boolean;
}
function ResizablePropertiesPanel({
@@ -141,8 +151,17 @@ function ResizablePropertiesPanel({
panelContentMode,
panelVisible,
setPanelVisible,
taskDetailLayout,
}: ResizablePropertiesPanelProps) {
const [width, setWidth] = useState(() => clampPaneWidth(readStoredPaneWidth()));
const defaultPaneWidth = taskDetailLayout
? TASK_DETAIL_DEFAULT_PANE_WIDTH
: DEFAULT_PANE_WIDTH;
const widthStorageKey = taskDetailLayout
? TASK_DETAIL_WIDTH_STORAGE_KEY
: WIDTH_STORAGE_KEY;
const [width, setWidth] = useState(() =>
clampPaneWidth(readStoredPaneWidth(widthStorageKey, defaultPaneWidth)),
);
const [dragging, setDragging] = useState(false);
const [maximized, setMaximized] = useState(false);
const [fixedPane, setFixedPane] = useState<FixedPane | null>(null);
@@ -192,8 +211,8 @@ function ResizablePropertiesPanel({
dragStateRef.current = null;
setDragging(false);
document.body.style.userSelect = previousBodyUserSelectRef.current;
if (persist) persistPaneWidth(widthRef.current);
}, []);
if (persist) persistPaneWidth(widthStorageKey, widthRef.current);
}, [widthStorageKey]);
const handleGripPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
// Primary button only (touch/pen report button 0 or -1 for down events).
@@ -231,9 +250,9 @@ function ResizablePropertiesPanel({
}, [endDrag]);
const handleGripDoubleClick = useCallback(() => {
setWidth(DEFAULT_PANE_WIDTH);
clearStoredPaneWidth();
}, []);
setWidth(defaultPaneWidth);
clearStoredPaneWidth(widthStorageKey);
}, [defaultPaneWidth, widthStorageKey]);
const handleMaximize = useCallback(() => {
const aside = asideRef.current;
@@ -307,7 +326,8 @@ function ResizablePropertiesPanel({
<aside
ref={asideRef}
className={cn(
"hidden md:flex border-l border-border bg-card flex-col",
"hidden md:flex bg-card flex-col",
!maximized && "border-l border-border",
isFixed
? "tc-pane-glide fixed z-40 overflow-hidden"
: cn(
@@ -361,6 +381,7 @@ function ResizablePropertiesPanel({
presentation="docked"
maximized={maximized}
contentMode="full-bleed"
headerSize={taskDetailLayout ? "task-detail" : "default"}
className="flex-1 border-l-0"
header={(
<div
@@ -371,6 +392,7 @@ function ResizablePropertiesPanel({
trailingControls={(
<SidePanelWindowControls
maximized={maximized}
closeControl={taskDetailLayout ? "close" : "toggle"}
onMaximizedChange={(next) => {
if (next) handleMaximize();
else handleRestore();
@@ -9,8 +9,6 @@ import { RequestCollapsedSidebar } from "./RequestCollapsedSidebar";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed";
let capturedValue: ReturnType<typeof useSidebar> | null = null;
function Capture() {
@@ -82,25 +80,24 @@ describe("RequestCollapsedSidebar", () => {
localStorage.clear();
});
it("requests collapsed while mounted when there is no user pin", async () => {
it("records the legacy route request without collapsing the global navigation", async () => {
active = await render(true);
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(false);
});
it("lets an explicit user pin override the route request", async () => {
it("keeps the global navigation expanded when old pin APIs are called", async () => {
active = await render(true);
expect(capturedValue?.collapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(false);
// User explicitly pins expanded — must win over the route's request.
flushSync(() => capturedValue?.setCollapsed(false));
flushSync(() => capturedValue?.setCollapsed(true));
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(false);
});
it("clears the request on unmount, restoring the global default", async () => {
active = await render(true);
expect(capturedValue?.collapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(false);
// Navigate away: the route (and its <RequestCollapsedSidebar/>) unmounts.
flushSync(() => active!.root.render(<Harness onRoute={false} />));
@@ -109,15 +106,14 @@ describe("RequestCollapsedSidebar", () => {
expect(capturedValue?.collapsed).toBe(false);
});
it("keeps a user pin after navigating away (pin persists, request cleared)", async () => {
it("clears the request without persisting a retired collapsed pin", async () => {
active = await render(true);
flushSync(() => capturedValue?.setCollapsed(true));
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1");
expect(localStorage.getItem("paperclip.sidebar.collapsed")).toBeNull();
flushSync(() => active!.root.render(<Harness onRoute={false} />));
await flushReact();
// Route request gone, but the explicit collapsed pin still applies.
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(false);
});
});
@@ -2,14 +2,10 @@ import { useEffect } from "react";
import { useSidebar } from "../context/SidebarContext";
/**
* Effect-only component (renders nothing). While mounted, it asks the app
* sidebar to default to **collapsed** (still peek-able) — the generic
* "this route brings its own sidebar, keep the app one out of the way" case.
*
* Precedence is enforced by {@link SidebarContext}: an explicit user pin
* (collapsed *or* expanded) always wins over this route request. When the
* component unmounts — e.g. navigating away from the route that rendered it —
* the cleanup clears the request and the global default is restored.
* Back-compat effect for routes that previously requested the app sidebar's
* retired icon rail. The request remains observable to older integrations,
* but {@link SidebarContext} keeps the global navigation expanded. Cleanup
* still clears the request when the route unmounts.
*
* Drop it anywhere inside a route's element tree:
*
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
RoutineContextualSidebar,
resolveRoutineDetailDestination,
routineActivityAuditHref,
routineDetailHref,
routineRunsAuditHref,
} from "./RoutineContextualSidebar";
vi.mock("@/lib/router", () => ({
useParams: () => ({ routineId: "routine-route" }),
}));
vi.mock("@/api/routines", () => ({
routinesApi: { get: vi.fn() },
}));
vi.mock("./ContextualSidebarFrame", () => ({
ContextualSidebarFrame: ({
surface,
title,
fallbackTo,
showHeader,
className,
children,
}: {
surface: string;
title: string;
fallbackTo: string;
showHeader?: boolean;
className?: string;
children: ReactNode;
}) => (
<aside
className={className}
data-surface={surface}
data-title={title}
data-fallback={fallbackTo}
data-show-header={String(showHeader)}
>
{children}
</aside>
),
}));
vi.mock("./SidebarNavItem", () => ({
SidebarNavItem: ({ to, label }: { to: string; label: string }) => <a href={to}>{label}</a>,
}));
describe("routine contextual navigation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
it("builds stable detail and scoped Audit destinations", () => {
expect(routineDetailHref("routine-1")).toBe("/routines/routine-1/overview");
expect(routineDetailHref("routine-1", "triggers")).toBe("/routines/routine-1/triggers");
expect(routineRunsAuditHref("routine-1")).toBe(
"/activity/runs?entityType=routine&entityId=routine-1",
);
expect(routineActivityAuditHref("routine-1")).toBe(
"/activity?entityType=routine&entityId=routine-1",
);
});
it("defaults and redirects legacy operation routes without remembering a prior section", () => {
expect(resolveRoutineDetailDestination({ routineId: "routine-1" }))
.toBe("/routines/routine-1/overview");
expect(resolveRoutineDetailDestination({ routineId: "routine-1", section: "unknown" }))
.toBe("/routines/routine-1/overview");
expect(resolveRoutineDetailDestination({ routineId: "routine-1", section: "runs" }))
.toBe("/activity/runs?entityType=routine&entityId=routine-1");
expect(resolveRoutineDetailDestination({ routineId: "routine-1", legacyTab: "activity" }))
.toBe("/activity?entityType=routine&entityId=routine-1");
});
it("renders the routine contextual sidebar with configuration and Audit links", () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
flushSync(() => root.render(
<QueryClientProvider client={queryClient}>
<RoutineContextualSidebar routineId="routine-1" title="Weekly release review" />
</QueryClientProvider>,
));
const frame = container.querySelector("aside");
expect(frame?.getAttribute("data-surface")).toBe("routine");
expect(frame?.getAttribute("data-title")).toBe("Weekly release review");
expect(frame?.getAttribute("data-fallback")).toBe("/routines");
expect(frame?.getAttribute("data-show-header")).toBe("false");
expect(frame?.classList).toContain("bg-background");
expect(frame?.classList).toContain("border-r");
expect(container.querySelector('a[href="/routines/routine-1/overview"]')?.textContent).toBe("Overview");
expect(container.querySelector('a[href="/routines/routine-1/triggers"]')?.textContent).toBe("Schedule");
expect(container.querySelector('a[href="/activity/runs?entityType=routine&entityId=routine-1"]'))
.not.toBeNull();
expect(container.querySelector('a[href="/activity?entityType=routine&entityId=routine-1"]'))
.not.toBeNull();
expect(container.textContent).not.toContain("History");
});
});
@@ -0,0 +1,140 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
Braces,
CalendarClock,
KeyRound,
LayoutDashboard,
Play,
Repeat,
Send,
type LucideIcon,
} from "lucide-react";
import { routinesApi } from "@/api/routines";
import { queryKeys } from "@/lib/queryKeys";
import { useParams } from "@/lib/router";
import {
auditSectionHref,
routineAuditHref,
} from "@/pages/audit/audit-navigation";
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
import { SidebarNavItem } from "./SidebarNavItem";
export const ROUTINE_DETAIL_VIEWS = [
"overview",
"triggers",
"variables",
"delivery",
"secrets",
"history",
] as const;
export type RoutineDetailView = (typeof ROUTINE_DETAIL_VIEWS)[number];
export type RoutineContextualNavItem = {
view: Exclude<RoutineDetailView, "history">;
label: string;
icon: LucideIcon;
};
export const ROUTINE_CONTEXTUAL_NAV_ITEMS: readonly RoutineContextualNavItem[] = [
{ view: "overview", label: "Overview", icon: LayoutDashboard },
{ view: "triggers", label: "Schedule", icon: CalendarClock },
{ view: "variables", label: "Variables", icon: Braces },
{ view: "delivery", label: "Delivery", icon: Send },
{ view: "secrets", label: "Secrets", icon: KeyRound },
];
export function isRoutineDetailView(value: string | null | undefined): value is RoutineDetailView {
return ROUTINE_DETAIL_VIEWS.includes(value as RoutineDetailView);
}
export function routineDetailHref(routineId: string, view: RoutineDetailView = "overview") {
return `/routines/${routineId}/${view}`;
}
export function routineRunsAuditHref(routineId: string) {
return auditSectionHref("runs", {
entityType: "routine",
entityId: routineId,
});
}
export function routineActivityAuditHref(routineId: string) {
return routineAuditHref(routineId);
}
/**
* Canonical landing/legacy resolver used by the page and available to the
* shell. Detail configuration stays local; immutable operational history
* lives in scoped Audit.
*/
export function resolveRoutineDetailDestination(input: {
routineId: string;
section?: string | null;
legacyTab?: string | null;
}) {
const requested = input.legacyTab ?? input.section;
if (requested === "runs") return routineRunsAuditHref(input.routineId);
if (requested === "activity") return routineActivityAuditHref(input.routineId);
if (isRoutineDetailView(requested)) return routineDetailHref(input.routineId, requested);
return routineDetailHref(input.routineId, "overview");
}
export function RoutineContextualSidebar({
routineId: routineIdProp,
title,
}: {
routineId?: string;
title?: string;
} = {}) {
const params = useParams<{ routineId?: string }>();
const routineId = routineIdProp ?? params.routineId ?? "";
const { data: routine } = useQuery({
queryKey: queryKeys.routines.detail(routineId),
queryFn: () => routinesApi.get(routineId),
enabled: Boolean(routineId && !title),
});
const frameTitle = title ?? routine?.title ?? "Routine";
return (
<ContextualSidebarFrame
surface="routine"
title={frameTitle}
icon={Repeat}
fallbackTo="/routines"
showHeader={false}
className="border-r border-border bg-background"
>
<nav className="min-h-0 flex-1 overflow-y-auto px-3 py-2" aria-label="Routine navigation">
<div className="flex flex-col gap-0.5">
{ROUTINE_CONTEXTUAL_NAV_ITEMS.map((item) => (
<SidebarNavItem
key={item.view}
to={routineDetailHref(routineId, item.view)}
label={item.label}
icon={item.icon}
end
/>
))}
</div>
<p className="px-4 pb-1 pt-5 text-(length:--text-nano) font-mono font-medium uppercase tracking-widest text-muted-foreground/60">
Audit
</p>
<div className="flex flex-col gap-0.5">
<SidebarNavItem
to={routineRunsAuditHref(routineId)}
label="Runs"
icon={Play}
/>
<SidebarNavItem
to={routineActivityAuditHref(routineId)}
label="Activity"
icon={Activity}
/>
</div>
</nav>
</ContextualSidebarFrame>
);
}
+203
View File
@@ -0,0 +1,203 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import type { RoutineDetail, RoutineRunSummary } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineDetailContext, type RoutineDetailContextValue } from "./routine-sections/context";
import {
RoutineOverview,
routineRunIssue,
summarizeRoutineSchedule,
} from "./RoutineOverview";
const issueRowRender = vi.hoisted(() => vi.fn());
vi.mock("@/components/IssueRow", () => ({
IssueRow: (props: { issue: { title: string }; presentation?: string }) => {
issueRowRender(props);
return <div data-slot="task-row">{props.issue.title}</div>;
},
}));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div data-testid="markdown">{children}</div>,
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>{children}</a>
),
}));
const triggeredAt = new Date("2026-08-31T16:00:00.000Z");
const run: RoutineRunSummary = {
id: "run-1",
companyId: "company-1",
routineId: "routine-1",
triggerId: "trigger-1",
source: "schedule",
status: "succeeded",
triggeredAt,
idempotencyKey: null,
triggerPayload: null,
dispatchFingerprint: null,
linkedIssueId: "issue-1",
coalescedIntoRunId: null,
failureReason: null,
completedAt: triggeredAt,
createdAt: triggeredAt,
updatedAt: triggeredAt,
trigger: { id: "trigger-1", kind: "schedule", label: "weekday" },
linkedIssue: {
id: "issue-1",
identifier: "PAP-42",
title: "Prepare the release digest",
status: "done",
priority: "high",
updatedAt: triggeredAt,
},
};
const routine = {
id: "routine-1",
companyId: "company-1",
projectId: "project-1",
folderId: null,
goalId: null,
parentIssueId: null,
title: "Weekly release review",
description: "Summarize **release readiness** for the operator.",
assigneeAgentId: "agent-1",
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
variables: [],
env: { PRIVATE_TOKEN: { type: "secret_ref", secretId: "secret-1" } },
latestRevisionId: "revision-1",
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: null,
responsibleUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: triggeredAt,
lastEnqueuedAt: triggeredAt,
createdAt: triggeredAt,
updatedAt: triggeredAt,
project: null,
assignee: { id: "agent-1", name: "Release Manager", role: "manager", title: null, urlKey: "release-manager" },
parentIssue: null,
triggers: [{
id: "trigger-1",
companyId: "company-1",
routineId: "routine-1",
kind: "schedule",
label: "weekday",
enabled: true,
cronExpression: "0 9 * * 1-5",
timezone: "America/Los_Angeles",
nextRunAt: new Date("2026-09-01T16:00:00.000Z"),
lastFiredAt: triggeredAt,
publicId: null,
secretId: null,
signingMode: null,
replayWindowSec: null,
lastRotatedAt: null,
lastResult: "succeeded",
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: triggeredAt,
updatedAt: triggeredAt,
}],
recentRuns: [run],
activeIssue: null,
} as RoutineDetail;
function contextFixture(): RoutineDetailContextValue {
return {
routine,
routineId: routine.id,
companyId: routine.companyId,
routineRuns: [run],
currentAssignee: {
id: "agent-1",
name: "Release Manager",
urlKey: "release-manager",
},
hasLiveRun: false,
} as RoutineDetailContextValue;
}
describe("RoutineOverview", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
issueRowRender.mockClear();
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-08-31T17:00:00.000Z").getTime());
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.restoreAllMocks();
});
it("summarizes enabled schedules and their next run", () => {
expect(summarizeRoutineSchedule(routine.triggers)).toMatchObject({
label: "1 active schedule",
detail: "0 9 * * 1-5 · America/Los_Angeles",
nextRunAt: new Date("2026-09-01T16:00:00.000Z"),
});
expect(summarizeRoutineSchedule([{ ...routine.triggers[0]!, enabled: false }])).toEqual({
label: "No active schedule",
detail: "Manual runs only",
nextRunAt: null,
});
});
it("adapts compact run tasks to the canonical task presentation", () => {
const issue = routineRunIssue(run.linkedIssue!, run, "company-1", "project-1");
expect(issue).toMatchObject({
id: "issue-1",
companyId: "company-1",
projectId: "project-1",
status: "done",
priority: "high",
originKind: "routine_execution",
originId: "routine-1",
originRunId: "run-1",
});
});
it("shows operational facts, readable description, agent, and recent task rows without secrets", () => {
flushSync(() => root.render(
<RoutineDetailContext.Provider value={contextFixture()}>
<RoutineOverview />
</RoutineDetailContext.Provider>,
));
expect(container.textContent).toContain("1 active schedule");
expect(container.textContent).toContain("Next run");
expect(container.textContent).toContain("Release Manager");
expect(container.textContent).toContain("Summarize **release readiness** for the operator.");
expect(container.textContent).toContain("Prepare the release digest");
expect(container.textContent).not.toContain("PRIVATE_TOKEN");
expect(container.textContent).not.toContain("secret-1");
expect(issueRowRender).toHaveBeenCalledWith(expect.objectContaining({ presentation: "task" }));
expect(container.querySelector('a[href="/activity/runs?entityType=routine&entityId=routine-1"]'))
.not.toBeNull();
expect(container.querySelector('a[href="/activity?entityType=routine&entityId=routine-1"]'))
.not.toBeNull();
});
});
+249
View File
@@ -0,0 +1,249 @@
import type {
Issue,
IssuePriority,
IssueStatus,
RoutineRunSummary,
RoutineTrigger,
} from "@paperclipai/shared";
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
import { CalendarClock, Clock3, Play, Repeat, UserRound } from "lucide-react";
import { IssueRow } from "@/components/IssueRow";
import { MarkdownBody } from "@/components/MarkdownBody";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/button";
import { createIssueDetailLocationState } from "@/lib/issueDetailBreadcrumb";
import { Link } from "@/lib/router";
import {
routineActivityAuditHref,
routineDetailHref,
routineRunsAuditHref,
} from "./RoutineContextualSidebar";
import { useRoutineDetail } from "./routine-sections/context";
export type RoutineScheduleSummary = {
label: string;
detail: string;
nextRunAt: Date | null;
};
export function formatRoutineTimestamp(value: Date | string) {
return new Date(value).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
export function summarizeRoutineSchedule(triggers: RoutineTrigger[]): RoutineScheduleSummary {
const schedules = triggers.filter((trigger) => trigger.kind === "schedule" && trigger.enabled);
const nextRunAt = schedules
.map((trigger) => trigger.nextRunAt ? new Date(trigger.nextRunAt) : null)
.filter((value): value is Date => value !== null && Number.isFinite(value.getTime()))
.sort((left, right) => left.getTime() - right.getTime())[0] ?? null;
if (schedules.length === 0) {
return { label: "No active schedule", detail: "Manual runs only", nextRunAt: null };
}
const first = schedules[0]!;
return {
label: schedules.length === 1 ? "1 active schedule" : `${schedules.length} active schedules`,
detail: first.cronExpression
? `${first.cronExpression}${first.timezone ? ` · ${first.timezone}` : ""}`
: first.label ?? "Scheduled trigger",
nextRunAt,
};
}
function normalizeIssueStatus(value: string): IssueStatus {
return ISSUE_STATUSES.includes(value as IssueStatus) ? value as IssueStatus : "todo";
}
function normalizeIssuePriority(value: string): IssuePriority {
return ISSUE_PRIORITIES.includes(value as IssuePriority) ? value as IssuePriority : "medium";
}
/** Display-only adapter for the canonical task row; run summaries intentionally carry compact task data. */
export function routineRunIssue(
summary: NonNullable<RoutineRunSummary["linkedIssue"]>,
run: RoutineRunSummary,
companyId: string,
projectId: string | null,
): Issue {
return {
...summary,
companyId,
projectId,
projectWorkspaceId: null,
goalId: null,
parentId: null,
description: null,
status: normalizeIssueStatus(summary.status),
workMode: "standard",
priority: normalizeIssuePriority(summary.priority),
reviewPolicy: null,
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
responsibleUserId: null,
issueNumber: null,
originKind: "routine_execution",
originId: run.routineId,
originRunId: run.id,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: run.triggeredAt,
};
}
function OverviewFact({
icon: Icon,
label,
value,
detail,
}: {
icon: typeof Clock3;
label: string;
value: React.ReactNode;
detail?: React.ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col gap-1 rounded-lg border border-border p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{label}</span>
</div>
<div className="min-w-0 text-sm font-medium text-foreground">{value}</div>
{detail ? <div className="min-w-0 text-xs text-muted-foreground">{detail}</div> : null}
</div>
);
}
export function RoutineOverview() {
const { routine, routineRuns, currentAssignee, hasLiveRun } = useRoutineDetail();
const schedule = summarizeRoutineSchedule(routine.triggers);
const sortedRuns = [...(routineRuns ?? [])].sort(
(left, right) => new Date(right.triggeredAt).getTime() - new Date(left.triggeredAt).getTime(),
);
const lastRun = sortedRuns[0] ?? null;
const recentRuns = sortedRuns.slice(0, 5);
const detailOrigin = createIssueDetailLocationState(
routine.title,
routineDetailHref(routine.id),
"issues",
);
const automationState = routine.status === "archived"
? "archived"
: !routine.assigneeAgentId
? "draft"
: routine.status;
return (
<div className="flex flex-col gap-6" data-routine-overview-mode="read">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<OverviewFact
icon={Repeat}
label="State"
value={<StatusBadge status={automationState} />}
detail={hasLiveRun ? "A run is active now" : "No active run"}
/>
<OverviewFact
icon={CalendarClock}
label="Schedule"
value={schedule.label}
detail={<span className="font-mono">{schedule.detail}</span>}
/>
<OverviewFact
icon={Clock3}
label="Next run"
value={schedule.nextRunAt ? formatRoutineTimestamp(schedule.nextRunAt) : "Not scheduled"}
detail={schedule.nextRunAt ? "Scheduled" : "Add or enable a schedule"}
/>
<OverviewFact
icon={Play}
label="Last run"
value={lastRun ? <StatusBadge status={lastRun.status} /> : "No runs yet"}
detail={lastRun ? formatRoutineTimestamp(lastRun.triggeredAt) : "Run manually or wait for the schedule"}
/>
</div>
<section className="flex flex-col gap-2" aria-labelledby="routine-agent-heading">
<h2 id="routine-agent-heading" className="text-sm font-semibold">Default agent</h2>
{currentAssignee ? (
<Link
to={`/agents/${currentAssignee.urlKey ?? currentAssignee.id}`}
className="flex w-fit items-center gap-2 rounded-md text-sm font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<UserRound className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{currentAssignee.name}
</Link>
) : (
<p className="text-sm text-muted-foreground">No default agent. Automatic triggers remain paused.</p>
)}
</section>
<section className="flex flex-col gap-2" aria-labelledby="routine-description-heading">
<h2 id="routine-description-heading" className="text-sm font-semibold">Description</h2>
{routine.description?.trim() ? (
<MarkdownBody className="text-sm text-foreground" linkIssueReferences>
{routine.description}
</MarkdownBody>
) : (
<p className="text-sm text-muted-foreground">No description yet.</p>
)}
</section>
<section className="flex flex-col gap-2" aria-labelledby="routine-recent-runs-heading">
<div className="flex items-center justify-between gap-3">
<h2 id="routine-recent-runs-heading" className="text-sm font-semibold">Recent runs</h2>
<Button variant="ghost" size="sm" asChild>
<Link to={routineRunsAuditHref(routine.id)}>View all runs</Link>
</Button>
</div>
{recentRuns.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No runs yet. Run the routine now or wait for its schedule.
</p>
) : (
<div className="flex flex-col gap-0.5">
{recentRuns.map((run) => run.linkedIssue ? (
<IssueRow
key={run.id}
issue={routineRunIssue(run.linkedIssue, run, routine.companyId, routine.projectId)}
issueLinkState={detailOrigin}
presentation="task"
metadata={(
<span className="flex items-center gap-2">
<StatusBadge status={run.status} />
<span className="font-mono text-xs text-muted-foreground">{formatRoutineTimestamp(run.triggeredAt)}</span>
</span>
)}
/>
) : (
<div key={run.id} className="flex min-w-0 items-center gap-2 rounded-lg px-2 py-2 text-sm">
<StatusBadge status={run.status} />
<span className="min-w-0 flex-1 truncate">{run.trigger?.label ?? "Routine run"}</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">{formatRoutineTimestamp(run.triggeredAt)}</span>
</div>
))}
</div>
)}
<Button variant="link" size="sm" className="w-fit px-0" asChild>
<Link to={routineActivityAuditHref(routine.id)}>View routine activity</Link>
</Button>
</section>
</div>
);
}
@@ -0,0 +1,39 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { SidebarNavExpandedProvider } from "./SidebarNavItem.production";
/**
* Fixed-width contextual pane that sits between the collapsed app rail and the
* main content on takeover routes (company settings, plugin `routeSidebar`).
*
* The takeover model (PAP-10695) no longer *replaces* the app `<Sidebar/>`:
* the host collapses it to its 64px rail (still peek-able) and renders the
* contextual sidebar here, yielding `[ rail 64px ][ secondary ~240px ][ content ]`.
*
* It is a dumb container — fixed `w-60`, full-height, non-shrinking, with a
* right border and its own vertical scroll — so callers just drop the
* contextual nav (or a plugin slot mount) inside as `children`.
*
* Because the pane is always 240px wide it forces the full-label presentation
* on any `SidebarNavItem` inside it, so the contextual nav stays readable even
* while the app sidebar is collapsed to its rail (PAP-10700).
*/
export function SecondarySidebar({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
data-secondary-sidebar=""
className={cn(
"h-full w-60 shrink-0 overflow-y-auto border-r border-border bg-background",
className,
)}
>
<SidebarNavExpandedProvider>{children}</SidebarNavExpandedProvider>
</div>
);
}
+4 -15
View File
@@ -3,20 +3,9 @@ import { cn } from "@/lib/utils";
import { SidebarNavExpandedProvider } from "./SidebarNavItem";
/**
* Fixed-width contextual pane that sits between the collapsed app rail and the
* main content on takeover routes (company settings, plugin `routeSidebar`).
*
* The takeover model (PAP-10695) no longer *replaces* the app `<Sidebar/>`:
* the host collapses it to its 64px rail (still peek-able) and renders the
* contextual sidebar here, yielding `[ rail 64px ][ secondary ~240px ][ content ]`.
*
* It is a dumb container — fixed `w-60`, full-height, non-shrinking, with a
* right border and its own vertical scroll — so callers just drop the
* contextual nav (or a plugin slot mount) inside as `children`.
*
* Because the pane is always 240px wide it forces the full-label presentation
* on any `SidebarNavItem` inside it, so the contextual nav stays readable even
* while the app sidebar is collapsed to its rail (PAP-10700).
* Content adapter for a contextual sidebar. Depending on the route, Layout
* either places it inside the primary SidebarShell or mounts it as an adjacent
* secondary rail.
*/
export function SecondarySidebar({
children,
@@ -29,7 +18,7 @@ export function SecondarySidebar({
<div
data-secondary-sidebar=""
className={cn(
"h-full w-60 shrink-0 overflow-y-auto border-r border-border bg-background",
"h-full w-full min-w-0 overflow-hidden",
className,
)}
>
+251
View File
@@ -0,0 +1,251 @@
import {
Inbox,
ListChecks,
CircleDot,
Target,
LayoutDashboard,
DollarSign,
History,
Search,
SquarePen,
Network,
Boxes,
Repeat,
Layers,
GitBranch,
Package,
Settings,
FolderOpen,
AppWindow,
MessagesSquare,
GanttChartSquare,
LayoutGrid,
} from "lucide-react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem.production";
import { SidebarAgents } from "./SidebarAgents.production";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarStarredProjects } from "./SidebarStarredProjects.production";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { attentionApi } from "../api/attention";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { attentionBadgeCount } from "../lib/attention";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { PluginSlotOutlet } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu.production";
export function Sidebar() {
const { openNewIssue } = useDialogActions();
// Every labeled section is collapsible (session-scoped, default open) —
// one policy across static nav groups and the data-driven sections.
const [workOpen, setWorkOpen] = useState(true);
const [companyOpen, setCompanyOpen] = useState(true);
const { selectedCompanyId, selectedCompany } = useCompany();
const { collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
const sharedLiveRuns = useSharedPollingQuery({
companyId: selectedCompanyId,
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId,
// Event-sourced via LiveUpdatesProvider (GitHub issue 9627) + reconnect reconcile — no
// interval poll needed. Polling here also re-armed React Query's timer on
// every live-event cache write, a major source of steady-state churn.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
queryKey: liveRunsQueryKey,
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: sharedLiveRuns.enabled,
refetchInterval: sharedLiveRuns.refetchInterval,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
const liveRunCount = liveRuns?.length ?? 0;
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
const showApps = experimentalSettings?.enableApps === true;
const showPipelines = experimentalSettings?.enablePipelines === true;
const showStatusCards = experimentalSettings?.enableStatusCards === true;
const goalsLinkPending = experimentalSettings === undefined;
const showGoalsLink = experimentalSettings?.enableGoalsSidebarLink === true;
// Decisions (attention home) is an experimental surface (PAP-13481): the nav
// item is hidden entirely until the flag is enabled (same no-flash pattern as
// showWorkspacesLink — it defaults hidden, so no placeholder is needed).
const showDecisions = experimentalSettings?.enableDecisions === true;
const { data: attentionFeed } = useQuery({
queryKey: queryKeys.attention(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && showDecisions,
refetchInterval: 60_000,
});
const attentionCount = attentionBadgeCount(attentionFeed);
const showCases = experimentalSettings?.enableCases === true;
// Streamlined left navigation (top-level Projects link + starred children) is
// now the standard product sidebar (PAP-12472). The former experimental
// opt-out was retired; classic per-project collapsible mode is no longer
// user-selectable. Kept as a constant so the classic branch below stays as a
// documented reference until it is fully removed. Routes are unaffected.
const streamlined = true;
// Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item
// is a new surface, hidden entirely while the flag is off (same no-flash
// pattern as showWorkspacesLink above).
const conferenceRoomChatEnabled = experimentalSettings?.enableConferenceRoomChat === true;
const pluginContext = {
companyId: selectedCompanyId,
companyPrefix: selectedCompany?.issuePrefix ?? null,
};
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
{/* Top bar: company name, aligned with top sections and borderless.
Search deliberately does NOT live here:
the header's spare width goes to the workspace/organization name,
which is the user's orientation anchor and truncates otherwise.
Search is the first nav item below instead. */}
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
<SidebarCompanyMenu />
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
<div className="flex flex-col gap-0.5">
{/* New Task button aligned with nav items */}
{(() => {
const newTaskButton = (
<button
onClick={() => openNewIssue()}
data-slot="icon-button"
aria-label={rail ? "New Task" : undefined}
className="flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
>
<SquarePen className="h-4 w-4 shrink-0" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "truncate"}>New Task</span>
</button>
);
return rail ? (
<Tooltip>
<TooltipTrigger asChild>{newTaskButton}</TooltipTrigger>
<TooltipContent side="right">New Task</TooltipContent>
</Tooltip>
) : (
newTaskButton
);
})()}
{/* Search moved out of the header so the workspace name keeps the
width; a nav row also keeps search reachable from the
collapsed rail, where the old header icon was dropped entirely.
Cmd/Ctrl+K remains the keyboard path (command palette). */}
<SidebarNavItem to="/search" label="Search" icon={Search} />
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
<SidebarNavItem
to="/inbox"
label="Inbox"
icon={Inbox}
badge={inboxBadge.inbox}
badgeLabel="unread"
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
{showDecisions ? (
<SidebarNavItem
to="/decisions"
label="Decisions"
icon={ListChecks}
badge={attentionCount}
badgeLabel="decisions"
/>
) : null}
{showStatusCards ? (
<SidebarNavItem to="/status" label="Status" icon={LayoutGrid} textBadge="beta" />
) : null}
{conferenceRoomChatEnabled ? (
<SidebarNavItem to="/board-chat" label="Conference Room" icon={MessagesSquare} />
) : null}
</div>
<SidebarSection label="Work" collapsible={{ open: workOpen, onOpenChange: setWorkOpen }}>
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
{showCases ? (
<SidebarNavItem to="/cases" label="Cases" icon={Layers} textBadge="beta" />
) : null}
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
{showPipelines ? (
<SidebarNavItem to="/pipelines" label="Pipelines" icon={GitBranch} />
) : null}
{showGoalsLink ? (
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
) : goalsLinkPending ? (
<div
data-testid="sidebar-goals-placeholder"
className="h-8 pointer-coarse:h-7"
aria-hidden="true"
/>
) : null}
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
{streamlined ? (
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<SidebarStarredProjects />
</>
) : null}
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-(length:--text-compact) font-medium"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
placementZones={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-(length:--text-compact) font-medium"
/>
</SidebarSection>
{/* Classic mode restores the per-project collapsible below Work. */}
{streamlined ? null : <SidebarProjects />}
<SidebarAgents streamlined={streamlined} />
<SidebarSection label="Company" collapsible={{ open: companyOpen, onOpenChange: setCompanyOpen }}>
<SidebarNavItem to="/org" label="Org" icon={Network} />
{showApps ? <SidebarNavItem to="/apps" label="Apps" icon={AppWindow} /> : null}
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
{/* One entry — /audit merged into the rich Activity feed (PAP-16302). */}
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
<PluginSlotOutlet
slotTypes={["sidebarPanel"]}
context={pluginContext}
className="flex flex-col gap-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
</nav>
</aside>
);
}
+98 -111
View File
@@ -100,18 +100,24 @@ vi.mock("./SidebarCompanyMenu", () => ({
vi.mock("./SidebarAgents", () => ({
SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)} />
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)}>
Active agents
</div>
),
}));
vi.mock("./SidebarProjects", () => ({
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
SidebarProjects: () => <div data-testid="sidebar-projects">Classic projects</div>,
}));
vi.mock("./SidebarStarredProjects", () => ({
SidebarStarredProjects: () => <div data-testid="sidebar-starred-projects" />,
}));
vi.mock("./SidebarRecentTasks", () => ({
SidebarRecentTasks: () => <div data-testid="sidebar-recent-tasks">Recent Tasks</div>,
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
@@ -150,6 +156,7 @@ describe("Sidebar", () => {
mockAttentionApi.list.mockResolvedValue({ items: [] });
mockSidebar.isMobile = false;
mockSidebar.collapsed = false;
mockSidebar.collapseLocked = false;
mockSidebar.peeking = false;
});
@@ -159,6 +166,21 @@ describe("Sidebar", () => {
vi.clearAllMocks();
});
it("keeps the default sidebar edge borderless", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const sidebar = container.querySelector("aside");
expect(sidebar?.classList).not.toContain("border-r");
expect(sidebar?.classList).not.toContain("border-border");
expect(sidebar?.classList).toContain("bg-border/50");
expect(sidebar?.classList).toContain("dark:bg-muted");
flushSync(() => {
root.unmount();
});
});
it("shows Search as a nav item instead of a header icon", async () => {
// The header's spare width goes to the workspace name (which otherwise
// truncates at ~78px), so search lives in the nav list — still
@@ -201,7 +223,7 @@ describe("Sidebar", () => {
});
});
it("streamlined (flag ON): keeps Task wording, top-level Projects link, no per-project collapsible", async () => {
it("uses the simplified work navigation with one Agents destination", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
@@ -217,34 +239,35 @@ describe("Sidebar", () => {
const projectsLink = [...container.querySelectorAll("nav a")].find((a) => a.textContent?.trim() === "Projects");
expect(projectsLink?.getAttribute("href")).toBe("/projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("true");
const agentLinks = [...container.querySelectorAll('a[href="/agents"]')];
expect(agentLinks).toHaveLength(1);
expect([...container.querySelectorAll('a[href="/activity"]')]).toHaveLength(1);
expect(navLabels).toContain("Audit");
expect(navLabels).not.toContain("Settings");
expect(navLabels).not.toContain("Activity");
expect(navLabels).not.toContain("Costs");
expect(container.querySelector('[data-testid="sidebar-recent-tasks"]')).not.toBeNull();
flushSync(() => {
root.unmount();
});
});
it("defaults to streamlined navigation while experimental settings are loading", async () => {
it("keeps the simplified navigation while experimental settings are loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = await renderSidebar();
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
expect(navLabels).toContain("Projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("true");
expect(navLabels).toContain("Agents");
expect(container.textContent).not.toContain("Organization");
flushSync(() => {
root.unmount();
});
});
it("streamlined is now standard: a stale enableStreamlinedLeftNavigation=false opt-out is ignored", async () => {
it("ignores the retired streamlined navigation opt-out", async () => {
// PAP-12472 retired the experimental opt-out; the streamlined sidebar is the
// only path, so an old `false` setting no longer restores classic mode.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
@@ -257,11 +280,44 @@ describe("Sidebar", () => {
expect(navLabels).toContain("Tasks");
// Top-level Projects link + starred children stay, per-project collapsible gone.
expect(navLabels).toContain("Projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(container.querySelector('[data-testid="sidebar-starred-projects"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("true");
expect(navLabels).toContain("Agents");
flushSync(() => {
root.unmount();
});
});
it("restores legacy agent and organization navigation when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableStreamlinedUi: false,
enableApps: true,
});
const root = await renderSidebar();
const labels = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim());
expect(container.querySelector('[data-testid="sidebar-recent-tasks"]')).toBeNull();
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined")).toBe("undefined");
expect(container.textContent).toContain("Organization");
expect(labels).toEqual(expect.arrayContaining(["Org", "Apps", "Timeline", "Costs", "Activity", "Settings"]));
expect(labels).not.toContain("Audit");
expect(labels).not.toContain("Projects");
expect(container.querySelector('a[href="/agents"]')).toBeNull();
expect(container.querySelector("aside")?.classList).toContain("border-r");
flushSync(() => {
root.unmount();
});
});
it("locks the legacy collapse control while a secondary sidebar forces the rail", async () => {
mockSidebar.collapseLocked = true;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: false });
const root = await renderSidebar();
expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull();
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
flushSync(() => {
root.unmount();
@@ -336,25 +392,25 @@ describe("Sidebar", () => {
});
});
it("shows Skills directly below Artifacts in Work", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
it("groups and orders the streamlined Work and Org navigation", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableApps: true,
});
const root = await renderSidebar();
const artifactsLink = [...container.querySelectorAll("a")].find(
(anchor) => anchor.textContent === "Artifacts",
);
expect(artifactsLink?.getAttribute("href")).toBe("/artifacts");
const navText = container.querySelector("nav")?.textContent ?? "";
expect(navText).toContain("Artifacts");
expect(navText).toContain("Skills");
expect(navText.indexOf("Artifacts")).toBeLessThan(navText.indexOf("Skills"));
const sections = [...container.querySelectorAll("nav > div")];
const workSection = sections.find((section) => section.textContent?.startsWith("Work"));
const companySection = sections.find((section) => section.textContent?.startsWith("Organization"));
expect(workSection?.textContent).toContain("Skills");
expect(companySection?.textContent).not.toContain("Skills");
const orgSection = sections.find((section) => section.textContent?.startsWith("Org"));
const labels = (section: Element | undefined) => [...(section?.querySelectorAll("a") ?? [])]
.map((anchor) => anchor.textContent?.trim());
expect(labels(workSection)).toEqual(["Tasks", "Projects", "Routines", "Artifacts"]);
expect(labels(orgSection)).toEqual(["Agents", "Skills", "Apps", "Audit"]);
expect(sections.indexOf(workSection!)).toBeLessThan(sections.indexOf(orgSection!));
expect(
workSection?.querySelector('a[href="/issues"] svg')?.classList.contains("lucide-circle-check"),
).toBe(true);
flushSync(() => {
root.unmount();
@@ -398,25 +454,22 @@ describe("Sidebar", () => {
expect(link?.getAttribute("href")).toBe("/goals");
const navText = container.querySelector("nav")?.textContent ?? "";
expect(navText.indexOf("Goals")).toBeLessThan(navText.indexOf("Artifacts"));
expect(navText.indexOf("Artifacts")).toBeLessThan(navText.indexOf("Goals"));
flushSync(() => {
root.unmount();
});
});
it("places Timeline in the Company section", async () => {
it("keeps Timeline out of the global navigation", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const sections = [...container.querySelectorAll("nav > div")];
const workSection = sections.find((section) => section.textContent?.startsWith("Work"));
const companySection = sections.find((section) => section.textContent?.startsWith("Organization"));
expect(workSection?.textContent).toContain("Projects");
expect(workSection?.textContent).not.toContain("Timeline");
expect(companySection?.textContent).toContain("Timeline");
const timelineLink = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Timeline");
expect(timelineLink?.getAttribute("href")).toBe("/timeline");
expect(container.querySelector('a[href="/timeline"]')).toBeNull();
flushSync(() => {
root.unmount();
@@ -479,7 +532,7 @@ describe("Sidebar", () => {
});
});
it("always shows Apps at the bottom of the Work section", async () => {
it("always shows Apps in the Org section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
const root = await renderSidebar();
@@ -487,10 +540,10 @@ describe("Sidebar", () => {
const link = links.find((anchor) => anchor.textContent === "Apps");
expect(link?.getAttribute("href")).toBe("/apps");
expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeGreaterThan(
links.findIndex((anchor) => anchor.textContent === "Projects"),
links.findIndex((anchor) => anchor.textContent === "Skills"),
);
expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeLessThan(
links.findIndex((anchor) => anchor.textContent === "Org"),
links.findIndex((anchor) => anchor.textContent === "Audit"),
);
flushSync(() => {
@@ -536,80 +589,14 @@ describe("Sidebar", () => {
});
});
it("header toggle collapses an expanded sidebar (aria-expanded reflects state)", async () => {
it("does not render a global navigation collapse affordance", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Collapse sidebar"]');
expect(toggle).not.toBeNull();
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
flushSync(() => {
toggle?.click();
});
expect(mockSidebar.toggleCollapsed).toHaveBeenCalledTimes(1);
flushSync(() => {
root.unmount();
});
});
it("hides the expand/collapse toggle while a secondary sidebar locks the rail", async () => {
// A secondary sidebar forces the rail; the user must not be able to expand
// the primary while it is shown (PAP-10694).
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
mockSidebar.collapseLocked = true;
const root = await renderSidebar();
expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull();
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
mockSidebar.collapseLocked = false;
flushSync(() => {
root.unmount();
});
});
it("keeps the collapsed rail top bar to just the company logo (no clipped toggle)", async () => {
// In the narrow rail the collapse toggle doesn't fit beside the logo and
// would overflow/clip, shoving the logo out of the icon column (PAP-10676), so
// it is dropped in the rail. Expansion stays reachable via hover-peek + Pin
// and Cmd/Ctrl+B. The toggle returns as soon as the panel is expanded or
// peeking (covered by the other top-bar tests).
mockSidebar.collapsed = true;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
// The company menu (company switcher / logo) is still present in the rail.
expect(container.querySelector('button[aria-label="Keep sidebar expanded"]')).toBeNull();
expect(container.textContent).toContain("Company menu");
// Search survives the rail as an icon-only nav item — the old
// header icon was dropped entirely here, leaving the rail with no visible
// search affordance.
const railSearchLink = [...container.querySelectorAll("nav a")]
.find((anchor) => anchor.getAttribute("href") === "/search");
expect(railSearchLink).toBeTruthy();
flushSync(() => {
root.unmount();
});
});
it("peek header shows a pin that promotes the peek to pinned-expanded", async () => {
mockSidebar.collapsed = true;
mockSidebar.peeking = true;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
// The collapse toggle is replaced by the pin while peeking.
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
const pin = container.querySelector<HTMLButtonElement>('button[aria-label="Keep sidebar expanded"]');
expect(pin).not.toBeNull();
flushSync(() => {
pin?.click();
});
expect(mockSidebar.setCollapsed).toHaveBeenCalledWith(false);
flushSync(() => {
root.unmount();
+64 -82
View File
@@ -1,7 +1,7 @@
import {
Inbox,
ListChecks,
CircleDot,
CircleCheck,
Target,
LayoutDashboard,
DollarSign,
@@ -16,13 +16,11 @@ import {
Package,
Settings,
FolderOpen,
PanelLeftClose,
PanelLeftOpen,
Pin,
AppWindow,
MessagesSquare,
GanttChartSquare,
LayoutGrid,
Users,
} from "lucide-react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
@@ -31,6 +29,7 @@ import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarAgents } from "./SidebarAgents";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarStarredProjects } from "./SidebarStarredProjects";
import { SidebarRecentTasks } from "./SidebarRecentTasks";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
@@ -40,8 +39,8 @@ import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { attentionBadgeCount } from "../lib/attention";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { PluginSlotOutlet } from "@/plugins/slots";
@@ -53,9 +52,10 @@ export function Sidebar() {
// Every labeled section is collapsible (session-scoped, default open) —
// one policy across static nav groups and the data-driven sections.
const [workOpen, setWorkOpen] = useState(true);
const [companyOpen, setCompanyOpen] = useState(true);
const [organizationOpen, setOrganizationOpen] = useState(true);
const { selectedCompanyId, selectedCompany } = useCompany();
const { isMobile, collapsed, collapseLocked, peeking, toggleCollapsed, setCollapsed } = useSidebar();
const { collapsed, peeking } = useSidebar();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const rail = collapsed && !peeking;
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: experimentalSettings } = useQuery({
@@ -82,6 +82,9 @@ export function Sidebar() {
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
const liveRunCount = liveRuns?.length ?? 0;
const liveIssueIds = new Set(
(liveRuns ?? []).flatMap((run) => run.issueId ? [run.issueId] : []),
);
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
const showPipelines = experimentalSettings?.enablePipelines === true;
const showStatusCards = experimentalSettings?.enableStatusCards === true;
@@ -99,12 +102,6 @@ export function Sidebar() {
});
const attentionCount = attentionBadgeCount(attentionFeed);
const showCases = experimentalSettings?.enableCases === true;
// Streamlined left navigation (top-level Projects link + starred children) is
// now the standard product sidebar (PAP-12472). The former experimental
// opt-out was retired; classic per-project collapsible mode is no longer
// user-selectable. Kept as a constant so the classic branch below stays as a
// documented reference until it is fully removed. Routes are unaffected.
const streamlined = true;
// Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item
// is a new surface, hidden entirely while the flag is off (same no-flash
// pattern as showWorkspacesLink above).
@@ -116,55 +113,21 @@ export function Sidebar() {
};
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
{/* Top bar: Company name (bold) + collapse control — aligned with top
sections (no visible border). Search deliberately does NOT live here:
<aside
className={cn(
"w-full h-full min-h-0 flex flex-col",
streamlinedUiEnabled
? "bg-border/50 dark:bg-muted"
: "border-r border-border bg-background",
)}
>
{/* Top bar: company name, aligned with top sections and borderless.
Search deliberately does NOT live here:
the header's spare width goes to the workspace/organization name,
which is the user's orientation anchor and truncates otherwise.
Search is the first nav item below instead. */}
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
<div className="flex h-(--sz-60px) shrink-0 items-center gap-1 px-3">
<SidebarCompanyMenu />
{/* In the collapsed rail the toggle doesn't fit beside the logo —
keeping it would overflow the 64px rail and squeeze the logo out of
alignment with the icon column below it. It returns as
soon as the panel is expanded (pinned) or peeking. Expansion in the
rail is still reachable via hover-peek + Pin and Cmd/Ctrl+B. */}
{!rail ? (
<>
{/* Desktop-only collapse/expand affordance. While peeking (hover flyout
over the collapsed rail) it becomes a Pin that promotes the peek to a
pinned-expanded sidebar; otherwise it toggles the pinned rail. Mobile
uses the off-canvas drawer, so this control is hidden there. It is
also hidden while a secondary sidebar forces the rail (collapseLocked):
the user cannot expand the primary while a secondary sidebar is shown. */}
{!isMobile && !collapseLocked ? (
peeking ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
aria-label="Keep sidebar expanded"
title="Keep sidebar expanded"
onClick={() => setCollapsed(false)}
>
<Pin className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
aria-expanded={!collapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
onClick={() => toggleCollapsed()}
>
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
)
) : null}
</>
) : null}
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
@@ -176,7 +139,10 @@ export function Sidebar() {
onClick={() => openNewIssue()}
data-slot="icon-button"
aria-label={rail ? "New Task" : undefined}
className="flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
className={cn(
"flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-foreground/80 hover:text-foreground transition-colors",
streamlinedUiEnabled ? "hover:bg-background" : "hover:bg-accent/50",
)}
>
<SquarePen className="h-4 w-4 shrink-0" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "truncate"}>New Task</span>
@@ -224,11 +190,18 @@ export function Sidebar() {
</div>
<SidebarSection label="Work" collapsible={{ open: workOpen, onOpenChange: setWorkOpen }}>
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
<SidebarNavItem to="/issues" label="Tasks" icon={CircleCheck} />
{streamlinedUiEnabled ? (
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<SidebarStarredProjects />
</>
) : null}
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
{showCases ? (
<SidebarNavItem to="/cases" label="Cases" icon={Layers} textBadge="beta" />
) : null}
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
{showPipelines ? (
<SidebarNavItem to="/pipelines" label="Pipelines" icon={GitBranch} />
) : null}
@@ -241,17 +214,9 @@ export function Sidebar() {
aria-hidden="true"
/>
) : null}
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
{streamlined ? (
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<SidebarStarredProjects />
</>
) : null}
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
@@ -265,22 +230,39 @@ export function Sidebar() {
className="flex flex-col gap-0.5"
itemClassName="text-(length:--text-compact) font-medium"
/>
<SidebarNavItem to="/apps" label="Apps" icon={AppWindow} />
</SidebarSection>
{/* Classic mode restores the per-project collapsible below Work. */}
{streamlined ? null : <SidebarProjects />}
{streamlinedUiEnabled ? (
<SidebarSection
label="Org"
collapsible={{ open: organizationOpen, onOpenChange: setOrganizationOpen }}
>
<SidebarNavItem to="/agents" label="Agents" icon={Users} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
<SidebarNavItem to="/apps" label="Apps" icon={AppWindow} />
<SidebarNavItem to="/activity" label="Audit" icon={History} />
</SidebarSection>
) : null}
<SidebarAgents streamlined={streamlined} />
<SidebarSection label="Organization" collapsible={{ open: companyOpen, onOpenChange: setCompanyOpen }}>
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
{/* One entry — /audit merged into the rich Activity feed (PAP-16302). */}
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
{streamlinedUiEnabled ? (
<SidebarRecentTasks companyId={selectedCompanyId} liveIssueIds={liveIssueIds} />
) : (
<>
<SidebarProjects />
<SidebarAgents />
<SidebarSection
label="Organization"
collapsible={{ open: organizationOpen, onOpenChange: setOrganizationOpen }}
>
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/apps" label="Apps" icon={AppWindow} />
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
</>
)}
<PluginSlotOutlet
slotTypes={["sidebarPanel"]}
@@ -0,0 +1,286 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
LogOut,
Megaphone,
type LucideIcon,
UserRound,
UserRoundPen,
} from "lucide-react";
import type { DeploymentMode, ServerGitInfo } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
import { useSignOut } from "@/hooks/useSignOut";
import { useSidebar } from "../context/SidebarContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { ThemeToggle } from "./ThemeToggle";
import { SidebarServerInfo } from "./SidebarServerInfo";
import { Badge } from "@/components/ui/badge";
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
const DOCS_URL = "https://docs.paperclip.ing/";
const FEEDBACK_URL = "https://paperclip.ing/feedback";
const SOURCE_REPOSITORY_URL = "https://github.com/paperclipai/paperclip";
const SOURCE_VERSION_RE = /\+\d+\.git\.([0-9a-f]{7,40})(?:\.dirty)?$/i;
interface SidebarAccountMenuProps {
deploymentMode?: DeploymentMode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
serverGit?: ServerGitInfo;
version?: string | null;
}
interface MenuActionProps {
label: string;
description: string;
icon: LucideIcon;
onClick?: () => void;
href?: string;
external?: boolean;
}
function deriveInitials(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) {
const candidates = [name, email?.split("@")[0], email, id];
for (const candidate of candidates) {
const slug = candidate
?.trim()
.toLowerCase()
.replace(/['"]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (slug) return slug;
}
return "me";
}
function sourceVersionSha(version: string): string | null {
const sourceVersion = version.match(SOURCE_VERSION_RE);
return sourceVersion?.[1] ?? null;
}
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
const className =
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
const content = (
<>
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs text-muted-foreground">{description}</span>
</span>
</>
);
if (href) {
if (external) {
return (
<a href={href} target="_blank" rel="noreferrer" className={className} onClick={onClick}>
{content}
</a>
);
}
return (
<Link to={href} className={className} onClick={onClick}>
{content}
</Link>
);
}
return (
<button type="button" className={className} onClick={onClick}>
{content}
</button>
);
}
export function SidebarAccountMenu({
deploymentMode,
open: controlledOpen,
onOpenChange,
serverGit,
version,
}: SidebarAccountMenuProps) {
const [internalOpen, setInternalOpen] = useState(false);
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
const displayName = session?.user.name?.trim() || "Board";
const secondaryLabel =
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
const initials = deriveInitials(displayName);
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
const sourceSha = version ? sourceVersionSha(version) : null;
const sourceFullSha =
sourceSha && serverGit?.available && serverGit.fullSha.toLowerCase().startsWith(sourceSha.toLowerCase())
? serverGit.fullSha
: sourceSha;
const sourceBranch = sourceSha && serverGit?.available ? serverGit.branchName : null;
function closeNavigationChrome() {
setOpen(false);
if (isMobile) setSidebarOpen(false);
}
function handleSignOut() {
signOutMutation.mutate();
}
return (
<div className="border-t border-r border-border bg-background px-3 py-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground"
aria-label="Open account menu"
>
<Avatar size="sm">
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
sideOffset={10}
className="w-(--sz-277px) max-w-(--sz-calc-24) overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
>
<div className="h-24 bg-(image:--gradient-extract-25)" />
<div className="-mt-8 px-4 pb-4">
<div className="flex items-start gap-3">
<div className="rounded-2xl border-4 border-popover bg-popover p-0.5 shadow-sm">
<Avatar size="lg">
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</div>
<div className="min-w-0 flex-1 pt-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
<Badge variant="ghost" className="bg-accent text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">
{accountBadge}
</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
{sourceSha && sourceFullSha ? (
<div className="mt-1 text-xs text-muted-foreground">
{sourceBranch ? (
<a
href={`${SOURCE_REPOSITORY_URL}/tree/${encodeURIComponent(sourceBranch)}`}
target="_blank"
rel="noreferrer"
className="block truncate transition-colors hover:text-foreground"
>
{sourceBranch}
</a>
) : null}
<p>
Paperclip{" "}
<a
href={`${SOURCE_REPOSITORY_URL}/commit/${sourceFullSha}`}
target="_blank"
rel="noreferrer"
className="transition-colors hover:text-foreground"
>
{sourceSha.slice(0, 7)}
</a>
</p>
</div>
) : version ? (
<p className="mt-1 text-xs text-muted-foreground">Paperclip v{version}</p>
) : null}
</div>
</div>
<div className="mt-4 space-y-1">
<MenuAction
label="View profile"
description="Open your activity, task, and usage ledger."
icon={UserRound}
href={profileHref}
onClick={closeNavigationChrome}
/>
<MenuAction
label="Edit profile"
description="Update your display name and avatar."
icon={UserRoundPen}
href={PROFILE_SETTINGS_PATH}
onClick={closeNavigationChrome}
/>
<MenuAction
label="Documentation"
description="Open Paperclip docs in a new tab."
icon={BookOpen}
href={DOCS_URL}
external
onClick={() => setOpen(false)}
/>
<MenuAction
label="Feedback"
description="Share feedback or report an issue."
icon={Megaphone}
href={FEEDBACK_URL}
external
onClick={() => setOpen(false)}
/>
<ThemeToggle variant="menu-action" onAfterToggle={() => setOpen(false)} />
{deploymentMode === "authenticated" ? (
<button
type="button"
className={cn(
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-destructive/10",
signOutMutation.isPending && "cursor-not-allowed opacity-60",
)}
onClick={handleSignOut}
disabled={signOutMutation.isPending}
>
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
<LogOut className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
</span>
<span className="block text-xs text-muted-foreground">
End this browser session.
</span>
</span>
</button>
) : null}
<SidebarServerInfo />
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
@@ -100,6 +100,29 @@ describe("SidebarAccountMenu", () => {
vi.clearAllMocks();
});
it("shares the nav background without separator borders", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarAccountMenu deploymentMode="local_trusted" />
</QueryClientProvider>,
);
});
await flushReact();
const accountSurface = container.firstElementChild;
expect(accountSurface?.className).toContain("bg-border/50");
expect(accountSurface?.className).toContain("dark:bg-muted");
expect(accountSurface?.className).not.toContain("border-t");
expect(accountSurface?.className).not.toContain("border-r");
expect(accountSurface?.className).not.toContain("border-border");
await act(async () => root.unmount());
});
it("keeps authenticated self-hosted sign-out on the local auth flow", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
@@ -135,6 +158,7 @@ describe("SidebarAccountMenu", () => {
await flushReact();
expect(document.body.textContent).toContain("Edit profile");
expect(document.body.textContent).toContain("Settings");
expect(document.body.textContent).not.toContain("Instance settings");
expect(document.body.textContent).toContain("Documentation");
expect(document.body.textContent).toContain("Feedback");
@@ -157,6 +181,7 @@ describe("SidebarAccountMenu", () => {
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
.toContain("w-(--sz-277px)");
expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull();
expect(document.body.querySelector('a[href="/company/settings"]')).not.toBeNull();
const signOutButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Sign out"),
+13 -2
View File
@@ -4,6 +4,7 @@ import {
BookOpen,
LogOut,
Megaphone,
Settings,
type LucideIcon,
UserRound,
UserRoundPen,
@@ -33,6 +34,8 @@ interface SidebarAccountMenuProps {
onOpenChange?: (open: boolean) => void;
serverGit?: ServerGitInfo;
version?: string | null;
/** Contextual navigation occupies a full sidebar even if the saved global nav mode is collapsed. */
forceExpanded?: boolean;
}
interface MenuActionProps {
@@ -116,10 +119,11 @@ export function SidebarAccountMenu({
onOpenChange,
serverGit,
version,
forceExpanded = false,
}: SidebarAccountMenuProps) {
const [internalOpen, setInternalOpen] = useState(false);
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const rail = collapsed && !peeking && !forceExpanded;
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const { data: session } = useQuery({
@@ -153,7 +157,7 @@ export function SidebarAccountMenu({
}
return (
<div className="border-t border-r border-border bg-background px-3 py-2">
<div className="bg-border/50 px-3 py-2 dark:bg-muted">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
@@ -222,6 +226,13 @@ export function SidebarAccountMenu({
</div>
<div className="mt-4 space-y-1">
<MenuAction
label="Settings"
description="Manage company and instance settings."
icon={Settings}
href="/company/settings"
onClick={closeNavigationChrome}
/>
<MenuAction
label="View profile"
description="Open your activity, task, and usage ledger."
@@ -0,0 +1,683 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
MoreHorizontal,
Loader2,
LogOut,
PauseCircle,
Pencil,
PlayCircle,
Plus,
Star,
Users,
AlertTriangle,
} from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { useToastActions } from "../context/ToastContext";
import { agentsApi } from "../api/agents";
import { builtInAgentsApi, type BuiltInAgentStatus } from "../api/builtInAgents";
import { BuiltInLifecycleChip } from "./BuiltInAgentBadges";
import { authApi } from "../api/auth";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder";
import {
isStarred,
resourceMembershipState,
starredResourceIds,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import {
AGENT_SORT_MODE_UPDATED_EVENT,
getAgentSortModeStorageKey,
readAgentSortMode,
type AgentSortModeUpdatedDetail,
type AgentSidebarSortMode,
writeAgentSortMode,
} from "../lib/agent-order";
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { SidebarNavItem } from "./SidebarNavItem.production";
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
import { StarToggle } from "./StarToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Agent } from "@paperclipai/shared";
/**
* When no agent is running, the sidebar falls back to showing at most this many
* recently-active agents plus a "See all agents" link (IA Phase 5).
*/
const RECENT_AGENT_LIMIT = 3;
const LIVE_AGENT_LINGER_MS = 120_000;
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
{ value: "top", label: "Top" },
{ value: "alphabetical", label: "Alphabetical" },
{ value: "recent", label: "Recent" },
];
function agentTimestamp(agent: Agent, field: "lastHeartbeatAt" | "updatedAt" | "createdAt"): number {
const raw = agent[field];
if (!raw) return 0;
const time = new Date(raw).getTime();
return Number.isFinite(time) ? time : 0;
}
function sortAgents(agents: Agent[], sortMode: AgentSidebarSortMode): Agent[] {
if (sortMode === "top") return agents;
const sorted = [...agents];
if (sortMode === "alphabetical") {
sorted.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
return sorted;
}
sorted.sort((left, right) => {
const heartbeatDiff = agentTimestamp(right, "lastHeartbeatAt") - agentTimestamp(left, "lastHeartbeatAt");
if (heartbeatDiff !== 0) return heartbeatDiff;
const updatedDiff = agentTimestamp(right, "updatedAt") - agentTimestamp(left, "updatedAt");
if (updatedDiff !== 0) return updatedDiff;
const createdDiff = agentTimestamp(right, "createdAt") - agentTimestamp(left, "createdAt");
return createdDiff !== 0
? createdDiff
: left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
});
return sorted;
}
// Sidebar star reveals with the agent row's own group, not the shared group.
const AGENT_STAR_ROW_REVEAL =
"opacity-0 transition-opacity group-hover/agent:opacity-100 group-focus-within/agent:opacity-100";
function SidebarAgentItem({
activeAgentId,
activeTab,
agent,
disabled,
isMobile,
leaving,
onLeaveAgent,
onPauseResume,
rail,
runCount,
setSidebarOpen,
builtInStatus,
starred = false,
onToggleStar,
starPending = false,
}: {
activeAgentId: string | null;
activeTab: string | null;
agent: Agent;
disabled: boolean;
isMobile: boolean;
leaving: boolean;
onLeaveAgent: (agent: Agent) => void;
onPauseResume: (agent: Agent, action: "pause" | "resume") => void;
rail: boolean;
runCount: number;
setSidebarOpen: (open: boolean) => void;
builtInStatus?: BuiltInAgentStatus;
starred?: boolean;
onToggleStar?: (agent: Agent, starred: boolean) => void;
starPending?: boolean;
}) {
const routeRef = agentRouteRef(agent);
const href = activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent);
const editHref = `${agentUrl(agent)}/configuration`;
const isActive = activeAgentId === routeRef;
const isPaused = agent.status === "paused";
const isBudgetPaused = isPaused && agent.pauseReason === "budget";
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
const pauseResumeLabel = isPaused ? "Resume agent" : "Pause agent";
const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused || (isPaused && hasInvalidOrgChain);
const pauseResumeDisabledLabel = disabled
? "Updating..."
: isBudgetPaused
? "Budget paused"
: isPaused && hasInvalidOrgChain
? "Invalid org chain"
: pauseResumeLabel;
const showBuiltInLifecycle = builtInStatus === "needs_setup" || builtInStatus === "pending_approval";
const trailingLabel = [
showBuiltInLifecycle ? `Built-in agent ${builtInStatus.replace(/_/g, " ")}` : null,
hasInvalidOrgChain ? "Invalid reporting chain" : null,
].filter(Boolean).join(", ") || undefined;
// C11 (DECISION-SHEET.md): the row itself is a SidebarNavItem, so agent rows
// share the nav-row chrome (type, active state, rail tooltip, live dot).
const navItem = (
<SidebarNavItem
to={href}
label={agent.name}
iconNode={<AgentIcon icon={agent.icon} className="shrink-0 h-4 w-4" />}
active={isActive}
liveCount={runCount}
labelClassName={showBuiltInLifecycle ? "min-w-(--sz-4_5rem) flex-initial" : undefined}
className={cn(
"min-w-0 flex-1",
// Reserve room for the hover ⋯ menu; starred rows widen it for the
// inline unstar star.
starred && !isMobile ? "pr-14" : "pr-8",
)}
trailing={
showBuiltInLifecycle || hasInvalidOrgChain ? (
<span className="ml-1 flex shrink-0 items-center gap-1">
{showBuiltInLifecycle ? <BuiltInLifecycleChip status={builtInStatus} compact /> : null}
{hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : null}
</span>
) : undefined
}
trailingLabel={trailingLabel}
liveAccessory={
agent.pauseReason === "budget" ? <BudgetSidebarMarker title="Agent paused by budget" /> : undefined
}
/>
);
// Rail: the star/menu overlays are hidden, so render the nav item bare (it
// supplies its own rail tooltip) and let it fill the column like every other
// rail row.
if (rail) return navItem;
return (
<div className="group/agent relative flex items-center">
{navItem}
{starred && !isMobile && onToggleStar ? (
// Desktop: quiet inline unstar, left of the ⋯ menu, revealed on hover/focus.
<span className="absolute right-10 top-1/2 -translate-y-1/2">
<StarToggle
size="row"
quiet
starred
pending={starPending}
resourceName={agent.name}
onToggle={() => onToggleStar(agent, false)}
revealClassName={AGENT_STAR_ROW_REVEAL}
/>
</span>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className={cn(
"absolute right-3 top-1/2 h-6 w-6 -translate-y-1/2 transition-opacity data-[state=open]:pointer-events-auto data-[state=open]:opacity-100",
isMobile
? "opacity-100"
: "pointer-events-none opacity-0 group-hover/agent:pointer-events-auto group-hover/agent:opacity-100 group-focus-within/agent:pointer-events-auto group-focus-within/agent:opacity-100",
)}
aria-label={`Open actions for ${agent.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{onToggleStar ? (
<>
<DropdownMenuItem
onClick={() => {
if (starPending) return;
onToggleStar(agent, !starred);
}}
disabled={starPending}
>
{starPending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<Star className={cn("size-4", starred && "fill-amber-500 text-amber-500")} />
)}
<span>{starred ? "Remove from starred" : "Star agent"}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem asChild>
<Link
to={editHref}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Pencil className="size-4" />
<span>Edit agent</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (pauseResumeDisabled) return;
onPauseResume(agent, isPaused ? "resume" : "pause");
}}
disabled={pauseResumeDisabled}
title={isBudgetPaused ? "Agent was paused by budget limits" : undefined}
>
{isPaused ? <PlayCircle className="size-4" /> : <PauseCircle className="size-4" />}
<span>{pauseResumeDisabledLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (leaving) return;
onLeaveAgent(agent);
}}
disabled={leaving}
>
{leaving ? <Loader2 className="size-4 motion-safe:animate-spin" /> : <LogOut className="size-4" />}
<span>{leaving ? "Leaving..." : "Leave agent"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) {
const [open, setOpen] = useState(true);
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
const [liveLingerVersion, setLiveLingerVersion] = useState(0);
const lastSeenLiveAtRef = useRef<Map<string, number>>(new Map());
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const { openNewAgent } = useDialogActions();
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const { pushToast } = useToastActions();
const location = useLocation();
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: !!selectedCompanyId,
});
const builtInAgentsEnabled = experimentalSettings?.enableBuiltInAgents === true;
const { data: builtInAgents } = useQuery({
queryKey: queryKeys.builtInAgents.list(selectedCompanyId!),
queryFn: () => builtInAgentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && builtInAgentsEnabled,
});
const builtInStatusByAgentId = useMemo(() => {
const map = new Map<string, BuiltInAgentStatus>();
if (!builtInAgentsEnabled) return map;
for (const entry of builtInAgents ?? []) {
if (entry.agentId) map.set(entry.agentId, entry.status);
}
return map;
}, [builtInAgents, builtInAgentsEnabled]);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
const sharedLiveRuns = useSharedPollingQuery({
companyId: selectedCompanyId,
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId,
// Event-sourced via LiveUpdatesProvider (issue 9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
queryKey: liveRunsQueryKey,
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: sharedLiveRuns.enabled,
refetchInterval: sharedLiveRuns.refetchInterval,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
const liveCountByAgent = useMemo(() => {
const counts = new Map<string, number>();
for (const run of liveRuns ?? []) {
counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1);
}
return counts;
}, [liveRuns]);
const liveAgentIds = useMemo(() => {
const ids = new Set<string>();
for (const [agentId, count] of liveCountByAgent) {
if (count > 0) ids.add(agentId);
}
return ids;
}, [liveCountByAgent]);
const visibleAgents = useMemo(() => {
const filtered = (agents ?? []).filter(
(a: Agent) =>
a.status !== "terminated" &&
(
!membershipsQuery.isSuccess ||
resourceMembershipState(membershipsQuery.data, "agent", a.id) !== "left"
)
);
return filtered;
}, [agents, membershipsQuery.data, membershipsQuery.isSuccess]);
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const sortModeStorageKey = useMemo(() => {
if (!selectedCompanyId) return null;
return getAgentSortModeStorageKey(selectedCompanyId, currentUserId);
}, [currentUserId, selectedCompanyId]);
const [sortMode, setSortMode] = useState<AgentSidebarSortMode>(() => {
if (!sortModeStorageKey) return "top";
return readAgentSortMode(sortModeStorageKey);
});
const { orderedAgents } = useAgentOrder({
agents: visibleAgents,
companyId: selectedCompanyId,
userId: currentUserId,
});
const sortedAgents = useMemo(
() => sortAgents(orderedAgents, sortMode),
[orderedAgents, sortMode],
);
const sortedAgentIdSet = useMemo(
() => new Set(sortedAgents.map((agent: Agent) => agent.id)),
[sortedAgents],
);
useEffect(() => {
const now = Date.now();
for (const agentId of liveAgentIds) {
lastSeenLiveAtRef.current.set(agentId, now);
}
for (const agentId of lastSeenLiveAtRef.current.keys()) {
if (!sortedAgentIdSet.has(agentId)) {
lastSeenLiveAtRef.current.delete(agentId);
}
}
}, [liveAgentIds, sortedAgentIdSet]);
// IA Phase 5 (streamlined): if any agent has a live run, show only those
// active agents. Agents that just stopped running linger briefly so clustered
// run boundaries do not make rows pop out and the section does not immediately
// swap to the recent fallback during short all-idle gaps. Otherwise fall back
// to up to RECENT_AGENT_LIMIT agents. Either way a "See all agents" link is
// shown so the full list is always reachable.
// Classic mode (PAP-89, flag OFF) restores the show-all behavior.
const runningAgents = useMemo(() => {
const nowForLiveLinger = Date.now();
const lastSeenLiveAtByAgent = lastSeenLiveAtRef.current;
return sortedAgents.filter((agent: Agent) => {
if ((liveCountByAgent.get(agent.id) ?? 0) > 0) return true;
const lastSeenLiveAt = lastSeenLiveAtByAgent.get(agent.id);
return lastSeenLiveAt !== undefined && nowForLiveLinger - lastSeenLiveAt <= LIVE_AGENT_LINGER_MS;
});
}, [liveCountByAgent, liveLingerVersion, sortedAgents]);
const hasActiveAgents = runningAgents.length > 0;
const displayedAgents = !streamlined
? sortedAgents
: hasActiveAgents
? runningAgents
: sortedAgents.slice(0, RECENT_AGENT_LIMIT);
// Always expose "See all agents" whenever the displayed list is a subset of all
// agents, so users never lose the entry point to the full list. In classic mode
// every agent is already shown, so the link is unnecessary.
const showSeeAllLink = streamlined && sortedAgents.length > 0;
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
const activeAgentId = agentMatch?.[1] ?? null;
const activeTab = agentMatch?.[2] ?? null;
useEffect(() => {
if (!sortModeStorageKey) {
setSortMode("top");
return;
}
setSortMode(readAgentSortMode(sortModeStorageKey));
}, [sortModeStorageKey]);
useEffect(() => {
if (!sortModeStorageKey) return;
const onStorage = (event: StorageEvent) => {
if (event.key !== sortModeStorageKey) return;
setSortMode(readAgentSortMode(sortModeStorageKey));
};
const onCustomEvent = (event: Event) => {
const detail = (event as CustomEvent<AgentSortModeUpdatedDetail>).detail;
if (!detail || detail.storageKey !== sortModeStorageKey) return;
setSortMode(detail.sortMode);
};
window.addEventListener("storage", onStorage);
window.addEventListener(AGENT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(AGENT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
};
}, [sortModeStorageKey]);
useEffect(() => {
if (!streamlined) return;
const now = Date.now();
let nextExpiryAt: number | null = null;
for (const agent of sortedAgents) {
if ((liveCountByAgent.get(agent.id) ?? 0) > 0) continue;
const lastSeenLiveAt = lastSeenLiveAtRef.current.get(agent.id);
if (lastSeenLiveAt === undefined) continue;
const expiresAt = lastSeenLiveAt + LIVE_AGENT_LINGER_MS;
if (expiresAt < now) continue;
nextExpiryAt = nextExpiryAt === null ? expiresAt : Math.min(nextExpiryAt, expiresAt);
}
if (nextExpiryAt === null) return;
const timeoutId = window.setTimeout(() => {
setLiveLingerVersion((version) => version + 1);
}, Math.max(0, nextExpiryAt - now + 1));
return () => {
window.clearTimeout(timeoutId);
};
}, [streamlined, sortedAgents, liveCountByAgent, liveLingerVersion]);
const persistSortMode = useCallback(
(value: string) => {
const nextSortMode: AgentSidebarSortMode =
value === "alphabetical" || value === "recent" ? value : "top";
setSortMode(nextSortMode);
if (sortModeStorageKey) {
writeAgentSortMode(sortModeStorageKey, nextSortMode);
}
},
[sortModeStorageKey],
);
const pauseResumeAgent = useMutation({
mutationFn: ({ agent, action }: { agent: Agent; action: "pause" | "resume" }) =>
action === "pause"
? agentsApi.pause(agent.id, selectedCompanyId ?? undefined)
: agentsApi.resume(agent.id, selectedCompanyId ?? undefined),
onMutate: ({ agent }) => {
setPendingAgentIds((current) => {
const next = new Set(current);
next.add(agent.id);
return next;
});
},
onSuccess: async (_agent, { agent, action }) => {
if (selectedCompanyId) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(selectedCompanyId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(selectedCompanyId) }),
]);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentRouteRef(agent)) }),
]);
pushToast({
title: action === "pause" ? "Agent paused" : "Agent resumed",
body: agent.name,
tone: "success",
});
},
onError: (error, { agent, action }) => {
pushToast({
title: action === "pause" ? "Could not pause agent" : "Could not resume agent",
body: error instanceof Error ? error.message : agent.name,
tone: "error",
});
},
onSettled: (_data, _error, { agent }) => {
setPendingAgentIds((current) => {
const next = new Set(current);
next.delete(agent.id);
return next;
});
},
});
const leaveAgent = useCallback(
(agent: Agent) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "left",
}),
[membershipMutation],
);
const agentLeaving = useCallback(
(agent: Agent) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id,
[membershipMutation.isPending, membershipMutation.variables],
);
const toggleStarAgent = useCallback(
(agent: Agent, starred: boolean) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred,
}),
[membershipMutation],
);
const agentStarPending = useCallback(
(agent: Agent) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id &&
membershipMutation.variables.starred !== undefined,
[membershipMutation.isPending, membershipMutation.variables],
);
// Starred agents pin to the top of the section (name order), and are deduped
// out of the active/recent subset so no agent appears twice.
const starredAgentIdSet = useMemo(
() => new Set(starredResourceIds(membershipsQuery.data, "agent")),
[membershipsQuery.data],
);
const starredAgents = useMemo(
() => sortAgents(visibleAgents.filter((agent: Agent) => starredAgentIdSet.has(agent.id)), "alphabetical"),
[visibleAgents, starredAgentIdSet],
);
const dedupedDisplayedAgents = useMemo(
() => displayedAgents.filter((agent: Agent) => !starredAgentIdSet.has(agent.id)),
[displayedAgents, starredAgentIdSet],
);
const renderAgentRow = (agent: Agent, isStarredRow: boolean) => (
<SidebarAgentItem
key={agent.id}
activeAgentId={activeAgentId}
activeTab={activeTab}
agent={agent}
disabled={pendingAgentIds.has(agent.id)}
isMobile={isMobile}
leaving={agentLeaving(agent)}
onLeaveAgent={leaveAgent}
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
rail={rail}
runCount={liveCountByAgent.get(agent.id) ?? 0}
setSidebarOpen={setSidebarOpen}
builtInStatus={builtInStatusByAgentId.get(agent.id)}
starred={isStarredRow || isStarred(membershipsQuery.data, "agent", agent.id)}
onToggleStar={toggleStarAgent}
starPending={agentStarPending(agent)}
/>
);
return (
<SidebarSection
label="Agents"
collapsible={{ open, onOpenChange: setOpen }}
headerAction={{
ariaLabel: "New agent",
icon: Plus,
onClick: openNewAgent,
}}
menu={{
ariaLabel: "Agents section actions",
actions: [
{ type: "item", label: "Browse agents", icon: Users, href: "/agents/all" },
{ type: "separator" },
],
radioLabel: "Agent sort",
radioChoices: AGENT_SORT_CHOICES,
radioValue: sortMode,
onRadioValueChange: persistSortMode,
}}
>
{starredAgents.map((agent: Agent) => renderAgentRow(agent, true))}
{dedupedDisplayedAgents.map((agent: Agent) => renderAgentRow(agent, false))}
{showSeeAllLink && (() => {
// Deliberately NOT a SidebarNavItem: this is a quiet muted affordance
// (plain Link) that must not adopt nav-row active-route highlighting.
const seeAllLink = (
<Link
to="/agents/all"
state={SIDEBAR_SCROLL_RESET_STATE}
aria-label={rail ? "See all agents" : undefined}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Users className="shrink-0 h-4 w-4" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>See all agents</span>
</Link>
);
return rail ? (
<Tooltip>
<TooltipTrigger asChild>{seeAllLink}</TooltipTrigger>
<TooltipContent side="right">See all agents</TooltipContent>
</Tooltip>
) : (
seeAllLink
);
})()}
</SidebarSection>
);
}
+1 -1
View File
@@ -663,7 +663,7 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
className="flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
>
<Users className="shrink-0 h-4 w-4" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>See all agents</span>
@@ -0,0 +1,513 @@
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Check,
ChevronsUpDown,
GripVertical,
LogOut,
Plus,
RefreshCw,
UserPlus,
} from "lucide-react";
import {
DndContext,
MouseSensor,
TouchSensor,
closestCenter,
type DragEndEvent,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Company } from "@paperclipai/shared";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { authApi } from "@/api/auth";
import { cloudApi, type CloudStackSummary } from "@/api/cloud";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCompany } from "@/context/CompanyContext";
import { useDialogActions } from "@/context/DialogContext";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
import { useSignOut } from "@/hooks/useSignOut";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { cloudStackCreateUrl, cloudStackEnterUrl } from "@/lib/cloudLinks";
import { queryKeys } from "@/lib/queryKeys";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext";
import { CompanyPatternIcon } from "./CompanyPatternIcon";
interface SidebarCompanyMenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const WORKSPACE_ICON_CLASS = "size-5 shrink-0 rounded-md text-(length:--text-micro)";
const WORKSPACE_BADGE_CLASS =
"shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-(length:--text-nano) text-muted-foreground";
function WorkspaceIcon({ company }: { company: Company }) {
return (
<CompanyPatternIcon
companyName={company.name}
logoUrl={company.logoUrl}
className={WORKSPACE_ICON_CLASS}
/>
);
}
/**
* Cloud stacks have no hot-linkable icon URL in the portfolio payload, so v1
* renders the same deterministic monogram treatment cloud's own portfolio page
* uses — seeded by the display name, never fetched.
*/
function StackIcon({ displayName }: { displayName: string }) {
return <CompanyPatternIcon companyName={displayName} className={WORKSPACE_ICON_CLASS} />;
}
/**
* The switcher trigger on a Cloud instance. A Cloud tenant holds exactly one
* company and the harness pushes the stack's uploaded workspace icon into
* that company's branding, so the company logo is the stack logo here.
* The stack rows keep the monogram treatment above.
*/
function CurrentStackIcon({
displayName,
company,
}: {
displayName: string;
company: Company | null;
}) {
return (
<CompanyPatternIcon
companyName={displayName}
logoUrl={company?.logoUrl}
className={WORKSPACE_ICON_CLASS}
/>
);
}
function CloudStackItem({
stack,
isSelected,
onSelect,
}: {
stack: CloudStackSummary;
isSelected: boolean;
onSelect: (stack: CloudStackSummary) => void;
}) {
return (
<DropdownMenuItem
onSelect={() => onSelect(stack)}
className={cn("min-w-0 gap-2 py-2", isSelected && "bg-accent text-accent-foreground")}
>
<StackIcon displayName={stack.displayName} />
<span className="min-w-0 flex-1 truncate" title={stack.displayName}>
{stack.displayName}
</span>
{/* Company badges are 3-4 character issue prefixes, but stack slugs are
user-chosen and can be long enough to truncate the name to a single
letter — the name is the primary identifier, so the badge yields. */}
<span className={cn(WORKSPACE_BADGE_CLASS, "max-w-24 truncate")} title={stack.stackSlug}>
{stack.stackSlug}
</span>
{isSelected ? <Check className="size-4 shrink-0 text-muted-foreground" /> : null}
</DropdownMenuItem>
);
}
function SortableCompanyItem({
company,
isEditing,
isSelected,
onSelect,
}: {
company: Company;
isEditing: boolean;
isSelected: boolean;
onSelect: (company: Company) => void;
}) {
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: company.id, disabled: !isEditing });
return (
<DropdownMenuItem
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
}}
onSelect={(event) => {
if (isEditing) {
event.preventDefault();
return;
}
onSelect(company);
}}
className={cn(
"min-w-0 gap-2 py-2",
isEditing && "cursor-grab",
isDragging && "opacity-80",
isSelected && "bg-accent text-accent-foreground",
)}
>
<WorkspaceIcon company={company} />
<span className="min-w-0 flex-1 truncate">{company.name}</span>
{isEditing ? (
<button
type="button"
ref={setActivatorNodeRef}
aria-label={`Reorder ${company.name}`}
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-(length:--rad-2) focus-visible:ring-ring"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
{...attributes}
{...listeners}
>
<GripVertical className="size-4" aria-hidden="true" />
</button>
) : (
<>
<span className={WORKSPACE_BADGE_CLASS}>{company.issuePrefix}</span>
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
</>
)}
</DropdownMenuItem>
);
}
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
const [internalOpen, setInternalOpen] = useState(false);
const [isEditingOrder, setIsEditingOrder] = useState(false);
const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } =
useCompany();
const { openOnboarding } = useDialogActions();
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const location = useLocation();
const navigate = useNavigate();
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 180, tolerance: 6 },
}),
);
const sidebarCompanies = useMemo(
() => companies.filter((company) => company.status !== "archived"),
[companies],
);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { orderedCompanies, persistOrder } = useCompanyOrder({
companies: sidebarCompanies,
userId: currentUserId,
});
// In Paperclip Cloud the switcher lists the signed-in user's stacks
// (organizations) instead of the instance's companies: a cloud instance holds
// exactly one company, and switching means leaving this tenant host entirely.
const cloud = useCloudInstance();
const isCloud = Boolean(cloud);
const cloudBaseUrl = cloud?.cloudBaseUrl ?? null;
const stacksQuery = useQuery({
queryKey: queryKeys.cloud.stacks,
queryFn: () => cloudApi.listStacks(),
enabled: isCloud,
staleTime: 30_000,
retry: false,
});
const stacks = stacksQuery.data?.stacks ?? [];
const currentStack = isCloud
? stacks.find((stack) => stack.isCurrent)
?? stacks.find((stack) => Boolean(cloud?.stackSlug) && stack.stackSlug === cloud?.stackSlug)
?? null
: null;
const createStackUrl = isCloud ? cloudStackCreateUrl(cloudBaseUrl) : null;
const switcherNoun = isCloud ? "organization" : "company";
// The one name the chrome shows for "where am I": the stack in cloud, the
// company when self-hosted.
const currentName = isCloud
? currentStack?.displayName ?? cloud?.stackDisplayName ?? cloud?.stackSlug ?? null
: selectedCompany?.name ?? null;
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) setIsEditingOrder(false);
setOpen(nextOpen);
}
function closeNavigationChrome() {
setOpen(false);
setIsEditingOrder(false);
if (isMobile) setSidebarOpen(false);
}
function selectCompany(company: Company) {
const pathPrefix = location.pathname.split("/")[1]?.toUpperCase();
const isCompanyRoute = sidebarCompanies.some((sidebarCompany) => (
sidebarCompany.issuePrefix.toUpperCase() === pathPrefix
));
const shouldLeaveCurrentRoute = company.id !== selectedCompany?.id
&& (location.pathname.startsWith("/instance/") || isCompanyRoute);
setSelectedCompanyId(company.id);
setOpen(false);
if (isMobile) setSidebarOpen(false);
if (shouldLeaveCurrentRoute) {
navigate(`/${company.issuePrefix}/dashboard`);
}
}
/**
* Switching stacks is a full top-level navigation, not client routing: the
* cloud entry-code handoff authenticates the user for the target stack and
* wakes it if it is asleep before landing on its own tenant host.
*/
function selectStack(stack: CloudStackSummary) {
setOpen(false);
if (isMobile) setSidebarOpen(false);
if (stack.stackSlug === currentStack?.stackSlug) return;
const target = cloudStackEnterUrl(cloudBaseUrl, stack.stackSlug);
if (!target) return;
navigateTopLevel(target);
}
function addCompany() {
setOpen(false);
if (isMobile) setSidebarOpen(false);
// Cloud creates organizations in the cloud app; the in-app company wizard
// is unreachable there (POST /companies is a 403 floor on managed stacks).
if (isCloud) {
if (createStackUrl) navigateTopLevel(createStackUrl);
return;
}
// Skip the front-door "how would you like to get started?" choice and land
// directly on "Name your organization" — this entry point is unambiguously
// "create a new company" (PAP-431).
openOnboarding({ initialStep: 1 });
}
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = orderedCompanies.map((company) => company.id);
const oldIndex = ids.indexOf(active.id as string);
const newIndex = ids.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
persistOrder(arrayMove(ids, oldIndex, newIndex));
},
[orderedCompanies, persistOrder],
);
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
// The nav icon column sits at nav px-3 + item mx-2 + item px-2.
// Match that inset with wrapper px-3 + trigger px-4. Override the
// Button's direct-SVG padding too so the expanded chevron cannot pull
// the avatar four pixels left of the nav icons.
// `min-w-0` on every link of the flex chain (button → label row → label)
// is what lets the name truncate: a flex item's default `min-width:auto`
// floors it at its content width, so without it a long name widens the
// trigger past the sidebar and pushes the chevron out of bounds. Company
// names were short in practice; cloud stack names are user-chosen.
className="h-9 min-w-0 flex-1 justify-start gap-2 px-4 text-left hover:bg-accent/50 hover:text-foreground has-[>svg]:px-4 dark:hover:bg-accent/50"
aria-label={
currentName
? `Open ${currentName} ${switcherNoun} switcher`
: `Open ${switcherNoun} switcher`
}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{isCloud
? currentName ? <CurrentStackIcon displayName={currentName} company={selectedCompany} /> : null
: selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
{/* The header has room for ~110px of name beside the collapse
control (~142px on mobile, which hides it) — search moved to
the nav to buy that width. A name that still
truncates stays hover-recoverable via title. */}
<span
className={cn(
"min-w-0 truncate text-sm font-bold text-foreground",
rail && SIDEBAR_RAIL_HIDDEN_LABEL,
)}
title={currentName ?? undefined}
>
{currentName ?? (isCloud ? "Select organization" : "Select company")}
</span>
</span>
{!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1">
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<DropdownMenuLabel className="p-0 text-(length:--text-micro) font-semibold uppercase text-muted-foreground">
{isCloud ? "Switch organization" : "Switch company"}
</DropdownMenuLabel>
{/* Stack order is owned by cloud's own portfolio in v1, so the
drag-to-reorder affordance stays self-hosted-only. */}
{isCloud ? null : (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsEditingOrder((current) => !current);
}}
className="rounded px-1.5 py-0.5 text-(length:--text-micro) font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{isEditingOrder ? "Done" : "Edit"}
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{isCloud ? (
<>
{stacks.map((stack) => (
<CloudStackItem
key={stack.stackSlug}
stack={stack}
isSelected={stack.stackSlug === currentStack?.stackSlug}
onSelect={selectStack}
/>
))}
{stacks.length === 0 ? (
<DropdownMenuItem disabled>
{stacksQuery.isLoading
? "Loading organizations..."
: stacksQuery.isError
? "Could not load organizations"
: "No organizations"}
</DropdownMenuItem>
) : null}
</>
) : (
<>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedCompanies.map((company) => company.id)}
strategy={verticalListSortingStrategy}
>
{orderedCompanies.map((company) => (
<SortableCompanyItem
key={company.id}
company={company}
isEditing={isEditingOrder}
isSelected={company.id === selectedCompany?.id}
onSelect={selectCompany}
/>
))}
</SortableContext>
</DndContext>
{orderedCompanies.length === 0 ? (
// "No companies" is a claim about the account. After a failed
// list request it is one we cannot make, and this menu is the
// only place the customer can act on it — say what happened and
// offer the way back.
companyListUnavailable ? (
<>
<DropdownMenuItem disabled>Couldn&apos;t load companies</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
// Keep the menu open so the result of the retry is visible.
event.preventDefault();
void retryCompanies();
}}
>
<RefreshCw className="h-4 w-4 mr-2" />
Try again
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
)
) : null}
</>
)}
</div>
<DropdownMenuSeparator />
{/* A cloud instance without a configured cloud origin has nowhere to
send the user, so the row (and its separator) drop out entirely. */}
{isCloud && !createStackUrl ? null : (
<>
<DropdownMenuItem
onClick={addCompany}
className="gap-2 py-2 text-muted-foreground"
disabled={isEditingOrder}
>
<Plus className="size-4" />
<span>Create new organization...</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild disabled={isEditingOrder}>
<Link
to="/company/settings/invites"
onClick={(event) => {
if (isEditingOrder) {
event.preventDefault();
return;
}
closeNavigationChrome();
}}
>
<UserPlus className="size-4" />
<span className="truncate">
{currentName ? `Invite people to ${currentName}` : "Invite people"}
</span>
</Link>
</DropdownMenuItem>
{session?.session ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => signOutMutation.mutate()}
disabled={isEditingOrder || signOutMutation.isPending}
>
<LogOut className="size-4" />
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -293,6 +293,11 @@ describe("SidebarCompanyMenu", () => {
const trigger = container.querySelector('button[aria-label="Open Acme Labs organization switcher"]');
expect(trigger).not.toBeNull();
expect(trigger?.classList).toContain("px-4");
expect(trigger?.classList).toContain("has-[>svg]:px-4");
expect(trigger?.classList).toContain("hover:bg-background");
expect(trigger?.classList).toContain("hover:text-foreground");
expect(trigger?.classList).toContain("dark:hover:bg-background");
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+5 -6
View File
@@ -343,17 +343,16 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
// `px-3` (not px-2) so the logo's left edge lines up with the nav icon
// column (nav px-3 + item px-3) and, crucially, stays put between states:
// the Button's default size adds `has-[>svg]:px-3`, so with the chevron
// svg present (expanded) it was already 12px but without it (rail) it fell
// back to 8px — a 4px horizontal jump on collapse (PAP-10676).
// The nav icon column sits at nav px-3 + item mx-2 + item px-2.
// Match that inset with wrapper px-3 + trigger px-4. Override the
// Button's direct-SVG padding too so the expanded chevron cannot pull
// the avatar four pixels left of the nav icons.
// `min-w-0` on every link of the flex chain (button → label row → label)
// is what lets the name truncate: a flex item's default `min-width:auto`
// floors it at its content width, so without it a long name widens the
// trigger past the sidebar and pushes the chevron out of bounds. Company
// names were short in practice; cloud stack names are user-chosen.
className="h-9 min-w-0 flex-1 justify-start gap-2 px-3 text-left"
className="h-9 min-w-0 flex-1 justify-start gap-2 px-4 text-left hover:bg-background hover:text-foreground has-[>svg]:px-4 dark:hover:bg-background"
aria-label={
currentName
? `Open ${currentName} ${switcherNoun} switcher`
@@ -0,0 +1,226 @@
import { createContext, useContext, type ReactNode } from "react";
import { NavLink } from "@/lib/router";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useSidebar } from "../context/SidebarContext";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
/**
* Forces the full-label (non-rail) presentation for any `SidebarNavItem`
* rendered beneath it, regardless of the global `useSidebar().collapsed` state.
*
* Takeover routes (PAP-10695) collapse the app `<Sidebar/>` to its 64px rail
* and render the contextual nav in a fixed 240px `SecondarySidebar`. That pane
* is always wide enough for labels, but its `SidebarNavItem` children still
* read the *global* `collapsed=true` and would otherwise render icon-only —
* leaving the settings nav unreadable (PAP-10700). Wrapping the pane in this
* provider decouples its items from the global rail collapse.
*/
const SidebarNavExpandedContext = createContext(false);
export function SidebarNavExpandedProvider({ children }: { children: ReactNode }) {
return (
<SidebarNavExpandedContext.Provider value={true}>
{children}
</SidebarNavExpandedContext.Provider>
);
}
export function useSidebarNavExpanded() {
return useContext(SidebarNavExpandedContext);
}
interface SidebarNavItemProps {
to: string;
label: string;
icon?: LucideIcon;
/**
* Pre-rendered icon element for rows whose icon isn't a plain Lucide
* component (e.g. the agent rows' `AgentIcon`). Takes precedence over `icon`;
* the caller owns its sizing/color classes.
*/
iconNode?: ReactNode;
end?: boolean;
className?: string;
labelClassName?: string;
badge?: number;
badgeTone?: "default" | "danger" | "warning";
/**
* Accessible noun for the numeric badge when collapsed to the rail, where the
* count is rendered as a dot (e.g. `badgeLabel="unread"` → "Inbox, 28 unread").
*/
badgeLabel?: string;
textBadge?: string;
textBadgeTone?: "default" | "amber";
alert?: boolean;
liveCount?: number;
/**
* Overrides NavLink's own route matching for rows whose active state is
* computed externally (agent rows match `/agents/:ref` across tab suffixes).
*/
active?: boolean;
/** Rendered after the label, before the right-aligned status cluster. */
trailing?: ReactNode;
/** Accessible text for `trailing` status content, surfaced in the collapsed rail (where `trailing` is hidden). */
trailingLabel?: string;
/** Rendered inside the right-aligned status cluster, before the live dot. */
liveAccessory?: ReactNode;
}
export function SidebarNavItem({
to,
label,
icon: Icon,
iconNode,
end,
className,
labelClassName,
badge,
badgeTone = "default",
badgeLabel,
textBadge,
textBadgeTone = "default",
alert = false,
liveCount,
active,
trailing,
trailingLabel,
liveAccessory,
}: SidebarNavItemProps) {
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
// A fixed-width contextual pane (SecondarySidebar) forces full labels even
// when the global app sidebar is collapsed to its rail (PAP-10700).
const forceExpanded = useSidebarNavExpanded();
// The icon-only rail presentation only applies when pinned collapsed and not
// peeking; a peek/expanded panel — or an expanded contextual pane — restores
// the full label + badge.
const rail = collapsed && !peeking && !forceExpanded;
const hasBadge = badge != null && badge > 0;
const hasLive = liveCount != null && liveCount > 0;
// Accessible text equivalent for the collapsed dot indicator. The visible
// label is `sr-only` in the rail, so the count must be surfaced here.
const railStatusText = hasLive
? `${liveCount} live`
: hasBadge
? `${badge}${badgeLabel ? ` ${badgeLabel}` : ""}`
: alert
? "attention needed"
: undefined;
const railAriaLabel = !rail || (!railStatusText && !trailingLabel)
? undefined
: `${label}${railStatusText ? `, ${railStatusText}` : ""}${trailingLabel ? `, ${trailingLabel}` : ""}`;
const link = (
<NavLink
to={to}
state={SIDEBAR_SCROLL_RESET_STATE}
end={end}
aria-label={railAriaLabel}
onClick={() => { if (isMobile) setSidebarOpen(false); }}
className={({ isActive }) =>
cn(
// One rhythm and one inset pill highlight: mx-2 floats the row off
// the sidebar edges, rounded-lg matches the card anchor, px-2 gives
// the icon breathing room inside the pill. Rows with hover menus
// (agents/projects) reserve extra right padding via className.
"flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium transition-colors",
(active ?? isActive)
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
className,
)
}
>
<span className="relative shrink-0">
{iconNode ?? (Icon ? <Icon className="h-4 w-4" /> : null)}
{alert && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-red-500 shadow-(--shadow-extract-12)" aria-hidden="true" />
)}
{/* Collapsed rail: numeric badge / live count collapse to a dot on the
icon. The icon markup is untouched so it stays pixel-aligned. */}
{rail && !alert && hasLive && (
<span className="absolute -right-0.5 -top-0.5 flex h-2 w-2" aria-hidden="true">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-600 dark:bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 shadow-(--shadow-extract-12)" />
</span>
)}
{rail && !alert && !hasLive && hasBadge && (
<span
className={cn(
"absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full shadow-(--shadow-extract-12)",
badgeTone === "danger"
? "bg-red-600"
: badgeTone === "warning"
? "bg-amber-500"
: "bg-primary",
)}
aria-hidden="true"
/>
)}
</span>
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : cn("min-w-0 flex-1 truncate", labelClassName)}>{label}</span>
{!rail && trailing}
{!rail && textBadge && (
<Badge variant="ghost"
className={cn(
"ml-auto px-1.5 text-(length:--text-nano) leading-none",
textBadgeTone === "amber"
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
: "bg-muted text-muted-foreground",
)}
>
{textBadge}
</Badge>
)}
{!rail && (hasLive || liveAccessory) && (
<span className="ml-auto flex items-center gap-1.5">
{liveAccessory}
{hasLive && (
<>
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-600 dark:bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-600 dark:bg-blue-400" />
</span>
<span className="text-(length:--text-micro) font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
</>
)}
</span>
)}
{!rail && hasBadge && (
<Badge variant="ghost"
className={cn(
"ml-auto px-1.5 leading-none",
badgeTone === "danger"
? "bg-red-600/90 text-red-50"
: badgeTone === "warning"
? "bg-amber-500/90 text-amber-50"
: "bg-primary text-primary-foreground",
)}
>
{badge}
</Badge>
)}
</NavLink>
);
if (!rail) return link;
// The tooltip wraps a plain block element rather than the NavLink directly:
// Radix `asChild` (Slot) drops React Router's *function* className, which would
// strip `flex` off the <a> and render it as a block — the in-flow label would
// then stack under the icon and the row would grow. Anchoring the tooltip to a
// wrapper keeps the <a> rendering normally (flex), so the row stays 1:1 with
// the expanded state and the icon never moves (PAP-10676).
return (
<Tooltip>
<TooltipTrigger asChild>
<div>{link}</div>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
}
+33 -3
View File
@@ -82,6 +82,36 @@ describe("SidebarNavItem", () => {
expect(link().getAttribute("aria-label")).toBeNull();
});
it("outlines icon alert badges with the streamlined sidebar surface", () => {
render(<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} alert />);
const alertBadge = container.querySelector('[data-slot="sidebar-icon-alert-badge"]');
expect(alertBadge).not.toBeNull();
expect(classTokens(alertBadge)).toContain("shadow-(--shadow-sidebar-icon-badge)");
expect(classTokens(alertBadge)).not.toContain("shadow-(--shadow-extract-12)");
});
it("omits the icon slot when an item has no icon", () => {
render(<SidebarNavItem to="/issues/one" label="Recent task" />);
expect(link().querySelector('[data-slot="sidebar-nav-icon"]')).toBeNull();
expect(link().firstElementChild?.textContent).toBe("Recent task");
});
it("uses the Paper nav surface for the active item", () => {
render(<SidebarNavItem to="/issues" label="Tasks" icon={Inbox} active />);
expect(classTokens(link())).toContain("bg-background");
expect(classTokens(link())).not.toContain("bg-accent");
});
it("uses the active nav surface for hover", () => {
render(<SidebarNavItem to="/issues" label="Tasks" icon={Inbox} />);
expect(classTokens(link())).toContain("hover:bg-background");
expect(classTokens(link())).not.toContain("hover:bg-accent/50");
});
it("clips the label (kept in flow for 1:1 row height) and collapses the badge to a dot in the rail", () => {
sidebarState.collapsed = true;
render(<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />);
@@ -135,9 +165,9 @@ describe("SidebarNavItem", () => {
});
it("forces the full label inside an expanded contextual pane even when globally collapsed", () => {
// The takeover model collapses the global sidebar (collapsed=true) while the
// 240px SecondarySidebar still needs readable labels (PAP-10700). The
// provider must override the global rail collapse.
// The takeover preserves the user's saved global collapse preference while
// replacing its contents. The provider keeps the contextual navigation
// readable without changing that preference.
sidebarState.collapsed = true;
render(
<SidebarNavExpandedProvider>
+43 -38
View File
@@ -11,12 +11,10 @@ import { Badge } from "@/components/ui/badge";
* Forces the full-label (non-rail) presentation for any `SidebarNavItem`
* rendered beneath it, regardless of the global `useSidebar().collapsed` state.
*
* Takeover routes (PAP-10695) collapse the app `<Sidebar/>` to its 64px rail
* and render the contextual nav in a fixed 240px `SecondarySidebar`. That pane
* is always wide enough for labels, but its `SidebarNavItem` children still
* read the *global* `collapsed=true` and would otherwise render icon-only —
* leaving the settings nav unreadable (PAP-10700). Wrapping the pane in this
* provider decouples its items from the global rail collapse.
* Contextual takeover routes replace the global navigation inside the same
* sidebar shell. The user's saved global collapse preference can still be
* `collapsed=true`, so this provider keeps contextual navigation readable
* without mutating that preference.
*/
const SidebarNavExpandedContext = createContext(false);
@@ -90,16 +88,17 @@ export function SidebarNavItem({
liveAccessory,
}: SidebarNavItemProps) {
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
// A fixed-width contextual pane (SecondarySidebar) forces full labels even
// when the global app sidebar is collapsed to its rail (PAP-10700).
// A contextual takeover forces full labels even when the saved global app
// sidebar preference is collapsed.
const forceExpanded = useSidebarNavExpanded();
// The icon-only rail presentation only applies when pinned collapsed and not
// peeking; a peek/expanded panel — or an expanded contextual pane — restores
// peeking; a peek/expanded panel — or a contextual takeover — restores
// the full label + badge.
const rail = collapsed && !peeking && !forceExpanded;
const hasBadge = badge != null && badge > 0;
const hasLive = liveCount != null && liveCount > 0;
const showIconSlot = Boolean(iconNode || Icon || alert || (rail && (hasLive || hasBadge)));
// Accessible text equivalent for the collapsed dot indicator. The visible
// label is `sr-only` in the rail, so the count must be surfaced here.
@@ -129,39 +128,45 @@ export function SidebarNavItem({
// (agents/projects) reserve extra right padding via className.
"flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium transition-colors",
(active ?? isActive)
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
? "bg-background text-foreground"
: "text-foreground/80 hover:bg-background hover:text-foreground",
className,
)
}
>
<span className="relative shrink-0">
{iconNode ?? (Icon ? <Icon className="h-4 w-4" /> : null)}
{alert && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-red-500 shadow-(--shadow-extract-12)" aria-hidden="true" />
)}
{/* Collapsed rail: numeric badge / live count collapse to a dot on the
icon. The icon markup is untouched so it stays pixel-aligned. */}
{rail && !alert && hasLive && (
<span className="absolute -right-0.5 -top-0.5 flex h-2 w-2" aria-hidden="true">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-600 dark:bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 shadow-(--shadow-extract-12)" />
</span>
)}
{rail && !alert && !hasLive && hasBadge && (
<span
className={cn(
"absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full shadow-(--shadow-extract-12)",
badgeTone === "danger"
? "bg-red-600"
: badgeTone === "warning"
? "bg-amber-500"
: "bg-primary",
)}
aria-hidden="true"
/>
)}
</span>
{showIconSlot ? (
<span data-slot="sidebar-nav-icon" className="relative shrink-0">
{iconNode ?? (Icon ? <Icon className="h-4 w-4" /> : null)}
{alert && (
<span
data-slot="sidebar-icon-alert-badge"
className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-red-500 shadow-(--shadow-sidebar-icon-badge)"
aria-hidden="true"
/>
)}
{/* Collapsed rail: numeric badge / live count collapse to a dot on the
icon. The icon markup is untouched so it stays pixel-aligned. */}
{rail && !alert && hasLive && (
<span className="absolute -right-0.5 -top-0.5 flex h-2 w-2" aria-hidden="true">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-600 dark:bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 shadow-(--shadow-sidebar-icon-badge)" />
</span>
)}
{rail && !alert && !hasLive && hasBadge && (
<span
className={cn(
"absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full shadow-(--shadow-sidebar-icon-badge)",
badgeTone === "danger"
? "bg-red-600"
: badgeTone === "warning"
? "bg-amber-500"
: "bg-primary",
)}
aria-hidden="true"
/>
)}
</span>
) : null}
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : cn("min-w-0 flex-1 truncate", labelClassName)}>{label}</span>
{!rail && trailing}
{!rail && textBadge && (
+2 -2
View File
@@ -140,8 +140,8 @@ function ProjectItem({
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pr-8 pointer-coarse:py-1 text-(length:--text-compact) font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
? "bg-background text-foreground"
: "text-foreground/80 hover:bg-background hover:text-foreground",
)}
>
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
@@ -0,0 +1,188 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SidebarRecentTasks } from "./SidebarRecentTasks";
import {
getRecentTasksStorageKey,
readRecentTasks,
recordRecentTask,
} from "@/lib/recent-tasks";
const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() }));
const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/api/auth", () => ({ authApi: mockAuthApi }));
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("@/lib/router", () => ({
NavLink: ({ children, to, className, ...props }: {
children: ReactNode;
to: string;
className?: string | ((state: { isActive: boolean }) => string);
}) => (
<a
href={to}
className={typeof className === "function" ? className({ isActive: false }) : className}
{...props}
>
{children}
</a>
),
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({
collapsed: false,
peeking: false,
isMobile: false,
setSidebarOpen: vi.fn(),
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("SidebarRecentTasks", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
window.localStorage.clear();
mockIssuesApi.get.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", name: "Board", email: "board@example.test", image: null },
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
async function render() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarRecentTasks companyId="company-1" liveIssueIds={new Set(["issue-1"])} />
</TooltipProvider>
</QueryClientProvider>,
);
});
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
});
return queryClient;
}
it("renders a compact empty state", async () => {
await render();
expect(container.textContent).toContain("Recent Tasks");
expect(container.textContent).toContain("Open or create a task");
});
it("renders refreshed task text and live state without shifting for a status icon", async () => {
recordRecentTask({
id: "issue-1",
companyId: "company-1",
title: "Initial title",
identifier: "PAP-1",
status: "todo",
updatedAt: new Date(1),
}, "user-1");
mockIssuesApi.get.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
title: "Refreshed title",
identifier: "PAP-1",
status: "in_progress",
hiddenAt: null,
updatedAt: new Date(2),
});
const queryClient = await render();
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
});
const link = container.querySelector('a[href="/issues/issue-1"]');
expect(mockIssuesApi.get).toHaveBeenCalledWith("issue-1");
expect(queryClient.getQueryData(["issues", "detail", "issue-1"])).toMatchObject({
title: "Refreshed title",
});
expect(link?.textContent).toContain("Refreshed title");
expect(link?.textContent).toContain("1 live");
expect(link?.querySelector('[aria-label="In Progress"]')).toBeNull();
expect(link?.querySelector('[data-slot="recent-task-icon-spacer"]')).toBeNull();
expect(link?.querySelector('[data-slot="sidebar-nav-icon"]')).toBeNull();
expect(link?.firstElementChild?.textContent).toBe("Refreshed title");
});
it("synchronizes recent tasks written by another tab", async () => {
mockIssuesApi.get.mockImplementation(() => new Promise(() => {}));
await render();
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
const entries = [{
id: "issue-2",
companyId: "company-1",
title: "Cross-tab task",
identifier: "PAP-2",
status: "todo" as const,
recordedAt: 2,
}];
await act(async () => {
window.localStorage.setItem(storageKey, JSON.stringify(entries));
window.dispatchEvent(new StorageEvent("storage", { key: storageKey }));
await Promise.resolve();
});
expect(container.querySelector('a[href="/issues/issue-2"]')?.textContent).toContain("Cross-tab task");
});
it("prunes tasks that become hidden", async () => {
recordRecentTask({
id: "issue-hidden",
companyId: "company-1",
title: "Hidden task",
identifier: "PAP-3",
status: "todo",
updatedAt: new Date(3),
}, "user-1");
mockIssuesApi.get.mockResolvedValue({
id: "issue-hidden",
companyId: "company-1",
title: "Hidden task",
identifier: "PAP-3",
status: "todo",
hiddenAt: new Date(),
updatedAt: new Date(4),
});
await render();
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
expect(container.querySelector('a[href="/issues/issue-hidden"]')).toBeNull();
expect(readRecentTasks(
getRecentTasksStorageKey("company-1", "user-1"),
"company-1",
)).toEqual([]);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { useQuery } from "@tanstack/react-query";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
import { useRecentTasks } from "@/hooks/useRecentTasks";
import { useSidebar } from "@/context/SidebarContext";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
export function SidebarRecentTasks({
companyId,
liveIssueIds,
}: {
companyId: string | null | undefined;
liveIssueIds: ReadonlySet<string>;
}) {
const { collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const { data: session, isPending } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
if (!companyId || isPending) return null;
const userId = session?.user?.id ?? session?.session?.userId ?? null;
return (
<RecentTasksList
key={`${companyId}:${userId ?? "__local_board__"}`}
companyId={companyId}
userId={userId}
liveIssueIds={liveIssueIds}
rail={rail}
/>
);
}
function RecentTasksList({
companyId,
userId,
liveIssueIds,
rail,
}: {
companyId: string;
userId: string | null;
liveIssueIds: ReadonlySet<string>;
rail: boolean;
}) {
const { entries } = useRecentTasks({ companyId, userId });
if (rail && entries.length === 0) return null;
return (
<SidebarSection label="Recent Tasks">
{entries.length === 0 ? (
<p className="mx-3 px-2 py-1 text-(length:--text-micro) leading-snug text-muted-foreground/70">
Open or create a task to keep it close at hand.
</p>
) : entries.map((entry) => (
<SidebarNavItem
key={entry.id}
to={`/issues/${entry.id}`}
label={entry.title}
liveCount={liveIssueIds.has(entry.id) ? 1 : undefined}
/>
))}
</SidebarSection>
);
}
@@ -0,0 +1,244 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FocusEventHandler,
type KeyboardEvent,
type MouseEventHandler,
type PointerEvent,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
const DEFAULT_SIDEBAR_WIDTH = 240;
const MIN_SIDEBAR_WIDTH = 208;
const MAX_SIDEBAR_WIDTH = 420;
const SIDEBAR_WIDTH_STEP = 16;
// Collapsed icon rail. Width is chosen so the icon is *centered* in the rail,
// which keeps the active/hover highlight symmetric around it (PAP-10676):
// the icon's left edge sits at nav px-3 (12) + item px-3 (12) = 24px, so a
// matching 24px trailing margin (12 item px-3 + 12 nav px-3) yields
// 24 + 16 (icon) + 24 = 64. The icon's left edge is unchanged from the expanded
// layout, so icons stay pixel-identical across states.
export const SIDEBAR_RAIL_WIDTH = 64;
function clampSidebarWidth(width: number) {
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width));
}
function readStoredSidebarWidth(storageKey: string) {
if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH;
try {
const stored = window.localStorage.getItem(storageKey);
if (!stored) return DEFAULT_SIDEBAR_WIDTH;
const parsed = Number.parseInt(stored, 10);
if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH;
return clampSidebarWidth(parsed);
} catch {
return DEFAULT_SIDEBAR_WIDTH;
}
}
function writeStoredSidebarWidth(storageKey: string, width: number) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, String(clampSidebarWidth(width)));
} catch {
// Storage can be unavailable in private contexts; resizing should still work.
}
}
type SidebarShellProps = {
children: ReactNode;
/** Whether the sidebar occupies space at all (mobile back-compat / hidden). */
open: boolean;
/** Pinned collapsed (rail) mode. Desktop-only; the caller gates this. */
collapsed?: boolean;
/** Ephemeral hover/focus peek. Only meaningful while collapsed. */
peeking?: boolean;
resizable?: boolean;
storageKey?: string;
className?: string;
/** Forwarded to the overlay panel so Layout can wire peek triggers. */
onPanelMouseEnter?: MouseEventHandler<HTMLDivElement>;
onPanelMouseLeave?: MouseEventHandler<HTMLDivElement>;
onPanelFocusCapture?: FocusEventHandler<HTMLDivElement>;
onPanelBlurCapture?: FocusEventHandler<HTMLDivElement>;
};
/**
* Layout shell for the desktop sidebar. Owns the persisted expanded width and
* renders two layers:
* - an in-flow spacer that reserves `reservedWidth` (rail when collapsed,
* expanded width otherwise) so content to the right only reflows on pin, and
* - an absolutely-positioned panel of `panelWidth` (rail at rest, expanded
* while peeking) that overlays content — with shadow/raised z-index — when it
* is wider than the reserved spacer.
*
* The icon-alignment guarantee comes for free: collapsing is a pure width
* change with `overflow-hidden` clipping the labels; item padding/icon markup
* are never touched, so the left-aligned icon stays pixel-identical.
*
* Opening/closing is intentionally instant (no width transition): the pin toggle
* and peek snap between rail and expanded widths so the content never appears to
* slide. Resizing the drag handle is likewise direct.
*/
export function SidebarShell({
children,
open,
collapsed = false,
peeking = false,
resizable = false,
storageKey = "paperclip.sidebar.width",
className,
onPanelMouseEnter,
onPanelMouseLeave,
onPanelFocusCapture,
onPanelBlurCapture,
}: SidebarShellProps) {
const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey));
const [isResizing, setIsResizing] = useState(false);
const widthRef = useRef(width);
const dragState = useRef<{ startX: number; startWidth: number } | null>(null);
useEffect(() => {
const storedWidth = readStoredSidebarWidth(storageKey);
widthRef.current = storedWidth;
setWidth(storedWidth);
}, [storageKey]);
// Unified width function (plan §5): one code path for every pinned state.
const expandedWidth = open ? width : 0;
const reservedWidth = !open ? 0 : collapsed ? SIDEBAR_RAIL_WIDTH : expandedWidth;
const panelWidth = !open ? 0 : collapsed && !peeking ? SIDEBAR_RAIL_WIDTH : expandedWidth;
const isOverlay = panelWidth > reservedWidth;
// The drag handle can only resize the expanded width, so it is disabled while
// collapsed (the rail width is a fixed constant, not user-resizable).
const canResize = resizable && open && !collapsed;
const reservedStyle = useMemo(
() => ({ width: `${reservedWidth}px` }),
[reservedWidth],
);
const panelStyle = useMemo(
() => ({ width: `${panelWidth}px` }),
[panelWidth],
);
const commitWidth = useCallback(
(nextWidth: number) => {
const clamped = clampSidebarWidth(nextWidth);
widthRef.current = clamped;
setWidth(clamped);
writeStoredSidebarWidth(storageKey, clamped);
},
[storageKey],
);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!canResize) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragState.current = { startX: event.clientX, startWidth: widthRef.current };
setIsResizing(true);
},
[canResize],
);
const handlePointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!dragState.current) return;
const nextWidth = dragState.current.startWidth + event.clientX - dragState.current.startX;
const clamped = clampSidebarWidth(nextWidth);
widthRef.current = clamped;
setWidth(clamped);
},
[],
);
const endResize = useCallback(() => {
if (!dragState.current) return;
dragState.current = null;
setIsResizing(false);
writeStoredSidebarWidth(storageKey, widthRef.current);
}, [storageKey]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (!canResize) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
commitWidth(width - SIDEBAR_WIDTH_STEP);
} else if (event.key === "ArrowRight") {
event.preventDefault();
commitWidth(width + SIDEBAR_WIDTH_STEP);
} else if (event.key === "Home") {
event.preventDefault();
commitWidth(MIN_SIDEBAR_WIDTH);
} else if (event.key === "End") {
event.preventDefault();
commitWidth(MAX_SIDEBAR_WIDTH);
}
},
[canResize, commitWidth, width],
);
return (
<div className={cn("relative h-full shrink-0", className)} style={reservedStyle}>
<div
className={cn(
"absolute inset-y-0 left-0 flex flex-col overflow-hidden",
// Open/close is instant (PAP-10676): no width transition so the rail and
// expanded states snap without any sliding motion.
// Overlay styling only while the panel is wider than its reserved
// spacer (i.e. peeking) so it floats above content without reflow.
isOverlay
? "z-30 border-r border-border bg-background shadow-lg"
: "z-0",
)}
style={panelStyle}
data-sidebar-overlay={isOverlay ? "" : undefined}
onMouseEnter={onPanelMouseEnter}
onMouseLeave={onPanelMouseLeave}
onFocusCapture={onPanelFocusCapture}
onBlurCapture={onPanelBlurCapture}
>
{children}
{canResize ? (
<div
role="separator"
aria-label="Resize sidebar"
aria-orientation="vertical"
aria-valuemin={MIN_SIDEBAR_WIDTH}
aria-valuemax={MAX_SIDEBAR_WIDTH}
aria-valuenow={width}
tabIndex={0}
className={cn(
"absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize touch-none outline-none",
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
"hover:before:bg-border focus-visible:before:bg-ring",
isResizing && "before:bg-ring",
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endResize}
onPointerCancel={endResize}
onLostPointerCapture={endResize}
onKeyDown={handleKeyDown}
/>
) : null}
</div>
</div>
);
}
+21
View File
@@ -88,6 +88,27 @@ describe("SidebarShell", () => {
expect(window.localStorage.getItem("test.sidebar.width")).toBe("320");
});
it("matches the properties panel resize-grip hover treatment", () => {
act(() => {
root.render(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
const separator = handle();
const indicator = separator?.firstElementChild as HTMLDivElement | null;
expect(separator?.parentElement).toBe(spacer());
expect(separator?.className).toContain("group");
expect(separator?.style.right).toBe("-4px");
expect(separator?.style.width).toBe("8px");
expect(indicator?.className).toContain("w-0.5");
expect(indicator?.className).toContain("group-hover:bg-ring");
expect(indicator?.className).toContain("group-focus-visible:bg-ring");
});
it("supports keyboard resizing and clamps to the configured bounds", () => {
act(() => {
root.render(
+29 -23
View File
@@ -215,30 +215,36 @@ export function SidebarShell({
onBlurCapture={onPanelBlurCapture}
>
{children}
{canResize ? (
<div
role="separator"
aria-label="Resize sidebar"
aria-orientation="vertical"
aria-valuemin={MIN_SIDEBAR_WIDTH}
aria-valuemax={MAX_SIDEBAR_WIDTH}
aria-valuenow={width}
tabIndex={0}
className={cn(
"absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize touch-none outline-none",
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
"hover:before:bg-border focus-visible:before:bg-ring",
isResizing && "before:bg-ring",
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endResize}
onPointerCancel={endResize}
onLostPointerCapture={endResize}
onKeyDown={handleKeyDown}
/>
) : null}
</div>
{canResize ? (
<div
role="separator"
aria-label="Resize sidebar"
aria-orientation="vertical"
aria-valuemin={MIN_SIDEBAR_WIDTH}
aria-valuemax={MAX_SIDEBAR_WIDTH}
aria-valuenow={width}
tabIndex={0}
data-dragging={isResizing ? "" : undefined}
className="group absolute inset-y-0 z-20 cursor-col-resize touch-none outline-none"
style={{ right: -4, width: 8 }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endResize}
onPointerCancel={endResize}
onLostPointerCapture={endResize}
onKeyDown={handleKeyDown}
>
<div
className={cn(
"mx-auto h-full w-0.5 transition-colors",
isResizing
? "bg-ring"
: "bg-transparent group-hover:bg-ring group-focus-visible:bg-ring",
)}
/>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,218 @@
import { useCallback, useMemo } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { Loader2, LogOut, MoreHorizontal, Star } from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { projectsApi } from "../api/projects";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import {
isStarred,
starredResourceIds,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { ProjectTile } from "./ProjectTile";
import { StarToggle } from "./StarToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Project } from "@paperclipai/shared";
// Sidebar star reveals with the row's own group, not the shared unnamed group.
const STAR_ROW_REVEAL =
"opacity-0 transition-opacity group-hover/starred-project:opacity-100 group-focus-within/starred-project:opacity-100";
/**
* Compact starred-project children rendered directly below the top-level
* `Projects` nav row in the streamlined sidebar. Starring/unstarring itself
* happens from browse/detail surfaces; here we only ever *remove* a star
* (plus the existing leave affordance). Archived projects are filtered out
* server-side, so a stale star never resurrects a hidden project.
*/
export function SidebarStarredProjects() {
const { selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const location = useLocation();
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
const starredProjects = useMemo(() => {
if (!membershipsQuery.isSuccess) return [];
const starredIds = new Set(starredResourceIds(membershipsQuery.data, "project"));
if (starredIds.size === 0) return [];
const byId = new Map((projects ?? []).map((project: Project) => [project.id, project]));
return Array.from(starredIds)
.map((id) => byId.get(id))
.filter((project): project is Project => !!project)
.sort((left, right) =>
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }),
);
}, [membershipsQuery.data, membershipsQuery.isSuccess, projects]);
const unstar = useCallback(
(project: Project) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: false,
}),
[membershipMutation],
);
const leave = useCallback(
(project: Project) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "left",
}),
[membershipMutation],
);
const pendingFor = useCallback(
(project: Project) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id,
[membershipMutation.isPending, membershipMutation.variables],
);
// Don't render anything until memberships load — no skeleton flash in the nav.
if (!membershipsQuery.isSuccess) return null;
// Empty starred groups should not add a placeholder row or extra sidebar spacing.
if (starredProjects.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-0.5" aria-label="Starred projects">
{starredProjects.map((project) => {
const routeRef = projectRouteRef(project);
const isActive = activeProjectRef === routeRef || activeProjectRef === project.id;
const pending = pendingFor(project);
const unstarPending = pending && membershipMutation.variables?.starred === false;
const leavePending = pending && membershipMutation.variables?.state === "left";
const starred = isStarred(membershipsQuery.data, "project", project.id);
const link = (
<NavLink
to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 pr-8 text-(length:--text-compact) font-medium transition-colors",
!rail && "pl-6",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
{!rail && project.pauseReason === "budget" ? (
<BudgetSidebarMarker title="Project paused by budget" />
) : null}
</NavLink>
);
return (
<div key={project.id} className="group/starred-project relative flex items-center">
{rail ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 flex-1">{link}</div>
</TooltipTrigger>
<TooltipContent side="right">{project.name}</TooltipContent>
</Tooltip>
) : (
link
)}
{!rail && !isMobile ? (
// Desktop: quiet inline unstar revealed on hover/focus.
<span className="absolute right-3 top-1/2 -translate-y-1/2">
<StarToggle
size="row"
quiet
starred={starred}
pending={unstarPending}
resourceName={project.name}
onToggle={() => unstar(project)}
revealClassName={STAR_ROW_REVEAL}
/>
</span>
) : null}
{!rail && isMobile ? (
// Touch: explicit ⋯ menu (no hover). Star action + separated Leave.
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="absolute right-3 top-1/2 h-6 w-6 -translate-y-1/2 opacity-100"
aria-label={`Open actions for ${project.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => {
if (pending) return;
unstar(project);
}}
disabled={pending}
>
{unstarPending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<Star className="size-4 fill-amber-500 text-amber-500" />
)}
<span>Remove from starred</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (pending) return;
leave(project);
}}
disabled={pending}
>
{leavePending ? (
<Loader2 className="size-4 motion-safe:animate-spin" />
) : (
<LogOut className="size-4" />
)}
<span>Leave project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
})}
</div>
);
}
+2 -2
View File
@@ -124,8 +124,8 @@ export function SidebarStarredProjects() {
"flex min-w-0 flex-1 items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 pr-8 text-(length:--text-compact) font-medium transition-colors",
!rail && "pl-6",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
? "bg-background text-foreground"
: "text-foreground/80 hover:bg-background hover:text-foreground",
)}
>
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SkillsContextualSidebar } from "./SkillsContextualSidebar";
import { contextualSidebarStyles } from "./contextual-sidebar-styles";
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockLocation = vi.hoisted(() => ({ pathname: "/PAP/skills", search: "" }));
vi.mock("@/lib/router", () => ({
useLocation: () => mockLocation,
useNavigate: () => vi.fn(),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompany: { name: "Paperclip", issuePrefix: "PAP" } }),
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }),
}));
vi.mock("./SidebarNavItem", () => ({
SidebarNavExpandedProvider: ({ children }: { children: React.ReactNode }) => children,
SidebarNavItem: (props: { to: string; label: string; active?: boolean }) => {
sidebarNavItemMock(props);
return <a href={props.to} aria-current={props.active ? "page" : undefined}>{props.label}</a>;
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("SkillsContextualSidebar", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockLocation.pathname = "/PAP/skills";
mockLocation.search = "";
});
afterEach(() => {
container.remove();
vi.clearAllMocks();
});
function render() {
const root = createRoot(container);
act(() => root.render(<SkillsContextualSidebar />));
return root;
}
it("keeps Installed, Discover, and authored My Skills visible together", () => {
const root = render();
expect(container.textContent).toContain("Installed");
expect(container.textContent).toContain("Discover");
expect(container.textContent).toContain("My Skills");
expect(container.textContent).toContain("Skills you create, edit, and test.");
expect(container.textContent).not.toContain("Paperclip");
expect(container.querySelector('[aria-label="Back from Skills"]')).toBeNull();
expect(container.querySelector('[aria-current="page"]')?.textContent).toBe("Installed");
act(() => root.unmount());
});
it("uses the shared contextual-navigation spacing and type contract", () => {
const root = render();
expect(container.querySelector('[data-slot="contextual-sidebar-nav"]')?.className).toBe(
contextualSidebarStyles.nav,
);
expect(container.querySelector('[data-slot="contextual-sidebar-section"]')?.className).toBe(
contextualSidebarStyles.section,
);
expect(container.querySelector('[data-slot="contextual-sidebar-section-label"]')?.className).toBe(
contextualSidebarStyles.sectionLabel,
);
expect(container.querySelector('[data-slot="contextual-sidebar-section-description"]')?.className).toBe(
contextualSidebarStyles.sectionDescription,
);
expect(
Array.from(container.querySelectorAll('[data-slot="contextual-sidebar-group"]')).every(
(group) => group.className === contextualSidebarStyles.group,
),
).toBe(true);
act(() => root.unmount());
});
it("activates Discover for old catalog links without changing the navigation", () => {
mockLocation.search = "?tab=bundled";
const root = render();
expect(container.querySelector('[aria-current="page"]')?.textContent).toBe("Discover");
expect(sidebarNavItemMock.mock.calls.map(([props]) => props.to)).toEqual([
"/skills",
"/skills?tab=discover",
"/skills/studio",
]);
act(() => root.unmount());
});
it("activates My Skills throughout Studio", () => {
mockLocation.pathname = "/PAP/skills/studio/skill-1";
mockLocation.search = "?tab=files";
const root = render();
expect(container.querySelector('[aria-current="page"]')?.textContent).toBe("My Skills");
act(() => root.unmount());
});
});
@@ -0,0 +1,79 @@
import { Compass, Library, PencilRuler } from "lucide-react";
import { useLocation } from "@/lib/router";
import {
resolveSkillsNavigationView,
SKILLS_NAVIGATION_HREFS,
} from "@/pages/skills/skills-navigation";
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
import { SidebarNavItem } from "./SidebarNavItem";
import { contextualSidebarStyles } from "./contextual-sidebar-styles";
export {
resolveSkillsDiscoveryView,
resolveSkillsNavigationView,
SKILLS_NAVIGATION_HREFS,
withSkillsDiscoveryView,
type SkillsNavigationView,
} from "@/pages/skills/skills-navigation";
export function SkillsContextualSidebar() {
const location = useLocation();
const activeView = resolveSkillsNavigationView(location.pathname, location.search);
return (
<ContextualSidebarFrame
surface="skills"
title="Skills"
icon={Library}
fallbackTo="/dashboard"
showHeader={false}
className="border-r border-border bg-background"
>
<nav
aria-label="Skills"
data-slot="contextual-sidebar-nav"
className={contextualSidebarStyles.nav}
>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
<SidebarNavItem
to={SKILLS_NAVIGATION_HREFS.installed}
label="Installed"
icon={Library}
active={activeView === "installed"}
end
/>
<SidebarNavItem
to={SKILLS_NAVIGATION_HREFS.discover}
label="Discover"
icon={Compass}
active={activeView === "discover"}
end
/>
</div>
<div data-slot="contextual-sidebar-section" className={contextualSidebarStyles.section}>
<div
data-slot="contextual-sidebar-section-label"
className={contextualSidebarStyles.sectionLabel}
>
Author
</div>
<p
data-slot="contextual-sidebar-section-description"
className={contextualSidebarStyles.sectionDescription}
>
Skills you create, edit, and test.
</p>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
<SidebarNavItem
to={SKILLS_NAVIGATION_HREFS.authored}
label="My Skills"
icon={PencilRuler}
active={activeView === "authored"}
/>
</div>
</div>
</nav>
</ContextualSidebarFrame>
);
}
+93 -49
View File
@@ -36,6 +36,7 @@ const DIRECT_ADAPTER_TYPES = [
"http",
"custom_plugin",
] as const;
const streamlinedState = vi.hoisted(() => ({ enabled: true }));
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: ({ runs }: { runs: unknown[] }) => {
@@ -61,6 +62,9 @@ vi.mock("@/context/SidebarContext", () => ({
vi.mock("@/hooks/useIssuePlanDocument", () => ({
useIssuePlanDocument: () => planState,
}));
vi.mock("@/hooks/useStreamlinedUiEnabled", () => ({
useStreamlinedUiEnabled: () => ({ enabled: streamlinedState.enabled, loaded: true }),
}));
vi.mock("@/lib/router", () => ({
Link: ({
to,
@@ -101,6 +105,7 @@ beforeEach(() => {
transcriptHookRuns.native.length = 0;
sidebarState.isMobile = false;
planState.data = null;
streamlinedState.enabled = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -269,6 +274,22 @@ function questionInteraction(
} as IssueThreadInteraction;
}
function createLongThreadComments() {
return Array.from({ length: 4 }, (_, index) => ({
id: `comment-${index + 1}`,
companyId: "company-1",
issueId: "issue-1",
authorType: "user" as const,
authorAgentId: null,
authorUserId: "user-1",
body: `Thread message ${index + 1}`,
presentation: null,
metadata: null,
createdAt: new Date(`2026-08-15T12:0${index}:00.000Z`),
updatedAt: new Date(`2026-08-15T12:0${index}:00.000Z`),
}));
}
describe("TaskChatThread draft pass-through", () => {
it("aligns the desktop thread header with the side-panel tab row", () => {
render(
@@ -325,6 +346,10 @@ describe("TaskChatThread draft pass-through", () => {
);
expect(dock?.classList).toContain("px-4");
expect(dock?.classList).not.toContain("px-1");
expect(dock?.classList).toContain("-mt-(--radius-task-composer)");
expect(dock?.classList).not.toContain("pt-1");
expect(dock?.classList).not.toContain("bg-background/80");
expect(dock?.classList).not.toContain("backdrop-blur");
});
it("forwards draftKey so the composer restores a task's saved draft", () => {
@@ -332,7 +357,7 @@ describe("TaskChatThread draft pass-through", () => {
render(
<TaskChatThread
comments={[]}
comments={createLongThreadComments()}
onAdd={async () => {}}
draftKey="task-chat-draft:issue-1"
/>,
@@ -1477,6 +1502,25 @@ describe("TaskChatThread composer alignment", () => {
expect(dock?.className).toContain("max-w-(--tc-shell-max-w)");
expect(dock?.className).not.toContain("md:w-(--pct-80)");
});
it("uses master's production composer and gutters when Streamlined UI is off", () => {
streamlinedState.enabled = false;
render(<TaskChatThread comments={[]} onAdd={async () => {}} />);
const dock = container.querySelector('[data-testid="task-chat-composer-dock"]');
const composer = container.querySelector(".paperclip-task-chat-composer");
const send = container.querySelector('[data-testid="task-chat-composer-send"]');
expect(dock?.classList).not.toContain("md:px-0");
expect(dock?.classList).not.toContain("md:pb-4");
expect(dock?.classList).toContain("bg-background/80");
expect(dock?.classList).toContain("pt-1");
expect(dock?.classList).not.toContain("-mt-(--radius-task-composer)");
expect(composer?.classList).not.toContain("border");
expect(composer?.classList).toContain("bg-card");
expect(send?.classList).toContain("rounded-md");
expect(send?.classList).not.toContain("rounded-full");
});
});
describe("TaskChatThread no-live-execution-path recovery", () => {
@@ -1551,7 +1595,7 @@ describe("TaskChatThread blocker links", () => {
render(
<TaskChatThread
comments={[]}
comments={createLongThreadComments()}
onAdd={async () => {}}
issueStatus="blocked"
blockedBy={[
@@ -1586,13 +1630,11 @@ describe("TaskChatThread blocker links", () => {
expect(notices).toHaveLength(2);
expect(notices[0]?.getAttribute("data-placement")).toBe("top");
expect(notices[1]?.getAttribute("data-placement")).toBe("bottom");
expect(notices[0]?.textContent).toContain("Blocked byPAP-600Waiting in review");
expect(notices[0]?.textContent).toContain("Root blockerPAP-777Actual work");
expect(notices[1]?.textContent).toContain("Still blocked byPAP-600Waiting in review");
expect(notices[1]?.textContent).toContain("Root blocker remainsPAP-777Actual work");
for (const notice of notices) {
expect(notice.textContent).toContain(
"Blocked byPAP-600Waiting in review",
);
expect(notice.textContent).toContain(
"Ultimately blocked byPAP-777Actual work",
);
expect(notice.querySelector('a[href="/issues/PAP-600"]')).not.toBeNull();
expect(notice.querySelector('a[href="/issues/PAP-777"]')).not.toBeNull();
}
@@ -1615,7 +1657,7 @@ describe("TaskChatThread blocker links", () => {
render(
<TaskChatThread
comments={[]}
comments={createLongThreadComments()}
onAdd={async () => {}}
issueStatus="blocked"
liveIssueIds={new Set(["direct-running", "terminal-running"])}
@@ -1672,24 +1714,20 @@ describe("TaskChatThread blocker links", () => {
expect(notices).toHaveLength(2);
expect(notices[0]?.getAttribute("data-placement")).toBe("top");
expect(notices[1]?.getAttribute("data-placement")).toBe("bottom");
expect(notices[0]?.textContent).toContain("Waiting on live work");
expect(notices[1]?.textContent).toContain("Still waiting on live work");
for (const notice of notices) {
expect(notice.textContent).toContain("Waiting on live work");
const orderedLinks = [
...notice.querySelectorAll(
'[data-testid="task-chat-live-work-step"] a',
),
].map((link) => link.textContent);
const orderedLinks = [...notice.querySelectorAll('[data-testid="task-chat-live-work-step"] a')]
.map((link) => link.textContent);
expect(orderedLinks).toEqual([
"PAP-17424Run the guarded cutover",
"PAP-17425Verify the live projection",
"PAP-17427Verify the completed projection",
]);
expect(notice.textContent).toContain(
"Now runningPAP-17426Restore live alias projection",
);
expect(
notice.querySelector('a[href="/issues/PAP-17426"]'),
).not.toBeNull();
expect(notice.textContent).toContain("Now runningPAP-17426Restore live alias projection");
expect(notice.querySelector('a[href="/issues/PAP-17426"]')).not.toBeNull();
expect(notice.querySelector('[data-testid="task-chat-live-work-step"] > a')).not.toBeNull();
expect(notice.querySelector('[data-testid="task-chat-live-work-step"] > span')).toBeNull();
}
expect(
container.querySelector('[data-testid="task-chat-blocker-links"]'),
@@ -1699,7 +1737,7 @@ describe("TaskChatThread blocker links", () => {
it("keeps the compact blocker rows when covered work is no longer live", () => {
render(
<TaskChatThread
comments={[]}
comments={createLongThreadComments()}
onAdd={async () => {}}
issueStatus="blocked"
liveIssueIds={new Set()}
@@ -1739,7 +1777,7 @@ describe("TaskChatThread blocker links", () => {
it("shows only the direct row when the blocker has no deeper unresolved leaf", () => {
render(
<TaskChatThread
comments={[]}
comments={createLongThreadComments()}
onAdd={async () => {}}
issueStatus="blocked"
blockedBy={[
@@ -1763,6 +1801,7 @@ describe("TaskChatThread blocker links", () => {
"Blocked byPAP-500Direct dependency",
);
expect(container.textContent).not.toContain("Ultimately blocked by");
expect(container.textContent).not.toContain("Root blocker");
});
it("keeps a server-selected intermediate blocker on its direct chain", () => {
@@ -1794,7 +1833,7 @@ describe("TaskChatThread blocker links", () => {
render(
<TaskChatThread
comments={[]}
comments={createLongThreadComments()}
onAdd={async () => {}}
issueStatus="blocked"
blockedBy={[
@@ -1825,34 +1864,16 @@ describe("TaskChatThread blocker links", () => {
/>,
);
for (const notice of container.querySelectorAll(
'[data-testid="task-chat-blocker-links"]',
)) {
expect(notice.textContent).toContain(
"Blocked byPAP-600Selected dependency",
);
expect(notice.textContent).toContain(
"Ultimately blocked byPAP-650Stalled intermediate review",
);
}
const notices = container.querySelectorAll('[data-testid="task-chat-blocker-links"]');
expect(notices[0]?.textContent).toContain("Blocked byPAP-600Selected dependency");
expect(notices[0]?.textContent).toContain("Root blockerPAP-650Stalled intermediate review");
expect(notices[1]?.textContent).toContain("Still blocked byPAP-600Selected dependency");
expect(notices[1]?.textContent).toContain("Root blocker remainsPAP-650Stalled intermediate review");
expect(container.textContent).not.toContain("Unrelated dependency");
expect(container.textContent).not.toContain("Deeper structural leaf");
});
it("auto-follows the new bottom blocker row when a pinned thread becomes blocked", () => {
const comment = {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorType: "user" as const,
authorAgentId: null,
authorUserId: "user-1",
body: "Waiting for the dependency.",
presentation: null,
metadata: null,
createdAt: new Date("2026-08-15T12:00:00.000Z"),
updatedAt: new Date("2026-08-15T12:00:00.000Z"),
};
const directBlocker = {
id: "direct-1",
identifier: "PAP-500",
@@ -1863,7 +1884,7 @@ describe("TaskChatThread blocker links", () => {
assigneeUserId: null,
};
const baseProps = {
comments: [comment],
comments: createLongThreadComments(),
onAdd: async () => {},
blockedBy: [directBlocker],
};
@@ -1879,10 +1900,33 @@ describe("TaskChatThread blocker links", () => {
expect(scroller.scrollTop).toBe(scroller.scrollHeight);
});
it("keeps a short blocked thread to one top affordance", () => {
render(
<TaskChatThread
comments={createLongThreadComments().slice(0, 1)}
onAdd={async () => {}}
issueStatus="blocked"
blockedBy={[{
id: "direct-1",
identifier: "PAP-500",
title: "Direct dependency",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
}]}
/>,
);
const notices = container.querySelectorAll('[data-testid="task-chat-blocker-links"]');
expect(notices).toHaveLength(1);
expect(notices[0]?.getAttribute("data-placement")).toBe("top");
});
it("does not show blocker rows outside the blocked state", () => {
render(
<TaskChatThread
comments={[]}
comments={createLongThreadComments()}
onAdd={async () => {}}
issueStatus="in_progress"
blockedBy={[
+37 -12
View File
@@ -80,6 +80,7 @@ import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
import { useSidebar } from "@/context/SidebarContext";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
@@ -98,6 +99,7 @@ import {
workProductHref,
} from "@/lib/issue-artifacts";
import { heartbeatsApi, type RuntimeRequestResolution } from "@/api/heartbeats";
import { TaskChatPresentationProvider } from "@/components/task-chat/presentation-mode";
function toMs(value: Date | string | null | undefined): number {
if (!value) return 0;
@@ -212,6 +214,16 @@ function isRunnerResponseComment(params: {
// well within this as soon as the settled turn/comment lands.
const SETTLING_TAIL_MAX_MS = 15_000;
const EMPTY_LIVE_ISSUE_IDS: ReadonlySet<string> = new Set<string>();
const LONG_THREAD_BLOCKER_REPEAT_COUNT = 4;
export function shouldRepeatTaskChatBlockers(items: TaskChatItem[]): boolean {
const conversationItems = items.filter((item) => (
item.kind === "brief"
|| item.kind === "interaction"
|| (item.kind === "message" && !item.interstitial)
));
return conversationItems.length >= LONG_THREAD_BLOCKER_REPEAT_COUNT;
}
function isNativePaperclipRunnerRun(
run:
@@ -432,6 +444,7 @@ function durableInputLabel(
* folded row. flag-OFF remains byte-for-byte IssueChatThread.
*/
export function TaskChatThread(props: TaskChatThreadProps) {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const {
comments,
interactions,
@@ -652,16 +665,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
</>
) : undefined;
const bottomBlockerLinks = liveWorkLinks ? (
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="bottom" />
) : blockerLinks ? (
<TaskChatBlockerLinks
directBlocker={blockerLinks.directBlocker}
ultimateBlocker={blockerLinks.ultimateBlocker}
placement="bottom"
/>
) : null;
const linkedRunMetaById = useMemo(() => {
const map = new Map<
string,
@@ -1867,6 +1870,18 @@ export function TaskChatThread(props: TaskChatThreadProps) {
? `${tailPlanItem.id}:${tailPlanItem.document.body.length}`
: "";
const threadContentKey = `${taskChatContentKey(items)}:${tailContentKey}:${tailPlanContentKey}:${blockerContentKey}`;
const repeatBlockersAtBottom = !streamlinedUiEnabled || shouldRepeatTaskChatBlockers(items);
const bottomBlockerLinks = repeatBlockersAtBottom
? liveWorkLinks ? (
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="bottom" />
) : blockerLinks ? (
<TaskChatBlockerLinks
directBlocker={blockerLinks.directBlocker}
ultimateBlocker={blockerLinks.ultimateBlocker}
placement="bottom"
/>
) : null
: null;
// Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish
// (once terminal), and the "called N tools" summary. Memoized on the
@@ -2311,6 +2326,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
useWindowAutoFollow(isMobile ? autoFollowContentKey : 0, isMobile);
return (
<TaskChatPresentationProvider mode={streamlinedUiEnabled ? "streamlined" : "production"}>
<div
className={cn(
"flex flex-col",
@@ -2328,6 +2344,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
className={cn(
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4",
isMobile ? "pt-4" : "pt-3",
streamlinedUiEnabled && "md:px-0",
)}
data-testid="task-chat-thread-header"
>
@@ -2338,7 +2355,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
{emptyMessage}
</div>
{bottomBlockerLinks ? (
<div className="mx-auto w-full max-w-(--tc-shell-max-w) px-4 pb-4">
<div className={cn(
"mx-auto w-full max-w-(--tc-shell-max-w) px-4 pb-4",
streamlinedUiEnabled && "md:px-0",
)}>
{bottomBlockerLinks}
</div>
) : null}
@@ -2446,7 +2466,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
isMobile
? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out"
: "bottom-0 z-10",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-4 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 px-4 pb-2",
streamlinedUiEnabled && "md:px-0 md:pb-4",
streamlinedUiEnabled && !isMobile
? "-mt-(--radius-task-composer)"
: "bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
)}
>
{composerAccessory}
@@ -2517,5 +2541,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
</div>
) : null}
</div>
</TaskChatPresentationProvider>
);
}
@@ -0,0 +1,15 @@
/**
* Shared visual contract for the Skills and Apps contextual navigation rails.
*
* Keep these surfaces on the same token-backed spacing and typography so their
* navigation hierarchy cannot drift as either area evolves.
*/
export const contextualSidebarStyles = {
nav: "flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-auto-hide px-3 py-2",
group: "flex flex-col gap-0.5",
section: "mt-4 border-t border-border pt-3",
sectionLabel:
"px-2 pb-1 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground",
sectionDescription:
"px-4 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70",
} as const;
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { createPortal } from "react-dom";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { issueStatusText } from "@/lib/status-colors";
import { copyTextToClipboard } from "@/lib/clipboard";
import { Link } from "@/lib/router";
@@ -21,6 +20,7 @@ import { instanceSettingsApi } from "../../api/instanceSettings";
import { issuesApi } from "../../api/issues";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
import { selectAgentArtifactAttachments } from "@/lib/issue-artifacts";
import { projectsApi } from "../../api/projects";
import { useCompany } from "../../context/CompanyContext";
@@ -55,6 +55,7 @@ import { StatusIcon } from "../StatusIcon";
import { PriorityIcon } from "../PriorityIcon";
import { SHOW_TASK_PRIORITY_UI } from "../../lib/ui-flags";
import { Identity } from "../Identity";
import { ProjectTile } from "../ProjectTile";
import { IssueReferencePill } from "../IssueReferencePill";
import { formatDate, formatDateTime, cn, projectUrl } from "../../lib/utils";
import type { IssueExternalObjectGroup } from "../../hooks/useIssueExternalObjects";
@@ -67,9 +68,15 @@ import { Textarea } from "@/components/ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { IssuePropertiesPlansTab } from "./IssuePropertiesPlansTab";
import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab";
import { User, ArrowUpRight, Plus, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2, ArchiveRestore, ChevronLeft } from "lucide-react";
import { User, ArrowUpRight, Plus, X, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2, ArchiveRestore, ChevronLeft } from "lucide-react";
import { AgentIcon } from "../AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../InlineEntitySelector";
import {
@@ -112,6 +119,20 @@ import { issueReviewPolicyBadge } from "../../lib/review-policy";
import { IssueCasesPanel } from "../IssueCasesPanel";
import { ExpandRelationListButton, RemovableIssueReferencePill } from "./relation-controls";
import { Badge } from "@/components/ui/badge";
import {
TaskDetailReferencesPanel,
TaskDetailSubtasksPanel,
type TaskDetailRelationItem,
} from "../task-detail/TaskDetailRelationsPanel";
function splitMiddleTruncation(value: string): { prefix: string; suffix: string } | null {
const splitAt = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
if (splitAt <= 0 || splitAt >= value.length - 1) return null;
return {
prefix: value.slice(0, splitAt + 1),
suffix: value.slice(splitAt + 1),
};
}
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: ComponentType<{ className?: string }> }) {
const [copied, setCopied] = useState(false);
@@ -125,18 +146,30 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: Compone
timerRef.current = setTimeout(() => setCopied(false), 1500);
} catch { /* noop */ }
}, [value]);
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const middle = streamlinedUiEnabled ? splitMiddleTruncation(value) : null;
return (
<div className="flex items-center gap-1.5 min-w-0 flex-1" title={value}>
<Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<button
type="button"
className="text-sm font-mono min-w-0 truncate text-left cursor-pointer hover:text-foreground transition-colors"
className={cn(
"cursor-pointer text-left font-mono text-sm transition-colors hover:text-foreground",
streamlinedUiEnabled ? "min-w-0 flex-1" : "min-w-0 truncate",
)}
onClick={handleCopy}
title={value}
aria-label={`Copy ${value} to clipboard`}
>
{value}
{!streamlinedUiEnabled ? value : middle ? (
<span className="flex min-w-0" data-middle-truncate="true">
<span className="min-w-0 truncate">{middle.prefix}</span>
<span className="max-w-1/2 shrink-0 truncate">{middle.suffix}</span>
</span>
) : (
<span className="block truncate">{value}</span>
)}
</button>
{copied && (
<span className={cn("inline-flex items-center gap-1 text-xs shrink-0", issueStatusText.done)} role="status">
@@ -151,6 +184,7 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: Compone
interface IssuePropertiesProps {
issue: Issue;
childIssues?: Issue[];
issueLinkState?: unknown;
onAddSubIssue?: () => void;
onUpdate: (data: Record<string, unknown>) => void;
inline?: boolean;
@@ -176,10 +210,22 @@ export interface IssuePropertiesDocumentDeepLink {
const ISSUE_BLOCKER_SEARCH_LIMIT = 50;
const ISSUE_PROPERTY_RELATION_PREVIEW_COUNT = 5;
const STREAMLINED_PANE_TAB_CLASS =
"task-detail-pane-tab h-7 rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
type IssuePaneTab = "properties" | "subtasks" | "references" | "plans" | "artifacts";
interface IssuePaneTabDescriptor {
value: IssuePaneTab;
label: string;
count?: number;
closable: boolean;
}
export function IssueProperties({
issue,
childIssues = [],
issueLinkState,
onAddSubIssue,
onUpdate,
inline,
@@ -208,12 +254,17 @@ export function IssueProperties({
// the folder the policy exists to hide.
const hideHostPaths =
experimentalSettings === undefined || experimentalSettings.enableManagedSandboxOnly === true;
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
// Classic Task Interface: gate the Properties | Plans | Artifacts tab shell.
// Flag ON renders the legacy stacked sections verbatim (no Tabs wrapper);
// flag OFF — including while settings load — renders the chat-style tab
// shell. This pane is always task-scoped, so the flag alone is a sufficient
// gate.
// Classic Task Interface alone controls the production tabbed-vs-stacked
// boundary. Streamlined UI layers new relationship tabs and visual treatment
// onto master's tabbed task-chat pane without changing that boundary.
const taskChatShellEnabled = experimentalSettings?.enableClassicTaskInterface !== true;
const streamlinedPropertiesEnabled = streamlinedUiEnabled && taskChatShellEnabled;
// When hosted by the resizable PropertiesPanel, the tab strip portals into
// the pane's header bar (left of the window controls). The slot only exists
// once the panel has committed, hence the effect; inline hosts (mobile sheet)
@@ -264,7 +315,8 @@ export function IssueProperties({
(paneTabWorkProducts?.length ?? 0) > 0
|| paneTabStandaloneDocuments.length > 0
|| selectAgentArtifactAttachments(paneTabAttachments, paneTabWorkProducts).length > 0;
const [paneTab, setPaneTab] = useState("properties");
const [paneTab, setPaneTab] = useState<IssuePaneTab>("properties");
const [closedPaneTabs, setClosedPaneTabs] = useState<Set<IssuePaneTab>>(() => new Set());
// Once a plan document exists, surface it: switch the pane to the Plan tab so
// the write-up is exposed alongside the plan-approval card, instead of leaving
// the user on Properties. Only auto-switch until the user picks a tab by hand —
@@ -272,8 +324,11 @@ export function IssueProperties({
const paneTabUserChosenRef = useRef(false);
const handlePaneTabChange = useCallback((value: string) => {
paneTabUserChosenRef.current = true;
setPaneTab(value);
setPaneTab(value as IssuePaneTab);
}, []);
useEffect(() => {
setClosedPaneTabs(new Set());
}, [issue.id]);
useEffect(() => {
if (hasPlanTab && !paneTabUserChosenRef.current) {
setPaneTab("plans");
@@ -281,9 +336,16 @@ export function IssueProperties({
}, [hasPlanTab]);
useEffect(() => {
if (!documentDeepLink) return;
if (documentDeepLink.tab === "document") return;
const targetTab = documentDeepLink.tab;
if (targetTab === "document") return;
paneTabUserChosenRef.current = true;
setPaneTab(documentDeepLink.tab);
setPaneTab(targetTab);
setClosedPaneTabs((current) => {
if (!current.has(targetTab)) return current;
const next = new Set(current);
next.delete(targetTab);
return next;
});
}, [documentDeepLink]);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
@@ -305,6 +367,7 @@ export function IssueProperties({
const [blockedByExpanded, setBlockedByExpanded] = useState(false);
const [blockingExpanded, setBlockingExpanded] = useState(false);
const [subTasksExpanded, setSubTasksExpanded] = useState(false);
const [subtasksOpen, setSubtasksOpen] = useState(false);
const [relatedTasksExpanded, setRelatedTasksExpanded] = useState(false);
const [parentOpen, setParentOpen] = useState(false);
const [parentSearch, setParentSearch] = useState("");
@@ -606,6 +669,29 @@ export function IssueProperties({
.filter((identifier) => !excluded.has(identifier))
.map((identifier) => ({ id: identifier, identifier, title: identifier }));
}, [childIssues, issue.blockedBy, issue.blocks, issue.relatedWork?.outbound, referencedIssueIdentifiers]);
const panelReferencedTasks = useMemo<TaskDetailRelationItem[]>(() => {
const outbound = issue.relatedWork?.outbound.map(({ issue: referenced }) => ({
id: referenced.id,
identifier: referenced.identifier,
title: referenced.title,
status: referenced.status,
})) ?? [];
if (outbound.length > 0) return outbound;
return referencedIssueIdentifiers.map((identifier) => ({
id: identifier,
identifier,
title: identifier,
}));
}, [issue.relatedWork?.outbound, referencedIssueIdentifiers]);
const panelMentionedInTasks = useMemo<TaskDetailRelationItem[]>(
() => issue.relatedWork?.inbound.map(({ issue: referenced }) => ({
id: referenced.id,
identifier: referenced.identifier,
title: referenced.title,
status: referenced.status,
})) ?? [],
[issue.relatedWork?.inbound],
);
const projectLink = (id: string | null) => {
if (!id) return null;
const project = projects?.find((p) => p.id === id) ?? null;
@@ -1046,6 +1132,18 @@ export function IssueProperties({
) : (
<span className="text-sm text-muted-foreground">None</span>
);
const labelsExtra = !streamlinedPropertiesEnabled && (issue.labelIds ?? []).length > 0 ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onClick={() => setLabelsOpen(true)}
aria-label="Add label"
title="Add label"
>
<Plus className="h-3 w-3" />
Add label
</button>
) : undefined;
const watchdogContent = (
<div className="space-y-3 p-2">
<div className="space-y-1.5">
@@ -1481,14 +1579,14 @@ export function IssueProperties({
}, [issue.labelIds, issue.labels, labels]);
const labelsTrigger = selectedIssueLabels.length > 0 ? (
<div className="flex items-center gap-1 flex-wrap">
<div className="flex min-w-0 flex-col items-start gap-1">
{selectedIssueLabels.slice(0, 3).map((label) => (
<PropertyChip
key={label.id}
className="border-0"
style={{
borderColor: label.color,
backgroundColor: `${label.color}22`,
color: pickTextColorForPillBg(label.color, 0.13),
color: label.color,
}}
>
{label.name}
@@ -1503,19 +1601,6 @@ export function IssueProperties({
) : (
<span className="text-sm text-muted-foreground">None</span>
);
const labelsExtra = (issue.labelIds ?? []).length > 0 ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onClick={() => setLabelsOpen(true)}
aria-label="Add label"
title="Add label"
>
<Plus className="h-3 w-3" />
Add label
</button>
) : undefined;
const labelsContent = (
<>
<input
@@ -1832,9 +1917,10 @@ export function IssueProperties({
const projectTrigger = issue.projectId ? (
<>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "var(--project-seed)" }}
<ProjectTile
color={issueProject?.color ?? null}
icon={issueProject?.icon ?? null}
size="xs"
/>
<span className="text-sm truncate min-w-0" title={projectName(issue.projectId)}>{projectName(issue.projectId)}</span>
</>
@@ -1905,9 +1991,10 @@ export function IssueProperties({
}}
>
{option.kind === "project" ? (
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: option.color ?? "var(--project-seed)" }}
<ProjectTile
color={option.project.color ?? null}
icon={option.project.icon ?? null}
size="xs"
/>
) : null}
{option.name}
@@ -1932,6 +2019,38 @@ export function IssueProperties({
? blockingIssues
: blockingIssues.slice(0, ISSUE_PROPERTY_RELATION_PREVIEW_COUNT);
const hiddenBlockingIssueCount = blockingIssues.length - visibleBlockingIssues.length;
const blockedByTrigger = blockedByRelations.length > 0 ? (
<div className="flex min-w-0 flex-col items-start gap-1">
{blockedByRelations.slice(0, 2).map((relation) => (
<PropertyChip key={relation.id}>
{relation.identifier ?? relation.title}
</PropertyChip>
))}
{blockedByRelations.length > 2 ? (
<Badge variant="outline" className="border-border text-muted-foreground">
+{blockedByRelations.length - 2} more
</Badge>
) : null}
</div>
) : (
<span className="text-sm text-muted-foreground">None</span>
);
const subtasksTrigger = childIssues.length > 0 ? (
<div className="flex min-w-0 flex-col items-start gap-1">
{childIssues.slice(0, 2).map((child) => (
<PropertyChip key={child.id}>
{child.identifier ?? child.title}
</PropertyChip>
))}
{childIssues.length > 2 ? (
<Badge variant="outline" className="border-border text-muted-foreground">
+{childIssues.length - 2} more
</Badge>
) : null}
</div>
) : (
<span className="text-sm text-muted-foreground">None</span>
);
const visibleRelatedTasks = relatedTasksExpanded
? relatedTasks
: relatedTasks.slice(0, ISSUE_PROPERTY_RELATION_PREVIEW_COUNT);
@@ -2048,9 +2167,20 @@ export function IssueProperties({
</>
);
const blockerSearchActive = normalizedBlockedBySearch.length > 0;
const blockerSourceIssues = blockerSearchActive ? searchedBlockedByIssues : allIssues;
const blockerOptions = (blockerSourceIssues ?? [])
.filter((candidate) => candidate.id !== issue.id);
const blockerSourceIssues = blockerSearchActive
? searchedBlockedByIssues
: streamlinedPropertiesEnabled
? [...(issue.blockedBy ?? []), ...(allIssues ?? [])]
: allIssues;
const blockerOptions = streamlinedPropertiesEnabled
? Array.from(
new Map(
(blockerSourceIssues ?? [])
.filter((candidate) => candidate.id !== issue.id)
.map((candidate) => [candidate.id, candidate]),
).values(),
)
: (blockerSourceIssues ?? []).filter((candidate) => candidate.id !== issue.id);
if (!blockerSearchActive) {
blockerOptions.sort((a, b) => {
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
@@ -2073,7 +2203,6 @@ export function IssueProperties({
const removeBlockedBy = (blockedByIssueId: string) => {
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
};
const blockedByContent = (
<>
<input
@@ -2136,10 +2265,52 @@ export function IssueProperties({
Add blocker
</button>
);
const subtasksContent = (
<>
<div className="max-h-48 overflow-y-auto overscroll-contain">
{childIssues.length > 0 ? childIssues.map((child) => (
<Link
key={child.id}
to={`/issues/${child.identifier ?? child.id}`}
state={issueLinkState}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50"
onClick={() => setSubtasksOpen(false)}
>
<StatusIcon status={child.status} className="h-3 w-3" />
<span className="min-w-0 truncate">
{child.identifier ? `${child.identifier} ` : ""}
{child.title}
</span>
</Link>
)) : (
<div className="px-2 py-2 text-xs text-muted-foreground">No subtasks yet.</div>
)}
</div>
{onAddSubIssue ? (
<div className="mt-2 border-t border-border pt-2">
<button
type="button"
className="flex w-full items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent/50"
onClick={() => {
setSubtasksOpen(false);
onAddSubIssue();
}}
>
<Plus className="h-3 w-3" />
Add subtask
</button>
</div>
) : null}
</>
);
const propertiesBody = (
<div>
<PropertySection title="Triage" first>
<div className={cn(streamlinedPropertiesEnabled && "task-detail-properties pl-4")}>
<PropertySection
title={streamlinedPropertiesEnabled ? "Work" : "Triage"}
first
streamlined={streamlinedPropertiesEnabled}
>
<PropertyRow label="Status">
<StatusIcon
status={issue.status}
@@ -2161,19 +2332,6 @@ export function IssueProperties({
</PropertyRow>
)}
<PropertyPicker
inline={inline}
label="Labels"
open={labelsOpen}
onOpenChange={(open) => { setLabelsOpen(open); if (!open) setLabelSearch(""); }}
triggerContent={labelsTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-64"
extra={labelsExtra}
>
{labelsContent}
</PropertyPicker>
<PropertyPicker
inline={inline}
label="Assignee"
@@ -2228,9 +2386,23 @@ export function IssueProperties({
>
{projectContent}
</PropertyPicker>
<PropertyPicker
inline={inline}
label="Labels"
open={labelsOpen}
onOpenChange={(open) => { setLabelsOpen(open); if (!open) setLabelSearch(""); }}
triggerContent={labelsTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-64"
extra={labelsExtra}
stacked
>
{labelsContent}
</PropertyPicker>
</PropertySection>
<PropertySection title="Relationships">
<PropertySection title="Relationships" streamlined={streamlinedPropertiesEnabled}>
<PropertyPicker
inline={inline}
label="Parent"
@@ -2247,7 +2419,23 @@ export function IssueProperties({
{parentContent}
</PropertyPicker>
{inline ? (
{streamlinedPropertiesEnabled ? (
<PropertyPicker
inline={inline}
label="Blocked by"
open={blockedByOpen}
onOpenChange={(open) => {
setBlockedByOpen(open);
if (!open) setBlockedBySearch("");
}}
triggerContent={blockedByTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
stacked
>
{blockedByContent}
</PropertyPicker>
) : inline ? (
<div>
<PropertyRow label="Blocked by" wrap>
{visibleBlockedByRelations.map((relation) => (
@@ -2265,11 +2453,11 @@ export function IssueProperties({
/>
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
</PropertyRow>
{blockedByOpen && (
{blockedByOpen ? (
<div className="rounded-md border border-border bg-popover p-1 mb-2">
{blockedByContent}
</div>
)}
) : null}
</div>
) : (
<PropertyRow label="Blocked by" wrap>
@@ -2293,9 +2481,7 @@ export function IssueProperties({
if (!open) setBlockedBySearch("");
}}
>
<PopoverTrigger asChild>
{renderAddBlockedByButton()}
</PopoverTrigger>
<PopoverTrigger asChild>{renderAddBlockedByButton()}</PopoverTrigger>
<PopoverContent className="w-72 p-1" align="end" collisionPadding={16}>
{blockedByContent}
</PopoverContent>
@@ -2305,7 +2491,7 @@ export function IssueProperties({
<PropertyRow label="Blocking" wrap>
{blockingIssues.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex flex-col items-start gap-1.5">
{visibleBlockingIssues.map((relation) => (
<IssueReferencePill key={relation.id} issue={relation} />
))}
@@ -2320,17 +2506,25 @@ export function IssueProperties({
)}
</PropertyRow>
{/* Chat shell promotes sub-tasks to their own pane tab (the full tree),
so the slim pill row here would duplicate that home (PAP-496). Keep
the pill row only for the classic center-column layout. */}
{taskChatShellEnabled ? null : (
{streamlinedPropertiesEnabled ? (
<PropertyPicker
inline={inline}
label="Subtasks"
open={subtasksOpen}
onOpenChange={setSubtasksOpen}
triggerContent={subtasksTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
stacked
>
{subtasksContent}
</PropertyPicker>
) : !taskChatShellEnabled ? (
<PropertyRow label="Sub-tasks" wrap>
<div className="flex flex-wrap items-center gap-1.5">
{childIssues.length > 0
? visibleChildIssues.map((child) => (
<IssueReferencePill key={child.id} issue={child} />
))
: null}
<div className="flex flex-col items-start gap-1.5">
{visibleChildIssues.map((child) => (
<IssueReferencePill key={child.id} issue={child} />
))}
<ExpandRelationListButton
hiddenCount={hiddenChildIssueCount}
expanded={subTasksExpanded}
@@ -2348,11 +2542,11 @@ export function IssueProperties({
) : null}
</div>
</PropertyRow>
)}
) : null}
{relatedTasks.length > 0 ? (
<PropertyRow label="Related tasks" wrap>
<div className="flex flex-wrap items-center gap-1.5">
{(!streamlinedPropertiesEnabled || !taskChatShellEnabled) && relatedTasks.length > 0 ? (
<PropertyRow label={streamlinedPropertiesEnabled ? "Referenced" : "Related tasks"} wrap>
<div className="flex flex-col items-start gap-1.5">
{visibleRelatedTasks.map((related) => (
<IssueReferencePill key={related.id} issue={related} />
))}
@@ -2373,7 +2567,7 @@ export function IssueProperties({
/>
</PropertySection>
<PropertySection title="Execution">
<PropertySection title="Execution" streamlined={streamlinedPropertiesEnabled}>
{/* Read-only: agents set the policy, the board does not. */}
{reviewPolicyBadge ? (
<PropertyRow label="Approvals">
@@ -2482,7 +2676,7 @@ export function IssueProperties({
</PropertySection>
{workspacePickerEligible || hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
<PropertySection title="Workspace">
<PropertySection title="Workspace" streamlined={streamlinedPropertiesEnabled}>
{workspacePickerEligible ? (
<PropertyPicker
inline={inline}
@@ -2651,7 +2845,7 @@ export function IssueProperties({
</PropertySection>
) : null}
<PropertySection title="About">
<PropertySection title="About" streamlined={streamlinedPropertiesEnabled}>
{originatingActor ? (
<PropertyRow label="Originating">
{originatingActor.kind === "agent" ? (
@@ -2683,19 +2877,31 @@ export function IssueProperties({
) : null}
{issue.startedAt && (
<PropertyRow label="Started">
<span className="text-sm">{formatDateTime(issue.startedAt)}</span>
<span
className={streamlinedPropertiesEnabled ? "min-w-0 truncate whitespace-nowrap text-sm" : "text-sm"}
title={streamlinedPropertiesEnabled ? formatDateTime(issue.startedAt) : undefined}
>{formatDateTime(issue.startedAt)}</span>
</PropertyRow>
)}
{issue.completedAt && (
<PropertyRow label="Completed">
<span className="text-sm">{formatDateTime(issue.completedAt)}</span>
<span
className={streamlinedPropertiesEnabled ? "min-w-0 truncate whitespace-nowrap text-sm" : "text-sm"}
title={streamlinedPropertiesEnabled ? formatDateTime(issue.completedAt) : undefined}
>{formatDateTime(issue.completedAt)}</span>
</PropertyRow>
)}
<PropertyRow label="Created">
<span className="text-sm">{formatDateTime(issue.createdAt)}</span>
<span
className={streamlinedPropertiesEnabled ? "min-w-0 truncate whitespace-nowrap text-sm" : "text-sm"}
title={streamlinedPropertiesEnabled ? formatDateTime(issue.createdAt) : undefined}
>{formatDateTime(issue.createdAt)}</span>
</PropertyRow>
<PropertyRow label="Updated">
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
<span
className={streamlinedPropertiesEnabled ? "min-w-0 truncate whitespace-nowrap text-sm" : "text-sm"}
title={streamlinedPropertiesEnabled ? timeAgo(issue.updatedAt) : undefined}
>{timeAgo(issue.updatedAt)}</span>
</PropertyRow>
{issue.archivedAt && issue.archivedByActorType === "agent" && issue.archivedByAgentId ? (
(() => {
@@ -2761,13 +2967,51 @@ export function IssueProperties({
// Classic Task Interface ON: the legacy stacked pane, byte-for-byte.
if (!taskChatShellEnabled || sidePanelContentOnly) return propertiesBody;
// Chat-style with nothing to switch between: no tab strip — the header bar
// shows a plain title and the pane body is just the properties stack.
if (!hasPlanTab && !hasArtifactsTab) {
const hasSubtasksTab = streamlinedPropertiesEnabled && childIssues.length > 0;
const hasReferencesTab = streamlinedPropertiesEnabled
&& (panelReferencedTasks.length > 0 || panelMentionedInTasks.length > 0);
const availablePaneTabs: IssuePaneTabDescriptor[] = [
{ value: "properties", label: "Properties", closable: false },
...(hasSubtasksTab
? [{ value: "subtasks" as const, label: "Subtasks", count: childIssues.length, closable: true }]
: []),
...(hasReferencesTab
? [{ value: "references" as const, label: "References", closable: true }]
: []),
...(hasPlanTab
? [{ value: "plans" as const, label: "Plan", closable: true }]
: []),
...(hasArtifactsTab
? [{ value: "artifacts" as const, label: "Artifacts", closable: true }]
: []),
];
const visiblePaneTabs = availablePaneTabs.filter(
(tab) => tab.value === "properties" || !closedPaneTabs.has(tab.value),
);
const hiddenPaneTabs = availablePaneTabs.filter((tab) => closedPaneTabs.has(tab.value));
// Chat-style with nothing to switch between: render one selected tab button
// so the header uses the same filled-tab treatment as the multi-tab state.
if (!hasSubtasksTab && !hasReferencesTab && !hasPlanTab && !hasArtifactsTab) {
return (
<>
{paneHeaderSlot
? createPortal(<span className="text-sm font-medium">Properties</span>, paneHeaderSlot)
? streamlinedPropertiesEnabled ? createPortal(
<div className="flex items-center" role="tablist" aria-label="Properties panel sections">
<button
type="button"
role="tab"
aria-selected="true"
className={cn(
STREAMLINED_PANE_TAB_CLASS,
"inline-flex flex-none items-center bg-muted px-3",
)}
>
Properties
</button>
</div>,
paneHeaderSlot,
) : createPortal(<span className="text-sm font-medium">Properties</span>, paneHeaderSlot)
: null}
{propertiesBody}
</>
@@ -2783,37 +3027,150 @@ export function IssueProperties({
const activePaneTab =
(paneTab === "plans" && !hasPlanTab)
|| (paneTab === "artifacts" && !hasArtifactsTab)
|| (paneTab === "subtasks" && !hasSubtasksTab)
|| (paneTab === "references" && !hasReferencesTab)
|| closedPaneTabs.has(paneTab)
? "properties"
: paneTab;
// In the pane header the strip stretches to the bar's full height and the
// active underline drops to bottom-0, so it hugs the header's border line.
const closePaneTab = (value: IssuePaneTab) => {
if (value === "properties") return;
paneTabUserChosenRef.current = true;
setClosedPaneTabs((current) => {
const next = new Set(current);
next.add(value);
return next;
});
if (activePaneTab !== value) return;
const closingIndex = visiblePaneTabs.findIndex((tab) => tab.value === value);
const fallback = visiblePaneTabs[closingIndex - 1]
?? visiblePaneTabs[closingIndex + 1]
?? availablePaneTabs[0];
setPaneTab(fallback.value);
};
const reopenPaneTab = (value: IssuePaneTab) => {
paneTabUserChosenRef.current = true;
setClosedPaneTabs((current) => {
if (!current.has(value)) return current;
const next = new Set(current);
next.delete(value);
return next;
});
setPaneTab(value);
};
const codexStylePaneTabs = Boolean(paneHeaderSlot && streamlinedPropertiesEnabled);
// Production keeps the master underline treatment. Streamlined task detail
// uses the compact, truncating Codex-inspired pane-tab treatment instead.
const paneTabTriggerClass = paneHeaderSlot
? "h-full group-data-[orientation=horizontal]/tabs:after:bottom-0"
? streamlinedPropertiesEnabled
? cn(
STREAMLINED_PANE_TAB_CLASS,
"min-w-0 flex-1 justify-start overflow-hidden after:hidden",
)
: "h-full group-data-[orientation=horizontal]/tabs:after:bottom-0"
: undefined;
const tabStrip = (
const paneTabs = codexStylePaneTabs ? visiblePaneTabs : availablePaneTabs;
const tabList = (
<TabsList
variant="line"
className={
paneHeaderSlot
? "items-stretch justify-start gap-1 p-0 group-data-[orientation=horizontal]/tabs:h-full"
? streamlinedPropertiesEnabled
? "min-w-0 flex-1 items-center justify-start gap-0 overflow-hidden p-0 group-data-[orientation=horizontal]/tabs:h-full"
: "items-stretch justify-start gap-1 p-0 group-data-[orientation=horizontal]/tabs:h-full"
: "w-full justify-start gap-1"
}
>
<TabsTrigger value="properties" className={paneTabTriggerClass}>
Properties
</TabsTrigger>
{hasPlanTab ? (
<TabsTrigger value="plans" className={paneTabTriggerClass}>
Plan
</TabsTrigger>
) : null}
{hasArtifactsTab ? (
<TabsTrigger value="artifacts" className={paneTabTriggerClass}>
Artifacts
</TabsTrigger>
) : null}
{paneTabs.map((tab, index) => {
const trigger = (
<TabsTrigger
key={codexStylePaneTabs ? undefined : tab.value}
value={tab.value}
className={cn(
paneTabTriggerClass,
codexStylePaneTabs && "mx-1.5 hover:bg-accent/50 group-focus-within/pane-tab:bg-accent/50",
codexStylePaneTabs && "px-3",
)}
>
<span
className={cn(
codexStylePaneTabs
&& "task-detail-pane-tab-label min-w-0 flex-1 overflow-hidden whitespace-nowrap",
)}
title={tab.label}
>
{tab.label}
</span>
{tab.count ? (
<span className="shrink-0 font-mono text-(length:--text-nano) text-muted-foreground transition-opacity group-hover/pane-tab:opacity-0 group-focus-within/pane-tab:opacity-0">
{tab.count}
</span>
) : null}
</TabsTrigger>
);
if (!codexStylePaneTabs) return trigger;
return (
<div
key={tab.value}
data-slot="task-detail-pane-tab"
className="group/pane-tab relative flex min-w-0 flex-1 basis-0 items-center"
>
{index > 0 ? (
<span
data-slot="task-detail-pane-tab-divider"
aria-hidden="true"
className="absolute left-0 top-1/2 h-4 w-px -translate-y-1/2 bg-border"
/>
) : null}
{trigger}
{tab.closable ? (
<button
type="button"
className="absolute right-2.5 top-1/2 z-20 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring group-hover/pane-tab:opacity-100 group-focus-within/pane-tab:opacity-100"
aria-label={`Close ${tab.label} tab`}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
closePaneTab(tab.value);
}}
>
<X className="size-3.5" aria-hidden />
</button>
) : null}
</div>
);
})}
</TabsList>
);
const tabStrip = codexStylePaneTabs ? (
<div className="flex h-full min-w-0 flex-1 items-center">
{tabList}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="size-6 shrink-0 text-muted-foreground"
disabled={hiddenPaneTabs.length === 0}
aria-label="Open closed sidebar tab"
title={hiddenPaneTabs.length === 0 ? "All sidebar tabs are open" : "Open closed sidebar tab"}
>
<Plus className="size-3.5" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-(--sz-8rem)">
{hiddenPaneTabs.map((tab) => (
<DropdownMenuItem key={tab.value} onClick={() => reopenPaneTab(tab.value)}>
{tab.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : tabList;
return (
<Tabs value={activePaneTab} onValueChange={handlePaneTabChange} className="flex min-h-0 flex-col gap-3">
{paneHeaderSlot
@@ -2829,6 +3186,24 @@ export function IssueProperties({
)
: tabStrip}
<TabsContent value="properties">{propertiesBody}</TabsContent>
{hasSubtasksTab ? (
<TabsContent value="subtasks">
<TaskDetailSubtasksPanel
items={childIssues}
onAddSubtask={onAddSubIssue}
issueLinkState={issueLinkState}
/>
</TabsContent>
) : null}
{hasReferencesTab ? (
<TabsContent value="references">
<TaskDetailReferencesPanel
referenced={panelReferencedTasks}
mentionedIn={panelMentionedInTasks}
issueLinkState={issueLinkState}
/>
</TabsContent>
) : null}
{hasPlanTab ? (
<TabsContent value="plans">
<IssuePropertiesPlansTab issue={issue} inline={inline} />
@@ -7,6 +7,7 @@ export function PropertySection({
className,
title,
first,
streamlined,
}: {
children: ReactNode;
className?: string;
@@ -14,13 +15,16 @@ export function PropertySection({
title?: string;
/** First section drops the top padding on its header. */
first?: boolean;
streamlined?: boolean;
}) {
return (
<div className={className}>
<div className={className} data-property-section="true">
{title ? (
<div
className={cn(
"text-xs font-semibold uppercase tracking-wide text-muted-foreground pb-1",
streamlined
? "pb-1 font-mono text-(length:--text-nano) font-normal uppercase tracking-wide text-muted-foreground/70"
: "text-xs font-semibold uppercase tracking-wide text-muted-foreground pb-1",
first ? "pt-0" : "pt-3",
)}
>
@@ -60,7 +64,15 @@ export function PropertyRow({
>
{label}
</span>
<div className={cn("flex min-w-0 flex-1 items-center gap-1.5", wrap && "flex-wrap")}>{children}</div>
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-1.5",
wrap && "flex-col items-start",
)}
data-property-value="true"
>
{children}
</div>
</div>
);
}
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ChevronDown } from "lucide-react";
import { cn } from "../../lib/utils";
import { useStreamlinedUiEnabled } from "../../hooks/useStreamlinedUiEnabled";
import { PropertyRow } from "./primitives";
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
@@ -14,6 +16,7 @@ export function PropertyPicker({
popoverClassName,
popoverAlign = "end",
extra,
stacked = false,
children,
}: {
inline?: boolean;
@@ -25,19 +28,26 @@ export function PropertyPicker({
popoverClassName?: string;
popoverAlign?: "start" | "center" | "end";
extra?: ReactNode;
/** Top-aligns the row and vertically stacks chip collections in the trigger. */
stacked?: boolean;
children: ReactNode;
}) {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const btnCn = cn(
"inline-flex min-h-5 items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors min-w-0 max-w-full text-left",
streamlinedUiEnabled && "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
triggerClassName,
);
if (inline) {
return (
<div>
<PropertyRow label={label}>
<button className={btnCn} onClick={() => onOpenChange(!open)}>
<PropertyRow label={label} wrap={stacked}>
<button className={cn(btnCn, stacked && "items-start")} onClick={() => onOpenChange(!open)} aria-expanded={streamlinedUiEnabled ? open : undefined}>
{triggerContent}
{streamlinedUiEnabled ? (
<ChevronDown className={cn("ml-auto h-3 w-3 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} aria-hidden />
) : null}
</button>
{extra}
</PropertyRow>
@@ -51,10 +61,15 @@ export function PropertyPicker({
}
return (
<PropertyRow label={label}>
<PropertyRow label={label} wrap={stacked}>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button className={btnCn}>{triggerContent}</button>
<button className={cn(btnCn, stacked && "items-start")} aria-expanded={streamlinedUiEnabled ? open : undefined}>
{triggerContent}
{streamlinedUiEnabled ? (
<ChevronDown className={cn("ml-auto h-3 w-3 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} aria-hidden />
) : null}
</button>
</PopoverTrigger>
<PopoverContent className={cn("p-1", popoverClassName)} align={popoverAlign} collisionPadding={16}>
{children}
+9 -9
View File
@@ -34,8 +34,8 @@ function DormantPill() {
<svg viewBox="0 0 100 93" fill="none" aria-hidden className="size-full">
<path d={BODY_PATH} fill={`url(#${DORMANT_GRADIENT_ID})`} />
{/* Closed eyes: the same rounded rects as the open pair, flattened. */}
<rect x="75.9199" y="66.3047" width="9" height="4" rx="2" fill="#060606" />
<rect x="28.9199" y="66.3047" width="9" height="4" rx="2" fill="#060606" />
<rect x="75.9199" y="66.3047" width="9" height="4" rx="2" fill="var(--pill-guy-eye)" />
<rect x="28.9199" y="66.3047" width="9" height="4" rx="2" fill="var(--pill-guy-eye)" />
<defs>
<linearGradient
id={DORMANT_GRADIENT_ID}
@@ -45,8 +45,8 @@ function DormantPill() {
y2="107.486"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#626262" />
<stop offset="1" stopColor="#101010" />
<stop stopColor="var(--pill-guy-dormant-top)" />
<stop offset="1" stopColor="var(--pill-guy-dormant-bottom)" />
</linearGradient>
</defs>
</svg>
@@ -57,9 +57,9 @@ function AlivePill() {
return (
<svg viewBox="0 0 100 93" fill="none" aria-hidden className="size-full">
<path d={BODY_PATH} fill={`url(#${ALIVE_GRADIENT_ID})`} />
<rect x="76.1406" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="#060606" />
<rect x="28.8301" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="#060606" />
<path d={TUFT_PATH} fill="#2D200D" />
<rect x="76.1406" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="var(--pill-guy-eye)" />
<rect x="28.8301" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="var(--pill-guy-eye)" />
<path d={TUFT_PATH} fill="var(--pill-guy-tuft)" />
<defs>
<linearGradient
id={ALIVE_GRADIENT_ID}
@@ -69,8 +69,8 @@ function AlivePill() {
y2="107.486"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3028AA" />
<stop offset="1" stopColor="#FF0000" />
<stop stopColor="var(--pill-guy-alive-top)" />
<stop offset="1" stopColor="var(--pill-guy-alive-bottom)" />
</linearGradient>
</defs>
</svg>
@@ -0,0 +1,859 @@
import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
Braces,
Clock3,
Edit3,
KeyRound,
Play,
Plus,
X,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RadioCardGroup } from "@/components/ui/radio-card";
import { cn } from "@/lib/utils";
import { nextCronFires, previewFirePolicies } from "../../lib/cron-fires";
import { timeAgo } from "../../lib/timeAgo";
import { EmptyState } from "../EmptyState";
import { InlineEntitySelector } from "../InlineEntitySelector";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations";
import { AgentIcon } from "../AgentIconPicker";
import { MarkdownEditor } from "../MarkdownEditor";
import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor";
import { RoutineTriggerCard } from "../RoutineTriggerCard";
import { EnvironmentVariablesEditor } from "../environment-variables-editor";
import { createDefaultNewTrigger, useRoutineDetail } from "./context";
import type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared";
const concurrencyPolicyOptions = [
{
value: "coalesce_if_active",
title: "Coalesce if active",
description: "Keep one follow-up run queued while an active run is still working.",
},
{
value: "always_enqueue",
title: "Always enqueue",
description: "Queue every trigger occurrence, even if several runs stack up.",
},
{
value: "skip_if_active",
title: "Skip if active",
description: "Drop overlapping trigger occurrences while the routine is already active.",
},
];
const catchUpPolicyOptions = [
{
value: "skip_missed",
title: "Skip missed",
description: "Ignore schedule windows that were missed while paused.",
},
{
value: "enqueue_missed_with_cap",
title: "Enqueue missed with cap",
description: "Catch up missed schedule windows after recovery; sub-hourly schedules are combined into one catch-up run, slower schedules replay each missed window up to a cap.",
},
];
const activityGatePolicyOptions = [
{
value: "always",
title: "Run on every scheduled tick",
description: "Fire on the schedule no matter what — the default behavior.",
},
{
value: "require_external_activity",
title: "Skip when there's been no activity since the last run",
description:
"On a scheduled tick, only run if something happened since the last run that finished. Lets a watcher-style routine stay asleep while the system is settled instead of burning tokens.",
},
];
const activityGateScopeOptions = [
{
value: "company",
title: "Company-wide",
description: "Any activity across the company counts as a reason to run.",
},
{
value: "project",
title: "This project",
description: "Only activity in the routine's project counts as a reason to run.",
},
];
const triggerKinds = ["schedule", "webhook"];
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
const signingModeDescriptions: Record<string, string> = {
bearer: "Expect a shared bearer token in the Authorization header.",
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
none: "No authentication — the webhook URL itself acts as a shared secret.",
};
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
export function OverviewSection({
defaultDescriptionAnnotationsOpen = false,
}: {
defaultDescriptionAnnotationsOpen?: boolean;
} = {}) {
const ctx = useRoutineDetail();
const {
routine,
editDraft,
setEditDraft,
assigneeOptions,
projectOptions,
recentAssigneeIds,
recentProjectIds,
agentById,
projectById,
currentAssignee,
currentProject,
mentionOptions,
assigneeSelectorRef,
projectSelectorRef,
descriptionEditorRef,
routineRuns,
activity,
saveRoutine,
saveConflict,
isSectionDirty,
navigateToSection,
} = ctx;
const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(defaultDescriptionAnnotationsOpen);
const activeTriggers = routine.triggers.length;
const nextFire = useMemo(() => {
const upcoming = routine.triggers
.filter((trigger) => trigger.kind === "schedule" && trigger.nextRunAt)
.map((trigger) => new Date(trigger.nextRunAt as Date))
.sort((a, b) => a.getTime() - b.getTime())[0];
return upcoming ? upcoming.toLocaleString() : null;
}, [routine.triggers]);
const boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0;
const lastRun = (routineRuns ?? [])[0] ?? null;
const recentActivity = (activity ?? []).slice(0, 5);
return (
<div className="space-y-6">
{/* Assignment row */}
<div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={editDraft.assigneeAgentId}
options={assigneeOptions}
recentOptionIds={recentAssigneeIds}
placeholder="Responsible"
noneLabel="No responsible"
searchPlaceholder="Search responsible..."
emptyMessage="No responsible found."
onChange={(assigneeAgentId) =>
setEditDraft((current) => ({ ...current, assigneeAgentId }))
}
onConfirm={() => {
if (editDraft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
)
) : (
<span className="text-muted-foreground">Responsible</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agentById.get(option.id);
return (
<>
{assignee ? (
<AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<span>in</span>
<InlineEntitySelector
ref={projectSelectorRef}
value={editDraft.projectId}
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "var(--project-none)" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projectById.get(option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "var(--project-none)" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
</div>
{!routine.assigneeAgentId ? (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-900 dark:text-amber-200">
Default agent required. This routine can stay as a draft and still run manually, but
automation stays paused until you assign a default agent.
</div>
) : null}
{/* Instructions */}
<div className="space-y-2">
<div className="flex items-center justify-end">
{routine.descriptionDocument ? (
<DocumentAnnotationsCountChip
issueId={routine.id}
docKey="description"
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
panelOpen={descriptionAnnotationsOpen}
onToggle={() => setDescriptionAnnotationsOpen((open) => !open)}
/>
) : null}
</div>
{routine.descriptionDocument ? (
<IssueDocumentAnnotations
issueId={routine.id}
doc={routine.descriptionDocument}
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
bodyMarkdown={editDraft.description}
draftDirty={isSectionDirty("overview") || saveRoutine.isPending}
draftConflicted={saveConflict}
historicalPreview={false}
locationHash={typeof window === "undefined" ? "" : window.location.hash}
panelOpen={descriptionAnnotationsOpen}
onPanelOpenChange={setDescriptionAnnotationsOpen}
>
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-(--sz-120px) text-sm leading-7"
mentions={mentionOptions}
onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) {
saveRoutine.mutate();
}
}}
/>
</IssueDocumentAnnotations>
) : (
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-(--sz-120px) text-sm leading-7"
mentions={mentionOptions}
onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) {
saveRoutine.mutate();
}
}}
/>
)}
</div>
{/* Variables peek */}
<div className="space-y-3">
<RoutineVariablesHint />
<RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description}
value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
/>
</div>
{/* Summary cards */}
<div className="grid gap-3 sm:grid-cols-3">
<SummaryCard
icon={Clock3}
label="Triggers"
value={activeTriggers === 0 ? "None" : `${activeTriggers} active`}
hint={nextFire ? `Next fire ${nextFire}` : "No schedule"}
to={() => navigateToSection("triggers")}
ariaLabel={`${activeTriggers} triggers. Open triggers.`}
/>
<SummaryCard
icon={KeyRound}
label="Secrets"
value={boundSecrets === 0 ? "None" : `${boundSecrets} bound`}
hint="Manage bound secrets"
to={() => navigateToSection("secrets")}
ariaLabel={`${boundSecrets} secrets bound. Open secrets.`}
/>
<SummaryCard
icon={Play}
label="Last run"
value={lastRun ? lastRun.status.replaceAll("_", " ") : "No runs"}
hint={lastRun ? timeAgo(lastRun.triggeredAt) : "Trigger a run"}
to={() => navigateToSection("runs")}
ariaLabel={lastRun ? `Last run ${lastRun.status}. Open runs.` : "No runs. Open runs."}
/>
</div>
{/* Recent activity */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Recent activity
</p>
{recentActivity.length === 0 ? (
<p className="text-xs text-muted-foreground">No activity yet.</p>
) : (
<div className="divide-y divide-border/60">
{recentActivity.map((event) => (
<div key={event.id} className="flex items-center gap-2 py-1.5 text-xs">
<Badge variant="outline" className="shrink-0 font-mono">
{event.action}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{event.details && Object.keys(event.details).length > 0
? Object.keys(event.details).slice(0, 3).join(" · ")
: ""}
</span>
<span className="shrink-0 text-muted-foreground/60">{timeAgo(event.createdAt)}</span>
</div>
))}
<button
type="button"
onClick={() => navigateToSection("activity")}
className="flex items-center gap-1 pt-2 text-xs text-muted-foreground hover:text-foreground"
>
View all activity <ArrowRight className="h-3 w-3" />
</button>
</div>
)}
</div>
</div>
);
}
function SummaryCard({
icon: Icon,
label,
value,
hint,
to,
ariaLabel,
}: {
icon: typeof Clock3;
label: string;
value: string;
hint: string;
to: () => void;
ariaLabel: string;
}) {
return (
<button type="button" onClick={to} aria-label={ariaLabel} className="text-left">
<Card className="gap-2 p-4 transition-colors hover:border-border hover:bg-accent/30">
<CardContent className="space-y-1 p-0">
<div className="flex items-center gap-1.5 text-xs uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{label}
<ArrowRight className="ml-auto h-3.5 w-3.5 text-muted-foreground/60" />
</div>
<p className="text-lg font-semibold">{value}</p>
<p className="text-xs text-muted-foreground">{hint}</p>
</CardContent>
</Card>
</button>
);
}
export function TriggersSection() {
const ctx = useRoutineDetail();
const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx;
const [addOpen, setAddOpen] = useState(false);
const [newScheduleEditorValid, setNewScheduleEditorValid] = useState(true);
const newScheduleValidation = useMemo(
() => newTrigger.kind === "schedule" ? getScheduleCronValidation(newTrigger.cronExpression) : null,
[newTrigger.cronExpression, newTrigger.kind],
);
const addDisabled =
createTrigger.isPending ||
(newScheduleValidation ? !newScheduleValidation.valid || !newScheduleEditorValid : false);
useEffect(() => {
if (newTrigger.kind !== "schedule") setNewScheduleEditorValid(true);
}, [newTrigger.kind]);
return (
<div className="space-y-4">
{/* Add-trigger drawer header (§3.2) */}
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">
{routine.triggers.length === 0
? "No triggers yet"
: `${routine.triggers.length} trigger${routine.triggers.length === 1 ? "" : "s"}`}
</p>
<Button
size="sm"
variant={addOpen ? "secondary" : "default"}
onClick={() => setAddOpen((open) => !open)}
aria-expanded={addOpen}
>
{addOpen ? (
<>
<X className="mr-1.5 h-3.5 w-3.5" />
Cancel
</>
) : (
<>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New trigger
</>
)}
</Button>
</div>
{/* Add trigger form — expand-on-click drawer */}
{addOpen ? (
<div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">Add trigger</p>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Kind</Label>
<Select
value={newTrigger.kind}
onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triggerKinds.map((kind) => (
<SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
{kind}
{kind === "webhook" ? " — COMING SOON" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newTrigger.kind === "schedule" && (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Schedule</Label>
<ScheduleEditor
value={newTrigger.cronExpression}
onChange={(cronExpression) =>
setNewTrigger((current) => ({ ...current, cronExpression }))
}
onValidityChange={setNewScheduleEditorValid}
/>
</div>
)}
{newTrigger.kind === "webhook" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Signing mode</Label>
<Select
value={newTrigger.signingMode}
onValueChange={(signingMode) =>
setNewTrigger((current) => ({ ...current, signingMode }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>
{mode}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{signingModeDescriptions[newTrigger.signingMode]}
</p>
</div>
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input
value={newTrigger.replayWindowSec}
onChange={(event) =>
setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))
}
/>
</div>
)}
</>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={() =>
createTrigger.mutate(undefined, {
onSuccess: () => {
setNewTrigger(createDefaultNewTrigger());
setAddOpen(false);
},
})
}
disabled={addDisabled}
>
{createTrigger.isPending ? "Adding..." : "Add trigger"}
</Button>
</div>
</div>
) : null}
{/* Existing triggers */}
{routine.triggers.length === 0 ? (
<EmptyState
icon={Clock3}
message="No triggers yet."
action="Add a schedule"
onAction={() => setAddOpen(true)}
/>
) : (
<div className="space-y-3">
{routine.triggers.map((trigger) => (
<RoutineTriggerCard
key={trigger.id}
trigger={trigger}
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
onRotate={(id) => rotateTrigger.mutate(id)}
onDelete={(id) => deleteTrigger.mutate(id)}
/>
))}
</div>
)}
</div>
);
}
export function VariablesSection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft, navigateToSection } = ctx;
const hasVariables = editDraft.variables.length > 0;
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 px-4 py-3 text-xs">
<span className="flex-1 text-muted-foreground">
Variables are auto-detected from <code className="font-mono">{"{{placeholders}}"}</code> in
the title &amp; instructions. The variable name is read-only — rename by editing the
placeholder.
</span>
<Button variant="secondary" size="sm" onClick={() => navigateToSection("overview")}>
<Edit3 className="mr-1.5 h-3.5 w-3.5" />
Edit instructions
</Button>
</div>
{hasVariables ? (
<RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description}
value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
/>
) : (
<EmptyState
icon={Braces}
message="No variables yet. Add a {{placeholder}} in the title or instructions to create one."
action="Edit instructions"
onAction={() => navigateToSection("overview")}
/>
)}
</div>
);
}
export function SecretsSection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft, availableSecrets, createSecret, secretMessage, copySecretValue } = ctx;
// Project/company-scoped secrets that already see real usage, surfaced as
// quick-bind chips (§3.4). Ranked by reference count then recency.
const recentlyUsedSecrets = useMemo(
() =>
[...availableSecrets]
.filter((secret) => secret.status === "active")
.sort((a, b) => {
const refDelta = (b.referenceCount ?? 0) - (a.referenceCount ?? 0);
if (refDelta !== 0) return refDelta;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
})
.slice(0, 8),
[availableSecrets],
);
return (
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
Routine secrets apply to every task this routine creates. They override matching keys in
project and agent env. <span className="font-mono">PAPERCLIP_*</span> names are reserved.
</div>
{secretMessage ? (
<div className="space-y-3 rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm">
<div>
<p className="font-medium">{secretMessage.title}</p>
<p className="text-xs text-muted-foreground">
Save this now. Paperclip will not show the secret value again.
</p>
</div>
<div className="space-y-3">
{secretMessage.entries.map((entry, index) => (
<div key={`${entry.webhookUrl}-${index}`} className="space-y-2">
<div className="flex items-center gap-2">
<Input value={entry.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", entry.webhookUrl)}>
URL
</Button>
</div>
<div className="flex items-center gap-2">
<Input value={entry.webhookSecret} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", entry.webhookSecret)}>
Secret
</Button>
</div>
</div>
))}
</div>
</div>
) : null}
<EnvironmentVariablesEditor
value={(editDraft.env ?? {}) as Record<string, EnvBinding>}
secrets={availableSecrets}
recentlyUsedSecrets={recentlyUsedSecrets}
onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })}
onChange={(env) => setEditDraft((current) => ({ ...current, env: env ?? null }))}
/>
</div>
);
}
export function DeliverySection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft, routine } = ctx;
// The activity gate only affects schedule ticks (webhook/manual/API fires are
// themselves activity and always run), so the control is only meaningful for
// routines that have a schedule trigger. Disable — rather than hide — it
// elsewhere so the capability stays discoverable.
const hasScheduleTrigger = routine.triggers.some((trigger) => trigger.kind === "schedule");
const gateEnabled = editDraft.activityGatePolicy === "require_external_activity";
return (
<div className="space-y-6">
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Concurrency
</p>
<RadioCardGroup
ariaLabel="Concurrency policy"
value={editDraft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) =>
setEditDraft((current) => ({ ...current, concurrencyPolicy }))
}
options={concurrencyPolicyOptions}
/>
</div>
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Catch-up
</p>
<RadioCardGroup
ariaLabel="Catch-up policy"
value={editDraft.catchUpPolicy}
onValueChange={(catchUpPolicy) =>
setEditDraft((current) => ({ ...current, catchUpPolicy }))
}
options={catchUpPolicyOptions}
/>
</div>
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Advanced run policy
</p>
<RadioCardGroup
ariaLabel="Advanced run policy"
value={editDraft.activityGatePolicy}
onValueChange={(activityGatePolicy) =>
setEditDraft((current) => ({ ...current, activityGatePolicy }))
}
options={activityGatePolicyOptions}
disabled={!hasScheduleTrigger}
/>
{!hasScheduleTrigger ? (
<p className="text-xs text-muted-foreground">
Add a schedule trigger to gate runs on activity. Webhook, manual, and API fires always
run.
</p>
) : gateEnabled ? (
<div className="space-y-2 rounded-lg border border-border p-3">
<Label className="text-xs font-medium">Activity scope</Label>
<RadioCardGroup
ariaLabel="Activity gate scope"
value={editDraft.activityGateScope}
onValueChange={(activityGateScope) =>
setEditDraft((current) => ({ ...current, activityGateScope }))
}
options={activityGateScopeOptions}
/>
</div>
) : null}
</div>
<NextFiresPreview
triggers={routine.triggers}
concurrencyPolicy={editDraft.concurrencyPolicy}
/>
</div>
);
}
const dispositionToneClass: Record<string, string> = {
queued: "text-emerald-600 dark:text-emerald-400",
coalesced: "text-amber-600 dark:text-amber-400",
skipped: "text-muted-foreground",
};
/**
* "Next 5 fires" preview (§3.5) — the strongest "what does this policy mean?"
* surface. Picks the soonest-firing schedule trigger, computes its next fires
* client-side, and annotates each with how the chosen concurrency policy would
* treat it.
*/
function NextFiresPreview({
triggers,
concurrencyPolicy,
}: {
triggers: RoutineDetailType["triggers"];
concurrencyPolicy: string;
}) {
const preview = useMemo(() => {
const schedule = triggers
.filter((trigger) => trigger.kind === "schedule" && trigger.enabled && trigger.cronExpression)
.map((trigger) => {
const fires = nextCronFires(trigger.cronExpression, 5, {
timeZone: trigger.timezone ?? "UTC",
});
return { trigger, fires };
})
.filter((entry) => entry.fires.length > 0)
.sort((a, b) => a.fires[0]!.getTime() - b.fires[0]!.getTime())[0];
if (!schedule) return null;
return {
timeZone: schedule.trigger.timezone ?? "UTC",
entries: previewFirePolicies(schedule.fires, concurrencyPolicy),
};
}, [triggers, concurrencyPolicy]);
return (
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Next 5 fires
</p>
{preview ? (
<>
<div className="space-y-1.5 rounded-lg border border-border p-3 font-mono text-xs">
{preview.entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span className="text-muted-foreground/40">·</span>
<span className="tabular-nums">{formatFireTime(entry.at, preview.timeZone)}</span>
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground/50" />
<span className={cn("font-medium", dispositionToneClass[entry.disposition])}>
{entry.label}
</span>
{entry.note ? (
<span className="truncate text-muted-foreground/60">({entry.note})</span>
) : null}
</div>
))}
</div>
<p className="text-(length:--text-micro) text-muted-foreground/60">
Preview assumes the previous run is still in flight when the next fires. Times shown in{" "}
{preview.timeZone}.
</p>
</>
) : (
<p className="rounded-lg border border-dashed border-border p-3 text-xs text-muted-foreground">
No enabled schedule trigger to preview. Add a schedule in Triggers to see how this policy
treats upcoming fires.
</p>
)}
</div>
);
}
function formatFireTime(date: Date, timeZone: string): string {
try {
return new Intl.DateTimeFormat(undefined, {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
.format(date)
.replace(",", "");
} catch {
return date.toISOString();
}
}
@@ -4,7 +4,6 @@ import {
Braces,
Clock3,
Edit3,
KeyRound,
Play,
Plus,
X,
@@ -144,7 +143,6 @@ export function OverviewSection({
.sort((a, b) => a.getTime() - b.getTime())[0];
return upcoming ? upcoming.toLocaleString() : null;
}, [routine.triggers]);
const boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0;
const lastRun = (routineRuns ?? [])[0] ?? null;
const recentActivity = (activity ?? []).slice(0, 5);
@@ -320,7 +318,7 @@ export function OverviewSection({
</div>
{/* Summary cards */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2">
<SummaryCard
icon={Clock3}
label="Triggers"
@@ -329,14 +327,6 @@ export function OverviewSection({
to={() => navigateToSection("triggers")}
ariaLabel={`${activeTriggers} triggers. Open triggers.`}
/>
<SummaryCard
icon={KeyRound}
label="Secrets"
value={boundSecrets === 0 ? "None" : `${boundSecrets} bound`}
hint="Manage bound secrets"
to={() => navigateToSection("secrets")}
ariaLabel={`${boundSecrets} secrets bound. Open secrets.`}
/>
<SummaryCard
icon={Play}
label="Last run"
+8
View File
@@ -12,6 +12,14 @@ The components exported by `@/components/side-panel` are controlled, domain-free
Never put React nodes or fetched content in a persisted `SidePanelTabRecord`. Project icons and live status into `SidePanelTabItem` at render time.
`SidePanelTabs` also accepts `appearance="streamlined-task"` for the experimental task-detail shell. That appearance preserves the approved pre-rebase Codex-inspired treatment—equal-width text tabs, separated surfaces, edge fades, and hover-revealed close actions—while the default appearance remains master's portable side-panel chrome.
Use `headerSize="task-detail"` on `SidePanelFrame` when the panel shares the 60px task breadcrumb row. It keeps the task-detail footer treatment while matching the default portable header height.
`SidePanelWindowControls` defaults to the production visibility-toggle icon. Streamlined task-detail panels use `closeControl="close"` for an explicit X while retaining the same close behavior and accessible labeling.
The Streamlined task adapter registers a counted Subtasks tab only when the current task has child tasks. Closing it moves the surface back into the `+` launcher for the remainder of the session; tasks without children do not advertise the tab.
## Registering another page
1. Define a serializable payload union for that page, such as `details | activity | history`.
@@ -67,6 +67,53 @@ describe("side-panel shell controls", () => {
expect(onWindowToggle).toHaveBeenCalledOnce();
});
it("offers an explicit X close control for the task-detail sidebar", async () => {
const onToggle = vi.fn();
await act(async () => root.render(
<TooltipProvider>
<SidePanelWindowControls
maximized={false}
onMaximizedChange={vi.fn()}
onToggle={onToggle}
closeControl="close"
/>
</TooltipProvider>,
));
const closeButton = container.querySelector<HTMLButtonElement>('[aria-label="Close side panel"]');
expect(closeButton).not.toBeNull();
expect(closeButton?.querySelector(".lucide-x")).not.toBeNull();
expect(container.querySelector('[aria-label="Toggle side panel"]')).toBeNull();
await act(async () => closeButton?.click());
expect(onToggle).toHaveBeenCalledOnce();
});
it("keeps the task-detail frame borderless except for its docked left edge", async () => {
await act(async () => root.render(
<SidePanelFrame header={<div>Tabs</div>} footer={<div>Actions</div>} headerSize="task-detail">
Body
</SidePanelFrame>,
));
const frame = container.querySelector("section")!;
const header = container.querySelector("header")!;
const footer = container.querySelector("footer")!;
expect(frame.className).toContain("border-l");
expect(header.className).toContain("h-(--side-panel-header-height)");
expect(header.className).not.toContain("h-(--sz-60px)");
expect(header.className).not.toContain("border-b");
expect(footer.className).not.toContain("border-t");
});
it("preserves the shared default header and footer treatment", async () => {
await act(async () => root.render(
<SidePanelFrame header={<div>Tabs</div>} footer={<div>Actions</div>}>
Body
</SidePanelFrame>,
));
expect(container.querySelector("header")?.className).toContain("h-(--side-panel-header-height)");
expect(container.querySelector("footer")?.className).toContain("border-t");
});
it("keeps the prose scrollbar on the full-width viewport while centering its content", async () => {
await act(async () => root.render(
<SidePanelFrame contentMode="prose">Document body</SidePanelFrame>,
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from "react";
import { Maximize2, Minimize2, PanelRightClose, PanelRightOpen } from "lucide-react";
import { Maximize2, Minimize2, PanelRightClose, PanelRightOpen, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@@ -17,6 +17,7 @@ export interface SidePanelFrameProps {
maximized?: boolean;
resizing?: boolean;
label?: string;
headerSize?: "default" | "task-detail";
className?: string;
bodyClassName?: string;
style?: CSSProperties;
@@ -33,6 +34,7 @@ export function SidePanelFrame({
maximized = false,
resizing = false,
label = "Side panel",
headerSize = "default",
className,
bodyClassName,
style,
@@ -57,7 +59,9 @@ export function SidePanelFrame({
style={style}
>
{(header || trailingControls) ? (
<header className="flex h-(--side-panel-header-height) min-w-0 shrink-0 items-center gap-1 px-2">
<header className={cn(
"flex h-(--side-panel-header-height) min-w-0 shrink-0 items-center gap-1 px-2",
)}>
<div className="flex min-w-0 flex-1 self-stretch">{header}</div>
{trailingControls ? (
<div className="flex shrink-0 items-center gap-1">{trailingControls}</div>
@@ -81,7 +85,14 @@ export function SidePanelFrame({
</div>
) : children}
</div>
{footer ? <footer className="shrink-0 border-t border-border">{footer}</footer> : null}
{footer ? (
<footer className={cn(
"shrink-0",
headerSize !== "task-detail" && "border-t border-border",
)}>
{footer}
</footer>
) : null}
</section>
);
}
@@ -128,10 +139,12 @@ export function SidePanelWindowControls({
maximized,
onMaximizedChange,
onToggle,
closeControl = "toggle",
}: {
maximized: boolean;
onMaximizedChange: (maximized: boolean) => void;
onToggle: () => void;
closeControl?: "toggle" | "close";
}) {
return (
<>
@@ -146,7 +159,21 @@ export function SidePanelWindowControls({
>
{maximized ? <Minimize2 aria-hidden /> : <Maximize2 aria-hidden />}
</Button>
<SidePanelToggleButton open onToggle={onToggle} />
{closeControl === "close" ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-(--side-panel-tab-height) w-(--side-panel-tab-height) text-muted-foreground hover:text-foreground focus-visible:text-foreground"
onClick={onToggle}
aria-label="Close side panel"
title="Close side panel"
>
<X aria-hidden />
</Button>
) : (
<SidePanelToggleButton open onToggle={onToggle} />
)}
</>
);
}
+53 -19
View File
@@ -18,6 +18,7 @@ export interface SidePanelTabProps {
onClose?: () => void;
onAuxClick?: (event: MouseEvent<HTMLButtonElement>) => void;
onKeyDown?: ButtonHTMLAttributes<HTMLButtonElement>["onKeyDown"];
appearance?: "default" | "streamlined-task";
className?: string;
}
@@ -36,17 +37,19 @@ export function SidePanelTab({
onClose,
onAuxClick,
onKeyDown,
appearance = "default",
className,
}: SidePanelTabProps) {
const closeLabel = `Close ${label}`;
const wrapperRef = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const [labelIsTruncated, setLabelIsTruncated] = useState(false);
const sizingKey = `${label}:${icon ? "icon" : "no-icon"}:${status ? "status" : "no-status"}:${closable ? "closable" : "fixed"}`;
const sizingKey = `${appearance}:${label}:${icon ? "icon" : "no-icon"}:${status ? "status" : "no-status"}:${closable ? "closable" : "fixed"}`;
const [stableWidth, setStableWidth] = useState<{ key: string; width: number } | null>(null);
const hasStableWidth = stableWidth?.key === sizingKey;
useLayoutEffect(() => {
if (appearance === "streamlined-task") return;
if (hasStableWidth) return;
const measuredWidth = wrapperRef.current?.getBoundingClientRect().width ?? 0;
if (measuredWidth <= 0) return;
@@ -71,18 +74,25 @@ export function SidePanelTab({
ref={wrapperRef}
data-side-panel-tab-wrapper={id}
data-active={active ? "true" : "false"}
style={hasStableWidth ? { width: stableWidth.width } : undefined}
data-appearance={appearance}
style={appearance === "default" && hasStableWidth ? { width: stableWidth.width } : undefined}
className={cn(
"group/side-panel-tab relative flex h-(--side-panel-tab-height) min-w-0 shrink-0 items-center rounded-(--side-panel-tab-radius) border border-transparent",
appearance === "streamlined-task"
? "group/side-panel-tab relative mx-1.5 flex h-7 min-w-0 flex-1 basis-0 items-center rounded-md border border-transparent"
: "group/side-panel-tab relative flex h-(--side-panel-tab-height) min-w-0 shrink-0 items-center rounded-(--side-panel-tab-radius) border border-transparent",
"side-panel-tab-motion",
active
? "bg-(--side-panel-tab-active-bg) text-accent-foreground"
: "text-muted-foreground hover:bg-(--side-panel-tab-hover-bg) hover:text-foreground",
? appearance === "streamlined-task"
? "text-foreground hover:bg-accent/50"
: "bg-(--side-panel-tab-active-bg) text-accent-foreground"
: appearance === "streamlined-task"
? "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
: "text-muted-foreground hover:bg-(--side-panel-tab-hover-bg) hover:text-foreground",
disabled && "opacity-50",
className,
)}
>
<Tooltip>
<Tooltip open={labelIsTruncated ? undefined : false}>
<TooltipTrigger asChild>
<button
{...dragHandleProps}
@@ -90,6 +100,7 @@ export function SidePanelTab({
type="button"
role="tab"
data-side-panel-tab-target={id}
data-side-panel-tab-tooltip={labelIsTruncated ? "enabled" : "disabled"}
id={`side-panel-tab-${id}`}
aria-controls={`side-panel-content-${id}`}
aria-selected={active}
@@ -100,43 +111,66 @@ export function SidePanelTab({
onAuxClick={onAuxClick}
onKeyDown={onKeyDown}
className={cn(
"flex h-full w-full min-w-0 items-center gap-1.5 rounded-(--side-panel-tab-radius) py-1.5 pl-2 text-xs font-medium outline-none",
closable && (!hasStableWidth || active) ? "pr-7" : "pr-2.5",
appearance === "streamlined-task"
? "flex h-full w-full min-w-0 items-center justify-start rounded-md px-3 text-sm font-medium outline-none"
: "flex h-full w-full min-w-0 items-center gap-1.5 rounded-(--side-panel-tab-radius) py-1.5 pl-2 text-xs font-medium outline-none",
appearance === "default" && (closable && (!hasStableWidth || active) ? "pr-7" : "pr-2.5"),
"focus-visible:ring-2 focus-visible:ring-ring/60",
dragHandleProps?.className,
)}
>
{icon ? <span className="flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-3.5">{icon}</span> : null}
{icon && appearance === "default" ? <span className="flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-3.5">{icon}</span> : null}
<span
ref={labelRef}
data-truncated={labelIsTruncated ? "true" : undefined}
className={cn(
"overflow-hidden whitespace-nowrap",
closable && hasStableWidth && !active
? "max-w-(--side-panel-tab-label-expanded-max-width)"
: "max-w-(--side-panel-tab-label-max-width)",
labelIsTruncated && "side-panel-tab-label-fade",
appearance === "streamlined-task"
? "side-panel-tab-label-close-fade task-detail-pane-tab-label min-w-0 flex-1 overflow-hidden whitespace-nowrap text-center"
: "overflow-hidden whitespace-nowrap",
appearance === "default" && (
closable && hasStableWidth && !active
? "max-w-(--side-panel-tab-label-expanded-max-width)"
: "max-w-(--side-panel-tab-label-max-width)"
),
appearance === "default" && labelIsTruncated && "side-panel-tab-label-fade",
)}
>
{label}
</span>
{status ? <span className="flex shrink-0 items-center">{status}</span> : null}
{status ? (
<span className={cn(
"flex shrink-0 items-center",
appearance === "streamlined-task" && "transition-opacity group-hover/side-panel-tab:opacity-0 group-focus-within/side-panel-tab:opacity-0",
)}>
{status}
</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
{labelIsTruncated ? <TooltipContent side="bottom">{label}</TooltipContent> : null}
</Tooltip>
{closable && onClose && active ? (
{closable && onClose && (appearance === "streamlined-task" || active) ? (
<button
type="button"
aria-label={closeLabel}
title={closeLabel}
onPointerDown={(event) => {
if (appearance !== "streamlined-task") return;
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onClose();
}}
className="side-panel-tab-motion absolute right-1 flex size-6 items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-background/70 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
className={cn(
"side-panel-tab-close-motion absolute flex items-center justify-center text-muted-foreground outline-none hover:text-foreground",
appearance === "streamlined-task"
? "right-0 top-1/2 z-20 size-5 -translate-y-1/2 rounded-sm opacity-0 hover:bg-accent focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/60 group-hover/side-panel-tab:opacity-100"
: "right-1 size-6 rounded-lg hover:bg-background/70 focus-visible:ring-2 focus-visible:ring-ring/60",
)}
>
<X className="size-3" aria-hidden />
<X className={appearance === "streamlined-task" ? "size-3.5" : "size-3"} aria-hidden />
</button>
) : null}
</div>
@@ -101,6 +101,193 @@ describe("SidePanelTabs", () => {
expect(separators[0]?.parentElement?.querySelector('[data-side-panel-tab-target="inactive-two"]')).not.toBeNull();
});
it("restores the pre-rebase Streamlined UI tab treatment", () => {
act(() => {
root.render(
<TooltipProvider>
<SidePanelTabs
tabs={[
{ id: "properties", type: "view", label: "Properties", icon: <SlidersHorizontal />, closable: true },
{ id: "document:plan", type: "document", label: "Implementation plan", icon: <FileText />, closable: true },
]}
activeTabId="properties"
onActiveTabChange={vi.fn()}
onCloseTab={vi.fn()}
onAddTab={vi.fn()}
appearance="streamlined-task"
/>
</TooltipProvider>,
);
});
const tabs = container.querySelectorAll<HTMLButtonElement>('[role="tab"]');
expect(tabs).toHaveLength(2);
expect(tabs[0]?.className).toContain("text-sm");
expect(tabs[0]?.querySelector("svg")).toBeNull();
expect(container.querySelectorAll('[data-side-panel-tab-separator="true"]')).toHaveLength(1);
const propertiesWrapper = container.querySelector<HTMLElement>('[data-side-panel-tab-wrapper="properties"]');
expect(propertiesWrapper?.className).toContain("mx-1.5");
expect(propertiesWrapper?.className).toContain("h-7");
expect(propertiesWrapper?.className).toContain("text-foreground");
expect(propertiesWrapper?.className).toContain("hover:bg-accent/50");
expect(propertiesWrapper?.className).not.toContain("bg-muted");
expect(propertiesWrapper?.style.width).toBe("");
expect(propertiesWrapper?.parentElement?.className)
.toContain("min-w-(--side-panel-streamlined-tab-min-width)");
expect(propertiesWrapper?.parentElement?.className)
.toContain("max-w-(--side-panel-streamlined-tab-max-width)");
const planLabel = container.querySelector<HTMLElement>('[data-side-panel-tab-target="document:plan"] span');
expect(planLabel?.className).toContain("task-detail-pane-tab-label");
expect(planLabel?.className).toContain("side-panel-tab-label-close-fade");
expect(planLabel?.className).toContain("text-center");
const tabList = container.querySelector<HTMLElement>('[role="tablist"]');
expect(tabList?.className).toContain("overflow-x-auto");
expect(tabList?.className).not.toContain("overflow-hidden");
expect(tabList?.firstElementChild?.className).toContain("w-max");
expect(tabList?.firstElementChild?.className).toContain("min-w-full");
const planCloseButton = container.querySelector<HTMLButtonElement>('button[aria-label="Close Implementation plan"]');
expect(planCloseButton?.className).toContain("opacity-0");
expect(planCloseButton?.className).toContain("right-0");
expect(planCloseButton?.className).toContain("side-panel-tab-close-motion");
expect(planCloseButton?.className).not.toContain("side-panel-tab-motion");
expect(planCloseButton?.className).not.toContain("group-focus-within/side-panel-tab:opacity-100");
const addButton = container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]');
expect(addButton?.className).toContain("h-(--side-panel-tab-height)");
expect(addButton?.className).toContain("w-(--side-panel-tab-height)");
expect(addButton?.className).toContain("hover:bg-accent");
});
it("lets a lone Streamlined tab fill the space before the add action", () => {
act(() => {
root.render(
<TooltipProvider>
<SidePanelTabs
tabs={[
{
id: "properties",
type: "view",
label: "Properties",
closable: true,
},
]}
activeTabId="properties"
onActiveTabChange={vi.fn()}
onCloseTab={vi.fn()}
onAddTab={vi.fn()}
appearance="streamlined-task"
/>
</TooltipProvider>,
);
});
const tabList = container.querySelector<HTMLElement>('[role="tablist"]');
const tabTrack = tabList?.firstElementChild;
const propertiesWrapper = container.querySelector<HTMLElement>(
'[data-side-panel-tab-wrapper="properties"]',
)?.parentElement;
expect(tabTrack?.className).toContain("w-full");
expect(tabTrack?.className).not.toContain("w-max");
expect(propertiesWrapper?.className).toContain("max-w-none");
expect(propertiesWrapper?.className).toContain("flex-1");
expect(propertiesWrapper?.className).not.toContain(
"max-w-(--side-panel-streamlined-tab-max-width)",
);
});
it("enables the tab flyout only while its label is truncated", () => {
const scrollWidth = vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(72);
const clientWidth = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(96);
const renderLabel = (label: string) => {
act(() => {
root.render(
<TooltipProvider>
<SidePanelTabs
tabs={[{ id: "properties", type: "view", label, closable: true }]}
activeTabId="properties"
onActiveTabChange={vi.fn()}
onCloseTab={vi.fn()}
appearance="streamlined-task"
/>
</TooltipProvider>,
);
});
};
renderLabel("Properties");
const tab = container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="properties"]')!;
const label = tab.querySelector<HTMLElement>(".task-detail-pane-tab-label")!;
expect(tab.dataset.sidePanelTabTooltip).toBe("disabled");
expect(label.dataset.truncated).toBeUndefined();
scrollWidth.mockReturnValue(128);
clientWidth.mockReturnValue(72);
renderLabel("Properties panel");
expect(tab.dataset.sidePanelTabTooltip).toBe("enabled");
expect(label.dataset.truncated).toBe("true");
});
it("fades the right edge only while more Streamlined tabs remain", () => {
act(() => {
root.render(
<TooltipProvider>
<SidePanelTabs
tabs={[
{ id: "properties", type: "view", label: "Properties", closable: true },
{ id: "subtasks", type: "view", label: "Subtasks", closable: true },
{ id: "artifacts", type: "view", label: "Artifacts", closable: true },
]}
activeTabId="properties"
onActiveTabChange={vi.fn()}
onCloseTab={vi.fn()}
onAddTab={vi.fn()}
appearance="streamlined-task"
/>
</TooltipProvider>,
);
});
const tabList = container.querySelector<HTMLElement>('[role="tablist"]')!;
Object.defineProperties(tabList, {
clientWidth: { configurable: true, value: 152 },
scrollWidth: { configurable: true, value: 384 },
scrollLeft: { configurable: true, value: 0, writable: true },
});
act(() => tabList.dispatchEvent(new Event("scroll")));
expect(tabList.dataset.scrollEndFade).toBe("true");
tabList.scrollLeft = 232;
act(() => tabList.dispatchEvent(new Event("scroll")));
expect(tabList.dataset.scrollEndFade).toBeUndefined();
});
it("selects an inactive Streamlined UI tab without closing it", () => {
const onActiveTabChange = vi.fn();
const onCloseTab = vi.fn();
act(() => {
root.render(
<TooltipProvider>
<SidePanelTabs
tabs={[
{ id: "properties", type: "view", label: "Properties", closable: true },
{ id: "subtasks", type: "view", label: "Subtasks", closable: true },
]}
activeTabId="properties"
onActiveTabChange={onActiveTabChange}
onCloseTab={onCloseTab}
appearance="streamlined-task"
/>
</TooltipProvider>,
);
});
act(() => container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="subtasks"]')?.click());
expect(onActiveTabChange).toHaveBeenCalledWith("subtasks");
expect(onCloseTab).not.toHaveBeenCalled();
});
it("keeps intrinsic tab width stable while giving inactive labels the close-button space", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
x: 0,
+90 -10
View File
@@ -33,6 +33,8 @@ import type { SidePanelTabItem } from "./types";
interface SortableSidePanelTabProps {
tab: SidePanelTabItem;
active: boolean;
appearance: "default" | "streamlined-task";
fillAvailableWidth: boolean;
showLeadingSeparator: boolean;
onSelect: () => void;
onClose: () => void;
@@ -40,7 +42,17 @@ interface SortableSidePanelTabProps {
onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
}
function SortableSidePanelTab({ tab, active, showLeadingSeparator, onSelect, onClose, onAuxClick, onKeyDown }: SortableSidePanelTabProps) {
function SortableSidePanelTab({
tab,
active,
appearance,
fillAvailableWidth,
showLeadingSeparator,
onSelect,
onClose,
onAuxClick,
onKeyDown,
}: SortableSidePanelTabProps) {
const sortable = useSortable({ id: tab.id, disabled: tab.disabled });
return (
<div
@@ -49,13 +61,28 @@ function SortableSidePanelTab({ tab, active, showLeadingSeparator, onSelect, onC
transform: DndCSS.Transform.toString(sortable.transform),
transition: sortable.transition,
}}
className={cn("relative", sortable.isDragging && "z-20 opacity-80")}
className={cn(
appearance === "streamlined-task"
? cn(
"relative flex flex-1 items-center",
fillAvailableWidth
? "min-w-0 max-w-none basis-auto"
: "min-w-(--side-panel-streamlined-tab-min-width) max-w-(--side-panel-streamlined-tab-max-width) basis-0",
)
: "relative",
sortable.isDragging && "z-20 opacity-80",
)}
>
{showLeadingSeparator ? (
<span
aria-hidden
data-side-panel-tab-separator="true"
className="pointer-events-none absolute inset-y-2 -left-0.5 w-px bg-border/60"
className={cn(
"pointer-events-none absolute w-px bg-border",
appearance === "streamlined-task"
? "left-0 top-1/2 h-4 -translate-y-1/2"
: "inset-y-2 -left-0.5 bg-border/60",
)}
/>
) : null}
<SidePanelTab
@@ -65,6 +92,7 @@ function SortableSidePanelTab({ tab, active, showLeadingSeparator, onSelect, onC
icon={tab.icon}
status={tab.status}
active={active}
appearance={appearance}
closable={tab.closable}
disabled={tab.disabled}
tabRef={sortable.setActivatorNodeRef}
@@ -90,6 +118,7 @@ export interface SidePanelTabsProps {
onAddTab?: () => void;
addControl?: ReactNode;
addLabel?: string;
appearance?: "default" | "streamlined-task";
className?: string;
}
@@ -102,9 +131,11 @@ export function SidePanelTabs({
onAddTab,
addControl,
addLabel = "Open a new tab",
appearance = "default",
className,
}: SidePanelTabsProps) {
const [announcement, setAnnouncement] = useState("");
const [showEndFade, setShowEndFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const addButtonRef = useRef<HTMLButtonElement>(null);
const addControlRef = useRef<HTMLDivElement>(null);
@@ -133,6 +164,27 @@ export function SidePanelTabs({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId]);
useEffect(() => {
const element = scrollRef.current;
if (!element || appearance !== "streamlined-task") {
setShowEndFade(false);
return;
}
const updateEndFade = () => {
const remainingScroll = element.scrollWidth - element.clientWidth - element.scrollLeft;
setShowEndFade(remainingScroll > 1);
};
updateEndFade();
element.addEventListener("scroll", updateEndFade, { passive: true });
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateEndFade);
observer?.observe(element);
if (element.firstElementChild) observer?.observe(element.firstElementChild);
return () => {
element.removeEventListener("scroll", updateEndFade);
observer?.disconnect();
};
}, [appearance, tabIds]);
function focusTab(tabId: string | null) {
window.requestAnimationFrame(() => {
if (!tabId) {
@@ -198,25 +250,48 @@ export function SidePanelTabs({
}
return (
<div className={cn("flex min-w-0 flex-1 items-center gap-1", className)}>
<div className={cn(
"flex min-w-0 flex-1 items-center",
appearance === "streamlined-task" ? "gap-0" : "gap-1",
className,
)}>
<div
ref={scrollRef}
role="tablist"
aria-orientation="horizontal"
className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
data-scroll-end-fade={appearance === "streamlined-task" && showEndFade ? "true" : undefined}
className={cn(
"side-panel-tabs-scroll min-w-0 flex-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
appearance === "streamlined-task"
? "overflow-x-auto overscroll-x-contain"
: "overflow-x-auto",
)}
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<div className="flex min-w-max items-center gap-1 py-1">
<div className={cn(
"flex items-center",
appearance === "streamlined-task"
? tabs.length === 1
? "w-full gap-0"
: "w-max min-w-full gap-0"
: "min-w-max gap-1 py-1",
)}>
{tabs.map((tab, index) => (
<SortableSidePanelTab
key={tab.id}
tab={tab}
active={tab.id === activeTabId}
appearance={appearance}
fillAvailableWidth={
appearance === "streamlined-task" && tabs.length === 1
}
showLeadingSeparator={
index > 0
&& tab.id !== activeTabId
&& tabs[index - 1]?.id !== activeTabId
appearance === "streamlined-task"
? index > 0
: index > 0
&& tab.id !== activeTabId
&& tabs[index - 1]?.id !== activeTabId
}
onSelect={() => onActiveTabChange(tab.id)}
onClose={() => closeTab(tab.id)}
@@ -241,7 +316,12 @@ export function SidePanelTabs({
onClick={onAddTab}
aria-label={addLabel}
title={addLabel}
className="h-(--side-panel-tab-height) w-(--side-panel-tab-height) shrink-0 rounded-(--side-panel-control-radius) text-muted-foreground hover:text-foreground focus-visible:text-foreground"
className={cn(
"shrink-0 text-muted-foreground hover:text-foreground focus-visible:text-foreground",
appearance === "streamlined-task"
? "h-(--side-panel-tab-height) w-(--side-panel-tab-height) rounded-md"
: "h-(--side-panel-tab-height) w-(--side-panel-tab-height) rounded-(--side-panel-control-radius)",
)}
>
<Plus aria-hidden />
</Button>
@@ -10,6 +10,7 @@ import {
type WaitingBlockerStep,
} from "@/lib/issue-blockers";
import { Link } from "@/lib/router";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
function isUnresolved(blocker: IssueRelationIssueSummary): boolean {
return blocker.status !== "done" && blocker.status !== "cancelled";
@@ -100,9 +101,20 @@ function BlockerRow({
label: string;
blocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
}) {
const streamlined = useStreamlinedTaskChatPresentation();
const issuePathId = blocker.identifier ?? blocker.id;
return (
return streamlined ? (
<Link
to={createIssueDetailPath(issuePathId)}
className="flex min-w-0 items-baseline gap-1.5 rounded px-1 py-0.5 text-amber-800 underline-offset-2 transition-colors hover:bg-accent/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-200"
title={`${blocker.identifier ?? blocker.id.slice(0, 8)} — ${blocker.title}`}
>
<span className="shrink-0 font-medium">{label}</span>
<span className="shrink-0 font-mono">{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="truncate text-amber-700/80 dark:text-amber-300/80">{blocker.title}</span>
</Link>
) : (
<div className="flex min-w-0 items-baseline gap-1.5 whitespace-nowrap">
<span className="shrink-0 font-medium">{label}</span>
<Link
@@ -146,16 +158,25 @@ function LiveWorkGlyph({ status }: { status: WaitingBlockerStatus }) {
function LiveWorkLink({
blocker,
status,
label,
}: {
blocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
status: WaitingBlockerStatus;
label?: string;
}) {
const streamlined = useStreamlinedTaskChatPresentation();
const issuePathId = blocker.identifier ?? blocker.id;
return (
<Link
to={createIssueDetailPath(issuePathId)}
className="flex min-w-0 items-baseline gap-1 text-blue-800 underline-offset-2 hover:underline dark:text-blue-200"
className={streamlined
? "flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-blue-800 underline-offset-2 transition-colors hover:bg-accent/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-blue-200"
: "flex min-w-0 items-baseline gap-1 text-blue-800 underline-offset-2 hover:underline dark:text-blue-200"}
title={`${blocker.identifier ?? blocker.id.slice(0, 8)} — ${blocker.title}`}
>
{streamlined && label ? <span className="shrink-0 font-medium">{label}</span> : null}
{streamlined ? <LiveWorkGlyph status={status} /> : null}
<span className="shrink-0 font-mono">{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="truncate text-blue-700/80 dark:text-blue-300/80">{blocker.title}</span>
</Link>
@@ -171,6 +192,11 @@ export function TaskChatBlockerLinks({
ultimateBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary | null;
placement: "top" | "bottom";
}) {
const streamlined = useStreamlinedTaskChatPresentation();
const directLabel = streamlined && placement === "bottom" ? "Still blocked by" : "Blocked by";
const rootLabel = streamlined
? placement === "bottom" ? "Root blocker remains" : "Root blocker"
: "Ultimately blocked by";
return (
<div
aria-label="Task blockers"
@@ -178,9 +204,9 @@ export function TaskChatBlockerLinks({
data-testid="task-chat-blocker-links"
className="flex min-w-0 flex-col gap-1 overflow-hidden text-(length:--text-micro) leading-4 text-amber-700 dark:text-amber-300"
>
<BlockerRow label="Blocked by" blocker={directBlocker} />
<BlockerRow label={directLabel} blocker={directBlocker} />
{ultimateBlocker ? (
<BlockerRow label="Ultimately blocked by" blocker={ultimateBlocker} />
<BlockerRow label={rootLabel} blocker={ultimateBlocker} />
) : null}
</div>
);
@@ -193,6 +219,8 @@ export function TaskChatLiveWorkLinks({
liveWork: ResolvedTaskChatLiveWork;
placement: "top" | "bottom";
}) {
const streamlined = useStreamlinedTaskChatPresentation();
const heading = streamlined && placement === "bottom" ? "Still waiting on live work" : "Waiting on live work";
return (
<div
aria-label="Tasks waiting on live work"
@@ -204,27 +232,33 @@ export function TaskChatLiveWorkLinks({
<span className="flex h-3.5 w-3.5 items-center justify-center" aria-hidden>
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-400" />
</span>
Waiting on live work
{heading}
</div>
<ol className="flex min-w-0 flex-col gap-1">
{liveWork.steps.map(({ blocker, status }, index) => (
<li
key={blocker.id}
data-testid="task-chat-live-work-step"
className="flex min-w-0 items-center gap-1.5 whitespace-nowrap"
className={streamlined ? "min-w-0 whitespace-nowrap" : "flex min-w-0 items-center gap-1.5 whitespace-nowrap"}
>
<span className="w-4 shrink-0 text-right font-mono text-blue-500/80" aria-hidden>
{index + 1}.
</span>
<LiveWorkGlyph status={status} />
<LiveWorkLink blocker={blocker} />
{!streamlined ? (
<>
<span className="w-4 shrink-0 text-right font-mono text-blue-500/80" aria-hidden>
{index + 1}.
</span>
<LiveWorkGlyph status={status} />
</>
) : null}
<LiveWorkLink blocker={blocker} status={status} />
</li>
))}
</ol>
{liveWork.nowRunning.map((blocker) => (
{liveWork.nowRunning.map((blocker) => streamlined ? (
<LiveWorkLink key={blocker.id} blocker={blocker} status="running" label="Now running" />
) : (
<div key={blocker.id} className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
<span className="shrink-0 font-medium">Now running</span>
<LiveWorkLink blocker={blocker} />
<LiveWorkLink blocker={blocker} status="running" />
</div>
))}
</div>
@@ -406,7 +406,7 @@ describe("TaskChatBubble footer actions (PAP-413)", () => {
expect(slot?.querySelector('[data-testid="fake-actions"]')).toBeNull();
});
it("leads a runless agent reply with actions, timestamp trailing", () => {
it("keeps runless timestamp left and actions at the stable right edge", () => {
render(
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
{ actions },
@@ -414,6 +414,9 @@ describe("TaskChatBubble footer actions (PAP-413)", () => {
expect(container.querySelector('[data-testid="fake-actions"]')).not.toBeNull();
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
expect(stamp).not.toBeNull();
const actionNode = container.querySelector('[data-testid="fake-actions"]')!;
expect(stamp!.compareDocumentPosition(actionNode) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
expect(actionNode.parentElement?.className).toContain("justify-between");
});
it("omits the actions row entirely when none are supplied (human bubble)", () => {
+21 -10
View File
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import { MarkdownBody } from "@/components/MarkdownBody";
import {
ImageGalleryModal,
@@ -139,6 +140,7 @@ export function TaskChatBubble({
onTryAgainNoLiveExecutionPath,
tryAgainNoLiveExecutionPathPending,
}: TaskChatBubbleProps) {
const streamlined = useStreamlinedTaskChatPresentation();
// Clicking an embedded image opens the full-screen lightbox (with download);
// arrow keys walk across the other images in the same bubble.
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
@@ -364,16 +366,25 @@ export function TaskChatBubble({
{attachedTurn}
</div>
) : actions ? (
// Agent reply without run activity: the actions still lead the footer,
// with the always-visible timestamp trailing (PAP-413).
<div className="flex items-center gap-1">
{actions}
{item.timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.timestamp}
</span>
) : null}
</div>
streamlined ? (
<div className="flex w-full items-center justify-between gap-2 px-1">
{item.timestamp ? (
<span className="text-(length:--text-micro) text-muted-foreground">
{item.timestamp}
</span>
) : null}
{actions}
</div>
) : (
<div className="flex items-center gap-1">
{actions}
{item.timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.timestamp}
</span>
) : null}
</div>
)
) : item.timestamp ? (
// Timestamps are always visible (round 9) — no longer hover-revealed.
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
@@ -293,7 +293,24 @@ describe("TaskChatComposer", () => {
expect(composer?.className).not.toContain("p-2");
});
it("renders the composer shell and its selectors without outlines", () => {
it("leaves an 8px token gap between the editor and action row", () => {
render(
<TaskChatComposer
onAdd={async () => {}}
workMode="planning"
onWorkModeChange={async () => {}}
/>,
);
const actions = container.querySelector(
'[data-testid="task-chat-composer-actions"]',
);
expect(actions?.classList).toContain("mt-2");
expect(actions?.classList).not.toContain("mt-1");
});
it("renders a light card shell while preserving the borderless dark treatment", () => {
render(
<TaskChatComposer
onAdd={async () => {}}
@@ -313,9 +330,14 @@ describe("TaskChatComposer", () => {
'[data-testid="task-chat-composer-assignee"]',
)!;
expect(composer.classList).not.toContain("border");
expect(composer.className).not.toContain("shadow-");
expect(composer.className).not.toContain("focus-within:ring-");
expect(composer.classList).toContain("border");
expect(composer.classList).toContain("border-border");
expect(composer.classList).toContain("bg-card");
expect(composer.classList).toContain("shadow-(--shadow-task-composer)");
expect(composer.classList).toContain("dark:border-0");
expect(composer.classList).toContain("dark:bg-muted");
expect(composer.classList).toContain("dark:shadow-none");
expect(composer.className).not.toContain("focus-within:ring");
expect(mode.classList).not.toContain("border");
expect(mode.className).not.toContain("ring-");
expect(runner.classList).toContain("border-0");
@@ -450,6 +472,34 @@ describe("TaskChatComposer", () => {
expect(onWorkModeChange).not.toHaveBeenCalled();
});
it("uses the borderless Paper controls and inverse circular send button", () => {
render(
<TaskChatComposer
onAdd={vi.fn()}
workMode="standard"
onWorkModeChange={vi.fn()}
enableReassign
reassignOptions={[{ id: "agent:a1", label: "Chief of Staff" }]}
currentAssigneeValue="agent:a1"
/>,
);
const mode = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-mode"]')!;
const assignee = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-assignee"]')!;
const send = sendButton();
expect(mode.classList).not.toContain("border");
expect(mode.classList).toContain("border-0");
expect(mode.classList).toContain("status-chip");
expect(mode.style.getPropertyValue("--sc")).toBe("var(--tc-mode-agent)");
expect(assignee.classList).toContain("border-0");
expect(assignee.classList).toContain("shadow-none");
expect(send.classList).toContain("rounded-full");
expect(send.classList).toContain("bg-foreground");
expect(send.classList).toContain("text-background");
expect(send.classList).toContain("disabled:opacity-100");
});
it("passes reopen=true when the issue resumes-to-todo and the assignee is an agent", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(
@@ -8,6 +8,7 @@ import {
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import {
DRAFT_DEBOUNCE_MS,
clearDraft,
@@ -269,7 +270,7 @@ function escapeMarkdownLabel(name: string): string {
* Composer for the redesigned thread (v7 spec): the shared MarkdownEditor
* (rich lists, @-mentions, /-commands, inline pasted images) over a 32px
* comp-bar of [attach] [mode chip] … [assignee] [send]. The mode chip is a
* status-chip rectangle carrying the pending mode's hue; the composer chrome
* borderless filled control carrying the pending mode's hue; the composer chrome
* itself stays neutral. Cmd/Ctrl+. and Shift+Tab cycle modes (captured before
* Lexical); Cmd/Ctrl+Enter posts via the editor's native onSubmit; plain Enter
* stays a newline / next list item. Pasted or dropped images upload through
@@ -302,6 +303,7 @@ export function TaskChatComposer({
takeover = null,
pendingTakeover = null,
}: TaskChatComposerProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
const [submitting, setSubmitting] = useState(false);
const [takeoverBusy, setTakeoverBusy] = useState(false);
@@ -677,7 +679,9 @@ export function TaskChatComposer({
return (
<div
className={cn(
"paperclip-task-chat-composer rounded-xl bg-card p-(--sz-18px)",
streamlined
? "paperclip-task-chat-composer rounded-(--radius-task-composer) border border-border bg-card p-(--sz-18px) shadow-(--shadow-task-composer) dark:border-0 dark:bg-muted dark:shadow-none"
: "paperclip-task-chat-composer rounded-xl bg-card p-(--sz-18px)",
)}
onKeyDownCapture={(e) => {
// Capture mode shortcuts on the wrapper so they work while the rich
@@ -882,7 +886,10 @@ export function TaskChatComposer({
</AttachmentGroup>
) : null}
<div className="mt-1 flex items-center gap-2">
<div
className="mt-2 flex items-center gap-2"
data-testid="task-chat-composer-actions"
>
{canAcceptFiles ? (
<>
<input
@@ -918,7 +925,12 @@ export function TaskChatComposer({
type="button"
disabled={disabled || !onWorkModeChange}
aria-keyshortcuts="Meta+Period Control+Period Shift+Tab"
className="status-chip flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition hover:brightness-110 focus-visible:brightness-110 focus-visible:outline-none disabled:opacity-50"
className={cn(
"flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium disabled:opacity-50",
streamlined
? "status-chip border-0 transition hover:brightness-110 focus-visible:brightness-110 focus-visible:outline-none"
: "status-chip transition hover:brightness-110 focus-visible:brightness-110 focus-visible:outline-none",
)}
style={{ "--sc": modeHue(pendingMode) } as CSSProperties}
data-testid="task-chat-composer-mode"
data-pending-work-mode={pendingMode}
@@ -927,7 +939,11 @@ export function TaskChatComposer({
<ChevronDown className="h-3 w-3" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuContent
align="start"
className="flex w-(--sz-300px) flex-col gap-0.5"
data-testid="task-chat-composer-mode-menu"
>
{workModeMetaList().map((m) => {
const Icon = m.icon;
const selected = m.value === pendingMode;
@@ -950,7 +966,7 @@ export function TaskChatComposer({
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{m.label}</span>
<span className="text-xs text-muted-foreground">
<span className="whitespace-nowrap text-xs text-muted-foreground">
{MODE_DESCRIPTION[m.value] ?? ""}
</span>
</span>
@@ -1052,7 +1068,12 @@ export function TaskChatComposer({
: "Save queued message"
: "Send"
}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-transform hover:scale-105 disabled:scale-100 disabled:bg-muted disabled:text-muted-foreground"
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center transition-transform hover:scale-105 disabled:scale-100",
streamlined
? "rounded-full bg-foreground text-background disabled:bg-foreground disabled:text-background disabled:opacity-100"
: "rounded-md bg-primary text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
)}
data-testid="task-chat-composer-send"
>
{submitting ? (
@@ -86,6 +86,7 @@ describe("TaskChatDescriptionBubble (PAP-375)", () => {
expect(body?.textContent).toContain("Ship the widget by");
// Markdown renders (bold), not raw asterisks.
expect(body?.querySelector("strong")?.textContent).toBe("Friday");
expect(bubble?.querySelector('[data-testid="task-chat-description-edit"]')).toBeNull();
});
it("renders an agent-created task as the agent-side bubble with the avatar author header", () => {
@@ -106,8 +107,8 @@ describe("TaskChatDescriptionBubble (PAP-375)", () => {
expect(container.textContent).not.toContain("Ship the widget");
});
it("swaps to the InlineEditor on pencil click and returns to the bubble on Escape", () => {
render(makeBrief());
it("keeps the agent-side pencil editor and returns to the bubble on Escape", () => {
render(makeBrief({ author: "agent", authorName: "CEO" }));
click(container.querySelector('[data-testid="task-chat-description-edit"]'));
const editorWrap = container.querySelector('[data-testid="task-chat-description-editor"]');
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more