Skip to content

Commit d1f1d56

Browse files
fix(frontend): single-click tabs, smart session naming, push reliability (#169)
## Summary ### UX Improvements - **Single-click tab switching** — Tabs now activate on pointer-up (bypasses dnd-kit click interference), cursor changed to pointer - **Clickable session cards** — Entire SessionsHub card is clickable, no need to find the Connect button - **Smart session naming** — New sessions default to folder name (e.g. `termbeam`) with `(X)` dedup for duplicates - **Stable Create button** — No more size shift when submitting ### Push Notification Fixes - **Auto-recovery** — `isPushSubscribed()` now re-subscribes when subscription is lost but permission is granted - **Page-load re-init** — `ensurePushSubscription()` always tries re-init when permission exists (recovers from server restarts) - **No more duplicates** — Local `new Notification()` is skipped when push is active (service worker handles it) ### Test Coverage - **82.6% → 93.4%** line coverage - 120+ new tests across routes, sessions, websocket, push, resume, update-executor, update-check, bin/termbeam - Fixed pre-existing resume.test.js crash (TERMBEAM_SESSION env var interference) - Excluded `bin/` from coverage (entry point, not logic code) ### Files Changed - `SortableTab.tsx` — onPointerUp click detection - `SessionCard.tsx` — full card onClick + swipe prevention - `NewSessionModal.tsx` — folder-name default + dedup - `pushSubscription.ts` — auto-recovery logic - `audio.ts` — skip local notification when push active - 10 test files added/updated --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 308a2dd commit d1f1d56

19 files changed

Lines changed: 4318 additions & 61 deletions

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/frontend/src/components/SessionsHub/NewSessionModal.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
157157
}
158158

159159
.submitBtn {
160+
min-width: 100px;
160161
padding: 0.55rem 1.25rem;
161162
background: var(--accent);
162163
color: var(--bg);

src/frontend/src/components/SessionsHub/NewSessionModal.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from 'react';
1+
import { useState, useEffect, useCallback } from 'react';
22
import * as Dialog from '@radix-ui/react-dialog';
33
import { toast } from 'sonner';
44
import { createSession, fetchShells } from '@/services/api';
@@ -13,9 +13,22 @@ interface NewSessionModalProps {
1313
onCreated: (id: string) => void;
1414
}
1515

16+
function folderName(dir: string): string {
17+
const parts = dir.replace(/[/\\]+$/, '').split(/[/\\]/);
18+
return parts[parts.length - 1] || dir;
19+
}
20+
21+
function uniqueName(base: string, existing: Set<string>): string {
22+
if (!existing.has(base)) return base;
23+
let i = 2;
24+
while (existing.has(`${base} (${i})`)) i++;
25+
return `${base} (${i})`;
26+
}
27+
1628
export default function NewSessionModal({ onCreated }: NewSessionModalProps) {
1729
const { newSessionModalOpen, closeNewSessionModal } = useUIStore();
1830
const [name, setName] = useState('');
31+
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
1932
const [shell, setShell] = useState('');
2033
const [shells, setShells] = useState<ShellInfo[]>([]);
2134
const [cwd, setCwd] = useState('');
@@ -24,6 +37,17 @@ export default function NewSessionModal({ onCreated }: NewSessionModalProps) {
2437
const [submitting, setSubmitting] = useState(false);
2538
const [browsing, setBrowsing] = useState(false);
2639

40+
const deriveNameFromCwd = useCallback(
41+
(dir: string) => {
42+
if (nameManuallyEdited) return;
43+
const sessions = useSessionStore.getState().sessions;
44+
const existingNames = new Set<string>();
45+
for (const s of sessions.values()) existingNames.add(s.name);
46+
setName(uniqueName(folderName(dir), existingNames));
47+
},
48+
[nameManuallyEdited],
49+
);
50+
2751
useEffect(() => {
2852
if (newSessionModalOpen) {
2953
fetchShells()
@@ -34,14 +58,18 @@ export default function NewSessionModal({ onCreated }: NewSessionModalProps) {
3458
list.find((s) => s.cmd === defaultShell) || list.find((s) => s.path === defaultShell);
3559
setShell(def?.cmd ?? list[0]?.cmd ?? '');
3660
}
37-
if (!cwd && serverCwd) setCwd(serverCwd);
61+
if (!cwd && serverCwd) {
62+
setCwd(serverCwd);
63+
deriveNameFromCwd(serverCwd);
64+
}
3865
})
3966
.catch(() => setShells([]));
4067
}
4168
}, [newSessionModalOpen]);
4269

4370
function resetForm() {
4471
setName('');
72+
setNameManuallyEdited(false);
4573
setShell('');
4674
setCwd('');
4775
setInitialCommand('');
@@ -99,6 +127,7 @@ export default function NewSessionModal({ onCreated }: NewSessionModalProps) {
99127
currentDir={cwd || '/'}
100128
onSelect={(dir: string) => {
101129
setCwd(dir);
130+
deriveNameFromCwd(dir);
102131
setBrowsing(false);
103132
}}
104133
onCancel={() => setBrowsing(false)}
@@ -110,9 +139,12 @@ export default function NewSessionModal({ onCreated }: NewSessionModalProps) {
110139
<input
111140
className={styles.input}
112141
type="text"
113-
placeholder="my-session"
142+
placeholder={folderName(cwd || '/')}
114143
value={name}
115-
onChange={(e) => setName(e.target.value)}
144+
onChange={(e) => {
145+
setName(e.target.value);
146+
setNameManuallyEdited(true);
147+
}}
116148
data-testid="ns-name"
117149
/>
118150
</div>
@@ -199,7 +231,7 @@ export default function NewSessionModal({ onCreated }: NewSessionModalProps) {
199231
data-testid="ns-create"
200232
onClick={handleSubmit}
201233
>
202-
{submitting ? 'Creating…' : 'Create'}
234+
Create
203235
</button>
204236
</div>
205237
</div>

src/frontend/src/components/SessionsHub/SessionCard.module.css

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
border-radius: 12px;
4343
touch-action: pan-y;
4444
user-select: none;
45+
cursor: pointer;
4546
z-index: 1;
4647
transition: border-color 0.15s;
4748
}
@@ -142,32 +143,16 @@
142143
color: var(--danger);
143144
}
144145

145-
/* Connect button */
146-
.connectBtn {
146+
/* Connect hint */
147+
.connectHint {
147148
align-self: flex-end;
148-
background: var(--accent);
149-
color: #fff;
150-
border: none;
151-
border-radius: 8px;
152-
padding: 8px 20px;
153-
font-size: 14px;
149+
font-size: 13px;
154150
font-weight: 600;
155-
cursor: pointer;
156-
transition:
157-
opacity 0.15s,
158-
transform 0.15s;
159-
}
160-
161-
.connectBtn:hover {
162-
opacity: 0.9;
163-
}
164-
165-
.connectBtn:active {
166-
transform: scale(0.97);
151+
color: var(--accent);
167152
}
168153

169154
@media (pointer: coarse) {
170-
.connectBtn {
171-
padding: 10px 24px;
155+
.connectHint {
156+
font-size: 14px;
172157
}
173158
}

src/frontend/src/components/SessionsHub/SessionCard.tsx

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export default function SessionCard({
165165
const handleTouchEnd = useCallback(
166166
(e: React.TouchEvent) => {
167167
if (!isSwiping.current) return;
168+
e.preventDefault(); // prevent click after swipe
168169

169170
const touch = e.changedTouches[0];
170171
if (!touch) return;
@@ -222,6 +223,7 @@ export default function SessionCard({
222223
ref={cardRef}
223224
className={styles.card}
224225
data-testid="session-card"
226+
onClick={() => onSelect(session.id)}
225227
onTouchStart={handleTouchStart}
226228
onTouchMove={handleTouchMove}
227229
onTouchEnd={handleTouchEnd}
@@ -277,17 +279,7 @@ export default function SessionCard({
277279
</div>
278280
)}
279281

280-
{/* Connect button */}
281-
<button
282-
className={styles.connectBtn}
283-
data-testid="connect-btn"
284-
onClick={(e) => {
285-
e.stopPropagation();
286-
onSelect(session.id);
287-
}}
288-
>
289-
Connect →
290-
</button>
282+
<span className={styles.connectHint}>Connect →</span>
291283
</div>
292284
</div>
293285
);

src/frontend/src/components/TabBar/SortableTab.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useRef } from 'react';
12
import { useSortable } from '@dnd-kit/sortable';
23
import { CSS } from '@dnd-kit/utilities';
34
import type { ManagedSession } from '@/stores/sessionStore';
@@ -13,6 +14,8 @@ interface SortableTabProps {
1314
onMouseLeave?: () => void;
1415
}
1516

17+
const TAP_THRESHOLD = 5;
18+
1619
function formatTabActivity(lastActivity: string | number): string {
1720
const ts = typeof lastActivity === 'number' ? lastActivity : new Date(lastActivity).getTime();
1821
const diff = Date.now() - ts;
@@ -34,6 +37,7 @@ export function SortableTab({
3437
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
3538
id: session.id,
3639
});
40+
const pointerStart = useRef<{ x: number; y: number } | null>(null);
3741

3842
const style: React.CSSProperties = {
3943
transform: CSS.Transform.toString(transform),
@@ -50,7 +54,21 @@ export function SortableTab({
5054
className={`${styles.tab} ${isActive ? styles.tabActive : ''} ${isSplit ? styles.tabSplit : ''}`}
5155
data-testid="session-tab"
5256
{...(isActive ? { 'data-active': 'true' } : {})}
53-
onClick={onActivate}
57+
{...attributes}
58+
{...listeners}
59+
onPointerDown={(e) => {
60+
pointerStart.current = { x: e.clientX, y: e.clientY };
61+
// Chain with dnd-kit's handler
62+
(listeners as Record<string, Function>)?.onPointerDown?.(e);
63+
}}
64+
onPointerUp={(e) => {
65+
if (pointerStart.current && e.button === 0) {
66+
const dx = Math.abs(e.clientX - pointerStart.current.x);
67+
const dy = Math.abs(e.clientY - pointerStart.current.y);
68+
if (dx < TAP_THRESHOLD && dy < TAP_THRESHOLD) onActivate();
69+
}
70+
pointerStart.current = null;
71+
}}
5472
onAuxClick={(e) => {
5573
if (e.button === 1) {
5674
e.preventDefault();
@@ -59,8 +77,6 @@ export function SortableTab({
5977
}}
6078
onMouseEnter={onMouseEnter}
6179
onMouseLeave={onMouseLeave}
62-
{...attributes}
63-
{...listeners}
6480
>
6581
<span className={styles.colorDot} style={{ backgroundColor: session.color }} />
6682
<span className={styles.tabName} data-testid="tab-name">

src/frontend/src/components/TabBar/TabBar.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
gap: 6px;
6262
padding: 4px 10px;
6363
border-radius: 6px;
64-
cursor: grab;
64+
cursor: pointer;
6565
white-space: nowrap;
6666
user-select: none;
6767
font-size: 12px;

src/frontend/src/services/audio.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,13 @@ export function setNotificationsEnabled(enabled: boolean): void {
4646
}
4747
}
4848

49+
import { isPushSubscribedSync } from './pushSubscription';
50+
4951
export function sendCommandNotification(sessionName: string): void {
5052
if (!isNotificationsEnabled()) return;
5153
if (Notification.permission !== 'granted') return;
54+
// Skip local notification when push is active — the service worker handles it
55+
if (isPushSubscribedSync()) return;
5256
try {
5357
new Notification('Command finished in ' + sessionName, {
5458
icon: '/icons/icon-192.png',

src/frontend/src/services/pushSubscription.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,28 @@ export function isPushSubscribedSync(): boolean {
106106
}
107107
}
108108

109-
/** Async check — queries the actual PushManager. */
109+
/**
110+
* Async check — queries the actual PushManager.
111+
* If a subscription was lost but permission is still granted,
112+
* attempts to re-subscribe automatically.
113+
*/
110114
export async function isPushSubscribed(): Promise<boolean> {
111115
try {
112116
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false;
113117
const registration = await navigator.serviceWorker.ready;
114118
const subscription = await registration.pushManager?.getSubscription();
115-
const active = !!subscription;
116-
setPushState(active);
117-
return active;
119+
if (subscription) {
120+
setPushState(true);
121+
return true;
122+
}
123+
124+
// Subscription lost — try to recover if we had it before and still have permission
125+
if (isPushSubscribedSync() && Notification.permission === 'granted') {
126+
return initPushSubscription();
127+
}
128+
129+
setPushState(false);
130+
return false;
118131
} catch {
119132
return false;
120133
}
@@ -127,7 +140,6 @@ export async function isPushSubscribed(): Promise<boolean> {
127140
*/
128141
export async function ensurePushSubscription(): Promise<void> {
129142
try {
130-
if (!isPushSubscribedSync()) return;
131143
if (Notification.permission !== 'granted') return;
132144
await initPushSubscription();
133145
} catch {

test/cli/resume.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,13 @@ describe('resume', () => {
99
let resume;
1010
let tempDir;
1111
let CONNECTION_FILE;
12+
let savedTermbeamSession;
1213

1314
beforeEach(() => {
15+
// Save and clear TERMBEAM_SESSION so resume() doesn't bail out
16+
savedTermbeamSession = process.env.TERMBEAM_SESSION;
17+
delete process.env.TERMBEAM_SESSION;
18+
1419
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'termbeam-test-'));
1520
process.env.TERMBEAM_CONFIG_DIR = tempDir;
1621
CONNECTION_FILE = path.join(tempDir, 'connection.json');
@@ -22,6 +27,12 @@ describe('resume', () => {
2227
});
2328

2429
afterEach(() => {
30+
// Restore TERMBEAM_SESSION
31+
if (savedTermbeamSession !== undefined) {
32+
process.env.TERMBEAM_SESSION = savedTermbeamSession;
33+
} else {
34+
delete process.env.TERMBEAM_SESSION;
35+
}
2536
delete process.env.TERMBEAM_CONFIG_DIR;
2637
try {
2738
fs.rmSync(tempDir, { recursive: true, force: true });

0 commit comments

Comments
 (0)