feat(web): unify workspace navigation#7153

merged4
main
agent/ui-workspace-refresh
openedAug 16, 2026byMmaria
View on GitHubTwo-way sync enabledPR last imported 4d ago
25 unresolved threads · Jump to file
Selected thread: apps/web/src/components/Sidebar.tsx:1326 — <!-- MURMUR_IGNORE --> `components/Sidebar.tsx:1300` The full row's Snooze / Unpin / Settle controls were migrated onto `Button size="icon-micro"|"micro" variant="ghost-muted"` in this PR, but the slim row's Wake / Un-settle / Settle buttons (lines 1266, 1279, 1291) still hand-roll the same treatment: `inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground`. That is now a behavioral divergence, not just duplication: `ghost-muted` also supplies `hover:bg-accent`, so the *same* Settle action gets a filled hover affordance in the full row and a text-color-only hover in the slim row, and the row-action treatment has two owners in one file. Suggest moving these three onto the same primitive and leaving only the positioning/visibility classes (`pointer-events-none absolute inset-y-0 right-0 …`) at the call site. No longer relevant as of e20c6800cde8857d00eb070bdfb1bfd4ba2e12d8
0 / 12 viewed12 files changedLine totals unavailable

apps/web/src/components/threadActionMenu.logic.ts · Earlier revision aa7c78cb · right 109–121

Original diff
@@ -91,26 +94,48 @@ export function buildThreadActionMenuItems(
               },
         ]
       : []),
-    { id: "rename", label: "Rename thread" },
+    { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },
     ...(state.supports.titleRegeneration
       ? [
           {
             id: "regenerate-title" as const,
             label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
+            icon: "refresh-cw",
             disabled: state.isRegeneratingTitle,
           },
         ]
       : []),
-    { id: "mark-unread", label: "Mark unread" },
-    { id: "copy-path", label: "Copy path", icon: "copy" },
-    ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
-    { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },
+    { id: "mark-unread", label: "Mark unread", icon: "mail-open" },
+    {
+      id: "copy",
+      label: "Copy",
+      icon: "copy",
+      separatorBefore: true,
+      children: [
+        { id: "copy-path", label: "Path", icon: "folder" },
+        ...(state.branch
+          ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
+          : []),
+        { id: "copy-thread-id", label: "Thread ID", icon: "hash" },
+      ],
+    },

apps/web/src/components/threadActionMenu.logic.ts · right 109–121

M
macroscopeappBotAug 19, 2026

Grouping these three under a submenu drops them out of reach in the web build. showContextMenuFallback (the renderer used whenever window.desktopBridge is absent) only opens a submenu from the parent row's mouseenter handler and explicitly swallows the parent's activation (button.addEventListener("click", (event) => event.preventDefault()), contextMenuFallback.ts:383), and the menu handles no keys other than Escape. So Copy path / Copy branch / Copy thread ID, which were flat, focusable, Enter-activatable rows before this change, now have no keyboard path at all on web — only the native Electron menu handles submenu traversal. Snooze had the same limitation, but it is duplicated by the row's snooze control; these copy actions are not.

Smallest fix: keep the grouping and let the fallback's submenu parent open on activation too — in the hasChildren branch, open the child menu from the click handler (and focus its first row) instead of only preventing default. Alternatively leave the copy items flat until the fallback supports keyboard submenu navigation.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

The thread action menu redesign was removed from this stack PR and moved to #7476, where it can be reviewed independently.

apps/web/src/contextMenuFallback.ts · Earlier revision ba1df537 · right 284–284

Original diff
@@ -200,6 +281,15 @@ export function showContextMenuFallback<T extends string>(
         "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
 
       for (const item of entries) {
+        if (item.separatorBefore === true && inner.childElementCount > 0) {

apps/web/src/contextMenuFallback.ts · right 284–284

M
macroscopeappBotAug 17, 2026

src/contextMenuFallback.ts:284

This divider is invisible to this module's own harness: FakeElement in contextMenuFallback.test.ts implements neither childElementCount (so the guard evaluates undefined > 0 and no divider is ever appended) nor setAttribute (so line 289 would throw the moment a test passes separatorBefore) — the same test-double gap that forced the createElementNS guard added just above. Net effect: the web fallback's section grouping ships untested, while the Electron template got two dedicated cases for the same contract.

Suggest using the array-compatible children.length here, then adding setAttribute to FakeElement plus a case asserting a divider lands between sections (a separatorBefore assertion in threadActionMenu.logic.test.ts would cover the item side cheaply too).

suggestion
1 if (item.separatorBefore === true && inner.children.length > 0) {

No longer relevant as of aa7c78cbb699367e3075b91585ccfc0431c820da

apps/web/src/components/ui/button.tsx · Earlier revision 27bb7f48 · right 32–33

Original diff
@@ -29,6 +29,8 @@ const buttonVariants = cva(
         "icon-xs":
           "size-7 sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5",
         lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9",
+        micro:
+          "h-5 gap-1 rounded-md px-[calc(--spacing(1.5)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3",

apps/web/src/components/ui/button.tsx · right 32–33

M
macroscopeappBotAug 16, 2026

ui/button.tsx:33

The new micro size keeps rounded-md (--radius-md, 8px — same as the base --control-radius), while its intended icon sibling icon-micro (size-5) uses rounded-sm (6px). This PR renders the two adjacent in the sidebar row hover slot (Snooze icon-micro next to Settle micro), so two 20px-tall controls in the same group end up with different corner radii.

Suggest matching icon-micro so the micro pair shares one radius contract:

suggestion
1 micro:
2 "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-xs before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3",

No longer relevant as of e20c6800cde8857d00eb070bdfb1bfd4ba2e12d8

apps/web/src/components/Sidebar.tsx · Earlier revision bc081741 · right 1391–1394

Original diff
@@ -1382,136 +1384,138 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
               ) : (
                 <span className="flex-1" />
               )}
-              {props.isPinned ? (
-                props.pinningSupported ? (
-                  <Tooltip>
-                    <TooltipTrigger
-                      render={
-                        <button
-                          type="button"
-                          aria-label="Unpin thread"
-                          onClick={handleUnpinClick}
-                          className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
-                        />
-                      }
+              <span className="ml-auto flex h-5 shrink-0 items-center">
+                {props.isPinned ? (
+                  props.pinningSupported ? (
+                    <Button
+                      size="icon-micro"
+                      variant="ghost-muted"
+                      aria-label="Unpin thread"
+                      title="Unpin thread"

apps/web/src/components/Sidebar.tsx · right 1391–1394

M
macroscopeappBotAug 16, 2026

components/Sidebar.tsx:1394

This migration swapped the shared Tooltip/TooltipPopup for a native title on the Unpin control (and on the Snooze trigger at line 400). Those are the only two title attributes on interactive controls in apps/web/src — every other icon-only control, including SidebarUtilityItem added in this PR and the Woke button in this same slot (line 1432), keeps the primitive.

Besides the styling/delay difference, the row itself is a TooltipTrigger with detailsTooltip, so hovering these buttons now raises the browser's native tip and the row's styled tooltip instead of the button's own tooltip replacing it. Suggest keeping the primitive over the Button:

diff
1- <Button
2- size="icon-micro"
3- variant="ghost-muted"
4- aria-label="Unpin thread"
5- title="Unpin thread"
6- onClick={handleUnpinClick}
7- >
8- <PinIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
9- </Button>
10+ <Tooltip>
11+ <TooltipTrigger
12+ render={
13+ <Button
14+ size="icon-micro"
15+ variant="ghost-muted"
16+ aria-label="Unpin thread"
17+ onClick={handleUnpinClick}
18+ />
19+ }
20+ >
21+ <PinIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
22+ </TooltipTrigger>
23+ <TooltipPopup>Unpin thread</TooltipPopup>
24+ </Tooltip>

No longer relevant as of d8a25c983cbbe4ee4764448ae1664fe6f0a69393

apps/web/src/components/chat/PanelLayoutControls.tsx · Earlier revision 27bb7f48 · right 15–15

Original diff
@@ -12,6 +12,7 @@ interface PanelLayoutControlsProps {
   rightPanelAvailable: boolean;
   rightPanelOpen: boolean;
   rightPanelShortcutLabel: string | null;
+  rightPanelUnavailableLabel?: string;

apps/web/src/components/chat/PanelLayoutControls.tsx · right 15–15

M
macroscopeappBotAug 16, 2026

rightPanelUnavailableLabel has no call site — the only references in the repo are this declaration, the default on line 30, and the read on line 99. A prop added to a shared control ahead of its first consumer can't have its contract validated (who overrides it, and with what copy), and it widens the component API without changing any current behavior.

Suggest landing this together with the surface that needs the alternate label, or keeping the literal until then.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Removed from the focused PR. This component no longer adds the unused prop.

apps/web/src/components/WorkspacePageContainer.tsx · Earlier revision a323f962 · right 23–23

Original diff
@@ -0,0 +1,54 @@
+import type { ComponentPropsWithoutRef } from "react";
+
+import { cn } from "../lib/utils";
+import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar";
+
+export type WorkspacePageWidth = "readable" | "wide" | "expanded";
+
+const WIDTH_CLASS: Record<WorkspacePageWidth, string> = {
+  readable: "max-w-4xl",
+  wide: "max-w-5xl",
+  expanded: "max-w-6xl",
+};
+
+/** Shared full-page frame for workspace routes beneath their top bar. */
+export function WorkspacePageContainer({
+  width = "readable",
+  className,
+  ...props
+}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) {
+  return (
+    <div
+      className={cn(
+        "mx-auto flex w-full flex-col gap-6 px-5 pt-6 pb-12 sm:px-6",

apps/web/src/components/WorkspacePageContainer.tsx · right 23–23

M
macroscopeappBotAug 17, 2026

components/WorkspacePageContainer.tsx:23

WorkspacePageContainer ships with one consumer (SettingsPageContainer), while apps/web/src/routes/_chat.pull-requests.tsx:1923 still hand-rolls the same frame verbatim — mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 pt-6 pb-12, inside the identical topbar-scroll-fade … [--topbar-scroll-fade-height:1.5rem] sm:[--topbar-scroll-fade-height:1.5rem] scroller and carrying the same "top padding is the fade band's own height" comment this container's pt-6 now encodes.

Two owners for one geometry drift immediately: this container adds sm:px-6, so above sm the settings frame insets 1.5rem while the pull-requests frame stays at 1.25rem, and the next tweak to the shared frame reaches only one of the two pages.

Suggest migrating that call site in this PR so the frame has a single owner:

diff
1- <div className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 pt-6 pb-12">
2+ <WorkspacePageContainer className="gap-4">

No longer relevant as of aa7c78cbb699367e3075b91585ccfc0431c820da

apps/web/src/components/chat/PanelLayoutControls.tsx · Earlier revision f1817579 · right 45–45

Original diff
@@ -40,7 +42,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
           <TooltipTrigger
             render={
               <Toggle
-                className="shrink-0 [-webkit-app-region:no-drag]"
+                className="shrink-0 text-foreground [-webkit-app-region:no-drag]"

apps/web/src/components/chat/PanelLayoutControls.tsx · right 45–45

M
macroscopeappBotAug 16, 2026

chat/PanelLayoutControls.tsx:45

Toggle's base class and its ghost variant already apply text-foreground, and in themed chrome the [data-panel-layout-controls] / [data-workspace-titlebar-controls] rule updated in this PR sets color from --toolbar-control-foreground at higher specificity — so this call-site text-foreground can never win in either mode. It just adds a second, silent owner for the control tone that will drift from the primitive/theme rule (same on lines 68 and 119).

suggestion
1 className="shrink-0 [-webkit-app-region:no-drag]"

No longer relevant as of 27bb7f481e723c2eef312ca4fa5ba8c0c1394aae

apps/web/src/components/WorkspacePageContainer.tsx · Earlier revision 8f1ea220 · right 32–33

Original diff
@@ -0,0 +1,54 @@
+import type { ComponentPropsWithoutRef } from "react";
+
+import { cn } from "../lib/utils";
+import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar";
+
+export type WorkspacePageWidth = "readable" | "wide" | "expanded";
+
+const WIDTH_CLASS: Record<WorkspacePageWidth, string> = {
+  readable: "max-w-4xl",
+  wide: "max-w-5xl",
+  expanded: "max-w-6xl",
+};
+
+/** Shared full-page frame for workspace routes beneath their top bar. */
+export function WorkspacePageContainer({
+  width = "readable",
+  className,
+  ...props
+}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) {
+  return (
+    <div
+      className={cn(
+        "mx-auto flex w-full flex-col gap-6 px-5 pt-6 pb-12 sm:px-6",
+        WIDTH_CLASS[width],
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+/** Shared top-bar geometry for every full-width workspace surface. */
+export function WorkspacePageHeader({

apps/web/src/components/WorkspacePageContainer.tsx · right 32–33

M
macroscopeappBotAug 17, 2026

The doc comment claims this owns the top bar for "every full-width workspace surface", but apps/web/src/components/usage/UsagePage.tsx:107-131 still hand-rolls the exact {!isElectron && <header>} / {isElectron && <div>} twin this PR deletes from settings.tsx and ProjectSettingsPanel.tsx, with the pre-refactor strings (px-3 sm:px-5, h-[52px], wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]).

After this change the migrated surfaces use safe-area padding, --workspace-topbar-height, --workspace-native-controls-inset and gap-3, while Usage keeps the literal values — so its top bar sits at a different inset/height from every sibling surface, which is the drift the shared header exists to prevent.

Suggest migrating that header here too (<WorkspacePageHeader electron={isElectron}> around the single breadcrumb), or narrowing the doc comment to the surfaces actually covered. apps/web/src/routes/_chat.pull-requests.tsx:1841-1850 repeats the same geometry and could take reserveNativeControls={!rightPanelOpen}.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

The shared header scope was narrowed here. Usage and Pull Requests own their page-specific migrations in #7147 and #7148.

apps/web/src/routes/-chatIndexTitlebar.test.ts · Earlier revision 528d2c9b · right 18–18

Original diff
@@ -15,9 +15,7 @@ describe("hosted static onboarding header", () => {
 
     const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd);
 
-    expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]");
-    expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]");
-    expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS");
+    expect(onboardingHeader).toContain('<WorkspacePageHeader className="border-b border-border">');

apps/web/src/routes/-chatIndexTitlebar.test.ts · right 18–18

M
macroscopeappBotAug 19, 2026

routes/-chatIndexTitlebar.test.ts:18

This file exists as "regression coverage [that] compares the onboarding header with the shared titlebar contract" (line 2), but the rewritten assertion no longer checks the contract — it matches an exact JSX substring in _chat.index.tsx. It now passes even if WorkspacePageHeader loses h-[var(--workspace-topbar-height)] or COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, and it fails on a pure formatting change (Prettier wrapping the props onto separate lines).

Since the geometry — and a new default (reserveNativeControls = electron) — now live in one owner, suggest keeping a loose usage check here and asserting the class contract against ../components/WorkspacePageHeader.tsx:

suggestion
1 expect(onboardingHeader).toContain("<WorkspacePageHeader");
2 const headerSource = NodeFS.readFileSync(
3 new URL("../components/WorkspacePageHeader.tsx", import.meta.url),
4 "utf8",
5 );
6 expect(headerSource).toContain("h-[var(--workspace-topbar-height)]");
7 expect(headerSource).toContain("min-h-[var(--workspace-topbar-height)]");
8 expect(headerSource).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS");

No longer relevant as of 3f11bc2f64d3c4e5d0cd175b386a2a10065b0141

apps/web/src/components/threadActionMenu.logic.ts · Earlier revision d8a25c98 · right 130–130

Original diff
@@ -91,26 +94,48 @@ export function buildThreadActionMenuItems(
               },
         ]
       : []),
-    { id: "rename", label: "Rename thread" },
+    { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },
     ...(state.supports.titleRegeneration
       ? [
           {
             id: "regenerate-title" as const,
             label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
+            icon: "refresh-cw",
             disabled: state.isRegeneratingTitle,
           },
         ]
       : []),
-    { id: "mark-unread", label: "Mark unread" },
-    { id: "copy-path", label: "Copy path", icon: "copy" },
-    ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
-    { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },
+    { id: "mark-unread", label: "Mark unread", icon: "mail-open" },
+    {
+      id: "copy",
+      label: "Copy",
+      icon: "copy",
+      separatorBefore: true,
+      children: [
+        { id: "copy-path", label: "Path", icon: "folder" },
+        ...(state.branch
+          ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
+          : []),
+        { id: "copy-thread-id", label: "Thread ID", icon: "hash" },
+      ],
+    },
     // Archive removes the thread from the sidebar while keeping its
     // conversation under Settings > Archived threads — distinct from Settle
     // (stays visible in the Settled shelf) and Delete (clears history for
     // good), so it sits beside Delete without borrowing its destructive
     // styling.
-    { id: "archive", label: "Archive thread", disabled: state.isRunning },
-    { id: "delete", label: "Delete", destructive: true, icon: "trash" },
+    {
+      id: "archive",
+      label: "Archive thread",
+      icon: "folder",

apps/web/src/components/threadActionMenu.logic.ts · right 130–130

M
macroscopeappBotAug 17, 2026

components/threadActionMenu.logic.ts:130

Archive thread takes the same folder glyph as the new Copy > Path child on line 115, so one menu tree now uses one icon for two unrelated actions — and the app already represents archived threads with lucide's ArchiveIcon (Settings > Archived threads, SettingsPanels.tsx:1), so the folder glyph also reads as a different concept here.

Suggest an archive icon name instead, adding the matching entry to ICON_PATHS in apps/web/src/contextMenuFallback.ts (the only renderer that draws these names):

ts
1 archive: [
2 { tag: "rect", attrs: { width: "20", height: "5", x: "2", y: "3", rx: "1" } },
3 { tag: "path", attrs: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" } },
4 { tag: "path", attrs: { d: "M10 12h4" } },
5 ],

No longer relevant as of 904f059489d273e529dfae71a8c3e0211506d6e5

apps/web/src/components/Sidebar.tsx · Earlier revision 27bb7f48 · right 1300–1326

Original diff
@@ -1301,7 +1297,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
                     isWoke && "group-hover/sidebar-row:static",
                   )}
                 >
-                  <CheckIcon className="size-3" />
+                  <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />

apps/web/src/components/Sidebar.tsx · right 1300–1326

M
macroscopeappBotAug 16, 2026

components/Sidebar.tsx:1300

The full row's Snooze / Unpin / Settle controls were migrated onto Button size="icon-micro"|"micro" variant="ghost-muted" in this PR, but the slim row's Wake / Un-settle / Settle buttons (lines 1266, 1279, 1291) still hand-roll the same treatment: inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground.

That is now a behavioral divergence, not just duplication: ghost-muted also supplies hover:bg-accent, so the same Settle action gets a filled hover affordance in the full row and a text-color-only hover in the slim row, and the row-action treatment has two owners in one file.

Suggest moving these three onto the same primitive and leaving only the positioning/visibility classes (pointer-events-none absolute inset-y-0 right-0 …) at the call site.

No longer relevant as of e20c6800cde8857d00eb070bdfb1bfd4ba2e12d8

apps/web/src/components/WorkspaceBreadcrumb.tsx · Earlier revision 883bf98d · right 48–50

Original diff
@@ -45,9 +45,9 @@ export function WorkspaceBreadcrumbItem({
   );
 }
 
-export function WorkspaceBreadcrumbSeparator() {
+export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) {
   return (
-    <li aria-hidden="true" className="flex shrink-0 items-center text-icon-muted">
+    <li aria-hidden="true" className={cn("flex shrink-0 items-center text-icon-muted", className)}>

apps/web/src/components/WorkspaceBreadcrumb.tsx · right 48–50

M
macroscopeappBotAug 17, 2026

className has no call site: all five WorkspaceBreadcrumbSeparator usages (ChatHeader.tsx:261, SettingsBreadcrumb.tsx:26, ProjectSettingsPanel.tsx:215, _chat.pull-requests.tsx:1861) render it bare. A pass-through added to a shared breadcrumb part ahead of its first consumer can't have its contract validated — which class is expected to be layered on, and whether it should be allowed to override the separator's shrink-0/text-icon-muted tone — and it widens the API without changing behavior.

Suggest landing this with the surface that needs the override.

suggestion
1export function WorkspaceBreadcrumbSeparator() {
2 return (
3 <li aria-hidden="true" className="flex shrink-0 items-center text-icon-muted">

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Fixed by removing the unused override from the focused PR.

apps/web/src/components/Sidebar.tsx · Earlier revision 3f11bc2f · right 3496–3496

Original diff
@@ -3435,7 +3492,9 @@ export default function Sidebar() {
                     />
                   </TooltipTrigger>
                   <TooltipPopup side="right">
-                    {projectGroups.length > 1 ? (
+                    {scopedProjectGroup ? (
+                      `New thread in ${scopedProjectGroup.displayName}`

apps/web/src/components/Sidebar.tsx · right 3496–3496

M
macroscopeappBotAug 19, 2026

components/Sidebar.tsx:3496

The tooltip now names the scoped target, but the trigger still sets aria-label="New thread" (line 3484), so the accessible name no longer matches the visible label or what the button does — assistive-tech users get no signal that creation is scoped to a project. Suggest deriving both from the same string.

diff
1 onClick={handleNewThreadClick}
2- aria-label="New thread"
3+ aria-label={
4+ scopedProjectGroup
5+ ? `New thread in ${scopedProjectGroup.displayName}`
6+ : "New thread"
7+ }

No longer relevant as of a22960d18260f21b711753e6298fb454ad94ae85

apps/web/src/components/ui/segmented-tabs.tsx · Earlier revision b53b5693 · right 19–40

Original diff
@@ -0,0 +1,40 @@
+import type { ComponentProps, HTMLAttributes } from "react";
+
+import { cn } from "~/lib/utils";
+import { Toggle } from "~/components/ui/toggle";
+
+function SegmentedTabList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
+  return (
+    <div
+      className={cn(
+        "inline-flex w-fit items-center gap-0.5 rounded-lg bg-muted/45 p-0.5",
+        className,
+      )}
+      role="group"
+      {...props}
+    />
+  );
+}
+
+function SegmentedTab({
+  selected,
+  density = "default",
+  className,
+  ...props
+}: {
+  selected: boolean;
+  density?: "default" | "compact";
+} & Omit<ComponentProps<typeof Toggle>, "aria-pressed" | "pressed" | "size" | "type" | "variant">) {
+  return (
+    <Toggle
+      type="button"
+      pressed={selected}
+      size={density === "compact" ? "segmented-compact" : "segmented"}
+      variant="segmented"
+      className={className}
+      {...props}
+    />
+  );
+}
+
+export { SegmentedTab, SegmentedTabList };

apps/web/src/components/ui/segmented-tabs.tsx · right 19–40

M
macroscopeappBotAug 16, 2026

SegmentedTab/SegmentedTabList have no call sites anywhere in the repo, and the segmented / segmented-compact toggle size+variant added in toggle.tsx are referenced only from this file. A shared primitive landed ahead of its first consumer can't have its contract validated against real usage (density naming, role="group" rather than tab semantics, pressed/hover tones), and it becomes another variant future call sites may or may not pick up.

Suggest landing this together with the screen that needs it, or dropping it from this PR.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Removed from this PR. The segmented control now lives with its first consumer in #7147.

apps/web/src/components/WorkspacePageContainer.tsx · Earlier revision b53b5693 · right 56–61

Original diff
@@ -0,0 +1,62 @@
+import type { ComponentPropsWithoutRef } from "react";
+
+import { cn } from "../lib/utils";
+import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar";
+
+export type WorkspacePageWidth = "readable" | "wide" | "expanded";
+
+const WIDTH_CLASS: Record<WorkspacePageWidth, string> = {
+  readable: "max-w-4xl",
+  wide: "max-w-5xl",
+  expanded: "max-w-6xl",
+};
+
+/** Shared full-page frame for workspace routes beneath their top bar. */
+export function WorkspacePageContainer({
+  width = "readable",
+  className,
+  ...props
+}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) {
+  return (
+    <div
+      className={cn(
+        "mx-auto flex w-full flex-col gap-6 px-5 pt-6 pb-12 sm:px-6",
+        WIDTH_CLASS[width],
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+/** Shared top-bar geometry for every full-width workspace surface. */
+export function WorkspacePageHeader({
+  electron = false,
+  reserveNativeControls = electron,
+  className,
+  ...props
+}: ComponentPropsWithoutRef<"header"> & {
+  readonly electron?: boolean;
+  readonly reserveNativeControls?: boolean;
+}) {
+  return (
+    <header
+      className={cn(
+        "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-3 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
+        electron && "drag-region",
+        reserveNativeControls && "wco:pr-[var(--workspace-native-controls-inset)]",
+        COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */
+export function WorkspacePageHeaderEdgeControl({
+  className,
+  ...props
+}: ComponentPropsWithoutRef<"div">) {
+  return <div className={cn("-me-[7px] flex shrink-0", className)} {...props} />;

apps/web/src/components/WorkspacePageContainer.tsx · right 56–61

M
macroscopeappBotAug 16, 2026

components/WorkspacePageContainer.tsx:61

WorkspacePageHeaderEdgeControl is exported but not used by any header in this PR. The -me-[7px] offset is only meaningful relative to a concrete control's hit-target padding, so without a consumer there is nothing pinning that value to a real edge alignment.

Suggest landing this helper with its first call site so the offset is verifiable.

No longer relevant as of f18175796d90e63405862a4c2e246ec6b9f343c1

apps/web/src/components/Sidebar.tsx · Earlier revision b53b5693 · right 1477–1481

Original diff
@@ -1370,130 +1365,128 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
               ) : (
                 <span className="flex-1" />
               )}
-              {props.isPinned ? (
-                props.pinningSupported ? (
-                  <Tooltip>
-                    <TooltipTrigger
-                      render={
-                        <button
-                          type="button"
-                          aria-label="Unpin thread"
-                          onClick={handleUnpinClick}
-                          className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
-                        />
-                      }
+              <span className="ml-auto flex h-5 shrink-0 items-center">
+                {props.isPinned ? (
+                  props.pinningSupported ? (
+                    <Button
+                      size="icon-micro"
+                      variant="ghost-muted"
+                      aria-label="Unpin thread"
+                      title="Unpin thread"
+                      onClick={handleUnpinClick}
                     >
-                      <PinIcon aria-hidden className="size-3 shrink-0" />
-                    </TooltipTrigger>
-                    <TooltipPopup>Unpin thread</TooltipPopup>
-                  </Tooltip>
-                ) : (
-                  <PinIcon
-                    aria-label="Pinned"
-                    role="img"
-                    className="size-3 shrink-0 text-muted-foreground/65"
-                  />
-                )
-              ) : null}
-              {/* The visible state owns this slot's width: status at rest,
-                  actions on hover/keyboard focus or while the popover is open. Keeping
-                  the hidden state out of flow lets the project label reclaim
-                  space without either state overlapping it. */}
-              <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
-                {/* Read-only status labels yield to the hover actions. Woke is
-                    itself an action, so it stays pointer-enabled and visible
-                    while the other controls appear beside it. */}
+                      <PinIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
+                    </Button>
+                  ) : (
+                    <span className="inline-flex size-5 shrink-0 items-center justify-center">
+                      <PinIcon
+                        aria-label="Pinned"
+                        role="img"
+                        className={cn(SIDEBAR_LIFECYCLE_ICON_CLASS, "text-muted-foreground")}
+                      />
+                    </span>
+                  )
+                ) : null}
+                {/* Only the visible state owns this slot's width: the pin stays
+                    directly beside the idle status and beside the first action
+                    when the hover controls replace it. */}
                 <span
                   className={cn(
-                    isWokeStatus
-                      ? "pointer-events-auto"
-                      : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
-                    "self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
-                    snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
+                    "group/sidebar-status-slot relative flex h-5 shrink-0 items-stretch justify-end text-xs",
+                    props.isPinned ? "min-w-0" : "min-w-8",
                   )}
                 >
-                  {topStatus ? (
-                    isWokeStatus ? (
-                      <button
-                        type="button"
-                        aria-label="Dismiss Woke notification"
-                        title="Dismiss Woke notification"
-                        onClick={handleAcknowledgeWokeClick}
-                        className={cn(
-                          "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
-                          topStatus.className,
-                        )}
-                      >
-                        <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
-                        <span role="status">{topStatus.label}</span>
-                      </button>
-                    ) : (
-                      <span
-                        className={cn(
-                          "inline-flex items-center gap-1 font-medium",
-                          topStatus.className,
-                        )}
-                      >
-                        {topStatus.icon === "working" ? (
-                          <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
-                        ) : topStatus.icon === "done" ? (
-                          <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
-                        ) : null}
-                        {/* The label alone is the live region: a role="status"
-                            wrapper around the ticking duration would make
-                            screen readers announce every second. */}
-                        <span role="status">{topStatus.label}</span>
-                        {status === "working" ? (
-                          <span aria-hidden>
-                            <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
-                          </span>
-                        ) : null}
-                      </span>
-                    )
-                  ) : (
-                    threadTimeLabel(thread)
-                  )}
-                </span>
-                {props.settlementSupported || showSnoozeButton ? (
+                  {/* Read-only status labels yield to the hover actions. Woke is
+                      itself an action, so it stays pointer-enabled and visible
+                      while the other controls appear beside it. */}
                   <span
                     className={cn(
-                      // focus-visible, not focus-within: a mouse click leaves
-                      // the Settle button focused, and a plain focus-within
-                      // would keep the controls pinned over the status label
-                      // once the pointer moves away (e.g. after a failed
-                      // settle) instead of cross-fading back.
-                      "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
-                      snoozeMenuOpen && "pointer-events-auto static opacity-100",
+                      isWokeStatus
+                        ? "pointer-events-auto"
+                        : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
+                      "self-center tabular-nums text-secondary-label transition-opacity",
+                      snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
                     )}
                   >
-                    {showSnoozeButton ? (
-                      <SnoozePopoverButton
-                        open={snoozeMenuOpen}
-                        onOpenChange={setSnoozeMenuOpen}
-                        onSnooze={handleSnoozePreset}
-                        timestampFormat={props.timestampFormat}
-                      />
-                    ) : null}
-                    {props.settlementSupported ? (
-                      <Tooltip>
-                        <TooltipTrigger
-                          render={
-                            <button
-                              type="button"
-                              aria-label="Settle thread"
-                              onClick={handleSettleClick}
-                              className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
+                    {topStatus ? (
+                      isWokeStatus ? (
+                        <button
+                          type="button"
+                          aria-label="Dismiss Woke notification"
+                          title="Dismiss Woke notification"
+                          onClick={handleAcknowledgeWokeClick}
+                          className={cn(
+                            "inline-flex h-5 cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
+                            topStatus.className,
+                          )}
+                        >
+                          <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
+                          <span role="status">{topStatus.label}</span>
+                        </button>
+                      ) : (
+                        <span
+                          className={cn(
+                            "inline-flex items-center gap-1 font-medium",
+                            topStatus.className,
+                          )}
+                        >
+                          {topStatus.icon === "working" ? (
+                            <CircleDashedIcon
+                              aria-hidden
+                              className={SIDEBAR_LIFECYCLE_ICON_CLASS}
                             />
-                          }
+                          ) : topStatus.icon === "done" ? (
+                            <CircleCheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
+                          ) : null}
+                          {/* The label alone is the live region: a role="status"
+                            wrapper around the ticking duration would make
+                            screen readers announce every second. */}
+                          <span role="status">{topStatus.label}</span>
+                          {status === "working" ? (
+                            <span aria-hidden>
+                              <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
+                            </span>
+                          ) : null}
+                        </span>
+                      )
+                    ) : (
+                      threadTimeLabel(thread)
+                    )}
+                  </span>
+                  {props.settlementSupported || showSnoozeButton ? (
+                    <span
+                      className={cn(
+                        // focus-visible, not focus-within: a mouse click leaves
+                        // the Settle button focused, and a plain focus-within
+                        // would keep the controls pinned over the status label
+                        // once the pointer moves away (e.g. after a failed
+                        // settle) instead of cross-fading back.
+                        "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
+                        snoozeMenuOpen && "pointer-events-auto static opacity-100",
+                      )}
+                    >
+                      {showSnoozeButton ? (
+                        <SnoozePopoverButton
+                          open={snoozeMenuOpen}
+                          onOpenChange={setSnoozeMenuOpen}
+                          onSnooze={handleSnoozePreset}
+                          timestampFormat={props.timestampFormat}
+                        />
+                      ) : null}
+                      {props.settlementSupported ? (
+                        <button
+                          type="button"
+                          aria-label="Settle thread"
+                          onClick={handleSettleClick}
+                          className="-mr-1 inline-flex h-full cursor-pointer items-center gap-1 rounded-md bg-transparent ps-1 pe-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"

apps/web/src/components/Sidebar.tsx · right 1477–1481

M
macroscopeappBotAug 16, 2026

components/Sidebar.tsx:1481

This hover action now hand-rolls exactly what Button variant="ghost-muted" already provides (text-muted-foreground with hover:bg-accent hover:text-foreground, rounded-md, cursor-pointer) — the hover:bg-accent added here is the primitive's own hover treatment — while the Snooze and Unpin controls in the same slot were moved onto that primitive in this PR. Leaving one of the three siblings hand-rolled means hover/focus treatment for the row's actions now lives in two places and can drift.

Consider routing Settle through the same primitive; if the 20px slot needs a shorter size than xs, prefer adding that size to Button over keeping a bespoke class string here.

No longer relevant as of f18175796d90e63405862a4c2e246ec6b9f343c1

apps/web/src/components/ui/button.tsx · Earlier revision a22960d1 · right 32–33

Original diff
@@ -29,6 +29,8 @@ const buttonVariants = cva(
         "icon-xs":
           "size-7 sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5",
         lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9",
+        micro:
+          "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-xs before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3",

apps/web/src/components/ui/button.tsx · right 32–33

M
macroscopeappBotAug 19, 2026

The new micro size sets text-xs / [&_svg…]:size-3 but doesn't override the base's sm: step, so both are dead above 640px. The base string carries text-base … sm:text-sm and [&_svg:not([class*='size-'])]:size-4.5 sm:…:size-4; twMerge only resolves conflicts between classes with identical modifiers, so sm:text-sm survives and wins inside its media query. Net effect: a micro button renders 12px text on mobile and 14px on desktop — larger than xs (text-sm sm:text-xs → 12px) — inside a 20px-tall control, and its unsized icons snap back to size-4.

That is a regression for this PR's consumer: the Settle control (Sidebar.tsx:1522) was text-xs before the migration and sits beside 12px status text in the same slot. Every other size that shrinks the type pins the sm: step too (xs, xl, and the select/badge equivalents).

suggestion
1 micro:
2 "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-xs sm:text-xs before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3 sm:[&_svg:not([class*='size-'])]:size-3",

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

The micro size moved to its first consumer in #7150 and now pins both desktop text and icon sizes.

apps/web/src/contextMenuFallback.ts · Earlier revision 8f1ea220 · right 279–281

Original diff
@@ -200,6 +274,16 @@ export function showContextMenuFallback<T extends string>(
         "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
 
       for (const item of entries) {
+        if (item.separatorBefore === true && inner.childElementCount > 0) {
+          const separator = document.createElement("div");
+          separator.className = "my-1 h-px bg-border/70";
+          separator.style.cssText =
+            "height:1px;margin:0.25rem 0;background:var(--border);opacity:0.7;";

apps/web/src/contextMenuFallback.ts · right 279–281

M
macroscopeappBotAug 17, 2026

src/contextMenuFallback.ts:281

This fallback menu mirrors the shared menu primitive elsewhere (row base copies MenuItem's rounded-sm px-2 py-1 min-h-8 sm:min-h-7, the popup copies MenuPopup's dropdown-glass … min-w-32 rounded-lg), but the new divider diverges from MenuSeparator (mx-2 my-1 h-px bg-border): it is lighter (bg-border/70 / opacity:0.7) and has no horizontal inset, so it runs 0.5rem wider than the item labels it separates while native/Base UI menus keep the divider aligned to the label column.

Suggest matching the shared separator contract:

suggestion
1 separator.className = "mx-2 my-1 h-px bg-border";
2 separator.style.cssText = "height:1px;margin:0.25rem 0.5rem;background:var(--border);";

No longer relevant as of d8a25c983cbbe4ee4764448ae1664fe6f0a69393

apps/web/src/components/settings/SettingsSidebarNav.tsx · Earlier revision f1817579 · right 287–287

Original diff
@@ -296,14 +284,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
       <SidebarFooter className="p-[var(--sidebar-content-inset)]">
         <T3ConnectSidebarSignIn />
         <div className="flex items-center gap-1">
-          <SidebarMenu className="min-w-0 flex-1">
-            <SidebarMenuItem>
-              <SidebarMenuButton onClick={handleBackClick}>
-                <ArrowLeftIcon />
-                <span>Back</span>
-              </SidebarMenuButton>
-            </SidebarMenuItem>
-          </SidebarMenu>
+          <SidebarUtilityMenu />

apps/web/src/components/settings/SettingsSidebarNav.tsx · right 287–287

M
macroscopeappBotAug 16, 2026

settings/SettingsSidebarNav.tsx:287

The local menu this replaces carried min-w-0 flex-1, but SidebarUtilityMenu renders SidebarMenu with only w-full (flex-basis auto). In this footer row (flex items-center gap-1 next to T3ConnectSidebarAvatar) a 100%-wide item overflows the row, so the shrink is now shared with the avatar instead of the menu absorbing the free space.

Suggest restoring the previous distribution (or adding a className passthrough on SidebarUtilityMenu and passing min-w-0 flex-1):

suggestion
1 <div className="min-w-0 flex-1">
2 <SidebarUtilityMenu />
3 </div>

No longer relevant as of 27bb7f481e723c2eef312ca4fa5ba8c0c1394aae

apps/web/src/components/Sidebar.tsx · Earlier revision b53b5693 · right 3312

Original diff
@@ -3301,17 +3294,25 @@ export default function Sidebar() {
     autoAnimate(node, { duration: 150, easing: "ease-out" });
   }, []);
 
-  // New thread defaults to the project you're in (active thread's project,
-  // falling back to the top project) — same resolution the command palette
-  // uses. The command palette already offers a "New thread in..." submenu
-  // for multi-project setups.
+  // A selected project scope owns creation: users should not have to choose
+  // the same project twice. "All projects" keeps the picker in multi-project
+  // setups, while Shift+click retains the direct-create shortcut.
   const handleNewThreadClick = useCallback(
     (event?: ReactMouseEvent) => {
-      // One project: nothing to pick, create immediately. Shift+click creates
-      // directly in the current project even with several projects, skipping
-      // the palette picker.
-      if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) {
+      if (
+        shouldCreateNewThreadInCurrentProject(
+          event?.shiftKey ?? false,
+          projectGroups.length,
+          scopedProjectGroup !== null,
+        )
+      ) {
         if (isMobile) setOpenMobile(false);
+        if (scopedProjectGroup) {
+          void newThreadContext.handleNewThread(
+            scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id),
M
macroscopeappBotAug 16, 2026

🟡 Medium components/Sidebar.tsx:3312

When a scoped logical project has members in multiple environments, this branch always creates the thread with scopedProjectGroup.environmentId and scopedProjectGroup.id, so a thread or draft opened in another member environment is silently created in the representative environment instead of the current context. Resolve the contextual member project ref (as the command-palette path does) before falling back to the group's representative ref.

✅ Resolved in 93043b7f1baa338de70d66ee6e053a9dc032c1c2

apps/web/src/components/Sidebar.tsx · Earlier revision 528d2c9b · right 1518–1529

Original diff
@@ -1384,136 +1392,144 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
               ) : (
                 <span className="flex-1" />
               )}
-              {props.isPinned ? (
-                props.pinningSupported ? (
-                  <Tooltip>
-                    <TooltipTrigger
-                      render={
-                        <button
-                          type="button"
-                          aria-label="Unpin thread"
-                          onClick={handleUnpinClick}
-                          className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
-                        />
-                      }
-                    >
-                      <PinIcon aria-hidden className="size-3 shrink-0" />
-                    </TooltipTrigger>
-                    <TooltipPopup>Unpin thread</TooltipPopup>
-                  </Tooltip>
-                ) : (
-                  <PinIcon
-                    aria-label="Pinned"
-                    role="img"
-                    className="size-3 shrink-0 text-muted-foreground/65"
-                  />
-                )
-              ) : null}
-              {/* The visible state owns this slot's width: status at rest,
-                  actions on hover/keyboard focus or while the popover is open. Keeping
-                  the hidden state out of flow lets the project label reclaim
-                  space without either state overlapping it. */}
-              <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
-                {/* Read-only status labels yield to the hover actions. Woke is
-                    itself an action, so it stays pointer-enabled and visible
-                    while the other controls appear beside it. */}
+              <span className="ml-auto flex h-5 shrink-0 items-center">
+                {props.isPinned ? (
+                  props.pinningSupported ? (
+                    <Tooltip>
+                      <TooltipTrigger
+                        render={
+                          <Button
+                            size="icon-micro"
+                            variant="ghost-muted"
+                            aria-label="Unpin thread"
+                            onClick={handleUnpinClick}
+                          />
+                        }
+                      >
+                        <PinIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
+                      </TooltipTrigger>
+                      <TooltipPopup side="top">Unpin thread</TooltipPopup>
+                    </Tooltip>
+                  ) : (
+                    <span className="inline-flex size-5 shrink-0 items-center justify-center">
+                      <PinIcon
+                        aria-label="Pinned"
+                        role="img"
+                        className={cn(SIDEBAR_LIFECYCLE_ICON_CLASS, "text-muted-foreground")}
+                      />
+                    </span>
+                  )
+                ) : null}
+                {/* Only the visible state owns this slot's width: the pin stays
+                    directly beside the idle status and beside the first action
+                    when the hover controls replace it. */}
                 <span
                   className={cn(
-                    isWokeStatus
-                      ? "pointer-events-auto"
-                      : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
-                    "self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
-                    snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
+                    "group/sidebar-status-slot relative flex h-5 shrink-0 items-stretch justify-end text-xs",
+                    props.isPinned ? "min-w-0" : "min-w-8",
                   )}
                 >
-                  {topStatus ? (
-                    isWokeStatus ? (
-                      <Tooltip>
-                        <TooltipTrigger
-                          render={
-                            <button
-                              type="button"
-                              aria-label="Dismiss Woke notification"
-                              onClick={handleAcknowledgeWokeClick}
-                              className={cn(
-                                "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
-                                topStatus.className,
-                              )}
-                            >
-                              <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
-                              <span role="status">{topStatus.label}</span>
-                            </button>
-                          }
-                        />
-                        <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
-                      </Tooltip>
-                    ) : (
-                      <span
-                        className={cn(
-                          "inline-flex items-center gap-1 font-medium",
-                          topStatus.className,
-                        )}
-                      >
-                        {topStatus.icon === "working" ? (
-                          <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
-                        ) : topStatus.icon === "done" ? (
-                          <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
-                        ) : null}
-                        {/* The label alone is the live region: a role="status"
-                            wrapper around the ticking duration would make
-                            screen readers announce every second. */}
-                        <span role="status">{topStatus.label}</span>
-                        {status === "working" ? (
-                          <span aria-hidden>
-                            <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
-                          </span>
-                        ) : null}
-                      </span>
-                    )
-                  ) : (
-                    threadTimeLabel(thread)
-                  )}
-                </span>
-                {props.settlementSupported || showSnoozeButton ? (
+                  {/* Read-only status labels yield to the hover actions. Woke is
+                      itself an action, so it stays pointer-enabled and visible
+                      while the other controls appear beside it. */}
                   <span
                     className={cn(
-                      // focus-visible, not focus-within: a mouse click leaves
-                      // the Settle button focused, and a plain focus-within
-                      // would keep the controls pinned over the status label
-                      // once the pointer moves away (e.g. after a failed
-                      // settle) instead of cross-fading back.
-                      "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
-                      snoozeMenuOpen && "pointer-events-auto static opacity-100",
+                      isWokeStatus
+                        ? "pointer-events-auto"
+                        : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
+                      "self-center tabular-nums text-secondary-label transition-opacity",
+                      snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
                     )}
                   >
-                    {showSnoozeButton ? (
-                      <SnoozePopoverButton
-                        open={snoozeMenuOpen}
-                        onOpenChange={setSnoozeMenuOpen}
-                        onSnooze={handleSnoozePreset}
-                        timestampFormat={props.timestampFormat}
-                      />
-                    ) : null}
-                    {props.settlementSupported ? (
-                      <Tooltip>
-                        <TooltipTrigger
-                          render={
-                            <button
-                              type="button"
-                              aria-label="Settle thread"
-                              onClick={handleSettleClick}
-                              className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
+                    {topStatus ? (
+                      isWokeStatus ? (
+                        <Tooltip>
+                          <TooltipTrigger
+                            render={
+                              <button
+                                type="button"
+                                aria-label="Dismiss Woke notification"
+                                onClick={handleAcknowledgeWokeClick}
+                                className={cn(
+                                  "inline-flex h-5 cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
+                                  topStatus.className,
+                                )}
+                              >
+                                <AlarmClockIcon
+                                  aria-hidden
+                                  className={SIDEBAR_LIFECYCLE_ICON_CLASS}
+                                />
+                                <span role="status">{topStatus.label}</span>
+                              </button>
+                            }
+                          />
+                          <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
+                        </Tooltip>
+                      ) : (
+                        <span
+                          className={cn(
+                            "inline-flex items-center gap-1 font-medium",
+                            topStatus.className,
+                          )}
+                        >
+                          {topStatus.icon === "working" ? (
+                            <CircleDashedIcon
+                              aria-hidden
+                              className={SIDEBAR_LIFECYCLE_ICON_CLASS}
                             />
-                          }
+                          ) : topStatus.icon === "done" ? (
+                            <CircleCheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
+                          ) : null}
+                          {/* The label alone is the live region: a role="status"
+                            wrapper around the ticking duration would make
+                            screen readers announce every second. */}
+                          <span role="status">{topStatus.label}</span>
+                          {status === "working" ? (
+                            <span aria-hidden>
+                              <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
+                            </span>
+                          ) : null}
+                        </span>
+                      )
+                    ) : (
+                      threadTimeLabel(thread)
+                    )}
+                  </span>
+                  {props.settlementSupported || showSnoozeButton ? (
+                    <span
+                      className={cn(
+                        // focus-visible, not focus-within: a mouse click leaves
+                        // the Settle button focused, and a plain focus-within
+                        // would keep the controls pinned over the status label
+                        // once the pointer moves away (e.g. after a failed
+                        // settle) instead of cross-fading back.
+                        "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
+                        snoozeMenuOpen && "pointer-events-auto static opacity-100",
+                      )}
+                    >
+                      {showSnoozeButton ? (
+                        <SnoozePopoverButton
+                          open={snoozeMenuOpen}
+                          onOpenChange={setSnoozeMenuOpen}
+                          onSnooze={handleSnoozePreset}
+                          timestampFormat={props.timestampFormat}
+                        />
+                      ) : null}
+                      {props.settlementSupported ? (
+                        <Button
+                          size="micro"
+                          variant="ghost-muted"
+                          aria-label="Settle thread"
+                          onClick={handleSettleClick}
+                          className="-mr-1 ps-1 pe-1.5"
                         >
-                          <CheckIcon className="size-3.5" />
+                          <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
                           Settle
-                        </TooltipTrigger>
-                        <TooltipPopup>Settle thread</TooltipPopup>
-                      </Tooltip>
-                    ) : null}
-                  </span>
-                ) : null}
+                        </Button>
+                      ) : null}

apps/web/src/components/Sidebar.tsx · right 1518–1529

M
macroscopeappBotAug 19, 2026

components/Sidebar.tsx:1529

The Button migration dropped this control's Tooltip/TooltipPopup (Settle thread), which the other actions in this slot kept (SnoozePopoverButton line 412, Unpin line 411, Woke line 1466).

That is a behavior change, not just missing chrome: the whole row is a TooltipTrigger for detailsTooltip (lines 1359-1374, 1594), and the nested tooltips are what suppress it while the pointer is over an action. With this one removed, hovering Settle now pops the row's full details tooltip instead of the action's own label — so the last control in the hover strip behaves differently from its two neighbours.

suggestion
1 {props.settlementSupported ? (
2 <Tooltip>
3 <TooltipTrigger
4 render={
5 <Button
6 size="micro"
7 variant="ghost-muted"
8 aria-label="Settle thread"
9 onClick={handleSettleClick}
10 className="-mr-1 ps-1 pe-1.5"
11 />
12 }
13 >
14 <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
15 Settle
16 </TooltipTrigger>
17 <TooltipPopup side="top">Settle thread</TooltipPopup>
18 </Tooltip>
19 ) : null}

No longer relevant as of 3f11bc2f64d3c4e5d0cd175b386a2a10065b0141

apps/web/src/components/composerInlineChip.ts · Earlier revision bc081741 · right 25–25

Original diff
@@ -11,18 +11,18 @@ export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[
 export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME =
   "relative inline-flex align-[-0.125em] leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']";
 
-export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85";
+export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME =
+  "block size-[1.17em] shrink-0 self-center opacity-85 [&>svg]:block";
 
 export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";
 
-export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`;
+export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME =
+  "block self-center truncate leading-none select-none";
 
-// The skill label is smaller than the surrounding prompt text; offset its
-// glyphs without moving the pill box or changing the editor's line height.
-export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`;
+export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME;
 
 export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
-  "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300";
+  "inline-flex h-[1.41em] max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] font-medium text-[0.86em] leading-none text-fuchsia-700 align-middle dark:text-fuchsia-300";

apps/web/src/components/composerInlineChip.ts · right 25–25

M
macroscopeappBotAug 16, 2026

The skill chip repeats the base chip's geometry verbatim — inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] px-[0.5em] font-medium leading-none align-middle — so the new fixed h-[1.41em]/leading-none contract that replaced the removed py+top-[0.15em] offset now has two owners. Tuning one (the height is what keeps glyphs centered at the composer's user-configurable prompt size) silently misaligns the other, and these strings are raw-concatenated rather than merged through cn, so a call site cannot correct it.

Suggest hoisting the shared geometry into one constant (e.g. INLINE_CHIP_GEOMETRY_CLASS_NAME) that both INLINE_CHIP_CLASS_NAME and the skill chip build on, leaving only the fuchsia border/background/text tone here. Related: COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME (line 22) is now an exact alias of COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; pointing its call sites at the base constant would drop the dead indirection.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Removed from this PR while narrowing it to workspace navigation and page layout.

apps/desktop/src/electron/ElectronMenu.ts · Earlier revision f1817579 · right 155

Original diff
@@ -141,10 +142,17 @@ export const make = Effect.gen(function* () {
   ): Electron.MenuItemConstructorOptions[] => {
     const template: Electron.MenuItemConstructorOptions[] = [];
     let hasInsertedDestructiveSeparator = false;
+    const appendSeparator = () => {
+      if (template.length === 0 || template.at(-1)?.type === "separator") return;
+      template.push({ type: "separator" });
+    };
 
     for (const item of entries) {
+      if (item.separatorBefore) {
+        appendSeparator();
+      }
       if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
-        template.push({ type: "separator" });
+        appendSeparator();
C
cursorBotAug 16, 2026

Archive isolated from Delete on Electron

Low Severity

separatorBefore on archive and the Electron builder’s automatic separator before the first destructive item both fire, so native menus render Archive between two dividers. The web fallback only honors separatorBefore, so Archive stays next to Delete there.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit f18175796d90e63405862a4c2e246ec6b9f343c1. Configure here.

apps/web/src/components/Sidebar.tsx · Earlier revision 3f11bc2f · right 197–197

Original diff
@@ -193,6 +194,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25;
 // Keep the v2 key so existing preferences survive the v2-to-default rename.
 const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded";
 const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded";
+const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0";

apps/web/src/components/Sidebar.tsx · right 197–197

M
macroscopeappBotAug 19, 2026

components/Sidebar.tsx:197

Moving these controls onto Button variant="ghost-muted" pins their glyph tone. The variant sets [--control-icon-color:var(--muted-foreground)], and the base rule [&_svg:not([class*='text-'])]:text-[var(--control-icon-color)] matches because this class carries no text-* — so the variant's [:hover]:text-foreground brightens only the element's own text, not the icon.

The hand-rolled controls this replaces were text-muted-foreground hover:text-foreground with the glyph inheriting currentColor, so Snooze (400), Wake (1286), Un-settle (1301), Settle (1315), Unpin (1400) all lost their hover brightening. It is most visible on the micro Settle button (1522): the "Settle" label brightens on hover while its check icon stays muted, a two-tone state the control never had.

Suggest opting the glyph back into currentColor here — the pinned-icon call site (cn(SIDEBAR_LIFECYCLE_ICON_CLASS, "text-muted-foreground")) still wins through cn, and per-button [--control-icon-color:currentColor] (the pattern used in ui/toast.tsx, ComposerPreviewAnnotationCards.tsx) also works if you prefer keeping the override on the button.

suggestion
1const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0 text-current";

No longer relevant as of a22960d18260f21b711753e6298fb454ad94ae85

apps/web/src/contextMenuFallback.ts · Earlier revision d8a25c98 · right 134–134

Original diff
@@ -57,7 +131,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV
   svg.setAttribute("stroke-linejoin", "round");
   svg.setAttribute(
     "class",
-    tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground",
+    tone === "destructive" ? "size-4 shrink-0" : "size-4 shrink-0 text-muted-foreground",

apps/web/src/contextMenuFallback.ts · right 134–134

M
macroscopeappBotAug 17, 2026

src/contextMenuFallback.ts:134

This fallback deliberately mirrors MenuItem's row metrics (min-h-8 text-base sm:min-h-7 sm:text-sm), but the new icon size is fixed at 16px while the primitive's icon contract is responsive ([&>svg:not([class*='size-'])]:size-4.5 sm:...:size-4). Below sm — the touch/mobile-web case where this fallback is the menu — its rows render 16px glyphs where a Base UI menu renders 18px, so the two menus drift on exactly the viewport range the row base is matching.

Same applies to the new chevron on line 335 (size-4 + text-muted-foreground/80), where MenuSubmenuTrigger renders -me-0.5 ms-auto opacity-80 at the same responsive size.

suggestion
1 tone === "destructive"
2 ? "size-4.5 shrink-0 sm:size-4"
3 : "size-4.5 shrink-0 text-muted-foreground sm:size-4",

No longer relevant as of 904f059489d273e529dfae71a8c3e0211506d6e5