mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
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:
commit
cb9f13e6df
192 files changed
+38922
-3786
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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&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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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 }));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={[
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 & 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"
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user