Merge master into feat/offline-state (keep both offline overlay and approvals drawer)

This commit is contained in:
Codeman maintainer
2026-08-09 17:01:34 +02:00
58 changed files with 4768 additions and 138 deletions
+21
View File
@@ -237,10 +237,17 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'],
[SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'],
[SSE_EVENTS.HOOK_ELICITATION_DIALOG, '_onHookElicitationDialog'],
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
// Approvals Inbox (handlers in approvals-ui.js)
[SSE_EVENTS.APPROVAL_PENDING, '_onApprovalPending'],
[SSE_EVENTS.APPROVAL_UPDATED, '_onApprovalUpdated'],
[SSE_EVENTS.APPROVAL_RESOLVED, '_onApprovalResolved'],
// Subagents (Claude Code background agents)
[SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'],
[SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'],
@@ -615,6 +622,11 @@ class CodemanApp {
this.fileBrowserFilter = '';
this.fileBrowserAllExpanded = false;
this.fileBrowserDragListeners = null;
// Show hidden (dot-prefixed) files and folders in the File Viewer tree.
// Per-device, persisted to its own localStorage key by panels-ui.js. Safe to
// call a mixin method here: instantiation is deferred to DOMContentLoaded,
// so every module's Object.assign has already run.
this.fileBrowserShowHidden = this._loadFileBrowserShowHidden?.() ?? false;
this.filePreviewContent = '';
// Toast container cache (methods in panels-ui.js)
@@ -630,6 +642,9 @@ class CodemanApp {
// Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt)
this.pendingHooks = new Map();
// Approvals Inbox: Map<approvalId, ApprovalItem> (methods in approvals-ui.js)
this.approvals = new Map();
// WebSocket terminal I/O (low-latency bypass of HTTP POST + SSE)
this._ws = null; // WebSocket instance for active session
this._wsSessionId = null; // Session ID the WS is connected to
@@ -3168,6 +3183,8 @@ class CodemanApp {
this._predictiveEcho?.clearPredictions();
// Clear pending hooks
this.pendingHooks.clear();
// Clear approvals (re-seeded from GET /api/approvals right after init)
this.approvals?.clear();
// Clear parent name cache (prevents stale session name entries accumulating)
if (this._parentNameCache) this._parentNameCache.clear();
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
@@ -3308,6 +3325,10 @@ class CodemanApp {
this.updateCost();
this.renderSessionTabs();
// Approvals Inbox: re-seed pending prompts from the server so alerts
// survive reloads and SSE reconnects (methods in approvals-ui.js).
this.seedApprovals?.();
// Start/stop system stats polling based on session count
if (this.sessions.size > 0) {
this.startSystemStatsPolling();
+242
View File
@@ -0,0 +1,242 @@
/**
* @fileoverview Approvals Inbox UI: cross-session queue of prompts waiting on a human.
*
* Everything here is gated on the OPT-IN `approvalsInboxEnabled` setting
* (synced, default OFF): with it off, no bell, no drawer, no overview strips,
* no seeding. When on, the header bell renders only while items are pending
* (count badge), opening a right-side drawer of approval cards; pending items
* are seeded from `GET /api/approvals` on init/reconnect (so tab alerts
* survive a reload) and answered in place via `POST /api/approvals/:id/answer`. Cards render
* buttons from the server-parsed dialog options; without parsed options they
* fall back to Approve/Deny (permission/question) or a text prompt (idle).
* Backend: src/web/approval-inbox.ts, design: docs/approvals-inbox-plan.md.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.approvals, setPendingHook/clearPendingHooks, selectSession)
* @dependency constants.js (escapeHtml)
* @dependency api-client.js at runtime (this._apiJson; loads later but is only called after init)
* @loadorder 11.6 of 17, after ultracode-panel.js, before admin-ui.js
*/
/** Map an approval kind to the pendingHooks entry that drives tab alerts. */
function approvalKindToHook(kind) {
return kind === 'permission' ? 'permission_prompt' : kind === 'question' ? 'elicitation_dialog' : 'idle_prompt';
}
Object.assign(CodemanApp.prototype, {
/** Synced setting, default OFF, opt-in via App Settings → Panels. */
approvalsInboxEnabled() {
return this.loadAppSettingsFromStorage().approvalsInboxEnabled === true;
},
/**
* Seed pending approvals from the server. Called from handleInit, i.e. on
* every page load AND SSE reconnect; this is what makes pending alerts
* survive a reload (pre-inbox they lived only in SSE-transient memory).
*/
async seedApprovals() {
if (!this.approvals) this.approvals = new Map();
this.approvals.clear();
if (this.approvalsInboxEnabled()) {
const data = await this._apiJson('/api/approvals');
for (const item of (data && data.approvals) || []) {
this.approvals.set(item.id, item);
// Re-arm the tab alert state machine (idempotent set-add).
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
}
}
this.renderApprovals();
},
// ─── SSE handlers ────────────────────────────────────────────
_onApprovalPending(item) {
if (!item || !item.id) return;
if (!this.approvals) this.approvals = new Map();
// One active item per session (server invariant): drop any stale sibling.
for (const [id, existing] of this.approvals) {
if (existing.sessionId === item.sessionId) this.approvals.delete(id);
}
this.approvals.set(item.id, item);
this.renderApprovals();
},
_onApprovalUpdated(item) {
if (!item || !item.id || !this.approvals?.has(item.id)) return;
this.approvals.set(item.id, item);
this.renderApprovals();
},
_onApprovalResolved(info) {
if (!info || !info.id || !this.approvals) return;
if (this.approvals.delete(info.id)) {
// Clear the matching tab alert: the inbox resolves on more signals than
// the hook handlers do (superseded, expired, answered from another
// device), and clearPendingHooks is a no-op when nothing is set.
this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind));
this.renderApprovals();
}
},
// ─── Actions ─────────────────────────────────────────────────
async answerApproval(id, action, option) {
const body = option !== undefined ? { action, option } : { action };
const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, {
method: 'POST',
body,
});
if (data) {
this.showToast(action === 'deny' ? 'Denied' : 'Answer sent', 'success');
} else {
// 404/409 = resolved elsewhere or the dialog left the screen; refresh truth.
this.showToast('Could not answer, the prompt may already be resolved', 'warning');
this.seedApprovals();
}
},
/** Idle prompts: send the typed line from the card's input as a prompt. */
async answerApprovalIdleText(id) {
const input = document.getElementById(`approvalText-${id}`);
const text = input ? input.value.trim() : '';
if (!text) return;
const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, {
method: 'POST',
body: { action: 'text', text },
});
if (data) this.showToast('Prompt sent', 'success');
else {
this.showToast('Could not send, the session may be busy', 'warning');
this.seedApprovals();
}
},
async dismissApproval(id) {
await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/dismiss`, { method: 'POST', body: {} });
// The SSE resolved event also lands; delete now for instant feedback.
if (this.approvals?.delete(id)) this.renderApprovals();
},
openApprovalSession(id) {
const item = this.approvals?.get(id);
if (!item) return;
this.closeApprovalsInbox();
if (this.sessions.has(item.sessionId)) this.selectSession(item.sessionId);
},
/**
* Push-notification action relay (sw.js → settings-ui notification-click →
* here). Falls back to opening the session when the item is unknown, or
* when the inbox is disabled (a stale notification from before the toggle
* flipped can still carry an action).
*/
handleNotificationAction(action, approvalId, sessionId) {
if ((action === 'approve' || action === 'deny') && approvalId && this.approvalsInboxEnabled()) {
this.answerApproval(approvalId, action);
return;
}
if (sessionId && this.sessions.has(sessionId)) this.selectSession(sessionId);
},
// ─── Rendering ───────────────────────────────────────────────
toggleApprovalsInbox() {
const drawer = document.getElementById('approvalsDrawer');
if (!drawer) return;
if (drawer.classList.contains('open')) this.closeApprovalsInbox();
else {
drawer.classList.add('open');
document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'true');
this.renderApprovals();
}
},
closeApprovalsInbox() {
document.getElementById('approvalsDrawer')?.classList.remove('open');
document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'false');
},
renderApprovals() {
const count = this.approvals ? this.approvals.size : 0;
const btn = document.querySelector('.btn-approvals');
if (btn) {
// Marker-class visibility (base header rules are display !important):
// the bell exists only while something is pending, so the header stays
// untouched for everyone else.
btn.classList.toggle('btn-approvals--hidden', count === 0 || !this.approvalsInboxEnabled());
const badge = document.getElementById('approvalsBadge');
if (badge) badge.textContent = String(count);
}
this.renderApprovalsDrawer();
// Phone overview NEEDS YOU rows re-render on the tab-render tail; nudge it
// so inline approve/deny buttons appear without a state change elsewhere.
this.renderSessionTabs?.();
},
renderApprovalsDrawer() {
const drawer = document.getElementById('approvalsDrawer');
if (!drawer || !drawer.classList.contains('open')) return;
const list = drawer.querySelector('.approvals-list');
if (!list) return;
const items = this.approvals ? [...this.approvals.values()].sort((a, b) => a.createdAt - b.createdAt) : [];
if (items.length === 0) {
list.innerHTML = '<div class="approvals-empty">No pending approvals</div>';
return;
}
list.innerHTML = items.map((item) => this._approvalCardHtml(item)).join('');
},
_approvalCardHtml(item) {
const id = escapeHtml(item.id);
const kindLabel = item.kind === 'permission' ? 'Permission' : item.kind === 'question' ? 'Question' : 'Idle';
const summary = item.toolName
? `${item.toolName}${item.toolSummary ? ': ' + item.toolSummary : ''}`
: item.message || '';
const age = this._approvalAge(item.createdAt);
let actions = '';
if (item.kind === 'idle') {
actions =
`<div class="approval-text-row">` +
`<input type="text" id="approvalText-${id}" class="approval-text-input" placeholder="Send a prompt…" data-i18n-skip ` +
`onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
`</div>`;
} else if (item.options && item.options.length) {
actions = item.options
.map(
(o) =>
`<button class="approval-btn ${o.n === 1 ? 'approval-btn-primary' : ''}" data-i18n-skip ` +
`title="${escapeHtml(o.label)}" onclick="app.answerApproval('${id}','option',${o.n})">` +
`${o.n}. ${escapeHtml(o.label.length > 42 ? o.label.slice(0, 42) + '…' : o.label)}</button>`
)
.join('');
} else {
actions =
`<button class="approval-btn approval-btn-primary" onclick="app.answerApproval('${id}','approve')">Approve</button>` +
`<button class="approval-btn approval-btn-danger" onclick="app.answerApproval('${id}','deny')">Deny (Esc)</button>`;
}
return (
`<div class="approval-card approval-kind-${item.kind}" data-approval-id="${id}">` +
`<div class="approval-card-head">` +
`<span class="approval-kind-badge">${kindLabel}</span>` +
`<span class="approval-session" data-i18n-skip>${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}</span>` +
`<span class="approval-age" data-i18n-skip>${age}</span>` +
`</div>` +
(summary ? `<div class="approval-summary" data-i18n-skip>${escapeHtml(summary)}</div>` : '') +
(item.context ? `<pre class="approval-context">${escapeHtml(item.context)}</pre>` : '') +
`<div class="approval-actions">${actions}</div>` +
`<div class="approval-meta-actions">` +
`<button class="approval-link" onclick="app.openApprovalSession('${id}')">Open session</button>` +
`<button class="approval-link" onclick="app.dismissApproval('${id}')">Dismiss</button>` +
`</div>` +
`</div>`
);
},
_approvalAge(createdAt) {
const s = Math.max(0, Math.floor((Date.now() - createdAt) / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
return `${Math.floor(s / 3600)}h`;
},
});
+7
View File
@@ -487,10 +487,17 @@ const SSE_EVENTS = {
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
HOOK_ELICITATION_DIALOG: 'hook:elicitation_dialog',
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
HOOK_STOP: 'hook:stop',
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
HOOK_TASK_COMPLETED: 'hook:task_completed',
// Approvals Inbox
APPROVAL_PENDING: 'approval:pending',
APPROVAL_UPDATED: 'approval:updated',
APPROVAL_RESOLVED: 'approval:resolved',
// Subagents (Claude Code background agents)
SUBAGENT_DISCOVERED: 'subagent:discovered',
SUBAGENT_UPDATED: 'subagent:updated',
+16
View File
@@ -235,6 +235,22 @@
Subagents: '子智能体',
'Ultracode Agents': 'Ultracode 智能体',
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Approvals Inbox': '审批收件箱',
Approvals: '审批',
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
'No pending approvals': '没有待处理的审批',
'Approvals waiting on you': '等待您审批的请求',
'Open approvals inbox': '打开审批收件箱',
'Close approvals inbox': '关闭审批收件箱',
Approve: '批准',
'Deny (Esc)': '拒绝 (Esc)',
Deny: '拒绝',
'Open session': '打开会话',
Dismiss: '忽略',
Send: '发送',
Permission: '权限',
Question: '问题',
Idle: '空闲',
'Subagent Options': '子智能体选项',
'Enable Tracking': '启用跟踪',
'Active Tab Only': '仅活动标签页',
+25
View File
@@ -131,6 +131,10 @@
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
<button class="btn-icon-header btn-approvals btn-approvals--hidden" id="approvalsBtn" onclick="app.toggleApprovalsInbox()" title="Approvals waiting on you" aria-label="Open approvals inbox" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="approvals-badge" id="approvalsBadge">0</span>
</button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
@@ -417,6 +421,7 @@
<div class="file-browser-header">
<span class="file-browser-title">Files</span>
<div class="file-browser-actions">
<button class="btn-icon-sm btn-file-browser-hidden" onclick="app.toggleFileBrowserHidden()" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false" id="fileBrowserHiddenBtn">.*</button>
<button class="btn-icon-sm" onclick="app.refreshFileBrowser()" title="Refresh">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.toggleFileBrowserExpand()" title="Expand/Collapse All" id="fileBrowserExpandBtn">&#x229E;</button>
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">&times;</button>
@@ -1532,6 +1537,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Cross-session inbox of prompts waiting on you (permission dialogs, questions, idle prompts) with answer-in-place buttons; the header bell appears only while something is pending">
<span class="settings-item-label">Approvals Inbox</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsApprovalsInbox">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)">
<span class="settings-item-label">Ultracode Agents</span>
<label class="switch switch-sm">
@@ -2722,6 +2734,18 @@
</div>
</div>
<!-- Approvals Inbox drawer (populated by approvals-ui.js; opened from the header bell) -->
<div class="approvals-drawer" id="approvalsDrawer" role="complementary" aria-label="Approvals inbox">
<div class="approvals-header">
<div>
<div class="approvals-title">Approvals</div>
<div class="approvals-subtitle">Prompts waiting on you, across all sessions</div>
</div>
<button class="approvals-close" onclick="app.closeApprovalsInbox()" title="Close" aria-label="Close approvals inbox">✕</button>
</div>
<div class="approvals-list"></div>
</div>
<script defer src="constants.js"></script>
<script defer src="i18n.js"></script>
<script defer src="mobile-handlers.js"></script>
@@ -2740,6 +2764,7 @@
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
<script defer src="approvals-ui.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="webview-tabs.js"></script>
+47
View File
@@ -33,6 +33,12 @@
// Shared Filesystem Path Picker
// ═══════════════════════════════════════════════════════════════
// Per-device, and deliberately its own key rather than a shared "show hidden"
// preference with the File Viewer: that tree is confined to one workspace, while
// the picker browses Home and every configured root, so wanting dotfiles in a
// project does not imply wanting them in ~.
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
const PathPicker = {
overlay: null,
_options: null,
@@ -43,6 +49,7 @@ const PathPicker = {
_previewOverlay: null,
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_showHidden: false,
/**
* Open the lazy filesystem browser.
@@ -53,6 +60,7 @@ const PathPicker = {
this.close(false);
this._options = options;
this._selectedPath = '';
this._showHidden = this._loadShowHidden();
this._previousFocus = document.activeElement;
this._previousFocus?.blur?.();
@@ -74,6 +82,7 @@ const PathPicker = {
<div class="path-picker-nav">
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">&#x2191;</button>
<div class="path-picker-current" title="Current folder"></div>
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<div class="path-picker-status" aria-live="polite">Loading...</div>
@@ -100,6 +109,8 @@ const PathPicker = {
if (current) this.select(current);
});
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
this._syncHiddenButton();
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
if (parent) this.load(parent);
@@ -120,6 +131,38 @@ const PathPicker = {
this.load(options.initialPath || '');
},
_loadShowHidden() {
try {
return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1';
} catch {
return false;
}
},
_syncHiddenButton() {
const btn = this.overlay?.querySelector('.path-picker-hidden');
if (!btn) return;
const label = this._showHidden ? 'Hide hidden files and folders' : 'Show hidden files and folders';
btn.classList.toggle('active', this._showHidden);
btn.setAttribute('aria-pressed', this._showHidden ? 'true' : 'false');
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
},
toggleHidden() {
if (!this.overlay) return;
this._showHidden = !this._showHidden;
try {
localStorage.setItem(PATH_PICKER_SHOW_HIDDEN_KEY, this._showHidden ? '1' : '0');
} catch {}
this._syncHiddenButton();
// Reload where we are rather than resetting to the root. Turning the toggle
// OFF inside a hidden folder makes the current path unbrowsable again; the
// server answers 403 and load()'s catch falls back to the default root,
// which is the only place left to stand.
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
},
async load(path) {
if (!this.overlay || !this._options) return;
const loadSequence = ++this._loadSequence;
@@ -131,6 +174,7 @@ const PathPicker = {
const params = new URLSearchParams();
if (path) params.set('path', path);
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
if (this._showHidden) params.set('showHidden', 'true');
try {
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
const result = await response.json();
@@ -248,6 +292,9 @@ const PathPicker = {
const requestSequence = ++this._previewRequestSequence;
const params = new URLSearchParams({ path: entry.path });
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
// A hidden file is only reachable while the toggle is on, and the preview
// endpoint re-resolves the path independently, so it needs the flag too.
if (this._showHidden) params.set('showHidden', 'true');
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
const overlay = document.createElement('div');
+49
View File
@@ -639,9 +639,58 @@ Object.assign(CodemanApp.prototype, {
chevron.textContent = '›';
item.appendChild(chevron);
// Approvals Inbox: a pending dialog for this session gets an answer strip
// BELOW the row (the row itself is a <button>, so actions cannot nest
// inside it). Tapping the row still opens the session, unchanged.
const approval = this._pendingApprovalForSession(row.id);
if (approval) {
const wrap = document.createElement('div');
wrap.className = 'mobile-overview-row-wrap';
wrap.appendChild(item);
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
return wrap;
}
return item;
},
/** The session's pending approval, when the strip should render (dialogs only). */
_pendingApprovalForSession(sessionId) {
if (!this.approvals || !this.approvalsInboxEnabled || !this.approvalsInboxEnabled()) return null;
for (const item of this.approvals.values()) {
if (item.sessionId === sessionId && item.kind !== 'idle') return item;
}
return null;
},
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
_buildMobileOverviewApprovalStrip(approval) {
const strip = document.createElement('div');
strip.className = 'mobile-overview-approval-strip';
strip.setAttribute('data-i18n-skip', '');
const addBtn = (label, cls, onTap) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'mobile-overview-approval-btn' + (cls ? ' ' + cls : '');
btn.textContent = label;
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
onTap();
});
strip.appendChild(btn);
};
if (approval.options && approval.options.length) {
for (const o of approval.options) {
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label;
addBtn(`${o.n}. ${label}`, o.n === 1 ? 'primary' : '', () => this.answerApproval(approval.id, 'option', o.n));
}
} else {
addBtn('Approve', 'primary', () => this.answerApproval(approval.id, 'approve'));
addBtn('Deny', 'danger', () => this.answerApproval(approval.id, 'deny'));
}
return strip;
},
/** A past conversation. Tapping it resumes, which creates a fresh session. */
_buildMobileOverviewPastRow(row) {
const item = document.createElement('button');
+122 -1
View File
@@ -479,7 +479,11 @@ html.mobile-init .file-browser-panel {
.btn-icon-header.btn-lifecycle-log,
.btn-icon-header.btn-away-digest,
.btn-icon-header.btn-session-manager,
.btn-icon-header.btn-file-viewer {
.btn-icon-header.btn-file-viewer,
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
(inline approve/deny in mobile-overview.js); the bell would only crowd the
header it was designed to stay out of. */
.btn-icon-header.btn-approvals {
display: none !important;
}
@@ -2503,6 +2507,41 @@ html.mobile-init .file-browser-panel {
background: var(--bg-hover);
}
/* Approvals Inbox answer strip: sits under a NEEDS YOU row (sibling of the
row <button>, see _buildMobileOverviewApprovalStrip). Buttons inherit no
toolbar styling on purpose; they are one-tap dialog answers, not runs. */
.mobile-overview-row-wrap {
width: 100%;
}
.mobile-overview-approval-strip {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding: 0.4rem 0.2rem 0.1rem;
}
.mobile-overview-approval-btn {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
color: var(--text);
font-family: inherit;
font-size: 0.72rem;
padding: 0.35rem 0.6rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-overview-approval-btn.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.mobile-overview-approval-btn.danger {
border-color: var(--error);
color: var(--error);
}
/* Attention states mirror the session tabs exactly: red blink when the agent
asked something (permission / question), yellow blink when it is waiting for
a prompt. Same hues and same cadence as tab-blink-red / tab-blink-yellow in
@@ -2522,6 +2561,51 @@ html.mobile-init .file-browser-panel {
border-color: var(--red);
}
/* Working is not an alert, so it gets a calm green breathing edge rather than a
blink: at a glance the row reads "this one is moving", without competing with
the two states that actually want you. Slower than both of them on purpose. */
.mobile-overview-row--working {
border-color: var(--green);
animation: mobile-overview-breathe-green 2.2s ease-in-out infinite;
}
@keyframes mobile-overview-breathe-green {
0%,
100% {
background: var(--bg-card);
border-color: var(--border);
}
50% {
background: rgba(34, 197, 94, 0.1);
border-color: var(--green);
}
}
/* The pill picks up a three-dot ellipsis that fills in and empties, so the row
still reads as active on a skin where the border tint is subtle. */
.mobile-overview-pill--working::after {
content: '';
display: inline-block;
width: 0.75em;
text-align: left;
animation: mobile-overview-pill-dots 1.5s steps(1, end) infinite;
}
@keyframes mobile-overview-pill-dots {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
}
@keyframes mobile-overview-blink-red {
0%,
100% {
@@ -2603,6 +2687,25 @@ html.mobile-init .file-browser-panel {
will-change: opacity;
}
/* Ring the pulsing dot with the SAME spinner a tab shows while it loads: same
2px ring, same bright leading edge, same `tab-load-spin` keyframes from
styles.css (reused, not re-declared, so the two can never drift). Green
rather than the tab's blue because here it means "running", not "loading":
the motion is the shared part, the color still belongs to the state. */
.mobile-overview-dot {
position: relative;
}
.mobile-overview-dot--working::after {
content: '';
position: absolute;
inset: -4px;
border: 2px solid rgba(34, 197, 94, 0.25);
border-top-color: var(--green);
border-radius: 50%;
animation: tab-load-spin 0.7s linear infinite;
}
.mobile-overview-dot--idle {
background: var(--green);
}
@@ -2713,6 +2816,24 @@ html.mobile-init .file-browser-panel {
.mobile-overview-dot--working {
animation: none;
}
/* The ring stays as a static full circle: it still marks the row, it just
stops turning. */
.mobile-overview-dot--working::after {
border-color: var(--green);
animation: none;
}
/* Working is only informational, so it drops to a static green edge and a
static ellipsis rather than holding a tint the way the alerts do. */
.mobile-overview-row--working {
animation: none;
}
.mobile-overview-pill--working::after {
content: '...';
animation: none;
}
}
/* Light-skin compatibility for mobile-only chrome. These components predate
+41 -2
View File
@@ -14,6 +14,7 @@
*/
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -2944,18 +2945,56 @@ Object.assign(CodemanApp.prototype, {
// File Browser Panel
// ═══════════════════════════════════════════════════════════════
// Hidden files/folders (dot-prefixed) are filtered SERVER-side by
// GET /api/sessions/:id/files, so the toggle re-fetches rather than
// re-rendering the cached tree (issue #221). The flag is per-device and lives
// in its own localStorage key instead of the app-settings object: that object
// is rebuilt from the settings-modal DOM on every save, so a key toggled from
// outside the modal would be dropped the next time settings are saved.
_loadFileBrowserShowHidden() {
try {
return localStorage.getItem(FILE_BROWSER_SHOW_HIDDEN_KEY) === '1';
} catch {
return false;
}
},
_syncFileBrowserHiddenBtn() {
const btn = this.$('fileBrowserHiddenBtn');
if (!btn) return;
const on = this.fileBrowserShowHidden === true;
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', String(on));
const label = on ? 'Hide hidden files and folders' : 'Show hidden files and folders';
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
},
async toggleFileBrowserHidden() {
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
try {
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
} catch {}
this._syncFileBrowserHiddenBtn();
// Expanded-directory state is deliberately preserved so toggling does not
// collapse the tree the user just navigated.
if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId);
},
async loadFileBrowser(sessionId) {
if (!sessionId) return;
const treeEl = this.$('fileBrowserTree');
const statusEl = this.$('fileBrowserStatus');
this._syncFileBrowserHiddenBtn();
if (!treeEl) return;
// Show loading state
treeEl.innerHTML = '<div class="file-browser-loading">Loading files...</div>';
try {
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=false`);
const showHidden = this.fileBrowserShowHidden === true;
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`);
if (!res.ok) throw new Error('Failed to load files');
const result = await res.json();
@@ -2967,7 +3006,7 @@ Object.assign(CodemanApp.prototype, {
// Update status
if (statusEl) {
const { totalFiles, totalDirectories, truncated } = result.data;
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}`;
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
}
} catch (err) {
console.error('Failed to load file browser:', err);
+22 -2
View File
@@ -38,6 +38,18 @@ Object.assign(CodemanApp.prototype, {
this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer');
},
_onHookElicitationComplete(data) {
// Question answered in the terminal: clear the action alert without
// waiting for `stop` (the turn may keep running for a long time).
if (data.sessionId) {
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
}
},
_onHookElicitationResponse(data) {
this._onHookElicitationComplete(data);
},
_onHookStop(data) {
// Clear all pending hooks when Claude finishes responding
if (data.sessionId) {
@@ -158,8 +170,12 @@ Object.assign(CodemanApp.prototype, {
// Listen for messages from service worker (notification clicks)
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'notification-click') {
const { sessionId } = event.data;
if (sessionId && this.sessions.has(sessionId)) {
const { sessionId, action, approvalId } = event.data;
if (action) {
// Approve/Deny action buttons on a push: answer via the
// Approvals Inbox instead of just focusing the session.
this.handleNotificationAction?.(action, approvalId, sessionId);
} else if (sessionId && this.sessions.has(sessionId)) {
this.selectSession(sessionId);
}
window.focus();
@@ -326,6 +342,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
@@ -1525,6 +1543,7 @@ Object.assign(CodemanApp.prototype, {
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
@@ -1690,6 +1709,7 @@ Object.assign(CodemanApp.prototype, {
this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
this.renderApprovals?.(); // Approvals Inbox toggle (hide/show bell + drawer)
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
+246 -1
View File
@@ -9192,6 +9192,20 @@ kbd {
gap: 0.25rem;
}
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
* (dot-prefixed files and folders) survives every skin and font stack. */
.btn-file-browser-hidden {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: -0.05em;
}
.btn-file-browser-hidden.active {
color: var(--accent);
background: var(--bg-hover);
}
.file-browser-search {
padding: 0.4rem;
border-bottom: 1px solid var(--border);
@@ -10643,6 +10657,222 @@ kbd {
display: none !important;
}
/* "Approvals" header bell: appears ONLY while prompts are pending (JS toggles
the marker class on count changes), so it ships hidden and stays out of the
default header. Same marker pattern as the attachments button. */
.btn-approvals {
display: inline-flex !important;
position: relative;
}
.btn-approvals.btn-approvals--hidden {
display: none !important;
}
.approvals-badge {
position: absolute;
top: 2px;
right: 1px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--error, #e5484d);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
/* Approvals Inbox drawer: same shell as the attachment history drawer. */
.approvals-drawer {
position: fixed;
top: var(--header-height);
right: 0;
width: 420px;
max-width: calc(100vw - 24px);
height: calc(100vh - var(--header-height) - var(--toolbar-height));
height: calc(100dvh - var(--header-height) - var(--toolbar-height));
background: var(--floating-bg);
border-left: 1px solid var(--border);
z-index: 10000;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.18s ease;
box-shadow: -10px 0 28px rgba(0, 0, 0, 0.36);
}
.approvals-drawer.open {
transform: translateX(0);
}
.approvals-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.approvals-title {
color: var(--text);
font-size: 0.9rem;
font-weight: 650;
}
.approvals-subtitle {
margin-top: 2px;
color: var(--text-dim);
font-size: 0.68rem;
}
.approvals-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 0.9rem;
cursor: pointer;
padding: 4px 8px;
}
.approvals-close:hover {
color: var(--text);
}
.approvals-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.approvals-empty {
color: var(--text-dim);
font-size: 0.78rem;
text-align: center;
padding: 24px 8px;
}
.approval-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
background: var(--bg-secondary, rgba(255, 255, 255, 0.02));
}
.approval-card-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.approval-kind-badge {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 4px;
background: var(--accent);
color: #fff;
}
.approval-kind-question .approval-kind-badge {
background: #d97706;
}
.approval-kind-idle .approval-kind-badge {
background: #6b7280;
}
.approval-session {
color: var(--text);
font-size: 0.78rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-age {
margin-left: auto;
color: var(--text-dim);
font-size: 0.68rem;
}
.approval-summary {
color: var(--text);
font-size: 0.76rem;
margin-bottom: 6px;
word-break: break-word;
}
.approval-context {
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
line-height: 1.35;
color: var(--text-dim);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
margin: 0 0 8px;
max-height: 180px;
overflow: auto;
white-space: pre;
}
.approval-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.approval-btn {
border: 1px solid var(--border);
background: var(--bg-tertiary, rgba(255, 255, 255, 0.05));
color: var(--text);
font-size: 0.72rem;
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-btn:hover {
border-color: var(--accent);
}
.approval-btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.approval-btn-danger {
border-color: var(--error, #e5484d);
color: var(--error, #e5484d);
}
.approval-text-row {
display: flex;
gap: 6px;
width: 100%;
}
.approval-text-input {
flex: 1;
background: var(--bg, rgba(0, 0, 0, 0.3));
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.74rem;
padding: 5px 8px;
}
.approval-meta-actions {
display: flex;
gap: 12px;
margin-top: 6px;
}
.approval-link {
background: none;
border: none;
color: var(--text-dim);
font-size: 0.68rem;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.approval-link:hover {
color: var(--text);
}
/* "Attachments" header button — opt-in (App Settings → Display), hidden by
default. Same pattern as the response viewer: a base inline-flex !important so
an inline style can't override it, and a more-specific marker rule to hide. */
@@ -11980,7 +12210,8 @@ body.touch-device.cjk-input-visible .main {
}
.path-picker-up,
.path-picker-refresh {
.path-picker-refresh,
.path-picker-hidden {
flex: 0 0 38px;
height: 38px;
color: var(--text);
@@ -11990,6 +12221,20 @@ body.touch-device.cjk-input-visible .main {
cursor: pointer;
}
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
* (dot-prefixed files and folders) survives every skin and font stack. */
.path-picker-hidden {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.05em;
}
.path-picker-hidden.active {
color: var(--accent);
border-color: var(--accent);
}
.path-picker-up:disabled {
opacity: 0.35;
cursor: default;
+44 -20
View File
@@ -111,14 +111,14 @@ self.addEventListener('push', (event) => {
return;
}
const { title, hostTitle, body, tag, sessionId, urgency, actions } = payload;
const { title, hostTitle, body, tag, sessionId, approvalId, urgency, actions } = payload;
const options = {
body: body || '',
tag: tag || 'codeman-default',
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' },
renotify: true,
requireInteraction: urgency === 'critical',
};
@@ -142,24 +142,48 @@ self.addEventListener('push', (event) => {
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { sessionId, url } = event.notification.data || {};
const { sessionId, approvalId, url } = event.notification.data || {};
const targetUrl = url || '/';
const action = event.action || null;
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
// Try to find an existing Codeman tab
for (const client of clients) {
if (client.url.includes(self.location.origin)) {
client.postMessage({
type: 'notification-click',
sessionId,
action: event.action || null,
});
return client.focus();
}
}
// No existing tab -- open a new one
return self.clients.openWindow(targetUrl);
})
);
// Approve/Deny action buttons answer the Approvals Inbox item directly from
// the worker, so they work with NO Codeman tab open (lock-screen approvals).
// Same-origin POST with cookie credentials; the CSRF Origin check passes
// because a service worker fetch carries the worker's own (same) origin.
if ((action === 'approve' || action === 'deny') && approvalId) {
event.waitUntil(
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}).then((res) => {
if (res && res.ok) return undefined;
// 401/404/409: let the human see the state by falling back to a tab.
return openOrFocus(sessionId, action, approvalId, targetUrl);
}).catch(() => openOrFocus(sessionId, action, approvalId, targetUrl))
);
return;
}
event.waitUntil(openOrFocus(sessionId, action, approvalId, targetUrl));
});
function openOrFocus(sessionId, action, approvalId, targetUrl) {
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
// Try to find an existing Codeman tab
for (const client of clients) {
if (client.url.includes(self.location.origin)) {
client.postMessage({
type: 'notification-click',
sessionId,
approvalId,
action,
});
return client.focus();
}
}
// No existing tab -- open a new one
return self.clients.openWindow(targetUrl);
});
}