Skip to content

Commit 32c82a5

Browse files
committed
feat(web): desktop sidebar, collapse choreography, and empty-state halo
Port the reference desktop sidebar 1:1: workspaces header with search/filter/new actions, inset session rows, settings footer, a 90px collapsed icon rail that hosts the macOS traffic lights, and the phased fade-swap-slide collapse animation. Add a breathing halo and entry animation to the empty conversation state.
1 parent 37b3cd5 commit 32c82a5

6 files changed

Lines changed: 653 additions & 104 deletions

File tree

apps/pythinker-web/src/App.vue

Lines changed: 151 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ onMounted(() => {
162162
onUnmounted(() => {
163163
document.removeEventListener('keydown', onGlobalKeydown, true);
164164
stopSpinner();
165+
clearTimeout(sidebarSwapTimer);
165166
});
166167
167168
// Escape closes whichever transient right-side detail panel is open.
@@ -195,10 +196,12 @@ const SIDEBAR_COLLAPSED_KEY = 'pythinker-web.sidebar-collapsed';
195196
const SIDEBAR_DEFAULT = 270;
196197
const SIDEBAR_MIN = 170;
197198
const SIDEBAR_MAX = 420;
198-
const SIDEBAR_COLLAPSED_WIDTH = 36;
199+
const SIDEBAR_COLLAPSED_WIDTH = 90;
199200
200201
const sessionColWidth = ref(SIDEBAR_DEFAULT);
201202
const sidebarCollapsed = ref(false);
203+
const railVisible = ref(false);
204+
let sidebarSwapTimer: ReturnType<typeof setTimeout> | undefined;
202205
const sideWidth = computed(() =>
203206
sidebarCollapsed.value ? SIDEBAR_COLLAPSED_WIDTH : sessionColWidth.value,
204207
);
@@ -209,6 +212,7 @@ function loadSidebarCollapsed(): void {
209212
} catch {
210213
sidebarCollapsed.value = false;
211214
}
215+
railVisible.value = sidebarCollapsed.value;
212216
}
213217
214218
function saveSidebarCollapsed(): void {
@@ -219,9 +223,23 @@ function saveSidebarCollapsed(): void {
219223
}
220224
}
221225
222-
function toggleSidebarCollapse(): void {
223-
sidebarCollapsed.value = !sidebarCollapsed.value;
226+
function setSidebarCollapsed(collapsed: boolean): void {
227+
sidebarCollapsed.value = collapsed;
224228
saveSidebarCollapsed();
229+
clearTimeout(sidebarSwapTimer);
230+
sidebarSwapTimer = setTimeout(() => {
231+
railVisible.value = sidebarCollapsed.value;
232+
}, 150);
233+
}
234+
235+
function toggleSidebarCollapse(): void {
236+
setSidebarCollapsed(!sidebarCollapsed.value);
237+
}
238+
239+
async function expandAndSearch(): Promise<void> {
240+
setSidebarCollapsed(false);
241+
await nextTick();
242+
void sidebarRef.value?.openSearch();
225243
}
226244
227245
// ---------------------------------------------------------------------------
@@ -565,6 +583,7 @@ watch(client.activeSessionId, () => {
565583
});
566584
567585
// Reference to ConversationPane so we can imperatively switch tabs
586+
const sidebarRef = ref<InstanceType<typeof Sidebar> | null>(null);
568587
const conversationPaneRef = ref<InstanceType<typeof ConversationPane> | null>(null);
569588
570589
// Shift-multi-selected workspace ids; when >1 are selected the main pane
@@ -872,13 +891,17 @@ function openPr(url: string): void {
872891
<div
873892
v-else
874893
class="app"
875-
:class="{ mobile: isMobile, 'sidebar-collapsed': sidebarCollapsed && !isMobile }"
894+
:class="{
895+
mobile: isMobile,
896+
'sidebar-collapsed': sidebarCollapsed && !isMobile,
897+
'rail-visible': railVisible && !isMobile,
898+
}"
876899
:style="{ '--side-w': sideWidth + 'px', '--preview-w': previewWidth + 'px' }"
877900
>
878901
<!-- Desktop navigation: workspace rail + resizable session column. -->
879902
<template v-if="!isMobile">
880903
<Sidebar
881-
v-show="!sidebarCollapsed"
904+
ref="sidebarRef"
882905
:col-width="sessionColWidth"
883906
:active-workspace="client.visibleWorkspace.value"
884907
:active-workspace-id="client.activeWorkspaceId.value"
@@ -910,19 +933,63 @@ function openPr(url: string): void {
910933
:max="SIDEBAR_MAX"
911934
@update:width="sessionColWidth = $event"
912935
/>
913-
<div v-if="sidebarCollapsed" class="sidebar-rail">
936+
<div class="sidebar-rail">
914937
<button
915938
type="button"
916-
class="sidebar-expand-btn"
939+
class="rail-btn rail-logo"
917940
:title="t('sidebar.expandSidebar')"
918941
:aria-label="t('sidebar.expandSidebar')"
919942
@click="toggleSidebarCollapse"
920943
>
921-
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
922-
<path d="M4 6h9" />
923-
<path d="M4 12h9" />
924-
<path d="M4 18h9" />
925-
<path d="M17 9l3 3-3 3" />
944+
<PythinkerLogo size="sm" />
945+
</button>
946+
<button
947+
type="button"
948+
class="rail-btn"
949+
:title="t('sidebar.newSession')"
950+
:aria-label="t('sidebar.newSession')"
951+
@click="handleCreateSession"
952+
>
953+
<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
954+
<circle cx="8" cy="8" r="6.5" />
955+
<path d="M8 5v6M5 8h6" />
956+
</svg>
957+
</button>
958+
<button
959+
type="button"
960+
class="rail-btn"
961+
:title="t('sidebar.newWorkspace')"
962+
:aria-label="t('sidebar.newWorkspace')"
963+
@click="showAddWorkspace = true"
964+
>
965+
<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
966+
<path d="M1 4V2.5A1 1 0 0 1 2 1.5h3.5l1.3 2H13a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z" />
967+
<path d="M1 5.5h13M10.5 8v3M9 9.5h3" />
968+
</svg>
969+
</button>
970+
<button
971+
type="button"
972+
class="rail-btn"
973+
:title="t('sidebar.searchSessions')"
974+
:aria-label="t('sidebar.searchSessions')"
975+
@click="expandAndSearch"
976+
>
977+
<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
978+
<circle cx="7" cy="7" r="4.5" />
979+
<path d="m10.5 10.5 3 3" />
980+
</svg>
981+
</button>
982+
<div class="rail-spacer" />
983+
<button
984+
type="button"
985+
class="rail-btn"
986+
:title="t('settings.title')"
987+
:aria-label="t('settings.title')"
988+
@click="showSettings = true"
989+
>
990+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
991+
<circle cx="12" cy="12" r="3" />
992+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z" />
926993
</svg>
927994
</button>
928995
</div>
@@ -1347,6 +1414,7 @@ function openPr(url: string): void {
13471414
when closed) — opening animates the aside's width, so the conversation
13481415
column is squeezed over smoothly instead of snapping to a new template. */
13491416
grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
1417+
transition: grid-template-columns 0.3s cubic-bezier(0.4, 0, 0.2, 1);
13501418
background: var(--bg);
13511419
color: var(--ink);
13521420
overflow: hidden;
@@ -1375,22 +1443,62 @@ function openPr(url: string): void {
13751443
min-height: 0;
13761444
min-width: 0;
13771445
}
1446+
.app > .side {
1447+
grid-column: 1;
1448+
grid-row: 1;
1449+
overflow: hidden;
1450+
}
1451+
1452+
/* Which layer is shown — swapped 150ms after the toggle. */
1453+
:global(.app.rail-visible > .side) {
1454+
visibility: hidden;
1455+
pointer-events: none;
1456+
}
1457+
:global(.app:not(.rail-visible) .sidebar-rail) {
1458+
visibility: hidden;
1459+
pointer-events: none;
1460+
}
13781461
1379-
/* Collapsed sidebar rail: keeps a slim, dedicated grid track so the expand
1380-
button never overlaps the conversation header or squeezes the main pane. */
1462+
/* Phase 1a: collapsing — expanded content's children fade out. */
1463+
:global(.app.sidebar-collapsed:not(.rail-visible) > .side .col > *) {
1464+
opacity: 0;
1465+
transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
1466+
}
1467+
/* Phase 1b: expanding — rail's children fade out. */
1468+
:global(.app:not(.sidebar-collapsed).rail-visible .sidebar-rail > *) {
1469+
opacity: 0;
1470+
transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
1471+
}
1472+
/* Phase 2a: rail items animate in (collapse direction). */
1473+
:global(.app.sidebar-collapsed.rail-visible .sidebar-rail > .rail-btn) {
1474+
animation: rail-in 0.15s cubic-bezier(0.4, 0, 0.2, 1) backwards;
1475+
}
1476+
/* Phase 2b: sidebar content animates back in (expand direction). */
1477+
:global(.app:not(.sidebar-collapsed):not(.rail-visible) > .side .col > *) {
1478+
animation: content-in 0.15s cubic-bezier(0.4, 0, 0.2, 1) backwards;
1479+
}
1480+
@keyframes rail-in { 0% { opacity: 0; transform: translateX(66px); } }
1481+
@keyframes content-in { 0% { opacity: 0; } }
1482+
1483+
/* Collapsed sidebar rail: keeps a dedicated grid track for the icon rail. */
13811484
.sidebar-rail {
13821485
grid-column: 1;
1486+
grid-row: 1;
13831487
display: flex;
1384-
justify-content: center;
1385-
padding-top: 8px;
1488+
flex-direction: column;
1489+
align-items: center;
1490+
gap: 6px;
1491+
padding: 10px 0 10px;
13861492
background: var(--panel);
13871493
border-right: 1px solid var(--line);
1494+
min-height: 0;
13881495
}
1389-
.sidebar-expand-btn {
1496+
.rail-spacer { flex: 1; }
1497+
.rail-btn {
13901498
flex: none;
1391-
width: 28px;
1392-
height: 28px;
1393-
border-radius: 6px;
1499+
width: 36px;
1500+
height: 36px;
1501+
border-radius: 8px;
13941502
background: none;
13951503
border: none;
13961504
color: var(--muted);
@@ -1400,14 +1508,18 @@ function openPr(url: string): void {
14001508
cursor: pointer;
14011509
padding: 0;
14021510
}
1403-
.sidebar-expand-btn:hover {
1404-
background: var(--soft);
1405-
color: var(--ink);
1406-
}
1407-
.sidebar-expand-btn:focus-visible {
1511+
.rail-btn:hover { background: var(--soft); color: var(--ink); }
1512+
.rail-btn:focus-visible {
14081513
outline: 2px solid var(--blue);
14091514
outline-offset: -2px;
14101515
}
1516+
:global(html[data-desktop-platform='darwin'] .sidebar-rail) {
1517+
padding-top: 48px;
1518+
-webkit-app-region: drag;
1519+
}
1520+
:global(html[data-desktop-platform='darwin'] .sidebar-rail button) {
1521+
-webkit-app-region: no-drag;
1522+
}
14111523
14121524
/* The collapsed rail occupies track 1; keep the main pane pinned to the
14131525
conversation track even though the sidebar/handle are display:none. */
@@ -1423,6 +1535,20 @@ function openPr(url: string): void {
14231535
grid-template-rows: auto 1fr;
14241536
}
14251537
1538+
@media (prefers-reduced-motion: reduce) {
1539+
.app { transition: none; }
1540+
.app > .side,
1541+
.sidebar-rail { transition: none; }
1542+
:global(.app.sidebar-collapsed:not(.rail-visible) > .side .col > *),
1543+
:global(.app:not(.sidebar-collapsed).rail-visible .sidebar-rail > *) {
1544+
transition: none;
1545+
}
1546+
:global(.app.sidebar-collapsed.rail-visible .sidebar-rail > .rail-btn),
1547+
:global(.app:not(.sidebar-collapsed):not(.rail-visible) > .side .col > *) {
1548+
animation: none;
1549+
}
1550+
}
1551+
14261552
/* The right-side panel column: a permanent grid item whose width animates
14271553
0 ↔ var(--preview-w). The CONTENT keeps a fixed width (and carries the
14281554
left hairline) so it clips during the transition instead of reflowing. */

apps/pythinker-web/src/components/ConversationPane.vue

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,7 @@ defineExpose({ loadComposerForEdit });
842842
<!-- Empty session: Composer rendered in the centre of the pane -->
843843
<div class="empty-spacer" />
844844
<div class="empty-hint">
845+
<div class="empty-halo" aria-hidden="true" />
845846
<div class="empty-hint-head">
846847
<span class="empty-mascot" aria-hidden="true" />
847848
<span class="empty-hint-title">{{ t('composer.emptyConversationTitle') }}</span>
@@ -1290,6 +1291,29 @@ defineExpose({ loadComposerForEdit });
12901291
padding: 0 16px 16px;
12911292
color: var(--ink);
12921293
font-family: var(--sans);
1294+
position: relative;
1295+
}
1296+
.empty-halo {
1297+
position: absolute;
1298+
top: -140px;
1299+
left: 50%;
1300+
width: 520px;
1301+
height: 420px;
1302+
transform: translateX(-50%);
1303+
pointer-events: none;
1304+
background: radial-gradient(
1305+
ellipse 60% 55% at 50% 45%,
1306+
color-mix(in srgb, var(--blue) 22%, transparent) 0%,
1307+
color-mix(in srgb, var(--blue) 10%, transparent) 40%,
1308+
transparent 72%
1309+
);
1310+
filter: blur(2px);
1311+
animation: halo-breathe 6s ease-in-out infinite;
1312+
z-index: 0;
1313+
}
1314+
.empty-hint > *:not(.empty-halo) {
1315+
position: relative;
1316+
z-index: 1;
12931317
}
12941318
.empty-hint-head {
12951319
display: flex;
@@ -1326,6 +1350,18 @@ defineExpose({ loadComposerForEdit });
13261350
text-overflow: ellipsis;
13271351
white-space: nowrap;
13281352
}
1353+
@keyframes halo-breathe {
1354+
0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); }
1355+
50% { opacity: 0.9; transform: translateX(-50%) scale(1.06); }
1356+
}
1357+
.empty-hint-head, .empty-hint-text { animation: hero-rise 0.5s ease-out both; }
1358+
@keyframes hero-rise {
1359+
from { opacity: 0; transform: translateY(6px); }
1360+
to { opacity: 1; transform: translateY(0); }
1361+
}
1362+
@media (prefers-reduced-motion: reduce) {
1363+
.empty-halo, .empty-hint-head, .empty-hint-text { animation: none; }
1364+
}
13291365
.empty-add-workspace {
13301366
display: inline-flex;
13311367
align-items: center;

apps/pythinker-web/src/components/SessionRow.vue

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,15 +225,17 @@ defineExpose({ closeMenu, cancelArchive });
225225

226226
<style scoped>
227227
.se {
228-
/* --sb-* vars come from .side in Sidebar.vue: the title starts at
229-
--sb-pad-x + --sb-gutter + --sb-gap, exactly under the workspace name. */
228+
/* --sb-* vars come from .side in Sidebar.vue. The outer margin and reduced
229+
inner padding keep the title at --sb-pad-x + --sb-gutter + --sb-gap. */
230230
display: block;
231-
padding: 7px var(--sb-pad-x, 12px);
231+
margin: 0 8px;
232+
padding: 7px calc(var(--sb-pad-x, 12px) - 8px);
233+
border-radius: 8px;
232234
cursor: pointer;
233235
position: relative;
234236
}
235237
.se:hover { background: var(--panel2); }
236-
.se.on { background: color-mix(in srgb, var(--blue) 7%, transparent); }
238+
.se.on { background: var(--soft); }
237239
238240
.row {
239241
display: flex;
@@ -351,7 +353,7 @@ defineExpose({ closeMenu, cancelArchive });
351353
352354
.menu {
353355
position: absolute;
354-
right: 10px;
356+
right: 2px;
355357
top: 30px;
356358
background: var(--bg);
357359
border: 1px solid var(--line);

0 commit comments

Comments
 (0)