claude-remote-cli 2.0.0 → 2.2.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,8 +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
- - **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/`
145
+ - **Repo sessions** — open Claude directly in any repo root, with fresh or `--continue` mode (one session per repo)
146
+ - **Branch-aware worktrees** — create worktrees from new or existing branches with a type-to-search branch picker
147
+ - **Tabbed sidebar** — switch between Repos and Worktrees views with shared filters and item counts
148
+ - **Worktree isolation** — each worktree session runs in its own git worktree under `.worktrees/`
147
149
  - **Resume sessions** — click inactive worktrees to reconnect with `--continue`
148
150
  - **Persistent session names** — display names, branch names, and timestamps survive server restarts
149
151
  - **Clipboard image paste** — paste screenshots directly into remote terminal sessions (macOS clipboard + xclip on Linux)
@@ -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;
@@ -246,28 +250,30 @@ async function main() {
246
250
  reposToScan = scanAllRepos(roots);
247
251
  }
248
252
  for (const repo of reposToScan) {
249
- const worktreeDir = path.join(repo.path, '.worktrees');
250
- let entries;
251
- try {
252
- entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
253
- }
254
- catch (_) {
255
- continue;
256
- }
257
- for (const entry of entries) {
258
- 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 (_) {
259
260
  continue;
260
- const wtPath = path.join(worktreeDir, entry.name);
261
- const meta = readMeta(CONFIG_PATH, wtPath);
262
- worktrees.push({
263
- name: entry.name,
264
- path: wtPath,
265
- repoName: repo.name,
266
- repoPath: repo.path,
267
- root: repo.root,
268
- displayName: meta ? meta.displayName : '',
269
- lastActivity: meta ? meta.lastActivity : '',
270
- });
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
+ }
271
277
  }
272
278
  }
273
279
  res.json(worktrees);
@@ -315,9 +321,8 @@ async function main() {
315
321
  res.status(400).json({ error: 'worktreePath and repoPath are required' });
316
322
  return;
317
323
  }
318
- // Validate the path is inside a .worktrees/ directory
319
- if (!worktreePath.includes(path.sep + '.worktrees' + path.sep)) {
320
- res.status(400).json({ error: 'Path is not inside a .worktrees/ directory' });
324
+ if (!isValidWorktreePath(worktreePath)) {
325
+ res.status(400).json({ error: 'Path is not inside a worktree directory' });
321
326
  return;
322
327
  }
323
328
  // Check no active session is using this worktree
@@ -335,9 +340,7 @@ async function main() {
335
340
  await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoPath });
336
341
  }
337
342
  catch (err) {
338
- const execErr = err;
339
- const message = (execErr.stderr || execErr.message || 'Failed to remove worktree').trim();
340
- res.status(500).json({ error: message });
343
+ res.status(500).json({ error: execErrorMessage(err, 'Failed to remove worktree') });
341
344
  return;
342
345
  }
343
346
  try {
@@ -427,9 +430,7 @@ async function main() {
427
430
  }
428
431
  }
429
432
  catch (err) {
430
- const execErr = err;
431
- const message = (execErr.stderr || execErr.message || 'Failed to create worktree').trim();
432
- res.status(500).json({ error: message });
433
+ res.status(500).json({ error: execErrorMessage(err, 'Failed to create worktree') });
433
434
  return;
434
435
  }
435
436
  worktreeName = dirName;
@@ -439,6 +440,7 @@ async function main() {
439
440
  }
440
441
  const displayName = branchName || worktreeName;
441
442
  const session = sessions.create({
443
+ type: 'worktree',
442
444
  repoName: name,
443
445
  repoPath: sessionRepoPath,
444
446
  cwd,
@@ -459,6 +461,36 @@ async function main() {
459
461
  }
460
462
  res.status(201).json(session);
461
463
  });
464
+ // POST /sessions/repo — start a session in the repo root (no worktree)
465
+ app.post('/sessions/repo', requireAuth, (req, res) => {
466
+ const { repoPath, repoName, continue: continueSession, claudeArgs } = req.body;
467
+ if (!repoPath) {
468
+ res.status(400).json({ error: 'repoPath is required' });
469
+ return;
470
+ }
471
+ // One repo session at a time
472
+ const existing = sessions.findRepoSession(repoPath);
473
+ if (existing) {
474
+ res.status(409).json({ error: 'A session already exists for this repo', sessionId: existing.id });
475
+ return;
476
+ }
477
+ const name = repoName || repoPath.split('/').filter(Boolean).pop() || 'session';
478
+ const baseArgs = [...(config.claudeArgs || []), ...(claudeArgs || [])];
479
+ const args = continueSession ? ['--continue', ...baseArgs] : [...baseArgs];
480
+ const roots = config.rootDirs || [];
481
+ const root = roots.find(function (r) { return repoPath.startsWith(r); }) || '';
482
+ const session = sessions.create({
483
+ type: 'repo',
484
+ repoName: name,
485
+ repoPath,
486
+ cwd: repoPath,
487
+ root,
488
+ displayName: name,
489
+ command: config.claudeCommand,
490
+ args,
491
+ });
492
+ res.status(201).json(session);
493
+ });
462
494
  // DELETE /sessions/:id
463
495
  app.delete('/sessions/:id', requireAuth, (req, res) => {
464
496
  try {
@@ -11,7 +11,7 @@ let idleChangeCallback = null;
11
11
  function onIdleChange(cb) {
12
12
  idleChangeCallback = cb;
13
13
  }
14
- function create({ repoName, repoPath, cwd, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
14
+ function create({ type, repoName, repoPath, cwd, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
15
15
  const id = crypto.randomBytes(8).toString('hex');
16
16
  const createdAt = new Date().toISOString();
17
17
  // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
@@ -30,6 +30,7 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
30
30
  const MAX_SCROLLBACK = 256 * 1024; // 256KB max
31
31
  const session = {
32
32
  id,
33
+ type: type || 'worktree',
33
34
  root: root || '',
34
35
  repoName: repoName || '',
35
36
  repoPath,
@@ -96,15 +97,16 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
96
97
  const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
97
98
  fs.rm(tmpDir, { recursive: true, force: true }, () => { });
98
99
  });
99
- return { id, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false };
100
+ return { id, type: session.type, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false };
100
101
  }
101
102
  function get(id) {
102
103
  return sessions.get(id);
103
104
  }
104
105
  function list() {
105
106
  return Array.from(sessions.values())
106
- .map(({ id, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity, idle }) => ({
107
+ .map(({ id, type, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity, idle }) => ({
107
108
  id,
109
+ type,
108
110
  root,
109
111
  repoName,
110
112
  repoPath,
@@ -145,4 +147,7 @@ function write(id, data) {
145
147
  }
146
148
  session.pty.write(data);
147
149
  }
148
- export { create, get, list, kill, resize, updateDisplayName, write, onIdleChange };
150
+ function findRepoSession(repoPath) {
151
+ return list().find((s) => s.type === 'repo' && s.repoPath === repoPath);
152
+ }
153
+ export { create, get, list, kill, resize, updateDisplayName, write, onIdleChange, findRepoSession };
@@ -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,12 +37,16 @@ export class WorktreeWatcher extends EventEmitter {
30
37
  }
31
38
  }
32
39
  _watchRepo(repoPath) {
33
- const worktreeDir = path.join(repoPath, '.worktrees');
34
- if (fs.existsSync(worktreeDir)) {
35
- this._addWatch(worktreeDir);
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;
46
+ }
36
47
  }
37
- else {
38
- // Watch the repo root so we detect when .worktrees/ is first created
48
+ if (!anyWatched) {
49
+ // Watch repo root so we detect when either dir is first created
39
50
  this._addWatch(repoPath);
40
51
  }
41
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "2.0.0",
3
+ "version": "2.2.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
@@ -39,6 +39,8 @@
39
39
  var dialogYolo = document.getElementById('dialog-yolo');
40
40
  var dialogBranchInput = document.getElementById('dialog-branch-input');
41
41
  var dialogBranchList = document.getElementById('dialog-branch-list');
42
+ var dialogContinue = document.getElementById('dialog-continue');
43
+ var dialogContinueField = document.getElementById('dialog-continue-field');
42
44
  var contextMenu = document.getElementById('context-menu');
43
45
  var ctxResumeYolo = document.getElementById('ctx-resume-yolo');
44
46
  var ctxDeleteWorktree = document.getElementById('ctx-delete-worktree');
@@ -60,6 +62,10 @@
60
62
  var terminalScrollbarThumb = document.getElementById('terminal-scrollbar-thumb');
61
63
  var mobileInput = document.getElementById('mobile-input');
62
64
  var mobileHeader = document.getElementById('mobile-header');
65
+ var sidebarTabs = document.querySelectorAll('.sidebar-tab');
66
+ var tabReposCount = document.getElementById('tab-repos-count');
67
+ var tabWorktreesCount = document.getElementById('tab-worktrees-count');
68
+ var activeTab = 'repos';
63
69
  var isMobileDevice = 'ontouchstart' in window;
64
70
 
65
71
  // Context menu state
@@ -92,6 +98,7 @@
92
98
  var cachedWorktrees = [];
93
99
  var allRepos = [];
94
100
  var allBranches = [];
101
+ var cachedRepos = [];
95
102
  var attentionSessions = {};
96
103
 
97
104
  function loadBranches(repoPath) {
@@ -523,10 +530,12 @@
523
530
  Promise.all([
524
531
  fetch('/sessions').then(function (res) { return res.json(); }),
525
532
  fetch('/worktrees').then(function (res) { return res.json(); }),
533
+ fetch('/repos').then(function (res) { return res.json(); }),
526
534
  ])
527
535
  .then(function (results) {
528
536
  cachedSessions = results[0] || [];
529
537
  cachedWorktrees = results[1] || [];
538
+ cachedRepos = results[2] || [];
530
539
 
531
540
  // Prune attention flags for sessions that no longer exist
532
541
  var activeIds = {};
@@ -606,6 +615,16 @@
606
615
  renderUnifiedList();
607
616
  });
608
617
 
618
+ sidebarTabs.forEach(function (tab) {
619
+ tab.addEventListener('click', function () {
620
+ activeTab = tab.dataset.tab;
621
+ sidebarTabs.forEach(function (t) { t.classList.remove('active'); });
622
+ tab.classList.add('active');
623
+ newSessionBtn.textContent = activeTab === 'repos' ? '+ New Session' : '+ New Worktree';
624
+ renderUnifiedList();
625
+ });
626
+ });
627
+
609
628
  function rootShortName(path) {
610
629
  return path.split('/').filter(Boolean).pop() || path;
611
630
  }
@@ -633,7 +652,42 @@
633
652
  var repoFilter = sidebarRepoFilter.value;
634
653
  var textFilter = sessionFilter.value.toLowerCase();
635
654
 
636
- var filteredSessions = cachedSessions.filter(function (s) {
655
+ // Split sessions by type
656
+ var repoSessions = cachedSessions.filter(function (s) { return s.type === 'repo'; });
657
+ var worktreeSessions = cachedSessions.filter(function (s) { return s.type !== 'repo'; });
658
+
659
+ // Filtered repo sessions
660
+ var filteredRepoSessions = repoSessions.filter(function (s) {
661
+ if (rootFilter && s.root !== rootFilter) return false;
662
+ if (repoFilter && s.repoName !== repoFilter) return false;
663
+ if (textFilter) {
664
+ var name = (s.displayName || s.repoName || s.id).toLowerCase();
665
+ if (name.indexOf(textFilter) === -1) return false;
666
+ }
667
+ return true;
668
+ });
669
+
670
+ // Idle repos: all repos without an active repo session
671
+ var activeRepoPathSet = new Set();
672
+ repoSessions.forEach(function (s) { activeRepoPathSet.add(s.repoPath); });
673
+
674
+ var filteredIdleRepos = cachedRepos.filter(function (r) {
675
+ if (activeRepoPathSet.has(r.path)) return false;
676
+ if (rootFilter && r.root !== rootFilter) return false;
677
+ if (repoFilter && r.name !== repoFilter) return false;
678
+ if (textFilter) {
679
+ var name = (r.name || '').toLowerCase();
680
+ if (name.indexOf(textFilter) === -1) return false;
681
+ }
682
+ return true;
683
+ });
684
+
685
+ filteredIdleRepos.sort(function (a, b) {
686
+ return (a.name || '').localeCompare(b.name || '');
687
+ });
688
+
689
+ // Filtered worktree sessions
690
+ var filteredWorktreeSessions = worktreeSessions.filter(function (s) {
637
691
  if (rootFilter && s.root !== rootFilter) return false;
638
692
  if (repoFilter && s.repoName !== repoFilter) return false;
639
693
  if (textFilter) {
@@ -643,8 +697,9 @@
643
697
  return true;
644
698
  });
645
699
 
700
+ // Inactive worktrees (deduped against active sessions)
646
701
  var activeWorktreePaths = new Set();
647
- cachedSessions.forEach(function (s) {
702
+ worktreeSessions.forEach(function (s) {
648
703
  if (s.repoPath) activeWorktreePaths.add(s.repoPath);
649
704
  });
650
705
 
@@ -663,23 +718,35 @@
663
718
  return (a.name || '').localeCompare(b.name || '');
664
719
  });
665
720
 
666
- sessionList.innerHTML = '';
721
+ // Update tab counts
722
+ tabReposCount.textContent = filteredRepoSessions.length + filteredIdleRepos.length;
723
+ tabWorktreesCount.textContent = filteredWorktreeSessions.length + filteredWorktrees.length;
667
724
 
668
- filteredSessions.forEach(function (session) {
669
- sessionList.appendChild(createActiveSessionLi(session));
670
- });
725
+ // Render based on active tab
726
+ sessionList.innerHTML = '';
671
727
 
672
- if (filteredSessions.length > 0 && filteredWorktrees.length > 0) {
673
- var divider = document.createElement('li');
674
- divider.className = 'session-divider';
675
- divider.textContent = 'Available';
676
- sessionList.appendChild(divider);
728
+ if (activeTab === 'repos') {
729
+ filteredRepoSessions.forEach(function (session) {
730
+ sessionList.appendChild(createActiveSessionLi(session));
731
+ });
732
+ if (filteredRepoSessions.length > 0 && filteredIdleRepos.length > 0) {
733
+ sessionList.appendChild(createSectionDivider('Available'));
734
+ }
735
+ filteredIdleRepos.forEach(function (repo) {
736
+ sessionList.appendChild(createIdleRepoLi(repo));
737
+ });
738
+ } else {
739
+ filteredWorktreeSessions.forEach(function (session) {
740
+ sessionList.appendChild(createActiveSessionLi(session));
741
+ });
742
+ if (filteredWorktreeSessions.length > 0 && filteredWorktrees.length > 0) {
743
+ sessionList.appendChild(createSectionDivider('Available'));
744
+ }
745
+ filteredWorktrees.forEach(function (wt) {
746
+ sessionList.appendChild(createInactiveWorktreeLi(wt));
747
+ });
677
748
  }
678
749
 
679
- filteredWorktrees.forEach(function (wt) {
680
- sessionList.appendChild(createInactiveWorktreeLi(wt));
681
- });
682
-
683
750
  highlightActiveSession();
684
751
  }
685
752
 
@@ -824,6 +891,45 @@
824
891
  return li;
825
892
  }
826
893
 
894
+ function createSectionDivider(label) {
895
+ var divider = document.createElement('li');
896
+ divider.className = 'session-divider';
897
+ divider.textContent = label;
898
+ return divider;
899
+ }
900
+
901
+ function createIdleRepoLi(repo) {
902
+ var li = document.createElement('li');
903
+ li.className = 'inactive-worktree';
904
+ li.title = repo.path;
905
+
906
+ var infoDiv = document.createElement('div');
907
+ infoDiv.className = 'session-info';
908
+
909
+ var nameSpan = document.createElement('span');
910
+ nameSpan.className = 'session-name';
911
+ nameSpan.textContent = repo.name;
912
+ nameSpan.title = repo.name;
913
+
914
+ var dot = document.createElement('span');
915
+ dot.className = 'status-dot status-dot--inactive';
916
+
917
+ var subSpan = document.createElement('span');
918
+ subSpan.className = 'session-sub';
919
+ subSpan.textContent = repo.root ? rootShortName(repo.root) : repo.path;
920
+
921
+ infoDiv.appendChild(dot);
922
+ infoDiv.appendChild(nameSpan);
923
+ infoDiv.appendChild(subSpan);
924
+ li.appendChild(infoDiv);
925
+
926
+ li.addEventListener('click', function () {
927
+ openNewSessionDialogForRepo(repo);
928
+ });
929
+
930
+ return li;
931
+ }
932
+
827
933
  function startRename(li, session) {
828
934
  var nameSpan = li.querySelector('.session-name');
829
935
  if (!nameSpan) return;
@@ -1043,13 +1149,74 @@
1043
1149
  .catch(function () {});
1044
1150
  }
1045
1151
 
1046
- newSessionBtn.addEventListener('click', function () {
1152
+ function resetDialogFields() {
1047
1153
  customPath.value = '';
1048
1154
  dialogYolo.checked = false;
1155
+ dialogContinue.checked = false;
1049
1156
  dialogBranchInput.value = '';
1050
1157
  dialogBranchList.hidden = true;
1051
1158
  allBranches = [];
1052
1159
  populateDialogRootSelect();
1160
+ }
1161
+
1162
+ function showDialogForTab(tab) {
1163
+ var dialogBranchField = dialogBranchInput.closest('.dialog-field');
1164
+ if (tab === 'repos') {
1165
+ dialogBranchField.hidden = true;
1166
+ dialogContinueField.hidden = false;
1167
+ dialogStart.textContent = 'New Session';
1168
+ } else {
1169
+ dialogBranchField.hidden = false;
1170
+ dialogContinueField.hidden = true;
1171
+ dialogStart.textContent = 'New Worktree';
1172
+ }
1173
+ }
1174
+
1175
+ function openNewSessionDialogForRepo(repo) {
1176
+ resetDialogFields();
1177
+
1178
+ if (repo.root) {
1179
+ dialogRootSelect.value = repo.root;
1180
+ dialogRootSelect.dispatchEvent(new Event('change'));
1181
+ dialogRepoSelect.value = repo.path;
1182
+ }
1183
+
1184
+ showDialogForTab('repos');
1185
+ dialog.showModal();
1186
+ }
1187
+
1188
+ function startRepoSession(repoPath, continueSession, claudeArgs) {
1189
+ var body = { repoPath: repoPath };
1190
+ if (continueSession) body.continue = true;
1191
+ if (claudeArgs) body.claudeArgs = claudeArgs;
1192
+
1193
+ fetch('/sessions/repo', {
1194
+ method: 'POST',
1195
+ headers: { 'Content-Type': 'application/json' },
1196
+ body: JSON.stringify(body),
1197
+ })
1198
+ .then(function (res) {
1199
+ if (res.status === 409) {
1200
+ return res.json().then(function (data) {
1201
+ if (dialog.open) dialog.close();
1202
+ refreshAll();
1203
+ if (data.sessionId) connectToSession(data.sessionId);
1204
+ return null;
1205
+ });
1206
+ }
1207
+ return res.json();
1208
+ })
1209
+ .then(function (data) {
1210
+ if (!data) return;
1211
+ if (dialog.open) dialog.close();
1212
+ refreshAll();
1213
+ if (data.id) connectToSession(data.id);
1214
+ })
1215
+ .catch(function () {});
1216
+ }
1217
+
1218
+ newSessionBtn.addEventListener('click', function () {
1219
+ resetDialogFields();
1053
1220
 
1054
1221
  var sidebarRoot = sidebarRootFilter.value;
1055
1222
  if (sidebarRoot) {
@@ -1069,6 +1236,7 @@
1069
1236
  dialogRepoSelect.disabled = true;
1070
1237
  }
1071
1238
 
1239
+ showDialogForTab(activeTab);
1072
1240
  dialog.showModal();
1073
1241
  });
1074
1242
 
@@ -1076,8 +1244,13 @@
1076
1244
  var repoPathValue = customPath.value.trim() || dialogRepoSelect.value;
1077
1245
  if (!repoPathValue) return;
1078
1246
  var args = dialogYolo.checked ? ['--dangerously-skip-permissions'] : undefined;
1079
- var branch = dialogBranchInput.value.trim() || undefined;
1080
- startSession(repoPathValue, undefined, args, branch);
1247
+
1248
+ if (activeTab === 'repos') {
1249
+ startRepoSession(repoPathValue, dialogContinue.checked, args);
1250
+ } else {
1251
+ var branch = dialogBranchInput.value.trim() || undefined;
1252
+ startSession(repoPathValue, undefined, args, branch);
1253
+ }
1081
1254
  });
1082
1255
 
1083
1256
  customPath.addEventListener('blur', function () {
package/public/index.html CHANGED
@@ -52,6 +52,10 @@
52
52
  </select>
53
53
  <input type="text" id="session-filter" placeholder="Filter..." />
54
54
  </div>
55
+ <div class="sidebar-tabs">
56
+ <button class="sidebar-tab active" data-tab="repos">Repos (<span id="tab-repos-count">0</span>)</button>
57
+ <button class="sidebar-tab" data-tab="worktrees">Worktrees (<span id="tab-worktrees-count">0</span>)</button>
58
+ </div>
55
59
  <ul id="session-list"></ul>
56
60
  <button id="new-session-btn">+ New Session</button>
57
61
  <button id="settings-btn">Settings</button>
@@ -140,6 +144,14 @@
140
144
  <span class="dialog-option-hint">Leave empty for auto-generated name</span>
141
145
  </div>
142
146
 
147
+ <div class="dialog-field" id="dialog-continue-field" hidden>
148
+ <label>
149
+ <input type="checkbox" id="dialog-continue" />
150
+ Continue previous conversation
151
+ </label>
152
+ <span class="dialog-option-hint">Resume where you left off (--continue)</span>
153
+ </div>
154
+
143
155
  <hr class="dialog-separator" />
144
156
  <div class="dialog-custom-path">
145
157
  <label for="custom-path-input">Or enter a local path:</label>
package/public/style.css CHANGED
@@ -202,6 +202,36 @@ html, body {
202
202
  border-color: var(--accent);
203
203
  }
204
204
 
205
+ /* Sidebar Tabs */
206
+ .sidebar-tabs {
207
+ display: flex;
208
+ gap: 0;
209
+ padding: 0 8px;
210
+ border-bottom: 1px solid var(--border);
211
+ }
212
+
213
+ .sidebar-tab {
214
+ flex: 1;
215
+ background: none;
216
+ border: none;
217
+ border-bottom: 2px solid transparent;
218
+ color: var(--text-muted);
219
+ font-size: 0.7rem;
220
+ padding: 6px 4px;
221
+ cursor: pointer;
222
+ transition: color 0.15s, border-color 0.15s;
223
+ text-align: center;
224
+ }
225
+
226
+ .sidebar-tab:hover {
227
+ color: var(--text);
228
+ }
229
+
230
+ .sidebar-tab.active {
231
+ color: var(--accent);
232
+ border-bottom-color: var(--accent);
233
+ }
234
+
205
235
  #session-list {
206
236
  list-style: none;
207
237
  flex: 1;