From 01669f2f12122b5cbf6078d61325ab616a74d13b Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 12 Jun 2026 23:50:59 +0530 Subject: [PATCH] opentui(v6): /sessions groups this directory's sessions first + TUI persists its cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resume picker never had cwd grouping — deliberately deferred in the v1 spec because TUI session rows had no cwd to group by: the TUI's session.create sent only {cols}, so explicit_cwd stayed false and _ensure_session_db_row skipped cwd stamping by design (the desktop's launch dir is meaningless — 'No workspace' grouping is its desired default). In a terminal the launch directory IS the workspace choice, so the entry now passes cwd: process.cwd() at session.create — the existing explicit-workspace machinery persists it to the session row on first message (covered by test_ensure_session_db_row_persists_explicit_cwd; zero gateway changes). Picker: while browsing (no search), sessions whose cwd matches the TUI's current directory order first under a '▾ this directory (N)' caption, the rest under '▾ other directories' — one flat reordered list, so selection/ windowing/load-more math is untouched, and captions are pure render decoration keyed off hereCount. During search the fuzzy score keeps owning the order. Trailing-slash-normalized comparison, no fs calls. Old sessions can't be backfilled (their cwd was never recorded); coverage accumulates from here. 6 new tests (pure ordering edges + grouped frames, search-drops-grouping, no-cwd passthrough). --- ui-opentui/src/entry/main.tsx | 8 ++- ui-opentui/src/logic/sessionPicker.ts | 30 +++++++++ ui-opentui/src/test/sessionPicker.test.ts | 29 ++++++++- .../src/test/sessionPickerView.test.tsx | 65 +++++++++++++++++++ ui-opentui/src/view/App.tsx | 1 + .../src/view/overlays/sessionPicker.tsx | 17 ++++- 6 files changed, 147 insertions(+), 3 deletions(-) diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index 61997655634..742e5a4784d 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -180,7 +180,13 @@ const postSessionSetup = (gateway: GatewayServiceShape, store: SessionStore, sid const createFreshSession = (gateway: GatewayServiceShape, store: SessionStore, input: TuiInput) => Effect.gen(function* () { const created = yield* gateway.request<{ session_id?: string; info?: Record }>('session.create', { - cols: input.cols + cols: input.cols, + // The launch directory IS the workspace choice in a terminal (you cd'd + // here) — passing it makes the gateway treat it as explicit, so the + // session row gets a persisted cwd on first message and /sessions can + // group this directory's sessions first. (The desktop deliberately + // omits cwd — its launch dir is meaningless; see _ensure_session_db_row.) + cwd: process.cwd() }) const sid = created?.session_id ?? gateway.sessionId() if (!sid) { diff --git a/ui-opentui/src/logic/sessionPicker.ts b/ui-opentui/src/logic/sessionPicker.ts index 9c7d97e753c..5dd506fa96f 100644 --- a/ui-opentui/src/logic/sessionPicker.ts +++ b/ui-opentui/src/logic/sessionPicker.ts @@ -163,6 +163,36 @@ export function filterSessions(query: string, rows: readonly SessionRow[]): Sess return fuzzyFilter(query, rows, sessionFields) } +// ── this-directory grouping ─────────────────────────────────────────────── + +/** Path equality for cwd grouping: trim + drop trailing slashes. Pure string + * work (no fs) — rows carry the gateway's already-absolute paths. */ +export function normalizeCwd(path: string | undefined): string { + return (path ?? '').trim().replace(/\/+$/, '') +} + +/** Display order with sessions started in the CURRENT directory first. + * + * Browse mode only: while a search query is active the fuzzy score owns the + * order (relevance beats locality), so `hereCount` is 0 and rows pass through. + * Stable within both groups (each keeps the gateway's recency order). The + * view renders section captions off `hereCount`; selection math is untouched + * because this just reorders the one flat list. + */ +export function orderRowsForCwd( + rows: SessionRow[], + currentCwd: string | undefined, + query: string +): { rows: SessionRow[]; hereCount: number } { + const here = normalizeCwd(currentCwd) + if (!here || query.trim()) return { hereCount: 0, rows } + const local: SessionRow[] = [] + const elsewhere: SessionRow[] = [] + for (const row of rows) (normalizeCwd(row.cwd) === here ? local : elsewhere).push(row) + if (!local.length) return { hereCount: 0, rows } + return { hereCount: local.length, rows: [...local, ...elsewhere] } +} + // ── key routing (pattern: completionMenu.ts routeMenuKey) ──────────────── export interface SessionPickerKeyContext { diff --git a/ui-opentui/src/test/sessionPicker.test.ts b/ui-opentui/src/test/sessionPicker.test.ts index f02c4f4404e..493875fafa0 100644 --- a/ui-opentui/src/test/sessionPicker.test.ts +++ b/ui-opentui/src/test/sessionPicker.test.ts @@ -22,7 +22,9 @@ import { SESSION_TABS, tabAccepts, tailTruncate, - type SessionRow + type SessionRow, + normalizeCwd, + orderRowsForCwd } from '../logic/sessionPicker.ts' const row = (over: Partial): SessionRow => ({ @@ -254,3 +256,28 @@ describe('slash entry points', () => { expect(resolveSessionArg(rows, '')).toBeUndefined() }) }) + +describe('orderRowsForCwd — pure edges', () => { + const row = (id: string, cwd?: string) => + ({ cwd, id, lastActive: 0, messageCount: 0, preview: '', source: 'tui', startedAt: 0, title: id }) as never + + test('normalizeCwd trims and strips trailing slashes; empty-safe', () => { + expect(normalizeCwd(' /a/b// ')).toBe('/a/b') + expect(normalizeCwd(undefined)).toBe('') + expect(normalizeCwd(' ')).toBe('') + }) + + test('identity passthrough when nothing matches or no cwd known', () => { + const rows = [row('a', '/x'), row('b')] + expect(orderRowsForCwd(rows, undefined, '')).toEqual({ hereCount: 0, rows }) + expect(orderRowsForCwd(rows, '/nowhere', '')).toEqual({ hereCount: 0, rows }) + expect(orderRowsForCwd(rows, '/x', 'query')).toEqual({ hereCount: 0, rows }) + }) + + test('stable partition: here first, recency kept within groups', () => { + const rows = [row('a', '/y'), row('b', '/x'), row('c'), row('d', '/x/')] + const out = orderRowsForCwd(rows, '/x', '') + expect(out.hereCount).toBe(2) + expect(out.rows.map(r => (r as { id: string }).id)).toEqual(['b', 'd', 'a', 'c']) + }) +}) diff --git a/ui-opentui/src/test/sessionPickerView.test.tsx b/ui-opentui/src/test/sessionPickerView.test.tsx index 2deaf574e28..bc2f599c427 100644 --- a/ui-opentui/src/test/sessionPickerView.test.tsx +++ b/ui-opentui/src/test/sessionPickerView.test.tsx @@ -74,6 +74,8 @@ interface MountOptions { sessions?: FakeSession[] truncated?: boolean initialTab?: 'recent' | 'cron' | 'gateways' | 'all' + /** The TUI's cwd — drives the this-directory-first grouping. */ + currentCwd?: string /** Per-id peek delay (ms) — drives the stale-cancellation test. */ peekDelay?: (id: string) => number /** Paged list: serve `sessions` windowed by offset/limit instead of whole. */ @@ -118,6 +120,7 @@ async function mountPicker(options: MountOptions = {}): Promise { options.currentCwd} peekDebounceMs={20} onResume={id => resumed.push(id)} onClose={() => (closed.value = true)} @@ -356,3 +359,65 @@ describe('SessionPicker — pagination', () => { } }) }) + +describe('this-directory grouping', () => { + const CWD = '/home/u/projects/alpha' + const GROUPED: FakeSession[] = [ + { cwd: '/elsewhere/beta', id: 'g1', last_active: NOW_S - 60, message_count: 5, source: 'tui', title: 'beta work' }, + { cwd: CWD, id: 'g2', last_active: NOW_S - 120, message_count: 8, source: 'tui', title: 'alpha here' }, + { id: 'g3', last_active: NOW_S - 180, message_count: 2, source: 'cli', title: 'no cwd at all' }, + { + cwd: CWD + '/', + id: 'g4', + last_active: NOW_S - 240, + message_count: 4, + source: 'tui', + title: 'alpha trailing slash' + } + ] + + test('sessions started in the current cwd group first under a caption', async () => { + const h = await mountPicker({ currentCwd: CWD, sessions: GROUPED }) + try { + const frame = h.probe.frame() + const at = (needle: string) => frame.indexOf(needle) + expect(at('▾ this directory (2)')).toBeGreaterThanOrEqual(0) + expect(at('▾ other directories')).toBeGreaterThan(at('▾ this directory (2)')) + // here-rows (incl. trailing-slash normalization) above the caption split, + // elsewhere rows below it; selection starts on the first here-row. + expect(at('alpha here')).toBeLessThan(at('▾ other directories')) + expect(at('alpha trailing slash')).toBeLessThan(at('▾ other directories')) + expect(at('beta work')).toBeGreaterThan(at('▾ other directories')) + expect(at('no cwd at all')).toBeGreaterThan(at('▾ other directories')) + expect(frame).toContain('❯ alpha here') + // Enter resumes the first here-session + h.probe.keys.pressEnter() + await h.probe.settle() + expect(h.resumed).toEqual(['g2']) + } finally { + h.probe.destroy() + } + }) + + test('search drops the grouping (fuzzy relevance owns the order)', async () => { + const h = await mountPicker({ currentCwd: CWD, sessions: GROUPED }) + try { + await h.probe.keys.typeText('beta') + await h.probe.settle() + const frame = await h.probe.waitForFrame(f => f.includes('beta work')) + expect(frame).not.toContain('▾ this directory') + expect(frame).not.toContain('▾ other directories') + } finally { + h.probe.destroy() + } + }) + + test('no current cwd (or no matches) → plain recency list, no captions', async () => { + const h = await mountPicker({ sessions: GROUPED }) + try { + expect(h.probe.frame()).not.toContain('▾ this directory') + } finally { + h.probe.destroy() + } + }) +}) diff --git a/ui-opentui/src/view/App.tsx b/ui-opentui/src/view/App.tsx index 82b6e6b29fe..53a52afd108 100644 --- a/ui-opentui/src/view/App.tsx +++ b/ui-opentui/src/view/App.tsx @@ -156,6 +156,7 @@ export function App(props: AppProps) { props.store.state.info.cwd} onResume={resume} onClose={closeSessionPicker} /> diff --git a/ui-opentui/src/view/overlays/sessionPicker.tsx b/ui-opentui/src/view/overlays/sessionPicker.tsx index bca1c70129e..da3804e59bc 100644 --- a/ui-opentui/src/view/overlays/sessionPicker.tsx +++ b/ui-opentui/src/view/overlays/sessionPicker.tsx @@ -34,6 +34,7 @@ import { visibleRows, type PickerRow } from '../../logic/fuzzy.ts' import { filterSessions, listParamsFor, + orderRowsForCwd, mapSessionRows, relativeTime, routeSessionPickerKey, @@ -79,6 +80,9 @@ export function SessionPicker(props: { onResume: (sessionId: string) => void onClose: () => void initialTab?: SessionTabId + /** The TUI's working directory — sessions started here group first while + * browsing (no effect during search). */ + currentCwd?: () => string | undefined /** Test seam: override the peek debounce (default PEEK_DEBOUNCE_MS). */ peekDebounceMs?: number }) { @@ -134,7 +138,12 @@ export function SessionPicker(props: { }) ) - const filtered = createMemo(() => filterSessions(query(), rows())) + // Display order: this-directory sessions first while browsing (design ask + // 2026-06-12); search keeps pure fuzzy relevance. One flat list, so the + // selection/windowing math below is order-agnostic. + const ordered = createMemo(() => orderRowsForCwd(filterSessions(query(), rows()), props.currentCwd?.(), query())) + const filtered = createMemo(() => ordered().rows) + const hereCount = createMemo(() => ordered().hereCount) createEffect(on(query, () => setSel(0), { defer: true })) // "load more" pseudo-row: selectable index === filtered().length. Offered @@ -371,6 +380,12 @@ export function SessionPicker(props: { {row => row.kind === 'item' && row.item ? ( setSel(row.index)}> + 0 && row.index === 0}> + {` ▾ this directory (${hereCount()})`} + + 0 && row.index === hereCount()}> + {' ▾ other directories'} + {row.index === sel() ? '❯ ' : ' '}