mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
Remove non-merge-ready workspace and terminal agent hooks
This commit is contained in:
@@ -529,9 +529,6 @@ upload_cleanup_task = None
|
||||
from routes.emoji_routes import setup_emoji_routes
|
||||
app.include_router(setup_emoji_routes())
|
||||
|
||||
from routes.workspace_routes import setup_workspace_routes
|
||||
app.include_router(setup_workspace_routes())
|
||||
|
||||
# Sessions
|
||||
from routes.session_routes import setup_session_routes
|
||||
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
||||
@@ -650,10 +647,6 @@ app.include_router(calendar_router)
|
||||
from routes.shell_routes import setup_shell_routes
|
||||
app.include_router(setup_shell_routes())
|
||||
|
||||
# Terminal agents (tmux-backed Codex/Claude/shell sessions)
|
||||
from routes.terminal_agent_routes import setup_terminal_agent_routes
|
||||
app.include_router(setup_terminal_agent_routes())
|
||||
|
||||
# Cookbook (model download/serve/cache, cookbook state sync)
|
||||
from routes.cookbook_routes import setup_cookbook_routes
|
||||
app.include_router(setup_cookbook_routes())
|
||||
|
||||
@@ -456,12 +456,7 @@ def setup_chat_routes(
|
||||
# manual form posts that still send plan_mode=true.
|
||||
plan_mode = False
|
||||
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
|
||||
# Workspace: confine the agent's file/shell tools to this folder. Validate
|
||||
# it's a real directory; ignore (no confinement) otherwise.
|
||||
workspace = (form_data.get("workspace") or "").strip()
|
||||
if workspace:
|
||||
_ws_real = os.path.realpath(os.path.expanduser(workspace))
|
||||
workspace = _ws_real if os.path.isdir(_ws_real) else ""
|
||||
workspace = ""
|
||||
# Plan mode is a modifier on agent mode — it only makes sense with tools.
|
||||
if plan_mode:
|
||||
chat_mode = "agent"
|
||||
@@ -1140,7 +1135,7 @@ def setup_chat_routes(
|
||||
tool_policy=tool_policy,
|
||||
owner=_user,
|
||||
fallbacks=_fallback_candidates,
|
||||
workspace=workspace or None,
|
||||
workspace=None,
|
||||
plan_mode=plan_mode,
|
||||
approved_plan=approved_plan or None,
|
||||
):
|
||||
@@ -1272,8 +1267,7 @@ def setup_chat_routes(
|
||||
# without waiting on the next streamed chunk.
|
||||
#
|
||||
# Normal chat/agent streams keep the DETACHED behavior below: they
|
||||
# survive the client closing the tab / navigating away (true
|
||||
# terminal-agent semantics). The SSE response just subscribes (replay
|
||||
# survive the client closing the tab / navigating away. The SSE response just subscribes (replay
|
||||
# buffered output + live); dropping the SSE only removes a subscriber —
|
||||
# the run keeps going and saves the assistant message on completion
|
||||
# regardless. Reconnect via /api/chat/resume.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Workspace API — browse server directories to pick a tool workspace folder."""
|
||||
import os
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
|
||||
|
||||
def setup_workspace_routes():
|
||||
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(request: Request, path: str = Query(default="")):
|
||||
"""List subdirectories of `path` (default: home) so the UI can navigate
|
||||
the server filesystem and pick a workspace folder. Directories only.
|
||||
|
||||
ADMIN-ONLY: this enumerates the server filesystem, so it is gated the
|
||||
same way the file/shell tools are (read_file/write_file/bash are in
|
||||
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
|
||||
be able to map the host's directory tree either.
|
||||
"""
|
||||
owner = get_current_user(request)
|
||||
if not owner_is_admin_or_single_user(owner):
|
||||
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
|
||||
|
||||
# Resolve symlinks so the reported path is canonical and the UI navigates
|
||||
# real directories (defends against symlink games in displayed paths).
|
||||
target = os.path.realpath(os.path.expanduser(path.strip() or "~"))
|
||||
if not os.path.isdir(target):
|
||||
target = os.path.realpath(os.path.expanduser("~"))
|
||||
|
||||
dirs = []
|
||||
try:
|
||||
with os.scandir(target) as it:
|
||||
for entry in it:
|
||||
try:
|
||||
# Don't follow symlinks when classifying — a symlinked
|
||||
# dir is skipped rather than letting the browser wander
|
||||
# off via a link. Hidden entries are omitted.
|
||||
if entry.is_dir(follow_symlinks=False) and not entry.name.startswith("."):
|
||||
# Build the child path server-side with os.path.join
|
||||
# so it's correct on Windows (backslashes) and Linux.
|
||||
dirs.append({"name": entry.name, "path": os.path.join(target, entry.name)})
|
||||
except OSError:
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
dirs = []
|
||||
|
||||
parent = os.path.dirname(target)
|
||||
return {
|
||||
"path": target,
|
||||
"parent": parent if parent and parent != target else None,
|
||||
"dirs": sorted(dirs, key=lambda d: d["name"].lower()),
|
||||
}
|
||||
|
||||
return router
|
||||
@@ -4,7 +4,6 @@
|
||||
// ============================================
|
||||
import Storage from './js/storage.js';
|
||||
import uiModule from './js/ui.js';
|
||||
import workspaceModule from './js/workspace.js';
|
||||
import fileHandlerModule from './js/fileHandler.js';
|
||||
import modelsModule from './js/models.js';
|
||||
import ragModule from './js/rag.js';
|
||||
@@ -1702,9 +1701,6 @@ function initializeEventListeners() {
|
||||
}
|
||||
setupToggle('web-toggle-btn', 'web-toggle', 'web');
|
||||
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
|
||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||
|
||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||
|
||||
// Document editor toggle (special: uses module panel, not a checkbox)
|
||||
const overflowDocBtn = el('overflow-doc-btn');
|
||||
|
||||
@@ -1040,13 +1040,6 @@
|
||||
<span>RAG</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
<span>Workspace</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<!-- Inline "deep research mode" toggle removed (superseded by the
|
||||
Deep Research sidebar / trigger_research). The hidden
|
||||
#research-toggle checkbox is kept inert so existing JS refs
|
||||
@@ -1078,12 +1071,6 @@
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Workspace indicator (hidden until a folder is set) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Workspace — click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
<!-- RAG toolbar indicator (hidden until active) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="RAG active — click to deactivate" id="rag-indicator-btn" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
||||
@@ -817,10 +817,6 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (incognitoChk && incognitoChk.checked) {
|
||||
fd.append('incognito', 'true');
|
||||
}
|
||||
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
|
||||
if (_ws) {
|
||||
fd.append('workspace', _ws);
|
||||
}
|
||||
if (presetsModule.getSelectedPreset()) {
|
||||
fd.append('preset_id', presetsModule.getSelectedPreset());
|
||||
}
|
||||
|
||||
@@ -5048,7 +5048,7 @@ async function initUnifiedIntegrations() {
|
||||
});
|
||||
formEl.querySelectorAll('.uf-codex-revoke').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!await window.styledConfirm(`Revoke this ${cfg.word} token? Terminal agents using it will lose access.`, { confirmText: 'Revoke', danger: true })) return;
|
||||
if (!await window.styledConfirm(`Revoke this ${cfg.word} token? Integrations using it will lose access.`, { confirmText: 'Revoke', danger: true })) return;
|
||||
await fetch(`/api/tokens/${btn.dataset.tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
formEl.style.display = 'none';
|
||||
await renderList();
|
||||
|
||||
@@ -17,7 +17,6 @@ import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js';
|
||||
import documentModule from './document.js';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
import { EVAL_PROMPTS } from './compare/index.js';
|
||||
@@ -1226,34 +1225,6 @@ async function _cmdToggleDoc(args, ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workspace: confine the agent's file/shell tools to a folder. Not a boolean —
|
||||
// show / set <path> / clear / pick (open the directory browser).
|
||||
async function _cmdWorkspace(args, ctx) {
|
||||
const sub = (args[0] || '').toLowerCase();
|
||||
const rest = args.slice(1).join(' ').trim();
|
||||
const cur = workspaceModule.getWorkspace();
|
||||
if (!sub || sub === 'show' || sub === 'status' || sub === 'info') {
|
||||
slashReply(cur ? `Workspace: <code>${uiModule.esc(cur)}</code>` : 'No workspace set. <code>/workspace pick</code> or <code>/workspace set /path</code>.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'set' || sub === 'cd' || sub === 'use') {
|
||||
if (!rest) { slashReply('Usage: <code>/workspace set /absolute/path</code>'); return true; }
|
||||
workspaceModule.setWorkspace(rest);
|
||||
slashReply(`Workspace set: <code>${uiModule.esc(rest)}</code>`);
|
||||
return true;
|
||||
}
|
||||
if (sub === 'clear' || sub === 'off' || sub === 'none' || sub === 'unset') {
|
||||
workspaceModule.clearWorkspace();
|
||||
slashReply('Workspace cleared.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'pick' || sub === 'browse' || sub === 'open') {
|
||||
workspaceModule.openWorkspaceBrowser();
|
||||
return true;
|
||||
}
|
||||
slashReply('Usage: <code>/workspace</code> · <code>set /path</code> · <code>clear</code> · <code>pick</code>');
|
||||
return true;
|
||||
}
|
||||
async function _cmdToggleShow(args, ctx) {
|
||||
const name = (args[0] || '').toLowerCase();
|
||||
const val = (args[1] || '').toLowerCase();
|
||||
@@ -5757,14 +5728,6 @@ const COMMANDS = {
|
||||
'_show': { handler: _cmdToggleShow, alias: [], help: 'Show all toggle states', usage: '/toggle' }
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
alias: ['ws'],
|
||||
category: 'Agent',
|
||||
help: 'Set the folder the agent works in',
|
||||
handler: _cmdWorkspace,
|
||||
noUserBubble: true,
|
||||
usage: '/workspace [set <path> | clear | pick]',
|
||||
},
|
||||
memory: {
|
||||
alias: ['m'],
|
||||
category: 'Memory',
|
||||
|
||||
@@ -23,8 +23,7 @@ export const KEYS = {
|
||||
MCP_ACTIVE: 'odysseus-mcp-active',
|
||||
SECTION_ORDER: 'sidebar-section-order',
|
||||
ADMIN_LAST_TAB: 'admin-last-tab',
|
||||
DENSITY: 'odysseus-density',
|
||||
WORKSPACE: 'odysseus-workspace'
|
||||
DENSITY: 'odysseus-density'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// static/js/workspace.js
|
||||
//
|
||||
// Workspace picker: browse server directories in a draggable modal, choose a
|
||||
// folder, and show it as a removable pill in the chat input bar. While set, the
|
||||
// chat request sends `workspace` so the agent's file/shell tools are confined
|
||||
// to that folder (see routes/chat_routes.py + src/tool_execution.py).
|
||||
|
||||
import Storage, { KEYS } from './storage.js';
|
||||
import uiModule from './ui.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
// Same folder glyph as the overflow menu item + pill (not an emoji).
|
||||
const _FOLDER_SVG = '<svg class="workspace-row-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
|
||||
let _modal = null;
|
||||
let _curPath = '';
|
||||
|
||||
export function getWorkspace() {
|
||||
return Storage.get(KEYS.WORKSPACE, '') || '';
|
||||
}
|
||||
|
||||
function _basename(p) {
|
||||
if (!p) return '';
|
||||
// Handle both POSIX (/) and Windows (\) separators.
|
||||
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
|
||||
return parts[parts.length - 1] || p;
|
||||
}
|
||||
|
||||
export function syncWorkspaceIndicator(path) {
|
||||
const pill = document.getElementById('workspace-indicator-btn');
|
||||
const name = document.getElementById('workspace-indicator-name');
|
||||
const overflow = document.getElementById('overflow-workspace-btn');
|
||||
if (pill) {
|
||||
pill.style.display = path ? '' : 'none';
|
||||
pill.classList.toggle('active', !!path);
|
||||
if (path) pill.title = `Workspace: ${path} — click to clear`;
|
||||
}
|
||||
if (name) name.textContent = path ? _basename(path) : '';
|
||||
if (overflow) overflow.classList.toggle('active', !!path);
|
||||
// Recompute the "+" overflow dot (app.js owns updatePlusDot via this event).
|
||||
try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {}
|
||||
}
|
||||
|
||||
export function setWorkspace(path) {
|
||||
if (path) Storage.set(KEYS.WORKSPACE, path);
|
||||
else Storage.remove(KEYS.WORKSPACE);
|
||||
syncWorkspaceIndicator(path || '');
|
||||
}
|
||||
|
||||
export function clearWorkspace() {
|
||||
setWorkspace('');
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared');
|
||||
}
|
||||
|
||||
async function _load(path) {
|
||||
const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`;
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`browse failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function _render(data) {
|
||||
_curPath = data.path;
|
||||
const body = _modal.querySelector('#workspace-body');
|
||||
const pathEl = _modal.querySelector('#workspace-cur-path');
|
||||
if (pathEl) {
|
||||
// Reflect the resolved (realpath) location back into the editable field.
|
||||
pathEl.value = data.path;
|
||||
pathEl.title = data.path;
|
||||
}
|
||||
let rows = '';
|
||||
if (data.parent) {
|
||||
rows += `<div class="workspace-row workspace-up" data-path="${encodeURIComponent(data.parent)}">↑ ..</div>`;
|
||||
}
|
||||
for (const d of data.dirs) {
|
||||
// Backend supplies the full child path (os.path.join → cross-platform).
|
||||
rows += `<div class="workspace-row" data-path="${encodeURIComponent(d.path)}">${_FOLDER_SVG}<span>${uiModule.esc(d.name)}</span></div>`;
|
||||
}
|
||||
if (!data.dirs.length && !data.parent) rows = '<div class="workspace-empty">No subfolders</div>';
|
||||
body.innerHTML = rows || '<div class="workspace-empty">No subfolders</div>';
|
||||
body.querySelectorAll('.workspace-row').forEach((row) => {
|
||||
row.addEventListener('click', () => _navigate(decodeURIComponent(row.dataset.path)));
|
||||
});
|
||||
}
|
||||
|
||||
async function _navigate(path) {
|
||||
try {
|
||||
_render(await _load(path));
|
||||
} catch (e) {
|
||||
if (uiModule && uiModule.showError) uiModule.showError('Could not open folder');
|
||||
}
|
||||
}
|
||||
|
||||
function _getModal() {
|
||||
if (_modal) return _modal;
|
||||
_modal = document.createElement('div');
|
||||
_modal.id = 'workspace-modal';
|
||||
_modal.className = 'modal';
|
||||
_modal.style.display = 'none';
|
||||
_modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Select workspace</h4>
|
||||
<button class="close-btn" id="workspace-close" aria-label="Close">✖</button>
|
||||
</div>
|
||||
<input type="text" class="styled-prompt-input workspace-cur" id="workspace-cur-path"
|
||||
spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
|
||||
placeholder="Type or paste a folder path, then press Enter" />
|
||||
<div class="modal-body workspace-body" id="workspace-body"></div>
|
||||
<div class="modal-footer workspace-footer">
|
||||
<button type="button" class="confirm-btn confirm-btn-secondary" id="workspace-cancel">Cancel</button>
|
||||
<button type="button" class="confirm-btn confirm-btn-primary" id="workspace-use">Use this folder</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#workspace-close').addEventListener('click', closeWorkspaceBrowser);
|
||||
_modal.querySelector('#workspace-cancel').addEventListener('click', closeWorkspaceBrowser);
|
||||
// Editable path bar: Enter navigates to a typed/pasted folder.
|
||||
_modal.querySelector('#workspace-cur-path').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const v = e.target.value.trim();
|
||||
if (v) _navigate(v);
|
||||
}
|
||||
});
|
||||
_modal.querySelector('#workspace-use').addEventListener('click', () => {
|
||||
setWorkspace(_curPath);
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast(`Workspace set: ${_basename(_curPath)}`);
|
||||
closeWorkspaceBrowser();
|
||||
});
|
||||
const content = _modal.querySelector('.modal-content');
|
||||
const header = _modal.querySelector('.modal-header');
|
||||
if (content && header) makeWindowDraggable(_modal, { content, header });
|
||||
return _modal;
|
||||
}
|
||||
|
||||
export async function openWorkspaceBrowser() {
|
||||
const modal = _getModal();
|
||||
modal.style.display = 'flex';
|
||||
try {
|
||||
_render(await _load(getWorkspace() || ''));
|
||||
} catch (e) {
|
||||
if (uiModule && uiModule.showError) uiModule.showError('Could not browse folders');
|
||||
}
|
||||
}
|
||||
|
||||
export function closeWorkspaceBrowser() {
|
||||
if (_modal) _modal.style.display = 'none';
|
||||
}
|
||||
|
||||
export function initWorkspace() {
|
||||
// Restore persisted workspace into the pill on load.
|
||||
syncWorkspaceIndicator(getWorkspace());
|
||||
const overflow = document.getElementById('overflow-workspace-btn');
|
||||
if (overflow) overflow.addEventListener('click', openWorkspaceBrowser);
|
||||
const pill = document.getElementById('workspace-indicator-btn');
|
||||
if (pill) pill.addEventListener('click', clearWorkspace);
|
||||
}
|
||||
|
||||
export default { initWorkspace, openWorkspaceBrowser, getWorkspace, setWorkspace, clearWorkspace, syncWorkspaceIndicator };
|
||||
@@ -36363,49 +36363,6 @@ body.theme-frosted .modal {
|
||||
line-height: 1.4;
|
||||
color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
}
|
||||
/* ── Workspace picker ───────────────────────────────────────────── */
|
||||
/* Layout (width/flex column/max-height) inherited from base .modal-content. */
|
||||
/* Editable path/address bar: reuses .styled-prompt-input for border/bg/radius/
|
||||
focus ring (set in the element's class list). Overrides only the deltas:
|
||||
mono font, and full-bleed via flex stretch with no horizontal margin (the
|
||||
modal-content's 10px padding is the gutter) instead of the base width:100%,
|
||||
which overflowed against the overflow:auto scrollbar. */
|
||||
.workspace-cur {
|
||||
align-self: stretch;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin: 4px 0 8px;
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* flex/overflow inherited from base .modal-body; only the padding differs. */
|
||||
.workspace-body { padding: 6px 0; }
|
||||
.workspace-row {
|
||||
padding: 7px 18px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.workspace-row > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.workspace-row-icon { flex-shrink: 0; opacity: 0.75; }
|
||||
.workspace-row:hover {
|
||||
background: color-mix(in srgb, var(--border) 20%, transparent);
|
||||
}
|
||||
.workspace-up { opacity: 0.7; }
|
||||
.workspace-empty { padding: 14px 18px; opacity: 0.5; font-size: 13px; }
|
||||
.workspace-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
/* Cookbook serve panel: Launch + ^ split button pair */
|
||||
.hwfit-serve-launch-group {
|
||||
display: inline-flex;
|
||||
|
||||
Reference in New Issue
Block a user