claude-remote-cli 1.9.6 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -142,9 +142,10 @@ The PIN hash is stored in config under `pinHash`. To reset:
142
142
  ## Features
143
143
 
144
144
  - **PIN-protected access** with rate limiting
145
- - **Worktree isolation** — each session runs in its own Claude Code `--worktree`
145
+ - **Branch-aware sessions** — create worktrees from new or existing branches with a type-to-search branch picker
146
+ - **Worktree isolation** — each session runs in its own git worktree under `.worktrees/`
146
147
  - **Resume sessions** — click inactive worktrees to reconnect with `--continue`
147
- - **Persistent session names** — display names and timestamps survive server restarts
148
+ - **Persistent session names** — display names, branch names, and timestamps survive server restarts
148
149
  - **Clipboard image paste** — paste screenshots directly into remote terminal sessions (macOS clipboard + xclip on Linux)
149
150
  - **Yolo mode** — skip permission prompts with `--dangerously-skip-permissions` (per-session checkbox or context menu)
150
151
  - **Worktree cleanup** — delete inactive worktrees from the context menu (removes worktree, prunes refs, deletes branch)
@@ -169,7 +170,7 @@ claude-remote-cli/
169
170
  │ ├── index.ts # Express server, REST API routes
170
171
  │ ├── sessions.ts # PTY session manager (node-pty)
171
172
  │ ├── ws.ts # WebSocket relay (PTY ↔ browser)
172
- │ ├── watcher.ts # File watcher for .claude/worktrees/ changes
173
+ │ ├── watcher.ts # File watcher for .worktrees/ changes
173
174
  │ ├── auth.ts # PIN hashing, verification, rate limiting
174
175
  │ ├── config.ts # Config loading/saving, worktree metadata
175
176
  │ ├── clipboard.ts # System clipboard operations (image paste)
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
- import { execFile } from 'node:child_process';
4
+ import { execFile, spawn } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import * as service from '../server/service.js';
8
8
  import { DEFAULTS } from '../server/config.js';
9
9
  const execFileAsync = promisify(execFile);
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ function execErrorMessage(err, fallback) {
12
+ const e = err;
13
+ return (e.stderr || e.message || fallback).trimEnd();
14
+ }
11
15
  // Parse CLI flags
12
16
  const args = process.argv.slice(2);
13
17
  if (args.includes('--help') || args.includes('-h')) {
@@ -19,12 +23,17 @@ Commands:
19
23
  install Install as a background service (survives reboot)
20
24
  uninstall Stop and remove the background service
21
25
  status Show whether the service is running
26
+ worktree Manage git worktrees (wraps git worktree)
27
+ add [path] [-b branch] [--yolo] Create worktree and launch Claude
28
+ remove <path> Forward to git worktree remove
29
+ list Forward to git worktree list
22
30
 
23
31
  Options:
24
32
  --bg Shortcut: install and start as background service
25
33
  --port <port> Override server port (default: 3456)
26
34
  --host <host> Override bind address (default: 0.0.0.0)
27
35
  --config <path> Path to config.json (default: ~/.config/claude-remote-cli/config.json)
36
+ --yolo With 'worktree add': pass --dangerously-skip-permissions to Claude
28
37
  --version, -v Show version
29
38
  --help, -h Show this help`);
30
39
  process.exit(0);
@@ -87,6 +96,76 @@ if (command === 'update') {
87
96
  }
88
97
  process.exit(0);
89
98
  }
99
+ if (command === 'worktree') {
100
+ const wtArgs = args.slice(1);
101
+ const subCommand = wtArgs[0];
102
+ if (!subCommand) {
103
+ console.error('Usage: claude-remote-cli worktree <add|remove|list> [options]');
104
+ process.exit(1);
105
+ }
106
+ if (subCommand !== 'add') {
107
+ try {
108
+ const result = await execFileAsync('git', ['worktree', ...wtArgs]);
109
+ if (result.stdout)
110
+ console.log(result.stdout.trimEnd());
111
+ }
112
+ catch (err) {
113
+ console.error(execErrorMessage(err, 'git worktree failed'));
114
+ process.exit(1);
115
+ }
116
+ process.exit(0);
117
+ }
118
+ // Handle 'add' -- strip --yolo, determine path, forward to git, then launch claude
119
+ const hasYolo = wtArgs.includes('--yolo');
120
+ const gitWtArgs = wtArgs.filter(function (a) { return a !== '--yolo'; });
121
+ const addSubArgs = gitWtArgs.slice(1);
122
+ let targetDir;
123
+ const bIdx = gitWtArgs.indexOf('-b');
124
+ const branchForDefault = bIdx !== -1 && bIdx + 1 < gitWtArgs.length ? gitWtArgs[bIdx + 1] : undefined;
125
+ if (addSubArgs.length === 0 || addSubArgs[0].startsWith('-')) {
126
+ let repoRoot;
127
+ try {
128
+ const result = await execFileAsync('git', ['rev-parse', '--show-toplevel']);
129
+ repoRoot = result.stdout.trim();
130
+ }
131
+ catch {
132
+ console.error('Not inside a git repository.');
133
+ process.exit(1);
134
+ }
135
+ const dirName = branchForDefault
136
+ ? branchForDefault.replace(/\//g, '-')
137
+ : 'worktree-' + Date.now().toString(36);
138
+ targetDir = path.join(repoRoot, '.worktrees', dirName);
139
+ gitWtArgs.splice(1, 0, targetDir);
140
+ }
141
+ else {
142
+ targetDir = path.resolve(addSubArgs[0]);
143
+ }
144
+ try {
145
+ const result = await execFileAsync('git', ['worktree', ...gitWtArgs]);
146
+ if (result.stdout)
147
+ console.log(result.stdout.trimEnd());
148
+ }
149
+ catch (err) {
150
+ console.error(execErrorMessage(err, 'git worktree add failed'));
151
+ process.exit(1);
152
+ }
153
+ console.log(`Worktree created at ${targetDir}`);
154
+ const claudeArgs = [];
155
+ if (hasYolo)
156
+ claudeArgs.push('--dangerously-skip-permissions');
157
+ console.log(`Launching claude${hasYolo ? ' (yolo mode)' : ''} in ${targetDir}...`);
158
+ const child = spawn('claude', claudeArgs, {
159
+ cwd: targetDir,
160
+ stdio: 'inherit',
161
+ env: { ...process.env, CLAUDECODE: undefined },
162
+ });
163
+ child.on('exit', (code) => {
164
+ process.exit(code ?? 0);
165
+ });
166
+ // Block until child exits via the handler above
167
+ await new Promise(() => { });
168
+ }
90
169
  if (command === 'install' || command === 'uninstall' || command === 'status' || args.includes('--bg')) {
91
170
  if (command === 'uninstall') {
92
171
  runServiceCommand(() => { service.uninstall(); });
@@ -12,7 +12,7 @@ import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, ensureMetaDir }
12
12
  import * as auth from './auth.js';
13
13
  import * as sessions from './sessions.js';
14
14
  import { setupWebSocket } from './ws.js';
15
- import { WorktreeWatcher } from './watcher.js';
15
+ import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath } from './watcher.js';
16
16
  import { isInstalled as serviceIsInstalled } from './service.js';
17
17
  import { extensionForMime, setClipboardImage } from './clipboard.js';
18
18
  const __filename = fileURLToPath(import.meta.url);
@@ -59,6 +59,10 @@ async function getLatestVersion() {
59
59
  return null;
60
60
  }
61
61
  }
62
+ function execErrorMessage(err, fallback) {
63
+ const e = err;
64
+ return (e.stderr || e.message || fallback).trim();
65
+ }
62
66
  function parseTTL(ttl) {
63
67
  if (typeof ttl !== 'string')
64
68
  return 24 * 60 * 60 * 1000;
@@ -109,6 +113,24 @@ function scanAllRepos(rootDirs) {
109
113
  }
110
114
  return repos;
111
115
  }
116
+ function ensureGitignore(repoPath, entry) {
117
+ const gitignorePath = path.join(repoPath, '.gitignore');
118
+ try {
119
+ if (fs.existsSync(gitignorePath)) {
120
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
121
+ if (content.split('\n').some((line) => line.trim() === entry))
122
+ return;
123
+ const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
124
+ fs.appendFileSync(gitignorePath, prefix + entry + '\n');
125
+ }
126
+ else {
127
+ fs.writeFileSync(gitignorePath, entry + '\n');
128
+ }
129
+ }
130
+ catch (_) {
131
+ // Non-fatal: gitignore update failure shouldn't block session creation
132
+ }
133
+ }
112
134
  async function main() {
113
135
  let config;
114
136
  try {
@@ -193,6 +215,27 @@ async function main() {
193
215
  }
194
216
  res.json(repos);
195
217
  });
218
+ // GET /branches?repo=<path> — list local and remote branches for a repo
219
+ app.get('/branches', requireAuth, async (req, res) => {
220
+ const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
221
+ if (!repoPath) {
222
+ res.status(400).json({ error: 'repo query parameter is required' });
223
+ return;
224
+ }
225
+ try {
226
+ const { stdout } = await execFileAsync('git', ['branch', '-a', '--format=%(refname:short)'], { cwd: repoPath });
227
+ const branches = stdout
228
+ .split('\n')
229
+ .map((b) => b.trim())
230
+ .filter((b) => b && !b.includes('HEAD'))
231
+ .map((b) => b.replace(/^origin\//, ''));
232
+ const unique = [...new Set(branches)];
233
+ res.json(unique.sort());
234
+ }
235
+ catch (_) {
236
+ res.json([]);
237
+ }
238
+ });
196
239
  // GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
197
240
  app.get('/worktrees', requireAuth, (req, res) => {
198
241
  const repoParam = typeof req.query.repo === 'string' ? req.query.repo : undefined;
@@ -207,28 +250,30 @@ async function main() {
207
250
  reposToScan = scanAllRepos(roots);
208
251
  }
209
252
  for (const repo of reposToScan) {
210
- const worktreeDir = path.join(repo.path, '.claude', 'worktrees');
211
- let entries;
212
- try {
213
- entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
214
- }
215
- catch (_) {
216
- continue;
217
- }
218
- for (const entry of entries) {
219
- if (!entry.isDirectory())
253
+ for (const dir of WORKTREE_DIRS) {
254
+ const worktreeDir = path.join(repo.path, dir);
255
+ let entries;
256
+ try {
257
+ entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
258
+ }
259
+ catch (_) {
220
260
  continue;
221
- const wtPath = path.join(worktreeDir, entry.name);
222
- const meta = readMeta(CONFIG_PATH, wtPath);
223
- worktrees.push({
224
- name: entry.name,
225
- path: wtPath,
226
- repoName: repo.name,
227
- repoPath: repo.path,
228
- root: repo.root,
229
- displayName: meta ? meta.displayName : '',
230
- lastActivity: meta ? meta.lastActivity : '',
231
- });
261
+ }
262
+ for (const entry of entries) {
263
+ if (!entry.isDirectory())
264
+ continue;
265
+ const wtPath = path.join(worktreeDir, entry.name);
266
+ const meta = readMeta(CONFIG_PATH, wtPath);
267
+ worktrees.push({
268
+ name: entry.name,
269
+ path: wtPath,
270
+ repoName: repo.name,
271
+ repoPath: repo.path,
272
+ root: repo.root,
273
+ displayName: meta ? meta.displayName : '',
274
+ lastActivity: meta ? meta.lastActivity : '',
275
+ });
276
+ }
232
277
  }
233
278
  }
234
279
  res.json(worktrees);
@@ -276,9 +321,8 @@ async function main() {
276
321
  res.status(400).json({ error: 'worktreePath and repoPath are required' });
277
322
  return;
278
323
  }
279
- // Validate the path is inside a .claude/worktrees/ directory
280
- if (!worktreePath.includes(path.sep + '.claude' + path.sep + 'worktrees' + path.sep)) {
281
- res.status(400).json({ error: 'Path is not inside a .claude/worktrees/ directory' });
324
+ if (!isValidWorktreePath(worktreePath)) {
325
+ res.status(400).json({ error: 'Path is not inside a worktree directory' });
282
326
  return;
283
327
  }
284
328
  // Check no active session is using this worktree
@@ -288,15 +332,15 @@ async function main() {
288
332
  res.status(409).json({ error: 'Close the active session first' });
289
333
  return;
290
334
  }
291
- // Derive branch name from worktree directory name
292
- const branchName = worktreePath.split('/').pop() || '';
335
+ // Derive branch name from metadata or worktree directory name
336
+ const meta = readMeta(CONFIG_PATH, worktreePath);
337
+ const branchName = (meta && meta.branchName) || worktreePath.split('/').pop() || '';
293
338
  try {
294
- // Remove the worktree (will fail if uncommitted changes no --force)
339
+ // Will fail if uncommitted changes -- no --force
295
340
  await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoPath });
296
341
  }
297
342
  catch (err) {
298
- const message = err instanceof Error ? err.message : 'Failed to remove worktree';
299
- res.status(500).json({ error: message });
343
+ res.status(500).json({ error: execErrorMessage(err, 'Failed to remove worktree') });
300
344
  return;
301
345
  }
302
346
  try {
@@ -318,8 +362,8 @@ async function main() {
318
362
  res.json({ ok: true });
319
363
  });
320
364
  // POST /sessions
321
- app.post('/sessions', requireAuth, (req, res) => {
322
- const { repoPath, repoName, worktreePath, claudeArgs } = req.body;
365
+ app.post('/sessions', requireAuth, async (req, res) => {
366
+ const { repoPath, repoName, worktreePath, branchName, claudeArgs } = req.body;
323
367
  if (!repoPath) {
324
368
  res.status(400).json({ error: 'repoPath is required' });
325
369
  return;
@@ -333,32 +377,87 @@ async function main() {
333
377
  let cwd;
334
378
  let worktreeName;
335
379
  let sessionRepoPath;
380
+ let resolvedBranch = '';
336
381
  if (worktreePath) {
337
- // Resume existing worktree — run claude --continue inside the worktree directory
382
+ // Resume existing worktree
338
383
  args = ['--continue', ...baseArgs];
339
384
  cwd = worktreePath;
340
385
  sessionRepoPath = worktreePath;
341
386
  worktreeName = worktreePath.split('/').pop() || '';
342
387
  }
343
388
  else {
344
- // New worktree — PTY spawns in the repo root (so `claude --worktree X` works),
345
- // but repoPath points to the expected worktree dir for identity/metadata matching
346
- worktreeName = 'mobile-' + name + '-' + Date.now().toString(36);
347
- args = ['--worktree', worktreeName, ...baseArgs];
348
- cwd = repoPath;
349
- sessionRepoPath = path.join(repoPath, '.claude', 'worktrees', worktreeName);
389
+ // Create new worktree via git
390
+ let dirName;
391
+ if (branchName) {
392
+ dirName = branchName.replace(/\//g, '-');
393
+ resolvedBranch = branchName;
394
+ }
395
+ else {
396
+ dirName = 'mobile-' + name + '-' + Date.now().toString(36);
397
+ resolvedBranch = dirName;
398
+ }
399
+ const worktreeDir = path.join(repoPath, '.worktrees');
400
+ let targetDir = path.join(worktreeDir, dirName);
401
+ if (fs.existsSync(targetDir)) {
402
+ targetDir = targetDir + '-' + Date.now().toString(36);
403
+ dirName = path.basename(targetDir);
404
+ }
405
+ ensureGitignore(repoPath, '.worktrees/');
406
+ try {
407
+ // Check if branch exists locally or on a remote
408
+ let branchExists = false;
409
+ if (branchName) {
410
+ const localCheck = await execFileAsync('git', ['rev-parse', '--verify', branchName], { cwd: repoPath }).then(() => true, () => false);
411
+ if (localCheck) {
412
+ branchExists = true;
413
+ }
414
+ else {
415
+ const remoteCheck = await execFileAsync('git', ['rev-parse', '--verify', 'origin/' + branchName], { cwd: repoPath }).then(() => true, () => false);
416
+ if (remoteCheck) {
417
+ branchExists = true;
418
+ resolvedBranch = 'origin/' + branchName;
419
+ }
420
+ }
421
+ }
422
+ if (branchName && branchExists) {
423
+ await execFileAsync('git', ['worktree', 'add', targetDir, resolvedBranch], { cwd: repoPath });
424
+ }
425
+ else if (branchName) {
426
+ await execFileAsync('git', ['worktree', 'add', '-b', branchName, targetDir, 'HEAD'], { cwd: repoPath });
427
+ }
428
+ else {
429
+ await execFileAsync('git', ['worktree', 'add', '-b', dirName, targetDir, 'HEAD'], { cwd: repoPath });
430
+ }
431
+ }
432
+ catch (err) {
433
+ res.status(500).json({ error: execErrorMessage(err, 'Failed to create worktree') });
434
+ return;
435
+ }
436
+ worktreeName = dirName;
437
+ sessionRepoPath = targetDir;
438
+ cwd = targetDir;
439
+ args = [...baseArgs];
350
440
  }
441
+ const displayName = branchName || worktreeName;
351
442
  const session = sessions.create({
352
443
  repoName: name,
353
444
  repoPath: sessionRepoPath,
354
445
  cwd,
355
446
  root,
356
447
  worktreeName,
357
- displayName: worktreeName,
448
+ displayName,
358
449
  command: config.claudeCommand,
359
450
  args,
360
451
  configPath: CONFIG_PATH,
361
452
  });
453
+ if (!worktreePath) {
454
+ writeMeta(CONFIG_PATH, {
455
+ worktreePath: sessionRepoPath,
456
+ displayName,
457
+ lastActivity: new Date().toISOString(),
458
+ branchName: branchName || worktreeName,
459
+ });
460
+ }
362
461
  res.status(201).json(session);
363
462
  });
364
463
  // DELETE /sessions/:id
@@ -1,6 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { EventEmitter } from 'node:events';
4
+ export const WORKTREE_DIRS = ['.worktrees', '.claude/worktrees'];
5
+ export function isValidWorktreePath(worktreePath) {
6
+ const resolved = path.resolve(worktreePath);
7
+ return WORKTREE_DIRS.some(function (dir) {
8
+ return resolved.includes(path.sep + dir + path.sep);
9
+ });
10
+ }
4
11
  export class WorktreeWatcher extends EventEmitter {
5
12
  _watchers;
6
13
  _debounceTimer;
@@ -30,16 +37,18 @@ export class WorktreeWatcher extends EventEmitter {
30
37
  }
31
38
  }
32
39
  _watchRepo(repoPath) {
33
- const worktreeDir = path.join(repoPath, '.claude', 'worktrees');
34
- if (fs.existsSync(worktreeDir)) {
35
- this._addWatch(worktreeDir);
36
- }
37
- else {
38
- const claudeDir = path.join(repoPath, '.claude');
39
- if (fs.existsSync(claudeDir)) {
40
- this._addWatch(claudeDir);
40
+ let anyWatched = false;
41
+ for (const dir of WORKTREE_DIRS) {
42
+ const worktreeDir = path.join(repoPath, dir);
43
+ if (fs.existsSync(worktreeDir)) {
44
+ this._addWatch(worktreeDir);
45
+ anyWatched = true;
41
46
  }
42
47
  }
48
+ if (!anyWatched) {
49
+ // Watch repo root so we detect when either dir is first created
50
+ this._addWatch(repoPath);
51
+ }
43
52
  }
44
53
  _addWatch(dirPath) {
45
54
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.9.6",
3
+ "version": "2.1.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
package/public/app.js CHANGED
@@ -37,6 +37,8 @@
37
37
  var dialogRootSelect = document.getElementById('dialog-root-select');
38
38
  var dialogRepoSelect = document.getElementById('dialog-repo-select');
39
39
  var dialogYolo = document.getElementById('dialog-yolo');
40
+ var dialogBranchInput = document.getElementById('dialog-branch-input');
41
+ var dialogBranchList = document.getElementById('dialog-branch-list');
40
42
  var contextMenu = document.getElementById('context-menu');
41
43
  var ctxResumeYolo = document.getElementById('ctx-resume-yolo');
42
44
  var ctxDeleteWorktree = document.getElementById('ctx-delete-worktree');
@@ -89,8 +91,82 @@
89
91
  var cachedSessions = [];
90
92
  var cachedWorktrees = [];
91
93
  var allRepos = [];
94
+ var allBranches = [];
92
95
  var attentionSessions = {};
93
96
 
97
+ function loadBranches(repoPath) {
98
+ allBranches = [];
99
+ dialogBranchList.innerHTML = '';
100
+ dialogBranchList.hidden = true;
101
+ if (!repoPath) return;
102
+
103
+ fetch('/branches?repo=' + encodeURIComponent(repoPath))
104
+ .then(function (res) {
105
+ if (!res.ok) return [];
106
+ return res.json();
107
+ })
108
+ .then(function (data) {
109
+ allBranches = data || [];
110
+ })
111
+ .catch(function () {
112
+ allBranches = [];
113
+ });
114
+ }
115
+
116
+ function filterBranches(query) {
117
+ dialogBranchList.innerHTML = '';
118
+ if (!query) {
119
+ dialogBranchList.hidden = true;
120
+ return;
121
+ }
122
+
123
+ var lower = query.toLowerCase();
124
+ var matches = allBranches.filter(function (b) {
125
+ return b.toLowerCase().indexOf(lower) !== -1;
126
+ }).slice(0, 10);
127
+
128
+ var exactMatch = allBranches.some(function (b) { return b === query; });
129
+
130
+ if (!exactMatch) {
131
+ var createLi = document.createElement('li');
132
+ createLi.className = 'branch-create-new';
133
+ createLi.textContent = 'Create new: ' + query;
134
+ createLi.addEventListener('click', function () {
135
+ dialogBranchInput.value = query;
136
+ dialogBranchList.hidden = true;
137
+ });
138
+ dialogBranchList.appendChild(createLi);
139
+ }
140
+
141
+ matches.forEach(function (branch) {
142
+ var li = document.createElement('li');
143
+ li.textContent = branch;
144
+ li.addEventListener('click', function () {
145
+ dialogBranchInput.value = branch;
146
+ dialogBranchList.hidden = true;
147
+ });
148
+ dialogBranchList.appendChild(li);
149
+ });
150
+
151
+ dialogBranchList.hidden = dialogBranchList.children.length === 0;
152
+ }
153
+
154
+ dialogBranchInput.addEventListener('input', function () {
155
+ filterBranches(dialogBranchInput.value.trim());
156
+ });
157
+
158
+ dialogBranchInput.addEventListener('focus', function () {
159
+ if (dialogBranchInput.value.trim()) {
160
+ filterBranches(dialogBranchInput.value.trim());
161
+ }
162
+ });
163
+
164
+ document.addEventListener('click', function (e) {
165
+ if (!dialogBranchInput.contains(e.target) && !dialogBranchList.contains(e.target)) {
166
+ dialogBranchList.hidden = true;
167
+ }
168
+ });
169
+
94
170
  // ── PIN Auth ────────────────────────────────────────────────────────────────
95
171
 
96
172
  function submitPin() {
@@ -917,6 +993,8 @@
917
993
  dialogRootSelect.addEventListener('change', function () {
918
994
  var root = dialogRootSelect.value;
919
995
  dialogRepoSelect.innerHTML = '<option value="">Select a repo...</option>';
996
+ dialogBranchInput.value = '';
997
+ allBranches = [];
920
998
 
921
999
  if (!root) {
922
1000
  dialogRepoSelect.disabled = true;
@@ -934,13 +1012,20 @@
934
1012
  dialogRepoSelect.disabled = false;
935
1013
  });
936
1014
 
937
- function startSession(repoPath, worktreePath, claudeArgs) {
1015
+ dialogRepoSelect.addEventListener('change', function () {
1016
+ var repoPath = dialogRepoSelect.value;
1017
+ dialogBranchInput.value = '';
1018
+ loadBranches(repoPath);
1019
+ });
1020
+
1021
+ function startSession(repoPath, worktreePath, claudeArgs, branchName) {
938
1022
  var body = {
939
1023
  repoPath: repoPath,
940
1024
  repoName: repoPath.split('/').filter(Boolean).pop(),
941
1025
  };
942
1026
  if (worktreePath) body.worktreePath = worktreePath;
943
1027
  if (claudeArgs) body.claudeArgs = claudeArgs;
1028
+ if (branchName) body.branchName = branchName;
944
1029
 
945
1030
  fetch('/sessions', {
946
1031
  method: 'POST',
@@ -961,6 +1046,9 @@
961
1046
  newSessionBtn.addEventListener('click', function () {
962
1047
  customPath.value = '';
963
1048
  dialogYolo.checked = false;
1049
+ dialogBranchInput.value = '';
1050
+ dialogBranchList.hidden = true;
1051
+ allBranches = [];
964
1052
  populateDialogRootSelect();
965
1053
 
966
1054
  var sidebarRoot = sidebarRootFilter.value;
@@ -985,10 +1073,18 @@
985
1073
  });
986
1074
 
987
1075
  dialogStart.addEventListener('click', function () {
988
- var path = customPath.value.trim() || dialogRepoSelect.value;
989
- if (!path) return;
1076
+ var repoPathValue = customPath.value.trim() || dialogRepoSelect.value;
1077
+ if (!repoPathValue) return;
990
1078
  var args = dialogYolo.checked ? ['--dangerously-skip-permissions'] : undefined;
991
- startSession(path, undefined, args);
1079
+ var branch = dialogBranchInput.value.trim() || undefined;
1080
+ startSession(repoPathValue, undefined, args, branch);
1081
+ });
1082
+
1083
+ customPath.addEventListener('blur', function () {
1084
+ var pathValue = customPath.value.trim();
1085
+ if (pathValue) {
1086
+ loadBranches(pathValue);
1087
+ }
992
1088
  });
993
1089
 
994
1090
  dialogCancel.addEventListener('click', function () {
package/public/index.html CHANGED
@@ -131,6 +131,15 @@
131
131
  <select id="dialog-repo-select" disabled><option value="">Select a repo...</option></select>
132
132
  </div>
133
133
 
134
+ <div class="dialog-field">
135
+ <label for="dialog-branch-input">Branch</label>
136
+ <div class="branch-input-wrapper">
137
+ <input type="text" id="dialog-branch-input" placeholder="Search or create branch..." autocomplete="off" />
138
+ <ul id="dialog-branch-list" class="branch-dropdown" hidden></ul>
139
+ </div>
140
+ <span class="dialog-option-hint">Leave empty for auto-generated name</span>
141
+ </div>
142
+
134
143
  <hr class="dialog-separator" />
135
144
  <div class="dialog-custom-path">
136
145
  <label for="custom-path-input">Or enter a local path:</label>
package/public/style.css CHANGED
@@ -618,6 +618,62 @@ dialog#new-session-dialog h2 {
618
618
  cursor: not-allowed;
619
619
  }
620
620
 
621
+ /* ===== Branch Input ===== */
622
+ .branch-input-wrapper {
623
+ position: relative;
624
+ }
625
+
626
+ .branch-input-wrapper input {
627
+ width: 100%;
628
+ padding: 10px 12px;
629
+ background: var(--bg);
630
+ border: 1px solid var(--border);
631
+ border-radius: 6px;
632
+ color: var(--text);
633
+ font-size: 0.875rem;
634
+ outline: none;
635
+ -webkit-appearance: none;
636
+ }
637
+
638
+ .branch-input-wrapper input:focus {
639
+ border-color: var(--accent);
640
+ }
641
+
642
+ .branch-dropdown {
643
+ position: absolute;
644
+ top: 100%;
645
+ left: 0;
646
+ right: 0;
647
+ max-height: 200px;
648
+ overflow-y: auto;
649
+ background: var(--bg);
650
+ border: 1px solid var(--border);
651
+ border-top: none;
652
+ border-radius: 0 0 6px 6px;
653
+ list-style: none;
654
+ z-index: 10;
655
+ margin: 0;
656
+ padding: 0;
657
+ }
658
+
659
+ .branch-dropdown li {
660
+ padding: 8px 12px;
661
+ font-size: 0.85rem;
662
+ color: var(--text);
663
+ cursor: pointer;
664
+ }
665
+
666
+ .branch-dropdown li:hover,
667
+ .branch-dropdown li.highlighted {
668
+ background: var(--surface);
669
+ color: var(--accent);
670
+ }
671
+
672
+ .branch-dropdown li.branch-create-new {
673
+ color: var(--accent);
674
+ font-style: italic;
675
+ }
676
+
621
677
  .repo-group-label {
622
678
  font-size: 0.75rem;
623
679
  font-weight: 600;