Spaces:
Paused
Paused
Pi Web fork 1b39ee7: full-width New, ungrouped rows aligned
Browse files- 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..
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10814 |
background: var(--bg-selected);
|
| 10815 |
}
|
| 10816 |
|
|
@@ -10846,7 +10860,7 @@ index 0b8f66a..c70f0e7 100644
|
|
| 10846 |
cursor: default;
|
| 10847 |
}
|
| 10848 |
|
| 10849 |
-
@@ -255,14 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 10927 |
align-items: center;
|
| 10928 |
}
|
| 10929 |
|
|
@@ -10951,7 +10965,7 @@ index 0b8f66a..c70f0e7 100644
|
|
| 10951 |
color: var(--text-dim);
|
| 10952 |
}
|
| 10953 |
|
| 10954 |
-
@@ -408,11 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 |
-
@@ -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 11262 |
align-items: stretch;
|
| 11263 |
}
|
| 11264 |
|
|
@@ -11340,7 +11365,7 @@ index 0b8f66a..c70f0e7 100644
|
|
| 11340 |
}
|
| 11341 |
|
| 11342 |
.sidebar-tab-count {
|
| 11343 |
-
@@ -1019,27 +
|
| 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 +
|
| 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
|
| 11406 |
-
+
|
| 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 |
-
+/*
|
| 11469 |
-
+
|
| 11470 |
.sidebar-new-button {
|
| 11471 |
- font-weight: 500;
|
| 11472 |
-
+ gap:
|
| 11473 |
-
+ padding: 0 0 0
|
| 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
|
| 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
|
| 11503 |
-
+ background: var(--text
|
|
|
|
| 11504 |
}
|
| 11505 |
|
| 11506 |
.sidebar-new-button:disabled {
|
| 11507 |
-
@@ -1070,16 +1183,
|
| 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 |
-
|
| 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 +
|
| 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 +
|
| 11545 |
}
|
| 11546 |
|
| 11547 |
.sidebar-new-button:focus-visible,
|
|
@@ -11560,7 +11569,7 @@ index 0b8f66a..c70f0e7 100644
|
|
| 11560 |
}
|
| 11561 |
|
| 11562 |
.sidebar-panel {
|
| 11563 |
-
@@ -1186,7 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 11649 |
}
|
| 11650 |
}
|
| 11651 |
|
|
@@ -11708,7 +11717,7 @@ index 0b8f66a..c70f0e7 100644
|
|
| 11708 |
.sidebar-archive-bar {
|
| 11709 |
display: flex;
|
| 11710 |
flex: none;
|
| 11711 |
-
@@ -1397,7 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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..
|
| 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"[^>]*><
|
| 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..
|
| 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
|
| 29769 |
-
+
|
| 29770 |
-
+
|
| 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 |
-
+ <
|
| 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..
|
| 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,
|
| 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
|
| 30105 |
-
+ // button's white
|
| 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"
|
| 30111 |
}
|
| 30112 |
assert.doesNotMatch(css, /color: color-mix\(in srgb, var\(--text\)/);
|
| 30113 |
});
|
| 30114 |
-
@@ -514,8 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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..
|
| 30202 |
--- a/components/SessionTree.tsx
|
| 30203 |
+++ b/components/SessionTree.tsx
|
| 30204 |
-
@@ -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 +
|
| 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 |
-
@@ -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 +
|
| 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 |
-
@@ -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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..
|
| 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):
|
| 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.
|