Mike0021 commited on
Commit
bc047fa
·
verified ·
1 Parent(s): 55788e8

Pi Web fork: effort rows on the icon column; a scrolling dropdown keeps its rows centred

Browse files
Files changed (1) hide show
  1. piweb/hf-design.patch +264 -78
piweb/hf-design.patch CHANGED
@@ -1,5 +1,5 @@
1
  diff --git a/AGENTS.md b/AGENTS.md
2
- index a5c8983..36e53be 100644
3
  --- a/AGENTS.md
4
  +++ b/AGENTS.md
5
  @@ -67,6 +67,11 @@ app/api/
@@ -57,7 +57,7 @@ index a5c8983..36e53be 100644
57
  + ContextMeter.tsx the composer's context ring and its popover (tokens, cost, Compact)
58
  + ComposerModelMenu.tsx the composer's model-and-effort menu
59
  + ComposerModeMenu.tsx the composer's mode menu: tool presets as Standard / Plan / Chat (+ pinned sets), ⌘/Ctrl+Shift+M
60
- + ui/ Base UI wrappers, styled in app/ui.css (every dropdown's proportions): menu.tsx (MenuSection for labelled groups), tooltip.tsx, popover.tsx, composer-list.tsx (the "/", "@" and history list)
61
  + ModelMark.tsx a model's maker mark for menus (the repo's organisation on a router, else the provider), in grays
62
  SessionSidebar.tsx toolbar row (Sessions | Files tabs, or the Pi Web brand with the files below them; new, search): tree, archive, menus, toast; files section (resizable, foldable; one header row with the file keys and changed-files count, the picker's row) or tab (picker boxes + file buttons), FileExplorer
63
  - PiWebTitle.tsx the sidebar's "Pi Web" brand (files below the sessions only): a click scrambles it into the versions
@@ -6555,7 +6555,7 @@ index 0000000..8dbaf17
6555
  +.browser-console-text { flex: 1 1 auto; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
6556
  +.browser-console-location { flex: none; max-width: 40%; overflow: hidden; color: var(--text-dim); text-overflow: ellipsis; white-space: nowrap; direction: rtl; }
6557
  diff --git a/app/sidebar-menu.css b/app/sidebar-menu.css
6558
- index 737e7a5..b453d7e 100644
6559
  --- a/app/sidebar-menu.css
6560
  +++ b/app/sidebar-menu.css
6561
  @@ -19,13 +19,14 @@
@@ -6566,8 +6566,8 @@ index 737e7a5..b453d7e 100644
6566
  - border: 1px solid var(--border);
6567
  - border-radius: 8px;
6568
  - background: var(--bg);
6569
- + /* The dropdowns' proportions (app/ui.css, "dropdowns"). */
6570
- + padding: var(--popup-inset);
6571
  + border-radius: var(--popup-radius);
6572
  + background: var(--bg-elevated);
6573
  color: var(--text);
@@ -6681,7 +6681,7 @@ index 737e7a5..b453d7e 100644
6681
  height: 1px;
6682
  - margin: 4px 2px;
6683
  - background: var(--border);
6684
- + margin: var(--popup-inset) calc(-1 * var(--popup-inset));
6685
  + background: var(--line);
6686
  }
6687
 
@@ -6750,8 +6750,8 @@ index 737e7a5..b453d7e 100644
6750
  - padding: 4px;
6751
  - border-bottom: 1px solid var(--border);
6752
  - background: var(--bg);
6753
- + margin: calc(-1 * var(--popup-inset)) calc(-1 * var(--popup-inset)) 0;
6754
- + padding: var(--popup-inset) var(--popup-inset) 0;
6755
  + background: var(--bg-elevated);
6756
  }
6757
 
@@ -7875,10 +7875,10 @@ index 86728a2..98fb8f2 100644
7875
  cursor: pointer;
7876
  diff --git a/app/ui.css b/app/ui.css
7877
  new file mode 100644
7878
- index 0000000..9113d0c
7879
  --- /dev/null
7880
  +++ b/app/ui.css
7881
- @@ -0,0 +1,563 @@
7882
  +/* The shared components on Base UI (components/ui/*): menus, tooltips,
7883
  + popovers and the composer's suggestion list. Popups are portaled to <body>,
7884
  + so they stack by the positioner's z-index, above every pane. */
@@ -7900,13 +7900,17 @@ index 0000000..9113d0c
7900
  + - a 14px-rounded surface, set off by a hairline and a soft shadow;
7901
  + - rows inset 7px from its edge and rounded 7px, so the two curves run
7902
  + parallel (the surface's radius is the inset plus the row's);
7903
- + - one icon column 14px from the edge and one text column at 37px; section
7904
- + labels, filters and headers start on the icon column, and a row without an
7905
- + icon in a menu that has icons keeps the text column (`is-inset`);
 
7906
  + - rows 28px, back to back; a section label at 12px in --text-dim, in
7907
  + sentence case; a separator edge to edge with the inset above and below;
7908
  + - a row's description inline after its label, the same size, in --text-dim.
7909
- + A touch screen takes the reference's own scale (15px text, 32px rows). */
 
 
 
7910
  +:root {
7911
  + --popup-font: 13px;
7912
  + --popup-label-font: 12px;
@@ -7919,6 +7923,8 @@ index 0000000..9113d0c
7919
  + --popup-gap: 9px;
7920
  + --popup-text-gap: 10px;
7921
  + --popup-edge: calc(var(--popup-inset) + var(--popup-row-pad));
 
 
7922
  + --popup-hairline: color-mix(in srgb, var(--text-strong) 10%, transparent);
7923
  + --popup-shadow: 0 0 0 1px var(--popup-hairline), 0 2px 4px rgb(0 0 0 / 0.04), 0 12px 28px -6px rgb(0 0 0 / 0.14);
7924
  + /* Rows on a popup: a tint of the ink, so they show on --bg-elevated in every theme. */
@@ -7965,13 +7971,50 @@ index 0000000..9113d0c
7965
  + font-weight: 400;
7966
  + line-height: 16px;
7967
  +}
7968
- +/* Edge to edge, 6px either side; inside a padded surface it reaches past the inset. */
7969
  +.ui-menu-separator,
7970
  +.ui-dropdown-separator {
7971
  + height: 1px;
7972
- + margin: var(--popup-inset) calc(-1 * var(--popup-inset));
7973
  + background: var(--line);
7974
  +}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7975
  +
7976
  +/* ---------------------------------------------------------------- menus */
7977
  +.ui-menu {
@@ -7979,7 +8022,7 @@ index 0000000..9113d0c
7979
  + max-width: min(420px, calc(100vw - 24px));
7980
  + max-height: var(--available-height);
7981
  + overflow-y: auto;
7982
- + padding: var(--popup-inset);
7983
  + transform-origin: var(--transform-origin);
7984
  + transition: opacity 0.12s, transform 0.12s;
7985
  +}
@@ -8000,7 +8043,6 @@ index 0000000..9113d0c
8000
  + user-select: none;
8001
  + -webkit-user-select: none;
8002
  +}
8003
- +.ui-menu-item.is-inset { padding-left: calc(var(--popup-row-pad) + var(--popup-icon) + var(--popup-gap)); }
8004
  +.ui-menu-item[data-highlighted] { background: var(--popup-highlight); color: var(--text-strong); }
8005
  +.ui-menu-item[data-disabled] { opacity: 0.45; }
8006
  +.ui-menu-item[data-popup-open] { background: var(--popup-highlight); }
@@ -8038,7 +8080,7 @@ index 0000000..9113d0c
8038
  +}
8039
  +.ui-dropdown-filter-input::placeholder { color: var(--text-dim); }
8040
  +/* The scroller reaches the surface's sides, so a separator inside it can too. */
8041
- +.ui-dropdown-scroll { min-height: 0; overflow-y: auto; margin: 0 calc(-1 * var(--popup-inset)); padding: 0 var(--popup-inset); }
8042
  +.ui-dropdown-empty { padding: 4px var(--popup-row-pad) 6px; color: var(--text-dim); white-space: nowrap; }
8043
  +.ui-dropdown-row-wrap { position: relative; border-radius: var(--popup-row-radius); transition: background 0.1s; }
8044
  +.ui-dropdown-row-wrap:hover, .ui-dropdown-row-wrap:focus-within { background: var(--popup-highlight); }
@@ -8061,7 +8103,6 @@ index 0000000..9113d0c
8061
  +}
8062
  +.ui-dropdown-row:focus-visible { outline: none; }
8063
  +.ui-dropdown-row[aria-selected="true"] { color: var(--text-strong); }
8064
- +.ui-dropdown-row.is-inset { padding-left: calc(var(--popup-row-pad) + var(--popup-icon) + var(--popup-gap)); }
8065
  +.ui-dropdown-row.has-gutter { padding-right: calc(var(--popup-row-pad) + 28px); }
8066
  +.ui-dropdown-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: var(--popup-icon); height: var(--popup-icon); color: var(--text-muted); }
8067
  +.ui-dropdown-row-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
@@ -8151,10 +8192,10 @@ index 0000000..9113d0c
8151
  + font-size: var(--popup-label-font);
8152
  + line-height: 16px;
8153
  +}
8154
- +.ui-composer-list-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 var(--popup-inset); }
8155
  +/* A group after the first starts after an edge-to-edge line, as a menu's section does. */
8156
  +.ui-composer-list-row + .ui-composer-list-group {
8157
- + margin: var(--popup-inset) calc(-1 * var(--popup-inset)) 0;
8158
  + padding: calc(var(--popup-inset) + 4px) var(--popup-edge) 4px;
8159
  + border-top: 1px solid var(--line);
8160
  +}
@@ -11596,7 +11637,7 @@ index 42d3545..8418d90 100644
11596
  assert.equal(clampChatContentWidth(2400), 2000);
11597
  });
11598
  diff --git a/components/ChatInput.test.mjs b/components/ChatInput.test.mjs
11599
- index adfcc12..023eb9c 100644
11600
  --- a/components/ChatInput.test.mjs
11601
  +++ b/components/ChatInput.test.mjs
11602
  @@ -263,15 +263,26 @@ test("cycleListIndex wraps in both directions", () => {
@@ -11715,8 +11756,8 @@ index adfcc12..023eb9c 100644
11715
  // Rows without a star still line up with starred ones in the same menu.
11716
  - assert.match(row({ gutter: true }), /padding:7px 36px 7px 12px/);
11717
  + assert.match(row({ gutter: true }), /class="ui-dropdown-row has-gutter"/);
11718
- + // A row without a mark keeps the text column of the rows that have one.
11719
- + assert.match(row({ inset: true }), /class="ui-dropdown-row is-inset"/);
11720
  + assert.match(row({ media: React.createElement("svg", { id: "mark" }) }), /<span class="ui-dropdown-icon"><svg id="mark">/);
11721
 
11722
  + // The chosen row's check sits on the right, as the menus' do.
@@ -16216,10 +16257,10 @@ index 0000000..a194c8b
16216
  +}
16217
  diff --git a/components/ComposerModelMenu.tsx b/components/ComposerModelMenu.tsx
16218
  new file mode 100644
16219
- index 0000000..0f021aa
16220
  --- /dev/null
16221
  +++ b/components/ComposerModelMenu.tsx
16222
- @@ -0,0 +1,184 @@
16223
  +"use client";
16224
  +
16225
  +// One menu for what answers and how hard it thinks, as Claude Desktop's model
@@ -16341,7 +16382,7 @@ index 0000000..0f021aa
16341
  + </MenuTrigger>
16342
  + </Tip>
16343
  + <MenuPopup side="top" align="end" width={272}>
16344
- + {/* Each model with its maker's mark; the levels and the action keep the same text column. */}
16345
  + <MenuSection label={t("chat.modelGroup")}>
16346
  + <MenuRadioGroup
16347
  + value={value ? modelKey(value) : ""}
@@ -16375,7 +16416,6 @@ index 0000000..0f021aa
16375
  + <MenuRadioItem
16376
  + key={level}
16377
  + value={level}
16378
- + inset
16379
  + description={level === "auto" ? t(THINKING_LEVEL_DESC_KEYS[level]) : undefined}
16380
  + hint={level !== "auto" && savedDefaultThinkingLevel === level ? t("chat.defaultBadge") : undefined}
16381
  + >
@@ -19743,20 +19783,29 @@ index 0000000..1b0fea9
19743
  + return maker ? <ProviderIcon id={maker} size={size} mono /> : <CarbonIcon icon={Carbon.Chip} size={size} />;
19744
  +}
19745
  diff --git a/components/ModelSelector.tsx b/components/ModelSelector.tsx
19746
- index ba912ee..dc8def1 100644
19747
  --- a/components/ModelSelector.tsx
19748
  +++ b/components/ModelSelector.tsx
19749
- @@ -4,6 +4,9 @@ import { useEffect, useRef, useState, type CSSProperties } from "react";
19750
  import { useI18n } from "@/hooks/useI18n";
19751
  import { useIsMobile } from "@/hooks/useIsMobile";
19752
  import { SelectorRow } from "./SelectorRow";
19753
  +import { ModelMark } from "./ModelMark";
 
19754
  +import { CarbonIcon } from "./CarbonIcon";
19755
  +import * as Carbon from "./carbon/icons";
19756
 
19757
  export interface ModelSelectorOption {
19758
  provider: string;
19759
- @@ -110,11 +113,11 @@ export function ModelSelector({
 
 
 
 
 
 
 
 
19760
  gap: 7,
19761
  width: "100%",
19762
  minWidth: 0,
@@ -19771,7 +19820,7 @@ index ba912ee..dc8def1 100644
19771
  background: locked ? "var(--bg-panel)" : "var(--bg)",
19772
  color: locked ? "var(--text-dim)" : "var(--text)",
19773
  cursor: locked ? "default" : "pointer",
19774
- @@ -128,15 +131,18 @@ export function ModelSelector({
19775
  gap: 6,
19776
  width: isMobile ? "100%" : undefined,
19777
  maxWidth: isMobile ? "100%" : 220,
@@ -19794,7 +19843,7 @@ index ba912ee..dc8def1 100644
19794
  opacity: locked ? 0.5 : 1,
19795
  transition: "background 0.12s, color 0.12s",
19796
  };
19797
- @@ -203,22 +209,12 @@ export function ModelSelector({
19798
  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" style={{ animation: "spin 0.8s linear infinite", flexShrink: 0 }} aria-hidden="true">
19799
  <path d="M21 12a9 9 0 1 1-2.64-6.36" />
19800
  </svg>
@@ -19822,7 +19871,7 @@ index ba912ee..dc8def1 100644
19822
  </button>
19823
 
19824
  {open && anchorRect && (() => {
19825
- @@ -240,24 +236,19 @@ export function ModelSelector({
19826
  ref={panelRef}
19827
  role="listbox"
19828
  aria-label={ariaLabel}
@@ -19850,7 +19899,7 @@ index ba912ee..dc8def1 100644
19850
  value={filter}
19851
  onChange={(event) => setFilter(event.target.value)}
19852
  placeholder={t("chat.filterModels")}
19853
- @@ -268,25 +259,13 @@ export function ModelSelector({
19854
  autoFocus={!isMobile}
19855
  autoComplete="off"
19856
  spellCheck={false}
@@ -19872,14 +19921,11 @@ index ba912ee..dc8def1 100644
19872
  </div>
19873
  )}
19874
  - <div style={{ minHeight: 0, overflowY: "auto" }}>
19875
- + <div className="ui-dropdown-scroll">
19876
  {onClear && !filter.trim() && (
19877
- - <SelectorRow active={!value} gutter={Boolean(onSetDefault)} onSelect={() => {
19878
- + <SelectorRow active={!value} inset gutter={Boolean(onSetDefault)} onSelect={() => {
19879
  setOpen(false);
19880
- setFilter("");
19881
- onClear();
19882
- @@ -295,20 +274,22 @@ export function ModelSelector({
19883
  </SelectorRow>
19884
  )}
19885
  {modelsByProvider.length === 0 ? (
@@ -20490,7 +20536,7 @@ index cf38be6..c08aaef 100644
20490
  </button>
20491
  {report && (
20492
  diff --git a/components/SelectorRow.tsx b/components/SelectorRow.tsx
20493
- index 555da8f..b821d16 100644
20494
  --- a/components/SelectorRow.tsx
20495
  +++ b/components/SelectorRow.tsx
20496
  @@ -2,12 +2,12 @@
@@ -20509,7 +20555,7 @@ index 555da8f..b821d16 100644
20509
  );
20510
  }
20511
 
20512
- @@ -22,17 +22,22 @@ export interface SelectorRowStar {
20513
  }
20514
 
20515
  /**
@@ -20519,9 +20565,9 @@ index 555da8f..b821d16 100644
20519
  - * "save as default" button that floats in on hover or keyboard focus. Touch
20520
  - * screens have no hover, so there the button stays visible.
20521
  + * A row of a selector dropdown (the model field), in every dropdown's
20522
- + * proportions (app/ui.css, .ui-dropdown-*): the icon column (`media`, or its
20523
- + * room with `inset`), the label, a check on the right while chosen, as the
20524
- + * menus have it; the whole row selects and highlights on hover. With `star`,
20525
  + * the right gutter holds the default marker: a small static star on the
20526
  + * default row, and on every other row a "save as default" button that floats
20527
  + * in on hover or keyboard focus. Touch screens have no hover, so there the
@@ -20533,22 +20579,19 @@ index 555da8f..b821d16 100644
20533
  star,
20534
  gutter = Boolean(star),
20535
  + media,
20536
- + inset = false,
20537
  children,
20538
  }: {
20539
  active: boolean;
20540
- @@ -40,6 +45,10 @@ export function SelectorRow({
20541
  star?: SelectorRowStar;
20542
  /** Keep room for the star so it never covers the row's text; pass it to every row of a menu that has stars so they line up. */
20543
  gutter?: boolean;
20544
  + /** A mark in the icon column (a model maker's). */
20545
  + media?: ReactNode;
20546
- + /** No mark, but the label on the text column of the rows that have one. */
20547
- + inset?: boolean;
20548
  children: ReactNode;
20549
  }) {
20550
  const isMobile = useIsMobile();
20551
- @@ -50,49 +59,27 @@ export function SelectorRow({
20552
 
20553
  return (
20554
  <div
@@ -20578,7 +20621,7 @@ index 555da8f..b821d16 100644
20578
  - cursor: "pointer", fontSize: 12, fontWeight: active ? 600 : 400,
20579
  - textAlign: "left", whiteSpace: "nowrap",
20580
  - }}
20581
- + className={`ui-dropdown-row${gutter ? " has-gutter" : ""}${inset ? " is-inset" : ""}`}
20582
  >
20583
  - {active
20584
  - ? <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true"><polyline points="1.5 5 4 7.5 8.5 2.5" /></svg>
@@ -20604,7 +20647,7 @@ index 555da8f..b821d16 100644
20604
  <StarIcon filled size={10} />
20605
  </span>
20606
  )}
20607
- @@ -106,27 +93,8 @@ export function SelectorRow({
20608
  event.stopPropagation();
20609
  star.onSave();
20610
  }}
@@ -21850,6 +21893,35 @@ index 451bcd2..cef4ae2 100644
21850
  const spinner = renderToStaticMarkup(h(SpinnerIcon, { size: 12, label: "Agent running", className: "row-status" }));
21851
  assert.match(spinner, /width="12"/);
21852
  assert.match(spinner, /stroke-width="2\.8"/);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21853
  diff --git a/components/SkillsConfig.tsx b/components/SkillsConfig.tsx
21854
  index de0d8ee..6b744f7 100644
21855
  --- a/components/SkillsConfig.tsx
@@ -23316,10 +23388,10 @@ index 0000000..ae9fdae
23316
  +}
23317
  diff --git a/components/ui/composer-list.tsx b/components/ui/composer-list.tsx
23318
  new file mode 100644
23319
- index 0000000..b7ac3af
23320
  --- /dev/null
23321
  +++ b/components/ui/composer-list.tsx
23322
- @@ -0,0 +1,139 @@
23323
  +"use client";
23324
  +
23325
  +// The composer's suggestion list ("/" commands, "@" files, input history): a
@@ -23333,6 +23405,7 @@ index 0000000..b7ac3af
23333
  +
23334
  +import { Popover } from "@base-ui/react/popover";
23335
  +import { Fragment, useEffect, useRef, type ReactNode, type RefObject } from "react";
 
23336
  +
23337
  +export interface ComposerListItem {
23338
  + key: string;
@@ -23383,6 +23456,7 @@ index 0000000..b7ac3af
23383
  + className?: string;
23384
  +}) {
23385
  + const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
 
23386
  + const total = groups.reduce((sum, group) => sum + group.items.length, 0);
23387
  +
23388
  + useEffect(() => {
@@ -23421,7 +23495,7 @@ index 0000000..b7ac3af
23421
  + onMouseDown={(event) => event.preventDefault()}
23422
  + >
23423
  + {header && <div className="ui-composer-list-header">{header}</div>}
23424
- + <div className="ui-composer-list-scroll" role="listbox" id={listId}>
23425
  + {total === 0 && empty && <div className="ui-composer-list-empty">{empty}</div>}
23426
  + {groups.map((group) => group.items.length === 0 ? null : (
23427
  + <Fragment key={group.key}>
@@ -23461,10 +23535,10 @@ index 0000000..b7ac3af
23461
  +}
23462
  diff --git a/components/ui/dropdowns.test.mjs b/components/ui/dropdowns.test.mjs
23463
  new file mode 100644
23464
- index 0000000..8a2625d
23465
  --- /dev/null
23466
  +++ b/components/ui/dropdowns.test.mjs
23467
- @@ -0,0 +1,58 @@
23468
  +import assert from "node:assert/strict";
23469
  +import { readFileSync, readdirSync } from "node:fs";
23470
  +import test from "node:test";
@@ -23523,25 +23597,86 @@ index 0000000..8a2625d
23523
  + assert.equal(modelMaker("huggingface", "meta-llama/Llama-5"), null, "an unknown maker gets the generic chip");
23524
  + assert.equal(modelMaker("openrouter", "some-model"), null, "a router's own mark says nothing about the model");
23525
  +});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23526
  diff --git a/components/ui/menu.tsx b/components/ui/menu.tsx
23527
  new file mode 100644
23528
- index 0000000..15ecac5
23529
  --- /dev/null
23530
  +++ b/components/ui/menu.tsx
23531
- @@ -0,0 +1,185 @@
23532
  +"use client";
23533
  +
23534
  +// Menus on Base UI (`@base-ui/react/menu`): keyboard navigation, typeahead,
23535
  +// focus return and collision handling come from Base UI; the look is
23536
  +// app/ui.css (.ui-menu*, the dropdown proportions every popup shares). Every
23537
  +// menu in the app is one of these, so they all open, highlight and close the
23538
- +// same way. A row is an icon (or a custom mark, or, `inset`, the icon's room),
23539
- +// its label with its description on the same line, then a hint or a check.
 
 
23540
  +
23541
  +import { Menu } from "@base-ui/react/menu";
23542
  +import type { ComponentProps, ReactNode } from "react";
23543
  +import { CarbonIcon } from "../CarbonIcon";
23544
  +import * as Carbon from "../carbon/icons";
 
23545
  +
23546
  +export const MenuRoot = Menu.Root;
23547
  +export const MenuTrigger = Menu.Trigger;
@@ -23584,10 +23719,11 @@ index 0000000..15ecac5
23584
  + /** A fixed width; menus otherwise size to their longest item. */
23585
  + width?: number;
23586
  +}) {
 
23587
  + return (
23588
  + <Menu.Portal>
23589
  + <Menu.Positioner className="ui-positioner" side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset}>
23590
- + <Menu.Popup className={className ? `ui-menu ${className}` : "ui-menu"} style={width ? { width } : undefined}>
23591
  + {children}
23592
  + </Menu.Popup>
23593
  + </Menu.Positioner>
@@ -23599,8 +23735,6 @@ index 0000000..15ecac5
23599
  + icon?: Carbon.CarbonIconData;
23600
  + /** A mark of its own in the icon column (a model maker's logo). */
23601
  + media?: ReactNode;
23602
- + /** No icon, but the label on the text column of the rows that have one. */
23603
- + inset?: boolean;
23604
  + /** Quieter, after the label on the same line. */
23605
  + description?: ReactNode;
23606
  + /** Right-aligned hint: a shortcut or a value. */
@@ -23608,7 +23742,7 @@ index 0000000..15ecac5
23608
  + children: ReactNode;
23609
  +}
23610
  +
23611
- +function ItemContent({ icon, media, description, hint, children }: Omit<ItemContentProps, "inset">) {
23612
  + return (
23613
  + <>
23614
  + {icon ? <CarbonIcon icon={icon} size={16} className="ui-menu-icon" /> : media ? <span className="ui-menu-icon">{media}</span> : null}
@@ -23621,12 +23755,9 @@ index 0000000..15ecac5
23621
  + );
23622
  +}
23623
  +
23624
- +const itemClass = (base: string, inset?: boolean, extra?: string) => `${base}${inset ? " is-inset" : ""}${extra ? ` ${extra}` : ""}`;
23625
- +
23626
  +export function MenuItem({
23627
  + icon,
23628
  + media,
23629
- + inset,
23630
  + description,
23631
  + hint,
23632
  + danger,
@@ -23635,7 +23766,7 @@ index 0000000..15ecac5
23635
  + ...props
23636
  +}: ItemContentProps & { danger?: boolean; className?: string } & Omit<ComponentProps<typeof Menu.Item>, "children" | "className">) {
23637
  + return (
23638
- + <Menu.Item {...props} className={itemClass("ui-menu-item", inset, `${danger ? "is-danger" : ""}${className ? ` ${className}` : ""}`.trim() || undefined)}>
23639
  + <ItemContent icon={icon} media={media} description={description} hint={hint}>{children}</ItemContent>
23640
  + </Menu.Item>
23641
  + );
@@ -23645,7 +23776,6 @@ index 0000000..15ecac5
23645
  +export function MenuRadioItem({
23646
  + icon,
23647
  + media,
23648
- + inset,
23649
  + description,
23650
  + hint,
23651
  + children,
@@ -23653,7 +23783,7 @@ index 0000000..15ecac5
23653
  + ...props
23654
  +}: ItemContentProps & Omit<ComponentProps<typeof Menu.RadioItem>, "children" | "className">) {
23655
  + return (
23656
- + <Menu.RadioItem {...props} closeOnClick={closeOnClick} className={itemClass("ui-menu-item is-radio", inset)}>
23657
  + <ItemContent icon={icon} media={media} description={description} hint={hint}>{children}</ItemContent>
23658
  + <Menu.RadioItemIndicator className="ui-menu-check" keepMounted>
23659
  + <CarbonIcon icon={Carbon.Checkmark} size={14} />
@@ -23666,7 +23796,6 @@ index 0000000..15ecac5
23666
  +export function MenuCheckboxItem({
23667
  + icon,
23668
  + media,
23669
- + inset,
23670
  + description,
23671
  + hint,
23672
  + children,
@@ -23674,7 +23803,7 @@ index 0000000..15ecac5
23674
  + ...props
23675
  +}: ItemContentProps & Omit<ComponentProps<typeof Menu.CheckboxItem>, "children" | "className">) {
23676
  + return (
23677
- + <Menu.CheckboxItem {...props} closeOnClick={closeOnClick} className={itemClass("ui-menu-item is-radio", inset)}>
23678
  + <ItemContent icon={icon} media={media} description={description} hint={hint}>{children}</ItemContent>
23679
  + <Menu.CheckboxItemIndicator className="ui-menu-check" keepMounted>
23680
  + <CarbonIcon icon={Carbon.Checkmark} size={14} />
@@ -23757,6 +23886,63 @@ index 0000000..1e96650
23757
  + </Popover.Portal>
23758
  + );
23759
  +}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23760
  diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
23761
  new file mode 100644
23762
  index 0000000..019f84d
@@ -25098,7 +25284,7 @@ index 0000000..cfe33b4
25098
  +- One browser for all sessions and users of a process; tools of two sessions interleave on the same tabs.
25099
  diff --git a/docs/agents/desktop-shell.md b/docs/agents/desktop-shell.md
25100
  new file mode 100644
25101
- index 0000000..434c66a
25102
  --- /dev/null
25103
  +++ b/docs/agents/desktop-shell.md
25104
  @@ -0,0 +1,52 @@
@@ -25107,9 +25293,9 @@ index 0000000..434c66a
25107
  +Pi Web's frame follows Claude Code Desktop's Code tab: a sidebar of sessions, a header, then the conversation and the panes it opened as tiles. Pi's power stays reachable through "/", the composer's "+" menu, the title's session menu and Settings rather than permanent chrome. Popups are Base UI (`@base-ui/react` 1.8.0) through the wrappers in `components/ui/`, styled in `app/ui.css`; the header and tiles in `app/shell.css`. The Code tab is claude.ai web code (its chunks are on assets-proxy.anthropic.com, no Cloudflare there); the tile behaviour below was read from its tile engine and side-pane store, not from screenshots. The conversation outline (upstream's ChatMinimap) is removed.
25108
  +
25109
  +## Dropdowns (`app/ui.css` "dropdowns", `components/ui/menu.tsx`, `app/sidebar-menu.css`)
25110
- +- **One set of proportions for every dropdown**, measured on `design/dropdown-spacing-reference.jpg` and `design/model-dropdown-reference.jpg` (phone captures at 2.81x: 15.7px text, rows 32.7px, rows inset 9.3px and rounded about 8px, surfaces rounded 14-18px, icons 16.7px and text 44.5px from the edge) and kept as `--popup-*` tokens: at 13px, a 14px surface (hairline + soft shadow, `--popup-shadow`), rows 28px inset 7px and rounded 7px (the surface's radius is the inset plus the row's), icons 14px from the edge, text at 37px, section labels 12px `--text-dim` in sentence case on the icon column, separators edge to edge with the inset above and below, a row's description inline after its label. Touch screens (`pointer: coarse`) take the reference's own scale: 15px, 32px rows, 8px inset, 16px surface. `components/ui/dropdowns.test.mjs` checks the ratios against the reference and that the sidebar's menus use the tokens.
25111
  +- Who follows it: Base UI menus (`.ui-menu`), the "/" "@" and history list (`.ui-composer-list`: its scroller holds the side inset so a group's line can reach the edges), popovers (content on the icon column), the sidebar's menus and their filter (a plain line like the reference's "Search here…"; the files tab's `classic` look is gone, only paths keep code type), the phone sheet (the same inset rounded rows, 44px), the model field (`ModelSelector`/`SelectorRow`: `.ui-dropdown-*`, check on the right as in the menus), and the header's panels (`.header-dropdown`: session info, system prompt, tools, subagents, branches, inset 8px under the header).
25112
- +- **One icon column**: model rows carry their maker's mark (`components/ModelMark.tsx`: the repo's organisation on a router such as Hugging Face, else the provider; brand colours in grays), effort rows and other icon-less rows in such a menu keep the text column (`inset`), "/" rows show their kind (the "+" menu's icons: commands, connectors, a page for prompts, skills) and history rows a clock.
25113
  +- **`MenuSection`** is the only way to label a group: Base UI throws on a `GroupLabel` outside a `Group`, and the Browser pane's ⋯ menu did that, which took the page down.
25114
  +
25115
  +## Composer (`components/ChatInput.tsx`)
 
1
  diff --git a/AGENTS.md b/AGENTS.md
2
+ index a5c8983..d2e5062 100644
3
  --- a/AGENTS.md
4
  +++ b/AGENTS.md
5
  @@ -67,6 +67,11 @@ app/api/
 
57
  + ContextMeter.tsx the composer's context ring and its popover (tokens, cost, Compact)
58
  + ComposerModelMenu.tsx the composer's model-and-effort menu
59
  + ComposerModeMenu.tsx the composer's mode menu: tool presets as Standard / Plan / Chat (+ pinned sets), ⌘/Ctrl+Shift+M
60
+ + ui/ Base UI wrappers, styled in app/ui.css (every dropdown's proportions): menu.tsx (MenuSection for labelled groups), tooltip.tsx, popover.tsx, composer-list.tsx (the "/", "@" and history list), scrollbar-inset.ts (a scrolling dropdown's bar takes its right inset)
61
  + ModelMark.tsx a model's maker mark for menus (the repo's organisation on a router, else the provider), in grays
62
  SessionSidebar.tsx toolbar row (Sessions | Files tabs, or the Pi Web brand with the files below them; new, search): tree, archive, menus, toast; files section (resizable, foldable; one header row with the file keys and changed-files count, the picker's row) or tab (picker boxes + file buttons), FileExplorer
63
  - PiWebTitle.tsx the sidebar's "Pi Web" brand (files below the sessions only): a click scrambles it into the versions
 
6555
  +.browser-console-text { flex: 1 1 auto; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
6556
  +.browser-console-location { flex: none; max-width: 40%; overflow: hidden; color: var(--text-dim); text-overflow: ellipsis; white-space: nowrap; direction: rtl; }
6557
  diff --git a/app/sidebar-menu.css b/app/sidebar-menu.css
6558
+ index 737e7a5..d0fd174 100644
6559
  --- a/app/sidebar-menu.css
6560
  +++ b/app/sidebar-menu.css
6561
  @@ -19,13 +19,14 @@
 
6566
  - border: 1px solid var(--border);
6567
  - border-radius: 8px;
6568
  - background: var(--bg);
6569
+ + /* The dropdowns' proportions (app/ui.css, "dropdowns"); a scrollbar takes the right inset. */
6570
+ + padding: var(--popup-inset) var(--popup-inset-end) var(--popup-inset) var(--popup-inset);
6571
  + border-radius: var(--popup-radius);
6572
  + background: var(--bg-elevated);
6573
  color: var(--text);
 
6681
  height: 1px;
6682
  - margin: 4px 2px;
6683
  - background: var(--border);
6684
+ + margin: var(--popup-inset) calc(-1 * var(--popup-inset-end)) var(--popup-inset) calc(-1 * var(--popup-inset));
6685
  + background: var(--line);
6686
  }
6687
 
 
6750
  - padding: 4px;
6751
  - border-bottom: 1px solid var(--border);
6752
  - background: var(--bg);
6753
+ + margin: calc(-1 * var(--popup-inset)) calc(-1 * var(--popup-inset-end)) 0 calc(-1 * var(--popup-inset));
6754
+ + padding: var(--popup-inset) var(--popup-inset-end) 0 var(--popup-inset);
6755
  + background: var(--bg-elevated);
6756
  }
6757
 
 
7875
  cursor: pointer;
7876
  diff --git a/app/ui.css b/app/ui.css
7877
  new file mode 100644
7878
+ index 0000000..950fe9e
7879
  --- /dev/null
7880
  +++ b/app/ui.css
7881
+ @@ -0,0 +1,604 @@
7882
  +/* The shared components on Base UI (components/ui/*): menus, tooltips,
7883
  + popovers and the composer's suggestion list. Popups are portaled to <body>,
7884
  + so they stack by the positioner's z-index, above every pane. */
 
7900
  + - a 14px-rounded surface, set off by a hairline and a soft shadow;
7901
  + - rows inset 7px from its edge and rounded 7px, so the two curves run
7902
  + parallel (the surface's radius is the inset plus the row's);
7903
+ + - one icon column 14px from the edge and one text column at 37px; whatever
7904
+ + has no icon starts on the icon column, as the references' section labels
7905
+ + and search line do: section labels, filters, headers, and rows without an
7906
+ + icon (the effort levels), never an empty icon's room before a label;
7907
  + - rows 28px, back to back; a section label at 12px in --text-dim, in
7908
  + sentence case; a separator edge to edge with the inset above and below;
7909
  + - a row's description inline after its label, the same size, in --text-dim.
7910
+ + A touch screen takes the reference's own scale (15px text, 32px rows).
7911
+ + A dropdown that scrolls keeps its rows centred: its scrollbar is as wide as
7912
+ + the inset and stands in the right inset's place (`--popup-inset-end`, set by
7913
+ + components/ui/scrollbar-inset.ts while a bar takes room). */
7914
  +:root {
7915
  + --popup-font: 13px;
7916
  + --popup-label-font: 12px;
 
7923
  + --popup-gap: 9px;
7924
  + --popup-text-gap: 10px;
7925
  + --popup-edge: calc(var(--popup-inset) + var(--popup-row-pad));
7926
+ + /* The right inset: the scrollbar's place while one takes room. */
7927
+ + --popup-inset-end: var(--popup-inset);
7928
  + --popup-hairline: color-mix(in srgb, var(--text-strong) 10%, transparent);
7929
  + --popup-shadow: 0 0 0 1px var(--popup-hairline), 0 2px 4px rgb(0 0 0 / 0.04), 0 12px 28px -6px rgb(0 0 0 / 0.14);
7930
  + /* Rows on a popup: a tint of the ink, so they show on --bg-elevated in every theme. */
 
7971
  + font-weight: 400;
7972
  + line-height: 16px;
7973
  +}
7974
+ +/* Edge to edge (to the scrollbar while one shows), the inset above and below. */
7975
  +.ui-menu-separator,
7976
  +.ui-dropdown-separator {
7977
  + height: 1px;
7978
+ + margin: var(--popup-inset) calc(-1 * var(--popup-inset-end)) var(--popup-inset) calc(-1 * var(--popup-inset));
7979
  + background: var(--line);
7980
  +}
7981
+ +/* Scrolling: the bar takes the right inset (components/ui/scrollbar-inset.ts
7982
+ + measures it), as wide as the inset in Chromium and Safari, thin in Firefox. */
7983
+ +.ui-menu[data-scrollbar],
7984
+ +.ui-composer-list-scroll[data-scrollbar],
7985
+ +.ui-dropdown-scroll[data-scrollbar],
7986
+ +.sidebar-menu[data-scrollbar] {
7987
+ + --popup-inset-end: max(0px, calc(var(--popup-inset) - var(--popup-scrollbar, 0px)));
7988
+ +}
7989
+ +.ui-menu::-webkit-scrollbar,
7990
+ +.ui-composer-list-scroll::-webkit-scrollbar,
7991
+ +.ui-dropdown-scroll::-webkit-scrollbar,
7992
+ +.sidebar-menu::-webkit-scrollbar { width: 7px; }
7993
+ +.ui-menu::-webkit-scrollbar-thumb,
7994
+ +.ui-composer-list-scroll::-webkit-scrollbar-thumb,
7995
+ +.ui-dropdown-scroll::-webkit-scrollbar-thumb,
7996
+ +.sidebar-menu::-webkit-scrollbar-thumb { border-width: 1.5px; }
7997
+ +/* Where the surface itself scrolls, the thumb keeps off its rounded corners. */
7998
+ +.ui-menu::-webkit-scrollbar-track,
7999
+ +.sidebar-menu::-webkit-scrollbar-track { margin: 7px 0; }
8000
+ +@media (pointer: coarse) {
8001
+ + .ui-menu::-webkit-scrollbar,
8002
+ + .ui-composer-list-scroll::-webkit-scrollbar,
8003
+ + .ui-dropdown-scroll::-webkit-scrollbar,
8004
+ + .sidebar-menu::-webkit-scrollbar { width: 8px; }
8005
+ + .ui-menu::-webkit-scrollbar-thumb,
8006
+ + .ui-composer-list-scroll::-webkit-scrollbar-thumb,
8007
+ + .ui-dropdown-scroll::-webkit-scrollbar-thumb,
8008
+ + .sidebar-menu::-webkit-scrollbar-thumb { border-width: 2px; }
8009
+ + .ui-menu::-webkit-scrollbar-track,
8010
+ + .sidebar-menu::-webkit-scrollbar-track { margin: 8px 0; }
8011
+ +}
8012
+ +@supports not selector(::-webkit-scrollbar) {
8013
+ + .ui-menu,
8014
+ + .ui-composer-list-scroll,
8015
+ + .ui-dropdown-scroll,
8016
+ + .sidebar-menu { scrollbar-width: thin; }
8017
+ +}
8018
  +
8019
  +/* ---------------------------------------------------------------- menus */
8020
  +.ui-menu {
 
8022
  + max-width: min(420px, calc(100vw - 24px));
8023
  + max-height: var(--available-height);
8024
  + overflow-y: auto;
8025
+ + padding: var(--popup-inset) var(--popup-inset-end) var(--popup-inset) var(--popup-inset);
8026
  + transform-origin: var(--transform-origin);
8027
  + transition: opacity 0.12s, transform 0.12s;
8028
  +}
 
8043
  + user-select: none;
8044
  + -webkit-user-select: none;
8045
  +}
 
8046
  +.ui-menu-item[data-highlighted] { background: var(--popup-highlight); color: var(--text-strong); }
8047
  +.ui-menu-item[data-disabled] { opacity: 0.45; }
8048
  +.ui-menu-item[data-popup-open] { background: var(--popup-highlight); }
 
8080
  +}
8081
  +.ui-dropdown-filter-input::placeholder { color: var(--text-dim); }
8082
  +/* The scroller reaches the surface's sides, so a separator inside it can too. */
8083
+ +.ui-dropdown-scroll { min-height: 0; overflow-y: auto; margin: 0 calc(-1 * var(--popup-inset)); padding: 0 var(--popup-inset-end) 0 var(--popup-inset); }
8084
  +.ui-dropdown-empty { padding: 4px var(--popup-row-pad) 6px; color: var(--text-dim); white-space: nowrap; }
8085
  +.ui-dropdown-row-wrap { position: relative; border-radius: var(--popup-row-radius); transition: background 0.1s; }
8086
  +.ui-dropdown-row-wrap:hover, .ui-dropdown-row-wrap:focus-within { background: var(--popup-highlight); }
 
8103
  +}
8104
  +.ui-dropdown-row:focus-visible { outline: none; }
8105
  +.ui-dropdown-row[aria-selected="true"] { color: var(--text-strong); }
 
8106
  +.ui-dropdown-row.has-gutter { padding-right: calc(var(--popup-row-pad) + 28px); }
8107
  +.ui-dropdown-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: var(--popup-icon); height: var(--popup-icon); color: var(--text-muted); }
8108
  +.ui-dropdown-row-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
 
8192
  + font-size: var(--popup-label-font);
8193
  + line-height: 16px;
8194
  +}
8195
+ +.ui-composer-list-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 var(--popup-inset-end) 0 var(--popup-inset); }
8196
  +/* A group after the first starts after an edge-to-edge line, as a menu's section does. */
8197
  +.ui-composer-list-row + .ui-composer-list-group {
8198
+ + margin: var(--popup-inset) calc(-1 * var(--popup-inset-end)) 0 calc(-1 * var(--popup-inset));
8199
  + padding: calc(var(--popup-inset) + 4px) var(--popup-edge) 4px;
8200
  + border-top: 1px solid var(--line);
8201
  +}
 
11637
  assert.equal(clampChatContentWidth(2400), 2000);
11638
  });
11639
  diff --git a/components/ChatInput.test.mjs b/components/ChatInput.test.mjs
11640
+ index adfcc12..17a4fbc 100644
11641
  --- a/components/ChatInput.test.mjs
11642
  +++ b/components/ChatInput.test.mjs
11643
  @@ -263,15 +263,26 @@ test("cycleListIndex wraps in both directions", () => {
 
11756
  // Rows without a star still line up with starred ones in the same menu.
11757
  - assert.match(row({ gutter: true }), /padding:7px 36px 7px 12px/);
11758
  + assert.match(row({ gutter: true }), /class="ui-dropdown-row has-gutter"/);
11759
+ + // A row without a mark starts on the icon column (the clear row), as a section label does.
11760
+ + assert.match(row({}), /<button type="button" role="option" aria-selected="false" class="ui-dropdown-row"><span class="ui-dropdown-row-label">/);
11761
  + assert.match(row({ media: React.createElement("svg", { id: "mark" }) }), /<span class="ui-dropdown-icon"><svg id="mark">/);
11762
 
11763
  + // The chosen row's check sits on the right, as the menus' do.
 
16257
  +}
16258
  diff --git a/components/ComposerModelMenu.tsx b/components/ComposerModelMenu.tsx
16259
  new file mode 100644
16260
+ index 0000000..220224e
16261
  --- /dev/null
16262
  +++ b/components/ComposerModelMenu.tsx
16263
+ @@ -0,0 +1,183 @@
16264
  +"use client";
16265
  +
16266
  +// One menu for what answers and how hard it thinks, as Claude Desktop's model
 
16382
  + </MenuTrigger>
16383
  + </Tip>
16384
  + <MenuPopup side="top" align="end" width={272}>
16385
+ + {/* Each model with its maker's mark; the levels have none, so they start on the marks' column, under their label. */}
16386
  + <MenuSection label={t("chat.modelGroup")}>
16387
  + <MenuRadioGroup
16388
  + value={value ? modelKey(value) : ""}
 
16416
  + <MenuRadioItem
16417
  + key={level}
16418
  + value={level}
 
16419
  + description={level === "auto" ? t(THINKING_LEVEL_DESC_KEYS[level]) : undefined}
16420
  + hint={level !== "auto" && savedDefaultThinkingLevel === level ? t("chat.defaultBadge") : undefined}
16421
  + >
 
19783
  + return maker ? <ProviderIcon id={maker} size={size} mono /> : <CarbonIcon icon={Carbon.Chip} size={size} />;
19784
  +}
19785
  diff --git a/components/ModelSelector.tsx b/components/ModelSelector.tsx
19786
+ index ba912ee..0283a47 100644
19787
  --- a/components/ModelSelector.tsx
19788
  +++ b/components/ModelSelector.tsx
19789
+ @@ -4,6 +4,10 @@ import { useEffect, useRef, useState, type CSSProperties } from "react";
19790
  import { useI18n } from "@/hooks/useI18n";
19791
  import { useIsMobile } from "@/hooks/useIsMobile";
19792
  import { SelectorRow } from "./SelectorRow";
19793
  +import { ModelMark } from "./ModelMark";
19794
+ +import { useScrollbarInset } from "./ui/scrollbar-inset";
19795
  +import { CarbonIcon } from "./CarbonIcon";
19796
  +import * as Carbon from "./carbon/icons";
19797
 
19798
  export interface ModelSelectorOption {
19799
  provider: string;
19800
+ @@ -63,6 +67,7 @@ export function ModelSelector({
19801
+ const isMobile = useIsMobile();
19802
+ const rootRef = useRef<HTMLDivElement>(null);
19803
+ const panelRef = useRef<HTMLDivElement>(null);
19804
+ + const scrollbarRef = useScrollbarInset<HTMLDivElement>();
19805
+ const [open, setOpen] = useState(false);
19806
+ const [anchorRect, setAnchorRect] = useState<{ top: number; right: number; bottom: number; left: number; width: number } | null>(null);
19807
+ const [filter, setFilter] = useState("");
19808
+ @@ -110,11 +115,11 @@ export function ModelSelector({
19809
  gap: 7,
19810
  width: "100%",
19811
  minWidth: 0,
 
19820
  background: locked ? "var(--bg-panel)" : "var(--bg)",
19821
  color: locked ? "var(--text-dim)" : "var(--text)",
19822
  cursor: locked ? "default" : "pointer",
19823
+ @@ -128,15 +133,18 @@ export function ModelSelector({
19824
  gap: 6,
19825
  width: isMobile ? "100%" : undefined,
19826
  maxWidth: isMobile ? "100%" : 220,
 
19843
  opacity: locked ? 0.5 : 1,
19844
  transition: "background 0.12s, color 0.12s",
19845
  };
19846
+ @@ -203,22 +211,12 @@ export function ModelSelector({
19847
  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" style={{ animation: "spin 0.8s linear infinite", flexShrink: 0 }} aria-hidden="true">
19848
  <path d="M21 12a9 9 0 1 1-2.64-6.36" />
19849
  </svg>
 
19871
  </button>
19872
 
19873
  {open && anchorRect && (() => {
19874
+ @@ -240,24 +238,19 @@ export function ModelSelector({
19875
  ref={panelRef}
19876
  role="listbox"
19877
  aria-label={ariaLabel}
 
19899
  value={filter}
19900
  onChange={(event) => setFilter(event.target.value)}
19901
  placeholder={t("chat.filterModels")}
19902
+ @@ -268,23 +261,11 @@ export function ModelSelector({
19903
  autoFocus={!isMobile}
19904
  autoComplete="off"
19905
  spellCheck={false}
 
19921
  </div>
19922
  )}
19923
  - <div style={{ minHeight: 0, overflowY: "auto" }}>
19924
+ + <div ref={scrollbarRef} className="ui-dropdown-scroll">
19925
  {onClear && !filter.trim() && (
19926
+ <SelectorRow active={!value} gutter={Boolean(onSetDefault)} onSelect={() => {
 
19927
  setOpen(false);
19928
+ @@ -295,20 +276,22 @@ export function ModelSelector({
 
 
19929
  </SelectorRow>
19930
  )}
19931
  {modelsByProvider.length === 0 ? (
 
20536
  </button>
20537
  {report && (
20538
  diff --git a/components/SelectorRow.tsx b/components/SelectorRow.tsx
20539
+ index 555da8f..0b5228b 100644
20540
  --- a/components/SelectorRow.tsx
20541
  +++ b/components/SelectorRow.tsx
20542
  @@ -2,12 +2,12 @@
 
20555
  );
20556
  }
20557
 
20558
+ @@ -22,17 +22,21 @@ export interface SelectorRowStar {
20559
  }
20560
 
20561
  /**
 
20565
  - * "save as default" button that floats in on hover or keyboard focus. Touch
20566
  - * screens have no hover, so there the button stays visible.
20567
  + * A row of a selector dropdown (the model field), in every dropdown's
20568
+ + * proportions (app/ui.css, .ui-dropdown-*): the icon column (`media`; a row
20569
+ + * without one starts there), the label, a check on the right while chosen, as
20570
+ + * the menus have it; the whole row selects and highlights on hover. With `star`,
20571
  + * the right gutter holds the default marker: a small static star on the
20572
  + * default row, and on every other row a "save as default" button that floats
20573
  + * in on hover or keyboard focus. Touch screens have no hover, so there the
 
20579
  star,
20580
  gutter = Boolean(star),
20581
  + media,
 
20582
  children,
20583
  }: {
20584
  active: boolean;
20585
+ @@ -40,6 +44,8 @@ export function SelectorRow({
20586
  star?: SelectorRowStar;
20587
  /** Keep room for the star so it never covers the row's text; pass it to every row of a menu that has stars so they line up. */
20588
  gutter?: boolean;
20589
  + /** A mark in the icon column (a model maker's). */
20590
  + media?: ReactNode;
 
 
20591
  children: ReactNode;
20592
  }) {
20593
  const isMobile = useIsMobile();
20594
+ @@ -50,49 +56,27 @@ export function SelectorRow({
20595
 
20596
  return (
20597
  <div
 
20621
  - cursor: "pointer", fontSize: 12, fontWeight: active ? 600 : 400,
20622
  - textAlign: "left", whiteSpace: "nowrap",
20623
  - }}
20624
+ + className={gutter ? "ui-dropdown-row has-gutter" : "ui-dropdown-row"}
20625
  >
20626
  - {active
20627
  - ? <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true"><polyline points="1.5 5 4 7.5 8.5 2.5" /></svg>
 
20647
  <StarIcon filled size={10} />
20648
  </span>
20649
  )}
20650
+ @@ -106,27 +90,8 @@ export function SelectorRow({
20651
  event.stopPropagation();
20652
  star.onSave();
20653
  }}
 
21893
  const spinner = renderToStaticMarkup(h(SpinnerIcon, { size: 12, label: "Agent running", className: "row-status" }));
21894
  assert.match(spinner, /width="12"/);
21895
  assert.match(spinner, /stroke-width="2\.8"/);
21896
+ diff --git a/components/SidebarMenu.tsx b/components/SidebarMenu.tsx
21897
+ index a87046d..4253bfe 100644
21898
+ --- a/components/SidebarMenu.tsx
21899
+ +++ b/components/SidebarMenu.tsx
21900
+ @@ -3,6 +3,7 @@
21901
+ import { Children, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref, type SyntheticEvent } from "react";
21902
+ import { createPortal } from "react-dom";
21903
+ import { CheckIcon } from "./SidebarIcons";
21904
+ +import { useScrollbarInset } from "./ui/scrollbar-inset";
21905
+
21906
+ /**
21907
+ * The session sidebar's popup menu: a row's ⋯ or right-click menu, a project
21908
+ @@ -480,6 +481,7 @@ export function SidebarMenuSurface({
21909
+ onActivate,
21910
+ onClose,
21911
+ }: SidebarMenuSurfaceProps) {
21912
+ + const desktopRef = useScrollbarInset<HTMLDivElement>(surfaceRef);
21913
+ const custom = hasCustomMenuBody(children);
21914
+ const hasFooter = !custom && hasCustomMenuBody(footer);
21915
+ const filtered = filter !== undefined && !custom && sidebarMenuHasFilter(items, filter.minChoices);
21916
+ @@ -524,7 +526,7 @@ export function SidebarMenuSurface({
21917
+ if (!sheet) {
21918
+ return (
21919
+ <div
21920
+ - ref={surfaceRef}
21921
+ + ref={desktopRef}
21922
+ className={classic ? "sidebar-menu is-classic" : "sidebar-menu"}
21923
+ role={menuRole ?? "dialog"}
21924
+ aria-label={ariaLabel}
21925
  diff --git a/components/SkillsConfig.tsx b/components/SkillsConfig.tsx
21926
  index de0d8ee..6b744f7 100644
21927
  --- a/components/SkillsConfig.tsx
 
23388
  +}
23389
  diff --git a/components/ui/composer-list.tsx b/components/ui/composer-list.tsx
23390
  new file mode 100644
23391
+ index 0000000..c9e3f78
23392
  --- /dev/null
23393
  +++ b/components/ui/composer-list.tsx
23394
+ @@ -0,0 +1,141 @@
23395
  +"use client";
23396
  +
23397
  +// The composer's suggestion list ("/" commands, "@" files, input history): a
 
23405
  +
23406
  +import { Popover } from "@base-ui/react/popover";
23407
  +import { Fragment, useEffect, useRef, type ReactNode, type RefObject } from "react";
23408
+ +import { useScrollbarInset } from "./scrollbar-inset";
23409
  +
23410
  +export interface ComposerListItem {
23411
  + key: string;
 
23456
  + className?: string;
23457
  +}) {
23458
  + const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
23459
+ + const scrollbarRef = useScrollbarInset<HTMLDivElement>();
23460
  + const total = groups.reduce((sum, group) => sum + group.items.length, 0);
23461
  +
23462
  + useEffect(() => {
 
23495
  + onMouseDown={(event) => event.preventDefault()}
23496
  + >
23497
  + {header && <div className="ui-composer-list-header">{header}</div>}
23498
+ + <div ref={scrollbarRef} className="ui-composer-list-scroll" role="listbox" id={listId}>
23499
  + {total === 0 && empty && <div className="ui-composer-list-empty">{empty}</div>}
23500
  + {groups.map((group) => group.items.length === 0 ? null : (
23501
  + <Fragment key={group.key}>
 
23535
  +}
23536
  diff --git a/components/ui/dropdowns.test.mjs b/components/ui/dropdowns.test.mjs
23537
  new file mode 100644
23538
+ index 0000000..bebb13a
23539
  --- /dev/null
23540
  +++ b/components/ui/dropdowns.test.mjs
23541
+ @@ -0,0 +1,116 @@
23542
  +import assert from "node:assert/strict";
23543
  +import { readFileSync, readdirSync } from "node:fs";
23544
  +import test from "node:test";
 
23597
  + assert.equal(modelMaker("huggingface", "meta-llama/Llama-5"), null, "an unknown maker gets the generic chip");
23598
  + assert.equal(modelMaker("openrouter", "some-model"), null, "a router's own mark says nothing about the model");
23599
  +});
23600
+ +
23601
+ +test("whatever has no icon starts on the icon column, as the references' labels and search line do", () => {
23602
+ + // The effort levels sat on the text column with an empty icon's room before them.
23603
+ + assert.doesNotMatch(css, /is-inset/);
23604
+ + for (const name of ["./menu.tsx", "../SelectorRow.tsx", "../ComposerModelMenu.tsx", "../ModelSelector.tsx"]) {
23605
+ + const code = readFileSync(new URL(name, import.meta.url), "utf8").replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "");
23606
+ + assert.doesNotMatch(code, /(?<![-\w])inset(?![-\w])|is-inset/, `${name} has no inset rows`);
23607
+ + }
23608
+ +});
23609
+ +
23610
+ +test("a dropdown's scrollbar stands in its right inset, as wide as the inset at both scales", () => {
23611
+ + const scrollers = [".ui-menu", ".ui-composer-list-scroll", ".ui-dropdown-scroll", ".sidebar-menu"];
23612
+ + const sidebar = readFileSync(new URL("../../app/sidebar-menu.css", import.meta.url), "utf8");
23613
+ + const rule = (source, selector) => source.match(new RegExp(`(?:^|\\n)${selector.replace(".", "\\.")} \\{([^}]*)\\}`))?.[1] ?? "";
23614
+ + for (const selector of scrollers) {
23615
+ + const body = rule(selector === ".sidebar-menu" ? sidebar : css, selector);
23616
+ + assert.match(body, /padding: (0|var\(--popup-inset\)) var\(--popup-inset-end\)/, `${selector} pads its right with the scrollbar's place`);
23617
+ + }
23618
+ + const widths = [...css.matchAll(/\.sidebar-menu::-webkit-scrollbar \{ width: (\d+)px; \}/g)].map((match) => Number(match[1]));
23619
+ + const desktop = tokens(css.slice(css.indexOf(":root {\n --popup-font"), css.indexOf("html.dark {\n --popup-shadow")));
23620
+ + const touch = tokens(css.slice(css.indexOf("@media (pointer: coarse)"), css.indexOf("/* The surface, shared by every dropdown. */")));
23621
+ + assert.deepEqual(widths, [Number.parseFloat(desktop["popup-inset"]), Number.parseFloat(touch["popup-inset"])]);
23622
+ +});
23623
+ +
23624
+ +test("the scrollbar's width is marked on the scroller while one takes room, and cleared when it goes", async () => {
23625
+ + const { watchScrollbarInset } = await jiti.import("./scrollbar-inset.ts");
23626
+ + const saved = { getComputedStyle: globalThis.getComputedStyle, ResizeObserver: globalThis.ResizeObserver, raf: globalThis.requestAnimationFrame, caf: globalThis.cancelAnimationFrame };
23627
+ + let notify = () => {};
23628
+ + let disconnected = false;
23629
+ + globalThis.getComputedStyle = () => ({ borderLeftWidth: "0px", borderRightWidth: "0px" });
23630
+ + globalThis.ResizeObserver = class { constructor(callback) { notify = callback; } observe() {} disconnect() { disconnected = true; } };
23631
+ + globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
23632
+ + globalThis.cancelAnimationFrame = () => {};
23633
+ + try {
23634
+ + const attributes = new Map();
23635
+ + const props = new Map();
23636
+ + const node = {
23637
+ + offsetWidth: 272, clientWidth: 272,
23638
+ + setAttribute: (key, value) => attributes.set(key, value), removeAttribute: (key) => attributes.delete(key), hasAttribute: (key) => attributes.has(key),
23639
+ + style: { setProperty: (key, value) => props.set(key, value), removeProperty: (key) => props.delete(key) },
23640
+ + };
23641
+ + const stop = watchScrollbarInset(node);
23642
+ + assert.equal(attributes.has("data-scrollbar"), false, "no bar, no mark");
23643
+ + node.clientWidth = 265; notify();
23644
+ + assert.equal(attributes.has("data-scrollbar"), true);
23645
+ + assert.equal(props.get("--popup-scrollbar"), "7px");
23646
+ + node.clientWidth = 272; notify();
23647
+ + assert.equal(attributes.has("data-scrollbar"), false, "the bar went");
23648
+ + assert.equal(props.has("--popup-scrollbar"), false);
23649
+ + stop();
23650
+ + assert.equal(disconnected, true);
23651
+ + } finally {
23652
+ + globalThis.getComputedStyle = saved.getComputedStyle;
23653
+ + globalThis.ResizeObserver = saved.ResizeObserver;
23654
+ + globalThis.requestAnimationFrame = saved.raf;
23655
+ + globalThis.cancelAnimationFrame = saved.caf;
23656
+ + }
23657
+ +});
23658
  diff --git a/components/ui/menu.tsx b/components/ui/menu.tsx
23659
  new file mode 100644
23660
+ index 0000000..ca0218f
23661
  --- /dev/null
23662
  +++ b/components/ui/menu.tsx
23663
+ @@ -0,0 +1,182 @@
23664
  +"use client";
23665
  +
23666
  +// Menus on Base UI (`@base-ui/react/menu`): keyboard navigation, typeahead,
23667
  +// focus return and collision handling come from Base UI; the look is
23668
  +// app/ui.css (.ui-menu*, the dropdown proportions every popup shares). Every
23669
  +// menu in the app is one of these, so they all open, highlight and close the
23670
+ +// same way. A row is an icon (or a custom mark), its label with its
23671
+ +// description on the same line, then a hint or a check. A row without an icon
23672
+ +// starts on the icon column, as the references' section labels and search line
23673
+ +// do: never on the text column with an empty icon's room before it.
23674
  +
23675
  +import { Menu } from "@base-ui/react/menu";
23676
  +import type { ComponentProps, ReactNode } from "react";
23677
  +import { CarbonIcon } from "../CarbonIcon";
23678
  +import * as Carbon from "../carbon/icons";
23679
+ +import { useScrollbarInset } from "./scrollbar-inset";
23680
  +
23681
  +export const MenuRoot = Menu.Root;
23682
  +export const MenuTrigger = Menu.Trigger;
 
23719
  + /** A fixed width; menus otherwise size to their longest item. */
23720
  + width?: number;
23721
  +}) {
23722
+ + const scrollbarRef = useScrollbarInset<HTMLDivElement>();
23723
  + return (
23724
  + <Menu.Portal>
23725
  + <Menu.Positioner className="ui-positioner" side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset}>
23726
+ + <Menu.Popup ref={scrollbarRef} className={className ? `ui-menu ${className}` : "ui-menu"} style={width ? { width } : undefined}>
23727
  + {children}
23728
  + </Menu.Popup>
23729
  + </Menu.Positioner>
 
23735
  + icon?: Carbon.CarbonIconData;
23736
  + /** A mark of its own in the icon column (a model maker's logo). */
23737
  + media?: ReactNode;
 
 
23738
  + /** Quieter, after the label on the same line. */
23739
  + description?: ReactNode;
23740
  + /** Right-aligned hint: a shortcut or a value. */
 
23742
  + children: ReactNode;
23743
  +}
23744
  +
23745
+ +function ItemContent({ icon, media, description, hint, children }: ItemContentProps) {
23746
  + return (
23747
  + <>
23748
  + {icon ? <CarbonIcon icon={icon} size={16} className="ui-menu-icon" /> : media ? <span className="ui-menu-icon">{media}</span> : null}
 
23755
  + );
23756
  +}
23757
  +
 
 
23758
  +export function MenuItem({
23759
  + icon,
23760
  + media,
 
23761
  + description,
23762
  + hint,
23763
  + danger,
 
23766
  + ...props
23767
  +}: ItemContentProps & { danger?: boolean; className?: string } & Omit<ComponentProps<typeof Menu.Item>, "children" | "className">) {
23768
  + return (
23769
+ + <Menu.Item {...props} className={["ui-menu-item", danger ? "is-danger" : "", className ?? ""].filter(Boolean).join(" ")}>
23770
  + <ItemContent icon={icon} media={media} description={description} hint={hint}>{children}</ItemContent>
23771
  + </Menu.Item>
23772
  + );
 
23776
  +export function MenuRadioItem({
23777
  + icon,
23778
  + media,
 
23779
  + description,
23780
  + hint,
23781
  + children,
 
23783
  + ...props
23784
  +}: ItemContentProps & Omit<ComponentProps<typeof Menu.RadioItem>, "children" | "className">) {
23785
  + return (
23786
+ + <Menu.RadioItem {...props} closeOnClick={closeOnClick} className="ui-menu-item is-radio">
23787
  + <ItemContent icon={icon} media={media} description={description} hint={hint}>{children}</ItemContent>
23788
  + <Menu.RadioItemIndicator className="ui-menu-check" keepMounted>
23789
  + <CarbonIcon icon={Carbon.Checkmark} size={14} />
 
23796
  +export function MenuCheckboxItem({
23797
  + icon,
23798
  + media,
 
23799
  + description,
23800
  + hint,
23801
  + children,
 
23803
  + ...props
23804
  +}: ItemContentProps & Omit<ComponentProps<typeof Menu.CheckboxItem>, "children" | "className">) {
23805
  + return (
23806
+ + <Menu.CheckboxItem {...props} closeOnClick={closeOnClick} className="ui-menu-item is-radio">
23807
  + <ItemContent icon={icon} media={media} description={description} hint={hint}>{children}</ItemContent>
23808
  + <Menu.CheckboxItemIndicator className="ui-menu-check" keepMounted>
23809
  + <CarbonIcon icon={Carbon.Checkmark} size={14} />
 
23886
  + </Popover.Portal>
23887
  + );
23888
  +}
23889
+ diff --git a/components/ui/scrollbar-inset.ts b/components/ui/scrollbar-inset.ts
23890
+ new file mode 100644
23891
+ index 0000000..5d2e784
23892
+ --- /dev/null
23893
+ +++ b/components/ui/scrollbar-inset.ts
23894
+ @@ -0,0 +1,51 @@
23895
+ +"use client";
23896
+ +
23897
+ +// A dropdown's scrollbar takes the place of its right inset. Chromium draws
23898
+ +// Pi Web's scrollbars as a box that takes room (app/globals.css), so a
23899
+ +// dropdown that scrolls held its rows 7px from the left edge and 7px plus the
23900
+ +// bar from the right, off centre under the highlight. While it scrolls, the
23901
+ +// scroller is marked `data-scrollbar` with the bar's width in
23902
+ +// `--popup-scrollbar`, and app/ui.css takes that much off the right inset
23903
+ +// (`--popup-inset-end`). Overlay scrollbars take no room: nothing changes.
23904
+ +
23905
+ +import { useCallback, useRef, type Ref } from "react";
23906
+ +
23907
+ +export function watchScrollbarInset(node: HTMLElement): () => void {
23908
+ + const update = () => {
23909
+ + const style = getComputedStyle(node);
23910
+ + const bar = node.offsetWidth - node.clientWidth - parseFloat(style.borderLeftWidth) - parseFloat(style.borderRightWidth);
23911
+ + if (bar > 0) {
23912
+ + node.setAttribute("data-scrollbar", "");
23913
+ + node.style.setProperty("--popup-scrollbar", `${bar}px`);
23914
+ + } else if (node.hasAttribute("data-scrollbar")) {
23915
+ + node.removeAttribute("data-scrollbar");
23916
+ + node.style.removeProperty("--popup-scrollbar");
23917
+ + }
23918
+ + };
23919
+ + update();
23920
+ + if (typeof ResizeObserver === "undefined") return () => {};
23921
+ + // The bar shows or goes as the content box narrows or widens. The inset it
23922
+ + // changes resizes that box again, so the update waits a frame: an update
23923
+ + // inside the observer's callback would loop it.
23924
+ + let frame = 0;
23925
+ + const observer = new ResizeObserver(() => {
23926
+ + cancelAnimationFrame(frame);
23927
+ + frame = requestAnimationFrame(update);
23928
+ + });
23929
+ + observer.observe(node);
23930
+ + return () => {
23931
+ + cancelAnimationFrame(frame);
23932
+ + observer.disconnect();
23933
+ + };
23934
+ +}
23935
+ +
23936
+ +/** A callback ref for a dropdown's scroller; `also` receives the node too. */
23937
+ +export function useScrollbarInset<T extends HTMLElement>(also?: Ref<T>): (node: T | null) => void {
23938
+ + const stop = useRef<(() => void) | null>(null);
23939
+ + return useCallback((node: T | null) => {
23940
+ + stop.current?.();
23941
+ + stop.current = node ? watchScrollbarInset(node) : null;
23942
+ + if (typeof also === "function") also(node);
23943
+ + else if (also) (also as { current: T | null }).current = node;
23944
+ + }, [also]);
23945
+ +}
23946
  diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
23947
  new file mode 100644
23948
  index 0000000..019f84d
 
25284
  +- One browser for all sessions and users of a process; tools of two sessions interleave on the same tabs.
25285
  diff --git a/docs/agents/desktop-shell.md b/docs/agents/desktop-shell.md
25286
  new file mode 100644
25287
+ index 0000000..778f35b
25288
  --- /dev/null
25289
  +++ b/docs/agents/desktop-shell.md
25290
  @@ -0,0 +1,52 @@
 
25293
  +Pi Web's frame follows Claude Code Desktop's Code tab: a sidebar of sessions, a header, then the conversation and the panes it opened as tiles. Pi's power stays reachable through "/", the composer's "+" menu, the title's session menu and Settings rather than permanent chrome. Popups are Base UI (`@base-ui/react` 1.8.0) through the wrappers in `components/ui/`, styled in `app/ui.css`; the header and tiles in `app/shell.css`. The Code tab is claude.ai web code (its chunks are on assets-proxy.anthropic.com, no Cloudflare there); the tile behaviour below was read from its tile engine and side-pane store, not from screenshots. The conversation outline (upstream's ChatMinimap) is removed.
25294
  +
25295
  +## Dropdowns (`app/ui.css` "dropdowns", `components/ui/menu.tsx`, `app/sidebar-menu.css`)
25296
+ +- **One set of proportions for every dropdown**, measured on `design/dropdown-spacing-reference.jpg` and `design/model-dropdown-reference.jpg` (phone captures at 2.81x: 15.7px text, rows 32.7px, rows inset 9.3px and rounded about 8px, surfaces rounded 14-18px, icons 16.7px and text 44.5px from the edge) and kept as `--popup-*` tokens: at 13px, a 14px surface (hairline + soft shadow, `--popup-shadow`), rows 28px inset 7px and rounded 7px (the surface's radius is the inset plus the row's), icons 14px from the edge, text at 37px, section labels 12px `--text-dim` in sentence case on the icon column, separators edge to edge with the inset above and below, a row's description inline after its label. Touch screens (`pointer: coarse`) take the reference's own scale: 15px, 32px rows, 8px inset, 16px surface. A dropdown that scrolls keeps its rows centred: Pi Web's Chromium scrollbars take room (10px, `app/globals.css`), which held a scrolling menu's rows 7px from the left and 17px from the right; dropdown scrollers take a scrollbar as wide as the inset, and `components/ui/scrollbar-inset.ts` marks the scroller (`data-scrollbar`, `--popup-scrollbar`) while one shows so the right inset (`--popup-inset-end`) gives way to it. Separators then end at the bar. Headless Chromium hides scrollbars: tests that check this launch without `--hide-scrollbars`. `components/ui/dropdowns.test.mjs` checks the ratios against the reference and that the sidebar's menus use the tokens.
25297
  +- Who follows it: Base UI menus (`.ui-menu`), the "/" "@" and history list (`.ui-composer-list`: its scroller holds the side inset so a group's line can reach the edges), popovers (content on the icon column), the sidebar's menus and their filter (a plain line like the reference's "Search here…"; the files tab's `classic` look is gone, only paths keep code type), the phone sheet (the same inset rounded rows, 44px), the model field (`ModelSelector`/`SelectorRow`: `.ui-dropdown-*`, check on the right as in the menus), and the header's panels (`.header-dropdown`: session info, system prompt, tools, subagents, branches, inset 8px under the header).
25298
+ +- **One icon column**: model rows carry their maker's mark (`components/ModelMark.tsx`: the repo's organisation on a router such as Hugging Face, else the provider; brand colours in grays), whatever has no icon starts on the icon column as the references' section labels and search line do (the effort levels, the model field's clear row: never an empty icon's room before a label), "/" rows show their kind (the "+" menu's icons: commands, connectors, a page for prompts, skills) and history rows a clock.
25299
  +- **`MenuSection`** is the only way to label a group: Base UI throws on a `GroupLabel` outside a `Group`, and the Browser pane's ⋯ menu did that, which took the page down.
25300
  +
25301
  +## Composer (`components/ChatInput.tsx`)