Mike0021 commited on
Commit
b48ffd2
·
verified ·
1 Parent(s): e03e8da

Pi Web fork 1b39ee7: full-width New, ungrouped rows aligned

Browse files
Files changed (1) hide show
  1. piweb/hf-design.patch +132 -99
piweb/hf-design.patch CHANGED
@@ -10751,7 +10751,7 @@ index 737e7a5..fdf88a2 100644
10751
 
10752
  .sidebar-sheet .sidebar-menu-question-text {
10753
  diff --git a/app/sidebar.css b/app/sidebar.css
10754
- index 0b8f66a..c70f0e7 100644
10755
  --- a/app/sidebar.css
10756
  +++ b/app/sidebar.css
10757
  @@ -91,8 +91,9 @@
@@ -10779,7 +10779,7 @@ index 0b8f66a..c70f0e7 100644
10779
  }
10780
 
10781
  .session-tree-pinned-count {
10782
- @@ -171,20 +171,22 @@
10783
  }
10784
 
10785
  .session-tree-pinned-dot.is-unread {
@@ -10810,7 +10810,21 @@ index 0b8f66a..c70f0e7 100644
10810
  cursor: pointer;
10811
  overflow: hidden;
10812
  }
10813
- @@ -197,32 +199,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10814
  background: var(--bg-selected);
10815
  }
10816
 
@@ -10846,7 +10860,7 @@ index 0b8f66a..c70f0e7 100644
10846
  cursor: default;
10847
  }
10848
 
10849
- @@ -255,14 +236,14 @@ a.session-tree-main {
10850
  width: 6px;
10851
  height: 6px;
10852
  border-radius: 50%;
@@ -10863,7 +10877,7 @@ index 0b8f66a..c70f0e7 100644
10863
  border-radius: 50%;
10864
  opacity: 0.35;
10865
  animation: session-tree-status-pulse 1.6s ease-in-out infinite;
10866
- @@ -297,20 +278,22 @@ a.session-tree-main {
10867
  }
10868
  }
10869
 
@@ -10891,7 +10905,7 @@ index 0b8f66a..c70f0e7 100644
10891
  }
10892
 
10893
  .session-tree-session.is-archived .session-tree-title {
10894
- @@ -321,6 +304,8 @@ a.session-tree-main {
10895
  from its source ("… · 3f9a"), never the suffix. */
10896
  .session-tree-title.has-fork-suffix {
10897
  display: flex;
@@ -10900,7 +10914,7 @@ index 0b8f66a..c70f0e7 100644
10900
  }
10901
 
10902
  .session-tree-title-base {
10903
- @@ -353,6 +338,22 @@ a.session-tree-main {
10904
  vertical-align: -1px;
10905
  }
10906
 
@@ -10923,7 +10937,7 @@ index 0b8f66a..c70f0e7 100644
10923
  /* The right column: the time, or the running spinner / unread dot in its place. */
10924
  .session-tree-meta {
10925
  display: flex;
10926
- @@ -384,16 +385,23 @@ a.session-tree-main {
10927
  align-items: center;
10928
  }
10929
 
@@ -10951,7 +10965,7 @@ index 0b8f66a..c70f0e7 100644
10951
  color: var(--text-dim);
10952
  }
10953
 
10954
- @@ -408,11 +416,18 @@ a.session-tree-main {
10955
  background: var(--bg-hover);
10956
  }
10957
 
@@ -10973,7 +10987,7 @@ index 0b8f66a..c70f0e7 100644
10973
  }
10974
 
10975
  .session-tree-meta.is-running {
10976
- @@ -436,7 +451,10 @@ a.session-tree-main {
10977
  * :focus-within fallback in the @supports block after the group rules.
10978
  */
10979
  .session-tree-session:has(:focus-visible) .session-tree-action {
@@ -10985,7 +10999,7 @@ index 0b8f66a..c70f0e7 100644
10985
  }
10986
 
10987
  .session-tree-session:has(:focus-visible) .session-tree-meta {
10988
- @@ -454,7 +472,7 @@ a.session-tree-main {
10989
  padding: 0 8px;
10990
  box-sizing: border-box;
10991
  border: 1px solid var(--accent);
@@ -10994,7 +11008,7 @@ index 0b8f66a..c70f0e7 100644
10994
  outline: none;
10995
  background: var(--bg);
10996
  color: var(--text);
10997
- @@ -483,7 +501,7 @@ a.session-tree-main {
10998
  gap: 4px;
10999
  height: 24px;
11000
  padding: 0 10px;
@@ -11003,7 +11017,7 @@ index 0b8f66a..c70f0e7 100644
11003
  font-size: 12px;
11004
  white-space: nowrap;
11005
  }
11006
- @@ -518,8 +536,8 @@ a.session-tree-main {
11007
  right: var(--session-tree-inset-right);
11008
  left: var(--session-tree-inset-left);
11009
  gap: 2px;
@@ -11014,7 +11028,7 @@ index 0b8f66a..c70f0e7 100644
11014
  -webkit-user-select: none;
11015
  user-select: none;
11016
  -webkit-touch-callout: none;
11017
- @@ -548,7 +566,7 @@ a.session-tree-main {
11018
  position: absolute;
11019
  right: var(--session-tree-inset-right);
11020
  left: var(--session-tree-inset-left);
@@ -11023,7 +11037,7 @@ index 0b8f66a..c70f0e7 100644
11023
  background: color-mix(in srgb, var(--bg-panel) 55%, transparent);
11024
  pointer-events: none;
11025
  }
11026
- @@ -589,7 +607,7 @@ a.session-tree-main {
11027
  height: 22px;
11028
  padding: 0 10px;
11029
  border: 1px solid var(--border);
@@ -11032,7 +11046,7 @@ index 0b8f66a..c70f0e7 100644
11032
  background: var(--bg-panel);
11033
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
11034
  color: var(--text);
11035
- @@ -618,7 +636,7 @@ a.session-tree-main {
11036
  padding: 0 0 0 5px;
11037
  color: var(--text-muted);
11038
  font-size: 12px;
@@ -11041,7 +11055,7 @@ index 0b8f66a..c70f0e7 100644
11041
  text-align: left;
11042
  white-space: nowrap;
11043
  }
11044
- @@ -669,19 +687,29 @@ a.session-tree-main {
11045
  width: 6px;
11046
  height: 6px;
11047
  border-radius: 50%;
@@ -11074,7 +11088,7 @@ index 0b8f66a..c70f0e7 100644
11074
  }
11075
 
11076
  .session-tree-group:hover .session-tree-summary,
11077
- @@ -690,17 +718,48 @@ a.session-tree-main {
11078
  }
11079
 
11080
  .session-tree-group:has(:focus-visible) .session-tree-group-actions {
@@ -11126,7 +11140,7 @@ index 0b8f66a..c70f0e7 100644
11126
  }
11127
 
11128
  .session-tree-session:focus-within .session-tree-meta,
11129
- @@ -723,10 +782,10 @@ a.session-tree-main {
11130
  flex: none;
11131
  align-items: center;
11132
  justify-content: center;
@@ -11140,7 +11154,18 @@ index 0b8f66a..c70f0e7 100644
11140
  color: var(--text-dim);
11141
  }
11142
 
11143
- @@ -772,44 +831,6 @@ a.session-tree-main {
 
 
 
 
 
 
 
 
 
 
 
11144
  color: var(--text);
11145
  }
11146
 
@@ -11185,7 +11210,7 @@ index 0b8f66a..c70f0e7 100644
11185
  /* Archive view: one header per project above its archived sessions, a main
11186
  group's header (chevron, name, ⋯ on hover) that folds its rows, with the
11187
  count after the name in the meta color, shown folded or not. */
11188
- @@ -822,15 +843,21 @@ a.session-tree-main {
11189
 
11190
  /* Phone layout: 44px rows, a step larger type for titles and project names,
11191
  ⋯ and the group buttons always shown, no archive quick button. */
@@ -11210,7 +11235,7 @@ index 0b8f66a..c70f0e7 100644
11210
  }
11211
 
11212
  .session-tree.is-mobile .session-tree-session:hover .session-tree-meta {
11213
- @@ -842,7 +869,10 @@ a.session-tree-main {
11214
  }
11215
 
11216
  .session-tree.is-mobile .session-tree-group-actions {
@@ -11222,7 +11247,7 @@ index 0b8f66a..c70f0e7 100644
11222
  }
11223
 
11224
  .session-tree.is-mobile .session-tree-group .session-tree-summary {
11225
- @@ -867,10 +897,22 @@ a.session-tree-main {
11226
  /* Touch screens wider than a phone have no hover: keep ⋯ reachable. */
11227
  @media (hover: none) {
11228
  .session-tree-more-action {
@@ -11246,7 +11271,7 @@ index 0b8f66a..c70f0e7 100644
11246
  @media (prefers-reduced-motion: reduce) {
11247
  .session-tree-unread::after {
11248
  animation: none;
11249
- @@ -925,9 +967,10 @@ a.session-tree-main {
11250
  display: flex;
11251
  flex: none;
11252
  align-items: stretch;
@@ -11258,7 +11283,7 @@ index 0b8f66a..c70f0e7 100644
11258
  }
11259
 
11260
  /* The tablist itself holds only the two tabs (ARIA allows nothing else in it). */
11261
- @@ -937,69 +980,45 @@ a.session-tree-main {
11262
  align-items: stretch;
11263
  }
11264
 
@@ -11340,7 +11365,7 @@ index 0b8f66a..c70f0e7 100644
11340
  }
11341
 
11342
  .sidebar-tab-count {
11343
- @@ -1019,27 +1038,36 @@ a.session-tree-main {
11344
  beside the two cells at the sidebar's 180px minimum, and at 11px so do
11345
  the versions with a digit to spare). */
11346
  .sidebar-brand {
@@ -11388,7 +11413,7 @@ index 0b8f66a..c70f0e7 100644
11388
  }
11389
 
11390
  .sidebar-brand:focus {
11391
- @@ -1047,22 +1075,107 @@ a.session-tree-main {
11392
  }
11393
 
11394
  .sidebar-brand:focus-visible {
@@ -11402,8 +11427,8 @@ index 0b8f66a..c70f0e7 100644
11402
  +
11403
  +/* Search, then New, under the header, as Claude Code's sidebar
11404
  + (design/sidebar-search-claude-reference.jpg, measured at 2x): a field the
11405
- + width of the rows (10px in from each side, their right edge) and a row of
11406
- + its own for New. The magnifier and New's disc are centred on one column,
11407
  + 15px into the field as in the reference; the placeholder and New's label
11408
  + start on another, 18px after it. Both are 28px tall, as the rows are (the
11409
  + reference's field is 26px at its own scale). */
@@ -11411,7 +11436,6 @@ index 0b8f66a..c70f0e7 100644
11411
  + display: flex;
11412
  + flex: none;
11413
  + flex-direction: column;
11414
- + /* The field's bottom to New's disc: 13px, as in the reference. */
11415
  + gap: 8px;
11416
  + padding: 2px 10px 6px;
11417
  }
@@ -11465,14 +11489,14 @@ index 0b8f66a..c70f0e7 100644
11465
  + text-overflow: ellipsis;
11466
  }
11467
 
11468
- +/* The disc is ink with the sidebar's colour for its plus, as the reference's light disc on a
11469
- + dark sidebar (and dark on light). */
11470
  .sidebar-new-button {
11471
  - font-weight: 500;
11472
- + gap: 9px;
11473
- + padding: 0 0 0 6px;
11474
  + border: 0;
11475
- + background: transparent;
11476
  + color: var(--text);
11477
  + font-weight: 400;
11478
  + text-align: left;
@@ -11481,30 +11505,17 @@ index 0b8f66a..c70f0e7 100644
11481
  -.sidebar-search-toggle {
11482
  - width: 36px;
11483
  - padding: 0;
11484
- +.sidebar-new-button:not(:disabled):hover {
11485
- + background: var(--bg-hover);
11486
- + color: var(--text-strong);
11487
- +}
11488
- +
11489
- +.sidebar-new-disc {
11490
- + display: inline-flex;
11491
  + flex: none;
11492
- + align-items: center;
11493
- + justify-content: center;
11494
- + width: 18px;
11495
- + height: 18px;
11496
- + border-radius: 50%;
11497
- + /* The reference's disc is 191 on a 17 sidebar: most of the text's ink, a step below it. */
11498
- + background: color-mix(in srgb, var(--text) 82%, var(--bg));
11499
- + color: var(--bg);
11500
  +}
11501
  +
11502
- +.sidebar-new-button:not(:disabled):hover .sidebar-new-disc {
11503
- + background: var(--text-strong);
 
11504
  }
11505
 
11506
  .sidebar-new-button:disabled {
11507
- @@ -1070,16 +1183,14 @@ a.session-tree-main {
11508
  cursor: not-allowed;
11509
  }
11510
 
@@ -11516,9 +11527,7 @@ index 0b8f66a..c70f0e7 100644
11516
  -.sidebar-header[data-fit="2"] .sidebar-new-button {
11517
  - width: 36px;
11518
  - padding: 0;
11519
- +.sidebar-new-button:disabled .sidebar-new-disc {
11520
- + background: var(--text-dim);
11521
- }
11522
 
11523
  +/* Where the labels do not fit (useHeaderFit measures them: their widths
11524
  + change with the language): the tabs keep their icons, or the brand's
@@ -11527,7 +11536,7 @@ index 0b8f66a..c70f0e7 100644
11527
  .sidebar-header[data-fit="2"] .sidebar-tab {
11528
  padding: 0 11px;
11529
  }
11530
- @@ -1088,8 +1199,11 @@ a.session-tree-main {
11531
  font-size: 11px;
11532
  }
11533
 
@@ -11541,7 +11550,7 @@ index 0b8f66a..c70f0e7 100644
11541
  .sidebar-header[data-fit="2"] .sidebar-tab-label {
11542
  position: absolute;
11543
  width: 1px;
11544
- @@ -1100,14 +1214,15 @@ a.session-tree-main {
11545
  }
11546
 
11547
  .sidebar-new-button:focus-visible,
@@ -11560,7 +11569,7 @@ index 0b8f66a..c70f0e7 100644
11560
  }
11561
 
11562
  .sidebar-panel {
11563
- @@ -1186,7 +1301,7 @@ a.session-tree-main {
11564
  the root too), and its time 4px inside the row. The header row's keys and
11565
  the picker's boxes end there. */
11566
  .session-sidebar.is-files-below {
@@ -11569,7 +11578,7 @@ index 0b8f66a..c70f0e7 100644
11569
  }
11570
 
11571
  /* The files section's header row (files below the sessions only), one row
11572
- @@ -1230,7 +1345,7 @@ a.session-tree-main {
11573
  padding: 0 5px;
11574
  overflow: hidden;
11575
  border: 0;
@@ -11578,7 +11587,7 @@ index 0b8f66a..c70f0e7 100644
11578
  background: transparent;
11579
  color: var(--text-muted);
11580
  font: inherit;
11581
- @@ -1256,10 +1371,8 @@ a.session-tree-main {
11582
  .sidebar-files-section-label {
11583
  min-width: 0;
11584
  overflow: hidden;
@@ -11590,7 +11599,7 @@ index 0b8f66a..c70f0e7 100644
11590
  }
11591
 
11592
  /* The changed files' count, as the pinned section's (.session-tree-pinned-count). */
11593
- @@ -1278,7 +1391,7 @@ a.session-tree-main {
11594
  display: flex;
11595
  flex: 1000 0 calc(7 * 18px);
11596
  align-items: center;
@@ -11599,7 +11608,7 @@ index 0b8f66a..c70f0e7 100644
11599
  }
11600
 
11601
  /* The section's keys: the files tab's square keys, a step under the row's
11602
- @@ -1286,9 +1399,9 @@ a.session-tree-main {
11603
  .sidebar-files-keys .sidebar-tool-button {
11604
  flex: 1 1 0;
11605
  min-width: 18px;
@@ -11612,7 +11621,7 @@ index 0b8f66a..c70f0e7 100644
11612
  }
11613
 
11614
  /* Under that row (or the whole files tab): the head, then the tree filling
11615
- @@ -1301,31 +1414,7 @@ a.session-tree-main {
11616
  min-height: 0;
11617
  }
11618
 
@@ -11645,7 +11654,7 @@ index 0b8f66a..c70f0e7 100644
11645
  slides in over the tree, which stays mounted under it (hidden once the
11646
  archive is in), and away again. SessionTree fills each. */
11647
  .sidebar-sessions-views {
11648
- @@ -1380,6 +1469,62 @@ a.session-tree-main {
11649
  }
11650
  }
11651
 
@@ -11708,7 +11717,7 @@ index 0b8f66a..c70f0e7 100644
11708
  .sidebar-archive-bar {
11709
  display: flex;
11710
  flex: none;
11711
- @@ -1397,7 +1542,7 @@ a.session-tree-main {
11712
  height: 26px;
11713
  padding: 0 6px;
11714
  border: 0;
@@ -11717,7 +11726,7 @@ index 0b8f66a..c70f0e7 100644
11717
  background: transparent;
11718
  color: var(--text-muted);
11719
  font-size: 12px;
11720
- @@ -1485,10 +1630,11 @@ a.session-tree-main {
11721
  six (the changes view is disabled, not gone, without changes), so nothing
11722
  moves as the count flips. At the sidebar's 180px minimum they give way
11723
  together, down to 21px wide, and the gap before the views closes. */
@@ -11730,7 +11739,7 @@ index 0b8f66a..c70f0e7 100644
11730
  margin-top: 4px;
11731
  }
11732
 
11733
- @@ -1498,17 +1644,18 @@ a.session-tree-main {
11734
  margin-left: auto;
11735
  }
11736
 
@@ -11754,7 +11763,7 @@ index 0b8f66a..c70f0e7 100644
11754
  background: transparent;
11755
  color: var(--text-dim);
11756
  cursor: pointer;
11757
- @@ -1516,8 +1663,8 @@ a.session-tree-main {
11758
  }
11759
 
11760
  .sidebar-tool-button:not(:disabled):hover {
@@ -11765,7 +11774,7 @@ index 0b8f66a..c70f0e7 100644
11765
  }
11766
 
11767
  .sidebar-tool-button.is-active,
11768
- @@ -1615,7 +1762,7 @@ a.session-tree-main {
11769
  @media (prefers-reduced-motion: reduce) {
11770
  .sidebar-tab,
11771
  .sidebar-new-button,
@@ -29207,7 +29216,7 @@ index ed7d604..0000000
29207
  - );
29208
  -}
29209
  diff --git a/components/SessionSidebar.test.mjs b/components/SessionSidebar.test.mjs
29210
- index db58db2..0a9b05f 100644
29211
  --- a/components/SessionSidebar.test.mjs
29212
  +++ b/components/SessionSidebar.test.mjs
29213
  @@ -120,16 +120,26 @@ test("only Shift skips the session deletion confirmation", () => {
@@ -29240,7 +29249,7 @@ index db58db2..0a9b05f 100644
29240
  + assert.match(html, /<div class="sidebar-header"><button type="button" class="sidebar-brand" aria-label="Pi Web, versions: web [^",]+, pi [^",]+"><svg class="sidebar-brand-logo"[^>]*>.*?<\/svg><span class="sidebar-brand-text">Pi Web<\/span><\/button><\/div><div class="sidebar-actions"><button type="button" title="Search conversations" aria-label="Search conversations" aria-haspopup="dialog" aria-expanded="false" class="sidebar-search-field">/);
29241
  assert.doesNotMatch(html, /role="tablist"|role="tab"|role="tabpanel"|View options|sidebar-title/);
29242
  - assert.match(html, /<span class="sidebar-new-label">New<\/span><\/button><button type="button" title="Search conversations" aria-label="Search conversations" aria-expanded="false" aria-controls="session-search-input" class="sidebar-search-toggle">/);
29243
- + assert.match(html, /<span class="sidebar-search-field-label" aria-hidden="true">Search<\/span><\/button><button type="button" class="sidebar-new-button"[^>]*><span class="sidebar-new-disc" aria-hidden="true"><svg[^>]*>.*?<\/svg><\/span><span class="sidebar-new-label">New<\/span><\/button><\/div>/);
29244
  // Two regions, both shown: the sessions, the separator, the files.
29245
  const sessions = openingTag(html, "session-sidebar-panel-sessions");
29246
  const files = openingTag(html, "session-sidebar-panel-files");
@@ -29346,7 +29355,7 @@ index db58db2..0a9b05f 100644
29346
  // Before the sidebar has a cwd: the project box asks for one.
29347
  assert.match(filesPanel, /<div class="project-picker is-row" role="group" aria-label="Project and worktree"><button type="button" class="project-picker-button is-project is-empty" title="" aria-haspopup="menu" aria-expanded="false">/);
29348
  diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx
29349
- index df09a76..0d10665 100644
29350
  --- a/components/SessionSidebar.tsx
29351
  +++ b/components/SessionSidebar.tsx
29352
  @@ -1,6 +1,7 @@
@@ -29765,9 +29774,9 @@ index df09a76..0d10665 100644
29765
  + </div>
29766
  +
29767
  + {/* Search, then New, as Claude Code's sidebar (design/sidebar-search-claude-reference.jpg):
29768
- + a field-like button that opens the search of the tab in view, and a row of its own
29769
- + for a new session. The magnifier and New's disc share one column, the placeholder
29770
- + and New's label another. */}
29771
  + <div className="sidebar-actions">
29772
  <button
29773
  ref={searchButtonRef}
@@ -29804,7 +29813,7 @@ index df09a76..0d10665 100644
29804
  + title={selectedCwd ? t("sidebar.newSessionTitle", { path: selectedCwd }) : t("sidebar.selectProject")}
29805
  >
29806
  - <SearchIcon size={16} />
29807
- + <span className="sidebar-new-disc" aria-hidden="true"><PlusIcon size={12} /></span>
29808
  + <span className="sidebar-new-label">{t("sidebar.new")}</span>
29809
  </button>
29810
  </div>
@@ -29954,7 +29963,7 @@ index df09a76..0d10665 100644
29954
  <SidebarMenu
29955
  open={menu !== null}
29956
  diff --git a/components/SessionTree.test.mjs b/components/SessionTree.test.mjs
29957
- index fe38e40..5a29f4d 100644
29958
  --- a/components/SessionTree.test.mjs
29959
  +++ b/components/SessionTree.test.mjs
29960
  @@ -109,7 +109,8 @@ function groupRow(extra = {}, projectExtra = {}) {
@@ -30092,7 +30101,7 @@ index fe38e40..5a29f4d 100644
30092
  const familyRow = rowMarkup(html, "session:group:a");
30093
  assert.match(familyRow, /is-selected/);
30094
  assert.match(familyRow, /aria-label="Agent running…"/);
30095
- @@ -474,12 +474,14 @@ test("row CSS stays flat, themed and quiet", () => {
30096
  assert.doesNotMatch(css, /&/, "no CSS nesting");
30097
  assert.doesNotMatch(css, /@starting-style|prefers-color-scheme/);
30098
  const colors = new Set((css.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).map((color) => color.toLowerCase()));
@@ -30101,17 +30110,16 @@ index fe38e40..5a29f4d 100644
30101
  - // the errors' red and the delete button's white). No mixes of their own.
30102
  + for (const color of colors) assert.ok(["#ef4444", "#d97706", "#f87171", "#fff"].includes(color), `unexpected color ${color}`);
30103
  + // Three text colors: content, labels, meta, the selected title a step
30104
- + // stronger (plus the accent for states, the errors' red, the delete
30105
- + // button's white, and the sidebar's own background for the plus knocked
30106
- + // out of New's ink disc). No mixes of their own.
30107
  const textColors = new Set([...css.matchAll(/(?:^|[\s;{])color: ([^;]+);/g)].map((match) => match[1]));
30108
  for (const color of textColors) {
30109
  - assert.ok(["var(--text)", "var(--text-muted)", "var(--text-dim)", "var(--accent)", "inherit", "#f87171", "#fff"].includes(color), `unexpected text color ${color}`);
30110
- + assert.ok(["var(--text)", "var(--text-strong)", "var(--text-muted)", "var(--text-dim)", "var(--accent)", "inherit", "#f87171", "#fff", "var(--bg)"].includes(color), `unexpected text color ${color}`);
30111
  }
30112
  assert.doesNotMatch(css, /color: color-mix\(in srgb, var\(--text\)/);
30113
  });
30114
- @@ -514,8 +516,7 @@ test("headers lead with their chevron, and every row's text starts in one column
30115
 
30116
  // The text column: the toggle's left padding, the 12px chevron and the
30117
  // gap. Session titles, "show more", the empty group's note, the archive's
@@ -30121,7 +30129,7 @@ index fe38e40..5a29f4d 100644
30121
  // shorthand is its left side.
30122
  const lastPx = (rule, property) => {
30123
  const value = cssRule(rule).match(new RegExp(`(?:^|\\n)\\s*${property}: ([^;]+);`))[1];
30124
- @@ -523,11 +524,15 @@ test("headers lead with their chevron, and every row's text starts in one column
30125
  };
30126
  const textStart = lastPx(".session-tree-group-toggle", "padding") + 12 + lastPx(".session-tree-group-toggle", "gap");
30127
  assert.equal(textStart, 20);
@@ -30142,7 +30150,7 @@ index fe38e40..5a29f4d 100644
30142
  // These rows span the whole width, so their padding adds the rows' inset.
30143
  assert.match(css, /\.session-tree-group-empty,\s*\.session-tree-more \{\s*padding-left: calc\(var\(--session-tree-inset-left\) \+ 20px\);/);
30144
  // The pinned section's chevron in the same column (its row spans the
30145
- @@ -535,26 +540,23 @@ test("headers lead with their chevron, and every row's text starts in one column
30146
  assert.match(cssRule(".session-tree-pinned-toggle"), /padding: 0 8px 0 calc\(var\(--session-tree-inset-left\) \+ 5px\);/);
30147
 
30148
  // No layout overrides these paddings, so phones line up the same way.
@@ -30174,7 +30182,7 @@ index fe38e40..5a29f4d 100644
30174
  ];
30175
  const html = render({ rows, onMoveGroup: noop });
30176
  assert.match(html, /^<div class="session-tree">/);
30177
- @@ -574,3 +576,22 @@ test("the drag ghost is shorter than a header", () => {
30178
  assert.match(cssRule(".session-tree-drag-ghost"), /\n\s*height: 22px;/);
30179
  assert.ok(22 < SIDEBAR_ROW_HEIGHTS.desktop.group, "shorter than a header");
30180
  });
@@ -30198,10 +30206,18 @@ index fe38e40..5a29f4d 100644
30198
  + assert.equal((SIDEBAR_ROW_HEIGHTS.mobile.session - 32) / 2, endPadding(cssRule(".session-tree-session")));
30199
  +});
30200
  diff --git a/components/SessionTree.tsx b/components/SessionTree.tsx
30201
- index f96a924..d848ab0 100644
30202
  --- a/components/SessionTree.tsx
30203
  +++ b/components/SessionTree.tsx
30204
- @@ -37,10 +37,8 @@ import { useI18n } from "@/hooks/useI18n";
 
 
 
 
 
 
 
 
30205
  import { useScrollbarVisibility } from "@/hooks/useScrollbarVisibility";
30206
  import type { SidebarMenuAnchor } from "./SidebarMenu";
30207
  import {
@@ -30212,7 +30228,7 @@ index f96a924..d848ab0 100644
30212
  MoreIcon,
30213
  PinIcon,
30214
  PlusIcon,
30215
- @@ -132,8 +130,6 @@ export interface SessionTreeProps {
30216
  onToggleArchiveGroup?(projectKey: string, all: boolean): void;
30217
  /** An archive header's ⋯: Restore all, Delete…. */
30218
  onArchiveGroupMenu?(project: SidebarProject, opener: HTMLElement): void;
@@ -30221,7 +30237,15 @@ index f96a924..d848ab0 100644
30221
  /** Scroll a row into view (and maybe focus it); see SessionTreeReveal. */
30222
  reveal?: SessionTreeReveal | null;
30223
  /** The reveal request with this id was scrolled to or dropped: the parent lets go of it. */
30224
- @@ -600,7 +596,8 @@ const SessionRowView = memo(function SessionRowView({
 
 
 
 
 
 
 
 
30225
  meta = <span className="session-tree-unread" role="img" aria-label={t("sidebar.newSessionActivity")} />;
30226
  metaState = " is-unread";
30227
  metaTitle = t("sidebar.newActivity");
@@ -30231,7 +30255,7 @@ index f96a924..d848ab0 100644
30231
  meta = <span className="session-tree-project">{row.project.name}</span>;
30232
  } else if (context === "archive" && row.archivedAt !== null) {
30233
  meta = formatShortRelativeTime(new Date(row.archivedAt), locale, nowDate);
30234
- @@ -608,12 +605,13 @@ const SessionRowView = memo(function SessionRowView({
30235
  meta = formatShortRelativeTime(family.latestModified, locale, nowDate);
30236
  }
30237
 
@@ -30249,7 +30273,7 @@ index f96a924..d848ab0 100644
30249
  {forkTitle ? (
30250
  <span className="session-tree-title has-fork-suffix">
30251
  <span className="session-tree-title-base">{forkTitle.base}</span>
30252
- @@ -629,7 +627,7 @@ const SessionRowView = memo(function SessionRowView({
30253
  </span>
30254
  )}
30255
  <span className={`session-tree-meta${metaState}`} title={metaTitle}>{meta}</span>
@@ -30258,7 +30282,7 @@ index f96a924..d848ab0 100644
30258
  );
30259
 
30260
  return (
30261
- @@ -668,7 +666,7 @@ const SessionRowView = memo(function SessionRowView({
30262
  )}
30263
  {!status.transient && (
30264
  <span className="session-tree-actions">
@@ -30267,7 +30291,7 @@ index f96a924..d848ab0 100644
30267
  <button
30268
  type="button"
30269
  className="session-tree-action session-tree-quick-action"
30270
- @@ -681,20 +679,7 @@ const SessionRowView = memo(function SessionRowView({
30271
  >
30272
  <RestoreIcon size={13} />
30273
  </button>
@@ -30289,7 +30313,16 @@ index f96a924..d848ab0 100644
30289
  <button
30290
  type="button"
30291
  className={`session-tree-action session-tree-more-action${menuOpen ? " is-active" : ""}`}
30292
- @@ -1046,29 +1031,6 @@ const PlainRowView = memo(function PlainRowView({
 
 
 
 
 
 
 
 
 
30293
  {t("sidebar.noLiveSessions")}
30294
  </div>
30295
  );
@@ -36815,7 +36848,7 @@ index 25796d6..51c3f1c 100644
36815
  - **Notices.** MCP off, with its reason. Otherwise `hostInactive`: an open session whose `/mcp` is another extension's, naming its folder and that extension (only a session can tell). A project folder the route refuses (`cwd-*`) does not hide the global servers: `loadMcpOverview()` loads again without the cwd and the Project group says "Not listed" with the reason. Trust is reported only when the project has a `.pi/mcp.json` or a problem with one: untrusted (`ConfigTrustNotice`), an inherited `false` naming its folder, and inherited trust as "Trusted through <path>", the only place that trust is visible. A folder that requires no trust gets no trust notice unless a decision marks it untrusted: a dangling `.pi/mcp.json` link is listed (`link-dangling`), but the SDK's `existsSync` finds nothing to trust, so Trust would only meet `trust-not-required`. File problems go to the footer (`ConfigFooterStatus`). A load ends after `MCP_OVERVIEW_TIMEOUT_MS` (15 s, both requests together), since Refresh is disabled while one runs.
36816
  - **The detail pane shows only what GET sends:** the masked command line (`mcpServerTarget()`), working directory, env and header **names** (each only when set), the `!command` fields and host variables (left out for a refused entry, which never connects; kept for a disabled one), the Sign-in row (**Settings › MCP sign-in** in [mcp-test-sign-in.md](mcp-test-sign-in.md)), the exposure, the file. The Code mode pane holds the choice, the sandbox and `-builtin:codemode`.
36817
  diff --git a/docs/agents/sessions.md b/docs/agents/sessions.md
36818
- index e99fbb7..d8928f5 100644
36819
  --- a/docs/agents/sessions.md
36820
  +++ b/docs/agents/sessions.md
36821
  @@ -44,7 +44,7 @@ On mount `useAgentSession` loads the history, then `GET /api/sessions/[id]/state
@@ -36843,7 +36876,7 @@ index e99fbb7..d8928f5 100644
36843
  Tree model (`buildSessionTree()`, pure; `components/SessionTree.tsx` virtualizes its flat `rows` over per-kind heights):
36844
  -- Pinned section first: every project's pinned families, newest pin first, 8 shown (+ show more, as for groups). Then one collapsible group per project: projects with live non-pinned families, the current project (even empty), and pinned projects. Pinned projects come first, then the rest; each band follows `projectOrder`, and activity (a new message, a run, unread, the selection) never moves a project that has a place in it. Projects without a place come first in their band, in the old order (pinned ones by pin time, the others by newest family activity, the current project with no live family first): those not saved on their own (the current project while it has no live family, a project of transient sessions only) before the rest, so saving the rest changes nothing on screen. The model lists them per band (`unorderedKeysByBand`) and the ones to save (`projectKeysToRecord`: pinned projects, also without sessions, and projects with a live family that has a file). The sidebar sends those with `add-projects` once its ui-state is `synced` and the full session details are in (the first paint's summary rows carry file mtimes): the first save records the activity order the user sees, and a new project lands at the top of its band and stays there until moved. More keys than one request holds (500; a long-time user's daily `~/pi-cwd/YYYYMMDD` folders are each a project) go bottom first (`nextProjectKeysToRecord()`): each batch lands where it shows, right below the keys still unsaved; top first, the first batch would drop below the rest and the next one would store that scrambled order for good. A key sent but still unsaved (refused, or no room left) ends the batch, since a key saved above it would drop below it. Each key goes out once per page (`recordedOrderKeysRef`, marked only when sent; a refused save is not retried in a loop), through the raw apply, so a failed background save shows no toast. A current project that has a place keeps it while empty, so after "Open another project…" its group may sit lower down. A group is open by default when current or pinned; an explicit choice (a toggle, Alt+click, collapse others, expand all, a fork's reveal) wins and is saved. When another project becomes current (a session picked in its group), the group that was open only as the current one stays open for the page's life (`keepOutgoingGroupOpen()` into `keptOpenGroups`, page memory), so it does not fold up and move the clicked row from under the pointer. It is never saved: saved, every project ever visited came back open after a reload and the tree only grew. The tree, the toggles and the group menu's disabled states read `effectiveGroupExpansion()` (the saved choices over the kept ones); every write goes through `saveGroupChoices()`, which saves explicit choices only and drops the kept entries they cover (all of them for a choice made for every group). After a reload only the current project, pinned projects and groups opened on purpose are open. Each shows its 6 newest families plus any later one that is running, unread or selected, then "show more · N" (N still hidden). A click reveals `SHOW_MORE_STEP` (20) more, never the whole project: a project with hundreds of sessions would otherwise mount and scroll them all at once. Families that show anyway (running, unread, selected) do not use up a click, so each one adds exactly 20 rows or what is left. That is decided at the click, by position: the more row offers `nextShown` (the window past the base limit that 20 more hidden families need, counted with today's flags), `handleShowMore()` stores it, and the tree then shows every family before that position plus the flagged ones after it. A family that turns selected, unread or running inside the window keeps its place and frees nothing; counted as a budget on every render, picking the last revealed row (now selected, so it showed "anyway") let the next, older family in below it. Once a click has revealed rows, "show less" sits beside it and folds back to the base limit (it is not offered when folding would hide nothing); the more row is then scrolled back into view, since the list can shrink by hundreds of pixels under the pointer, as it is after a keyboard "show more" (the focused button moves 20 rows down). A keyboard user's focus moves to the button that remains, else to the group or pinned header; it waits in a ref until a commit has that target mounted. The counts live in `moreShown` (per project key, `PINNED_MORE_KEY` for the pinned section; session state, not saved). Folding drops a window: every explicit choice that folds a group (a toggle, Alt+click, collapse others) goes through `saveGroupChoices()`, which forgets the folded groups' counts (`forgetShownMoreOfFolded()`), and folding the pinned section forgets its own, so a group opened again shows its first rows (and the running, unread or selected ones) only. A collapsed group shows running/unread counts. Footer: "Open another project…" and "Archived · N" (the archive view, `buildArchiveRows()`, grouped by project, newest `archivedAt` first).
36845
  +- Pinned section first: every project's pinned families, newest pin first, 8 shown (+ show more, as for groups). Then one collapsible group per project: projects with live non-pinned families, the current project (even empty), and pinned projects. Pinned projects come first, then the rest; each band follows `projectOrder`, and activity (a new message, a run, unread, the selection) never moves a project that has a place in it. Projects without a place come first in their band, in the old order (pinned ones by pin time, the others by newest family activity, the current project with no live family first): those not saved on their own (the current project while it has no live family, a project of transient sessions only) before the rest, so saving the rest changes nothing on screen. The model lists them per band (`unorderedKeysByBand`) and the ones to save (`projectKeysToRecord`: pinned projects, also without sessions, and projects with a live family that has a file). The sidebar sends those with `add-projects` once its ui-state is `synced` and the full session details are in (the first paint's summary rows carry file mtimes): the first save records the activity order the user sees, and a new project lands at the top of its band and stays there until moved. More keys than one request holds (500; a long-time user's daily `~/pi-cwd/YYYYMMDD` folders are each a project) go bottom first (`nextProjectKeysToRecord()`): each batch lands where it shows, right below the keys still unsaved; top first, the first batch would drop below the rest and the next one would store that scrambled order for good. A key sent but still unsaved (refused, or no room left) ends the batch, since a key saved above it would drop below it. Each key goes out once per page (`recordedOrderKeysRef`, marked only when sent; a refused save is not retried in a loop), through the raw apply, so a failed background save shows no toast. A current project that has a place keeps it while empty, so after opening another folder its group may sit lower down. A group is open by default when current or pinned; an explicit choice (a toggle, Alt+click, collapse others, expand all, a fork's reveal) wins and is saved. When another project becomes current (a session picked in its group), the group that was open only as the current one stays open for the page's life (`keepOutgoingGroupOpen()` into `keptOpenGroups`, page memory), so it does not fold up and move the clicked row from under the pointer. It is never saved: saved, every project ever visited came back open after a reload and the tree only grew. The tree, the toggles and the group menu's disabled states read `effectiveGroupExpansion()` (the saved choices over the kept ones); every write goes through `saveGroupChoices()`, which saves explicit choices only and drops the kept entries they cover (all of them for a choice made for every group). After a reload only the current project, pinned projects and groups opened on purpose are open. Each shows its 6 newest families plus any later one that is running, unread or selected, then "show more · N" (N still hidden). A click reveals `SHOW_MORE_STEP` (20) more, never the whole project: a project with hundreds of sessions would otherwise mount and scroll them all at once. Families that show anyway (running, unread, selected) do not use up a click, so each one adds exactly 20 rows or what is left. That is decided at the click, by position: the more row offers `nextShown` (the window past the base limit that 20 more hidden families need, counted with today's flags), `handleShowMore()` stores it, and the tree then shows every family before that position plus the flagged ones after it. A family that turns selected, unread or running inside the window keeps its place and frees nothing; counted as a budget on every render, picking the last revealed row (now selected, so it showed "anyway") let the next, older family in below it. Once a click has revealed rows, "show less" sits beside it and folds back to the base limit (it is not offered when folding would hide nothing); the more row is then scrolled back into view, since the list can shrink by hundreds of pixels under the pointer, as it is after a keyboard "show more" (the focused button moves 20 rows down). A keyboard user's focus moves to the button that remains, else to the group or pinned header; it waits in a ref until a commit has that target mounted. The counts live in `moreShown` (per project key, `PINNED_MORE_KEY` for the pinned section; session state, not saved). Folding drops a window: every explicit choice that folds a group (a toggle, Alt+click, collapse others) goes through `saveGroupChoices()`, which forgets the folded groups' counts (`forgetShownMoreOfFolded()`), and folding the pinned section forgets its own, so a group opened again shows its first rows (and the running, unread or selected ones) only. A collapsed group shows running/unread counts. There is no footer: another folder opens from the composer's project picker or the Files pane's, and the archive from the Conversations header.
36846
- +- **The Conversations header** (`.sidebar-conversations-header`, above the tree and the archive view, not a row) names the list, as Claude's Recents, with its settings on the right (`SlidersVertical`, its centre on the session rows' ⋯: 24px from the edge, 28px on a phone; design/conversations-menu-reference.jpg is illustrative, only these options are taken). Its menu: **Group by** Project (the groups above) or None (`groupBy: "none"` in `buildSessionTree()`: the pinned section, then every live family in one list, newest first, `UNGROUPED_VISIBLE_LIMIT` (20) shown before its show more, `UNGROUPED_MORE_KEY`; each row is `context: "ungrouped"` and names its project in place of its time, as a pinned row does; no group headers, not even the current project's), kept per browser in `pi-web:sidebar-group-by` (read after mount); and **Show archived**, a switch for the archive view (`buildArchiveRows()`, grouped by project, newest `archivedAt` first; disabled while nothing is archived and the view is shut). Leaving the archive view puts focus back on the header's button.
36847
  - The look keeps a few rules; new rows follow them. **Three text colors**: content (session titles) `--text`, labels (project names, section labels) `--text-muted`, meta (times, counts, branches, a pinned row's project, "show more", the footer) `--text-dim`; no `color-mix` greys of their own (`SessionTree.test.mjs` lists every `color:` in `app/sidebar.css`: besides those three only the accent, the error red and the delete button's white). **No icons in the list**: no folder per project (it read as a second file tree, and the files section's header as one more project) and no pin before the pinned section; the only icons are the chevrons, a pinned project's 10px pin and the activity summary after its name, the branch icon in a row's branch, and the footer's two. **Two alignment lines**: the chevron column (12px, at the rows' left inset plus a toggle's 5px: 11px in; the inset is 6px in every browser, a classic scrollbar taking its room from the right, so the brand, the files section's header, the picker's paths and the file tree keep the same two lines) and the text column 20px in from the inset (26px) (5px + the 12px chevron + the toggle's 3px gap), where project names, section labels, session titles (`padding-left: 20px` in their inset box), "show more", the empty group's note (inset + 20px), the archive's project names and the footer's labels start (the footer's 12px icons sit in the chevron column); `SessionTree.test.mjs` adds it up from the CSS. **The accent is a state's, never a label's**: the selected row's bar, a run's spinner (no accent folder for the current project).
36848
  - Headers are section headers over their rows, not one more row: the chevron leads (`ChevronIcon size={12} strokeWidth={3}`: a 1.5px stroke, as firm as a bold label; `--text-dim`, `--text-muted` on the header's hover; inside the toggle and `aria-hidden`, so the whole header still toggles and its name is the project's), then the name 12px/600 in `--text-muted` (`--text` for the current project, which shows by that alone, or a hovered one), then a pinned project's pin (5px off the name) and, folded, the activity summary at the right. The pinned section's header is a section label as tall as a group's (28px, 44px on a phone, a session row's height): its chevron in the same column, "Pinned" 11px/600 in small capitals (`letter-spacing: 0.05em`, uppercase; `--text` on hover), the count " · N" 11px/400 dim, then the dots while folded; the files section's header below the sessions is the same label. The archive view's project headers are group headers (`session-tree-group session-tree-archive-group`, the same toggle, chevron and name, the current project in `--text`), with the count after the name in 400 `--text-dim`, folded or not, and ⋯ alone on hover (no "+", no pin, no drag: the archive keeps no order). Phones take 13px for titles and project names (`.session-tree.is-mobile`, the component's layout prop, never a media query). The drag ghost's name is 12px/600, as a header's.
36849
  -- Spacing: groups sit right on each other, open or folded (collapsed ones at a 28px pitch); a gap between two of them only pushed the next header away. An 8px spacer row parts the pinned section from the groups (`pinned-spacer`) and the groups from the footer (`footer-spacer`, only when there are groups; a pinned section with no group has the one spacer before the footer).
@@ -36873,7 +36906,7 @@ index e99fbb7..d8928f5 100644
36873
  - Focus across the remount: the bar's control that moved the composer goes away with it, so AppShell keeps which one it was (`newSessionBarFocusRef`, set only when a move goes ahead) and the next bar focuses its counterpart once, if focus fell to the page. The request is used up on mount, so that bar must already have the control: after "New worktree…" the worktree button is there in its first commit only because of `newSessionMoveRef`. The folder picker gives focus back to the bar's button on Cancel and when its folder is refused. Any other new session (Ctrl+Alt+N, the sidebar's New, a group's "+", the header's +) puts the caret in the fresh composer instead, on desktop only (#1313): `handleNewSession` calls the new `ChatInputHandle.focus()` (the caret after a parked draft that came back, so typing continues it) from a `requestAnimationFrame`, once the `sessionKey` remount has mounted it, never when the bar holds the focus request (that flow keeps the bar's control), never while focus is in a modal dialog (`[aria-modal='true']`: Ctrl+Alt+N under Settings or a folder picker leaves it there) and never on a phone, where focus would raise the software keyboard uninvited, the picker's filter's rule since #1012.
36874
  -- The sidebar's top is one toolbar row in the cells of the chat's top bar beside it (AppShell): 36px with its line (border-box, as that bar is), so the two read as one bar. In the tabs layout the two tabs (an icon and a label each; only they are the tablist), then New and the search; the chosen tab and the open search take the bar's 2px accent line on top and `--bg-selected`, as the bar's open panels do. With the files below the sessions there is nothing to choose: the brand stands where the tabs were, the panel's typographic anchor as on the old sidebar (`components/PiWebTitle.tsx`, `.sidebar-brand`: "Pi Web" in code type, 15px/700, `--text`, a button with no box and no line on its right, its text in the chevron column: 7px margin and 4px padding), then New and the search, which is the sessions' alone there (`searchesFiles` false; the files section's header row has the files'). New draws a line on its left in both layouts (`.sidebar-new-button`): the brand is no cell, and in the tabs layout the spacer leaves a gap after the last tab at most widths. New and the search read as two cells, as the chat bar's right-end cell does; a -1px left margin lays the line over the last tab's where the spacer has collapsed and keeps New's footprint, so the fit levels are unchanged, and border-box keeps their 36px cells 36px. Its old easter egg came back with it: a click scrambles "Pi Web" into the versions (`<web>p<pi>`, `NEXT_PUBLIC_APP_VERSION` and `NEXT_PUBLIC_PI_VERSION`) in the accent, left to right over 4 frames a character, and they scramble back after 3 s (`VERSION_SHOWN_MS`, from the click) or at once on a second click; never on mount; under `prefers-reduced-motion` the text is swapped without the scramble. Its name (`sidebar.brandLabel`, "Pi Web, versions: web …, pi …") says what the click shows and never changes, so no scrambled text is read out. The brand is the stacked layout's only: the tabs layout keeps its tabs there, and the empty new-session page keeps its own, with the versions. There is no view menu: "View archived" is the tree's footer row, collapsing and expanding every group a group's ⋯ menu ("Collapse other projects", which keeps that one open, and "Expand all projects", each disabled when the groups already are so) and Alt+click on a group header (every group follows it, `setAllGroupsExpanded`). Labels give way where they do not fit (`useHeaderFit`): New keeps its +, then the tabs their icons, or below the sessions the versions take 11px (`data-fit="2"`, which only they reach there): the brand itself is never cut ("Pi Web" fits beside the two 36px cells at the 180px minimum, and so do the versions at 11px with a digit to spare; `PiWebTitle.test.mjs` adds it up). It measures rather than querying a fixed width, since the labels' widths change with the language (in the tabs layout, English needs New's + alone at the default 260px); `data-fit` on the row, set before paint and again when the row resizes or the layout, a label shown, the files count or the chosen tab (bolder) changes. The brand's text changes its width without the row resizing, so `PiWebTitle` calls the fit the hook returns (`onWidthChange`) from a layout effect on its text's length (a code font: length is width), before that paints; a `ResizeObserver` on the brand would see the fit change its type and loop. A hidden label is clipped, not removed, so it stays the button's name.
36875
  +- The sidebar's top is one toolbar row in the cells of the chat's top bar beside it (AppShell): 36px with its line (border-box, as that bar is), so the two read as one bar. In the tabs layout the two tabs (an icon and a label each; only they are the tablist); the chosen tab takes `--bg-selected`. With the files below the sessions there is nothing to choose: the brand stands where the tabs were, the panel's typographic anchor as on the old sidebar (`components/PiWebTitle.tsx`, `.sidebar-brand`: "Pi Web" in code type, 15px/700, `--text`, a button with no box and no line on its right, its text in the chevron column: 7px margin and 4px padding).
36876
- +- Search and New are under that row, as in Claude Code's sidebar (design/sidebar-search-claude-reference.jpg; `.sidebar-actions`): a field-like button the rows' width (10px in on each side, 28px, the rows' radius, a hairline and a faint fill from the text colour) that opens the search of the tab in view (the conversations' command bar; on the tabs layout's Files tab with a folder, the files' search, `searchesFiles`), then New in a row of its own (`.sidebar-new-button`, kept for upstream's e2e): an 18px ink disc with the sidebar's colour for its plus. The magnifier and the disc are centred on one column (25px from the sidebar's edge, 15px into the field as in the reference), the placeholder and New's label start on another (43px), and the field's bottom is 13px above the disc, the reference's measures at its 2x. Its accessible name stays "Search conversations"; the visible placeholder is "Search". Closing the command bar puts focus in the composer, not back on the field: so it did from the old header icon too. Its old easter egg came back with it: a click scrambles "Pi Web" into the versions (`<web>p<pi>`, `NEXT_PUBLIC_APP_VERSION` and `NEXT_PUBLIC_PI_VERSION`) in the accent, left to right over 4 frames a character, and they scramble back after 3 s (`VERSION_SHOWN_MS`, from the click) or at once on a second click; never on mount; under `prefers-reduced-motion` the text is swapped without the scramble. Its name (`sidebar.brandLabel`, "Pi Web, versions: web …, pi …") says what the click shows and never changes, so no scrambled text is read out. The brand is the stacked layout's only: the tabs layout keeps its tabs there, and the empty new-session page keeps its own, with the versions. There is no view menu: "View archived" is the tree's footer row, collapsing and expanding every group a group's ⋯ menu ("Collapse other projects", which keeps that one open, and "Expand all projects", each disabled when the groups already are so) and Alt+click on a group header (every group follows it, `setAllGroupsExpanded`). Labels give way where they do not fit (`useHeaderFit`): the tabs keep their icons, or below the sessions the versions take 11px (`data-fit="2"`, which only they reach there): the brand itself is never cut ("Pi Web" fits at the 180px minimum, and so do the versions at 11px; `PiWebTitle.test.mjs` adds it up). It measures rather than querying a fixed width, since the labels' widths change with the language `data-fit` on the row, set before paint and again when the row resizes or the layout, a label shown, the files count or the chosen tab (bolder) changes. The brand's text changes its width without the row resizing, so `PiWebTitle` calls the fit the hook returns (`onWidthChange`) from a layout effect on its text's length (a code font: length is width), before that paints; a `ResizeObserver` on the brand would see the fit change its type and loop. A hidden label is clipped, not removed, so it stays the button's name.
36877
  - Two layouts, one element tree. Where the files go is the desktop's choice (Settings › General › File browser, `useFilesPlacement()`, see settings-ui.md): below the sessions (the default) or in a tab of their own; phones always take the tabs (`stacked = !isMobile && placement === "below"`; the server and hydration render the stacked layout, both hooks' server snapshots). Both layouts render the same containers in the same order (the toolbar row, the sessions panel, the files panel and its body `#session-sidebar-files-body`, the menu, the toast): only attributes differ, and each layout's own rows are conditional siblings in fixed slots (`{stacked && …}` / `{!stacked && …}`: the brand or the tablist, the separator, the files section's header row, the files tab's row of keys). So a Settings change, the window crossing the phone breakpoint or a fold never remounts `FileExplorer` (its open folders, a search, an upload), the session tree (its scroll) or the picker. Never wrap a panel differently per layout, and never mount the explorer on anything but its cwd (`explorerOpen && <FileExplorer>`, before the tabs, lost all three on a fold).
36878
  - Tabs: "Sessions | Files" both stay mounted (`role="tabpanel"`, `hidden` on the inactive one), as before the stacked layout. Below the sessions: both panels are `role="region"` (named "Sessions" and "Files"), always shown, in one column (`.session-sidebar.is-files-below`). The sessions fill what the files leave, at least 120px (`SESSIONS_SECTION_MIN_HEIGHT`, the CSS `min-height`); then the separator; then the files section, as tall as `--sidebar-files-height`, set on the root by `useResizablePanel` (vertical, grows up; `pi-web:sidebar-files-height`, never the retired `pi-web:sidebar-session-pane-height`; `persistClamp: false`, so a window too short for the user's size shows less without saving it; only a drag that moved, a key or a reset saves, and grow and End start from the saved size, never lowering it): at least 220px (its header row, the picker's row, about six tree rows), at most the sidebar under its toolbar row less the sessions' minimum, 45% of that by default (320px until measured). A sidebar too short for both (a landscape phone wider than the breakpoint, a short window) shrinks the files section in CSS, down to its header row and line (29px, 37px on a coarse pointer): the sessions' zero `flex-basis` freezes them at their minimum, so the files take the whole shortfall and never run past the root, which would clip them. A `ResizeObserver` on the root fits it again while stacked: the window is not the only thing that changes the sidebar's height. The separator is the hook's `separatorProps` (arrows, Home/End, Enter or a double-click to reset) on a 12px hit area centred on the files' top border, which is the line it shows (`.panel-resize-handle.sidebar-files-resize-handle`: the side handles' look on its side), only while the section is open. The section's first row (`.sidebar-files-section`, 28px, 36px on a coarse pointer; stacked only) is all of the files' chrome above the picker: a section header as the pinned section's is, then, 2px after it, the files' keys (flush, no gap between them). The toggle: its 12px chevron in the chevron column the session tree and the file tree share (6px in plus the toggle's 5px), "Files" in the text column in the pinned label's small capitals (11px/600, `--text-muted`, `--text` on hover), then while something is changed the count as the pinned section's (" · N", 11px/400 `--text-dim`, `aria-hidden`: the changes key's name says it; the toggle's title is "N changed files"), `aria-expanded` and `aria-controls` on the body; no folder (it read as one more project). The keys are a group of their own (`.sidebar-files-keys`, `role="group"`, "File actions"), at the row's right end on the session times' and the boxes' edge (`--sidebar-files-inset-right` on `.session-sidebar.is-files-below`: `max(10px, the session tree's scrollbar + 4px)`, the root's `--session-tree-scrollbar`; 10px in beside an overlay or a coarse pointer's 6px scrollbar, 14px beside Chromium's 10px), in the Files tab's order with the search last: seven keys, always rendered and disabled without a folder (the terminal also without `onOpenTerminal`, which the files tab leaves out instead), so none ever moves: a terminal in the folder, the file manager (the tab's disabled reason and title; its failure shows in the section's body, so a failure opens a folded section: `openInFileManager()` resolves true when it failed, else the `role="alert"` would wait unseen in a hidden body until some later unfold), upload (a folded section opens first: the upload's progress and summary are the tree's), refresh (✓ for 2 s), the ignored-files switch (`aria-pressed`), the changes view (disabled while nothing is changed, `aria-pressed` while the changes show, named "N changed files"; on a folded section it opens it on the changes), the file search (`aria-expanded`, `aria-controls="file-search-input"`; on a folded section it opens the section with the field open and focused). They are the files tab's elements (`terminalButton`, `fileManagerButton`, …, `changesButton`, built once, rendered in one layout's place at a time; the search is this row's own, the tab's being the toolbar row's) and `.sidebar-tool-button`s with 13px icons (14px in the tab's row), 26px high, 6px corners, sharing what the toggle's content leaves: `flex: 1 1 0`, 18px to 26px wide (their group `flex: 1000 0 calc(7 * 18px)`, `max-width: calc(7 * 26px)`, so it takes the room before the toggle grows), about 24px at the default 260px beside "文件 · 2", 26px from about 300px; only with the keys at 18px does the toggle give way (its label, then its count, wrap out of view whole, down to the chevron). On a coarse pointer they are 32px and come before the label, as before. No count chip any more: it came and went and moved its neighbours. A key that turns disabled under the focus loses it: current Chromium (154) keeps it on the key until its next rendering update, after the commit's layout effects, and then blurs it to the page with `relatedTarget` null, which clears the root's note (`sidebarFocusRef`), so nothing would try again (older builds blur at once). So when a commit or a revert disables the changes key (either layout) while the note names it, a layout effect hands the focus to the nearest key still enabled, the next first (`nearestEnabledKey()`: the file search here, the ignored-files switch in the tab), else the toolbar row's search, when the focus is still on the key or fell to the page (`handOffDisabledKeyFocus()`); focus the user put elsewhere stays. The toggle has a zero basis, clips its overflow and keeps its chevron (`min-width: 17px`), so its label gives way first, then the count, and only then do the keys narrow together: at the default 260px beside Chromium's 10px scrollbar the keys keep 22px and the toggle 83px, room for "Files · 123" (82px in Chromium's 11px system font); at the 180px minimum (159px inside the row's padding) the keys still get 20px each (`SessionSidebar.test.mjs` adds both up from the CSS). On a coarse pointer the 32px keys leave the toggle only its chevron at the default width: touch keys over the label. Folding hides the body (`hidden`, nothing unmounts) and the separator: the row alone stays, its keys and count with it, at the bottom (`height: 29px`, 37px on a coarse pointer: a length, not `auto`, so the fold can move to it), and the sessions take the rest. A fold and an unfold move (`useSidebarMotion()`, `filesFoldState()`): while one runs `.is-files-folding` on the root puts `transition: height 180ms ease-out` on the files panel (and on the toast's `bottom`), so its height goes between `--sidebar-files-height` and the row's and the sessions' zero basis follows frame by frame. Only `setFilesSectionCollapsed()` starts it, and only on a change: the toggle, `revealFiles()`, the folded section's search, changes and upload keys and a file manager error move; a drag, keys, a window resize, a layout switch (the class needs `stacked`) and the restore after hydration (`setFilesCollapsed` directly) never do. Unfolding shows the body first and grows it; folding keeps it shown, `inert`, to the end, then `hidden`. The separator comes back only once the files are still (`filesFold.open`), so a drag never meets the transition. The phase ends on the panel's own `transitionend` for `height` (the toggle chevron's bubbles there too), else 300 ms after its start; a second click halfway turns it back from where it is. The session tree re-reads its viewport on every resize (its `ResizeObserver` on the scroll box), so no rows are missing during or after the move. Under `prefers-reduced-motion` no phase starts and the CSS has no transition: the fold is instant, as before. The fold is per browser (`pi-web:sidebar-files-collapsed`, `lib/sidebar-prefs.ts`). Below the sessions `switchTab()` does nothing (the archive view, the search's fallback), so the tabs layout and phones get the user's last tab back.
36879
  -- Everything that goes to the files (a group menu's "Open in Files", the footer's "Open another project…", the folded section's search) goes through `revealFiles(focusTarget)`: the files tab, or the files section opened (and saved so). Its focus effect waits for the files to show and runs on a request counter too, since below the sessions they may show already: the project button takes the focus, its menu opens, or the file search field takes it. An unfolding section shows once it is done (`filesShown` is `filesFold.open` there): until then its project button is still under the sidebar's edge, where the project menu would be placed. Focus fallbacks (`selectedTabButton()`, behind `familyRowButton`, `groupHeaderButton` and the archive's Back) take the selected tab, or with no tab mounted the toolbar row's search button (always there, never disabled), so focus never falls to `<body>`. A live layout switch (the setting from another window, the phone breakpoint) removes the controls one layout has (the tabs, the files tab's keys; the brand, the separator, the files section's header row and its keys) and may hide a panel: focus that was on one goes there too (the root notes the sidebar control focus is on, `sidebarFocusRef`, forgotten once focus leaves the sidebar, so focus elsewhere stays; a blur with no `relatedTarget` while `document.hasFocus()` is false is the window losing focus, which keeps it, since the setting from another window arrives just then). The toast is one live region at the root in both layouts; below the files, CSS lifts it to the bottom of the sessions (`calc(var(--sidebar-files-height) + 8px)`, 37px over a folded section, 45px on a coarse pointer), so it never covers the file tree.
 
10751
 
10752
  .sidebar-sheet .sidebar-menu-question-text {
10753
  diff --git a/app/sidebar.css b/app/sidebar.css
10754
+ index 0b8f66a..abd6d2d 100644
10755
  --- a/app/sidebar.css
10756
  +++ b/app/sidebar.css
10757
  @@ -91,8 +91,9 @@
 
10779
  }
10780
 
10781
  .session-tree-pinned-count {
10782
+ @@ -171,24 +171,36 @@
10783
  }
10784
 
10785
  .session-tree-pinned-dot.is-unread {
 
10810
  cursor: pointer;
10811
  overflow: hidden;
10812
  }
10813
+
10814
+ +/* Grouped by none there is no header to sit under: the rows start at the tree's
10815
+ + inset and their titles on the Conversations header's text (11px), and their
10816
+ + meta (the project) ends as far from the sidebar's edge. That edge is the
10817
+ + scrollbar's gutter or the inset, whichever is wider. */
10818
+ +.session-tree-session.is-ungrouped {
10819
+ + left: var(--session-tree-inset-left);
10820
+ + padding-right: max(0px, calc(11px - max(6px, var(--session-tree-scrollbar, 0px))));
10821
+ + padding-left: 5px;
10822
+ +}
10823
+ +
10824
+ .session-tree-session:hover {
10825
+ background: var(--bg-hover);
10826
+ }
10827
+ @@ -197,32 +209,11 @@
10828
  background: var(--bg-selected);
10829
  }
10830
 
 
10860
  cursor: default;
10861
  }
10862
 
10863
+ @@ -255,14 +246,14 @@ a.session-tree-main {
10864
  width: 6px;
10865
  height: 6px;
10866
  border-radius: 50%;
 
10877
  border-radius: 50%;
10878
  opacity: 0.35;
10879
  animation: session-tree-status-pulse 1.6s ease-in-out infinite;
10880
+ @@ -297,20 +288,22 @@ a.session-tree-main {
10881
  }
10882
  }
10883
 
 
10905
  }
10906
 
10907
  .session-tree-session.is-archived .session-tree-title {
10908
+ @@ -321,6 +314,8 @@ a.session-tree-main {
10909
  from its source ("… · 3f9a"), never the suffix. */
10910
  .session-tree-title.has-fork-suffix {
10911
  display: flex;
 
10914
  }
10915
 
10916
  .session-tree-title-base {
10917
+ @@ -353,6 +348,22 @@ a.session-tree-main {
10918
  vertical-align: -1px;
10919
  }
10920
 
 
10937
  /* The right column: the time, or the running spinner / unread dot in its place. */
10938
  .session-tree-meta {
10939
  display: flex;
10940
+ @@ -384,16 +395,23 @@ a.session-tree-main {
10941
  align-items: center;
10942
  }
10943
 
 
10965
  color: var(--text-dim);
10966
  }
10967
 
10968
+ @@ -408,11 +426,18 @@ a.session-tree-main {
10969
  background: var(--bg-hover);
10970
  }
10971
 
 
10987
  }
10988
 
10989
  .session-tree-meta.is-running {
10990
+ @@ -436,7 +461,10 @@ a.session-tree-main {
10991
  * :focus-within fallback in the @supports block after the group rules.
10992
  */
10993
  .session-tree-session:has(:focus-visible) .session-tree-action {
 
10999
  }
11000
 
11001
  .session-tree-session:has(:focus-visible) .session-tree-meta {
11002
+ @@ -454,7 +482,7 @@ a.session-tree-main {
11003
  padding: 0 8px;
11004
  box-sizing: border-box;
11005
  border: 1px solid var(--accent);
 
11008
  outline: none;
11009
  background: var(--bg);
11010
  color: var(--text);
11011
+ @@ -483,7 +511,7 @@ a.session-tree-main {
11012
  gap: 4px;
11013
  height: 24px;
11014
  padding: 0 10px;
 
11017
  font-size: 12px;
11018
  white-space: nowrap;
11019
  }
11020
+ @@ -518,8 +546,8 @@ a.session-tree-main {
11021
  right: var(--session-tree-inset-right);
11022
  left: var(--session-tree-inset-left);
11023
  gap: 2px;
 
11028
  -webkit-user-select: none;
11029
  user-select: none;
11030
  -webkit-touch-callout: none;
11031
+ @@ -548,7 +576,7 @@ a.session-tree-main {
11032
  position: absolute;
11033
  right: var(--session-tree-inset-right);
11034
  left: var(--session-tree-inset-left);
 
11037
  background: color-mix(in srgb, var(--bg-panel) 55%, transparent);
11038
  pointer-events: none;
11039
  }
11040
+ @@ -589,7 +617,7 @@ a.session-tree-main {
11041
  height: 22px;
11042
  padding: 0 10px;
11043
  border: 1px solid var(--border);
 
11046
  background: var(--bg-panel);
11047
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
11048
  color: var(--text);
11049
+ @@ -618,7 +646,7 @@ a.session-tree-main {
11050
  padding: 0 0 0 5px;
11051
  color: var(--text-muted);
11052
  font-size: 12px;
 
11055
  text-align: left;
11056
  white-space: nowrap;
11057
  }
11058
+ @@ -669,19 +697,29 @@ a.session-tree-main {
11059
  width: 6px;
11060
  height: 6px;
11061
  border-radius: 50%;
 
11088
  }
11089
 
11090
  .session-tree-group:hover .session-tree-summary,
11091
+ @@ -690,17 +728,48 @@ a.session-tree-main {
11092
  }
11093
 
11094
  .session-tree-group:has(:focus-visible) .session-tree-group-actions {
 
11140
  }
11141
 
11142
  .session-tree-session:focus-within .session-tree-meta,
11143
+ @@ -723,10 +792,10 @@ a.session-tree-main {
11144
  flex: none;
11145
  align-items: center;
11146
  justify-content: center;
 
11154
  color: var(--text-dim);
11155
  }
11156
 
11157
+ @@ -744,6 +813,10 @@ a.session-tree-main {
11158
+ font-size: 11px;
11159
+ }
11160
+
11161
+ +.session-tree-more.is-ungrouped {
11162
+ + padding-left: calc(var(--session-tree-inset-left) + 5px);
11163
+ +}
11164
+ +
11165
+ /* "Show more · N" and, once more is shown, "Show less" side by side. A lone
11166
+ button takes the whole row; on a narrow sidebar "show more" gives way so
11167
+ "Show less" is never clipped. */
11168
+ @@ -772,44 +845,6 @@ a.session-tree-main {
11169
  color: var(--text);
11170
  }
11171
 
 
11210
  /* Archive view: one header per project above its archived sessions, a main
11211
  group's header (chevron, name, ⋯ on hover) that folds its rows, with the
11212
  count after the name in the meta color, shown folded or not. */
11213
+ @@ -822,15 +857,21 @@ a.session-tree-main {
11214
 
11215
  /* Phone layout: 44px rows, a step larger type for titles and project names,
11216
  ⋯ and the group buttons always shown, no archive quick button. */
 
11235
  }
11236
 
11237
  .session-tree.is-mobile .session-tree-session:hover .session-tree-meta {
11238
+ @@ -842,7 +883,10 @@ a.session-tree-main {
11239
  }
11240
 
11241
  .session-tree.is-mobile .session-tree-group-actions {
 
11247
  }
11248
 
11249
  .session-tree.is-mobile .session-tree-group .session-tree-summary {
11250
+ @@ -867,10 +911,22 @@ a.session-tree-main {
11251
  /* Touch screens wider than a phone have no hover: keep ⋯ reachable. */
11252
  @media (hover: none) {
11253
  .session-tree-more-action {
 
11271
  @media (prefers-reduced-motion: reduce) {
11272
  .session-tree-unread::after {
11273
  animation: none;
11274
+ @@ -925,9 +981,10 @@ a.session-tree-main {
11275
  display: flex;
11276
  flex: none;
11277
  align-items: stretch;
 
11283
  }
11284
 
11285
  /* The tablist itself holds only the two tabs (ARIA allows nothing else in it). */
11286
+ @@ -937,69 +994,45 @@ a.session-tree-main {
11287
  align-items: stretch;
11288
  }
11289
 
 
11365
  }
11366
 
11367
  .sidebar-tab-count {
11368
+ @@ -1019,27 +1052,36 @@ a.session-tree-main {
11369
  beside the two cells at the sidebar's 180px minimum, and at 11px so do
11370
  the versions with a digit to spare). */
11371
  .sidebar-brand {
 
11413
  }
11414
 
11415
  .sidebar-brand:focus {
11416
+ @@ -1047,22 +1089,93 @@ a.session-tree-main {
11417
  }
11418
 
11419
  .sidebar-brand:focus-visible {
 
11427
  +
11428
  +/* Search, then New, under the header, as Claude Code's sidebar
11429
  + (design/sidebar-search-claude-reference.jpg, measured at 2x): a field the
11430
+ + width of the rows (10px in from each side, their right edge) and New, a
11431
+ + button as wide. The magnifier and New's plus are centred on one column,
11432
  + 15px into the field as in the reference; the placeholder and New's label
11433
  + start on another, 18px after it. Both are 28px tall, as the rows are (the
11434
  + reference's field is 26px at its own scale). */
 
11436
  + display: flex;
11437
  + flex: none;
11438
  + flex-direction: column;
 
11439
  + gap: 8px;
11440
  + padding: 2px 10px 6px;
11441
  }
 
11489
  + text-overflow: ellipsis;
11490
  }
11491
 
11492
+ +/* New: a full-width button the field's size, filled a step above it (no hairline: the
11493
+ + field's), its plain plus on the magnifier's column and its label on the placeholder's. */
11494
  .sidebar-new-button {
11495
  - font-weight: 500;
11496
+ + gap: 11px;
11497
+ + padding: 0 0 0 8px;
11498
  + border: 0;
11499
+ + background: color-mix(in srgb, var(--text) 7%, transparent);
11500
  + color: var(--text);
11501
  + font-weight: 400;
11502
  + text-align: left;
 
11505
  -.sidebar-search-toggle {
11506
  - width: 36px;
11507
  - padding: 0;
11508
+ +.sidebar-new-button svg {
 
 
 
 
 
 
11509
  + flex: none;
 
 
 
 
 
 
 
 
11510
  +}
11511
  +
11512
+ +.sidebar-new-button:not(:disabled):hover {
11513
+ + background: color-mix(in srgb, var(--text) 11%, transparent);
11514
+ + color: var(--text-strong);
11515
  }
11516
 
11517
  .sidebar-new-button:disabled {
11518
+ @@ -1070,16 +1183,11 @@ a.session-tree-main {
11519
  cursor: not-allowed;
11520
  }
11521
 
 
11527
  -.sidebar-header[data-fit="2"] .sidebar-new-button {
11528
  - width: 36px;
11529
  - padding: 0;
11530
+ -}
 
 
11531
 
11532
  +/* Where the labels do not fit (useHeaderFit measures them: their widths
11533
  + change with the language): the tabs keep their icons, or the brand's
 
11536
  .sidebar-header[data-fit="2"] .sidebar-tab {
11537
  padding: 0 11px;
11538
  }
11539
+ @@ -1088,8 +1196,11 @@ a.session-tree-main {
11540
  font-size: 11px;
11541
  }
11542
 
 
11550
  .sidebar-header[data-fit="2"] .sidebar-tab-label {
11551
  position: absolute;
11552
  width: 1px;
11553
+ @@ -1100,14 +1211,15 @@ a.session-tree-main {
11554
  }
11555
 
11556
  .sidebar-new-button:focus-visible,
 
11569
  }
11570
 
11571
  .sidebar-panel {
11572
+ @@ -1186,7 +1298,7 @@ a.session-tree-main {
11573
  the root too), and its time 4px inside the row. The header row's keys and
11574
  the picker's boxes end there. */
11575
  .session-sidebar.is-files-below {
 
11578
  }
11579
 
11580
  /* The files section's header row (files below the sessions only), one row
11581
+ @@ -1230,7 +1342,7 @@ a.session-tree-main {
11582
  padding: 0 5px;
11583
  overflow: hidden;
11584
  border: 0;
 
11587
  background: transparent;
11588
  color: var(--text-muted);
11589
  font: inherit;
11590
+ @@ -1256,10 +1368,8 @@ a.session-tree-main {
11591
  .sidebar-files-section-label {
11592
  min-width: 0;
11593
  overflow: hidden;
 
11599
  }
11600
 
11601
  /* The changed files' count, as the pinned section's (.session-tree-pinned-count). */
11602
+ @@ -1278,7 +1388,7 @@ a.session-tree-main {
11603
  display: flex;
11604
  flex: 1000 0 calc(7 * 18px);
11605
  align-items: center;
 
11608
  }
11609
 
11610
  /* The section's keys: the files tab's square keys, a step under the row's
11611
+ @@ -1286,9 +1396,9 @@ a.session-tree-main {
11612
  .sidebar-files-keys .sidebar-tool-button {
11613
  flex: 1 1 0;
11614
  min-width: 18px;
 
11621
  }
11622
 
11623
  /* Under that row (or the whole files tab): the head, then the tree filling
11624
+ @@ -1301,31 +1411,7 @@ a.session-tree-main {
11625
  min-height: 0;
11626
  }
11627
 
 
11654
  slides in over the tree, which stays mounted under it (hidden once the
11655
  archive is in), and away again. SessionTree fills each. */
11656
  .sidebar-sessions-views {
11657
+ @@ -1380,6 +1466,62 @@ a.session-tree-main {
11658
  }
11659
  }
11660
 
 
11717
  .sidebar-archive-bar {
11718
  display: flex;
11719
  flex: none;
11720
+ @@ -1397,7 +1539,7 @@ a.session-tree-main {
11721
  height: 26px;
11722
  padding: 0 6px;
11723
  border: 0;
 
11726
  background: transparent;
11727
  color: var(--text-muted);
11728
  font-size: 12px;
11729
+ @@ -1485,10 +1627,11 @@ a.session-tree-main {
11730
  six (the changes view is disabled, not gone, without changes), so nothing
11731
  moves as the count flips. At the sidebar's 180px minimum they give way
11732
  together, down to 21px wide, and the gap before the views closes. */
 
11739
  margin-top: 4px;
11740
  }
11741
 
11742
+ @@ -1498,17 +1641,18 @@ a.session-tree-main {
11743
  margin-left: auto;
11744
  }
11745
 
 
11763
  background: transparent;
11764
  color: var(--text-dim);
11765
  cursor: pointer;
11766
+ @@ -1516,8 +1660,8 @@ a.session-tree-main {
11767
  }
11768
 
11769
  .sidebar-tool-button:not(:disabled):hover {
 
11774
  }
11775
 
11776
  .sidebar-tool-button.is-active,
11777
+ @@ -1615,7 +1759,7 @@ a.session-tree-main {
11778
  @media (prefers-reduced-motion: reduce) {
11779
  .sidebar-tab,
11780
  .sidebar-new-button,
 
29216
  - );
29217
  -}
29218
  diff --git a/components/SessionSidebar.test.mjs b/components/SessionSidebar.test.mjs
29219
+ index db58db2..814136e 100644
29220
  --- a/components/SessionSidebar.test.mjs
29221
  +++ b/components/SessionSidebar.test.mjs
29222
  @@ -120,16 +120,26 @@ test("only Shift skips the session deletion confirmation", () => {
 
29249
  + assert.match(html, /<div class="sidebar-header"><button type="button" class="sidebar-brand" aria-label="Pi Web, versions: web [^",]+, pi [^",]+"><svg class="sidebar-brand-logo"[^>]*>.*?<\/svg><span class="sidebar-brand-text">Pi Web<\/span><\/button><\/div><div class="sidebar-actions"><button type="button" title="Search conversations" aria-label="Search conversations" aria-haspopup="dialog" aria-expanded="false" class="sidebar-search-field">/);
29250
  assert.doesNotMatch(html, /role="tablist"|role="tab"|role="tabpanel"|View options|sidebar-title/);
29251
  - assert.match(html, /<span class="sidebar-new-label">New<\/span><\/button><button type="button" title="Search conversations" aria-label="Search conversations" aria-expanded="false" aria-controls="session-search-input" class="sidebar-search-toggle">/);
29252
+ + assert.match(html, /<span class="sidebar-search-field-label" aria-hidden="true">Search<\/span><\/button><button type="button" class="sidebar-new-button"[^>]*><svg[^>]*data-lucide="plus"[^>]*>.*?<\/svg><span class="sidebar-new-label">New<\/span><\/button><\/div>/);
29253
  // Two regions, both shown: the sessions, the separator, the files.
29254
  const sessions = openingTag(html, "session-sidebar-panel-sessions");
29255
  const files = openingTag(html, "session-sidebar-panel-files");
 
29355
  // Before the sidebar has a cwd: the project box asks for one.
29356
  assert.match(filesPanel, /<div class="project-picker is-row" role="group" aria-label="Project and worktree"><button type="button" class="project-picker-button is-project is-empty" title="" aria-haspopup="menu" aria-expanded="false">/);
29357
  diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx
29358
+ index df09a76..061a3bb 100644
29359
  --- a/components/SessionSidebar.tsx
29360
  +++ b/components/SessionSidebar.tsx
29361
  @@ -1,6 +1,7 @@
 
29774
  + </div>
29775
  +
29776
  + {/* Search, then New, as Claude Code's sidebar (design/sidebar-search-claude-reference.jpg):
29777
+ + a field-like button that opens the search of the tab in view, and New, a button as
29778
+ + wide. The magnifier and New's plus share one column, the placeholder and New's
29779
+ + label another. */}
29780
  + <div className="sidebar-actions">
29781
  <button
29782
  ref={searchButtonRef}
 
29813
  + title={selectedCwd ? t("sidebar.newSessionTitle", { path: selectedCwd }) : t("sidebar.selectProject")}
29814
  >
29815
  - <SearchIcon size={16} />
29816
+ + <PlusIcon size={14} />
29817
  + <span className="sidebar-new-label">{t("sidebar.new")}</span>
29818
  </button>
29819
  </div>
 
29963
  <SidebarMenu
29964
  open={menu !== null}
29965
  diff --git a/components/SessionTree.test.mjs b/components/SessionTree.test.mjs
29966
+ index fe38e40..09f3107 100644
29967
  --- a/components/SessionTree.test.mjs
29968
  +++ b/components/SessionTree.test.mjs
29969
  @@ -109,7 +109,8 @@ function groupRow(extra = {}, projectExtra = {}) {
 
30101
  const familyRow = rowMarkup(html, "session:group:a");
30102
  assert.match(familyRow, /is-selected/);
30103
  assert.match(familyRow, /aria-label="Agent running…"/);
30104
+ @@ -474,12 +474,13 @@ test("row CSS stays flat, themed and quiet", () => {
30105
  assert.doesNotMatch(css, /&/, "no CSS nesting");
30106
  assert.doesNotMatch(css, /@starting-style|prefers-color-scheme/);
30107
  const colors = new Set((css.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).map((color) => color.toLowerCase()));
 
30110
  - // the errors' red and the delete button's white). No mixes of their own.
30111
  + for (const color of colors) assert.ok(["#ef4444", "#d97706", "#f87171", "#fff"].includes(color), `unexpected color ${color}`);
30112
  + // Three text colors: content, labels, meta, the selected title a step
30113
+ + // stronger (plus the accent for states, the errors' red and the delete
30114
+ + // button's white). No mixes of their own.
 
30115
  const textColors = new Set([...css.matchAll(/(?:^|[\s;{])color: ([^;]+);/g)].map((match) => match[1]));
30116
  for (const color of textColors) {
30117
  - assert.ok(["var(--text)", "var(--text-muted)", "var(--text-dim)", "var(--accent)", "inherit", "#f87171", "#fff"].includes(color), `unexpected text color ${color}`);
30118
+ + assert.ok(["var(--text)", "var(--text-strong)", "var(--text-muted)", "var(--text-dim)", "var(--accent)", "inherit", "#f87171", "#fff"].includes(color), `unexpected text color ${color}`);
30119
  }
30120
  assert.doesNotMatch(css, /color: color-mix\(in srgb, var\(--text\)/);
30121
  });
30122
+ @@ -514,8 +515,7 @@ test("headers lead with their chevron, and every row's text starts in one column
30123
 
30124
  // The text column: the toggle's left padding, the 12px chevron and the
30125
  // gap. Session titles, "show more", the empty group's note, the archive's
 
30129
  // shorthand is its left side.
30130
  const lastPx = (rule, property) => {
30131
  const value = cssRule(rule).match(new RegExp(`(?:^|\\n)\\s*${property}: ([^;]+);`))[1];
30132
+ @@ -523,11 +523,15 @@ test("headers lead with their chevron, and every row's text starts in one column
30133
  };
30134
  const textStart = lastPx(".session-tree-group-toggle", "padding") + 12 + lastPx(".session-tree-group-toggle", "gap");
30135
  assert.equal(textStart, 20);
 
30150
  // These rows span the whole width, so their padding adds the rows' inset.
30151
  assert.match(css, /\.session-tree-group-empty,\s*\.session-tree-more \{\s*padding-left: calc\(var\(--session-tree-inset-left\) \+ 20px\);/);
30152
  // The pinned section's chevron in the same column (its row spans the
30153
+ @@ -535,26 +539,23 @@ test("headers lead with their chevron, and every row's text starts in one column
30154
  assert.match(cssRule(".session-tree-pinned-toggle"), /padding: 0 8px 0 calc\(var\(--session-tree-inset-left\) \+ 5px\);/);
30155
 
30156
  // No layout overrides these paddings, so phones line up the same way.
 
30182
  ];
30183
  const html = render({ rows, onMoveGroup: noop });
30184
  assert.match(html, /^<div class="session-tree">/);
30185
+ @@ -574,3 +575,22 @@ test("the drag ghost is shorter than a header", () => {
30186
  assert.match(cssRule(".session-tree-drag-ghost"), /\n\s*height: 22px;/);
30187
  assert.ok(22 < SIDEBAR_ROW_HEIGHTS.desktop.group, "shorter than a header");
30188
  });
 
30206
  + assert.equal((SIDEBAR_ROW_HEIGHTS.mobile.session - 32) / 2, endPadding(cssRule(".session-tree-session")));
30207
  +});
30208
  diff --git a/components/SessionTree.tsx b/components/SessionTree.tsx
30209
+ index f96a924..3ced186 100644
30210
  --- a/components/SessionTree.tsx
30211
  +++ b/components/SessionTree.tsx
30212
+ @@ -19,6 +19,7 @@ import { splitForkSuffix } from "@/lib/session-fork-name";
30213
+ import { MAX_PROJECT_NAME_LENGTH, type ProjectMovePosition } from "@/lib/session-ui-state-shared";
30214
+ import {
30215
+ PINNED_MORE_KEY,
30216
+ + UNGROUPED_MORE_KEY,
30217
+ getRowOffsets,
30218
+ getVisibleRowIndices,
30219
+ revealScrollTop,
30220
+ @@ -37,10 +38,8 @@ import { useI18n } from "@/hooks/useI18n";
30221
  import { useScrollbarVisibility } from "@/hooks/useScrollbarVisibility";
30222
  import type { SidebarMenuAnchor } from "./SidebarMenu";
30223
  import {
 
30228
  MoreIcon,
30229
  PinIcon,
30230
  PlusIcon,
30231
+ @@ -132,8 +131,6 @@ export interface SessionTreeProps {
30232
  onToggleArchiveGroup?(projectKey: string, all: boolean): void;
30233
  /** An archive header's ⋯: Restore all, Delete…. */
30234
  onArchiveGroupMenu?(project: SidebarProject, opener: HTMLElement): void;
 
30237
  /** Scroll a row into view (and maybe focus it); see SessionTreeReveal. */
30238
  reveal?: SessionTreeReveal | null;
30239
  /** The reveal request with this id was scrolled to or dropped: the parent lets go of it. */
30240
+ @@ -522,6 +519,7 @@ const SessionRowView = memo(function SessionRowView({
30241
+ status.selected ? "is-selected" : "",
30242
+ status.running ? "is-running" : "",
30243
+ context === "archive" ? "is-archived" : "",
30244
+ + context === "ungrouped" ? "is-ungrouped" : "",
30245
+ menuOpen ? "is-menu-open" : "",
30246
+ confirming ? "is-confirming" : "",
30247
+ renaming ? "is-renaming" : "",
30248
+ @@ -600,7 +598,8 @@ const SessionRowView = memo(function SessionRowView({
30249
  meta = <span className="session-tree-unread" role="img" aria-label={t("sidebar.newSessionActivity")} />;
30250
  metaState = " is-unread";
30251
  metaTitle = t("sidebar.newActivity");
 
30255
  meta = <span className="session-tree-project">{row.project.name}</span>;
30256
  } else if (context === "archive" && row.archivedAt !== null) {
30257
  meta = formatShortRelativeTime(new Date(row.archivedAt), locale, nowDate);
30258
+ @@ -608,12 +607,13 @@ const SessionRowView = memo(function SessionRowView({
30259
  meta = formatShortRelativeTime(family.latestModified, locale, nowDate);
30260
  }
30261
 
 
30273
  {forkTitle ? (
30274
  <span className="session-tree-title has-fork-suffix">
30275
  <span className="session-tree-title-base">{forkTitle.base}</span>
30276
+ @@ -629,7 +629,7 @@ const SessionRowView = memo(function SessionRowView({
30277
  </span>
30278
  )}
30279
  <span className={`session-tree-meta${metaState}`} title={metaTitle}>{meta}</span>
 
30282
  );
30283
 
30284
  return (
30285
+ @@ -668,7 +668,7 @@ const SessionRowView = memo(function SessionRowView({
30286
  )}
30287
  {!status.transient && (
30288
  <span className="session-tree-actions">
 
30291
  <button
30292
  type="button"
30293
  className="session-tree-action session-tree-quick-action"
30294
+ @@ -681,20 +681,7 @@ const SessionRowView = memo(function SessionRowView({
30295
  >
30296
  <RestoreIcon size={13} />
30297
  </button>
 
30313
  <button
30314
  type="button"
30315
  className={`session-tree-action session-tree-more-action${menuOpen ? " is-active" : ""}`}
30316
+ @@ -1016,7 +1003,7 @@ const PlainRowView = memo(function PlainRowView({
30317
+ case "group-more": {
30318
+ const moreKey = row.kind === "pinned-more" ? PINNED_MORE_KEY : row.projectKey;
30319
+ return (
30320
+ - <div className="session-tree-row session-tree-more" style={style} data-row-key={row.key}>
30321
+ + <div className={`session-tree-row session-tree-more${moreKey === UNGROUPED_MORE_KEY ? " is-ungrouped" : ""}`} style={style} data-row-key={row.key}>
30322
+ {row.hidden > 0 && (
30323
+ <button
30324
+ type="button"
30325
+ @@ -1046,29 +1033,6 @@ const PlainRowView = memo(function PlainRowView({
30326
  {t("sidebar.noLiveSessions")}
30327
  </div>
30328
  );
 
36848
  - **Notices.** MCP off, with its reason. Otherwise `hostInactive`: an open session whose `/mcp` is another extension's, naming its folder and that extension (only a session can tell). A project folder the route refuses (`cwd-*`) does not hide the global servers: `loadMcpOverview()` loads again without the cwd and the Project group says "Not listed" with the reason. Trust is reported only when the project has a `.pi/mcp.json` or a problem with one: untrusted (`ConfigTrustNotice`), an inherited `false` naming its folder, and inherited trust as "Trusted through <path>", the only place that trust is visible. A folder that requires no trust gets no trust notice unless a decision marks it untrusted: a dangling `.pi/mcp.json` link is listed (`link-dangling`), but the SDK's `existsSync` finds nothing to trust, so Trust would only meet `trust-not-required`. File problems go to the footer (`ConfigFooterStatus`). A load ends after `MCP_OVERVIEW_TIMEOUT_MS` (15 s, both requests together), since Refresh is disabled while one runs.
36849
  - **The detail pane shows only what GET sends:** the masked command line (`mcpServerTarget()`), working directory, env and header **names** (each only when set), the `!command` fields and host variables (left out for a refused entry, which never connects; kept for a disabled one), the Sign-in row (**Settings › MCP sign-in** in [mcp-test-sign-in.md](mcp-test-sign-in.md)), the exposure, the file. The Code mode pane holds the choice, the sandbox and `-builtin:codemode`.
36850
  diff --git a/docs/agents/sessions.md b/docs/agents/sessions.md
36851
+ index e99fbb7..0118203 100644
36852
  --- a/docs/agents/sessions.md
36853
  +++ b/docs/agents/sessions.md
36854
  @@ -44,7 +44,7 @@ On mount `useAgentSession` loads the history, then `GET /api/sessions/[id]/state
 
36876
  Tree model (`buildSessionTree()`, pure; `components/SessionTree.tsx` virtualizes its flat `rows` over per-kind heights):
36877
  -- Pinned section first: every project's pinned families, newest pin first, 8 shown (+ show more, as for groups). Then one collapsible group per project: projects with live non-pinned families, the current project (even empty), and pinned projects. Pinned projects come first, then the rest; each band follows `projectOrder`, and activity (a new message, a run, unread, the selection) never moves a project that has a place in it. Projects without a place come first in their band, in the old order (pinned ones by pin time, the others by newest family activity, the current project with no live family first): those not saved on their own (the current project while it has no live family, a project of transient sessions only) before the rest, so saving the rest changes nothing on screen. The model lists them per band (`unorderedKeysByBand`) and the ones to save (`projectKeysToRecord`: pinned projects, also without sessions, and projects with a live family that has a file). The sidebar sends those with `add-projects` once its ui-state is `synced` and the full session details are in (the first paint's summary rows carry file mtimes): the first save records the activity order the user sees, and a new project lands at the top of its band and stays there until moved. More keys than one request holds (500; a long-time user's daily `~/pi-cwd/YYYYMMDD` folders are each a project) go bottom first (`nextProjectKeysToRecord()`): each batch lands where it shows, right below the keys still unsaved; top first, the first batch would drop below the rest and the next one would store that scrambled order for good. A key sent but still unsaved (refused, or no room left) ends the batch, since a key saved above it would drop below it. Each key goes out once per page (`recordedOrderKeysRef`, marked only when sent; a refused save is not retried in a loop), through the raw apply, so a failed background save shows no toast. A current project that has a place keeps it while empty, so after "Open another project…" its group may sit lower down. A group is open by default when current or pinned; an explicit choice (a toggle, Alt+click, collapse others, expand all, a fork's reveal) wins and is saved. When another project becomes current (a session picked in its group), the group that was open only as the current one stays open for the page's life (`keepOutgoingGroupOpen()` into `keptOpenGroups`, page memory), so it does not fold up and move the clicked row from under the pointer. It is never saved: saved, every project ever visited came back open after a reload and the tree only grew. The tree, the toggles and the group menu's disabled states read `effectiveGroupExpansion()` (the saved choices over the kept ones); every write goes through `saveGroupChoices()`, which saves explicit choices only and drops the kept entries they cover (all of them for a choice made for every group). After a reload only the current project, pinned projects and groups opened on purpose are open. Each shows its 6 newest families plus any later one that is running, unread or selected, then "show more · N" (N still hidden). A click reveals `SHOW_MORE_STEP` (20) more, never the whole project: a project with hundreds of sessions would otherwise mount and scroll them all at once. Families that show anyway (running, unread, selected) do not use up a click, so each one adds exactly 20 rows or what is left. That is decided at the click, by position: the more row offers `nextShown` (the window past the base limit that 20 more hidden families need, counted with today's flags), `handleShowMore()` stores it, and the tree then shows every family before that position plus the flagged ones after it. A family that turns selected, unread or running inside the window keeps its place and frees nothing; counted as a budget on every render, picking the last revealed row (now selected, so it showed "anyway") let the next, older family in below it. Once a click has revealed rows, "show less" sits beside it and folds back to the base limit (it is not offered when folding would hide nothing); the more row is then scrolled back into view, since the list can shrink by hundreds of pixels under the pointer, as it is after a keyboard "show more" (the focused button moves 20 rows down). A keyboard user's focus moves to the button that remains, else to the group or pinned header; it waits in a ref until a commit has that target mounted. The counts live in `moreShown` (per project key, `PINNED_MORE_KEY` for the pinned section; session state, not saved). Folding drops a window: every explicit choice that folds a group (a toggle, Alt+click, collapse others) goes through `saveGroupChoices()`, which forgets the folded groups' counts (`forgetShownMoreOfFolded()`), and folding the pinned section forgets its own, so a group opened again shows its first rows (and the running, unread or selected ones) only. A collapsed group shows running/unread counts. Footer: "Open another project…" and "Archived · N" (the archive view, `buildArchiveRows()`, grouped by project, newest `archivedAt` first).
36878
  +- Pinned section first: every project's pinned families, newest pin first, 8 shown (+ show more, as for groups). Then one collapsible group per project: projects with live non-pinned families, the current project (even empty), and pinned projects. Pinned projects come first, then the rest; each band follows `projectOrder`, and activity (a new message, a run, unread, the selection) never moves a project that has a place in it. Projects without a place come first in their band, in the old order (pinned ones by pin time, the others by newest family activity, the current project with no live family first): those not saved on their own (the current project while it has no live family, a project of transient sessions only) before the rest, so saving the rest changes nothing on screen. The model lists them per band (`unorderedKeysByBand`) and the ones to save (`projectKeysToRecord`: pinned projects, also without sessions, and projects with a live family that has a file). The sidebar sends those with `add-projects` once its ui-state is `synced` and the full session details are in (the first paint's summary rows carry file mtimes): the first save records the activity order the user sees, and a new project lands at the top of its band and stays there until moved. More keys than one request holds (500; a long-time user's daily `~/pi-cwd/YYYYMMDD` folders are each a project) go bottom first (`nextProjectKeysToRecord()`): each batch lands where it shows, right below the keys still unsaved; top first, the first batch would drop below the rest and the next one would store that scrambled order for good. A key sent but still unsaved (refused, or no room left) ends the batch, since a key saved above it would drop below it. Each key goes out once per page (`recordedOrderKeysRef`, marked only when sent; a refused save is not retried in a loop), through the raw apply, so a failed background save shows no toast. A current project that has a place keeps it while empty, so after opening another folder its group may sit lower down. A group is open by default when current or pinned; an explicit choice (a toggle, Alt+click, collapse others, expand all, a fork's reveal) wins and is saved. When another project becomes current (a session picked in its group), the group that was open only as the current one stays open for the page's life (`keepOutgoingGroupOpen()` into `keptOpenGroups`, page memory), so it does not fold up and move the clicked row from under the pointer. It is never saved: saved, every project ever visited came back open after a reload and the tree only grew. The tree, the toggles and the group menu's disabled states read `effectiveGroupExpansion()` (the saved choices over the kept ones); every write goes through `saveGroupChoices()`, which saves explicit choices only and drops the kept entries they cover (all of them for a choice made for every group). After a reload only the current project, pinned projects and groups opened on purpose are open. Each shows its 6 newest families plus any later one that is running, unread or selected, then "show more · N" (N still hidden). A click reveals `SHOW_MORE_STEP` (20) more, never the whole project: a project with hundreds of sessions would otherwise mount and scroll them all at once. Families that show anyway (running, unread, selected) do not use up a click, so each one adds exactly 20 rows or what is left. That is decided at the click, by position: the more row offers `nextShown` (the window past the base limit that 20 more hidden families need, counted with today's flags), `handleShowMore()` stores it, and the tree then shows every family before that position plus the flagged ones after it. A family that turns selected, unread or running inside the window keeps its place and frees nothing; counted as a budget on every render, picking the last revealed row (now selected, so it showed "anyway") let the next, older family in below it. Once a click has revealed rows, "show less" sits beside it and folds back to the base limit (it is not offered when folding would hide nothing); the more row is then scrolled back into view, since the list can shrink by hundreds of pixels under the pointer, as it is after a keyboard "show more" (the focused button moves 20 rows down). A keyboard user's focus moves to the button that remains, else to the group or pinned header; it waits in a ref until a commit has that target mounted. The counts live in `moreShown` (per project key, `PINNED_MORE_KEY` for the pinned section; session state, not saved). Folding drops a window: every explicit choice that folds a group (a toggle, Alt+click, collapse others) goes through `saveGroupChoices()`, which forgets the folded groups' counts (`forgetShownMoreOfFolded()`), and folding the pinned section forgets its own, so a group opened again shows its first rows (and the running, unread or selected ones) only. A collapsed group shows running/unread counts. There is no footer: another folder opens from the composer's project picker or the Files pane's, and the archive from the Conversations header.
36879
+ +- **The Conversations header** (`.sidebar-conversations-header`, above the tree and the archive view, not a row) names the list, as Claude's Recents, with its settings on the right (`SlidersVertical`, its centre on the session rows' ⋯: 24px from the edge, 28px on a phone; design/conversations-menu-reference.jpg is illustrative, only these options are taken). Its menu: **Group by** Project (the groups above) or None (`groupBy: "none"` in `buildSessionTree()`: the pinned section, then every live family in one list, newest first, `UNGROUPED_VISIBLE_LIMIT` (20) shown before its show more, `UNGROUPED_MORE_KEY`; each row is `context: "ungrouped"` and names its project in place of its time, as a pinned row does; no group headers, not even the current project's; with no chevron column to keep, `.is-ungrouped` rows and their show more start their text on the header's line, 11px in, and the meta ends 11px from the edge, a scrollbar's room included), kept per browser in `pi-web:sidebar-group-by` (read after mount); and **Show archived**, a switch for the archive view (`buildArchiveRows()`, grouped by project, newest `archivedAt` first; disabled while nothing is archived and the view is shut). Leaving the archive view puts focus back on the header's button.
36880
  - The look keeps a few rules; new rows follow them. **Three text colors**: content (session titles) `--text`, labels (project names, section labels) `--text-muted`, meta (times, counts, branches, a pinned row's project, "show more", the footer) `--text-dim`; no `color-mix` greys of their own (`SessionTree.test.mjs` lists every `color:` in `app/sidebar.css`: besides those three only the accent, the error red and the delete button's white). **No icons in the list**: no folder per project (it read as a second file tree, and the files section's header as one more project) and no pin before the pinned section; the only icons are the chevrons, a pinned project's 10px pin and the activity summary after its name, the branch icon in a row's branch, and the footer's two. **Two alignment lines**: the chevron column (12px, at the rows' left inset plus a toggle's 5px: 11px in; the inset is 6px in every browser, a classic scrollbar taking its room from the right, so the brand, the files section's header, the picker's paths and the file tree keep the same two lines) and the text column 20px in from the inset (26px) (5px + the 12px chevron + the toggle's 3px gap), where project names, section labels, session titles (`padding-left: 20px` in their inset box), "show more", the empty group's note (inset + 20px), the archive's project names and the footer's labels start (the footer's 12px icons sit in the chevron column); `SessionTree.test.mjs` adds it up from the CSS. **The accent is a state's, never a label's**: the selected row's bar, a run's spinner (no accent folder for the current project).
36881
  - Headers are section headers over their rows, not one more row: the chevron leads (`ChevronIcon size={12} strokeWidth={3}`: a 1.5px stroke, as firm as a bold label; `--text-dim`, `--text-muted` on the header's hover; inside the toggle and `aria-hidden`, so the whole header still toggles and its name is the project's), then the name 12px/600 in `--text-muted` (`--text` for the current project, which shows by that alone, or a hovered one), then a pinned project's pin (5px off the name) and, folded, the activity summary at the right. The pinned section's header is a section label as tall as a group's (28px, 44px on a phone, a session row's height): its chevron in the same column, "Pinned" 11px/600 in small capitals (`letter-spacing: 0.05em`, uppercase; `--text` on hover), the count " · N" 11px/400 dim, then the dots while folded; the files section's header below the sessions is the same label. The archive view's project headers are group headers (`session-tree-group session-tree-archive-group`, the same toggle, chevron and name, the current project in `--text`), with the count after the name in 400 `--text-dim`, folded or not, and ⋯ alone on hover (no "+", no pin, no drag: the archive keeps no order). Phones take 13px for titles and project names (`.session-tree.is-mobile`, the component's layout prop, never a media query). The drag ghost's name is 12px/600, as a header's.
36882
  -- Spacing: groups sit right on each other, open or folded (collapsed ones at a 28px pitch); a gap between two of them only pushed the next header away. An 8px spacer row parts the pinned section from the groups (`pinned-spacer`) and the groups from the footer (`footer-spacer`, only when there are groups; a pinned section with no group has the one spacer before the footer).
 
36906
  - Focus across the remount: the bar's control that moved the composer goes away with it, so AppShell keeps which one it was (`newSessionBarFocusRef`, set only when a move goes ahead) and the next bar focuses its counterpart once, if focus fell to the page. The request is used up on mount, so that bar must already have the control: after "New worktree…" the worktree button is there in its first commit only because of `newSessionMoveRef`. The folder picker gives focus back to the bar's button on Cancel and when its folder is refused. Any other new session (Ctrl+Alt+N, the sidebar's New, a group's "+", the header's +) puts the caret in the fresh composer instead, on desktop only (#1313): `handleNewSession` calls the new `ChatInputHandle.focus()` (the caret after a parked draft that came back, so typing continues it) from a `requestAnimationFrame`, once the `sessionKey` remount has mounted it, never when the bar holds the focus request (that flow keeps the bar's control), never while focus is in a modal dialog (`[aria-modal='true']`: Ctrl+Alt+N under Settings or a folder picker leaves it there) and never on a phone, where focus would raise the software keyboard uninvited, the picker's filter's rule since #1012.
36907
  -- The sidebar's top is one toolbar row in the cells of the chat's top bar beside it (AppShell): 36px with its line (border-box, as that bar is), so the two read as one bar. In the tabs layout the two tabs (an icon and a label each; only they are the tablist), then New and the search; the chosen tab and the open search take the bar's 2px accent line on top and `--bg-selected`, as the bar's open panels do. With the files below the sessions there is nothing to choose: the brand stands where the tabs were, the panel's typographic anchor as on the old sidebar (`components/PiWebTitle.tsx`, `.sidebar-brand`: "Pi Web" in code type, 15px/700, `--text`, a button with no box and no line on its right, its text in the chevron column: 7px margin and 4px padding), then New and the search, which is the sessions' alone there (`searchesFiles` false; the files section's header row has the files'). New draws a line on its left in both layouts (`.sidebar-new-button`): the brand is no cell, and in the tabs layout the spacer leaves a gap after the last tab at most widths. New and the search read as two cells, as the chat bar's right-end cell does; a -1px left margin lays the line over the last tab's where the spacer has collapsed and keeps New's footprint, so the fit levels are unchanged, and border-box keeps their 36px cells 36px. Its old easter egg came back with it: a click scrambles "Pi Web" into the versions (`<web>p<pi>`, `NEXT_PUBLIC_APP_VERSION` and `NEXT_PUBLIC_PI_VERSION`) in the accent, left to right over 4 frames a character, and they scramble back after 3 s (`VERSION_SHOWN_MS`, from the click) or at once on a second click; never on mount; under `prefers-reduced-motion` the text is swapped without the scramble. Its name (`sidebar.brandLabel`, "Pi Web, versions: web …, pi …") says what the click shows and never changes, so no scrambled text is read out. The brand is the stacked layout's only: the tabs layout keeps its tabs there, and the empty new-session page keeps its own, with the versions. There is no view menu: "View archived" is the tree's footer row, collapsing and expanding every group a group's ⋯ menu ("Collapse other projects", which keeps that one open, and "Expand all projects", each disabled when the groups already are so) and Alt+click on a group header (every group follows it, `setAllGroupsExpanded`). Labels give way where they do not fit (`useHeaderFit`): New keeps its +, then the tabs their icons, or below the sessions the versions take 11px (`data-fit="2"`, which only they reach there): the brand itself is never cut ("Pi Web" fits beside the two 36px cells at the 180px minimum, and so do the versions at 11px with a digit to spare; `PiWebTitle.test.mjs` adds it up). It measures rather than querying a fixed width, since the labels' widths change with the language (in the tabs layout, English needs New's + alone at the default 260px); `data-fit` on the row, set before paint and again when the row resizes or the layout, a label shown, the files count or the chosen tab (bolder) changes. The brand's text changes its width without the row resizing, so `PiWebTitle` calls the fit the hook returns (`onWidthChange`) from a layout effect on its text's length (a code font: length is width), before that paints; a `ResizeObserver` on the brand would see the fit change its type and loop. A hidden label is clipped, not removed, so it stays the button's name.
36908
  +- The sidebar's top is one toolbar row in the cells of the chat's top bar beside it (AppShell): 36px with its line (border-box, as that bar is), so the two read as one bar. In the tabs layout the two tabs (an icon and a label each; only they are the tablist); the chosen tab takes `--bg-selected`. With the files below the sessions there is nothing to choose: the brand stands where the tabs were, the panel's typographic anchor as on the old sidebar (`components/PiWebTitle.tsx`, `.sidebar-brand`: "Pi Web" in code type, 15px/700, `--text`, a button with no box and no line on its right, its text in the chevron column: 7px margin and 4px padding).
36909
+ +- Search and New are under that row, as in Claude Code's sidebar (design/sidebar-search-claude-reference.jpg; `.sidebar-actions`): a field-like button the rows' width (10px in on each side, 28px, the rows' radius, a hairline and a faint fill from the text colour) that opens the search of the tab in view (the conversations' command bar; on the tabs layout's Files tab with a folder, the files' search, `searchesFiles`), then New in a row of its own (`.sidebar-new-button`, kept for upstream's e2e): a button as wide and tall as the field, with a faint fill and no hairline, and a plain 14px plus (no disc). The magnifier and the plus are centred on one column (25px from the sidebar's edge, 15px into the field as in the reference) and the placeholder and New's label start on another (43px). Its accessible name stays "Search conversations"; the visible placeholder is "Search". Closing the command bar puts focus in the composer, not back on the field: so it did from the old header icon too. Its old easter egg came back with it: a click scrambles "Pi Web" into the versions (`<web>p<pi>`, `NEXT_PUBLIC_APP_VERSION` and `NEXT_PUBLIC_PI_VERSION`) in the accent, left to right over 4 frames a character, and they scramble back after 3 s (`VERSION_SHOWN_MS`, from the click) or at once on a second click; never on mount; under `prefers-reduced-motion` the text is swapped without the scramble. Its name (`sidebar.brandLabel`, "Pi Web, versions: web …, pi …") says what the click shows and never changes, so no scrambled text is read out. The brand is the stacked layout's only: the tabs layout keeps its tabs there, and the empty new-session page keeps its own, with the versions. There is no view menu: "Show archived" is in the Conversations menu, collapsing and expanding every group a group's ⋯ menu ("Collapse other projects", which keeps that one open, and "Expand all projects", each disabled when the groups already are so) and Alt+click on a group header (every group follows it, `setAllGroupsExpanded`). Labels give way where they do not fit (`useHeaderFit`): the tabs keep their icons, or below the sessions the versions take 11px (`data-fit="2"`, which only they reach there): the brand itself is never cut ("Pi Web" fits at the 180px minimum, and so do the versions at 11px; `PiWebTitle.test.mjs` adds it up). It measures rather than querying a fixed width, since the labels' widths change with the language `data-fit` on the row, set before paint and again when the row resizes or the layout, a label shown, the files count or the chosen tab (bolder) changes. The brand's text changes its width without the row resizing, so `PiWebTitle` calls the fit the hook returns (`onWidthChange`) from a layout effect on its text's length (a code font: length is width), before that paints; a `ResizeObserver` on the brand would see the fit change its type and loop. A hidden label is clipped, not removed, so it stays the button's name.
36910
  - Two layouts, one element tree. Where the files go is the desktop's choice (Settings › General › File browser, `useFilesPlacement()`, see settings-ui.md): below the sessions (the default) or in a tab of their own; phones always take the tabs (`stacked = !isMobile && placement === "below"`; the server and hydration render the stacked layout, both hooks' server snapshots). Both layouts render the same containers in the same order (the toolbar row, the sessions panel, the files panel and its body `#session-sidebar-files-body`, the menu, the toast): only attributes differ, and each layout's own rows are conditional siblings in fixed slots (`{stacked && …}` / `{!stacked && …}`: the brand or the tablist, the separator, the files section's header row, the files tab's row of keys). So a Settings change, the window crossing the phone breakpoint or a fold never remounts `FileExplorer` (its open folders, a search, an upload), the session tree (its scroll) or the picker. Never wrap a panel differently per layout, and never mount the explorer on anything but its cwd (`explorerOpen && <FileExplorer>`, before the tabs, lost all three on a fold).
36911
  - Tabs: "Sessions | Files" both stay mounted (`role="tabpanel"`, `hidden` on the inactive one), as before the stacked layout. Below the sessions: both panels are `role="region"` (named "Sessions" and "Files"), always shown, in one column (`.session-sidebar.is-files-below`). The sessions fill what the files leave, at least 120px (`SESSIONS_SECTION_MIN_HEIGHT`, the CSS `min-height`); then the separator; then the files section, as tall as `--sidebar-files-height`, set on the root by `useResizablePanel` (vertical, grows up; `pi-web:sidebar-files-height`, never the retired `pi-web:sidebar-session-pane-height`; `persistClamp: false`, so a window too short for the user's size shows less without saving it; only a drag that moved, a key or a reset saves, and grow and End start from the saved size, never lowering it): at least 220px (its header row, the picker's row, about six tree rows), at most the sidebar under its toolbar row less the sessions' minimum, 45% of that by default (320px until measured). A sidebar too short for both (a landscape phone wider than the breakpoint, a short window) shrinks the files section in CSS, down to its header row and line (29px, 37px on a coarse pointer): the sessions' zero `flex-basis` freezes them at their minimum, so the files take the whole shortfall and never run past the root, which would clip them. A `ResizeObserver` on the root fits it again while stacked: the window is not the only thing that changes the sidebar's height. The separator is the hook's `separatorProps` (arrows, Home/End, Enter or a double-click to reset) on a 12px hit area centred on the files' top border, which is the line it shows (`.panel-resize-handle.sidebar-files-resize-handle`: the side handles' look on its side), only while the section is open. The section's first row (`.sidebar-files-section`, 28px, 36px on a coarse pointer; stacked only) is all of the files' chrome above the picker: a section header as the pinned section's is, then, 2px after it, the files' keys (flush, no gap between them). The toggle: its 12px chevron in the chevron column the session tree and the file tree share (6px in plus the toggle's 5px), "Files" in the text column in the pinned label's small capitals (11px/600, `--text-muted`, `--text` on hover), then while something is changed the count as the pinned section's (" · N", 11px/400 `--text-dim`, `aria-hidden`: the changes key's name says it; the toggle's title is "N changed files"), `aria-expanded` and `aria-controls` on the body; no folder (it read as one more project). The keys are a group of their own (`.sidebar-files-keys`, `role="group"`, "File actions"), at the row's right end on the session times' and the boxes' edge (`--sidebar-files-inset-right` on `.session-sidebar.is-files-below`: `max(10px, the session tree's scrollbar + 4px)`, the root's `--session-tree-scrollbar`; 10px in beside an overlay or a coarse pointer's 6px scrollbar, 14px beside Chromium's 10px), in the Files tab's order with the search last: seven keys, always rendered and disabled without a folder (the terminal also without `onOpenTerminal`, which the files tab leaves out instead), so none ever moves: a terminal in the folder, the file manager (the tab's disabled reason and title; its failure shows in the section's body, so a failure opens a folded section: `openInFileManager()` resolves true when it failed, else the `role="alert"` would wait unseen in a hidden body until some later unfold), upload (a folded section opens first: the upload's progress and summary are the tree's), refresh (✓ for 2 s), the ignored-files switch (`aria-pressed`), the changes view (disabled while nothing is changed, `aria-pressed` while the changes show, named "N changed files"; on a folded section it opens it on the changes), the file search (`aria-expanded`, `aria-controls="file-search-input"`; on a folded section it opens the section with the field open and focused). They are the files tab's elements (`terminalButton`, `fileManagerButton`, …, `changesButton`, built once, rendered in one layout's place at a time; the search is this row's own, the tab's being the toolbar row's) and `.sidebar-tool-button`s with 13px icons (14px in the tab's row), 26px high, 6px corners, sharing what the toggle's content leaves: `flex: 1 1 0`, 18px to 26px wide (their group `flex: 1000 0 calc(7 * 18px)`, `max-width: calc(7 * 26px)`, so it takes the room before the toggle grows), about 24px at the default 260px beside "文件 · 2", 26px from about 300px; only with the keys at 18px does the toggle give way (its label, then its count, wrap out of view whole, down to the chevron). On a coarse pointer they are 32px and come before the label, as before. No count chip any more: it came and went and moved its neighbours. A key that turns disabled under the focus loses it: current Chromium (154) keeps it on the key until its next rendering update, after the commit's layout effects, and then blurs it to the page with `relatedTarget` null, which clears the root's note (`sidebarFocusRef`), so nothing would try again (older builds blur at once). So when a commit or a revert disables the changes key (either layout) while the note names it, a layout effect hands the focus to the nearest key still enabled, the next first (`nearestEnabledKey()`: the file search here, the ignored-files switch in the tab), else the toolbar row's search, when the focus is still on the key or fell to the page (`handOffDisabledKeyFocus()`); focus the user put elsewhere stays. The toggle has a zero basis, clips its overflow and keeps its chevron (`min-width: 17px`), so its label gives way first, then the count, and only then do the keys narrow together: at the default 260px beside Chromium's 10px scrollbar the keys keep 22px and the toggle 83px, room for "Files · 123" (82px in Chromium's 11px system font); at the 180px minimum (159px inside the row's padding) the keys still get 20px each (`SessionSidebar.test.mjs` adds both up from the CSS). On a coarse pointer the 32px keys leave the toggle only its chevron at the default width: touch keys over the label. Folding hides the body (`hidden`, nothing unmounts) and the separator: the row alone stays, its keys and count with it, at the bottom (`height: 29px`, 37px on a coarse pointer: a length, not `auto`, so the fold can move to it), and the sessions take the rest. A fold and an unfold move (`useSidebarMotion()`, `filesFoldState()`): while one runs `.is-files-folding` on the root puts `transition: height 180ms ease-out` on the files panel (and on the toast's `bottom`), so its height goes between `--sidebar-files-height` and the row's and the sessions' zero basis follows frame by frame. Only `setFilesSectionCollapsed()` starts it, and only on a change: the toggle, `revealFiles()`, the folded section's search, changes and upload keys and a file manager error move; a drag, keys, a window resize, a layout switch (the class needs `stacked`) and the restore after hydration (`setFilesCollapsed` directly) never do. Unfolding shows the body first and grows it; folding keeps it shown, `inert`, to the end, then `hidden`. The separator comes back only once the files are still (`filesFold.open`), so a drag never meets the transition. The phase ends on the panel's own `transitionend` for `height` (the toggle chevron's bubbles there too), else 300 ms after its start; a second click halfway turns it back from where it is. The session tree re-reads its viewport on every resize (its `ResizeObserver` on the scroll box), so no rows are missing during or after the move. Under `prefers-reduced-motion` no phase starts and the CSS has no transition: the fold is instant, as before. The fold is per browser (`pi-web:sidebar-files-collapsed`, `lib/sidebar-prefs.ts`). Below the sessions `switchTab()` does nothing (the archive view, the search's fallback), so the tabs layout and phones get the user's last tab back.
36912
  -- Everything that goes to the files (a group menu's "Open in Files", the footer's "Open another project…", the folded section's search) goes through `revealFiles(focusTarget)`: the files tab, or the files section opened (and saved so). Its focus effect waits for the files to show and runs on a request counter too, since below the sessions they may show already: the project button takes the focus, its menu opens, or the file search field takes it. An unfolding section shows once it is done (`filesShown` is `filesFold.open` there): until then its project button is still under the sidebar's edge, where the project menu would be placed. Focus fallbacks (`selectedTabButton()`, behind `familyRowButton`, `groupHeaderButton` and the archive's Back) take the selected tab, or with no tab mounted the toolbar row's search button (always there, never disabled), so focus never falls to `<body>`. A live layout switch (the setting from another window, the phone breakpoint) removes the controls one layout has (the tabs, the files tab's keys; the brand, the separator, the files section's header row and its keys) and may hide a panel: focus that was on one goes there too (the root notes the sidebar control focus is on, `sidebarFocusRef`, forgotten once focus leaves the sidebar, so focus elsewhere stays; a blur with no `relatedTarget` while `document.hasFocus()` is false is the window losing focus, which keeps it, since the setting from another window arrives just then). The toast is one live region at the root in both layouts; below the files, CSS lifts it to the bottom of the sessions (`calc(var(--sidebar-files-height) + 8px)`, 37px over a folded section, 45px on a coarse pointer), so it never covers the file tree.