feat(ui): refine core navigation and task detail (#12854)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The main navigation and task detail view are core operator surfaces. > - Several controls used different hover states, popover layouts, and spacing rules. > - Recent task actions also needed a compact menu and correct inbox archive behavior. > - These differences made the interface feel inconsistent and caused some content to look crowded or clipped. > - This pull request aligns these surfaces with the Paperclip design tokens and current interaction patterns. > - The benefit is a simpler and more consistent operator experience in light and dark modes. ## Linked Issues or Issue Description **What happened?** Profile and organization popovers used inconsistent layouts. Navigation controls used different hover and selected backgrounds. Task warnings and the composer could crowd nearby content. Archiving a recent task could also remove it from more than the inbox. **Expected behavior** Popover menus should use the same compact visual language. Navigation controls should share readable hover and selected tokens. Task detail content should keep consistent spacing. Archiving should hide a task from the inbox while keeping it in the task list. **Steps to reproduce** 1. Open the main sidebar in light or dark mode. 2. Open the profile and organization menus. 3. Hover navigation items, the organization trigger, the profile trigger, and the feedback flag. 4. Open a task with a warning banner and a long thread. 5. Use the recent task overflow menu and archive a task. **Paperclip version or commit** Reproduced on `master` before this branch. **Deployment mode** Local dev (`pnpm dev`). ## What Changed - Rebuilt the profile and organization popovers with compact token-based layouts. - Matched organization popover width and alignment to the profile popover. - Unified sidebar hover and selected states in light and dark modes. - Added a recent task overflow menu with rename, archive, and pause or restart actions. - Kept archived tasks in the task list while removing them from the inbox. - Improved warning banner and composer spacing in task detail views. - Added and updated focused UI tests for the changed behavior. ## Verification - `pnpm check:token-gates` passed. - `pnpm --filter @paperclipai/ui typecheck` passed. - The seven affected UI test files passed with 216 tests. - `pnpm --filter @paperclipai/ui build` passed. - GitHub CI passed the full build, typecheck and release registry, general test, serialized server, canary dry-run, and end-to-end matrices. - Greptile reviewed commit `6e296be85` at 5/5 with no outstanding actionable findings. ## Risks - Risk is limited to sidebar presentation, recent task actions, and task detail layout. - The recent task archive action now follows inbox-only archive semantics. - No database schema or public API contract changed. > I checked [`ROADMAP.md`](ROADMAP.md). This pull request does not duplicate planned core work. ## Model Used - OpenAI Codex, GPT-5.6. The model used high reasoning, tool use, and code execution. The context window size was not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Scott Tong <scott@scottsmbpm5max.lan> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
0ffc091473
commit
5b56d430e9
|
|
@ -141,8 +141,7 @@ export function Sidebar() {
|
|||
data-slot="icon-button"
|
||||
aria-label={rail ? "New Task" : undefined}
|
||||
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",
|
||||
"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 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ export function SidebarAccountMenu({
|
|||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Share feedback"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground/50 transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -125,14 +125,21 @@ describe("SidebarAccountMenu", () => {
|
|||
expect(accountSurface?.className).not.toContain("border-border");
|
||||
const accountTrigger = container.querySelector('button[aria-label="Open account menu"]');
|
||||
expect(accountTrigger?.classList).toContain("rounded-lg");
|
||||
expect(accountTrigger?.classList).toContain("hover:bg-background");
|
||||
expect(accountTrigger?.classList).toContain("hover:bg-sidebar-accent");
|
||||
expect(accountTrigger?.classList).toContain("hover:text-sidebar-accent-foreground");
|
||||
expect(accountTrigger?.classList).not.toContain("hover:bg-background");
|
||||
|
||||
const feedbackButton = container.querySelector<HTMLAnchorElement>(
|
||||
'a[aria-label="Share feedback"]',
|
||||
);
|
||||
expect(feedbackButton?.getAttribute("href")).toBe("https://paperclip.ing/feedback");
|
||||
expect(feedbackButton?.getAttribute("target")).toBe("_blank");
|
||||
expect(feedbackButton?.classList).toContain("hover:bg-background");
|
||||
expect(feedbackButton?.classList).toContain("text-muted-foreground/50");
|
||||
expect(feedbackButton?.classList).not.toContain("text-border");
|
||||
expect(feedbackButton?.classList).not.toContain("text-muted-foreground");
|
||||
expect(feedbackButton?.classList).toContain("hover:bg-sidebar-accent");
|
||||
expect(feedbackButton?.classList).toContain("hover:text-sidebar-accent-foreground");
|
||||
expect(feedbackButton?.classList).not.toContain("hover:bg-background");
|
||||
expect(feedbackButton?.querySelector("svg")?.classList).toContain("lucide-flag");
|
||||
expect(feedbackButton?.getAttribute("data-slot")).toBe("tooltip-trigger");
|
||||
expect(feedbackButton?.hasAttribute("title")).toBe(false);
|
||||
|
|
@ -166,6 +173,9 @@ describe("SidebarAccountMenu", () => {
|
|||
);
|
||||
expect(feedbackButton?.getAttribute("href")).toBe("https://paperclip.ing/feedback");
|
||||
expect(feedbackButton?.getAttribute("target")).toBe("_blank");
|
||||
expect(feedbackButton?.classList).toContain("text-muted-foreground/50");
|
||||
expect(feedbackButton?.classList).not.toContain("text-border");
|
||||
expect(feedbackButton?.classList).not.toContain("text-muted-foreground");
|
||||
expect(feedbackButton?.classList).toContain("hover:bg-accent/50");
|
||||
expect(feedbackButton?.querySelector("svg")?.classList).toContain("lucide-flag");
|
||||
expect(feedbackButton?.getAttribute("data-slot")).toBe("tooltip-trigger");
|
||||
|
|
@ -235,7 +245,15 @@ describe("SidebarAccountMenu", () => {
|
|||
expect(popover?.textContent).not.toContain("Paperclip v");
|
||||
expect(document.body.textContent).toContain("jane@example.com");
|
||||
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
||||
.toContain("w-(--sz-277px)");
|
||||
.toContain("w-(--profile-popover-width)");
|
||||
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
||||
.toContain("rounded-xl");
|
||||
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
||||
.toContain("min-h-(--profile-popover-min-height)");
|
||||
expect(document.body.querySelector('a[href="/company/settings"]')?.className)
|
||||
.not.toContain("bg-muted");
|
||||
expect(document.body.textContent).not.toContain("Manage company and instance settings.");
|
||||
expect(document.body.textContent).not.toContain("Open your activity, task, and usage ledger.");
|
||||
expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull();
|
||||
expect(document.body.querySelector('a[href="/company/settings"]')).not.toBeNull();
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ interface SidebarAccountMenuProps {
|
|||
|
||||
interface MenuActionProps {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
|
|
@ -65,19 +64,22 @@ function deriveUserSlug(name: string | null | undefined, email: string | null |
|
|||
return "me";
|
||||
}
|
||||
|
||||
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
|
||||
function MenuAction({
|
||||
label,
|
||||
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";
|
||||
"flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-accent";
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center 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>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -146,7 +148,7 @@ export function SidebarAccountMenu({
|
|||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2.5 rounded-lg text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-background hover:text-foreground",
|
||||
"flex min-w-0 items-center gap-2.5 rounded-lg text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
rail ? "w-full px-3 py-2" : "flex-1 px-2 py-1.5",
|
||||
)}
|
||||
aria-label="Open account menu"
|
||||
|
|
@ -162,79 +164,69 @@ export function SidebarAccountMenu({
|
|||
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"
|
||||
className="min-h-(--profile-popover-min-height) w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
|
||||
>
|
||||
<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">
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
|
||||
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
|
||||
</div>
|
||||
<div className="flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
|
||||
<Avatar className="size-9">
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback className="text-xs text-foreground">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-semibold leading-(--profile-popover-label-line-height) text-foreground">
|
||||
{displayName}
|
||||
</h2>
|
||||
<p className="truncate text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground">
|
||||
{secondaryLabel}
|
||||
</p>
|
||||
</div>
|
||||
</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."
|
||||
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)}
|
||||
/>
|
||||
<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 className="flex flex-1 flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
|
||||
<MenuAction
|
||||
label="Settings"
|
||||
icon={Settings}
|
||||
href="/company/settings"
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="View profile"
|
||||
icon={UserRound}
|
||||
href={profileHref}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Edit profile"
|
||||
icon={UserRoundPen}
|
||||
href={PROFILE_SETTINGS_PATH}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Documentation"
|
||||
icon={BookOpen}
|
||||
href={DOCS_URL}
|
||||
external
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<ThemeToggle variant="compact-menu-action" onAfterToggle={() => setOpen(false)} />
|
||||
{deploymentMode === "authenticated" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-destructive/10",
|
||||
signOutMutation.isPending && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
onClick={handleSignOut}
|
||||
disabled={signOutMutation.isPending}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<LogOut className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<SidebarServerInfo />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
@ -246,7 +238,7 @@ export function SidebarAccountMenu({
|
|||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Share feedback"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-foreground/80 transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground/50 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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-background 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-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Users className="shrink-0 h-4 w-4" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>See all agents</span>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
|
|
@ -51,16 +50,20 @@ interface SidebarCompanyMenuProps {
|
|||
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";
|
||||
const TRIGGER_WORKSPACE_ICON_CLASS = "size-5 shrink-0 rounded-md text-(length:--text-micro)";
|
||||
const POPOVER_WORKSPACE_ICON_CLASS =
|
||||
"size-(--organization-popover-avatar-size) shrink-0 rounded-lg text-(length:--text-micro)";
|
||||
const ORGANIZATION_ROW_CLASS =
|
||||
"h-(--organization-popover-company-row-height) min-w-0 gap-(--organization-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) focus:bg-accent/50 focus:text-foreground";
|
||||
const ORGANIZATION_ACTION_CLASS =
|
||||
"h-(--organization-popover-action-row-height) gap-(--organization-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--organization-popover-action-line-height) text-foreground focus:bg-accent/50 focus:text-foreground";
|
||||
|
||||
function WorkspaceIcon({ company }: { company: Company }) {
|
||||
function WorkspaceIcon({ company, inPopover = false }: { company: Company; inPopover?: boolean }) {
|
||||
return (
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
className={WORKSPACE_ICON_CLASS}
|
||||
className={inPopover ? POPOVER_WORKSPACE_ICON_CLASS : TRIGGER_WORKSPACE_ICON_CLASS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -71,7 +74,7 @@ function WorkspaceIcon({ company }: { company: Company }) {
|
|||
* uses — seeded by the display name, never fetched.
|
||||
*/
|
||||
function StackIcon({ displayName }: { displayName: string }) {
|
||||
return <CompanyPatternIcon companyName={displayName} className={WORKSPACE_ICON_CLASS} />;
|
||||
return <CompanyPatternIcon companyName={displayName} className={POPOVER_WORKSPACE_ICON_CLASS} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,7 +94,7 @@ function CurrentStackIcon({
|
|||
<CompanyPatternIcon
|
||||
companyName={displayName}
|
||||
logoUrl={company?.logoUrl}
|
||||
className={WORKSPACE_ICON_CLASS}
|
||||
className={TRIGGER_WORKSPACE_ICON_CLASS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -108,19 +111,26 @@ function CloudStackItem({
|
|||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onSelect(stack)}
|
||||
className={cn("min-w-0 gap-2 py-2", isSelected && "bg-accent text-accent-foreground")}
|
||||
className={ORGANIZATION_ROW_CLASS}
|
||||
>
|
||||
<StackIcon displayName={stack.displayName} />
|
||||
<span className="min-w-0 flex-1 truncate" title={stack.displayName}>
|
||||
{stack.displayName}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate font-medium leading-(--organization-popover-name-line-height)"
|
||||
title={stack.displayName}
|
||||
>
|
||||
{stack.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground"
|
||||
title={stack.stackSlug}
|
||||
>
|
||||
{stack.stackSlug}
|
||||
</span>
|
||||
</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 className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isSelected ? <Check className="size-4 text-foreground" /> : null}
|
||||
</span>
|
||||
{isSelected ? <Check className="size-4 shrink-0 text-muted-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -162,20 +172,28 @@ function SortableCompanyItem({
|
|||
onSelect(company);
|
||||
}}
|
||||
className={cn(
|
||||
"min-w-0 gap-2 py-2",
|
||||
ORGANIZATION_ROW_CLASS,
|
||||
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>
|
||||
<WorkspaceIcon company={company} inPopover />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium leading-(--organization-popover-name-line-height)">
|
||||
{company.name}
|
||||
</span>
|
||||
{isEditing ? null : (
|
||||
<span className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground">
|
||||
{company.issuePrefix}
|
||||
</span>
|
||||
)}
|
||||
</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"
|
||||
className="inline-flex size-8 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();
|
||||
|
|
@ -186,10 +204,9 @@ function SortableCompanyItem({
|
|||
<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}
|
||||
</>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isSelected ? <Check className="size-4 text-foreground" /> : null}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
|
@ -384,10 +401,14 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
{!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"}
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="ml-2 w-(--organization-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
|
||||
>
|
||||
<div className="flex h-(--organization-popover-header-height) items-center justify-between gap-2 px-3.5">
|
||||
<DropdownMenuLabel className="p-0 text-(length:--text-compact) font-semibold text-foreground">
|
||||
Organizations
|
||||
</DropdownMenuLabel>
|
||||
{/* Stack order is owned by cloud's own portfolio in v1, so the
|
||||
drag-to-reorder affordance stays self-hosted-only. */}
|
||||
|
|
@ -399,13 +420,13 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
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"
|
||||
className="rounded px-1.5 py-0.5 text-xs 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">
|
||||
<div className="flex max-h-96 flex-col gap-0.5 overflow-y-auto px-2.5 pb-2 pt-1">
|
||||
{isCloud ? (
|
||||
<>
|
||||
{stacks.map((stack) => (
|
||||
|
|
@ -474,54 +495,63 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
</>
|
||||
)}
|
||||
</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 : (
|
||||
<>
|
||||
<div className="flex flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
|
||||
{/* A cloud instance without a configured cloud origin has nowhere to
|
||||
send the user, so the row drops out entirely. */}
|
||||
{isCloud && !createStackUrl ? null : (
|
||||
<DropdownMenuItem
|
||||
onClick={addCompany}
|
||||
className="gap-2 py-2 text-muted-foreground"
|
||||
className={ORGANIZATION_ACTION_CLASS}
|
||||
disabled={isEditingOrder}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<span>Create new organization...</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{showInvitePeople ? (
|
||||
<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 className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Plus className="size-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{session?.session ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<span className="min-w-0 flex-1 truncate">Create organization</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showInvitePeople ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
asChild
|
||||
disabled={isEditingOrder}
|
||||
className={ORGANIZATION_ACTION_CLASS}
|
||||
>
|
||||
<Link
|
||||
to="/company/settings/invites"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<UserPlus className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{currentName
|
||||
? `Invite people to ${currentName}`
|
||||
: "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{session?.session ? (
|
||||
<DropdownMenuItem
|
||||
className={ORGANIZATION_ACTION_CLASS}
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
disabled={isEditingOrder || signOutMutation.isPending}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<LogOut className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -296,16 +296,20 @@ describe("SidebarCompanyMenu", () => {
|
|||
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");
|
||||
expect(trigger?.classList).toContain("hover:bg-sidebar-accent");
|
||||
expect(trigger?.classList).toContain("hover:text-sidebar-accent-foreground");
|
||||
expect(trigger?.classList).toContain("dark:hover:bg-sidebar-accent");
|
||||
expect(trigger?.classList).toContain("dark:hover:text-sidebar-accent-foreground");
|
||||
expect(trigger?.classList).not.toContain("hover:bg-background");
|
||||
expect(trigger?.classList).not.toContain("dark:hover:bg-background");
|
||||
expect(trigger?.classList).not.toContain("dark:hover:bg-accent/50");
|
||||
act(() => {
|
||||
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Create new organization...");
|
||||
expect(document.body.textContent).toContain("Create organization");
|
||||
expect(document.body.textContent).not.toContain("Add company...");
|
||||
|
||||
act(() => {
|
||||
|
|
@ -343,11 +347,11 @@ describe("SidebarCompanyMenu", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Switch organization");
|
||||
expect(document.body.textContent).toContain("Organizations");
|
||||
expect(document.body.textContent).toContain("Edit");
|
||||
expect(document.body.textContent).toContain("Strata");
|
||||
expect(document.body.textContent).toContain("ANA");
|
||||
expect(document.body.textContent).toContain("Create new organization...");
|
||||
expect(document.body.textContent).toContain("Create organization");
|
||||
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
||||
expect(document.body.textContent).not.toContain("Company settings");
|
||||
expect(document.body.textContent).toContain("Sign out");
|
||||
|
|
@ -364,7 +368,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
expect(mockAuthApi.signOut).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
||||
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true);
|
||||
expect(document.body.textContent).not.toContain("Switch organization");
|
||||
expect(document.body.textContent).not.toContain("Organizations");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
|
|
@ -413,7 +417,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
|
||||
await openMenu("Open Acme Labs company switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Switch");
|
||||
expect(document.body.textContent).toContain("Organizations");
|
||||
expect(document.body.textContent).not.toContain("Invite people");
|
||||
|
||||
act(() => {
|
||||
|
|
@ -430,7 +434,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Switch organization");
|
||||
expect(document.body.textContent).toContain("Organizations");
|
||||
expect(document.body.textContent).not.toContain("Invite people");
|
||||
|
||||
act(() => {
|
||||
|
|
@ -447,7 +451,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Switch organization");
|
||||
expect(document.body.textContent).toContain("Organizations");
|
||||
expect(document.body.textContent).not.toContain("Invite people");
|
||||
|
||||
act(() => {
|
||||
|
|
@ -464,7 +468,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Switch organization");
|
||||
expect(document.body.textContent).toContain("Organizations");
|
||||
expect(document.body.textContent).not.toContain("Invite people");
|
||||
|
||||
act(() => {
|
||||
|
|
@ -509,7 +513,11 @@ describe("SidebarCompanyMenu", () => {
|
|||
expect(document.body.textContent).toContain("Done");
|
||||
expect(document.body.textContent).not.toContain("PAP");
|
||||
expect(document.body.textContent).not.toContain("ANA");
|
||||
expect(document.body.querySelector('button[aria-label="Reorder Strata"]')).toBeTruthy();
|
||||
const reorderButton = document.body.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Reorder Strata"]',
|
||||
);
|
||||
expect(reorderButton).toBeTruthy();
|
||||
expect(reorderButton?.classList).toContain("size-8");
|
||||
|
||||
const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Strata"));
|
||||
|
|
@ -579,7 +587,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Create new organization..."));
|
||||
.find((element) => element.textContent?.includes("Create organization"));
|
||||
expect(createItem).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
|
|
@ -644,7 +652,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
expect(mockAuthApi.signOut).not.toHaveBeenCalled();
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledOnce();
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout");
|
||||
expect(document.body.textContent).not.toContain("Switch organization");
|
||||
expect(document.body.textContent).not.toContain("Organizations");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
|
|
@ -659,8 +667,8 @@ describe("SidebarCompanyMenu", () => {
|
|||
expect(mockCloudApi.listStacks).toHaveBeenCalledTimes(1);
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Switch organization");
|
||||
expect(document.body.textContent).toContain("Create new organization...");
|
||||
expect(document.body.textContent).toContain("Organizations");
|
||||
expect(document.body.textContent).toContain("Create organization");
|
||||
expect(document.body.textContent).not.toContain("Organization settings");
|
||||
expect(document.body.textContent).not.toContain("Switch company");
|
||||
expect(document.body.textContent).not.toContain("Create new company...");
|
||||
|
|
@ -677,10 +685,10 @@ describe("SidebarCompanyMenu", () => {
|
|||
|
||||
const currentRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Acme Labs"));
|
||||
expect(currentRow?.className).toContain("bg-accent");
|
||||
expect(currentRow?.classList.contains("bg-accent")).toBe(false);
|
||||
|
||||
// Long slugs must not squeeze the display name out of the row, so the
|
||||
// badge truncates and keeps the full slug on hover.
|
||||
// secondary line truncates and keeps the full slug on hover.
|
||||
const slugBadge = document.body.querySelector('[title="strata"]');
|
||||
expect(slugBadge?.className).toContain("truncate");
|
||||
|
||||
|
|
@ -777,7 +785,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Create new organization..."));
|
||||
.find((element) => element.textContent?.includes("Create organization"));
|
||||
expect(createItem).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
|
|
@ -808,7 +816,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
await openMenu("Open acme-labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Could not load organizations");
|
||||
expect(document.body.textContent).not.toContain("Create new organization...");
|
||||
expect(document.body.textContent).not.toContain("Create organization");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
|
|
@ -51,16 +50,20 @@ interface SidebarCompanyMenuProps {
|
|||
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";
|
||||
const TRIGGER_WORKSPACE_ICON_CLASS = "size-5 shrink-0 rounded-md text-(length:--text-micro)";
|
||||
const POPOVER_WORKSPACE_ICON_CLASS =
|
||||
"size-(--organization-popover-avatar-size) shrink-0 rounded-lg text-(length:--text-micro)";
|
||||
const ORGANIZATION_ROW_CLASS =
|
||||
"h-(--organization-popover-company-row-height) min-w-0 gap-(--organization-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) focus:bg-accent/50 focus:text-foreground";
|
||||
const ORGANIZATION_ACTION_CLASS =
|
||||
"h-(--organization-popover-action-row-height) gap-(--organization-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--organization-popover-action-line-height) text-foreground focus:bg-accent/50 focus:text-foreground";
|
||||
|
||||
function WorkspaceIcon({ company }: { company: Company }) {
|
||||
function WorkspaceIcon({ company, inPopover = false }: { company: Company; inPopover?: boolean }) {
|
||||
return (
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
className={WORKSPACE_ICON_CLASS}
|
||||
className={inPopover ? POPOVER_WORKSPACE_ICON_CLASS : TRIGGER_WORKSPACE_ICON_CLASS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -71,7 +74,7 @@ function WorkspaceIcon({ company }: { company: Company }) {
|
|||
* uses — seeded by the display name, never fetched.
|
||||
*/
|
||||
function StackIcon({ displayName }: { displayName: string }) {
|
||||
return <CompanyPatternIcon companyName={displayName} className={WORKSPACE_ICON_CLASS} />;
|
||||
return <CompanyPatternIcon companyName={displayName} className={POPOVER_WORKSPACE_ICON_CLASS} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,7 +94,7 @@ function CurrentStackIcon({
|
|||
<CompanyPatternIcon
|
||||
companyName={displayName}
|
||||
logoUrl={company?.logoUrl}
|
||||
className={WORKSPACE_ICON_CLASS}
|
||||
className={TRIGGER_WORKSPACE_ICON_CLASS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -108,19 +111,26 @@ function CloudStackItem({
|
|||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onSelect(stack)}
|
||||
className={cn("min-w-0 gap-2 py-2", isSelected && "bg-accent text-accent-foreground")}
|
||||
className={ORGANIZATION_ROW_CLASS}
|
||||
>
|
||||
<StackIcon displayName={stack.displayName} />
|
||||
<span className="min-w-0 flex-1 truncate" title={stack.displayName}>
|
||||
{stack.displayName}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate font-medium leading-(--organization-popover-name-line-height)"
|
||||
title={stack.displayName}
|
||||
>
|
||||
{stack.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground"
|
||||
title={stack.stackSlug}
|
||||
>
|
||||
{stack.stackSlug}
|
||||
</span>
|
||||
</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 className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isSelected ? <Check className="size-4 text-foreground" /> : null}
|
||||
</span>
|
||||
{isSelected ? <Check className="size-4 shrink-0 text-muted-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -162,20 +172,28 @@ function SortableCompanyItem({
|
|||
onSelect(company);
|
||||
}}
|
||||
className={cn(
|
||||
"min-w-0 gap-2 py-2",
|
||||
ORGANIZATION_ROW_CLASS,
|
||||
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>
|
||||
<WorkspaceIcon company={company} inPopover />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium leading-(--organization-popover-name-line-height)">
|
||||
{company.name}
|
||||
</span>
|
||||
{isEditing ? null : (
|
||||
<span className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground">
|
||||
{company.issuePrefix}
|
||||
</span>
|
||||
)}
|
||||
</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"
|
||||
className="inline-flex size-8 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();
|
||||
|
|
@ -186,10 +204,9 @@ function SortableCompanyItem({
|
|||
<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}
|
||||
</>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isSelected ? <Check className="size-4 text-foreground" /> : null}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
|
@ -352,7 +369,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
// 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-background hover:text-foreground has-[>svg]:px-4 dark:hover:bg-background"
|
||||
className="h-9 min-w-0 flex-1 justify-start gap-2 px-4 text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground has-[>svg]:px-4 dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground"
|
||||
aria-label={
|
||||
currentName
|
||||
? `Open ${currentName} ${switcherNoun} switcher`
|
||||
|
|
@ -380,10 +397,14 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
{!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">
|
||||
{`Switch ${switcherNoun}`}
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="ml-2 w-(--organization-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
|
||||
>
|
||||
<div className="flex h-(--organization-popover-header-height) items-center justify-between gap-2 px-3.5">
|
||||
<DropdownMenuLabel className="p-0 text-(length:--text-compact) font-semibold text-foreground">
|
||||
Organizations
|
||||
</DropdownMenuLabel>
|
||||
{/* Stack order is owned by cloud's own portfolio in v1, so the
|
||||
drag-to-reorder affordance stays self-hosted-only. */}
|
||||
|
|
@ -395,13 +416,13 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
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"
|
||||
className="rounded px-1.5 py-0.5 text-xs 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">
|
||||
<div className="flex max-h-96 flex-col gap-0.5 overflow-y-auto px-2.5 pb-2 pt-1">
|
||||
{isCloud ? (
|
||||
<>
|
||||
{stacks.map((stack) => (
|
||||
|
|
@ -470,54 +491,57 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
</>
|
||||
)}
|
||||
</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 : (
|
||||
<>
|
||||
<div className="flex flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
|
||||
{/* A cloud instance without a configured cloud origin has nowhere to
|
||||
send the user, so the row drops out entirely. */}
|
||||
{isCloud && !createStackUrl ? null : (
|
||||
<DropdownMenuItem
|
||||
onClick={addCompany}
|
||||
className="gap-2 py-2 text-muted-foreground"
|
||||
className={ORGANIZATION_ACTION_CLASS}
|
||||
disabled={isEditingOrder}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<span>Create new organization...</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{showInvitePeople ? (
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings/members?tab=invites"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span className="truncate">
|
||||
{currentName ? `Invite people to ${currentName}` : "Invite people"}
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Plus className="size-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{session?.session ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<span className="min-w-0 flex-1 truncate">Create organization</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showInvitePeople ? (
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder} className={ORGANIZATION_ACTION_CLASS}>
|
||||
<Link
|
||||
to="/company/settings/members?tab=invites"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<UserPlus className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{currentName ? `Invite people to ${currentName}` : "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{session?.session ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className={ORGANIZATION_ACTION_CLASS}
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
disabled={isEditingOrder || signOutMutation.isPending}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<LogOut className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -98,18 +98,20 @@ describe("SidebarNavItem", () => {
|
|||
expect(link().firstElementChild?.textContent).toBe("Recent task");
|
||||
});
|
||||
|
||||
it("uses the Paper nav surface for the active item", () => {
|
||||
it("uses the sidebar accent 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");
|
||||
expect(classTokens(link())).toContain("bg-sidebar-accent");
|
||||
expect(classTokens(link())).toContain("text-sidebar-accent-foreground");
|
||||
expect(classTokens(link())).not.toContain("bg-background");
|
||||
});
|
||||
|
||||
it("uses the active nav surface for hover", () => {
|
||||
it("uses the legible sidebar accent 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");
|
||||
expect(classTokens(link())).toContain("hover:bg-sidebar-accent");
|
||||
expect(classTokens(link())).toContain("hover:text-sidebar-accent-foreground");
|
||||
expect(classTokens(link())).not.toContain("hover:bg-background");
|
||||
});
|
||||
|
||||
it("clips the label (kept in flow for 1:1 row height) and collapses the badge to a dot in the rail", () => {
|
||||
|
|
|
|||
|
|
@ -128,8 +128,8 @@ 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-background text-foreground"
|
||||
: "text-foreground/80 hover:bg-background hover:text-foreground",
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-background text-foreground"
|
||||
: "text-foreground/80 hover:bg-background hover:text-foreground",
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
|
|
|
|||
|
|
@ -11,10 +11,20 @@ import {
|
|||
readRecentTasks,
|
||||
recordRecentTask,
|
||||
} from "@/lib/recent-tasks";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() }));
|
||||
const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const mockAgentsApi = vi.hoisted(() => ({ wakeup: vi.fn() }));
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
archiveFromInbox: vi.fn(),
|
||||
getTreeControlState: vi.fn(),
|
||||
createTreeHold: vi.fn(),
|
||||
releaseTreeHold: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
|
||||
vi.mock("@/api/auth", () => ({ authApi: mockAuthApi }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
|
|
@ -50,7 +60,8 @@ describe("SidebarRecentTasks", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockIssuesApi.get.mockReset();
|
||||
Object.values(mockAgentsApi).forEach((mock) => mock.mockReset());
|
||||
Object.values(mockIssuesApi).forEach((mock) => mock.mockReset());
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
|
@ -86,10 +97,31 @@ describe("SidebarRecentTasks", () => {
|
|||
return queryClient;
|
||||
}
|
||||
|
||||
it("renders a compact empty state", async () => {
|
||||
async function openActions(taskTitle: string) {
|
||||
const actions = container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="More actions for ${taskTitle}"]`,
|
||||
);
|
||||
await act(async () => {
|
||||
actions?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function menuItem(label: string) {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((item) => item.textContent?.trim() === label);
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
it("hides the section when there are no recent tasks", async () => {
|
||||
await render();
|
||||
expect(container.textContent).toContain("Recent Tasks");
|
||||
expect(container.textContent).toContain("Open or create a task");
|
||||
expect(container.textContent).not.toContain("Recent Tasks");
|
||||
expect(container.textContent).not.toContain("Open or create a task");
|
||||
});
|
||||
|
||||
it("renders refreshed task text and live state without shifting for a status icon", async () => {
|
||||
|
|
@ -130,6 +162,246 @@ describe("SidebarRecentTasks", () => {
|
|||
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");
|
||||
const actions = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="More actions for Refreshed title"]',
|
||||
);
|
||||
expect(actions).not.toBeNull();
|
||||
expect(actions?.className).toContain("opacity-0");
|
||||
});
|
||||
|
||||
it("opens the compact task actions menu from the ellipsis button", async () => {
|
||||
recordRecentTask({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
title: "Menu task",
|
||||
identifier: "PAP-1",
|
||||
status: "todo",
|
||||
updatedAt: new Date(1),
|
||||
}, "user-1");
|
||||
mockIssuesApi.get.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
title: "Menu task",
|
||||
identifier: "PAP-1",
|
||||
status: "todo",
|
||||
hiddenAt: null,
|
||||
updatedAt: new Date(1),
|
||||
});
|
||||
|
||||
await render();
|
||||
await openActions("Menu task");
|
||||
|
||||
const menu = document.body.querySelector('[data-slot="dropdown-menu-content"]');
|
||||
expect(menu?.textContent).toContain("Rename");
|
||||
expect(menu?.textContent).toContain("Archive");
|
||||
expect(menu?.textContent).toContain("Pause/Restart");
|
||||
});
|
||||
|
||||
it("archives a task from the inbox without hiding or removing the recent task", async () => {
|
||||
const issue = {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
title: "Archive me",
|
||||
identifier: "PAP-1",
|
||||
status: "todo" as const,
|
||||
hiddenAt: null,
|
||||
updatedAt: new Date(1),
|
||||
};
|
||||
recordRecentTask(issue, "user-1");
|
||||
mockIssuesApi.get.mockResolvedValue(issue);
|
||||
mockIssuesApi.archiveFromInbox.mockResolvedValue({
|
||||
id: issue.id,
|
||||
archivedAt: new Date(2),
|
||||
});
|
||||
|
||||
await render();
|
||||
await openActions("Archive me");
|
||||
await act(async () => {
|
||||
menuItem("Archive")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith("issue-1");
|
||||
expect(mockIssuesApi.update).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Recent Tasks");
|
||||
expect(container.querySelector('a[href="/issues/issue-1"]')?.textContent).toContain(
|
||||
"Archive me",
|
||||
);
|
||||
expect(readRecentTasks(
|
||||
getRecentTasksStorageKey("company-1", "user-1"),
|
||||
"company-1",
|
||||
)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refreshes task activity after a rename", async () => {
|
||||
const issue = {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
title: "Old title",
|
||||
identifier: "PAP-1",
|
||||
status: "todo" as const,
|
||||
hiddenAt: null,
|
||||
updatedAt: new Date(1),
|
||||
};
|
||||
const renamedIssue = { ...issue, title: "New title", updatedAt: new Date(2) };
|
||||
recordRecentTask(issue, "user-1");
|
||||
mockIssuesApi.get.mockResolvedValue(issue);
|
||||
mockIssuesApi.update.mockResolvedValue(renamedIssue);
|
||||
|
||||
const queryClient = await render();
|
||||
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
|
||||
await openActions("Old title");
|
||||
await act(async () => {
|
||||
menuItem("Rename")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const input = document.body.querySelector<HTMLInputElement>('input[aria-label="Task name"]');
|
||||
expect(input).not.toBeNull();
|
||||
await act(async () => {
|
||||
setInputValue(input!, "New title");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
input?.closest("form")?.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(mockIssuesApi.update).toHaveBeenCalledWith("issue-1", { title: "New title" });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.issues.activity("issue-1"),
|
||||
});
|
||||
});
|
||||
|
||||
it("pauses a running task or restarts its active pause hold", async () => {
|
||||
const issue = {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
title: "Toggle work",
|
||||
identifier: "PAP-1",
|
||||
status: "in_progress" as const,
|
||||
assigneeAgentId: "agent-1",
|
||||
hiddenAt: null,
|
||||
updatedAt: new Date(1),
|
||||
};
|
||||
recordRecentTask(issue, "user-1");
|
||||
mockIssuesApi.get.mockResolvedValue(issue);
|
||||
mockIssuesApi.getTreeControlState
|
||||
.mockResolvedValueOnce({ activePauseHold: null })
|
||||
.mockResolvedValueOnce({
|
||||
activePauseHold: {
|
||||
holdId: "hold-1",
|
||||
rootIssueId: "issue-1",
|
||||
issueId: "issue-1",
|
||||
isRoot: true,
|
||||
mode: "pause",
|
||||
reason: null,
|
||||
releasePolicy: { strategy: "manual" },
|
||||
},
|
||||
});
|
||||
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: {}, preview: {} });
|
||||
mockIssuesApi.releaseTreeHold.mockResolvedValue({});
|
||||
mockAgentsApi.wakeup.mockResolvedValue({ id: "run-1" });
|
||||
|
||||
await render();
|
||||
await openActions("Toggle work");
|
||||
await act(async () => {
|
||||
menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("issue-1", {
|
||||
mode: "pause",
|
||||
reason: "Paused from Recent Tasks.",
|
||||
releasePolicy: { strategy: "manual" },
|
||||
});
|
||||
|
||||
await openActions("Toggle work");
|
||||
await act(async () => {
|
||||
menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("issue-1", "hold-1", {
|
||||
reason: "Restarted from Recent Tasks.",
|
||||
});
|
||||
expect(mockAgentsApi.wakeup).toHaveBeenCalledWith(
|
||||
"agent-1",
|
||||
{
|
||||
source: "assignment",
|
||||
triggerDetail: "manual",
|
||||
reason: "recent_task_restart",
|
||||
payload: { issueId: "issue-1" },
|
||||
},
|
||||
"company-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("retries only the wake after a remount when restart releases the hold before wakeup fails", async () => {
|
||||
const issue = {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
title: "Retry restart",
|
||||
identifier: "PAP-1",
|
||||
status: "in_progress" as const,
|
||||
assigneeAgentId: "agent-1",
|
||||
hiddenAt: null,
|
||||
updatedAt: new Date(1),
|
||||
};
|
||||
recordRecentTask(issue, "user-1");
|
||||
mockIssuesApi.get.mockResolvedValue(issue);
|
||||
mockIssuesApi.getTreeControlState
|
||||
.mockResolvedValueOnce({
|
||||
activePauseHold: {
|
||||
holdId: "hold-1",
|
||||
rootIssueId: "issue-1",
|
||||
issueId: "issue-1",
|
||||
isRoot: true,
|
||||
mode: "pause",
|
||||
reason: null,
|
||||
releasePolicy: { strategy: "manual" },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ activePauseHold: null });
|
||||
mockIssuesApi.releaseTreeHold.mockResolvedValue({});
|
||||
mockAgentsApi.wakeup
|
||||
.mockRejectedValueOnce(new Error("Wake failed"))
|
||||
.mockResolvedValueOnce({ id: "run-1" });
|
||||
|
||||
await render();
|
||||
await openActions("Retry restart");
|
||||
await act(async () => {
|
||||
menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
await render();
|
||||
|
||||
await openActions("Retry restart");
|
||||
await act(async () => {
|
||||
menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledTimes(1);
|
||||
expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled();
|
||||
expect(mockAgentsApi.wakeup).toHaveBeenCalledTimes(2);
|
||||
expect(mockAgentsApi.wakeup).toHaveBeenLastCalledWith(
|
||||
"agent-1",
|
||||
{
|
||||
source: "assignment",
|
||||
triggerDetail: "manual",
|
||||
reason: "recent_task_restart_retry",
|
||||
payload: { issueId: "issue-1" },
|
||||
},
|
||||
"company-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("synchronizes recent tasks written by another tab", async () => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,71 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Archive, MoreHorizontal, Pencil, RefreshCw } from "lucide-react";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useRecentTasks } from "@/hooks/useRecentTasks";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useOptionalToastActions } from "@/context/ToastContext";
|
||||
import {
|
||||
updateRecentTaskSnapshots,
|
||||
type RecentTaskEntry,
|
||||
} from "@/lib/recent-tasks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
const RECENT_TASK_MENU_ITEM_CLASS =
|
||||
"h-(--profile-popover-row-height) gap-(--profile-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) focus:bg-accent/50 focus:text-foreground";
|
||||
const RESTART_WAKE_RETRY_STORAGE_SUFFIX = ":restart-wake-retry";
|
||||
|
||||
function restartWakeRetryStorageKey(storageKey: string | null) {
|
||||
return storageKey ? `${storageKey}${RESTART_WAKE_RETRY_STORAGE_SUFFIX}` : null;
|
||||
}
|
||||
|
||||
function readRestartWakeRetryIssueIds(storageKey: string | null) {
|
||||
if (!storageKey) return new Set<string>();
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]") as unknown;
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : []);
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
function setRestartWakeRetryPending(storageKey: string | null, issueId: string, pending: boolean) {
|
||||
if (!storageKey) return;
|
||||
const issueIds = readRestartWakeRetryIssueIds(storageKey);
|
||||
if (pending) issueIds.add(issueId);
|
||||
else issueIds.delete(issueId);
|
||||
try {
|
||||
if (issueIds.size > 0) window.localStorage.setItem(storageKey, JSON.stringify([...issueIds]));
|
||||
else window.localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Recent Tasks remains usable when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function SidebarRecentTasks({
|
||||
companyId,
|
||||
liveIssueIds,
|
||||
|
|
@ -46,24 +106,243 @@ function RecentTasksList({
|
|||
liveIssueIds: ReadonlySet<string>;
|
||||
rail: boolean;
|
||||
}) {
|
||||
const { entries } = useRecentTasks({ companyId, userId });
|
||||
const { entries, storageKey } = useRecentTasks({ companyId, userId });
|
||||
const queryClient = useQueryClient();
|
||||
const toastActions = useOptionalToastActions();
|
||||
const [renameEntry, setRenameEntry] = useState<RecentTaskEntry | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [pendingAction, setPendingAction] = useState<"rename" | "archive" | "pause" | null>(null);
|
||||
const restartRetryStorageKey = restartWakeRetryStorageKey(storageKey);
|
||||
|
||||
if (rail && entries.length === 0) return null;
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const refreshIssueQueries = async (issueId: string) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId) }),
|
||||
]);
|
||||
};
|
||||
|
||||
const beginRename = (entry: RecentTaskEntry) => {
|
||||
setRenameEntry(entry);
|
||||
setRenameValue(entry.title);
|
||||
};
|
||||
|
||||
const submitRename = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const nextTitle = renameValue.trim();
|
||||
if (!renameEntry || !nextTitle || nextTitle === renameEntry.title) {
|
||||
setRenameEntry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction("rename");
|
||||
try {
|
||||
const updated = await issuesApi.update(renameEntry.id, { title: nextTitle });
|
||||
queryClient.setQueryData(queryKeys.issues.detail(renameEntry.id), updated);
|
||||
if (storageKey) updateRecentTaskSnapshots(storageKey, companyId, [updated]);
|
||||
await refreshIssueQueries(renameEntry.id);
|
||||
setRenameEntry(null);
|
||||
toastActions?.pushToast({ title: "Task renamed", tone: "success" });
|
||||
} catch (error) {
|
||||
toastActions?.pushToast({
|
||||
title: "Task rename failed",
|
||||
body: errorMessage(error, "Unable to rename this task."),
|
||||
tone: "error",
|
||||
});
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const archiveTask = async (entry: RecentTaskEntry) => {
|
||||
setPendingAction("archive");
|
||||
try {
|
||||
await issuesApi.archiveFromInbox(entry.id);
|
||||
await refreshIssueQueries(entry.id);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.sidebarBadges(companyId),
|
||||
});
|
||||
toastActions?.pushToast({ title: "Task archived from inbox", tone: "success" });
|
||||
} catch (error) {
|
||||
toastActions?.pushToast({
|
||||
title: "Task archive failed",
|
||||
body: errorMessage(error, "Unable to archive this task from the inbox."),
|
||||
tone: "error",
|
||||
});
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskPause = async (entry: RecentTaskEntry) => {
|
||||
setPendingAction("pause");
|
||||
try {
|
||||
const state = await issuesApi.getTreeControlState(entry.id);
|
||||
if (state.activePauseHold?.isRoot) {
|
||||
const restartIssue = await issuesApi.get(entry.id);
|
||||
setRestartWakeRetryPending(restartRetryStorageKey, entry.id, true);
|
||||
await issuesApi.releaseTreeHold(entry.id, state.activePauseHold.holdId, {
|
||||
reason: "Restarted from Recent Tasks.",
|
||||
});
|
||||
if (restartIssue.assigneeAgentId) {
|
||||
const wakeResult = await agentsApi.wakeup(
|
||||
restartIssue.assigneeAgentId,
|
||||
{
|
||||
source: "assignment",
|
||||
triggerDetail: "manual",
|
||||
reason: "recent_task_restart",
|
||||
payload: { issueId: restartIssue.id },
|
||||
},
|
||||
restartIssue.companyId,
|
||||
);
|
||||
if (!("id" in wakeResult)) {
|
||||
throw new Error(wakeResult.message ?? "The assignee wake was skipped.");
|
||||
}
|
||||
}
|
||||
setRestartWakeRetryPending(restartRetryStorageKey, entry.id, false);
|
||||
toastActions?.pushToast({ title: "Task restarted", tone: "success" });
|
||||
} else if (state.activePauseHold) {
|
||||
throw new Error("This task is paused by a parent task. Restart it from the pause root.");
|
||||
} else if (readRestartWakeRetryIssueIds(restartRetryStorageKey).has(entry.id)) {
|
||||
const restartIssue = await issuesApi.get(entry.id);
|
||||
if (restartIssue.assigneeAgentId) {
|
||||
const wakeResult = await agentsApi.wakeup(
|
||||
restartIssue.assigneeAgentId,
|
||||
{
|
||||
source: "assignment",
|
||||
triggerDetail: "manual",
|
||||
reason: "recent_task_restart_retry",
|
||||
payload: { issueId: restartIssue.id },
|
||||
},
|
||||
restartIssue.companyId,
|
||||
);
|
||||
if (!("id" in wakeResult)) {
|
||||
throw new Error(wakeResult.message ?? "The assignee wake was skipped.");
|
||||
}
|
||||
}
|
||||
setRestartWakeRetryPending(restartRetryStorageKey, entry.id, false);
|
||||
toastActions?.pushToast({ title: "Task restarted", tone: "success" });
|
||||
} else {
|
||||
await issuesApi.createTreeHold(entry.id, {
|
||||
mode: "pause",
|
||||
reason: "Paused from Recent Tasks.",
|
||||
releasePolicy: { strategy: "manual" },
|
||||
});
|
||||
toastActions?.pushToast({ title: "Task paused", tone: "success" });
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["issues", "tree-control-state", entry.id],
|
||||
});
|
||||
} catch (error) {
|
||||
toastActions?.pushToast({
|
||||
title: "Task pause update failed",
|
||||
body: errorMessage(error, "Unable to pause or restart this task."),
|
||||
tone: "error",
|
||||
});
|
||||
} finally {
|
||||
setPendingAction(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>
|
||||
<>
|
||||
<SidebarSection label="Recent Tasks">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="group/recent-task relative">
|
||||
<SidebarNavItem
|
||||
to={`/issues/${entry.id}`}
|
||||
label={entry.title}
|
||||
className={rail ? undefined : "pr-10"}
|
||||
liveCount={liveIssueIds.has(entry.id) ? 1 : undefined}
|
||||
/>
|
||||
{!rail ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`More actions for ${entry.title}`}
|
||||
className="absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 pointer-coarse:opacity-100 group-hover/recent-task:opacity-100 group-focus-within/recent-task:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-(--profile-popover-width) rounded-xl p-1.5 shadow-(--shadow-profile-popover)"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className={RECENT_TASK_MENU_ITEM_CLASS}
|
||||
onSelect={() => beginRename(entry)}
|
||||
>
|
||||
<Pencil aria-hidden="true" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={RECENT_TASK_MENU_ITEM_CLASS}
|
||||
disabled={pendingAction !== null}
|
||||
onSelect={() => void archiveTask(entry)}
|
||||
>
|
||||
<Archive aria-hidden="true" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={RECENT_TASK_MENU_ITEM_CLASS}
|
||||
disabled={pendingAction !== null}
|
||||
onSelect={() => void toggleTaskPause(entry)}
|
||||
>
|
||||
<RefreshCw aria-hidden="true" />
|
||||
Pause/Restart
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</SidebarSection>
|
||||
|
||||
<Dialog
|
||||
open={renameEntry !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && pendingAction !== "rename") setRenameEntry(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form className="grid gap-4" onSubmit={(event) => void submitRename(event)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename task</DialogTitle>
|
||||
<DialogDescription>Choose a short, clear name for this task.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="Task name"
|
||||
value={renameValue}
|
||||
disabled={pendingAction === "rename"}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pendingAction === "rename"}
|
||||
onClick={() => setRenameEntry(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pendingAction === "rename" || !renameValue.trim()}
|
||||
>
|
||||
{pendingAction === "rename" ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-background text-foreground"
|
||||
: "text-foreground/80 hover:bg-background hover:text-foreground",
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
|
|
|
|||
|
|
@ -347,10 +347,17 @@ describe("TaskChatThread draft pass-through", () => {
|
|||
const dock = container.querySelector(
|
||||
'[data-testid="task-chat-composer-dock"]',
|
||||
);
|
||||
const thread = container.querySelector(
|
||||
'[data-testid="task-chat-thread"]',
|
||||
);
|
||||
expect(thread?.classList).not.toContain("h-(--tc-thread-max-h)");
|
||||
expect(thread?.classList).toContain("flex-1");
|
||||
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("-mt-(--radius-task-composer)");
|
||||
expect(dock?.classList).not.toContain("pt-1");
|
||||
expect(dock?.classList).toContain("md:pb-0");
|
||||
expect(dock?.classList).not.toContain("md:pb-4");
|
||||
expect(dock?.classList).not.toContain("bg-background/80");
|
||||
expect(dock?.classList).not.toContain("backdrop-blur");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2388,7 +2388,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "h-(--tc-thread-max-h) min-h-0 flex-1",
|
||||
!isMobile && "min-h-0 flex-1",
|
||||
)}
|
||||
data-testid="task-chat-thread"
|
||||
>
|
||||
|
|
@ -2533,10 +2533,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
? "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 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",
|
||||
streamlinedUiEnabled && "md:px-0 md:pb-0",
|
||||
(!streamlinedUiEnabled || isMobile) &&
|
||||
"bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
|
||||
)}
|
||||
>
|
||||
{composerAccessory}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,23 @@ describe("ThemeToggle", () => {
|
|||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the compact profile-menu row without secondary copy", async () => {
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<ThemeToggle variant="compact-menu-action" />);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button?.classList).toContain("h-(--profile-popover-row-height)");
|
||||
expect(button?.classList).toContain("gap-(--profile-popover-row-gap)");
|
||||
expect(button?.querySelector("span")?.classList).toContain("size-5");
|
||||
expect(container.textContent).toContain("Switch to light mode");
|
||||
expect(container.textContent).not.toContain("Toggle the app appearance.");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("calls onAfterToggle after toggling (used by SidebarAccountMenu to close the popover)", async () => {
|
||||
const onAfterToggle = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
|
||||
type ThemeToggleVariant = "icon" | "menu-action";
|
||||
type ThemeToggleVariant = "icon" | "menu-action" | "compact-menu-action";
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
|
|
@ -14,7 +14,10 @@ interface ThemeToggleProps {
|
|||
* other surface that just wants a toggle affordance.
|
||||
*
|
||||
* `menu-action`: full-width row with label + description + icon —
|
||||
* matches the surrounding `MenuAction` rows in `SidebarAccountMenu`.
|
||||
* suitable for explanatory menus.
|
||||
*
|
||||
* `compact-menu-action`: compact label + icon row — matches the
|
||||
* surrounding actions in `SidebarAccountMenu`.
|
||||
*/
|
||||
variant?: ThemeToggleVariant;
|
||||
/**
|
||||
|
|
@ -42,6 +45,25 @@ export function ThemeToggle({ className, variant = "icon", onAfterToggle }: Them
|
|||
onAfterToggle?.();
|
||||
}
|
||||
|
||||
if (variant === "compact-menu-action") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-accent",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
aria-label={label}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "menu-action") {
|
||||
return (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@
|
|||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent: oklch(0.88 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
|
@ -295,6 +295,23 @@
|
|||
--radius-task-composer: 16px;
|
||||
--shadow-task-composer: 0 2px 8px -2px color-mix(in oklab, var(--foreground) 7%, transparent),
|
||||
0 12px 32px -8px color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||
--profile-popover-width: 248px;
|
||||
--profile-popover-min-height: 304px;
|
||||
--profile-popover-header-height: 76px;
|
||||
--profile-popover-row-height: 38px;
|
||||
--profile-popover-row-gap: 9px;
|
||||
--profile-popover-label-line-height: 18px;
|
||||
--profile-popover-meta-line-height: 15px;
|
||||
--shadow-profile-popover: 0 18px 45px oklch(0 0 0 / 18%);
|
||||
--organization-popover-width: 248px;
|
||||
--organization-popover-header-height: 48px;
|
||||
--organization-popover-company-row-height: 44px;
|
||||
--organization-popover-action-row-height: 38px;
|
||||
--organization-popover-avatar-size: 30px;
|
||||
--organization-popover-row-gap: 9px;
|
||||
--organization-popover-name-line-height: 17px;
|
||||
--organization-popover-prefix-line-height: 13px;
|
||||
--organization-popover-action-line-height: 18px;
|
||||
|
||||
/* Composer mode-chip hues (v5–v7 decisions): agent + plan reuse the task
|
||||
status hues; ask is the v5 blue. Consumed as `--sc` seeds through the
|
||||
|
|
@ -368,7 +385,7 @@
|
|||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent: oklch(0.32 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges";
|
||||
|
|
@ -541,6 +542,16 @@ export function DesignGuide() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Theme Toggle">
|
||||
<SubSection title="Variants">
|
||||
<div className="flex max-w-sm flex-col items-start gap-3">
|
||||
<ThemeToggle />
|
||||
<ThemeToggle variant="menu-action" />
|
||||
<ThemeToggle variant="compact-menu-action" />
|
||||
</div>
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* COLORS */}
|
||||
{/* ============================================================ */}
|
||||
|
|
|
|||
|
|
@ -4043,6 +4043,18 @@ describe("IssueDetail", () => {
|
|||
expect(container.textContent).toContain("Subtree pause is active.");
|
||||
});
|
||||
|
||||
const pauseBannerTitle = Array.from(container.querySelectorAll("span")).find(
|
||||
(element) => element.textContent?.trim() === "Subtree pause is active.",
|
||||
);
|
||||
expect(pauseBannerTitle?.closest(".rounded-md")?.classList).toContain(
|
||||
"mt-3",
|
||||
);
|
||||
const taskChatShell = container.querySelector<HTMLElement>(
|
||||
"[data-task-chat-shell]",
|
||||
);
|
||||
expect(taskChatShell?.classList).toContain("gap-3");
|
||||
expect(taskChatShell?.classList).not.toContain("gap-6");
|
||||
|
||||
const resumeButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Resume subtree",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7251,7 +7251,10 @@ export function IssueDetail() {
|
|||
"flex w-full flex-col gap-6"
|
||||
: // Fill main exactly so the outer page never scrolls — the
|
||||
// thread's own viewport is the only scroll surface.
|
||||
"flex h-full min-h-0 w-full flex-col gap-6"
|
||||
// Keep status banners close to the transcript. A full section
|
||||
// gap here shortens the pinned message viewport enough to
|
||||
// leave its first visible bubble sliced at the top edge.
|
||||
"flex h-full min-h-0 w-full flex-col gap-3"
|
||||
: "max-w-3xl space-y-6"
|
||||
}
|
||||
>
|
||||
|
|
@ -7263,6 +7266,7 @@ export function IssueDetail() {
|
|||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive",
|
||||
shellSectionClass,
|
||||
taskChatShellEnabled && (isMobile ? "mt-4" : "mt-3"),
|
||||
)}
|
||||
>
|
||||
<EyeOff className="h-4 w-4 shrink-0" />
|
||||
|
|
@ -7274,6 +7278,9 @@ export function IssueDetail() {
|
|||
className={cn(
|
||||
"rounded-md border border-amber-500/35 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200",
|
||||
shellSectionClass,
|
||||
taskChatShellEnabled &&
|
||||
!issue.hiddenAt &&
|
||||
(isMobile ? "mt-4" : "mt-3"),
|
||||
)}
|
||||
>
|
||||
{activePauseHold.isRoot ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue