feat(ui): add link existing folder as case and improve UX

- Add tabbed "Add Case" modal with "Create New" and "Link Existing" tabs
- Add POST /api/cases/link endpoint to register existing folders
- Store linked cases in ~/.claudeman/linked-cases.json
- Update GET /api/cases to include linked cases
- Add instance count controls (+/-) for Run Shell button
- Update welcome message to explain instance count selector

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-21 13:20:43 +01:00
co-authored by Claude Opus 4.5
parent 951d13b6e6
commit e3ea33f9de
3 changed files with 234 additions and 26 deletions
+88 -6
View File
@@ -187,6 +187,7 @@ class ClaudemanApp {
this.terminal.writeln(''); this.terminal.writeln('');
this.terminal.writeln('\x1b[90m Each instance opens a persistent GNU Screen session running Claude or Shell.\x1b[0m'); this.terminal.writeln('\x1b[90m Each instance opens a persistent GNU Screen session running Claude or Shell.\x1b[0m');
this.terminal.writeln('\x1b[90m Sessions stay alive for autonomous work, even if you close this browser.\x1b[0m'); this.terminal.writeln('\x1b[90m Sessions stay alive for autonomous work, even if you close this browser.\x1b[0m');
this.terminal.writeln('\x1b[90m Use the \x1b[1;37m+/-\x1b[0m\x1b[90m controls to set how many instances to launch at once.\x1b[0m');
this.terminal.writeln(''); this.terminal.writeln('');
this.terminal.writeln('\x1b[90m Press \x1b[1;37mCtrl+Enter\x1b[0m\x1b[90m to start Claude • Click \x1b[1;37mRun Shell\x1b[0m\x1b[90m for a terminal\x1b[0m'); this.terminal.writeln('\x1b[90m Press \x1b[1;37mCtrl+Enter\x1b[0m\x1b[90m to start Claude • Click \x1b[1;37mRun Shell\x1b[0m\x1b[90m for a terminal\x1b[0m');
this.terminal.writeln(''); this.terminal.writeln('');
@@ -2635,25 +2636,65 @@ class ClaudemanApp {
showCreateCaseModal() { showCreateCaseModal() {
document.getElementById('newCaseName').value = ''; document.getElementById('newCaseName').value = '';
document.getElementById('newCaseDescription').value = ''; document.getElementById('newCaseDescription').value = '';
document.getElementById('createCaseModal').classList.add('active'); document.getElementById('linkCaseName').value = '';
document.getElementById('linkCasePath').value = '';
// Reset to first tab
this.caseModalTab = 'case-create';
this.switchCaseModalTab('case-create');
// Wire up tab buttons
const modal = document.getElementById('createCaseModal');
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
});
modal.classList.add('active');
document.getElementById('newCaseName').focus(); document.getElementById('newCaseName').focus();
} }
switchCaseModalTab(tabName) {
this.caseModalTab = tabName;
const modal = document.getElementById('createCaseModal');
// Toggle active class on tab buttons
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// Toggle hidden class on tab content
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
});
// Update submit button text
const submitBtn = document.getElementById('caseModalSubmit');
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
// Focus appropriate input
if (tabName === 'case-create') {
document.getElementById('newCaseName').focus();
} else {
document.getElementById('linkCaseName').focus();
}
}
closeCreateCaseModal() { closeCreateCaseModal() {
document.getElementById('createCaseModal').classList.remove('active'); document.getElementById('createCaseModal').classList.remove('active');
} }
async submitCaseModal() {
if (this.caseModalTab === 'case-create') {
await this.createCase();
} else {
await this.linkCase();
}
}
async createCase() { async createCase() {
const name = document.getElementById('newCaseName').value.trim(); const name = document.getElementById('newCaseName').value.trim();
const description = document.getElementById('newCaseDescription').value.trim(); const description = document.getElementById('newCaseDescription').value.trim();
if (!name) { if (!name) {
this.toast('Please enter a case name', 'error'); this.showToast('Please enter a case name', 'error');
return; return;
} }
if (!/^[a-zA-Z0-9_-]+$/.test(name)) { if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
this.toast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error'); this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return; return;
} }
@@ -2667,15 +2708,56 @@ class ClaudemanApp {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
this.closeCreateCaseModal(); this.closeCreateCaseModal();
this.toast(`Case "${name}" created`, 'success'); this.showToast(`Case "${name}" created`, 'success');
// Reload cases and select the new one // Reload cases and select the new one
await this.loadQuickStartCases(name); await this.loadQuickStartCases(name);
} else { } else {
this.toast(data.error || 'Failed to create case', 'error'); this.showToast(data.error || 'Failed to create case', 'error');
} }
} catch (err) { } catch (err) {
console.error('Failed to create case:', err); console.error('Failed to create case:', err);
this.toast('Failed to create case: ' + err.message, 'error'); this.showToast('Failed to create case: ' + err.message, 'error');
}
}
async linkCase() {
const name = document.getElementById('linkCaseName').value.trim();
const path = document.getElementById('linkCasePath').value.trim();
if (!name) {
this.showToast('Please enter a case name', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
if (!path) {
this.showToast('Please enter a folder path', 'error');
return;
}
try {
const res = await fetch('/api/cases/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, path })
});
const data = await res.json();
if (data.success) {
this.closeCreateCaseModal();
this.showToast(`Case "${name}" linked to ${path}`, 'success');
// Reload cases and select the new one
await this.loadQuickStartCases(name);
} else {
this.showToast(data.error || 'Failed to link case', 'error');
}
} catch (err) {
console.error('Failed to link case:', err);
this.showToast('Failed to link case: ' + err.message, 'error');
} }
} }
+24 -4
View File
@@ -537,25 +537,45 @@
<!-- Create Case Modal --> <!-- Create Case Modal -->
<div class="modal" id="createCaseModal"> <div class="modal" id="createCaseModal">
<div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div> <div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div>
<div class="modal-content modal-sm"> <div class="modal-content modal-lg">
<div class="modal-header"> <div class="modal-header">
<h3>Create New Case</h3> <h3>Add Case</h3>
<button class="modal-close" onclick="app.closeCreateCaseModal()">&times;</button> <button class="modal-close" onclick="app.closeCreateCaseModal()">&times;</button>
</div> </div>
<div class="modal-tabs">
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
</div>
<div class="modal-body"> <div class="modal-body">
<!-- Create New Tab -->
<div class="modal-tab-content" id="case-create">
<div class="form-row"> <div class="form-row">
<label>Case Name</label> <label>Case Name</label>
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+"> <input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+">
<span class="form-hint">Letters, numbers, hyphens, underscores only</span> <span class="form-hint">Letters, numbers, hyphens, underscores only. Created in ~/claudeman-cases/</span>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Description (optional)</label> <label>Description (optional)</label>
<input type="text" id="newCaseDescription" placeholder="A brief description..."> <input type="text" id="newCaseDescription" placeholder="A brief description...">
</div> </div>
</div> </div>
<!-- Link Existing Tab -->
<div class="modal-tab-content hidden" id="case-link">
<div class="form-row">
<label>Case Name</label>
<input type="text" id="linkCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+">
<span class="form-hint">Name to identify this case in Claudeman</span>
</div>
<div class="form-row">
<label>Folder Path</label>
<input type="text" id="linkCasePath" placeholder="/home/user/projects/my-project">
<span class="form-hint">Absolute path to an existing project folder</span>
</div>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button class="btn-toolbar" onclick="app.closeCreateCaseModal()">Cancel</button> <button class="btn-toolbar" onclick="app.closeCreateCaseModal()">Cancel</button>
<button class="btn-toolbar btn-primary" onclick="app.createCase()">Create</button> <button class="btn-toolbar btn-primary" id="caseModalSubmit" onclick="app.submitCaseModal()">Create</button>
</div> </div>
</div> </div>
</div> </div>
+113 -7
View File
@@ -727,17 +727,43 @@ export class WebServer extends EventEmitter {
const casesDir = join(homedir(), 'claudeman-cases'); const casesDir = join(homedir(), 'claudeman-cases');
this.app.get('/api/cases', async (): Promise<CaseInfo[]> => { this.app.get('/api/cases', async (): Promise<CaseInfo[]> => {
if (!existsSync(casesDir)) { const cases: CaseInfo[] = [];
return [];
} // Get cases from casesDir
if (existsSync(casesDir)) {
const entries = readdirSync(casesDir, { withFileTypes: true }); const entries = readdirSync(casesDir, { withFileTypes: true });
return entries for (const e of entries) {
.filter(e => e.isDirectory()) if (e.isDirectory()) {
.map(e => ({ cases.push({
name: e.name, name: e.name,
path: join(casesDir, e.name), path: join(casesDir, e.name),
hasClaudeMd: existsSync(join(casesDir, e.name, 'CLAUDE.md')), hasClaudeMd: existsSync(join(casesDir, e.name, 'CLAUDE.md')),
})); });
}
}
}
// Get linked cases
const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json');
try {
if (existsSync(linkedCasesFile)) {
const linkedCases: Record<string, string> = JSON.parse(readFileSync(linkedCasesFile, 'utf-8'));
for (const [name, path] of Object.entries(linkedCases)) {
// Only add if not already in cases (avoid duplicates) and path exists
if (!cases.some(c => c.name === name) && existsSync(path)) {
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
});
}
}
}
} catch {
// Ignore errors reading linked cases
}
return cases;
}); });
this.app.post('/api/cases', async (req): Promise<{ success: boolean; case?: { name: string; path: string }; error?: string }> => { this.app.post('/api/cases', async (req): Promise<{ success: boolean; case?: { name: string; path: string }; error?: string }> => {
@@ -777,8 +803,88 @@ export class WebServer extends EventEmitter {
} }
}); });
// Link an existing folder as a case
this.app.post('/api/cases/link', async (req): Promise<{ success: boolean; case?: { name: string; path: string }; error?: string }> => {
const { name, path: folderPath } = req.body as { name: string; path: string };
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) {
return { success: false, error: 'Invalid case name. Use only letters, numbers, hyphens, underscores.' };
}
if (!folderPath) {
return { success: false, error: 'Folder path is required.' };
}
// Expand ~ to home directory
const expandedPath = folderPath.startsWith('~')
? join(homedir(), folderPath.slice(1))
: folderPath;
// Validate the folder exists
if (!existsSync(expandedPath)) {
return { success: false, error: `Folder not found: ${expandedPath}` };
}
// Check if case name already exists in casesDir
const casePath = join(casesDir, name);
if (existsSync(casePath)) {
return { success: false, error: 'A case with this name already exists in claudeman-cases.' };
}
// Load existing linked cases
const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json');
let linkedCases: Record<string, string> = {};
try {
if (existsSync(linkedCasesFile)) {
linkedCases = JSON.parse(readFileSync(linkedCasesFile, 'utf-8'));
}
} catch {
// Ignore parse errors, start fresh
}
// Check if name is already linked
if (linkedCases[name]) {
return { success: false, error: `Case "${name}" is already linked to ${linkedCases[name]}` };
}
// Save the linked case
linkedCases[name] = expandedPath;
try {
const claudemanDir = join(homedir(), '.claudeman');
if (!existsSync(claudemanDir)) {
mkdirSync(claudemanDir, { recursive: true });
}
writeFileSync(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
this.broadcast('case:linked', { name, path: expandedPath });
return { success: true, case: { name, path: expandedPath } };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
this.app.get('/api/cases/:name', async (req) => { this.app.get('/api/cases/:name', async (req) => {
const { name } = req.params as { name: string }; const { name } = req.params as { name: string };
// First check linked cases
const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json');
try {
if (existsSync(linkedCasesFile)) {
const linkedCases: Record<string, string> = JSON.parse(readFileSync(linkedCasesFile, 'utf-8'));
if (linkedCases[name]) {
const linkedPath = linkedCases[name];
return {
name,
path: linkedPath,
hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')),
linked: true,
};
}
}
} catch {
// Ignore errors, fall through to casesDir check
}
// Then check casesDir
const casePath = join(casesDir, name); const casePath = join(casesDir, name);
if (!existsSync(casePath)) { if (!existsSync(casePath)) {