Spaces:
Paused
Paused
Pi Web fork: effort rows on the icon column; a scrolling dropdown keeps its rows centred
Browse files- 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..
|
| 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..
|
| 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..
|
| 7879 |
--- /dev/null
|
| 7880 |
+++ b/app/ui.css
|
| 7881 |
-
@@ -0,0 +1,
|
| 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;
|
| 7904 |
-
+
|
| 7905 |
-
+
|
|
|
|
| 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
|
| 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..
|
| 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
|
| 11719 |
-
+ assert.match(row({
|
| 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..
|
| 16220 |
--- /dev/null
|
| 16221 |
+++ b/components/ComposerModelMenu.tsx
|
| 16222 |
-
@@ -0,0 +1,
|
| 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
|
| 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..
|
| 19747 |
--- a/components/ModelSelector.tsx
|
| 19748 |
+++ b/components/ModelSelector.tsx
|
| 19749 |
-
@@ -4,6 +4,
|
| 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 |
-
@@ -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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,
|
| 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 |
-
|
| 19878 |
-
+ <SelectorRow active={!value} inset gutter={Boolean(onSetDefault)} onSelect={() => {
|
| 19879 |
setOpen(false);
|
| 19880 |
-
|
| 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..
|
| 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,
|
| 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`
|
| 20523 |
-
+ *
|
| 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 +
|
| 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 +
|
| 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={
|
| 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 +
|
| 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..
|
| 23320 |
--- /dev/null
|
| 23321 |
+++ b/components/ui/composer-list.tsx
|
| 23322 |
-
@@ -0,0 +1,
|
| 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..
|
| 23465 |
--- /dev/null
|
| 23466 |
+++ b/components/ui/dropdowns.test.mjs
|
| 23467 |
-
@@ -0,0 +1,
|
| 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..
|
| 23529 |
--- /dev/null
|
| 23530 |
+++ b/components/ui/menu.tsx
|
| 23531 |
-
@@ -0,0 +1,
|
| 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,
|
| 23539 |
-
+//
|
|
|
|
|
|
|
| 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 }:
|
| 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={
|
| 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=
|
| 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=
|
| 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..
|
| 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),
|
| 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`)
|