mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat: add subagent window parent tab connection
- Show "from [session name]" header in subagent windows - Add spawn animation from parent tab position - Draw curved SVG connection lines from tabs to windows - Update connection lines on drag, resize, minimize, close - Fix API response parsing (data.data not data.subagents) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md
|
||||
|
||||
Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs.
|
||||
|
||||
**Version**: 0.1369 (must match `package.json`)
|
||||
**Version**: 0.1370 (must match `package.json`)
|
||||
|
||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
||||
|
||||
@@ -108,8 +108,9 @@ npx vitest run -t "should create session" # By pattern
|
||||
| 3151 | browser-e2e.test.ts (SSE events tests) |
|
||||
| 3152 | browser-e2e.test.ts (hook events tests) |
|
||||
| 3153 | browser-e2e.test.ts (Ralph panel tests) |
|
||||
| 3154 | file-link-click.test.ts |
|
||||
|
||||
**Next available port**: 3154
|
||||
**Next available port**: 3155
|
||||
|
||||
Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator, ai-idle-checker, ai-plan-checker
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1369",
|
||||
"version": "0.1370",
|
||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+602
-7
@@ -443,6 +443,12 @@ class ClaudemanApp {
|
||||
this.subagentWindowZIndex = 1000;
|
||||
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
||||
|
||||
// Active Bash tool tracking (for clickable file paths)
|
||||
this.activeTools = new Map(); // Map<sessionId, ActiveBashTool[]>
|
||||
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
|
||||
this.logViewerWindowZIndex = 2000;
|
||||
this.activeToolsPanelVisible = false;
|
||||
|
||||
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
||||
this.tabAlerts = new Map();
|
||||
|
||||
@@ -561,6 +567,9 @@ class ClaudemanApp {
|
||||
this.terminal.open(container);
|
||||
this.fitAddon.fit();
|
||||
|
||||
// Register link provider for clickable file paths in Bash tool output
|
||||
this.registerFilePathLinkProvider();
|
||||
|
||||
// Always use mouse wheel for terminal scrollback, never forward to application.
|
||||
// Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
|
||||
container.addEventListener('wheel', (ev) => {
|
||||
@@ -597,6 +606,8 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update subagent connection lines when viewport resizes
|
||||
this.updateConnectionLines();
|
||||
}, 100); // Throttle to 100ms
|
||||
};
|
||||
|
||||
@@ -644,6 +655,179 @@ class ClaudemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom link provider for xterm.js that detects file paths
|
||||
* in Bash tool output and makes them clickable.
|
||||
* When clicked, opens a floating log viewer window with live streaming.
|
||||
*/
|
||||
registerFilePathLinkProvider() {
|
||||
const app = this;
|
||||
|
||||
// Create tooltip element
|
||||
let tooltip = document.getElementById('file-link-tooltip');
|
||||
if (!tooltip) {
|
||||
tooltip = document.createElement('div');
|
||||
tooltip.id = 'file-link-tooltip';
|
||||
tooltip.className = 'file-link-tooltip';
|
||||
tooltip.style.display = 'none';
|
||||
document.body.appendChild(tooltip);
|
||||
}
|
||||
|
||||
this.terminal.registerLinkProvider({
|
||||
provideLinks(bufferLineNumber, callback) {
|
||||
const line = app.terminal.buffer.active.getLine(bufferLineNumber);
|
||||
if (!line) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the line text
|
||||
let lineText = '';
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
lineText += line.getCell(i)?.getChars() || ' ';
|
||||
}
|
||||
|
||||
const links = [];
|
||||
|
||||
// Skip lines that are unlikely to contain file paths (performance optimization)
|
||||
// Only skip if line has NO forward slash at all
|
||||
if (!lineText.includes('/')) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pattern 1: File paths after tail/cat/head/less/grep/watch commands
|
||||
// Matches: tail -f /path/to/file, cat /path, head -n 10 /path, grep "foo" /path, etc.
|
||||
// The (?:[^\s/]+\s+)* handles any arguments before the path (flags, values, quoted strings)
|
||||
const cmdPattern = /(tail|cat|head|less|grep|watch|multitail)\s+(?:[^\/\n]+?\s+)?(\/[^\s"'<>|;&\n\)]+)/g;
|
||||
|
||||
// Pattern 2: Any absolute path inside Bash() parentheses
|
||||
const bashPathPattern = /Bash\([^)]*?(\/(?:var|tmp|home|usr|etc|opt|log|logs)[^\s"'<>|;&\)\n]+)/g;
|
||||
|
||||
// Pattern 3: General absolute paths with common file extensions (for Claude output)
|
||||
// Matches paths like /home/user/file.log, /tmp/test.txt, etc.
|
||||
const generalPathPattern = /(\/(?:home|tmp|var|etc|usr|opt)[^\s"'<>|;&\n\)\]]+\.(?:log|txt|json|md|ts|js|sh|py|yaml|yml|csv|xml|html|css))/g;
|
||||
|
||||
// Pattern 3b: Common log file paths without extensions
|
||||
// Matches /var/log/syslog, /var/log/auth.log, etc.
|
||||
const logPathPattern = /(\/var\/log\/[^\s"'<>|;&\n\)\]]+)/g;
|
||||
|
||||
// Pattern 4: Paths in Claude output phrases like "Created file:", "Writing to", "Saved:", etc.
|
||||
const claudeOutputPattern = /(?:Created|Writing|Saved|File|Output|Streaming|Monitoring|Log)(?:\s+(?:file|to|at))?[:\s]+([\/~][^\s"'<>|;&\n\)]+)/gi;
|
||||
|
||||
let match;
|
||||
|
||||
// Match command patterns
|
||||
cmdPattern.lastIndex = 0;
|
||||
while ((match = cmdPattern.exec(lineText)) !== null) {
|
||||
const filePath = match[2].replace(/[,;:]+$/, ''); // Remove trailing punctuation
|
||||
const startIndex = lineText.indexOf(filePath, match.index);
|
||||
|
||||
if (startIndex >= 0 && !app.isDuplicateLink(links, startIndex, filePath)) {
|
||||
links.push(app.createFileLink(bufferLineNumber, startIndex, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Match Bash() paths
|
||||
bashPathPattern.lastIndex = 0;
|
||||
while ((match = bashPathPattern.exec(lineText)) !== null) {
|
||||
const filePath = match[1].replace(/[,;:]+$/, '');
|
||||
const startIndex = lineText.indexOf(filePath, match.index);
|
||||
|
||||
if (startIndex >= 0 && !app.isDuplicateLink(links, startIndex, filePath)) {
|
||||
links.push(app.createFileLink(bufferLineNumber, startIndex, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Match general absolute paths with file extensions
|
||||
generalPathPattern.lastIndex = 0;
|
||||
while ((match = generalPathPattern.exec(lineText)) !== null) {
|
||||
const filePath = match[1].replace(/[,;:]+$/, '');
|
||||
const startIndex = lineText.indexOf(filePath, match.index);
|
||||
|
||||
if (startIndex >= 0 && !app.isDuplicateLink(links, startIndex, filePath)) {
|
||||
links.push(app.createFileLink(bufferLineNumber, startIndex, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Match log paths (no extension required)
|
||||
logPathPattern.lastIndex = 0;
|
||||
while ((match = logPathPattern.exec(lineText)) !== null) {
|
||||
const filePath = match[1].replace(/[,;:]+$/, '');
|
||||
const startIndex = lineText.indexOf(filePath, match.index);
|
||||
|
||||
if (startIndex >= 0 && !app.isDuplicateLink(links, startIndex, filePath)) {
|
||||
links.push(app.createFileLink(bufferLineNumber, startIndex, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Match Claude output phrases (Created file:, Writing to, etc.)
|
||||
claudeOutputPattern.lastIndex = 0;
|
||||
while ((match = claudeOutputPattern.exec(lineText)) !== null) {
|
||||
let filePath = match[1].replace(/[,;:]+$/, '');
|
||||
// Expand ~ to /home/username (approximate)
|
||||
if (filePath.startsWith('~')) {
|
||||
filePath = '/home' + filePath.substring(1);
|
||||
}
|
||||
const startIndex = lineText.indexOf(match[1], match.index);
|
||||
|
||||
if (startIndex >= 0 && filePath.startsWith('/') && !app.isDuplicateLink(links, startIndex, filePath)) {
|
||||
links.push(app.createFileLink(bufferLineNumber, startIndex, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging (can be removed later)
|
||||
if (links.length > 0) {
|
||||
console.log('[LinkProvider] Found', links.length, 'link(s) on line', bufferLineNumber, ':', links.map(l => l.text));
|
||||
}
|
||||
|
||||
callback(links.length > 0 ? links : undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isDuplicateLink(links, startIndex, filePath) {
|
||||
return links.some(l => l.range.start.x === startIndex + 1 && l.text === filePath);
|
||||
}
|
||||
|
||||
createFileLink(bufferLineNumber, startIndex, filePath) {
|
||||
const app = this;
|
||||
return {
|
||||
range: {
|
||||
start: { x: startIndex + 1, y: bufferLineNumber + 1 },
|
||||
end: { x: startIndex + filePath.length + 1, y: bufferLineNumber + 1 }
|
||||
},
|
||||
text: filePath,
|
||||
activate: () => {
|
||||
app.openLogViewerWindow(filePath, app.activeSessionId);
|
||||
},
|
||||
hover: (event, text) => {
|
||||
app.showFileLinkTooltip(event, text);
|
||||
},
|
||||
leave: () => {
|
||||
app.hideFileLinkTooltip();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
showFileLinkTooltip(event, filePath) {
|
||||
const tooltip = document.getElementById('file-link-tooltip');
|
||||
if (!tooltip) return;
|
||||
|
||||
const fileName = filePath.split('/').pop();
|
||||
tooltip.textContent = fileName;
|
||||
tooltip.style.display = 'block';
|
||||
tooltip.style.left = `${event.clientX + 10}px`;
|
||||
tooltip.style.top = `${event.clientY + 10}px`;
|
||||
}
|
||||
|
||||
hideFileLinkTooltip() {
|
||||
const tooltip = document.getElementById('file-link-tooltip');
|
||||
if (tooltip) {
|
||||
tooltip.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
showWelcome() {
|
||||
this.terminal.writeln('\x1b[1;36m Claudeman Terminal\x1b[0m');
|
||||
this.terminal.writeln('');
|
||||
@@ -889,6 +1073,8 @@ class ClaudemanApp {
|
||||
this.sessions.delete(data.id);
|
||||
this.terminalBuffers.delete(data.id);
|
||||
this.ralphStates.delete(data.id); // Clean up ralph state for this session
|
||||
this.activeTools.delete(data.id); // Clean up active tools for this session
|
||||
this.closeSessionLogViewerWindows(data.id); // Close log viewer windows for this session
|
||||
// Clean up idle timer for this session
|
||||
const idleTimer = this.idleTimers.get(data.id);
|
||||
if (idleTimer) {
|
||||
@@ -902,6 +1088,7 @@ class ClaudemanApp {
|
||||
}
|
||||
this.renderSessionTabs();
|
||||
this.renderRalphStatePanel(); // Update ralph panel after session deleted
|
||||
this.renderActiveToolsPanel(); // Update active tools panel after session deleted
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('session:terminal', (e) => {
|
||||
@@ -1327,6 +1514,22 @@ class ClaudemanApp {
|
||||
});
|
||||
});
|
||||
|
||||
// Active Bash tool events (for clickable file paths)
|
||||
this.eventSource.addEventListener('session:bashToolStart', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.handleBashToolStart(data.sessionId, data.tool);
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('session:bashToolEnd', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.handleBashToolEnd(data.sessionId, data.tool);
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('session:bashToolsUpdate', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.handleBashToolsUpdate(data.sessionId, data.tools);
|
||||
});
|
||||
|
||||
// Spawn agent notification events
|
||||
this.eventSource.addEventListener('spawn:failed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
@@ -1444,11 +1647,15 @@ class ClaudemanApp {
|
||||
|
||||
// ========== Subagent Events (Claude Code Background Agents) ==========
|
||||
|
||||
this.eventSource.addEventListener('subagent:discovered', (e) => {
|
||||
this.eventSource.addEventListener('subagent:discovered', async (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.subagents.set(data.agentId, data);
|
||||
this.subagentActivity.set(data.agentId, []);
|
||||
this.renderSubagentPanel();
|
||||
|
||||
// Find which Claudeman session owns this subagent (by working directory)
|
||||
await this.findParentSessionForSubagent(data.agentId);
|
||||
|
||||
// Auto-open window for new active agents
|
||||
if (data.status === 'active') {
|
||||
this.openSubagentWindow(data.agentId);
|
||||
@@ -1727,6 +1934,8 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
// Update connection lines after tabs change (positions may have shifted)
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
|
||||
getSessionName(session) {
|
||||
@@ -1823,6 +2032,9 @@ class ClaudemanApp {
|
||||
}
|
||||
this.renderRalphStatePanel();
|
||||
|
||||
// Update active tools panel for this session
|
||||
this.renderActiveToolsPanel();
|
||||
|
||||
this.terminal.focus();
|
||||
this.terminal.scrollToBottom();
|
||||
} catch (err) {
|
||||
@@ -4777,6 +4989,99 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Subagent Parent Session Tracking ==========
|
||||
|
||||
async findParentSessionForSubagent(agentId) {
|
||||
const agent = this.subagents.get(agentId);
|
||||
if (!agent) return;
|
||||
|
||||
// Query each session to find which one owns this subagent
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sessions/${sessionId}/subagents`);
|
||||
if (!resp.ok) continue;
|
||||
const result = await resp.json();
|
||||
const subagents = result.data || result.subagents || [];
|
||||
if (subagents.some(s => s.agentId === agentId)) {
|
||||
agent.parentSessionId = sessionId;
|
||||
agent.parentSessionName = this.getSessionName(session);
|
||||
this.subagents.set(agentId, agent);
|
||||
// Update window if already open
|
||||
this.updateSubagentWindowParent(agentId);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors, continue checking other sessions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSubagentWindowParent(agentId) {
|
||||
const windowData = this.subagentWindows.get(agentId);
|
||||
if (!windowData) return;
|
||||
const agent = this.subagents.get(agentId);
|
||||
if (!agent?.parentSessionId) return;
|
||||
|
||||
// Check if parent header already exists
|
||||
const win = windowData.element;
|
||||
if (win.querySelector('.subagent-window-parent')) return;
|
||||
|
||||
// Insert parent header after the main header
|
||||
const header = win.querySelector('.subagent-window-header');
|
||||
if (header) {
|
||||
const parentDiv = document.createElement('div');
|
||||
parentDiv.className = 'subagent-window-parent';
|
||||
parentDiv.dataset.parentSession = agent.parentSessionId;
|
||||
parentDiv.innerHTML = `
|
||||
<span class="parent-label">from</span>
|
||||
<span class="parent-name" onclick="app.selectSession('${agent.parentSessionId}')">${this.escapeHtml(agent.parentSessionName)}</span>
|
||||
`;
|
||||
header.insertAdjacentElement('afterend', parentDiv);
|
||||
}
|
||||
|
||||
// Update connection lines
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
|
||||
// ========== Subagent Connection Lines ==========
|
||||
|
||||
updateConnectionLines() {
|
||||
const svg = document.getElementById('connectionLines');
|
||||
if (!svg) return;
|
||||
|
||||
svg.innerHTML = '';
|
||||
|
||||
for (const [agentId, windowInfo] of this.subagentWindows) {
|
||||
if (windowInfo.minimized) continue;
|
||||
|
||||
const agent = this.subagents.get(agentId);
|
||||
if (!agent?.parentSessionId) continue;
|
||||
|
||||
const tab = document.querySelector(`.session-tab[data-id="${agent.parentSessionId}"]`);
|
||||
const win = windowInfo.element;
|
||||
if (!tab || !win) continue;
|
||||
|
||||
const tabRect = tab.getBoundingClientRect();
|
||||
const winRect = win.getBoundingClientRect();
|
||||
|
||||
// Draw curved line from tab bottom-center to window top-center
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
|
||||
// Bezier curve control points for smooth curve
|
||||
const midY = (y1 + y2) / 2;
|
||||
const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('class', 'connection-line');
|
||||
line.setAttribute('data-agent-id', agentId);
|
||||
svg.appendChild(line);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Subagent Floating Windows ==========
|
||||
|
||||
openSubagentWindow(agentId) {
|
||||
@@ -4784,25 +5089,39 @@ class ClaudemanApp {
|
||||
if (this.subagentWindows.has(agentId)) {
|
||||
const existing = this.subagentWindows.get(agentId);
|
||||
existing.element.style.zIndex = ++this.subagentWindowZIndex;
|
||||
if (existing.minimized) {
|
||||
this.restoreSubagentWindow(agentId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = this.subagents.get(agentId);
|
||||
if (!agent) return;
|
||||
|
||||
// Calculate position (cascade from top-left)
|
||||
// Calculate final position (cascade from top-left)
|
||||
const windowCount = this.subagentWindows.size;
|
||||
const offsetX = 50 + (windowCount % 5) * 30;
|
||||
const offsetY = 100 + (windowCount % 5) * 30;
|
||||
const finalX = 50 + (windowCount % 5) * 30;
|
||||
const finalY = 100 + (windowCount % 5) * 30;
|
||||
|
||||
// Get parent tab position for spawn animation
|
||||
const parentTab = agent.parentSessionId
|
||||
? document.querySelector(`.session-tab[data-id="${agent.parentSessionId}"]`)
|
||||
: null;
|
||||
|
||||
// Create window element
|
||||
const win = document.createElement('div');
|
||||
win.className = 'subagent-window';
|
||||
win.id = `subagent-window-${agentId}`;
|
||||
win.style.left = `${offsetX}px`;
|
||||
win.style.top = `${offsetY}px`;
|
||||
win.style.zIndex = ++this.subagentWindowZIndex;
|
||||
|
||||
// Build parent header if we have parent info
|
||||
const parentHeader = agent.parentSessionId && agent.parentSessionName
|
||||
? `<div class="subagent-window-parent" data-parent-session="${agent.parentSessionId}">
|
||||
<span class="parent-label">from</span>
|
||||
<span class="parent-name" onclick="app.selectSession('${agent.parentSessionId}')">${this.escapeHtml(agent.parentSessionName)}</span>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const windowTitle = agent.description || agentId.substring(0, 7);
|
||||
const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle;
|
||||
win.innerHTML = `
|
||||
@@ -4817,14 +5136,29 @@ class ClaudemanApp {
|
||||
<button onclick="app.closeSubagentWindow('${agentId}')" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
${parentHeader}
|
||||
<div class="subagent-window-body" id="subagent-window-body-${agentId}">
|
||||
<div class="subagent-empty">Loading activity...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// If we have a parent tab, start window at tab position for spawn animation
|
||||
if (parentTab) {
|
||||
const tabRect = parentTab.getBoundingClientRect();
|
||||
win.style.left = `${tabRect.left}px`;
|
||||
win.style.top = `${tabRect.bottom}px`;
|
||||
win.style.transform = 'scale(0.3)';
|
||||
win.style.opacity = '0';
|
||||
win.classList.add('spawning');
|
||||
} else {
|
||||
// No parent tab, just position normally
|
||||
win.style.left = `${finalX}px`;
|
||||
win.style.top = `${finalY}px`;
|
||||
}
|
||||
|
||||
document.body.appendChild(win);
|
||||
|
||||
// Make draggable
|
||||
// Make draggable (with connection line update callback)
|
||||
this.makeWindowDraggable(win, win.querySelector('.subagent-window-header'));
|
||||
|
||||
// Store reference
|
||||
@@ -4840,6 +5174,27 @@ class ClaudemanApp {
|
||||
win.addEventListener('mousedown', () => {
|
||||
win.style.zIndex = ++this.subagentWindowZIndex;
|
||||
});
|
||||
|
||||
// Animate to final position if spawning from tab
|
||||
if (parentTab) {
|
||||
requestAnimationFrame(() => {
|
||||
win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
win.style.left = `${finalX}px`;
|
||||
win.style.top = `${finalY}px`;
|
||||
win.style.transform = 'scale(1)';
|
||||
win.style.opacity = '1';
|
||||
|
||||
// Clean up after animation
|
||||
setTimeout(() => {
|
||||
win.style.transition = '';
|
||||
win.classList.remove('spawning');
|
||||
this.updateConnectionLines();
|
||||
}, 400);
|
||||
});
|
||||
} else {
|
||||
// No animation, just update connection lines
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
}
|
||||
|
||||
closeSubagentWindow(agentId) {
|
||||
@@ -4847,6 +5202,7 @@ class ClaudemanApp {
|
||||
if (windowData) {
|
||||
windowData.element.remove();
|
||||
this.subagentWindows.delete(agentId);
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4855,6 +5211,7 @@ class ClaudemanApp {
|
||||
if (windowData) {
|
||||
windowData.element.style.display = 'none';
|
||||
windowData.minimized = true;
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4864,12 +5221,14 @@ class ClaudemanApp {
|
||||
windowData.element.style.display = 'flex';
|
||||
windowData.element.style.zIndex = ++this.subagentWindowZIndex;
|
||||
windowData.minimized = false;
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
}
|
||||
|
||||
makeWindowDraggable(win, handle) {
|
||||
let isDragging = false;
|
||||
let startX, startY, startLeft, startTop;
|
||||
let dragUpdateScheduled = false;
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
@@ -4887,6 +5246,14 @@ class ClaudemanApp {
|
||||
const dy = e.clientY - startY;
|
||||
win.style.left = `${startLeft + dx}px`;
|
||||
win.style.top = `${startTop + dy}px`;
|
||||
// Throttle connection line updates during drag
|
||||
if (!dragUpdateScheduled) {
|
||||
dragUpdateScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.updateConnectionLines();
|
||||
dragUpdateScheduled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
@@ -4960,6 +5327,234 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Active Bash Tools (Clickable File Paths) ==========
|
||||
|
||||
handleBashToolStart(sessionId, tool) {
|
||||
let tools = this.activeTools.get(sessionId) || [];
|
||||
// Add new tool
|
||||
tools = tools.filter(t => t.id !== tool.id);
|
||||
tools.push(tool);
|
||||
this.activeTools.set(sessionId, tools);
|
||||
this.renderActiveToolsPanel();
|
||||
}
|
||||
|
||||
handleBashToolEnd(sessionId, tool) {
|
||||
const tools = this.activeTools.get(sessionId) || [];
|
||||
const existing = tools.find(t => t.id === tool.id);
|
||||
if (existing) {
|
||||
existing.status = 'completed';
|
||||
}
|
||||
this.renderActiveToolsPanel();
|
||||
// Remove after a short delay
|
||||
setTimeout(() => {
|
||||
const current = this.activeTools.get(sessionId) || [];
|
||||
this.activeTools.set(sessionId, current.filter(t => t.id !== tool.id));
|
||||
this.renderActiveToolsPanel();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
handleBashToolsUpdate(sessionId, tools) {
|
||||
this.activeTools.set(sessionId, tools);
|
||||
this.renderActiveToolsPanel();
|
||||
}
|
||||
|
||||
renderActiveToolsPanel() {
|
||||
const panel = this.$('activeToolsPanel');
|
||||
const list = this.$('activeToolsList');
|
||||
if (!panel || !list) return;
|
||||
|
||||
// Get tools for active session only
|
||||
const tools = this.activeTools.get(this.activeSessionId) || [];
|
||||
const runningTools = tools.filter(t => t.status === 'running');
|
||||
|
||||
if (runningTools.length === 0) {
|
||||
panel.classList.remove('visible');
|
||||
this.activeToolsPanelVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
panel.classList.add('visible');
|
||||
this.activeToolsPanelVisible = true;
|
||||
|
||||
const html = [];
|
||||
for (const tool of runningTools) {
|
||||
const cmdDisplay = tool.command.length > 40
|
||||
? tool.command.substring(0, 40) + '...'
|
||||
: tool.command;
|
||||
|
||||
html.push(`
|
||||
<div class="active-tool-item" data-tool-id="${tool.id}">
|
||||
<div class="active-tool-command">
|
||||
<span class="icon">💻</span>
|
||||
<span class="cmd" title="${this.escapeHtml(tool.command)}">${this.escapeHtml(cmdDisplay)}</span>
|
||||
<span class="active-tool-status ${tool.status}">${tool.status}</span>
|
||||
${tool.timeout ? `<span class="active-tool-timeout">${this.escapeHtml(tool.timeout)}</span>` : ''}
|
||||
</div>
|
||||
<div class="active-tool-paths">
|
||||
`);
|
||||
|
||||
for (const path of tool.filePaths) {
|
||||
const fileName = path.split('/').pop();
|
||||
html.push(`
|
||||
<span class="active-tool-filepath"
|
||||
onclick="app.openLogViewerWindow('${this.escapeHtml(path)}', '${tool.sessionId}')"
|
||||
title="${this.escapeHtml(path)}">${this.escapeHtml(fileName)}</span>
|
||||
`);
|
||||
}
|
||||
|
||||
html.push(`
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
list.innerHTML = html.join('');
|
||||
}
|
||||
|
||||
closeActiveToolsPanel() {
|
||||
const panel = this.$('activeToolsPanel');
|
||||
if (panel) {
|
||||
panel.classList.remove('visible');
|
||||
this.activeToolsPanelVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Log Viewer Windows (Floating File Streamers) ==========
|
||||
|
||||
openLogViewerWindow(filePath, sessionId) {
|
||||
sessionId = sessionId || this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
// Create unique window ID
|
||||
const windowId = `${sessionId}-${filePath.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
|
||||
// If window already exists, focus it
|
||||
if (this.logViewerWindows.has(windowId)) {
|
||||
const existing = this.logViewerWindows.get(windowId);
|
||||
existing.element.style.zIndex = ++this.logViewerWindowZIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate position (cascade from top-left)
|
||||
const windowCount = this.logViewerWindows.size;
|
||||
const offsetX = 100 + (windowCount % 5) * 30;
|
||||
const offsetY = 100 + (windowCount % 5) * 30;
|
||||
|
||||
// Get filename for title
|
||||
const fileName = filePath.split('/').pop();
|
||||
|
||||
// Create window element
|
||||
const win = document.createElement('div');
|
||||
win.className = 'log-viewer-window';
|
||||
win.id = `log-viewer-window-${windowId}`;
|
||||
win.style.left = `${offsetX}px`;
|
||||
win.style.top = `${offsetY}px`;
|
||||
win.style.zIndex = ++this.logViewerWindowZIndex;
|
||||
|
||||
win.innerHTML = `
|
||||
<div class="log-viewer-window-header">
|
||||
<div class="log-viewer-window-title" title="${this.escapeHtml(filePath)}">
|
||||
<span class="icon">📄</span>
|
||||
<span class="filename">${this.escapeHtml(fileName)}</span>
|
||||
<span class="status streaming">streaming</span>
|
||||
</div>
|
||||
<div class="log-viewer-window-actions">
|
||||
<button onclick="app.closeLogViewerWindow('${windowId}')" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
|
||||
<div class="log-info">Connecting to ${this.escapeHtml(filePath)}...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(win);
|
||||
|
||||
// Make draggable
|
||||
this.makeWindowDraggable(win, win.querySelector('.log-viewer-window-header'));
|
||||
|
||||
// Connect to SSE stream
|
||||
const eventSource = new EventSource(
|
||||
`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`
|
||||
);
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const body = document.getElementById(`log-viewer-body-${windowId}`);
|
||||
if (!body) return;
|
||||
|
||||
switch (data.type) {
|
||||
case 'connected':
|
||||
body.innerHTML = '';
|
||||
break;
|
||||
case 'data':
|
||||
// Append data, auto-scroll
|
||||
const wasAtBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 10;
|
||||
const content = this.escapeHtml(data.content);
|
||||
body.innerHTML += content;
|
||||
if (wasAtBottom) {
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
// Trim if too large
|
||||
if (body.innerHTML.length > 500000) {
|
||||
body.innerHTML = body.innerHTML.slice(-400000);
|
||||
}
|
||||
break;
|
||||
case 'end':
|
||||
this.updateLogViewerStatus(windowId, 'disconnected', 'ended');
|
||||
break;
|
||||
case 'error':
|
||||
body.innerHTML += `<div class="log-error">${this.escapeHtml(data.error)}</div>`;
|
||||
this.updateLogViewerStatus(windowId, 'error', 'error');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
this.updateLogViewerStatus(windowId, 'disconnected', 'connection error');
|
||||
};
|
||||
|
||||
// Store reference
|
||||
this.logViewerWindows.set(windowId, {
|
||||
element: win,
|
||||
eventSource,
|
||||
filePath,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
updateLogViewerStatus(windowId, statusClass, statusText) {
|
||||
const statusEl = document.querySelector(`#log-viewer-window-${windowId} .status`);
|
||||
if (statusEl) {
|
||||
statusEl.className = `status ${statusClass}`;
|
||||
statusEl.textContent = statusText;
|
||||
}
|
||||
}
|
||||
|
||||
closeLogViewerWindow(windowId) {
|
||||
const windowData = this.logViewerWindows.get(windowId);
|
||||
if (!windowData) return;
|
||||
|
||||
// Close SSE connection
|
||||
if (windowData.eventSource) {
|
||||
windowData.eventSource.close();
|
||||
}
|
||||
|
||||
// Remove element
|
||||
windowData.element.remove();
|
||||
|
||||
// Remove from map
|
||||
this.logViewerWindows.delete(windowId);
|
||||
}
|
||||
|
||||
// Close all log viewer windows for a session
|
||||
closeSessionLogViewerWindows(sessionId) {
|
||||
for (const [windowId, data] of this.logViewerWindows) {
|
||||
if (data.sessionId === sessionId) {
|
||||
this.closeLogViewerWindow(windowId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Screen Sessions (in Monitor Panel) ==========
|
||||
|
||||
async loadScreens() {
|
||||
|
||||
@@ -172,6 +172,15 @@
|
||||
<div class="terminal-container" id="terminalContainer"></div>
|
||||
</main>
|
||||
|
||||
<!-- Active Tools Panel (shows file-viewing Bash commands) -->
|
||||
<div class="active-tools-panel" id="activeToolsPanel">
|
||||
<div class="active-tools-header">
|
||||
<span class="active-tools-title">Active Tools</span>
|
||||
<button class="btn-icon-sm" onclick="app.closeActiveToolsPanel()" title="Close">×</button>
|
||||
</div>
|
||||
<div class="active-tools-body" id="activeToolsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Toolbar -->
|
||||
<footer class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
@@ -841,6 +850,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SVG Overlay for Subagent Connection Lines -->
|
||||
<svg id="connectionLines" class="connection-lines-svg">
|
||||
<!-- Lines drawn dynamically -->
|
||||
</svg>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+345
-4
@@ -3899,27 +3899,32 @@ kbd {
|
||||
|
||||
.subagent-window-body .activity-line {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
gap: 0.3rem;
|
||||
padding: 0.15rem 0;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.subagent-window-body .activity-line .time {
|
||||
color: var(--text-dim);
|
||||
flex: 0 0 70px;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagent-window-body .activity-line .tool-icon {
|
||||
flex: 0 0 20px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.subagent-window-body .activity-line .tool-name {
|
||||
color: var(--accent);
|
||||
flex: 0 0 90px;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagent-window-body .activity-line .tool-detail {
|
||||
color: white;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subagent-window-body .message-line {
|
||||
@@ -3931,3 +3936,339 @@ kbd {
|
||||
color: var(--warning);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Subagent Window Parent Tab Header */
|
||||
.subagent-window-parent {
|
||||
background: var(--bg-darker);
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.subagent-window-parent .parent-label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.subagent-window-parent .parent-name {
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.subagent-window-parent .parent-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Subagent Window Spawn Animation */
|
||||
.subagent-window.spawning {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ========== Connection Lines SVG Overlay ========== */
|
||||
.connection-lines-svg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 999; /* Below windows (1000+), above panels (91) */
|
||||
}
|
||||
|
||||
.connection-line {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 3;
|
||||
fill: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s, stroke-width 0.2s;
|
||||
}
|
||||
|
||||
.connection-line:hover {
|
||||
opacity: 0.8;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.connection-line.spawning-line {
|
||||
stroke-dasharray: 1000;
|
||||
stroke-dashoffset: 1000;
|
||||
}
|
||||
|
||||
/* ========== Active Tools Panel (Bash File Viewers) ========== */
|
||||
|
||||
.active-tools-panel {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: calc(var(--header-height) + 10px);
|
||||
width: 350px;
|
||||
max-height: 200px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.active-tools-panel.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.active-tools-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.active-tools-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.active-tools-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.active-tool-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.active-tool-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.active-tool-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.active-tool-command .icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.active-tool-command .cmd {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.active-tool-paths {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-top: 0.3rem;
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
|
||||
.active-tool-filepath {
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
text-decoration: underline;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.active-tool-filepath:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.active-tool-timeout {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.active-tool-status {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
.active-tool-status.running {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.active-tool-status.completed {
|
||||
background: var(--text-muted);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ========== Log Viewer Windows (Floating) ========== */
|
||||
|
||||
.log-viewer-window {
|
||||
position: fixed;
|
||||
width: 550px;
|
||||
height: 400px;
|
||||
min-width: 350px;
|
||||
min-height: 200px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.log-viewer-window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.log-viewer-window-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.log-viewer-window-title .icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.log-viewer-window-title .filename {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.log-viewer-window-title .status {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.log-viewer-window-title .status.streaming {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.log-viewer-window-title .status.disconnected {
|
||||
background: var(--text-muted);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-viewer-window-title .status.error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.log-viewer-window-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.log-viewer-window-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.log-viewer-window-actions button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.log-viewer-window-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-dark);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-viewer-window-body .log-line {
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.log-viewer-window-body .log-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.log-viewer-window-body .log-info {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ========== xterm.js Clickable Links ========== */
|
||||
|
||||
/* Style for detected file path links in terminal */
|
||||
.xterm-link-layer .xterm-link {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hover state for links */
|
||||
.xterm .xterm-screen .xterm-decoration-container .xterm-decoration.xterm-link-decoration {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Custom tooltip for file links */
|
||||
.file-link-tooltip {
|
||||
position: fixed;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.file-link-tooltip::before {
|
||||
content: "📄 Click to stream: ";
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user