Skip to content

Commit be7894b

Browse files
author
e2e
committed
feat(sessions): suggest recent workspace paths in new-session cwd input
1 parent 592c935 commit be7894b

3 files changed

Lines changed: 89 additions & 6 deletions

File tree

server/modules/providers/services/home-dirs.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import { readdir, realpath } from 'node:fs/promises';
1616
* containment and returns [].
1717
*/
1818

19-
export const MAX_DIR_SUGGESTIONS = 20;
19+
// 50, not 20: the cwd input doubles as a click-through directory browser and
20+
// an alphabetical cut hides late-alphabet folders (e.g. ~/workspace) entirely.
21+
export const MAX_DIR_SUGGESTIONS = 50;
2022

2123
/** Absolute HOME path — clients join it with home-relative picks. */
2224
export function getHomeDir(): string {

src/components/sidebar/view/subcomponents/SidebarNewSession.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ const PROVIDERS: { id: SpawnProvider; label: string }[] = [
2222
{ id: 'omp', label: 'Oh My Pi' },
2323
];
2424

25+
// Working directories of successful spawns, most recent first. Typing an
26+
// absolute path once is enough — later sessions pick it from the dropdown.
27+
const RECENT_CWDS_KEY = 'chatmux-recent-spawn-cwds';
28+
const RECENT_CWDS_MAX = 5;
29+
30+
function readRecentCwds(): string[] {
31+
try {
32+
const raw = localStorage.getItem(RECENT_CWDS_KEY);
33+
const parsed: unknown = raw ? JSON.parse(raw) : [];
34+
return Array.isArray(parsed)
35+
? parsed.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0).slice(0, RECENT_CWDS_MAX)
36+
: [];
37+
} catch {
38+
// localStorage unavailable (SSR/tests) or corrupted payload.
39+
return [];
40+
}
41+
}
42+
2543
/**
2644
* Unified new-session form. GJC boots through the control tower; every other
2745
* provider boots its native CLI in tmux through /sessions/external/spawn.
@@ -37,12 +55,27 @@ export default function SidebarNewSession({
3755
const [open, setOpen] = useState(initiallyOpen);
3856
const [provider, setProvider] = useState<SpawnProvider>('gjc');
3957
const [name, setName] = useState('');
40-
const [cwd, setCwd] = useState('');
58+
const [recentCwds, setRecentCwds] = useState<string[]>(readRecentCwds);
59+
// The most recent working directory is the best default: repeated spawns
60+
// in the same repo need no path input at all.
61+
const [cwd, setCwd] = useState(() => readRecentCwds()[0] ?? '');
4162
const [status, setStatus] = useState<SpawnStatus>({ kind: 'idle' });
4263

64+
const rememberCwd = (path: string) => {
65+
const next = [path, ...recentCwds.filter((entry) => entry !== path)].slice(0, RECENT_CWDS_MAX);
66+
setRecentCwds(next);
67+
try {
68+
localStorage.setItem(RECENT_CWDS_KEY, JSON.stringify(next));
69+
} catch {
70+
// best-effort persistence
71+
}
72+
};
73+
4374
const reset = () => {
4475
setName('');
45-
setCwd('');
76+
// Keep the path of least resistance: the next spawn most likely targets
77+
// the same repo, so the field reopens prefilled with the latest cwd.
78+
setCwd(readRecentCwds()[0] ?? '');
4679
setStatus({ kind: 'idle' });
4780
};
4881

@@ -64,6 +97,7 @@ export default function SidebarNewSession({
6497
detail?: string;
6598
};
6699
if (response.ok && data.ok) {
100+
rememberCwd(trimmedCwd);
67101
setOpen(false);
68102
reset();
69103
return;
@@ -82,6 +116,7 @@ export default function SidebarNewSession({
82116
const response = await api.externalCliSessionSpawn(provider, trimmedName, trimmedCwd);
83117
const body = await response.json().catch(() => null);
84118
if (response.ok && body?.data?.ok) {
119+
rememberCwd(trimmedCwd);
85120
setOpen(false);
86121
reset();
87122
onCreated();
@@ -140,6 +175,8 @@ export default function SidebarNewSession({
140175
onChange={setCwd}
141176
onSubmit={() => void spawn()}
142177
placeholder={t('newSessionForm.workingDirectoryPlaceholder')}
178+
quickPicks={recentCwds}
179+
quickPicksLabel={t('newSessionForm.recentPaths')}
143180
/>
144181
{status.kind === 'error' && <p className="text-[11px] text-red-500">{status.text}</p>}
145182
<div className="flex items-center justify-end gap-2">

src/shared/view/HomeDirInput.tsx

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ type HomeDirInputProps = {
99
onSubmit?: () => void;
1010
placeholder?: string;
1111
className?: string;
12+
/** Known-good absolute or home-relative paths surfaced while the input is empty (e.g. recent spawn cwds). */
13+
quickPicks?: string[];
14+
quickPicksLabel?: string;
1215
};
1316

1417
const DEBOUNCE_MS = 200;
@@ -21,7 +24,15 @@ const DEBOUNCE_MS = 200;
2124
* the endpoint takes home-relative prefixes only). Click or Tab (first match)
2225
* completes. Best-effort — endpoint errors just hide the dropdown.
2326
*/
24-
export default function HomeDirInput({ value, onChange, onSubmit, placeholder, className }: HomeDirInputProps) {
27+
export default function HomeDirInput({
28+
value,
29+
onChange,
30+
onSubmit,
31+
placeholder,
32+
className,
33+
quickPicks,
34+
quickPicksLabel,
35+
}: HomeDirInputProps) {
2536
const [suggestions, setSuggestions] = useState<string[]>([]);
2637
const [open, setOpen] = useState(false);
2738
// Absolute HOME path, learned from the endpoint (every response carries it).
@@ -34,7 +45,9 @@ export default function HomeDirInput({ value, onChange, onSubmit, placeholder, c
3445
if (debounceRef.current) {
3546
clearTimeout(debounceRef.current);
3647
}
37-
const relative = toHomeRelative(value, home);
48+
// Empty input browses HOME itself: focusing the blank field lists the
49+
// top-level folders so a working directory is reachable by clicks alone.
50+
const relative = value.trim() ? toHomeRelative(value, home) : '';
3851
if (relative === null) {
3952
setSuggestions([]);
4053
// An absolute path was typed before we learned HOME — fetch it once and
@@ -87,6 +100,14 @@ export default function HomeDirInput({ value, onChange, onSubmit, placeholder, c
87100
setOpen(true);
88101
};
89102

103+
// Final selections (recent paths) fill the field as-is and close the list.
104+
const pickQuick = (path: string) => {
105+
onChange(path);
106+
setOpen(false);
107+
};
108+
109+
const visibleQuickPicks = value.trim() === '' ? (quickPicks ?? []) : [];
110+
90111
return (
91112
<div className="relative">
92113
<input
@@ -119,8 +140,31 @@ export default function HomeDirInput({ value, onChange, onSubmit, placeholder, c
119140
placeholder={placeholder}
120141
className={className ?? 'w-full rounded-md border border-border bg-transparent px-2 py-1.5 text-sm outline-none focus:border-blue-500/60'}
121142
/>
122-
{open && suggestions.length > 0 && (
143+
{open && (suggestions.length > 0 || visibleQuickPicks.length > 0) && (
123144
<div className="absolute inset-x-0 top-full z-20 mt-1 max-h-48 overflow-y-auto rounded-md border border-border bg-card shadow-lg">
145+
{visibleQuickPicks.length > 0 && (
146+
<>
147+
{quickPicksLabel && (
148+
<div className="px-2 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
149+
{quickPicksLabel}
150+
</div>
151+
)}
152+
{visibleQuickPicks.map((path) => (
153+
<button
154+
key={`quick:${path}`}
155+
type="button"
156+
onMouseDown={(event) => {
157+
event.preventDefault();
158+
pickQuick(path);
159+
}}
160+
className="block w-full truncate px-2 py-1.5 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/60"
161+
>
162+
{path}
163+
</button>
164+
))}
165+
{suggestions.length > 0 && <div className="mx-2 my-1 border-t border-border/60" />}
166+
</>
167+
)}
124168
{suggestions.map((suggestion) => (
125169
<button
126170
key={suggestion}

0 commit comments

Comments
 (0)