diff --git a/frontend/package.json b/frontend/package.json
index 854240cea..e92fcd403 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -3,11 +3,13 @@
"private": true,
"version": "0.0.0",
"type": "module",
+ "packageManager": "pnpm@11.3.0",
"scripts": {
"dev": "vite",
"build:dev": "tsc && vite build --minify false --mode development",
"build": "tsc && vite build --mode production",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
@@ -32,6 +34,7 @@
"@vitejs/plugin-react": "^6.0.0",
"tailwindcss": "^4.3.2",
"typescript": "^5.2.2",
- "vite": "^8.0.5"
+ "vite": "^8.0.5",
+ "vitest": "^4.1.10"
}
}
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 0eada4c53..c870e2307 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -72,6 +72,9 @@ importers:
vite:
specifier: ^8.0.5
version: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0))
packages:
@@ -427,6 +430,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@tailwindcss/node@4.3.2':
resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==}
@@ -527,6 +533,15 @@ packages:
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
'@types/node@26.1.0':
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
@@ -557,6 +572,35 @@ packages:
babel-plugin-react-compiler:
optional: true
+ '@vitest/expect@4.1.10':
+ resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
+
+ '@vitest/mocker@4.1.10':
+ resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.10':
+ resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
+
+ '@vitest/runner@4.1.10':
+ resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
+
+ '@vitest/snapshot@4.1.10':
+ resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
+
+ '@vitest/spy@4.1.10':
+ resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
+
+ '@vitest/utils@4.1.10':
+ resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
+
'@wailsio/runtime@3.0.0-alpha.97':
resolution: {integrity: sha512-alvEG6B0YqW7AcBcNaUWNMD9q8JWqx5AO6BPOZpijBeq4ZjhQxSom3jvvq8Z1mWSMKPEjSvlFirKIh9NLI3ajA==}
@@ -598,6 +642,10 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
@@ -655,6 +703,10 @@ packages:
caniuse-lite@1.0.30001802:
resolution: {integrity: sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -842,6 +894,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
+ es-module-lexer@2.3.0:
+ resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
+
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
@@ -858,6 +913,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
@@ -878,6 +936,10 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
express-rate-limit@8.5.2:
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
engines: {node: '>= 16'}
@@ -1359,6 +1421,10 @@ packages:
resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==}
engines: {node: '>= 10'}
+ obug@2.1.3:
+ resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
+ engines: {node: '>=12.20.0'}
+
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -1432,6 +1498,9 @@ packages:
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1613,6 +1682,9 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -1631,10 +1703,16 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ std-env@4.1.0:
+ resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+
stdin-discarder@0.2.2:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
@@ -1686,10 +1764,21 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.2.4:
+ resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -1804,6 +1893,47 @@ packages:
yaml:
optional: true
+ vitest@4.1.10:
+ resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.10
+ '@vitest/browser-preview': 4.1.10
+ '@vitest/browser-webdriverio': 4.1.10
+ '@vitest/coverage-istanbul': 4.1.10
+ '@vitest/coverage-v8': 4.1.10
+ '@vitest/ui': 4.1.10
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -1814,6 +1944,11 @@ packages:
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -2230,6 +2365,8 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
+ '@standard-schema/spec@1.1.0': {}
+
'@tailwindcss/node@4.3.2':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -2309,6 +2446,15 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/estree@1.0.9': {}
+
'@types/node@26.1.0':
dependencies:
undici-types: 8.3.0
@@ -2331,6 +2477,47 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)
+ '@vitest/expect@4.1.10':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0))':
+ dependencies:
+ '@vitest/spy': 4.1.10
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)
+
+ '@vitest/pretty-format@4.1.10':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.10':
+ dependencies:
+ '@vitest/utils': 4.1.10
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.10':
+ dependencies:
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/utils': 4.1.10
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.10': {}
+
+ '@vitest/utils@4.1.10':
+ dependencies:
+ '@vitest/pretty-format': 4.1.10
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
'@wailsio/runtime@3.0.0-alpha.97': {}
accepts@2.0.0:
@@ -2361,6 +2548,8 @@ snapshots:
argparse@2.0.1: {}
+ assertion-error@2.0.1: {}
+
ast-types@0.16.1:
dependencies:
tslib: 2.8.1
@@ -2421,6 +2610,8 @@ snapshots:
caniuse-lite@1.0.30001802: {}
+ chai@6.2.2: {}
+
chalk@5.6.2: {}
class-variance-authority@0.7.1:
@@ -2561,6 +2752,8 @@ snapshots:
es-errors@1.3.0: {}
+ es-module-lexer@2.3.0: {}
+
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
@@ -2571,6 +2764,10 @@ snapshots:
esprima@4.0.1: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
etag@1.8.1: {}
eventsource-parser@3.1.0: {}
@@ -2606,6 +2803,8 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
+ expect-type@1.4.0: {}
+
express-rate-limit@8.5.2(express@5.2.1):
dependencies:
express: 5.2.1
@@ -2999,6 +3198,8 @@ snapshots:
object-treeify@1.1.33: {}
+ obug@2.1.3: {}
+
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -3077,6 +3278,8 @@ snapshots:
path-to-regexp@8.4.2: {}
+ pathe@2.0.3: {}
+
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -3327,6 +3530,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ siginfo@2.0.0: {}
+
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -3337,8 +3542,12 @@ snapshots:
source-map@0.6.1: {}
+ stackback@0.0.2: {}
+
statuses@2.0.2: {}
+ std-env@4.1.0: {}
+
stdin-discarder@0.2.2: {}
string-width@7.2.0:
@@ -3377,11 +3586,17 @@ snapshots:
tiny-invariant@1.3.3: {}
+ tinybench@2.9.0: {}
+
+ tinyexec@1.2.4: {}
+
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
+ tinyrainbow@3.1.0: {}
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -3449,6 +3664,33 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
+ vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)):
+ dependencies:
+ '@vitest/expect': 4.1.10
+ '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0))
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/runner': 4.1.10
+ '@vitest/snapshot': 4.1.10
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ es-module-lexer: 2.3.0
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ obug: 2.1.3
+ pathe: 2.0.3
+ picomatch: 4.0.5
+ std-env: 4.1.0
+ tinybench: 2.9.0
+ tinyexec: 1.2.4
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.0
+ vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 26.1.0
+ transitivePeerDependencies:
+ - msw
+
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -3457,6 +3699,11 @@ snapshots:
dependencies:
isexe: 3.1.5
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
wrappy@1.0.2: {}
wsl-utils@0.3.1:
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f87c8eedf..d4ead8556 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,22 +1,26 @@
import { HashRouter, Outlet, Route, Routes } from "react-router-dom"
import { SettingsProvider } from "@/lib/settings"
import { ProjectsProvider } from "@/lib/projects"
-import { Rail } from "@/components/Rail"
+import { ProjectTabs } from "@/components/ProjectTabs"
+import { SessionSidebar } from "@/components/SessionSidebar"
import { TerminalHost } from "@/components/TerminalHost"
import { Home } from "@/components/Home"
import { Settings } from "@/components/Settings"
-// Layout is persistent across navigation: the Rail and the TerminalHost stay
-// mounted while the Outlet swaps screens (Home, Settings) on top of the
-// terminals.
+// Layout is persistent across navigation: the project tabs, session sidebar and
+// TerminalHost stay mounted while the Outlet swaps screens (Home, Settings) on
+// top of the terminals.
function Layout() {
return (
-
-
-
-
-
-
+
)
}
@@ -31,7 +35,7 @@ function App() {
} />
{/* Terminals are rendered by TerminalHost; the route only selects
which one is visible, so this element is empty. */}
-
+
} />
diff --git a/frontend/src/components/ProjectTabs.tsx b/frontend/src/components/ProjectTabs.tsx
new file mode 100644
index 000000000..91ca9636c
--- /dev/null
+++ b/frontend/src/components/ProjectTabs.tsx
@@ -0,0 +1,84 @@
+import { NavLink } from "react-router-dom"
+import { Plus, Settings, X } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { useProjects } from "@/lib/projects"
+import type { Project } from "../../bindings/github.com/skipodotdev/skipo/internals/project"
+
+// ProjectTab is a browser-style tab: the project name, active underline, and a
+// close affordance that appears on hover.
+function ProjectTab({
+ project,
+ onClose,
+}: {
+ project: Project
+ onClose: () => void
+}) {
+ return (
+
+ cn(
+ "group flex h-8 max-w-52 items-center gap-2 rounded-lg px-3 text-sm text-muted-foreground transition-colors hover:bg-accent/60",
+ isActive && "bg-accent text-accent-foreground",
+ )
+ }
+ >
+ {project.name}
+ {
+ event.preventDefault()
+ onClose()
+ }}
+ className="flex size-4 shrink-0 items-center justify-center rounded opacity-0 transition-opacity hover:bg-foreground/15 group-hover:opacity-100"
+ >
+
+
+
+ )
+}
+
+// ProjectTabs is the top strip: open projects as tabs, a button to open another,
+// and settings pinned to the right.
+export function ProjectTabs() {
+ const { projects, openProject, closeProject } = useProjects()
+
+ return (
+
+
+ {projects.map((project) => (
+
closeProject(project.id)}
+ />
+ ))}
+ void openProject()}
+ title="Open project"
+ aria-label="Open project"
+ className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
+ >
+
+
+
+
+
+ cn(
+ "flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
+ isActive && "bg-accent text-accent-foreground",
+ )
+ }
+ >
+
+
+
+ )
+}
diff --git a/frontend/src/components/Rail.tsx b/frontend/src/components/Rail.tsx
deleted file mode 100644
index 83bb995b7..000000000
--- a/frontend/src/components/Rail.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import { NavLink } from "react-router-dom"
-import { Plus, Settings, Trash2 } from "lucide-react"
-import type { ReactNode } from "react"
-import { cn } from "@/lib/utils"
-import { useProjects } from "@/lib/projects"
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu"
-
-// RailLink is a Discord-style rail item: a rounded icon tile with a left "pill"
-// indicator that grows when the route is active.
-function RailLink({
- to,
- label,
- children,
-}: {
- to: string
- label: string
- children: ReactNode
-}) {
- return (
-
- {({ isActive }) => (
- <>
-
-
- {children}
-
- >
- )}
-
- )
-}
-
-// RailButton is a rail item that fires an action instead of navigating.
-function RailButton({
- label,
- onClick,
- children,
-}: {
- label: string
- onClick: () => void
- children: ReactNode
-}) {
- return (
-
- {children}
-
- )
-}
-
-// Rail is the Discord-style vertical navigation: home, the list of open
-// projects, an "open project" button, and settings pinned to the bottom.
-export function Rail() {
- const { projects, openProject, closeProject } = useProjects()
-
- return (
-
-
- {projects.map((project) => (
-
-
-
-
- {project.name.slice(0, 2)}
-
-
-
-
- closeProject(project.id)}
- >
-
- Remove from list
-
-
-
- ))}
-
-
void openProject()}>
-
-
-
-
-
-
-
-
- )
-}
diff --git a/frontend/src/components/SessionSidebar.tsx b/frontend/src/components/SessionSidebar.tsx
new file mode 100644
index 000000000..9a7e9e851
--- /dev/null
+++ b/frontend/src/components/SessionSidebar.tsx
@@ -0,0 +1,156 @@
+import { useRef, useState } from "react"
+import type { PointerEvent as ReactPointerEvent } from "react"
+import { useMatch } from "react-router-dom"
+import { Plus, X } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { useProjects } from "@/lib/projects"
+import { activeSessionId, sessionsOf, type Session } from "@/lib/sessions"
+
+// Sidebar width bounds in rem, matching the Tailwind v4 spacing scale. State and
+// storage stay in rem; the pointer drag delta arrives in CSS pixels and is
+// converted with the 16px root font size Tailwind assumes.
+const REM_PX = 16
+const MIN_WIDTH_REM = 12
+const MAX_WIDTH_REM = 30
+const DEFAULT_WIDTH_REM = 15
+const WIDTH_STORAGE_KEY = "skipo.sidebar.width"
+
+const clampWidth = (rem: number): number =>
+ Math.min(MAX_WIDTH_REM, Math.max(MIN_WIDTH_REM, rem))
+
+function readWidth(): number {
+ const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY))
+ return Number.isFinite(stored) && stored > 0
+ ? clampWidth(stored)
+ : DEFAULT_WIDTH_REM
+}
+
+// SessionRow is one session entry: the label plus a close button on hover.
+//
+// ponytail: this is where git branch/diff and inline rename will hang off a
+// session once those services exist — the row already reserves the room.
+function SessionRow({
+ session,
+ active,
+ onSelect,
+ onClose,
+}: {
+ session: Session
+ active: boolean
+ onSelect: () => void
+ onClose: () => void
+}) {
+ return (
+
+ {session.label}
+ {
+ event.stopPropagation()
+ onClose()
+ }}
+ className="flex size-4 shrink-0 items-center justify-center rounded opacity-0 transition-opacity hover:bg-foreground/15 group-hover:opacity-100"
+ >
+
+
+
+ )
+}
+
+// SessionSidebar lists the active project's sessions and can be drag-resized
+// within a fixed pixel range. Width persists across restarts. It renders nothing
+// when no project is active (Home, Settings), so it never competes with those
+// screens.
+//
+// Resizing only changes this element's width; the terminal keeps its PTY in sync
+// on its own via a ResizeObserver, so the sidebar does not need to know about it.
+export function SessionSidebar() {
+ const { sessions, newSession, closeSession, activateSession } = useProjects()
+ const match = useMatch("/projects/:projectId")
+ const projectId = match?.params.projectId
+ const [width, setWidth] = useState(readWidth)
+ const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
+
+ if (!projectId) {
+ return null
+ }
+
+ const list = sessionsOf(sessions, projectId)
+ const activeId = activeSessionId(sessions, projectId)
+
+ const startDrag = (event: ReactPointerEvent
) => {
+ event.preventDefault()
+ dragRef.current = { startX: event.clientX, startWidth: width }
+ event.currentTarget.setPointerCapture(event.pointerId)
+ }
+
+ const onDrag = (event: ReactPointerEvent) => {
+ const drag = dragRef.current
+ if (!drag) {
+ return
+ }
+ setWidth(clampWidth(drag.startWidth + (event.clientX - drag.startX) / REM_PX))
+ }
+
+ const endDrag = (event: ReactPointerEvent) => {
+ const drag = dragRef.current
+ if (!drag) {
+ return
+ }
+ const finalWidth = clampWidth(
+ drag.startWidth + (event.clientX - drag.startX) / REM_PX,
+ )
+ dragRef.current = null
+ event.currentTarget.releasePointerCapture(event.pointerId)
+ setWidth(finalWidth)
+ localStorage.setItem(WIDTH_STORAGE_KEY, String(finalWidth))
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx
index ab923ffa4..1bba5a67e 100644
--- a/frontend/src/components/Settings.tsx
+++ b/frontend/src/components/Settings.tsx
@@ -21,7 +21,7 @@ type SectionId = (typeof SECTIONS)[number]["id"]
// Settings is a full screen (not a modal): it fills the main area and sits on
// top of the persistent terminals, which stay mounted and running behind it. A
-// category nav on the left mirrors the terminal Rail; content is on the right.
+// category nav sits on the left; content is on the right.
export function Settings() {
const [active, setActive] = useState("terminal")
const [query, setQuery] = useState("")
diff --git a/frontend/src/components/TerminalHost.tsx b/frontend/src/components/TerminalHost.tsx
index 08cb6284e..b766b8538 100644
--- a/frontend/src/components/TerminalHost.tsx
+++ b/frontend/src/components/TerminalHost.tsx
@@ -1,35 +1,41 @@
import { useMatch } from "react-router-dom"
import { TerminalView } from "@/components/TerminalView"
import { useProjects } from "@/lib/projects"
+import { activeSessionId, sessionsOf } from "@/lib/sessions"
-// TerminalHost keeps one persistent terminal per open project stacked in the
-// same area. The router only decides which one is visible — terminals are never
-// unmounted by navigation, so background sessions keep running. Inactive layers
-// use visibility:hidden (not display:none) so they retain layout size and fit()
-// stays correct.
+// TerminalHost keeps one persistent terminal per session, across every open
+// project, stacked in the same area. The router picks the active project and the
+// per-project active session decides which layer is visible — terminals are
+// never unmounted by navigation, so background sessions keep running. Inactive
+// layers use visibility:hidden (not display:none) so they retain layout size and
+// fit() stays correct.
export function TerminalHost() {
- const { projects } = useProjects()
- const match = useMatch("/projects/:id")
- const activeId = match?.params.id ?? null
+ const { projects, sessions } = useProjects()
+ const match = useMatch("/projects/:projectId")
+ const activeProjectId = match?.params.projectId ?? null
return (
<>
- {projects.map((project) => {
- const visible = project.id === activeId
- return (
-
-
-
- )
+ {projects.flatMap((project) => {
+ const projectActiveId = activeSessionId(sessions, project.id)
+ return sessionsOf(sessions, project.id).map((session) => {
+ const visible =
+ project.id === activeProjectId && session.id === projectActiveId
+ return (
+
+
+
+ )
+ })
})}
>
)
diff --git a/frontend/src/components/TerminalView.tsx b/frontend/src/components/TerminalView.tsx
index f4a760448..e8ad36531 100644
--- a/frontend/src/components/TerminalView.tsx
+++ b/frontend/src/components/TerminalView.tsx
@@ -6,7 +6,7 @@ import { useSettings } from "@/lib/settings"
import type { ResolvedTheme } from "@/lib/settings"
// Event name prefixes mirror the backend (internals/terminal); the concrete
-// event carries the project ID as a suffix.
+// event carries the session ID as a suffix.
const DATA_EVENT_PREFIX = "terminal:data:"
const EXIT_EVENT_PREFIX = "terminal:exit:"
@@ -52,12 +52,12 @@ function decodeBase64(data: string): Uint8Array {
}
interface TerminalViewProps {
- projectId: string
+ sessionId: string
cwd: string
visible: boolean
}
-export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) {
+export function TerminalView({ sessionId, cwd, visible }: TerminalViewProps) {
const { font, resolvedTerminalTheme } = useSettings()
const containerRef = useRef(null)
const termRef = useRef(null)
@@ -70,9 +70,9 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) {
fontRef.current = font
themeRef.current = resolvedTerminalTheme
- // Create the terminal and its PTY session once per project. The session runs
- // in the background regardless of visibility and is only torn down when the
- // project is closed (this component unmounts) — never on navigation.
+ // Create the terminal and its PTY session once per session id. The session
+ // runs in the background regardless of visibility and is only torn down when
+ // it is closed (this component unmounts) — never on navigation.
useEffect(() => {
const container = containerRef.current
if (!container) {
@@ -82,12 +82,40 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) {
let disposed = false
const cleanups: Array<() => void> = []
- const onWindowResize = () => {
- const term = termRef.current
- fitRef.current?.fit()
- if (term && visibleRef.current) {
- void Service.Resize(projectId, term.cols, term.rows)
+ // Refit the grid whenever the container changes size — window resize, sidebar
+ // drag, zoom. Guard on integer dimensions: fit() nudges the canvas, which can
+ // make the observer re-fire at sub-pixel deltas and loop into a flicker.
+ // Ignoring sub-1px changes breaks that loop. Coalesced to one refit per frame.
+ // Only the visible terminal pushes its new size to the PTY; hidden ones refit
+ // but sync their PTY when shown.
+ let lastWidth = 0
+ let lastHeight = 0
+ let refitFrame = 0
+ const scheduleRefit = (entries: ResizeObserverEntry[]) => {
+ const rect = entries[0]?.contentRect
+ if (rect) {
+ const width = Math.round(rect.width)
+ const height = Math.round(rect.height)
+ if (width === lastWidth && height === lastHeight) {
+ return
+ }
+ lastWidth = width
+ lastHeight = height
}
+ if (refitFrame) {
+ return
+ }
+ refitFrame = requestAnimationFrame(() => {
+ refitFrame = 0
+ const term = termRef.current
+ if (!term) {
+ return
+ }
+ fitRef.current?.fit()
+ if (visibleRef.current) {
+ void Service.Resize(sessionId, term.cols, term.rows)
+ }
+ })
}
void (async () => {
@@ -111,26 +139,32 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) {
termRef.current = term
fitRef.current = fit
- const dataInput = term.onData((data) => Service.Write(projectId, data))
+ const dataInput = term.onData((data) => Service.Write(sessionId, data))
const resizeInput = term.onResize(({ cols, rows }) => {
if (visibleRef.current) {
- void Service.Resize(projectId, cols, rows)
+ void Service.Resize(sessionId, cols, rows)
}
})
cleanups.push(() => dataInput.dispose(), () => resizeInput.dispose())
- const offData = Events.On(DATA_EVENT_PREFIX + projectId, (event) => {
+ const offData = Events.On(DATA_EVENT_PREFIX + sessionId, (event) => {
term.write(decodeBase64(event.data as string))
})
- const offExit = Events.On(EXIT_EVENT_PREFIX + projectId, () => {
+ const offExit = Events.On(EXIT_EVENT_PREFIX + sessionId, () => {
term.write("\r\n[process exited]\r\n")
})
cleanups.push(offData, offExit)
- window.addEventListener("resize", onWindowResize)
- cleanups.push(() => window.removeEventListener("resize", onWindowResize))
+ const resizeObserver = new ResizeObserver(scheduleRefit)
+ resizeObserver.observe(container)
+ cleanups.push(() => {
+ if (refitFrame) {
+ cancelAnimationFrame(refitFrame)
+ }
+ resizeObserver.disconnect()
+ })
- await Service.Start(projectId, cwd, term.cols, term.rows)
+ await Service.Start(sessionId, cwd, term.cols, term.rows)
if (visibleRef.current) {
term.focus()
}
@@ -141,12 +175,12 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) {
for (const cleanup of cleanups) {
cleanup()
}
- void Service.Close(projectId)
+ void Service.Close(sessionId)
termRef.current?.dispose()
termRef.current = null
fitRef.current = null
}
- }, [projectId, cwd])
+ }, [sessionId, cwd])
// Apply a font change live to the running terminal.
useEffect(() => {
@@ -160,10 +194,10 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) {
// Font metrics changed: recompute the grid and sync the visible PTY.
fitRef.current?.fit()
if (visibleRef.current) {
- void Service.Resize(projectId, term.cols, term.rows)
+ void Service.Resize(sessionId, term.cols, term.rows)
}
})()
- }, [font, projectId])
+ }, [font, sessionId])
// Apply a terminal theme change live to the running terminal.
useEffect(() => {
@@ -181,9 +215,9 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) {
return
}
fitRef.current?.fit()
- void Service.Resize(projectId, term.cols, term.rows)
+ void Service.Resize(sessionId, term.cols, term.rows)
term.focus()
- }, [visible, projectId])
+ }, [visible, sessionId])
return
}
diff --git a/frontend/src/lib/projects.tsx b/frontend/src/lib/projects.tsx
index f8c722c6c..1593ea48b 100644
--- a/frontend/src/lib/projects.tsx
+++ b/frontend/src/lib/projects.tsx
@@ -5,21 +5,40 @@ import {
Project,
Service as ProjectService,
} from "../../bindings/github.com/skipodotdev/skipo/internals/project"
+import {
+ addSession,
+ closeSession as removeSession,
+ removeProject,
+ sessionsOf,
+ setActiveSession,
+ type SessionState,
+} from "./sessions"
interface ProjectsValue {
projects: Project[]
+ /** Sessions keyed by project id, with the active session per project. */
+ sessions: SessionState
/** Show the OS directory picker, add the chosen project and navigate to it. */
openProject: () => Promise
- /** Remove a project (unmounts its terminal, which closes the PTY). */
+ /** Remove a project (unmounts its terminals, which closes their PTYs). */
closeProject: (id: string) => void
+ /** Open a new terminal session in a project and focus it. */
+ newSession: (projectId: string) => void
+ /** Close a session; closing the last one recreates an empty one. */
+ closeSession: (projectId: string, sessionId: string) => void
+ /** Focus an existing session within a project. */
+ activateSession: (projectId: string, sessionId: string) => void
}
const ProjectsContext = createContext(null)
-// ponytail: open projects are kept in memory only. Persist to a config file
-// (and restore on launch) when projects should survive restarts.
+const newSessionId = (): string => crypto.randomUUID()
+
+// ponytail: open projects and their sessions are kept in memory only. Persist to
+// a config file (and restore on launch) when they should survive restarts.
export function ProjectsProvider({ children }: { children: ReactNode }) {
const [projects, setProjects] = useState([])
+ const [sessions, setSessions] = useState({})
const navigate = useNavigate()
const openProject = useCallback(async () => {
@@ -30,19 +49,51 @@ export function ProjectsProvider({ children }: { children: ReactNode }) {
setProjects((prev) =>
prev.some((project) => project.id === picked.id) ? prev : [...prev, picked],
)
+ setSessions((prev) =>
+ prev[picked.id] ? prev : addSession(prev, picked.id, newSessionId()),
+ )
navigate(`/projects/${picked.id}`)
}, [navigate])
const closeProject = useCallback(
(id: string) => {
setProjects((prev) => prev.filter((project) => project.id !== id))
+ setSessions((prev) => removeProject(prev, id))
navigate("/")
},
[navigate],
)
+ const newSession = useCallback((projectId: string) => {
+ setSessions((prev) => addSession(prev, projectId, newSessionId()))
+ }, [])
+
+ const closeSession = useCallback((projectId: string, sessionId: string) => {
+ setSessions((prev) => {
+ const next = removeSession(prev, projectId, sessionId)
+ // A project always keeps at least one session; recreate when emptied.
+ return sessionsOf(next, projectId).length === 0
+ ? addSession(next, projectId, newSessionId())
+ : next
+ })
+ }, [])
+
+ const activateSession = useCallback((projectId: string, sessionId: string) => {
+ setSessions((prev) => setActiveSession(prev, projectId, sessionId))
+ }, [])
+
return (
-
+
{children}
)
diff --git a/frontend/src/lib/sessions.test.ts b/frontend/src/lib/sessions.test.ts
new file mode 100644
index 000000000..e0e9acbb5
--- /dev/null
+++ b/frontend/src/lib/sessions.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from "vitest"
+import {
+ activeSessionId,
+ addSession,
+ closeSession,
+ createProjectSessions,
+ removeProject,
+ sessionsOf,
+ setActiveSession,
+ type SessionState,
+} from "./sessions"
+
+const P = "project-1"
+
+// buildState creates a project with `n` sessions (ids "s1".."sn"), the last one
+// active.
+function buildState(n: number): SessionState {
+ let state: SessionState = { [P]: createProjectSessions("s1") }
+ for (let i = 2; i <= n; i++) {
+ state = addSession(state, P, `s${i}`)
+ }
+ return state
+}
+
+describe("createProjectSessions", () => {
+ it("seeds one active session labeled Session 1", () => {
+ const project = createProjectSessions("s1")
+ expect(project.sessions).toEqual([{ id: "s1", label: "Session 1" }])
+ expect(project.activeId).toBe("s1")
+ expect(project.nextSeq).toBe(2)
+ })
+})
+
+describe("addSession", () => {
+ it("creates the project entry when absent", () => {
+ const state = addSession({}, P, "s1")
+ expect(sessionsOf(state, P)).toHaveLength(1)
+ expect(activeSessionId(state, P)).toBe("s1")
+ })
+
+ it("appends, focuses the new session and advances the label sequence", () => {
+ const state = addSession(buildState(1), P, "s2")
+ expect(sessionsOf(state, P).map((s) => s.label)).toEqual([
+ "Session 1",
+ "Session 2",
+ ])
+ expect(activeSessionId(state, P)).toBe("s2")
+ })
+
+ it("does not mutate the input state", () => {
+ const before = buildState(1)
+ addSession(before, P, "s2")
+ expect(sessionsOf(before, P)).toHaveLength(1)
+ })
+})
+
+describe("closeSession", () => {
+ it("removes a non-active session and keeps the active one", () => {
+ const state = closeSession(buildState(3), P, "s1") // active is s3
+ expect(sessionsOf(state, P).map((s) => s.id)).toEqual(["s2", "s3"])
+ expect(activeSessionId(state, P)).toBe("s3")
+ })
+
+ it("moves focus to the slot filler when the active session closes", () => {
+ // s1, s2, s3 with s2 active → closing s2 focuses s3 (fills index 1).
+ let state = buildState(3)
+ state = setActiveSession(state, P, "s2")
+ state = closeSession(state, P, "s2")
+ expect(activeSessionId(state, P)).toBe("s3")
+ })
+
+ it("falls back to the previous session when the last one closes", () => {
+ const state = closeSession(buildState(3), P, "s3") // active is s3 (last)
+ expect(activeSessionId(state, P)).toBe("s2")
+ })
+
+ it("empties the project but preserves nextSeq for recreate", () => {
+ const state = closeSession(buildState(1), P, "s1")
+ expect(sessionsOf(state, P)).toHaveLength(0)
+ expect(activeSessionId(state, P)).toBe("")
+ // A recreate keeps counting up instead of reusing "Session 1".
+ const recreated = addSession(state, P, "s2")
+ expect(sessionsOf(recreated, P)[0].label).toBe("Session 2")
+ })
+
+ it("ignores unknown project or session ids", () => {
+ const state = buildState(2)
+ expect(closeSession(state, "nope", "s1")).toBe(state)
+ expect(closeSession(state, P, "ghost")).toBe(state)
+ })
+})
+
+describe("setActiveSession", () => {
+ it("focuses an existing session", () => {
+ const state = setActiveSession(buildState(3), P, "s1")
+ expect(activeSessionId(state, P)).toBe("s1")
+ })
+
+ it("ignores unknown ids", () => {
+ const state = buildState(2)
+ expect(setActiveSession(state, P, "ghost")).toBe(state)
+ })
+})
+
+describe("removeProject", () => {
+ it("drops the project and its sessions", () => {
+ const state = removeProject(buildState(2), P)
+ expect(sessionsOf(state, P)).toHaveLength(0)
+ expect(P in state).toBe(false)
+ })
+
+ it("is a no-op for an unknown project", () => {
+ const state = buildState(1)
+ expect(removeProject(state, "nope")).toBe(state)
+ })
+})
diff --git a/frontend/src/lib/sessions.ts b/frontend/src/lib/sessions.ts
new file mode 100644
index 000000000..b0d4ffcfa
--- /dev/null
+++ b/frontend/src/lib/sessions.ts
@@ -0,0 +1,119 @@
+// Session state model for multi-session-per-project. A project owns an ordered
+// list of terminal sessions and tracks which one is active. Sessions decouple
+// from the project: each has its own opaque id used as the backend PTY key, so
+// the terminal service needs no knowledge of projects at all.
+//
+// Every function here is pure — it returns a new state and never mutates the
+// input — which keeps the reducer logic testable without React or a PTY.
+
+export interface Session {
+ id: string
+ label: string
+}
+
+export interface ProjectSessions {
+ sessions: Session[]
+ activeId: string
+ // Monotonic per-project counter for default labels. It only grows, so closing
+ // a session never renumbers the survivors.
+ nextSeq: number
+}
+
+export type SessionState = Record
+
+// createProjectSessions seeds a project with its first session.
+export function createProjectSessions(firstSessionId: string): ProjectSessions {
+ return {
+ sessions: [{ id: firstSessionId, label: "Session 1" }],
+ activeId: firstSessionId,
+ nextSeq: 2,
+ }
+}
+
+// addSession appends a session to a project and makes it active. If the project
+// has no entry yet, it is created with this session as the first.
+export function addSession(
+ state: SessionState,
+ projectId: string,
+ sessionId: string,
+): SessionState {
+ const current = state[projectId]
+ if (!current) {
+ return { ...state, [projectId]: createProjectSessions(sessionId) }
+ }
+ const session: Session = { id: sessionId, label: `Session ${current.nextSeq}` }
+ return {
+ ...state,
+ [projectId]: {
+ sessions: [...current.sessions, session],
+ activeId: sessionId,
+ nextSeq: current.nextSeq + 1,
+ },
+ }
+}
+
+// closeSession removes a session. When the active one is closed, focus moves to
+// a neighbor. The project entry is kept even when empty so nextSeq (and the
+// caller's recreate-on-empty policy) survive.
+export function closeSession(
+ state: SessionState,
+ projectId: string,
+ sessionId: string,
+): SessionState {
+ const current = state[projectId]
+ if (!current) {
+ return state
+ }
+ const index = current.sessions.findIndex((s) => s.id === sessionId)
+ if (index === -1) {
+ return state
+ }
+ const sessions = current.sessions.filter((s) => s.id !== sessionId)
+ const activeId =
+ current.activeId === sessionId ? neighborId(sessions, index) : current.activeId
+ return { ...state, [projectId]: { ...current, sessions, activeId } }
+}
+
+// neighborId picks the session that fills the closed slot, falling back to the
+// previous one, or "" when the list is now empty.
+function neighborId(sessions: Session[], removedIndex: number): string {
+ if (sessions.length === 0) {
+ return ""
+ }
+ const next = sessions[removedIndex] ?? sessions[removedIndex - 1]
+ return next.id
+}
+
+// setActiveSession focuses an existing session; unknown ids are ignored.
+export function setActiveSession(
+ state: SessionState,
+ projectId: string,
+ sessionId: string,
+): SessionState {
+ const current = state[projectId]
+ if (!current || !current.sessions.some((s) => s.id === sessionId)) {
+ return state
+ }
+ return { ...state, [projectId]: { ...current, activeId: sessionId } }
+}
+
+// removeProject drops a project and all its sessions.
+export function removeProject(
+ state: SessionState,
+ projectId: string,
+): SessionState {
+ if (!(projectId in state)) {
+ return state
+ }
+ const next = { ...state }
+ delete next[projectId]
+ return next
+}
+
+export function sessionsOf(state: SessionState, projectId: string): Session[] {
+ return state[projectId]?.sessions ?? []
+}
+
+export function activeSessionId(state: SessionState, projectId: string): string {
+ return state[projectId]?.activeId ?? ""
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index fc3e4e3a0..5709ce0b1 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -28,4 +28,5 @@
}
},
"include": ["src", "bindings"],
+ "exclude": ["src/**/*.test.ts"],
}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 000000000..c63ce1eaa
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,17 @@
+import path from "node:path"
+import { defineConfig } from "vitest/config"
+
+// Standalone from vite.config.ts: the Wails runtime plugin needs the desktop
+// bridge, which is absent under the test runner. Session logic is pure, so a
+// plain node environment is enough.
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.ts"],
+ },
+})
diff --git a/internals/project/project.go b/internals/project/project.go
index b35577d1c..e6a97c03d 100644
--- a/internals/project/project.go
+++ b/internals/project/project.go
@@ -1,6 +1,7 @@
// Package project opens project directories through the OS file picker. Project
-// metadata (list, active) lives in the frontend; this service only turns a
-// picked directory into a stable identity the terminal sessions can key on.
+// metadata (list, active, sessions) lives in the frontend; this service only
+// turns a picked directory into a stable identity the frontend can group its
+// terminal sessions under.
package project
import (
diff --git a/internals/terminal/terminal.go b/internals/terminal/terminal.go
index 23815c07f..5f3e54be2 100644
--- a/internals/terminal/terminal.go
+++ b/internals/terminal/terminal.go
@@ -1,7 +1,7 @@
// Package terminal spawns PTY-backed shell sessions and bridges their I/O to the
-// frontend over Wails events. Sessions are keyed by project ID and run
+// frontend over Wails events. Sessions are keyed by an opaque session ID and run
// independently of the frontend, so navigating away from a project (or hiding
-// its terminal) never kills its shell.
+// its terminal) never kills its shell. A project may own several sessions.
package terminal
import (
@@ -33,7 +33,7 @@ type session struct {
cmd *exec.Cmd
}
-// Service manages PTY-backed shell sessions keyed by project ID.
+// Service manages PTY-backed shell sessions keyed by session ID.
type Service struct {
mu sync.Mutex
sessions map[string]*session
@@ -53,9 +53,9 @@ func defaultShell() string {
return "/bin/sh"
}
-// Start spawns the user's shell for project id, attached to a new PTY sized to
+// Start spawns the user's shell for session id, attached to a new PTY sized to
// cols x rows and rooted at cwd, then streams its output to the frontend. An
-// empty cwd defaults to the user's home directory. Starting a project that is
+// empty cwd defaults to the user's home directory. Starting a session that is
// already running is a no-op.
func (s *Service) Start(id, cwd string, cols, rows int) error {
s.mu.Lock()
@@ -112,7 +112,7 @@ func (s *Service) stream(id string, ptmx *os.File, cmd *exec.Cmd) {
application.Get().Event.Emit(exitEventPrefix + id)
}
-// Write forwards keyboard input from the frontend to a project's PTY.
+// Write forwards keyboard input from the frontend to a session's PTY.
func (s *Service) Write(id, data string) error {
ptmx := s.ptmxOf(id)
if ptmx == nil {
@@ -122,7 +122,7 @@ func (s *Service) Write(id, data string) error {
return err
}
-// Resize updates a project's PTY window size. The frontend only calls this for
+// Resize updates a session's PTY window size. The frontend only calls this for
// the visible terminal; a hidden terminal is resized on the next time it is
// shown.
func (s *Service) Resize(id string, cols, rows int) error {
@@ -133,7 +133,7 @@ func (s *Service) Resize(id string, cols, rows int) error {
return pty.Setsize(ptmx, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)})
}
-// Close terminates a project's shell session, if any.
+// Close terminates a session's shell, if any.
func (s *Service) Close(id string) error {
s.mu.Lock()
sess, ok := s.sessions[id]