mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
Merge master into feat/offline-state (keep both offline overlay and approvals drawer)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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`;
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '仅活动标签页',
|
||||
|
||||
@@ -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">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.toggleFileBrowserExpand()" title="Expand/Collapse All" id="fileBrowserExpandBtn">⊞</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">×</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>
|
||||
|
||||
@@ -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">↑</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">↻</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');
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user