Changeset 0.25.0 (#160)
This commit is contained in:
153
src/css/admin-surfaces.css
Normal file
153
src/css/admin-surfaces.css
Normal file
@@ -0,0 +1,153 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Admin Surfaces
|
||||
==========================================
|
||||
v0.25.0: Surface lifecycle management UI.
|
||||
========================================== */
|
||||
|
||||
.admin-surface-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--border, #2a2a2e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-surface-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
}
|
||||
|
||||
.admin-surface-row:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.admin-surface-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-surface-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-surface-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.admin-surface-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.admin-surface-badge--core {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.admin-surface-badge--extension {
|
||||
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
|
||||
color: var(--purple, #a078ff);
|
||||
}
|
||||
|
||||
.admin-surface-badge--required {
|
||||
background: var(--bg-raised, #2a2a2e);
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
.admin-surface-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
.admin-surface-id {
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-surface-route {
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
.admin-surface-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.admin-surface-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-surface-toggle--locked {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"] {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--bg-raised, #2a2a2e);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"]:checked {
|
||||
background: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"]:checked::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-surface-toggle-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #999);
|
||||
min-width: 56px;
|
||||
}
|
||||
412
src/css/chat-pane.css
Normal file
412
src/css/chat-pane.css
Normal file
@@ -0,0 +1,412 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — ChatPane Component CSS
|
||||
==========================================
|
||||
v0.25.0-cs11.1: Self-contained styles for the ChatPane
|
||||
component. Works in any context — sidebar chat, editor
|
||||
assist pane, standalone embed. No external CSS dependencies.
|
||||
========================================== */
|
||||
|
||||
/* ── Container ─────────────────────────────── */
|
||||
|
||||
.chat-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Header Bar (standalone mode) ──────────── */
|
||||
|
||||
.chat-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
flex-shrink: 0;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.chat-pane-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-pane-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-pane-chat-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 4px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pane-chat-select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-pane-new-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #999);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.chat-pane-new-btn:hover {
|
||||
color: var(--accent, #b38a4e);
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
}
|
||||
|
||||
/* Model selector in header */
|
||||
.chat-pane-model-select {
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pane-model-select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
/* ── Messages Area ─────────────────────────── */
|
||||
|
||||
.chat-pane-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
padding: 12px 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Message Bubbles ───────────────────────── */
|
||||
|
||||
.chat-msg {
|
||||
padding: 8px 16px;
|
||||
font-size: var(--msg-font, 14px);
|
||||
line-height: 1.65;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.chat-msg + .chat-msg {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* User messages — right-aligned bubble */
|
||||
.chat-msg--user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.chat-msg--user .chat-msg__content {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--text, #eee);
|
||||
padding: 8px 14px;
|
||||
border-radius: 12px 12px 2px 12px;
|
||||
max-width: 85%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Assistant messages — left-aligned */
|
||||
.chat-msg--assistant {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.chat-msg--assistant .chat-msg__content {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* ── Streaming indicator ───────────────────── */
|
||||
|
||||
.chat-msg--streaming .chat-msg__content::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 14px;
|
||||
background: var(--accent, #b38a4e);
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: cp-cursor-blink 0.8s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes cp-cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Typing dots ───────────────────────────── */
|
||||
|
||||
.chat-msg--typing {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* System/error messages */
|
||||
.chat-msg--system {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-msg--typing .typing-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.chat-msg--typing .typing-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3, #555);
|
||||
animation: cp-dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-msg--typing .typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.chat-msg--typing .typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes cp-dot-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Markdown content ──────────────────────── */
|
||||
|
||||
.chat-msg__content p { margin: 0 0 0.5em; }
|
||||
.chat-msg__content p:last-child { margin-bottom: 0; }
|
||||
|
||||
.chat-msg__content pre {
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.chat-msg__content code {
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.88em;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.chat-msg__content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.chat-msg__content ul, .chat-msg__content ol {
|
||||
margin: 4px 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.chat-msg__content blockquote {
|
||||
border-left: 3px solid var(--accent, #b38a4e);
|
||||
padding: 4px 12px;
|
||||
margin: 8px 0;
|
||||
color: var(--text-2, #999);
|
||||
}
|
||||
|
||||
.chat-msg__content a {
|
||||
color: var(--accent, #b38a4e);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-msg__content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-msg__content table {
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-msg__content th,
|
||||
.chat-msg__content td {
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.chat-msg__content th {
|
||||
background: var(--bg-secondary, #151517);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Welcome / Empty State ─────────────────── */
|
||||
|
||||
.chat-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chat-welcome__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.chat-welcome__hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #555);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Input Bar ─────────────────────────────── */
|
||||
|
||||
.chat-pane-input-bar {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.chat-pane-input-wrap {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-pane-input-wrap:focus-within {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.chat-pane-input {
|
||||
flex: 1;
|
||||
min-height: 20px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
color: var(--text, #eee);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* CM6 editor inside chat-pane-input */
|
||||
.chat-pane-input .cm-editor {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-pane-input .cm-editor .cm-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-pane-input .cm-editor .cm-line {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-pane-input .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Textarea fallback */
|
||||
.chat-pane-input textarea {
|
||||
width: 100%;
|
||||
min-height: 20px;
|
||||
max-height: 160px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Send Button ───────────────────────────── */
|
||||
|
||||
.chat-pane-send {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--accent, #b38a4e);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.chat-pane-send:hover {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--accent-hover, #c9a05e);
|
||||
}
|
||||
|
||||
.chat-pane-send:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────── */
|
||||
|
||||
.chat-pane-toolbar {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-pane-toolbar:empty {
|
||||
display: none;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
/* ==========================================
|
||||
Editor Surface (v0.21.5)
|
||||
IDE-like layout: file tree | code + chat
|
||||
========================================== */
|
||||
|
||||
/* ── Header ──────────────────────────────── */
|
||||
|
||||
.editor-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 12px; gap: 8px;
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 36px; box-sizing: border-box;
|
||||
}
|
||||
.editor-header-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.editor-ws-name { font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.editor-branch { font-size: 12px; color: var(--text-tertiary); white-space: nowrap; }
|
||||
.editor-header-right { display: flex; align-items: center; gap: 4px; }
|
||||
.editor-hdr-btn {
|
||||
background: none; border: none; color: var(--text-secondary);
|
||||
cursor: pointer; padding: 4px; border-radius: 4px;
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.editor-hdr-btn:hover { background: var(--hover); color: var(--text-primary); }
|
||||
.editor-hdr-label { font-size: 12px; margin-left: 3px; }
|
||||
.editor-hdr-sep { width: 1px; height: 16px; background: var(--border); margin: 0 2px; }
|
||||
|
||||
/* Chat toggle */
|
||||
.editor-chat-toggle.active { color: var(--accent); }
|
||||
|
||||
/* Export dropdown */
|
||||
.editor-export-wrap { position: relative; }
|
||||
.editor-export-menu {
|
||||
display: none; position: absolute; right: 0; top: 100%;
|
||||
background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 4px 0; z-index: 100;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2); min-width: 160px;
|
||||
}
|
||||
.editor-export-menu.open { display: block; }
|
||||
.editor-export-item {
|
||||
display: block; width: 100%; padding: 6px 12px; font-size: 13px;
|
||||
background: none; border: none; text-align: left; cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.editor-export-item:hover { background: var(--hover); }
|
||||
|
||||
/* ── File Tree (sidebar-content region) ──── */
|
||||
|
||||
.editor-file-tree {
|
||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||
font-size: 13px; user-select: none;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.editor-tree-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 8px; flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.editor-tree-title { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-tertiary); letter-spacing: 0.5px; }
|
||||
.editor-tree-add {
|
||||
background: none; border: none; color: var(--text-tertiary);
|
||||
cursor: pointer; padding: 2px; border-radius: 4px;
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.editor-tree-add:hover { background: var(--hover); color: var(--accent); }
|
||||
.editor-tree-content { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||
.editor-file-tree.drag-over { outline: 2px dashed var(--accent, #5865f2); outline-offset: -2px; background: rgba(88,101,242,0.05); }
|
||||
.editor-tree-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 8px; cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 4px; margin: 0 4px;
|
||||
}
|
||||
.editor-tree-row:hover { background: var(--hover); }
|
||||
.editor-tree-row.active { background: var(--active-bg, rgba(59,130,246,0.12)); color: var(--accent); }
|
||||
.editor-tree-arrow { width: 14px; text-align: center; font-size: 10px; color: var(--text-tertiary); flex-shrink: 0; }
|
||||
.editor-tree-arrow.expanded { /* already rotated via character */ }
|
||||
.editor-tree-icon { flex-shrink: 0; font-size: 14px; line-height: 1; }
|
||||
.editor-tree-name { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* Git status indicators (v0.21.6) */
|
||||
.editor-tree-row.git-modified .editor-tree-name { color: var(--warning, #e5a50a); }
|
||||
.editor-tree-row.git-added .editor-tree-name { color: var(--success, #22c55e); }
|
||||
.editor-tree-row.git-untracked .editor-tree-name { color: var(--text-tertiary); font-style: italic; }
|
||||
.editor-tree-row.git-deleted .editor-tree-name { color: var(--error, #ef4444); text-decoration: line-through; }
|
||||
.editor-tree-row.git-modified::after,
|
||||
.editor-tree-row.git-added::after,
|
||||
.editor-tree-row.git-untracked::after {
|
||||
font-size: 10px; margin-left: auto; padding-right: 4px; flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-tree-row.git-modified::after { content: 'M'; color: var(--warning, #e5a50a); }
|
||||
.editor-tree-row.git-added::after { content: 'A'; color: var(--success, #22c55e); }
|
||||
.editor-tree-row.git-untracked::after { content: 'U'; }
|
||||
|
||||
.editor-tree-loading, .editor-tree-empty, .editor-tree-error {
|
||||
padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Main Split Layout ───────────────────── */
|
||||
|
||||
.editor-main {
|
||||
display: flex; flex: 1; min-height: 0; overflow: hidden;
|
||||
}
|
||||
.editor-left-pane {
|
||||
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
|
||||
}
|
||||
.editor-right-pane {
|
||||
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Resize handle */
|
||||
.editor-split-handle {
|
||||
width: 5px; cursor: col-resize; flex-shrink: 0;
|
||||
background: transparent; position: relative; z-index: 5;
|
||||
}
|
||||
.editor-split-handle:hover,
|
||||
.editor-split-handle:active {
|
||||
background: var(--accent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ── Tab Bar ─────────────────────────────── */
|
||||
|
||||
.editor-tab-bar {
|
||||
display: flex; overflow-x: auto; overflow-y: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 32px; flex-shrink: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.editor-tab-bar::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.editor-tab {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; font-size: 12px;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0; max-width: 180px;
|
||||
position: relative;
|
||||
}
|
||||
.editor-tab:hover { background: var(--hover); }
|
||||
.editor-tab.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.editor-tab.modified .editor-tab-name { font-style: italic; }
|
||||
.editor-tab-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.editor-tab-name { overflow: hidden; text-overflow: ellipsis; }
|
||||
.editor-tab-modified { color: var(--accent); font-size: 10px; margin-left: 2px; }
|
||||
.editor-tab-close {
|
||||
background: none; border: none; color: var(--text-tertiary);
|
||||
cursor: pointer; font-size: 11px; padding: 0 2px;
|
||||
border-radius: 3px; opacity: 0; transition: opacity 0.1s;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.editor-tab:hover .editor-tab-close { opacity: 1; }
|
||||
.editor-tab-close:hover { background: var(--hover); color: var(--text-primary); }
|
||||
|
||||
/* ── Code Editor Area ────────────────────── */
|
||||
|
||||
.editor-code-area {
|
||||
flex: 1; min-height: 0; position: relative; overflow: hidden;
|
||||
}
|
||||
.editor-cm-wrap {
|
||||
position: absolute; inset: 0; overflow: hidden;
|
||||
}
|
||||
.editor-cm-wrap .cm-editor { height: 100%; }
|
||||
.editor-cm-wrap .cm-scroller { overflow: auto; }
|
||||
|
||||
/* Textarea fallback */
|
||||
.editor-textarea-fallback {
|
||||
width: 100%; height: 100%; resize: none; border: none; outline: none;
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
padding: 8px 12px; box-sizing: border-box;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
/* Welcome screen */
|
||||
.editor-welcome {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.editor-welcome-inner { text-align: center; color: var(--text-tertiary); }
|
||||
.editor-welcome-inner p { margin: 8px 0; font-size: 14px; }
|
||||
.editor-welcome-hint { font-size: 12px; opacity: 0.6; }
|
||||
.editor-welcome-hint kbd {
|
||||
background: var(--hover); border: 1px solid var(--border);
|
||||
border-radius: 3px; padding: 1px 5px; font-size: 11px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ── Chat Panel (right pane) ─────────────── */
|
||||
|
||||
.editor-chat-header {
|
||||
padding: 6px 12px; font-size: 12px; font-weight: 600;
|
||||
color: var(--text-secondary); border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.editor-chat-messages {
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
/* Inherit chat message styles when embedded */
|
||||
.editor-chat-messages .messages { height: auto; overflow: visible; }
|
||||
.editor-chat-input { flex-shrink: 0; max-height: 140px; overflow: hidden; }
|
||||
/* Inherit input-area styles — constrain for narrow pane */
|
||||
.editor-chat-input .input-area { border-top: 1px solid var(--border); padding: 0 8px 8px; }
|
||||
.editor-chat-input .input-wrap { max-width: none; }
|
||||
.editor-chat-input .input-wrap textarea,
|
||||
.editor-chat-input #messageInputWrap .cm-editor { max-height: 80px; padding: 8px; font-size: 13px; }
|
||||
.editor-chat-input .input-meta { min-height: 0; padding: 2px 4px 0; }
|
||||
.editor-chat-input .input-toolbar { gap: 2px; }
|
||||
.editor-chat-input .input-toolbar button { padding: 4px; }
|
||||
|
||||
/* ── Status Bar (footer) ─────────────────── */
|
||||
|
||||
.editor-status-bar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 2px 12px; font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 22px; flex-shrink: 0;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.editor-status-sep { opacity: 0.3; }
|
||||
.editor-status-right { margin-left: auto; display: flex; gap: 8px; }
|
||||
.editor-status-branch { font-size: 11px; }
|
||||
.editor-status-file { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.editor-status-save { font-size: 11px; }
|
||||
.editor-status-save.unsaved { color: var(--warning, #e5a50a); }
|
||||
.editor-status-words { font-size: 11px; }
|
||||
|
||||
/* ── Context Menu ────────────────────────── */
|
||||
|
||||
.editor-ctx-menu {
|
||||
position: fixed; z-index: 1000;
|
||||
background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 4px 0;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
min-width: 120px; font-size: 13px;
|
||||
}
|
||||
.editor-ctx-item {
|
||||
padding: 6px 12px; cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.editor-ctx-item:hover { background: var(--hover); }
|
||||
|
||||
/* ── Quick Open Overlay ──────────────────── */
|
||||
|
||||
.editor-quick-open {
|
||||
position: fixed; inset: 0; z-index: 2000;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
.editor-qo-dialog {
|
||||
background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 8px; width: 460px; max-width: 90vw;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-qo-input {
|
||||
width: 100%; padding: 10px 14px; font-size: 14px;
|
||||
border: none; outline: none; box-sizing: border-box;
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.editor-qo-results { max-height: 300px; overflow-y: auto; }
|
||||
.editor-qo-row {
|
||||
padding: 6px 14px; cursor: pointer; font-size: 13px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.editor-qo-row:hover { background: var(--hover); }
|
||||
.editor-qo-icon { font-size: 14px; flex-shrink: 0; }
|
||||
|
||||
/* ── Mobile: single pane ─────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-main { flex-direction: column; }
|
||||
.editor-left-pane { flex: 1 !important; min-width: 0; }
|
||||
.editor-right-pane { min-width: 0; border-left: none; border-top: 1px solid var(--border); max-height: 40vh; }
|
||||
.editor-split-handle { display: none; }
|
||||
}
|
||||
535
src/css/editor-surface.css
Normal file
535
src/css/editor-surface.css
Normal file
@@ -0,0 +1,535 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Editor Surface (v0.25.0)
|
||||
==========================================
|
||||
Replaces editor-mode.css for the pane-based editor.
|
||||
Covers: topbar, bootstrap, and component-specific
|
||||
overrides within the editor context.
|
||||
========================================== */
|
||||
|
||||
/* ── Surface Shell ─────────────────────────── */
|
||||
|
||||
.surface-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Topbar ────────────────────────────────── */
|
||||
|
||||
.editor-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.editor-topbar-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-3, #777);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.editor-topbar-back:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.editor-topbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.editor-topbar-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
/* ── Workspace Selector ────────────────────── */
|
||||
|
||||
.editor-ws-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-ws-selector-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.editor-ws-selector-btn:hover {
|
||||
border-color: var(--border, #2a2a2e);
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.editor-ws-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown.open { display: block; }
|
||||
|
||||
.editor-ws-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item.active {
|
||||
color: var(--accent, #b38a4e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border, #2a2a2e);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.editor-ws-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.editor-topbar-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-topbar-branch-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--purple, #a078ff);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
/* ── Body ──────────────────────────────────── */
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Bootstrap (no workspace) ──────────────── */
|
||||
|
||||
.editor-bootstrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor-bootstrap-card {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 12px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.editor-bootstrap-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
margin-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor-bootstrap-input:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.editor-bootstrap-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: var(--accent, #b38a4e);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.editor-bootstrap-btn:hover { opacity: 0.9; }
|
||||
.editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Workspace list in bootstrap */
|
||||
.editor-bootstrap-ws-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
margin-bottom: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-item:hover {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #777);
|
||||
}
|
||||
|
||||
/* User menu in editor topbar — uses user-menu.css base styles.
|
||||
No overrides needed: user-menu.css defaults to drop-down (topbar) mode.
|
||||
Sidebar context flipped via .sidebar parent selector in user-menu.css. */
|
||||
|
||||
/* ── FileTree overrides (in editor context) ── */
|
||||
|
||||
.surface-editor .file-tree {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2, #999);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #999);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row.active {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-arrow {
|
||||
width: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-icon {
|
||||
font-size: 13px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Git status indicators */
|
||||
.surface-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning, #e5a842); }
|
||||
.surface-editor .file-tree-row.git-added .file-tree-name { color: var(--success, #4caf50); }
|
||||
.surface-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3, #555); font-style: italic; }
|
||||
.surface-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger, #f44336); text-decoration: line-through; }
|
||||
|
||||
/* Context menu */
|
||||
.file-tree-ctx-menu {
|
||||
position: fixed;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.file-tree-ctx-item {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text, #eee);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-tree-ctx-item:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
/* ── CodeEditor overrides ──────────────────── */
|
||||
|
||||
.surface-editor .code-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
height: 32px;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.surface-editor .code-editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #777);
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tab:hover { background: var(--bg-tertiary, #1e1e22); }
|
||||
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
|
||||
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }
|
||||
|
||||
.surface-editor .code-editor-tab-icon { font-size: 12px; }
|
||||
.surface-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
|
||||
|
||||
.surface-editor .code-editor-tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
margin-left: 4px;
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tab-close:hover {
|
||||
background: var(--danger-dim, rgba(244, 67, 54, 0.15));
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-welcome {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-cm-wrap {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-cm-wrap .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 12px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #777);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-textarea-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg, #0e0e10);
|
||||
color: var(--text, #eee);
|
||||
border: none;
|
||||
padding: 12px;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Tabbed assist pane overrides ──────────── */
|
||||
|
||||
.surface-editor .pane-tabbed {
|
||||
border-left: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
/* ChatPane in editor tabbed pane */
|
||||
.surface-editor .chat-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Notes in editor pane */
|
||||
.surface-editor .note-editor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.surface-editor .note-editor-list-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.surface-editor .notes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Compact notes toolbar for narrow pane */
|
||||
.surface-editor .notes-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .notes-toolbar .btn-small {
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-search-row {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-filter-row {
|
||||
padding: 2px 8px 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-filter-select {
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
167
src/css/pane-container.css
Normal file
167
src/css/pane-container.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Pane Container
|
||||
==========================================
|
||||
v0.25.0: Styles for the multi-pane layout system.
|
||||
Covers: pane container, leaf panes, tabbed panes,
|
||||
drag handles, and tab bars.
|
||||
========================================== */
|
||||
|
||||
/* ── Container ─────────────────────────────── */
|
||||
|
||||
.pane-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Split ─────────────────────────────────── */
|
||||
|
||||
.pane-split--horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane-split--vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Leaf Pane ─────────────────────────────── */
|
||||
|
||||
.pane-leaf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pane-minimized {
|
||||
max-width: 0 !important;
|
||||
max-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ── Tabbed Pane ───────────────────────────── */
|
||||
|
||||
.pane-tabbed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.pane-tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.pane-tab-bar::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.pane-tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #777);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.pane-tab-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.pane-tab-btn--active {
|
||||
color: var(--accent, #b38a4e);
|
||||
border-bottom-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* Tab Content */
|
||||
.pane-tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pane-tab-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* When panel is hidden (inactive tab), override absolute positioning */
|
||||
.pane-tab-panel[style*="display: none"] {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* ── Drag Handle ───────────────────────────── */
|
||||
|
||||
.pane-handle {
|
||||
flex-shrink: 0;
|
||||
background: var(--border, #2a2a2e);
|
||||
transition: background 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pane-handle--horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.pane-handle--vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.pane-handle:hover,
|
||||
.pane-handle--active {
|
||||
background: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* On mobile, splits collapse to vertical stack */
|
||||
.pane-split--horizontal {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pane-handle--horizontal {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
/* Hide non-primary panes on mobile */
|
||||
.pane-split--horizontal > .pane:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
.pane-split--horizontal > .pane-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
41
src/css/tool-grants.css
Normal file
41
src/css/tool-grants.css
Normal file
@@ -0,0 +1,41 @@
|
||||
/* v0.25.0: Tool grants section in persona form */
|
||||
.tool-grants-list {
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.tool-grants-category {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-3, #777);
|
||||
padding: 6px 0 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tool-grants-category:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.tool-grants-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text, #eee);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tool-grants-item:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.tool-grants-item input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
162
src/css/user-menu.css
Normal file
162
src/css/user-menu.css
Normal file
@@ -0,0 +1,162 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — UserMenu Component CSS
|
||||
==========================================
|
||||
v0.25.0-cs11.1: Self-contained styles for the UserMenu
|
||||
component. Works in two contexts:
|
||||
1. Sidebar (flyout pops UP) — default in .sidebar
|
||||
2. Topbar (flyout drops DOWN) — default standalone
|
||||
========================================== */
|
||||
|
||||
/* ── Wrapper ───────────────────────────────── */
|
||||
|
||||
.user-menu-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Trigger Button ────────────────────────── */
|
||||
|
||||
.user-menu-wrap .user-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius, 6px);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.user-menu-wrap .user-btn:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
/* ── Avatar ────────────────────────────────── */
|
||||
|
||||
.user-menu-wrap .user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.2));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #b38a4e);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-menu-wrap .user-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.user-menu-wrap .sb-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Flyout (default: drops DOWN for topbar) ─ */
|
||||
|
||||
.user-menu-wrap .user-flyout {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-raised, #1a1a1e);
|
||||
border: 1px solid var(--border-light, #333);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
padding: 4px;
|
||||
min-width: 170px;
|
||||
z-index: 200;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.user-menu-wrap .user-flyout.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Flyout Items ──────────────────────────── */
|
||||
|
||||
.user-menu-wrap .flyout-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius, 6px);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-2, #999);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-item:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-item svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-danger {
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-danger:hover {
|
||||
background: var(--danger-dim, rgba(244, 67, 54, 0.1));
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-divider {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--border, #2a2a2e);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar Context (flyout pops UP) ──────── */
|
||||
/* When user-menu is inside .sidebar, flip the flyout direction */
|
||||
|
||||
.sidebar .user-menu-wrap .user-flyout {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Sidebar collapsed: hide label, center avatar */
|
||||
.sidebar.collapsed .user-menu-wrap .user-btn {
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .user-menu-wrap .sb-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .user-menu-wrap .user-flyout {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
@@ -264,6 +264,9 @@
|
||||
'</div>' +
|
||||
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||
|
||||
SCAFFOLDING.surfaces =
|
||||
'<div id="adminSurfacesContent"></div>';
|
||||
|
||||
|
||||
// === Add button actions ==============================================
|
||||
|
||||
@@ -312,21 +315,23 @@
|
||||
if (!section) return;
|
||||
|
||||
// -- Category / nav resolution -----------
|
||||
var catMap = {
|
||||
users:'people', teams:'people', groups:'people',
|
||||
providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai',
|
||||
health:'routing', routing:'routing', capabilities:'routing',
|
||||
settings:'system', storage:'system', extensions:'system', channels:'system',
|
||||
usage:'monitoring', audit:'monitoring', stats:'monitoring',
|
||||
};
|
||||
var cat = catMap[section] || 'people';
|
||||
var secMap = {
|
||||
people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}],
|
||||
ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}],
|
||||
routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}],
|
||||
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'},{id:'channels',l:'Channels'}],
|
||||
monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}],
|
||||
};
|
||||
// Derive from ADMIN_SECTIONS + ADMIN_LABELS (ui-admin.js) — single source of truth.
|
||||
// admin-scaffold.js previously had its own duplicate catMap/secMap.
|
||||
var cat = 'people';
|
||||
if (typeof ADMIN_SECTIONS !== 'undefined') {
|
||||
for (var c in ADMIN_SECTIONS) {
|
||||
if (ADMIN_SECTIONS[c].indexOf(section) !== -1) { cat = c; break; }
|
||||
}
|
||||
}
|
||||
var secMap;
|
||||
if (typeof ADMIN_SECTIONS !== 'undefined' && typeof ADMIN_LABELS !== 'undefined') {
|
||||
secMap = {};
|
||||
for (var c in ADMIN_SECTIONS) {
|
||||
secMap[c] = ADMIN_SECTIONS[c].map(function(id) {
|
||||
return { id: id, l: ADMIN_LABELS[id] || id };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild left nav for current category
|
||||
var navEl = document.getElementById('adminNav');
|
||||
|
||||
198
src/js/admin-surfaces.js
Normal file
198
src/js/admin-surfaces.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Admin Surfaces UI
|
||||
// ==========================================
|
||||
// v0.25.0: Surface lifecycle management.
|
||||
// Renders in the "Surfaces" admin section via ADMIN_LOADERS.
|
||||
|
||||
async function _loadAdminSurfaces() {
|
||||
const container = document.getElementById('adminSurfacesContent');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0;line-height:1.6;flex:1;">
|
||||
Manage which surfaces are available. Disabled surfaces redirect to Chat and are hidden from navigation.
|
||||
</p>
|
||||
<button id="adminSurfaceInstallBtn" class="btn-small" style="margin-left:16px;white-space:nowrap;">
|
||||
+ Install Surface
|
||||
</button>
|
||||
</div>
|
||||
<div id="adminSurfaceInstallForm" style="display:none;margin-bottom:16px;padding:16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Install Surface Archive</div>
|
||||
<p style="font-size:12px;color:var(--text-3);margin:0 0 12px;">Upload a <code>.surface</code> or <code>.zip</code> archive containing manifest.json, js/, css/, and assets/.</p>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="file" id="adminSurfaceFile" accept=".surface,.zip" style="font-size:12px;color:var(--text-2);">
|
||||
<button id="adminSurfaceUploadBtn" class="btn-small btn-primary" disabled>Upload</button>
|
||||
<button id="adminSurfaceCancelBtn" class="btn-small">Cancel</button>
|
||||
</div>
|
||||
<div id="adminSurfaceInstallStatus" style="font-size:12px;margin-top:8px;"></div>
|
||||
</div>
|
||||
<div id="adminSurfaceList" class="admin-surface-list">
|
||||
<div style="padding:12px;font-size:12px;color:var(--text-3);">Loading…</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire install form
|
||||
var installBtn = document.getElementById('adminSurfaceInstallBtn');
|
||||
var installForm = document.getElementById('adminSurfaceInstallForm');
|
||||
var fileInput = document.getElementById('adminSurfaceFile');
|
||||
var uploadBtn = document.getElementById('adminSurfaceUploadBtn');
|
||||
var cancelBtn = document.getElementById('adminSurfaceCancelBtn');
|
||||
var statusEl = document.getElementById('adminSurfaceInstallStatus');
|
||||
|
||||
if (installBtn) installBtn.addEventListener('click', function() {
|
||||
installForm.style.display = '';
|
||||
installBtn.style.display = 'none';
|
||||
});
|
||||
if (cancelBtn) cancelBtn.addEventListener('click', function() {
|
||||
installForm.style.display = 'none';
|
||||
installBtn.style.display = '';
|
||||
fileInput.value = '';
|
||||
uploadBtn.disabled = true;
|
||||
statusEl.textContent = '';
|
||||
});
|
||||
if (fileInput) fileInput.addEventListener('change', function() {
|
||||
uploadBtn.disabled = !fileInput.files.length;
|
||||
});
|
||||
if (uploadBtn) uploadBtn.addEventListener('click', async function() {
|
||||
var file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = 'Uploading…';
|
||||
statusEl.textContent = '';
|
||||
statusEl.style.color = 'var(--text-3)';
|
||||
|
||||
try {
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
var base = window.__BASE__ || '';
|
||||
var resp = await fetch(base + '/api/v1/admin/surfaces/install', {
|
||||
method: 'POST',
|
||||
headers: API._authHeaders ? (function() { var h = API._authHeaders(); delete h['Content-Type']; return h; })() : {},
|
||||
body: formData,
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Upload failed');
|
||||
|
||||
statusEl.style.color = 'var(--success)';
|
||||
statusEl.textContent = 'Installed: ' + (data.title || data.id);
|
||||
if (typeof UI !== 'undefined') UI.toast('Surface installed: ' + (data.title || data.id), 'success');
|
||||
|
||||
// Refresh the list
|
||||
installForm.style.display = 'none';
|
||||
installBtn.style.display = '';
|
||||
fileInput.value = '';
|
||||
_loadSurfaceList();
|
||||
} catch (e) {
|
||||
statusEl.style.color = 'var(--danger)';
|
||||
statusEl.textContent = 'Error: ' + e.message;
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = 'Upload';
|
||||
}
|
||||
});
|
||||
|
||||
_loadSurfaceList();
|
||||
}
|
||||
|
||||
async function _loadSurfaceList() {
|
||||
var listEl = document.getElementById('adminSurfaceList');
|
||||
if (!listEl) return;
|
||||
|
||||
try {
|
||||
var resp = await API._get('/api/v1/admin/surfaces');
|
||||
var surfaces = resp.surfaces || [];
|
||||
|
||||
if (surfaces.length === 0) {
|
||||
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No surfaces registered</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = '';
|
||||
surfaces.forEach(function(s) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'admin-surface-row';
|
||||
row.dataset.surfaceId = s.id;
|
||||
|
||||
var isProtected = s.id === 'chat' || s.id === 'admin';
|
||||
var sourceLabel = s.source === 'core' ? 'Core' : 'Extension';
|
||||
var route = s.manifest && s.manifest.route ? s.manifest.route : '';
|
||||
|
||||
row.innerHTML =
|
||||
'<div class="admin-surface-info">' +
|
||||
'<div class="admin-surface-title">' +
|
||||
'<span class="admin-surface-name">' + _surfEsc(s.title) + '</span>' +
|
||||
'<span class="admin-surface-badge admin-surface-badge--' + s.source + '">' + sourceLabel + '</span>' +
|
||||
(isProtected ? '<span class="admin-surface-badge admin-surface-badge--required">Required</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="admin-surface-meta">' +
|
||||
'<span class="admin-surface-id">' + _surfEsc(s.id) + '</span>' +
|
||||
(route ? '<span class="admin-surface-route">' + _surfEsc(route) + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="admin-surface-actions">' +
|
||||
'<label class="admin-surface-toggle' + (isProtected ? ' admin-surface-toggle--locked' : '') + '">' +
|
||||
'<input type="checkbox"' + (s.enabled ? ' checked' : '') + (isProtected ? ' disabled' : '') +
|
||||
' data-surface-id="' + _surfEsc(s.id) + '">' +
|
||||
'<span class="admin-surface-toggle-label">' + (s.enabled ? 'Enabled' : 'Disabled') + '</span>' +
|
||||
'</label>' +
|
||||
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + _surfEsc(s.id) + '">Uninstall</button>' : '') +
|
||||
'</div>';
|
||||
|
||||
// Toggle handler
|
||||
var checkbox = row.querySelector('input[type="checkbox"]');
|
||||
if (checkbox && !isProtected) {
|
||||
checkbox.addEventListener('change', (function(surface) {
|
||||
return async function() {
|
||||
var id = this.dataset.surfaceId;
|
||||
var action = this.checked ? 'enable' : 'disable';
|
||||
var label = row.querySelector('.admin-surface-toggle-label');
|
||||
var cb = this;
|
||||
|
||||
try {
|
||||
await API._put('/api/v1/admin/surfaces/' + id + '/' + action);
|
||||
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.toast(surface.title + ' ' + (cb.checked ? 'enabled' : 'disabled') + ' — takes effect on next page load', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
cb.checked = !cb.checked;
|
||||
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
})(s));
|
||||
}
|
||||
|
||||
// Uninstall handler
|
||||
var uninstallBtn = row.querySelector('.admin-surface-uninstall');
|
||||
if (uninstallBtn) {
|
||||
uninstallBtn.addEventListener('click', (function(surface) {
|
||||
return async function() {
|
||||
var ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Uninstall ' + surface.title + '? This removes all files.')
|
||||
: window.confirm('Uninstall ' + surface.title + '?');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await API._del('/api/v1/admin/surfaces/' + surface.id);
|
||||
row.remove();
|
||||
if (typeof UI !== 'undefined') UI.toast(surface.title + ' uninstalled', 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
})(s));
|
||||
}
|
||||
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + _surfEsc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _surfEsc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
@@ -68,8 +68,52 @@ async function init() {
|
||||
async function startApp() {
|
||||
hideSplash();
|
||||
UI.restoreSidebar();
|
||||
await loadSettings();
|
||||
|
||||
// v0.25.0: Surface-aware initialization.
|
||||
// Common init runs on all surfaces. Chat-specific init only on chat.
|
||||
const surface = window.__SURFACE__ || 'chat';
|
||||
|
||||
// ── Common init (all surfaces) ──────────
|
||||
if (typeof loadSettings === 'function') await loadSettings();
|
||||
|
||||
// Guard: if token was invalidated during startup
|
||||
if (!API.isAuthed) {
|
||||
console.warn('⚠️ Auth lost during startup, returning to login');
|
||||
showSplash(null);
|
||||
return;
|
||||
}
|
||||
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(API.isAdmin);
|
||||
UI.restoreAppearance();
|
||||
|
||||
// Load models for all surfaces (editor needs them for model selector)
|
||||
await fetchModels();
|
||||
|
||||
// Connect EventBus WebSocket (non-blocking)
|
||||
try {
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
Events.on('ws.connected', () => {
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('EventBus connect error:', e.message);
|
||||
}
|
||||
|
||||
// Signal that common init is complete — surface-specific JS can react
|
||||
document.dispatchEvent(new CustomEvent('sb:ready', { detail: { surface } }));
|
||||
|
||||
// ── Chat surface init ───────────────────
|
||||
if (surface === 'chat') {
|
||||
await _startChatSurface();
|
||||
}
|
||||
|
||||
// Editor, notes, admin, settings — their surface-specific JS handles
|
||||
// the rest via DOMContentLoaded listeners in their own boot scripts.
|
||||
}
|
||||
|
||||
// Chat-surface-specific initialization (extracted from monolithic startApp)
|
||||
async function _startChatSurface() {
|
||||
// v0.23.1: Load folders and channels before rendering sidebar
|
||||
await loadFolders();
|
||||
await loadChannels();
|
||||
@@ -86,8 +130,7 @@ async function startApp() {
|
||||
await loadChats();
|
||||
await loadProjects();
|
||||
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
|
||||
await fetchModels();
|
||||
// app-state.js fetchModels populates App.models; UI updates are surface-specific
|
||||
// Models already loaded in common init; update chat-surface-specific UI
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.updateModelSelector();
|
||||
UI.updateCapabilityBadges();
|
||||
@@ -96,14 +139,6 @@ async function startApp() {
|
||||
// v0.23.2: Load user list for @mention autocomplete
|
||||
await loadUsers();
|
||||
|
||||
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
||||
// kick back to login instead of rendering a broken UI.
|
||||
if (!API.isAuthed) {
|
||||
console.warn('⚠️ Auth lost during startup, returning to login');
|
||||
showSplash(null);
|
||||
return;
|
||||
}
|
||||
|
||||
await initBanners();
|
||||
initFiles();
|
||||
|
||||
@@ -150,29 +185,14 @@ async function startApp() {
|
||||
UI.showEmptyState();
|
||||
}
|
||||
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(API.isAdmin);
|
||||
initListeners();
|
||||
|
||||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||||
try {
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
|
||||
// Once WS connects, re-fetch tools so browser extension tools appear
|
||||
// (ToolsToggle.init runs before WS is up → server omits browser tools)
|
||||
Events.on('ws.connected', () => {
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
|
||||
});
|
||||
|
||||
// Admin-only: persistent fallback alert banner (v0.17.0)
|
||||
Events.on('role.fallback', (payload) => {
|
||||
if (App.user?.role !== 'admin') return;
|
||||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||
showFallbackBanner(data);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('EventBus WebSocket not available:', e.message);
|
||||
}
|
||||
// Admin-only: persistent fallback alert banner (v0.17.0)
|
||||
Events.on('role.fallback', (payload) => {
|
||||
if (App.user?.role !== 'admin') return;
|
||||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||
showFallbackBanner(data);
|
||||
});
|
||||
|
||||
console.log('✅ Chat Switchboard ready');
|
||||
}
|
||||
|
||||
341
src/js/code-editor.js
Normal file
341
src/js/code-editor.js
Normal file
@@ -0,0 +1,341 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — CodeEditor Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from editor-mode.js (lines 536-940).
|
||||
// Tabbed code editor with CM6 integration, auto-save, language detection,
|
||||
// modified indicators, and status bar.
|
||||
//
|
||||
// Usage:
|
||||
// const editor = CodeEditor.create({
|
||||
// id: 'editor',
|
||||
// workspaceId: 'ws-123',
|
||||
// onSave: (path, content) => fileTree.refresh(),
|
||||
// onActivate: (path) => fileTree.setActiveFile(path),
|
||||
// });
|
||||
// await editor.openFile('src/main.go');
|
||||
// editor.getActiveFile(); // → 'src/main.go'
|
||||
// editor.isModified('src/main.go'); // → false
|
||||
// await editor.saveAll();
|
||||
// editor.destroy();
|
||||
|
||||
const CodeEditor = {
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'editor';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
tabsEl: document.getElementById(pfx + 'EditorTabs'),
|
||||
contentEl: document.getElementById(pfx + 'EditorContent'),
|
||||
welcomeEl: document.getElementById(pfx + 'EditorWelcome'),
|
||||
statusEl: document.getElementById(pfx + 'EditorStatus'),
|
||||
statusFileEl: document.getElementById(pfx + 'EditorStatusFile'),
|
||||
statusLangEl: document.getElementById(pfx + 'EditorStatusLang'),
|
||||
statusBranchEl: document.getElementById(pfx + 'EditorStatusBranch'),
|
||||
workspaceId: opts.workspaceId || null,
|
||||
onSave: opts.onSave || null,
|
||||
onActivate: opts.onActivate || null,
|
||||
_openFiles: new Map(), // path → { tab, editorWrap, editor, content, modified, language }
|
||||
_activeFile: null,
|
||||
_listeners: [],
|
||||
|
||||
// ── Public API ──────────────────────
|
||||
|
||||
setWorkspace(wsId) { this.workspaceId = wsId; },
|
||||
|
||||
async openFile(path, content, language) {
|
||||
if (this._openFiles.has(path)) {
|
||||
this.activateFile(path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch if content not provided
|
||||
if (content === undefined) {
|
||||
try {
|
||||
content = await API.readWorkspaceFile(this.workspaceId, path);
|
||||
} catch (e) {
|
||||
console.error('[CodeEditor] Failed to read:', e);
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to open ' + path, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lang = language || _ceDetectLanguage(path);
|
||||
|
||||
// Create tab
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'code-editor-tab';
|
||||
tab.dataset.path = path;
|
||||
const fileName = path.split('/').pop();
|
||||
tab.innerHTML =
|
||||
'<span class="code-editor-tab-icon">' + _ceFileIcon(fileName) + '</span>' +
|
||||
'<span class="code-editor-tab-name">' + _ceEsc(fileName) + '</span>' +
|
||||
'<span class="code-editor-tab-modified" style="display:none">●</span>' +
|
||||
'<button class="code-editor-tab-close" title="Close">✕</button>';
|
||||
|
||||
const self = this;
|
||||
tab.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.code-editor-tab-close')) self.activateFile(path);
|
||||
});
|
||||
tab.querySelector('.code-editor-tab-close').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
self.closeFile(path);
|
||||
});
|
||||
if (this.tabsEl) this.tabsEl.appendChild(tab);
|
||||
|
||||
// Create editor wrapper
|
||||
const editorWrap = document.createElement('div');
|
||||
editorWrap.className = 'code-editor-cm-wrap';
|
||||
editorWrap.style.display = 'none';
|
||||
if (this.contentEl) this.contentEl.appendChild(editorWrap);
|
||||
|
||||
// Create CM6 editor or textarea fallback
|
||||
let editor = null;
|
||||
if (window.CM?.codeEditor) {
|
||||
editor = CM.codeEditor(editorWrap, {
|
||||
language: lang,
|
||||
value: content,
|
||||
lineNumbers: true,
|
||||
});
|
||||
if (editor.onUpdate) {
|
||||
editor.onUpdate(() => self._markModified(path));
|
||||
}
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.className = 'code-editor-textarea-fallback';
|
||||
ta.value = content;
|
||||
ta.addEventListener('input', () => self._markModified(path));
|
||||
editorWrap.appendChild(ta);
|
||||
editor = {
|
||||
getValue: () => ta.value,
|
||||
setValue: (v) => { ta.value = v; },
|
||||
focus: () => ta.focus(),
|
||||
destroy: () => {},
|
||||
_textarea: true,
|
||||
};
|
||||
}
|
||||
|
||||
this._openFiles.set(path, {
|
||||
tab, editorWrap, editor, content, modified: false, language: lang,
|
||||
});
|
||||
|
||||
this.activateFile(path);
|
||||
},
|
||||
|
||||
activateFile(path) {
|
||||
if (this._activeFile === path) return;
|
||||
|
||||
// Auto-save previous if modified
|
||||
if (this._activeFile && this._openFiles.has(this._activeFile)) {
|
||||
const prev = this._openFiles.get(this._activeFile);
|
||||
if (prev.modified) this.saveFile(this._activeFile);
|
||||
prev.tab.classList.remove('active');
|
||||
prev.editorWrap.style.display = 'none';
|
||||
}
|
||||
|
||||
this._activeFile = path;
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f) return;
|
||||
|
||||
f.tab.classList.add('active');
|
||||
f.editorWrap.style.display = '';
|
||||
if (this.welcomeEl) this.welcomeEl.style.display = 'none';
|
||||
|
||||
this._updateStatusBar();
|
||||
if (this.onActivate) this.onActivate(path);
|
||||
if (f.editor?.focus) setTimeout(() => f.editor.focus(), 10);
|
||||
},
|
||||
|
||||
async closeFile(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f) return;
|
||||
|
||||
if (f.modified) {
|
||||
const save = await this._confirmClose(path);
|
||||
if (save === null) return; // cancelled
|
||||
if (save) await this.saveFile(path);
|
||||
}
|
||||
|
||||
f.tab.remove();
|
||||
f.editorWrap.remove();
|
||||
if (f.editor?.destroy) f.editor.destroy();
|
||||
this._openFiles.delete(path);
|
||||
|
||||
if (this._activeFile === path) {
|
||||
this._activeFile = null;
|
||||
const remaining = Array.from(this._openFiles.keys());
|
||||
if (remaining.length) {
|
||||
this.activateFile(remaining[remaining.length - 1]);
|
||||
} else {
|
||||
if (this.welcomeEl) this.welcomeEl.style.display = '';
|
||||
this._updateStatusBar();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async saveFile(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f) return;
|
||||
|
||||
const content = f.editor?.getValue?.() ?? '';
|
||||
try {
|
||||
await API.writeWorkspaceFile(this.workspaceId, path, content);
|
||||
f.content = content;
|
||||
f.modified = false;
|
||||
f.tab.querySelector('.code-editor-tab-modified').style.display = 'none';
|
||||
f.tab.classList.remove('modified');
|
||||
if (path === this._activeFile) this._updateStatusBar();
|
||||
if (typeof UI !== 'undefined') UI.toast('Saved ' + path.split('/').pop(), 'success');
|
||||
if (this.onSave) this.onSave(path, content);
|
||||
} catch (e) {
|
||||
console.error('[CodeEditor] Save failed:', e);
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to save: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async saveAll() {
|
||||
for (const [path, f] of this._openFiles) {
|
||||
if (f.modified) await this.saveFile(path);
|
||||
}
|
||||
},
|
||||
|
||||
getActiveFile() { return this._activeFile; },
|
||||
|
||||
isModified(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
return f ? f.modified : false;
|
||||
},
|
||||
|
||||
hasUnsaved() {
|
||||
for (const [, f] of this._openFiles) {
|
||||
if (f.modified) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
getOpenFiles() { return Array.from(this._openFiles.keys()); },
|
||||
|
||||
// ── Git Branch Display ──────────────
|
||||
|
||||
setBranch(branch) {
|
||||
if (this.statusBranchEl) {
|
||||
this.statusBranchEl.textContent = branch ? '⎇ ' + branch : '';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Keyboard Shortcuts ──────────────
|
||||
|
||||
bind() {
|
||||
this._on(document, 'keydown', (e) => {
|
||||
// Ctrl/Cmd+S — save active file
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (this._activeFile) this.saveFile(this._activeFile);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
// Destroy all CM6 instances
|
||||
for (const [, f] of this._openFiles) {
|
||||
if (f.editor?.destroy) f.editor.destroy();
|
||||
f.tab.remove();
|
||||
f.editorWrap.remove();
|
||||
}
|
||||
this._openFiles.clear();
|
||||
this._activeFile = null;
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
CodeEditor._instances.delete(this.id);
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────
|
||||
|
||||
_markModified(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f || f.modified) return;
|
||||
const currentVal = f.editor?.getValue?.() ?? '';
|
||||
if (currentVal === f.content) return;
|
||||
f.modified = true;
|
||||
f.tab.querySelector('.code-editor-tab-modified').style.display = '';
|
||||
f.tab.classList.add('modified');
|
||||
if (path === this._activeFile) this._updateStatusBar();
|
||||
},
|
||||
|
||||
_updateStatusBar() {
|
||||
if (!this.statusFileEl) return;
|
||||
if (!this._activeFile) {
|
||||
this.statusFileEl.textContent = 'No file open';
|
||||
if (this.statusLangEl) this.statusLangEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
const f = this._openFiles.get(this._activeFile);
|
||||
this.statusFileEl.textContent = this._activeFile + (f?.modified ? ' ●' : '');
|
||||
if (this.statusLangEl && f) {
|
||||
this.statusLangEl.textContent = f.language || '';
|
||||
}
|
||||
},
|
||||
|
||||
async _confirmClose(path) {
|
||||
const fileName = path.split('/').pop();
|
||||
if (typeof showConfirm === 'function') {
|
||||
return showConfirm('Save changes to ' + fileName + '?', {
|
||||
confirmLabel: 'Save', cancelLabel: 'Discard', showCancel: true
|
||||
});
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
CodeEditor._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
function _ceEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function _ceFileIcon(name) {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
const icons = {
|
||||
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
|
||||
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
|
||||
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
|
||||
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
|
||||
md: '📝', txt: '📄', svg: '🖼',
|
||||
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
|
||||
dockerfile: '🐳', makefile: '🔧',
|
||||
mod: '📦', sum: '📦', lock: '🔒',
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
}
|
||||
|
||||
function _ceDetectLanguage(path) {
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
const map = {
|
||||
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
||||
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
|
||||
go: 'go', py: 'python', rs: 'rust', rb: 'ruby',
|
||||
html: 'html', htm: 'html', css: 'css', scss: 'css', less: 'css',
|
||||
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
|
||||
md: 'markdown', mdx: 'markdown',
|
||||
sql: 'sql', sh: 'shell', bash: 'shell', zsh: 'shell',
|
||||
xml: 'xml', svg: 'xml',
|
||||
c: 'cpp', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
|
||||
java: 'java', kt: 'java',
|
||||
php: 'php', pl: 'perl',
|
||||
dockerfile: 'dockerfile',
|
||||
};
|
||||
return map[ext] || 'text';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
662
src/js/editor-surface.js
Normal file
662
src/js/editor-surface.js
Normal file
@@ -0,0 +1,662 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Editor Surface (v0.25.0)
|
||||
// ==========================================
|
||||
// Uses PaneContainer to mount a three-pane layout and moves
|
||||
// server-rendered component partials into pane slots.
|
||||
// Components are rendered by Go templates into #editorComponents
|
||||
// (hidden) — this JS moves them into the right pane slots.
|
||||
// ==========================================
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.__SURFACE__ !== 'editor') return;
|
||||
|
||||
const pageData = window.__PAGE_DATA__ || {};
|
||||
const wsId = pageData.WorkspaceID;
|
||||
const wsName = pageData.WorkspaceName || 'Workspace';
|
||||
|
||||
// Wire user menu
|
||||
_initUserMenu();
|
||||
|
||||
// Wire workspace selector
|
||||
_initWsSelector(wsId);
|
||||
|
||||
// No workspace — show bootstrap
|
||||
if (!wsId) {
|
||||
_showBootstrap();
|
||||
return;
|
||||
}
|
||||
|
||||
_mountEditor(wsId, wsName);
|
||||
});
|
||||
|
||||
// ── User Menu ───────────────────────────
|
||||
|
||||
function _initUserMenu() {
|
||||
if (typeof UserMenu === 'undefined') return;
|
||||
const menu = UserMenu.create({ id: '' });
|
||||
UserMenu.primary = menu;
|
||||
menu.setUser(API.user || window.__USER__);
|
||||
menu.bind({
|
||||
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
|
||||
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
|
||||
onDebug: () => { if (typeof openDebugModal === 'function') openDebugModal(); },
|
||||
onSignout: () => { if (typeof handleLogout === 'function') handleLogout(); },
|
||||
});
|
||||
menu.showAdmin(API.isAdmin);
|
||||
}
|
||||
|
||||
// ── Workspace Selector ──────────────────
|
||||
|
||||
function _initWsSelector(currentWsId) {
|
||||
const btn = document.getElementById('editorWsSelectorBtn');
|
||||
const dropdown = document.getElementById('editorWsDropdown');
|
||||
if (!btn || !dropdown) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = dropdown.classList.toggle('open');
|
||||
if (isOpen) _loadWsDropdown(currentWsId);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
|
||||
});
|
||||
|
||||
// New workspace button
|
||||
document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
|
||||
dropdown.classList.remove('open');
|
||||
const name = window.prompt('Workspace name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
const userId = API.user?.id || window.__USER__?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
|
||||
const newId = resp.id || resp.data?.id;
|
||||
if (newId) {
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadWsDropdown(currentWsId) {
|
||||
const listEl = document.getElementById('editorWsList');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Loading…</div>';
|
||||
|
||||
try {
|
||||
const resp = await API._get('/api/v1/workspaces');
|
||||
const workspaces = resp.data || resp || [];
|
||||
listEl.innerHTML = '';
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">No workspaces</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
workspaces.forEach(ws => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
|
||||
item.textContent = ws.name || ws.id?.slice(0, 8);
|
||||
item.addEventListener('click', () => {
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (_) {
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bootstrap (no workspace) ────────────
|
||||
|
||||
function _showBootstrap() {
|
||||
const body = document.getElementById('editorBody');
|
||||
const bootstrap = document.getElementById('editorBootstrap');
|
||||
if (body) body.style.display = 'none';
|
||||
if (bootstrap) bootstrap.style.display = '';
|
||||
|
||||
_loadBootstrapList();
|
||||
|
||||
const btn = document.getElementById('editorBootstrapBtn');
|
||||
const input = document.getElementById('editorBootstrapName');
|
||||
if (!btn || !input) return;
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
const name = input.value.trim() || 'workspace';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating…';
|
||||
try {
|
||||
const userId = API.user?.id || window.__USER__?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
|
||||
const newId = resp.id || resp.data?.id;
|
||||
if (!newId) throw new Error('No workspace ID returned');
|
||||
// Navigate to the new workspace
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Workspace';
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadBootstrapList() {
|
||||
const listEl = document.getElementById('editorBootstrapList');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const resp = await API._get('/api/v1/workspaces');
|
||||
const workspaces = resp.data || resp || [];
|
||||
if (workspaces.length === 0) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">No workspaces yet</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
workspaces.forEach(ws => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'editor-bootstrap-ws-item';
|
||||
item.innerHTML =
|
||||
'<span class="editor-bootstrap-ws-name">' + _edEsc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
|
||||
'<span class="editor-bootstrap-ws-date">' + _edEsc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
|
||||
item.addEventListener('click', () => {
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (_) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">Failed to load workspaces</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mount Pane Layout ───────────────────
|
||||
|
||||
function _mountEditor(wsId, wsName) {
|
||||
const body = document.getElementById('editorBody');
|
||||
if (!body || typeof PaneContainer === 'undefined') {
|
||||
console.error('[EditorSurface] Missing body or PaneContainer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mount the 'editor' preset: files | editor | <chat, notes>
|
||||
const layout = PaneContainer.mount(body, 'editor', { workspaceId: wsId });
|
||||
if (!layout) return;
|
||||
|
||||
// ── Move server-rendered components into pane slots ──
|
||||
// Components were rendered by Go templates into #editorComponents (hidden).
|
||||
// We move them into the pane slots created by PaneContainer.
|
||||
|
||||
const components = document.getElementById('editorComponents');
|
||||
|
||||
// File Tree → files pane
|
||||
const filesPaneInfo = layout._panes.get('files');
|
||||
const fileTreeEl = document.getElementById('edFileTree');
|
||||
if (filesPaneInfo?.el && fileTreeEl) {
|
||||
filesPaneInfo.el.appendChild(fileTreeEl);
|
||||
}
|
||||
const fileTree = FileTree.create({ id: 'ed', workspaceId: wsId,
|
||||
onSelect: (path) => codeEditor.openFile(path),
|
||||
onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
|
||||
onNewFile: () => _createNewFile(wsId, fileTree),
|
||||
});
|
||||
filesPaneInfo.component = fileTree;
|
||||
fileTree.bind();
|
||||
|
||||
// Code Editor → editor pane
|
||||
const editorPaneInfo = layout._panes.get('editor');
|
||||
const codeEditorEl = document.getElementById('edCodeEditor');
|
||||
if (editorPaneInfo?.el && codeEditorEl) {
|
||||
editorPaneInfo.el.appendChild(codeEditorEl);
|
||||
}
|
||||
const codeEditor = CodeEditor.create({ id: 'ed', workspaceId: wsId,
|
||||
onSave: () => fileTree.refresh(),
|
||||
onActivate: (path) => fileTree.setActiveFile(path),
|
||||
});
|
||||
editorPaneInfo.component = codeEditor;
|
||||
codeEditor.bind();
|
||||
|
||||
// Assist pane (tabbed: chat + notes)
|
||||
const assistPaneInfo = layout._panes.get('assist');
|
||||
if (assistPaneInfo?.tabs) {
|
||||
// Chat tab — move server-rendered ChatPane partial
|
||||
const chatPanel = assistPaneInfo.getTabPanel('chat');
|
||||
const chatPaneEl = document.getElementById('edChatPane');
|
||||
if (chatPanel && chatPaneEl) {
|
||||
chatPanel.appendChild(chatPaneEl);
|
||||
// Create ChatPane instance from the server-rendered mount points
|
||||
if (typeof ChatPane !== 'undefined') {
|
||||
const chatPane = ChatPane.create({
|
||||
id: 'ed',
|
||||
messagesEl: document.getElementById('edChatMessages'),
|
||||
inputEl: document.getElementById('edChatInput'),
|
||||
sendBtnEl: document.getElementById('edSendBtn'),
|
||||
modelSelEl: document.getElementById('edModelSel'),
|
||||
toolbarEl: document.getElementById('edToolbar'),
|
||||
standalone: true,
|
||||
});
|
||||
chatPane.showWelcome();
|
||||
_initAssistChat(chatPane, codeEditor);
|
||||
const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
|
||||
if (chatTab) chatTab.instance = chatPane;
|
||||
}
|
||||
}
|
||||
|
||||
// Notes tab — move server-rendered NoteEditor partial
|
||||
const notesPanel = assistPaneInfo.getTabPanel('notes');
|
||||
const noteEditorEl = document.getElementById('edNotesNoteEditor');
|
||||
if (notesPanel && noteEditorEl) {
|
||||
notesPanel.appendChild(noteEditorEl);
|
||||
if (typeof NoteEditor !== 'undefined') {
|
||||
const noteEditor = NoteEditor.create({ id: 'edNotes', onOpenGraph: null });
|
||||
noteEditor.bind();
|
||||
const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
|
||||
if (notesTab) {
|
||||
notesTab.instance = noteEditor;
|
||||
// Lazy-load on first tab click
|
||||
notesTab.btn.addEventListener('click', () => {
|
||||
if (!notesTab._loaded) {
|
||||
notesTab._loaded = true;
|
||||
noteEditor.loadNotes();
|
||||
noteEditor.loadFolders();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the hidden container
|
||||
if (components) components.remove();
|
||||
|
||||
// Git branch
|
||||
_refreshGitBranch(wsId, codeEditor);
|
||||
|
||||
// Toolbar
|
||||
document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
|
||||
fileTree.refresh();
|
||||
_refreshGitBranch(wsId, codeEditor);
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fileTree.refresh();
|
||||
|
||||
console.log('[EditorSurface] Mounted for workspace', wsId);
|
||||
}
|
||||
|
||||
// ── File Operations ─────────────────────
|
||||
|
||||
async function _deleteFile(wsId, path, fileTree, codeEditor) {
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Delete ' + path + '?')
|
||||
: window.confirm('Delete ' + path + '?');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.deleteWorkspaceFile(wsId, path);
|
||||
if (codeEditor.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
|
||||
fileTree.refresh();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _createNewFile(wsId, fileTree) {
|
||||
const name = window.prompt('File name (e.g. src/main.go):');
|
||||
if (!name) return;
|
||||
try {
|
||||
await API.writeWorkspaceFile(wsId, name.trim(), '');
|
||||
fileTree.refresh();
|
||||
if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git Branch ──────────────────────────
|
||||
|
||||
async function _refreshGitBranch(wsId, codeEditor) {
|
||||
try {
|
||||
const resp = await API.getWorkspaceGitBranches(wsId);
|
||||
const branch = resp.current || null;
|
||||
if (branch) {
|
||||
const badge = document.getElementById('editorBranchBadge');
|
||||
const name = document.getElementById('editorBranchName');
|
||||
if (badge) badge.style.display = '';
|
||||
if (name) name.textContent = branch;
|
||||
}
|
||||
if (codeEditor) codeEditor.setBranch(branch);
|
||||
} catch (_) {
|
||||
// Git not configured — hide branch badge
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
function _edEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
// ── Assist Chat (channel-based, standalone) ──
|
||||
// Full chat pane for the editor: model selector, chat history,
|
||||
// new chat, SSE streaming. Uses real channels/messages API.
|
||||
// No dependency on chat.js — fully standalone.
|
||||
|
||||
function _initAssistChat(chatPane, codeEditor) {
|
||||
const inputEl = chatPane.inputEl;
|
||||
const sendBtn = chatPane.sendBtnEl;
|
||||
const headerEl = document.getElementById('edChatHeader');
|
||||
const selectEl = document.getElementById('edChatSelect');
|
||||
const newBtnEl = document.getElementById('edChatNewBtn');
|
||||
const modelSelEl = document.getElementById('edModelSel');
|
||||
if (!inputEl) return;
|
||||
|
||||
// Show the header bar
|
||||
if (headerEl) headerEl.style.display = '';
|
||||
|
||||
// State
|
||||
let _channelId = null;
|
||||
let _messages = [];
|
||||
let _sending = false;
|
||||
let _abortController = null;
|
||||
let _selectedModel = App.settings?.model || '';
|
||||
|
||||
// ── Model Selector ──────────────────────
|
||||
async function _initModelSelector() {
|
||||
if (!modelSelEl) return;
|
||||
// Ensure models are loaded
|
||||
if (!App.models?.length && typeof fetchModels === 'function') {
|
||||
await fetchModels();
|
||||
}
|
||||
const models = App.models || [];
|
||||
if (!models.length) return;
|
||||
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'chat-pane-model-select';
|
||||
sel.title = 'Select model';
|
||||
|
||||
models.forEach(m => {
|
||||
if (m.hidden) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.isPersona ? (m.personaId || m.id) : m.id;
|
||||
const prefix = m.isPersona ? '🎭 ' : '';
|
||||
opt.textContent = prefix + (m.name || m.id);
|
||||
if (m.id === _selectedModel || m.personaId === _selectedModel) {
|
||||
opt.selected = true;
|
||||
}
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
sel.addEventListener('change', () => {
|
||||
_selectedModel = sel.value;
|
||||
});
|
||||
|
||||
modelSelEl.innerHTML = '';
|
||||
modelSelEl.appendChild(sel);
|
||||
}
|
||||
|
||||
// ── Chat History Selector ───────────────
|
||||
async function _loadChatHistory() {
|
||||
if (!selectEl) return;
|
||||
try {
|
||||
const resp = await API.listChannels(1, 20, 'direct');
|
||||
const channels = resp.data || resp || [];
|
||||
selectEl.innerHTML = '<option value="">New conversation</option>';
|
||||
channels.forEach(ch => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ch.id;
|
||||
opt.textContent = ch.title || 'Untitled';
|
||||
if (ch.id === _channelId) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[AssistChat] Failed to load history:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectEl) {
|
||||
selectEl.addEventListener('change', () => {
|
||||
const id = selectEl.value;
|
||||
if (id) {
|
||||
_switchToChannel(id);
|
||||
} else {
|
||||
_newChat();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (newBtnEl) {
|
||||
newBtnEl.addEventListener('click', _newChat);
|
||||
}
|
||||
|
||||
// ── Switch Channel ──────────────────────
|
||||
async function _switchToChannel(channelId) {
|
||||
_channelId = channelId;
|
||||
_messages = [];
|
||||
chatPane.clear();
|
||||
chatPane.appendTyping();
|
||||
|
||||
try {
|
||||
const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100');
|
||||
const msgs = resp.data || resp || [];
|
||||
_messages = msgs;
|
||||
chatPane.removeTyping();
|
||||
_renderAllMessages();
|
||||
} catch (e) {
|
||||
chatPane.removeTyping();
|
||||
_appendSystemMsg('Failed to load messages: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function _newChat() {
|
||||
_channelId = null;
|
||||
_messages = [];
|
||||
chatPane.showWelcome();
|
||||
if (selectEl) selectEl.value = '';
|
||||
}
|
||||
|
||||
// ── Create Textarea ─────────────────────
|
||||
const ta = document.createElement('textarea');
|
||||
ta.placeholder = 'Ask about your code…';
|
||||
ta.rows = 1;
|
||||
inputEl.appendChild(ta);
|
||||
|
||||
ta.addEventListener('input', () => {
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
||||
});
|
||||
|
||||
// ── Send ────────────────────────────────
|
||||
async function sendMessage() {
|
||||
const text = ta.value.trim();
|
||||
if (!text || _sending) return;
|
||||
|
||||
_sending = true;
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
ta.value = '';
|
||||
ta.style.height = 'auto';
|
||||
|
||||
// Create channel if new conversation
|
||||
if (!_channelId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '…' : '');
|
||||
const resp = await API.createChannel(title, _selectedModel, '', 'direct');
|
||||
_channelId = resp.id;
|
||||
// Update dropdown
|
||||
_loadChatHistory();
|
||||
} catch (e) {
|
||||
_appendSystemMsg('Failed to create chat: ' + e.message);
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Render user message
|
||||
_messages.push({ role: 'user', content: text });
|
||||
_renderAllMessages();
|
||||
|
||||
// Build context from active file
|
||||
const fileCtx = _getFileContext(codeEditor);
|
||||
|
||||
// Stream the completion
|
||||
try {
|
||||
_abortController = new AbortController();
|
||||
|
||||
// If we have file context, prepend it to the message
|
||||
const content = fileCtx
|
||||
? text + '\n\n[Context: Currently editing ' + fileCtx.path + ']\n```\n' + fileCtx.content + '\n```'
|
||||
: text;
|
||||
|
||||
const resp = await API.streamCompletion(
|
||||
_channelId, content, _selectedModel,
|
||||
_abortController.signal
|
||||
);
|
||||
|
||||
await _handleStream(resp, chatPane);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
_appendSystemMsg('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
_abortController = null;
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
|
||||
|
||||
ta.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// ── SSE Stream Handler ──────────────────
|
||||
async function _handleStream(response, pane) {
|
||||
const messagesEl = pane.messagesEl;
|
||||
if (!messagesEl) return;
|
||||
|
||||
// Create assistant bubble
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'chat-msg__content';
|
||||
div.appendChild(contentEl);
|
||||
messagesEl.appendChild(div);
|
||||
|
||||
let content = '';
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
content += delta;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content));
|
||||
} else {
|
||||
contentEl.textContent = content;
|
||||
}
|
||||
pane.scrollToBottom();
|
||||
}
|
||||
} catch (_) { /* skip unparseable lines */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
content += '\n\n**[Stream error: ' + e.message + ']**';
|
||||
}
|
||||
}
|
||||
|
||||
div.classList.remove('chat-msg--streaming');
|
||||
_messages.push({ role: 'assistant', content });
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────
|
||||
function _renderAllMessages() {
|
||||
if (!chatPane.messagesEl) return;
|
||||
chatPane.messagesEl.innerHTML = '';
|
||||
_messages.forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--' + m.role;
|
||||
const content = document.createElement('div');
|
||||
content.className = 'chat-msg__content';
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
content.innerHTML = DOMPurify.sanitize(marked.parse(m.content || ''));
|
||||
} else {
|
||||
content.textContent = m.content || '';
|
||||
}
|
||||
div.appendChild(content);
|
||||
chatPane.messagesEl.appendChild(div);
|
||||
});
|
||||
chatPane.scrollToBottom();
|
||||
}
|
||||
|
||||
function _appendSystemMsg(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--system';
|
||||
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + _edEsc(text) + '</div>';
|
||||
chatPane.messagesEl?.appendChild(div);
|
||||
chatPane.scrollToBottom();
|
||||
}
|
||||
|
||||
function _getFileContext(editor) {
|
||||
if (!editor) return null;
|
||||
try {
|
||||
const openFiles = editor.getOpenFiles();
|
||||
if (!openFiles.length) return null;
|
||||
// Find active tab
|
||||
const activeTab = document.querySelector('.code-editor-tab.active');
|
||||
const path = activeTab?.dataset?.path || openFiles[0];
|
||||
// Get content from the editor instances
|
||||
const inst = CodeEditor._instances?.get('ed');
|
||||
if (inst?._files) {
|
||||
const file = inst._files.get(path);
|
||||
if (file?.view) {
|
||||
const content = file.view.state.doc.toString();
|
||||
return { path, content: content.slice(0, 4000) };
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────
|
||||
// Defer model selector and chat history loading until app common init
|
||||
// completes (fetchModels, auth, etc.). The sb:ready event fires from
|
||||
// startApp() after App.models is populated.
|
||||
function _deferredInit() {
|
||||
_initModelSelector();
|
||||
_loadChatHistory();
|
||||
}
|
||||
|
||||
if (App.models?.length) {
|
||||
// Already loaded (unlikely but possible)
|
||||
_deferredInit();
|
||||
} else {
|
||||
document.addEventListener('sb:ready', _deferredInit, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
278
src/js/file-tree.js
Normal file
278
src/js/file-tree.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — FileTree Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from editor-mode.js (lines 336-535).
|
||||
// Workspace file browser with directory expand/collapse, git status
|
||||
// badges, context menu, and file icon mapping.
|
||||
//
|
||||
// Usage:
|
||||
// const tree = FileTree.create({
|
||||
// id: 'editor',
|
||||
// workspaceId: 'ws-123',
|
||||
// onSelect: (path) => codeEditor.openFile(path),
|
||||
// onDelete: (path) => { ... },
|
||||
// onNewFile: () => { ... },
|
||||
// });
|
||||
// await tree.refresh();
|
||||
// tree.setActiveFile('src/main.go');
|
||||
// tree.destroy();
|
||||
|
||||
const FileTree = {
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'editor';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
itemsEl: document.getElementById(pfx + 'TreeItems'),
|
||||
newFileEl: document.getElementById(pfx + 'TreeNewFile'),
|
||||
workspaceId: opts.workspaceId || null,
|
||||
onSelect: opts.onSelect || null,
|
||||
onDelete: opts.onDelete || null,
|
||||
onNewFile: opts.onNewFile || null,
|
||||
_treeData: [],
|
||||
_expandedDirs: new Set(['']),
|
||||
_gitStatusMap: new Map(),
|
||||
_activeFile: null,
|
||||
_listeners: [],
|
||||
|
||||
// ── Public API ──────────────────────
|
||||
|
||||
setWorkspace(wsId) { this.workspaceId = wsId; },
|
||||
|
||||
async refresh() {
|
||||
if (!this.workspaceId) return;
|
||||
try {
|
||||
const resp = await API.listWorkspaceFiles(this.workspaceId, '', true);
|
||||
this._treeData = resp.files || resp.data || resp || [];
|
||||
this._render();
|
||||
} catch (e) {
|
||||
console.error('[FileTree] Failed to load:', e);
|
||||
if (this.itemsEl) this.itemsEl.innerHTML = '<div class="file-tree-error">Failed to load files</div>';
|
||||
}
|
||||
// Non-blocking git status refresh
|
||||
this._refreshGitStatus();
|
||||
},
|
||||
|
||||
setActiveFile(path) {
|
||||
this._activeFile = path;
|
||||
if (!this.itemsEl) return;
|
||||
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.path === path);
|
||||
});
|
||||
},
|
||||
|
||||
getActiveFile() { return this._activeFile; },
|
||||
|
||||
// ── Git Status ──────────────────────
|
||||
|
||||
async _refreshGitStatus() {
|
||||
if (!this.workspaceId) return;
|
||||
this._gitStatusMap.clear();
|
||||
try {
|
||||
const resp = await API.getWorkspaceGitStatus(this.workspaceId);
|
||||
const files = resp.files || [];
|
||||
for (const f of files) {
|
||||
this._gitStatusMap.set(f.path, f.status || '?');
|
||||
}
|
||||
// Apply to rendered rows
|
||||
if (this.itemsEl) {
|
||||
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
|
||||
const p = row.dataset.path;
|
||||
const st = this._gitStatusMap.get(p);
|
||||
row.classList.remove('git-modified', 'git-added', 'git-untracked', 'git-deleted');
|
||||
if (st === 'M') row.classList.add('git-modified');
|
||||
else if (st === 'A') row.classList.add('git-added');
|
||||
else if (st === '?') row.classList.add('git-untracked');
|
||||
else if (st === 'D') row.classList.add('git-deleted');
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Git not configured — ignore
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tree Rendering ──────────────────
|
||||
|
||||
_render() {
|
||||
if (!this.itemsEl) return;
|
||||
this.itemsEl.innerHTML = '';
|
||||
if (!this._treeData.length) {
|
||||
this.itemsEl.innerHTML = '<div class="file-tree-empty">No files</div>';
|
||||
return;
|
||||
}
|
||||
const tree = this._buildStructure(this._treeData);
|
||||
this._renderNodes(tree, this.itemsEl, 0);
|
||||
},
|
||||
|
||||
_buildStructure(files) {
|
||||
const root = { name: '', path: '', isDir: true, children: [], file: null };
|
||||
const dirs = new Map();
|
||||
dirs.set('', root);
|
||||
|
||||
const sorted = [...files].sort((a, b) => {
|
||||
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
for (const f of sorted) {
|
||||
const parts = f.path.split('/');
|
||||
const name = parts[parts.length - 1];
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
|
||||
let parent = dirs.get(parentPath);
|
||||
if (!parent) {
|
||||
let accumulated = '';
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const prev = accumulated;
|
||||
accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i];
|
||||
if (!dirs.has(accumulated)) {
|
||||
const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null };
|
||||
dirs.set(accumulated, dirNode);
|
||||
const p = dirs.get(prev);
|
||||
if (p) p.children.push(dirNode);
|
||||
}
|
||||
}
|
||||
parent = dirs.get(parentPath);
|
||||
}
|
||||
|
||||
if (f.is_directory) {
|
||||
let existing = dirs.get(f.path);
|
||||
if (!existing) {
|
||||
existing = { name, path: f.path, isDir: true, children: [], file: f };
|
||||
dirs.set(f.path, existing);
|
||||
if (parent) parent.children.push(existing);
|
||||
} else {
|
||||
existing.file = f;
|
||||
}
|
||||
} else {
|
||||
const node = { name, path: f.path, isDir: false, children: [], file: f };
|
||||
if (parent) parent.children.push(node);
|
||||
}
|
||||
}
|
||||
return root.children;
|
||||
},
|
||||
|
||||
_renderNodes(nodes, container, depth) {
|
||||
const self = this;
|
||||
for (const node of nodes) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-tree-row' + (node.path === this._activeFile ? ' active' : '');
|
||||
row.style.paddingLeft = (12 + depth * 16) + 'px';
|
||||
row.dataset.path = node.path;
|
||||
|
||||
if (node.isDir) {
|
||||
const expanded = this._expandedDirs.has(node.path);
|
||||
row.innerHTML =
|
||||
'<span class="file-tree-arrow ' + (expanded ? 'expanded' : '') + '">' + (expanded ? '▾' : '▸') + '</span>' +
|
||||
'<span class="file-tree-icon">📁</span>' +
|
||||
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
|
||||
row.addEventListener('click', () => {
|
||||
if (self._expandedDirs.has(node.path)) {
|
||||
self._expandedDirs.delete(node.path);
|
||||
} else {
|
||||
self._expandedDirs.add(node.path);
|
||||
}
|
||||
self._render();
|
||||
});
|
||||
container.appendChild(row);
|
||||
if (expanded && node.children.length) {
|
||||
this._renderNodes(node.children, container, depth + 1);
|
||||
}
|
||||
} else {
|
||||
row.innerHTML =
|
||||
'<span class="file-tree-icon">' + _ftFileIcon(node.name) + '</span>' +
|
||||
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
|
||||
row.addEventListener('click', () => {
|
||||
if (self.onSelect) self.onSelect(node.path);
|
||||
});
|
||||
row.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
self._showContextMenu(e, node);
|
||||
});
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Context Menu ────────────────────
|
||||
|
||||
_showContextMenu(e, node) {
|
||||
const existing = document.querySelector('.file-tree-ctx-menu');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'file-tree-ctx-menu';
|
||||
menu.style.left = e.clientX + 'px';
|
||||
menu.style.top = e.clientY + 'px';
|
||||
|
||||
const items = [
|
||||
{ label: 'Open', action: () => { if (this.onSelect) this.onSelect(node.path); } },
|
||||
{ label: 'Delete', action: () => { if (this.onDelete) this.onDelete(node.path); } },
|
||||
];
|
||||
for (const item of items) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-tree-ctx-item';
|
||||
row.textContent = item.label;
|
||||
row.addEventListener('click', () => { menu.remove(); item.action(); });
|
||||
menu.appendChild(row);
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
const dismiss = (ev) => {
|
||||
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); }
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', dismiss), 0);
|
||||
},
|
||||
|
||||
// ── Event Wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
this._on(this.newFileEl, 'click', () => {
|
||||
if (this.onNewFile) this.onNewFile();
|
||||
});
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
this._treeData = [];
|
||||
this._expandedDirs = new Set(['']);
|
||||
this._gitStatusMap.clear();
|
||||
FileTree._instances.delete(this.id);
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
FileTree._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
function _ftEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function _ftFileIcon(name) {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
const icons = {
|
||||
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
|
||||
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
|
||||
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
|
||||
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
|
||||
md: '📝', txt: '📄', svg: '🖼',
|
||||
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
|
||||
dockerfile: '🐳', makefile: '🔧',
|
||||
mod: '📦', sum: '📦', lock: '🔒',
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
}
|
||||
191
src/js/model-selector.js
Normal file
191
src/js/model-selector.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — ModelSelector Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from ui-core.js.
|
||||
// Grouped dropdown: personas (global, team, personal), models, BYOK.
|
||||
// Pattern: Go template partial (model-selector.html) + JS factory (this file).
|
||||
//
|
||||
// Usage:
|
||||
// const sel = ModelSelector.create({ id: 'main', onChange: (id, label) => { ... } });
|
||||
// sel.setModels(App.models);
|
||||
// sel.select(modelId);
|
||||
// sel.getSelected(); // → current model ID
|
||||
// sel.destroy();
|
||||
|
||||
const ModelSelector = {
|
||||
primary: null,
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || '';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
dropdownEl: document.getElementById(pfx + 'modelDropdown'),
|
||||
btnEl: document.getElementById(pfx + 'modelDropdownBtn'),
|
||||
labelEl: document.getElementById(pfx + 'modelDropdownLabel'),
|
||||
menuEl: document.getElementById(pfx + 'modelDropdownMenu'),
|
||||
capsEl: document.getElementById(pfx + 'modelCaps'),
|
||||
onChange: opts.onChange || null,
|
||||
_value: '',
|
||||
_models: [],
|
||||
_listeners: [],
|
||||
|
||||
// ── Selection ───────────────────────
|
||||
|
||||
getSelected() { return this._value; },
|
||||
|
||||
select(id, label) {
|
||||
this._value = id;
|
||||
if (this.labelEl) this.labelEl.textContent = label || id || 'Select a model';
|
||||
// Highlight
|
||||
if (this.menuEl) {
|
||||
this.menuEl.querySelectorAll('.model-dropdown-item').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.value === id);
|
||||
});
|
||||
}
|
||||
this._updateCaps();
|
||||
if (this.onChange) this.onChange(id, label);
|
||||
},
|
||||
|
||||
// ── Model list ──────────────────────
|
||||
|
||||
setModels(models) {
|
||||
this._models = models || [];
|
||||
this._rebuild();
|
||||
},
|
||||
|
||||
_rebuild() {
|
||||
if (!this.menuEl) return;
|
||||
this.menuEl.innerHTML = '';
|
||||
|
||||
if (this._models.length === 0) {
|
||||
this.menuEl.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
|
||||
this.select('', 'No models loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const globalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'global');
|
||||
const teamPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'team');
|
||||
const personalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'personal');
|
||||
const models = this._models.filter(m => !m.isPersona && !m.hidden);
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
if (items.length === 0) return;
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'model-dropdown-group';
|
||||
hdr.textContent = label;
|
||||
this.menuEl.appendChild(hdr);
|
||||
items.forEach(m => this.menuEl.appendChild(this._createItem(m)));
|
||||
};
|
||||
|
||||
addGroup('\u26A1 Personas', globalPersonas);
|
||||
|
||||
// Group team personas by team name
|
||||
const teamGroups = {};
|
||||
teamPersonas.forEach(m => {
|
||||
const tn = m.personaTeamName || 'Team';
|
||||
(teamGroups[tn] = teamGroups[tn] || []).push(m);
|
||||
});
|
||||
Object.keys(teamGroups).sort().forEach(tn => {
|
||||
addGroup('\uD83D\uDC65 ' + tn, teamGroups[tn]);
|
||||
});
|
||||
|
||||
addGroup('\uD83D\uDD27 My Personas', personalPersonas);
|
||||
addGroup('Models', models.filter(m => m.source !== 'personal'));
|
||||
addGroup('\uD83D\uDD11 My Providers', models.filter(m => m.source === 'personal'));
|
||||
},
|
||||
|
||||
_createItem(m) {
|
||||
const self = this;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'model-dropdown-item';
|
||||
div.dataset.value = m.id;
|
||||
const avatar = m.personaAvatar ? '<img src="' + _msEsc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
|
||||
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + _msEsc(m.personaHandle) + '</span>' : '';
|
||||
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + _msEsc(m.teamName) + '</span>' : '';
|
||||
const provider = m.provider ? '<span class="item-provider">' + _msEsc(m.provider) + '</span>' : '';
|
||||
div.innerHTML = avatar + '<span class="item-label">' + _msEsc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
|
||||
div.addEventListener('click', () => {
|
||||
self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : ''));
|
||||
self.close();
|
||||
});
|
||||
return div;
|
||||
},
|
||||
|
||||
// ── Restore selection ───────────────
|
||||
|
||||
/**
|
||||
* Restore selection from a preferred ID, falling back through
|
||||
* admin default → first visible.
|
||||
*/
|
||||
restore(preferredId, adminDefault) {
|
||||
const visible = this._models.filter(m => !m.hidden);
|
||||
if (visible.length === 0) { this.select('', 'No visible models'); return; }
|
||||
|
||||
const byId = (id) => visible.find(m => m.id === id || m.baseModelId === id);
|
||||
const match = byId(preferredId) || byId(adminDefault) || visible[0];
|
||||
this.select(match.id, (match.name || match.id) + (match.provider ? ' (' + match.provider + ')' : ''));
|
||||
},
|
||||
|
||||
// ── Dropdown toggle ─────────────────
|
||||
|
||||
open() { if (this.menuEl) this.menuEl.classList.add('open'); },
|
||||
close() { if (this.menuEl) this.menuEl.classList.remove('open'); },
|
||||
toggle() { if (this.menuEl) this.menuEl.classList.toggle('open'); },
|
||||
|
||||
// ── Capability badges ───────────────
|
||||
|
||||
_updateCaps() {
|
||||
if (!this.capsEl) return;
|
||||
if (!this._value) { this.capsEl.innerHTML = ''; return; }
|
||||
const m = this._models.find(m => m.id === this._value);
|
||||
const caps = m?.capabilities;
|
||||
if (!caps || Object.keys(caps).length === 0) { this.capsEl.innerHTML = ''; return; }
|
||||
// Use global renderCapBadges if available (from ui-core.js)
|
||||
if (typeof renderCapBadges === 'function') {
|
||||
this.capsEl.innerHTML = renderCapBadges(caps);
|
||||
}
|
||||
},
|
||||
|
||||
getSelectedCaps() {
|
||||
const m = this._models.find(m => m.id === this._value);
|
||||
return m?.capabilities || {};
|
||||
},
|
||||
|
||||
// ── Event wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
this._on(this.btnEl, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
this._on(document, 'click', (e) => {
|
||||
if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close();
|
||||
});
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
ModelSelector._instances.delete(this.id);
|
||||
if (ModelSelector.primary === this) ModelSelector.primary = null;
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
ModelSelector._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
// HTML-escape for model selector content
|
||||
function _msEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
450
src/js/note-editor.js
Normal file
450
src/js/note-editor.js
Normal file
@@ -0,0 +1,450 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — NoteEditor Component
|
||||
// ==========================================
|
||||
// v0.25.0: Component wrapper for notes list + editor.
|
||||
// Hydrates the server-rendered note-editor.html partial.
|
||||
// Can run standalone (in editor tabbed pane) or coexist with
|
||||
// the existing notes.js panel system (on chat/notes surfaces).
|
||||
//
|
||||
// Usage:
|
||||
// const notes = NoteEditor.create({
|
||||
// id: 'main', // matches template prefix
|
||||
// onOpenGraph: () => { ... },
|
||||
// });
|
||||
// notes.bind();
|
||||
// await notes.loadNotes();
|
||||
// notes.destroy();
|
||||
//
|
||||
// The existing notes.js + PanelRegistry integration continues to
|
||||
// work on the chat surface. This component is for NEW mount points
|
||||
// (editor pane, future notes-studio layout).
|
||||
|
||||
const NoteEditor = {
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'main';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
rootEl: document.getElementById(pfx + 'NoteEditor'),
|
||||
listViewEl: document.getElementById(pfx + 'NotesListView'),
|
||||
editorViewEl: document.getElementById(pfx + 'NotesEditorView'),
|
||||
graphViewEl: document.getElementById(pfx + 'NotesGraphView'),
|
||||
listEl: document.getElementById(pfx + 'NotesList'),
|
||||
searchEl: document.getElementById(pfx + 'NotesSearchInput'),
|
||||
folderFilterEl: document.getElementById(pfx + 'NotesFolderFilter'),
|
||||
sortEl: document.getElementById(pfx + 'NotesSortSelect'),
|
||||
titleEl: document.getElementById(pfx + 'NoteEditorTitle'),
|
||||
folderEl: document.getElementById(pfx + 'NoteEditorFolder'),
|
||||
tagsEl: document.getElementById(pfx + 'NoteEditorTags'),
|
||||
contentContainerEl: document.getElementById(pfx + 'NoteEditorContentContainer'),
|
||||
readTitleEl: document.getElementById(pfx + 'NoteReadTitle'),
|
||||
readMetaEl: document.getElementById(pfx + 'NoteReadMeta'),
|
||||
readContentEl: document.getElementById(pfx + 'NoteReadContent'),
|
||||
editModeEl: document.getElementById(pfx + 'NoteEditMode'),
|
||||
readModeEl: document.getElementById(pfx + 'NoteReadMode'),
|
||||
backlinksEl: document.getElementById(pfx + 'NoteBacklinks'),
|
||||
backlinksListEl: document.getElementById(pfx + 'NoteBacklinksList'),
|
||||
backlinksCountEl: document.getElementById(pfx + 'NoteBacklinksCount'),
|
||||
onOpenGraph: opts.onOpenGraph || null,
|
||||
_editingNoteId: null,
|
||||
_currentNote: null,
|
||||
_sort: 'updated_desc',
|
||||
_cmEditor: null,
|
||||
_listeners: [],
|
||||
|
||||
// ── View Switching ───────────────────
|
||||
|
||||
showList() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = '';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
|
||||
this._destroyCmEditor();
|
||||
},
|
||||
|
||||
showEditor() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = 'none';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = '';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
|
||||
},
|
||||
|
||||
showGraph() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = 'none';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = '';
|
||||
},
|
||||
|
||||
// ── Note List ───────────────────────
|
||||
|
||||
async loadNotes(folder, searchQuery) {
|
||||
if (!this.listEl) return;
|
||||
this.listEl.innerHTML = '<div class="notes-loading">Loading…</div>';
|
||||
|
||||
try {
|
||||
let notes;
|
||||
if (searchQuery) {
|
||||
const resp = await API.searchNotes(searchQuery);
|
||||
notes = resp.data || [];
|
||||
} else {
|
||||
const folderVal = folder || this.folderFilterEl?.value || '';
|
||||
const resp = await API.listNotes(100, 0, folderVal, '', this._sort);
|
||||
notes = resp.data || [];
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
this.listEl.innerHTML = '<div class="notes-empty">' +
|
||||
(searchQuery ? 'No results found' : 'No notes yet') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = '';
|
||||
const self = this;
|
||||
notes.forEach(note => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'note-item';
|
||||
item.dataset.noteId = note.id;
|
||||
const title = note.title || 'Untitled';
|
||||
const preview = (note.content || '').slice(0, 80).replace(/\n/g, ' ');
|
||||
const date = note.updated_at ? new Date(note.updated_at).toLocaleDateString() : '';
|
||||
item.innerHTML =
|
||||
'<div class="note-content-col">' +
|
||||
'<div class="note-title">' + _neEsc(title) + '</div>' +
|
||||
'<div class="note-preview">' + _neEsc(preview) + '</div>' +
|
||||
'<div class="note-date">' + _neEsc(date) + '</div>' +
|
||||
'</div>';
|
||||
item.addEventListener('click', () => self.openNote(note.id));
|
||||
self.listEl.appendChild(item);
|
||||
});
|
||||
} catch (e) {
|
||||
this.listEl.innerHTML = '<div class="notes-empty">Failed to load: ' + _neEsc(e.message) + '</div>';
|
||||
}
|
||||
},
|
||||
|
||||
async loadFolders() {
|
||||
if (!this.folderFilterEl) return;
|
||||
try {
|
||||
const resp = await API.listNoteFolders();
|
||||
const folders = resp.data || resp || [];
|
||||
// Keep "All folders" option, rebuild the rest
|
||||
this.folderFilterEl.innerHTML = '<option value="">All folders</option>';
|
||||
folders.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f;
|
||||
opt.textContent = f;
|
||||
this.folderFilterEl.appendChild(opt);
|
||||
});
|
||||
} catch (_) { /* ignore */ }
|
||||
},
|
||||
|
||||
// ── Note CRUD ───────────────────────
|
||||
|
||||
async openNote(noteId) {
|
||||
this.showEditor();
|
||||
if (noteId) {
|
||||
this._editingNoteId = noteId;
|
||||
try {
|
||||
this._currentNote = await API.getNote(noteId);
|
||||
this._populateFields(this._currentNote);
|
||||
this._showReadMode();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to load note: ' + e.message, 'error');
|
||||
this.showList();
|
||||
}
|
||||
} else {
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
this._showEditMode();
|
||||
if (this.titleEl) this.titleEl.focus();
|
||||
}
|
||||
},
|
||||
|
||||
async saveNote() {
|
||||
const title = this.titleEl?.value?.trim() || '';
|
||||
const content = this._getContent();
|
||||
const folder = this.folderEl?.value?.trim() || '';
|
||||
const tagsStr = this.tagsEl?.value || '';
|
||||
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
if (!title && !content) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Note is empty', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this._editingNoteId) {
|
||||
await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags });
|
||||
} else {
|
||||
const resp = await API.createNote(title, content, folder, tags);
|
||||
this._editingNoteId = resp.id || resp.data?.id;
|
||||
}
|
||||
this._currentNote = { ...this._currentNote, title, content, folder_path: folder, tags };
|
||||
this._showReadMode();
|
||||
if (typeof UI !== 'undefined') UI.toast('Note saved', 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Save failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteNote() {
|
||||
if (!this._editingNoteId) return;
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Delete this note?')
|
||||
: window.confirm('Delete this note?');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await API.deleteNote(this._editingNoteId);
|
||||
if (typeof UI !== 'undefined') UI.toast('Note deleted', 'success');
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this.showList();
|
||||
this.loadNotes();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Read / Edit / Preview Modes ─────
|
||||
|
||||
_showReadMode() {
|
||||
if (this.editModeEl) this.editModeEl.style.display = 'none';
|
||||
if (this.readModeEl) this.readModeEl.style.display = '';
|
||||
|
||||
const n = this._currentNote;
|
||||
if (!n) return;
|
||||
|
||||
if (this.readTitleEl) this.readTitleEl.textContent = n.title || 'Untitled';
|
||||
if (this.readMetaEl) {
|
||||
const parts = [];
|
||||
if (n.folder_path) parts.push('📁 ' + n.folder_path);
|
||||
if (n.tags?.length) parts.push('🏷 ' + n.tags.join(', '));
|
||||
if (n.updated_at) parts.push(new Date(n.updated_at).toLocaleString());
|
||||
this.readMetaEl.textContent = parts.join(' · ');
|
||||
}
|
||||
if (this.readContentEl) {
|
||||
if (typeof marked !== 'undefined') {
|
||||
const raw = typeof marked.parse === 'function' ? marked.parse(n.content || '') : marked(n.content || '');
|
||||
this.readContentEl.innerHTML = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw) : raw;
|
||||
} else {
|
||||
this.readContentEl.textContent = n.content || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Show edit/delete buttons for read mode
|
||||
this._setBtn('NoteEditBtn', true);
|
||||
this._setBtn('NotePreviewBtn', false);
|
||||
this._setBtn('NoteCancelEditBtn', false);
|
||||
this._setBtn('NoteDeleteBtn', true);
|
||||
},
|
||||
|
||||
_showEditMode() {
|
||||
if (this.editModeEl) this.editModeEl.style.display = '';
|
||||
if (this.readModeEl) this.readModeEl.style.display = 'none';
|
||||
|
||||
this._setBtn('NoteEditBtn', false);
|
||||
this._setBtn('NotePreviewBtn', true);
|
||||
this._setBtn('NoteCancelEditBtn', !!this._currentNote);
|
||||
this._setBtn('NoteDeleteBtn', !!this._editingNoteId);
|
||||
},
|
||||
|
||||
_showPreview() {
|
||||
// Toggle between edit and read-mode preview
|
||||
if (this.readModeEl?.style.display === 'none') {
|
||||
// Show preview of current edits
|
||||
const tempNote = {
|
||||
title: this.titleEl?.value || '',
|
||||
content: this._getContent(),
|
||||
folder_path: this.folderEl?.value || '',
|
||||
tags: (this.tagsEl?.value || '').split(',').map(t => t.trim()).filter(Boolean),
|
||||
};
|
||||
const saved = this._currentNote;
|
||||
this._currentNote = tempNote;
|
||||
this._showReadMode();
|
||||
this._currentNote = saved;
|
||||
// Swap buttons for preview-of-edits state
|
||||
this._setBtn('NoteEditBtn', true);
|
||||
this._setBtn('NotePreviewBtn', false);
|
||||
} else {
|
||||
this._showEditMode();
|
||||
}
|
||||
},
|
||||
|
||||
// ── CM6 Editor Management ───────────
|
||||
|
||||
_getContent() {
|
||||
if (this._cmEditor) return this._cmEditor.getValue();
|
||||
const ta = this.contentContainerEl?.querySelector('textarea');
|
||||
return ta ? ta.value : '';
|
||||
},
|
||||
|
||||
_setContent(text) {
|
||||
if (this._cmEditor) { this._cmEditor.setValue(text); return; }
|
||||
|
||||
// Lazy-init CM6
|
||||
if (this.contentContainerEl && window.CM?.noteEditor) {
|
||||
this.contentContainerEl.innerHTML = '';
|
||||
this._cmEditor = CM.noteEditor(this.contentContainerEl, {
|
||||
value: text,
|
||||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
onLink: (title) => this._navigateToLink(title),
|
||||
linkCompleter: async (query) => {
|
||||
if (!query || query.length < 1) return [];
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(query, 8);
|
||||
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
|
||||
} catch { return []; }
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Textarea fallback
|
||||
const ta = this.contentContainerEl?.querySelector('textarea');
|
||||
if (ta) ta.value = text;
|
||||
},
|
||||
|
||||
_destroyCmEditor() {
|
||||
if (this._cmEditor?.destroy) this._cmEditor.destroy();
|
||||
this._cmEditor = null;
|
||||
},
|
||||
|
||||
async _navigateToLink(title) {
|
||||
try {
|
||||
const resp = await API.searchNotes(title);
|
||||
const notes = resp.data || [];
|
||||
const match = notes.find(n => n.title?.toLowerCase() === title.toLowerCase());
|
||||
if (match) {
|
||||
this.openNote(match.id);
|
||||
} else {
|
||||
// Create new note with this title
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
if (this.titleEl) this.titleEl.value = title;
|
||||
this._showEditMode();
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
// ── Field Helpers ───────────────────
|
||||
|
||||
_populateFields(note) {
|
||||
if (this.titleEl) this.titleEl.value = note.title || '';
|
||||
if (this.folderEl) this.folderEl.value = note.folder_path || '';
|
||||
if (this.tagsEl) this.tagsEl.value = (note.tags || []).join(', ');
|
||||
this._setContent(note.content || '');
|
||||
},
|
||||
|
||||
_clearFields() {
|
||||
if (this.titleEl) this.titleEl.value = '';
|
||||
if (this.folderEl) this.folderEl.value = '';
|
||||
if (this.tagsEl) this.tagsEl.value = '';
|
||||
this._setContent('');
|
||||
},
|
||||
|
||||
_setBtn(suffix, show) {
|
||||
const el = document.getElementById(pfx + suffix);
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
// ── Event Wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
const $ = (id) => document.getElementById(pfx + id);
|
||||
const self = this;
|
||||
|
||||
// Toolbar
|
||||
this._on($('NotesNewBtn'), 'click', () => self.openNote(null));
|
||||
this._on($('NotesTodayBtn'), 'click', () => self._openDailyNote());
|
||||
this._on($('NotesGraphBtn'), 'click', () => {
|
||||
if (self.onOpenGraph) self.onOpenGraph();
|
||||
});
|
||||
|
||||
// Editor header
|
||||
this._on($('NotesBackBtn'), 'click', () => { self.showList(); self.loadNotes(); self.loadFolders(); });
|
||||
this._on($('NoteSaveBtn'), 'click', () => self.saveNote());
|
||||
this._on($('NoteDeleteBtn'), 'click', () => self.deleteNote());
|
||||
this._on($('NoteEditBtn'), 'click', () => self._showEditMode());
|
||||
this._on($('NotePreviewBtn'), 'click', () => self._showPreview());
|
||||
this._on($('NoteCancelEditBtn'), 'click', () => {
|
||||
if (self._currentNote) { self._populateFields(self._currentNote); self._showReadMode(); }
|
||||
else self.showList();
|
||||
});
|
||||
|
||||
// Filters
|
||||
this._on(this.folderFilterEl, 'change', () => {
|
||||
if (self.searchEl) self.searchEl.value = '';
|
||||
self.loadNotes(self.folderFilterEl.value);
|
||||
});
|
||||
this._on(this.sortEl, 'change', () => {
|
||||
self._sort = self.sortEl.value;
|
||||
self.loadNotes();
|
||||
});
|
||||
|
||||
// Search with debounce
|
||||
let timer;
|
||||
this._on(this.searchEl, 'input', () => {
|
||||
clearTimeout(timer);
|
||||
const q = self.searchEl.value.trim();
|
||||
timer = setTimeout(() => {
|
||||
if (q.length >= 2) {
|
||||
if (self.folderFilterEl) self.folderFilterEl.value = '';
|
||||
self.loadNotes(null, q);
|
||||
} else if (q.length === 0) {
|
||||
self.loadNotes();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Daily Note ──────────────────────
|
||||
|
||||
async _openDailyNote() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const resp = await API.searchNotes(today);
|
||||
const notes = resp.data || [];
|
||||
const daily = notes.find(n => n.title === today || n.title?.startsWith(today));
|
||||
if (daily) {
|
||||
this.openNote(daily.id);
|
||||
} else {
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
if (this.titleEl) this.titleEl.value = today;
|
||||
this._setContent('# ' + today + '\n\n');
|
||||
this.showEditor();
|
||||
this._showEditMode();
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to open daily note: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._destroyCmEditor();
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
NoteEditor._instances.delete(this.id);
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
NoteEditor._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
function _neEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
554
src/js/pane-container.js
Normal file
554
src/js/pane-container.js
Normal file
@@ -0,0 +1,554 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — PaneContainer
|
||||
// ==========================================
|
||||
// v0.25.0: Composable multi-pane layout system.
|
||||
// Replaces the "one surface fills viewport" model with resizable
|
||||
// side-by-side panes. Supports leaf panes (single component) and
|
||||
// tabbed panes (N components, tab bar, one active at a time).
|
||||
//
|
||||
// Layout presets define the pane structure. The surface manifest
|
||||
// declares which preset to use via __MANIFEST__.layout.
|
||||
//
|
||||
// Usage:
|
||||
// const layout = PaneContainer.mount(
|
||||
// document.getElementById('editorBody'),
|
||||
// 'editor',
|
||||
// { workspaceId: 'ws-123' }
|
||||
// );
|
||||
// const fileTree = layout.getPane('files');
|
||||
// layout.resize('files', 250);
|
||||
// layout.destroy();
|
||||
|
||||
const PaneContainer = {
|
||||
_presets: {},
|
||||
_active: null,
|
||||
|
||||
// ── Layout Preset Registration ──────────
|
||||
|
||||
/**
|
||||
* Register a named layout preset.
|
||||
* @param {string} name - Preset name (e.g. 'single', 'editor', 'split')
|
||||
* @param {object} definition - Layout tree definition
|
||||
*
|
||||
* Definition format:
|
||||
* { type: 'leaf', id: 'main', component: 'chat-pane', opts: {} }
|
||||
* { type: 'tabbed', id: 'right', tabs: [{id,label,component,opts}], defaultTab: 0 }
|
||||
* { type: 'split', direction: 'horizontal', children: [def, def, ...], sizes: [220, null, 380] }
|
||||
*
|
||||
* sizes: array matching children. Numbers are initial px widths. null = flex:1 (fills remaining).
|
||||
*/
|
||||
registerPreset(name, definition) {
|
||||
this._presets[name] = definition;
|
||||
},
|
||||
|
||||
// ── Mount ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Mount a layout preset into a root element.
|
||||
* @param {HTMLElement} rootEl - Container element
|
||||
* @param {string} presetName - Registered preset name
|
||||
* @param {object} opts - Passed to component factories (e.g. { workspaceId })
|
||||
* @returns {object} Layout instance
|
||||
*/
|
||||
mount(rootEl, presetName, opts) {
|
||||
if (!rootEl) { console.error('[PaneContainer] No root element'); return null; }
|
||||
|
||||
const preset = this._presets[presetName];
|
||||
if (!preset) { console.error('[PaneContainer] Unknown preset:', presetName); return null; }
|
||||
|
||||
opts = opts || {};
|
||||
const surfaceId = window.__SURFACE__ || 'unknown';
|
||||
|
||||
// Build the DOM tree from the preset definition
|
||||
const panes = new Map(); // id → { el, component, type }
|
||||
const handles = [];
|
||||
|
||||
const buildNode = (def, parentEl) => {
|
||||
if (def.type === 'leaf') {
|
||||
return _buildLeafPane(def, parentEl, panes, opts);
|
||||
}
|
||||
if (def.type === 'tabbed') {
|
||||
return _buildTabbedPane(def, parentEl, panes, opts, surfaceId);
|
||||
}
|
||||
if (def.type === 'split') {
|
||||
return _buildSplit(def, parentEl, panes, handles, opts, surfaceId);
|
||||
}
|
||||
console.error('[PaneContainer] Unknown node type:', def.type);
|
||||
return null;
|
||||
};
|
||||
|
||||
rootEl.classList.add('pane-container');
|
||||
const rootNode = buildNode(preset, rootEl);
|
||||
|
||||
// Restore persisted sizes
|
||||
_restoreSizes(surfaceId, presetName, handles);
|
||||
|
||||
const instance = {
|
||||
rootEl,
|
||||
presetName,
|
||||
_panes: panes,
|
||||
_handles: handles,
|
||||
_rootNode: rootNode,
|
||||
|
||||
/** Get the component instance for a pane by ID. */
|
||||
getPane(id) {
|
||||
const p = panes.get(id);
|
||||
return p ? p.component : null;
|
||||
},
|
||||
|
||||
/** Get the pane element by ID. */
|
||||
getPaneEl(id) {
|
||||
const p = panes.get(id);
|
||||
return p ? p.el : null;
|
||||
},
|
||||
|
||||
/** Programmatic resize of a leaf pane. */
|
||||
resize(id, sizePx) {
|
||||
const p = panes.get(id);
|
||||
if (p?.el) {
|
||||
p.el.style.flexBasis = sizePx + 'px';
|
||||
p.el.style.flexGrow = '0';
|
||||
p.el.style.flexShrink = '0';
|
||||
}
|
||||
},
|
||||
|
||||
/** Minimize a pane (collapse to 0). */
|
||||
minimize(id) {
|
||||
const p = panes.get(id);
|
||||
if (!p?.el) return;
|
||||
p._prevBasis = p.el.style.flexBasis;
|
||||
p._prevGrow = p.el.style.flexGrow;
|
||||
p.el.style.flexBasis = '0px';
|
||||
p.el.style.flexGrow = '0';
|
||||
p.el.style.overflow = 'hidden';
|
||||
p.el.classList.add('pane-minimized');
|
||||
},
|
||||
|
||||
/** Restore a minimized pane. */
|
||||
restore(id) {
|
||||
const p = panes.get(id);
|
||||
if (!p?.el) return;
|
||||
p.el.style.flexBasis = p._prevBasis || '';
|
||||
p.el.style.flexGrow = p._prevGrow || '';
|
||||
p.el.style.overflow = '';
|
||||
p.el.classList.remove('pane-minimized');
|
||||
},
|
||||
|
||||
/** Tear down the entire layout. */
|
||||
destroy() {
|
||||
// Destroy all component instances
|
||||
for (const [, p] of panes) {
|
||||
if (p.component?.destroy) p.component.destroy();
|
||||
// Tabbed: destroy all tab components
|
||||
if (p.tabs) {
|
||||
for (const tab of p.tabs) {
|
||||
if (tab.component?.destroy) tab.component.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
panes.clear();
|
||||
handles.length = 0;
|
||||
rootEl.innerHTML = '';
|
||||
rootEl.classList.remove('pane-container');
|
||||
PaneContainer._active = null;
|
||||
},
|
||||
};
|
||||
|
||||
PaneContainer._active = instance;
|
||||
return instance;
|
||||
},
|
||||
|
||||
/** Get the currently active layout instance. */
|
||||
active() { return this._active; },
|
||||
};
|
||||
|
||||
// ── Leaf Pane Builder ───────────────────────
|
||||
|
||||
function _buildLeafPane(def, parentEl, panes, opts) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pane pane-leaf';
|
||||
el.dataset.paneId = def.id;
|
||||
|
||||
if (def.size) {
|
||||
el.style.flexBasis = def.size + 'px';
|
||||
el.style.flexGrow = '0';
|
||||
el.style.flexShrink = '0';
|
||||
} else {
|
||||
el.style.flex = '1';
|
||||
el.style.minWidth = '0';
|
||||
el.dataset.paneFlex = '1'; // Mark as flexible — drag handles skip this pane
|
||||
}
|
||||
if (def.minSize) el.style.minWidth = def.minSize + 'px';
|
||||
|
||||
parentEl.appendChild(el);
|
||||
|
||||
// Component instantiation is deferred — the surface boot script
|
||||
// creates components and mounts them into pane elements.
|
||||
// We just register the pane slot here.
|
||||
panes.set(def.id, {
|
||||
el,
|
||||
component: null, // set by surface boot script
|
||||
type: 'leaf',
|
||||
def,
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Tabbed Pane Builder ─────────────────────
|
||||
|
||||
function _buildTabbedPane(def, parentEl, panes, opts, surfaceId) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pane pane-tabbed';
|
||||
el.dataset.paneId = def.id;
|
||||
|
||||
if (def.size) {
|
||||
el.style.flexBasis = def.size + 'px';
|
||||
el.style.flexGrow = '0';
|
||||
el.style.flexShrink = '0';
|
||||
} else {
|
||||
el.style.flex = '1';
|
||||
el.style.minWidth = '0';
|
||||
}
|
||||
if (def.minSize) el.style.minWidth = def.minSize + 'px';
|
||||
|
||||
// Tab bar
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'pane-tab-bar';
|
||||
el.appendChild(tabBar);
|
||||
|
||||
// Content area
|
||||
const contentArea = document.createElement('div');
|
||||
contentArea.className = 'pane-tab-content';
|
||||
el.appendChild(contentArea);
|
||||
|
||||
// Restore persisted active tab
|
||||
const persistKey = 'sb_tabs_' + surfaceId + '_' + def.id;
|
||||
let savedTab = 0;
|
||||
try { savedTab = parseInt(localStorage.getItem(persistKey)) || 0; } catch (_) {}
|
||||
if (savedTab >= (def.tabs || []).length) savedTab = 0;
|
||||
|
||||
const tabs = (def.tabs || []).map((tabDef, i) => {
|
||||
// Tab button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'pane-tab-btn' + (i === savedTab ? ' pane-tab-btn--active' : '');
|
||||
btn.textContent = tabDef.label || tabDef.id;
|
||||
btn.dataset.tabIndex = i;
|
||||
tabBar.appendChild(btn);
|
||||
|
||||
// Tab panel (mount point for component)
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'pane-tab-panel';
|
||||
panel.dataset.tabId = tabDef.id;
|
||||
panel.style.display = i === savedTab ? '' : 'none';
|
||||
contentArea.appendChild(panel);
|
||||
|
||||
return {
|
||||
id: tabDef.id,
|
||||
label: tabDef.label,
|
||||
component: tabDef.component,
|
||||
opts: tabDef.opts || {},
|
||||
btn,
|
||||
panel,
|
||||
instance: null, // lazy-created on first activation
|
||||
_activated: i === savedTab,
|
||||
};
|
||||
});
|
||||
|
||||
// Tab click handler
|
||||
const activateTab = (index) => {
|
||||
tabs.forEach((tab, i) => {
|
||||
const isActive = i === index;
|
||||
tab.btn.classList.toggle('pane-tab-btn--active', isActive);
|
||||
tab.panel.style.display = isActive ? '' : 'none';
|
||||
if (isActive) tab._activated = true;
|
||||
});
|
||||
// Persist
|
||||
try { localStorage.setItem(persistKey, String(index)); } catch (_) {}
|
||||
};
|
||||
|
||||
tabs.forEach((tab, i) => {
|
||||
tab.btn.addEventListener('click', () => activateTab(i));
|
||||
});
|
||||
|
||||
parentEl.appendChild(el);
|
||||
|
||||
panes.set(def.id, {
|
||||
el,
|
||||
component: null, // tabbed panes don't have a single component
|
||||
type: 'tabbed',
|
||||
def,
|
||||
tabs,
|
||||
activateTab,
|
||||
getActiveTab() {
|
||||
return tabs.find((_, i) => tabs[i].btn.classList.contains('pane-tab-btn--active')) || tabs[0];
|
||||
},
|
||||
getTabPanel(tabId) {
|
||||
const t = tabs.find(t => t.id === tabId);
|
||||
return t ? t.panel : null;
|
||||
},
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Split Builder ───────────────────────────
|
||||
|
||||
function _buildSplit(def, parentEl, panes, handles, opts, surfaceId) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pane-split pane-split--' + (def.direction || 'horizontal');
|
||||
parentEl.appendChild(el);
|
||||
|
||||
const children = def.children || [];
|
||||
const sizes = def.sizes || [];
|
||||
|
||||
children.forEach((childDef, i) => {
|
||||
// Apply initial size from preset
|
||||
if (sizes[i] != null && childDef.size === undefined) {
|
||||
childDef.size = sizes[i];
|
||||
}
|
||||
|
||||
// Build child
|
||||
if (childDef.type === 'leaf') {
|
||||
_buildLeafPane(childDef, el, panes, opts);
|
||||
} else if (childDef.type === 'tabbed') {
|
||||
_buildTabbedPane(childDef, el, panes, opts, surfaceId);
|
||||
} else if (childDef.type === 'split') {
|
||||
_buildSplit(childDef, el, panes, handles, opts, surfaceId);
|
||||
}
|
||||
|
||||
// Add drag handle between children (not after last)
|
||||
if (i < children.length - 1) {
|
||||
const handle = _createHandle(def.direction || 'horizontal', el, i, surfaceId, def.id || 'root');
|
||||
handles.push(handle);
|
||||
}
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Drag Handle ─────────────────────────────
|
||||
|
||||
function _createHandle(direction, splitEl, index, surfaceId, splitId) {
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'pane-handle pane-handle--' + direction;
|
||||
handle.dataset.handleIndex = index;
|
||||
|
||||
// Insert after the Nth child (child, handle, child, handle, child)
|
||||
// Children are at positions 0, 2, 4, ... and handles at 1, 3, ...
|
||||
// But since we add sequentially, handle goes after the (index)th pane
|
||||
const childEls = splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split');
|
||||
const afterEl = childEls[index];
|
||||
if (afterEl?.nextSibling) {
|
||||
splitEl.insertBefore(handle, afterEl.nextSibling);
|
||||
} else {
|
||||
splitEl.appendChild(handle);
|
||||
}
|
||||
|
||||
// Drag logic
|
||||
let startX, startY, leftEl, rightEl, startLeftBasis, startRightBasis;
|
||||
|
||||
const onMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
const allPanes = Array.from(splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split'));
|
||||
// Find the two panes adjacent to this handle
|
||||
const handleIdx = Array.from(splitEl.children).indexOf(handle);
|
||||
leftEl = splitEl.children[handleIdx - 1];
|
||||
rightEl = splitEl.children[handleIdx + 1];
|
||||
if (!leftEl || !rightEl) return;
|
||||
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
// Use current rendered size (matches what flex layout computed)
|
||||
startLeftBasis = leftEl.getBoundingClientRect().width;
|
||||
startRightBasis = rightEl.getBoundingClientRect().width;
|
||||
|
||||
// Immediately lock the non-flex panes to their current sizes
|
||||
// so the first move delta doesn't cause a visual snap
|
||||
if (leftEl.dataset.paneFlex !== '1') {
|
||||
leftEl.style.flexBasis = startLeftBasis + 'px';
|
||||
leftEl.style.flexGrow = '0';
|
||||
leftEl.style.flexShrink = '0';
|
||||
}
|
||||
if (rightEl.dataset.paneFlex !== '1') {
|
||||
rightEl.style.flexBasis = startRightBasis + 'px';
|
||||
rightEl.style.flexGrow = '0';
|
||||
rightEl.style.flexShrink = '0';
|
||||
}
|
||||
|
||||
handle.classList.add('pane-handle--active');
|
||||
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const delta = direction === 'horizontal'
|
||||
? e.clientX - startX
|
||||
: e.clientY - startY;
|
||||
|
||||
const leftIsFlex = leftEl.dataset.paneFlex === '1';
|
||||
const rightIsFlex = rightEl.dataset.paneFlex === '1';
|
||||
// Cap: no single fixed pane should exceed 60% of container
|
||||
const maxSize = splitEl.getBoundingClientRect().width * 0.6;
|
||||
|
||||
if (leftIsFlex) {
|
||||
// Only adjust the right (fixed) pane; left flex absorbs remainder
|
||||
const newRight = Math.min(maxSize, Math.max(50, startRightBasis - delta));
|
||||
rightEl.style.flexBasis = newRight + 'px';
|
||||
rightEl.style.flexGrow = '0';
|
||||
rightEl.style.flexShrink = '0';
|
||||
} else if (rightIsFlex) {
|
||||
// Only adjust the left (fixed) pane; right flex absorbs remainder
|
||||
const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta));
|
||||
leftEl.style.flexBasis = newLeft + 'px';
|
||||
leftEl.style.flexGrow = '0';
|
||||
leftEl.style.flexShrink = '0';
|
||||
} else {
|
||||
// Both fixed — adjust both (original behavior)
|
||||
const newLeft = Math.max(50, startLeftBasis + delta);
|
||||
const newRight = Math.max(50, startRightBasis - delta);
|
||||
leftEl.style.flexBasis = newLeft + 'px';
|
||||
leftEl.style.flexGrow = '0';
|
||||
leftEl.style.flexShrink = '0';
|
||||
rightEl.style.flexBasis = newRight + 'px';
|
||||
rightEl.style.flexGrow = '0';
|
||||
rightEl.style.flexShrink = '0';
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
handle.classList.remove('pane-handle--active');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Persist sizes
|
||||
_persistSizes(surfaceId, splitEl);
|
||||
};
|
||||
|
||||
handle.addEventListener('mousedown', onMouseDown);
|
||||
|
||||
// Double-click resets to default
|
||||
handle.addEventListener('dblclick', () => {
|
||||
if (leftEl) {
|
||||
if (leftEl.dataset.paneFlex === '1') {
|
||||
leftEl.style.flex = '1'; leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = '';
|
||||
} else {
|
||||
leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = '';
|
||||
}
|
||||
}
|
||||
if (rightEl) {
|
||||
if (rightEl.dataset.paneFlex === '1') {
|
||||
rightEl.style.flex = '1'; rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = '';
|
||||
} else {
|
||||
rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = '';
|
||||
}
|
||||
}
|
||||
_persistSizes(surfaceId, splitEl);
|
||||
});
|
||||
|
||||
return { handle, splitEl, index, direction };
|
||||
}
|
||||
|
||||
// ── Persistence ─────────────────────────────
|
||||
|
||||
function _persistSizes(surfaceId, splitEl) {
|
||||
const key = 'sb_layout_' + surfaceId;
|
||||
try {
|
||||
// Collect sizes for fixed panes only (skip flex panes)
|
||||
const sizes = {};
|
||||
splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split').forEach(child => {
|
||||
const id = child.dataset?.paneId;
|
||||
if (id && child.dataset.paneFlex !== '1') {
|
||||
sizes[id] = child.getBoundingClientRect().width;
|
||||
}
|
||||
});
|
||||
// Merge with existing persisted data
|
||||
let existing = {};
|
||||
try { existing = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
|
||||
Object.assign(existing, sizes);
|
||||
localStorage.setItem(key, JSON.stringify(existing));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _restoreSizes(surfaceId, presetName, handles) {
|
||||
const key = 'sb_layout_' + surfaceId;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
if (Object.keys(saved).length === 0) return;
|
||||
|
||||
// Find the split container to measure available width
|
||||
const splitEl = document.querySelector('.pane-split--horizontal');
|
||||
const containerWidth = splitEl ? splitEl.getBoundingClientRect().width : window.innerWidth;
|
||||
|
||||
// Validate: sum of all fixed pane sizes must leave at least 150px for flex pane
|
||||
const fixedPanes = document.querySelectorAll('.pane[data-pane-id]');
|
||||
let totalFixed = 0;
|
||||
fixedPanes.forEach(el => {
|
||||
const id = el.dataset.paneId;
|
||||
if (saved[id] && el.dataset.paneFlex !== '1') {
|
||||
totalFixed += saved[id];
|
||||
}
|
||||
});
|
||||
|
||||
// If saved sizes overflow, clear corrupt data and use defaults
|
||||
if (totalFixed > containerWidth - 150) {
|
||||
console.warn('[PaneContainer] Saved layout overflows viewport (' + totalFixed + 'px > ' + containerWidth + 'px), resetting');
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply saved sizes to pane elements (skip flex panes)
|
||||
fixedPanes.forEach(el => {
|
||||
const id = el.dataset.paneId;
|
||||
if (saved[id] && el.dataset.paneFlex !== '1') {
|
||||
el.style.flexBasis = saved[id] + 'px';
|
||||
el.style.flexGrow = '0';
|
||||
el.style.flexShrink = '0';
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Built-in Presets ────────────────────────
|
||||
|
||||
// single: One leaf pane (default chat UX)
|
||||
PaneContainer.registerPreset('single', {
|
||||
type: 'leaf',
|
||||
id: 'main',
|
||||
component: 'chat-pane',
|
||||
});
|
||||
|
||||
// editor: files | code-editor | <chat, notes>
|
||||
PaneContainer.registerPreset('editor', {
|
||||
type: 'split',
|
||||
id: 'root',
|
||||
direction: 'horizontal',
|
||||
sizes: [220, null, 380],
|
||||
children: [
|
||||
{ type: 'leaf', id: 'files', component: 'file-tree', size: 220, minSize: 140 },
|
||||
{ type: 'leaf', id: 'editor', component: 'code-editor' },
|
||||
{
|
||||
type: 'tabbed', id: 'assist', size: 380, minSize: 200,
|
||||
tabs: [
|
||||
{ id: 'chat', label: 'Chat', component: 'chat-pane' },
|
||||
{ id: 'notes', label: 'Notes', component: 'note-editor' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// split: generic two-pane layout
|
||||
PaneContainer.registerPreset('split', {
|
||||
type: 'split',
|
||||
id: 'root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ type: 'leaf', id: 'primary', component: null },
|
||||
{ type: 'leaf', id: 'secondary', component: null },
|
||||
],
|
||||
});
|
||||
@@ -9,7 +9,7 @@ const ADMIN_SECTIONS = {
|
||||
people: ['users', 'teams', 'groups'],
|
||||
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||
routing: ['health', 'routing', 'capabilities'],
|
||||
system: ['settings', 'storage', 'extensions'],
|
||||
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
|
||||
monitoring: ['usage', 'audit', 'stats'],
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ const ADMIN_LABELS = {
|
||||
users: 'Users', teams: 'Teams', groups: 'Groups',
|
||||
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
|
||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces',
|
||||
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ const ADMIN_LOADERS = {
|
||||
settings: () => UI.loadAdminSettings(),
|
||||
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
|
||||
extensions: () => UI.loadAdminExtensions(),
|
||||
surfaces: () => typeof _loadAdminSurfaces === 'function' ? _loadAdminSurfaces() : null,
|
||||
health: () => UI.loadAdminHealth(),
|
||||
routing: () => UI.loadAdminRouting(),
|
||||
capabilities: () => UI.loadAdminCapabilities(),
|
||||
|
||||
@@ -78,6 +78,11 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
<textarea id="${pfx}_memoryExtractionPrompt" rows="2" placeholder="Override the default extraction prompt for this persona..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group tool-grants-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
|
||||
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_toolsAll" checked> All tools (inherit from context)</label>
|
||||
<p class="form-hint" style="margin:2px 0 6px">When unchecked, restrict this persona to only the selected tools below.</p>
|
||||
<div id="${pfx}_toolsList" class="tool-grants-list" style="display:none;max-height:200px;overflow-y:auto;padding:4px 0"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
|
||||
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
|
||||
@@ -127,6 +132,18 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
if (options.onSubmit) options.onSubmit(form.getValues());
|
||||
});
|
||||
|
||||
// v0.25.0: Tool grants — toggle "All tools" checkbox shows/hides tool list
|
||||
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
|
||||
const toolsListEl = document.getElementById(`${pfx}_toolsList`);
|
||||
if (toolsAllCb && toolsListEl) {
|
||||
toolsAllCb.addEventListener('change', () => {
|
||||
toolsListEl.style.display = toolsAllCb.checked ? 'none' : '';
|
||||
if (!toolsAllCb.checked && toolsListEl.children.length === 0) {
|
||||
form.loadToolList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const form = {
|
||||
getValues() {
|
||||
const v = {
|
||||
@@ -152,6 +169,14 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
if (memCb) v.memory_enabled = memCb.checked;
|
||||
const memPrompt = document.getElementById(`${pfx}_memoryExtractionPrompt`)?.value?.trim();
|
||||
if (memPrompt) v.memory_extraction_prompt = memPrompt;
|
||||
// v0.25.0: Tool grants
|
||||
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
|
||||
if (toolsAllCb && !toolsAllCb.checked) {
|
||||
const checked = document.querySelectorAll(`#${pfx}_toolsList input[type="checkbox"]:checked`);
|
||||
v.tool_grants = Array.from(checked).map(cb => cb.value);
|
||||
} else {
|
||||
v.tool_grants = []; // empty = inherit all
|
||||
}
|
||||
return v;
|
||||
},
|
||||
setValues(p) {
|
||||
@@ -168,6 +193,8 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
// Memory fields (v0.18.0)
|
||||
if (el('memoryEnabled')) el('memoryEnabled').checked = p.memory_enabled !== false;
|
||||
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
|
||||
// v0.25.0: Tool grants — load and apply
|
||||
if (p.id) form.loadToolGrants(p.id);
|
||||
},
|
||||
clearForm() {
|
||||
['name','handle','desc','prompt','temp','maxTokens'].forEach(f => {
|
||||
@@ -209,6 +236,63 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
},
|
||||
getModelSelect() { return document.getElementById(`${pfx}_model`); },
|
||||
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
|
||||
|
||||
// v0.25.0: Tool grants management
|
||||
async loadToolList() {
|
||||
const listEl = document.getElementById(`${pfx}_toolsList`);
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const resp = await API._get('/api/v1/tools');
|
||||
const tools = resp.tools || resp.data || resp || [];
|
||||
listEl.innerHTML = '';
|
||||
// Group by category
|
||||
const groups = {};
|
||||
tools.forEach(t => {
|
||||
const cat = t.category || 'other';
|
||||
(groups[cat] = groups[cat] || []).push(t);
|
||||
});
|
||||
Object.keys(groups).sort().forEach(cat => {
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'tool-grants-category';
|
||||
hdr.textContent = cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||
listEl.appendChild(hdr);
|
||||
groups[cat].forEach(t => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'tool-grants-item';
|
||||
label.innerHTML = `<input type="checkbox" value="${t.name}"> ${t.display_name || t.name}`;
|
||||
listEl.appendChild(label);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="form-hint">Failed to load tools</div>';
|
||||
}
|
||||
},
|
||||
|
||||
async loadToolGrants(personaId) {
|
||||
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
|
||||
const listEl = document.getElementById(`${pfx}_toolsList`);
|
||||
if (!toolsAllCb || !listEl) return;
|
||||
try {
|
||||
const resp = await API._get(`/api/v1/personas/${personaId}/tool-grants`);
|
||||
const grants = resp.data || [];
|
||||
if (grants.length === 0) {
|
||||
toolsAllCb.checked = true;
|
||||
listEl.style.display = 'none';
|
||||
} else {
|
||||
toolsAllCb.checked = false;
|
||||
listEl.style.display = '';
|
||||
await form.loadToolList();
|
||||
const grantSet = new Set(grants);
|
||||
listEl.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.checked = grantSet.has(cb.value);
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Endpoint may not exist yet — default to all tools
|
||||
toolsAllCb.checked = true;
|
||||
listEl.style.display = 'none';
|
||||
}
|
||||
},
|
||||
};
|
||||
return form;
|
||||
}
|
||||
@@ -247,8 +331,10 @@ const UI = {
|
||||
},
|
||||
|
||||
restoreSidebar() {
|
||||
const el = document.getElementById('sidebar');
|
||||
if (!el) return; // not all surfaces have a sidebar (e.g. editor)
|
||||
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
el.classList.add('collapsed');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -279,7 +365,8 @@ const UI = {
|
||||
},
|
||||
|
||||
closeUserMenu() {
|
||||
document.getElementById('userFlyout').classList.remove('open');
|
||||
const el = document.getElementById('userFlyout');
|
||||
if (el) el.classList.remove('open');
|
||||
},
|
||||
|
||||
// ── Sidebar Section Collapse ──────────────
|
||||
@@ -1191,6 +1278,7 @@ const UI = {
|
||||
const letter = (name[0] || '?').toUpperCase();
|
||||
const avatarEl = document.getElementById('userAvatar');
|
||||
const letterEl = document.getElementById('avatarLetter');
|
||||
if (!avatarEl || !letterEl) return; // not all surfaces have user menu DOM
|
||||
// Show avatar image or initial letter
|
||||
let existingImg = avatarEl.querySelector('.user-avatar-img');
|
||||
if (API.user?.avatar) {
|
||||
@@ -1214,11 +1302,13 @@ const UI = {
|
||||
letterEl.style.display = '';
|
||||
letterEl.textContent = letter;
|
||||
}
|
||||
document.getElementById('userName').textContent = name;
|
||||
const nameEl = document.getElementById('userName');
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
},
|
||||
|
||||
showAdminButton(show) {
|
||||
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
|
||||
const adminEl = document.getElementById('menuAdmin');
|
||||
if (adminEl) adminEl.style.display = show ? '' : 'none';
|
||||
var dbg = document.getElementById('menuDebug');
|
||||
if (dbg) dbg.style.display = show ? '' : 'none';
|
||||
},
|
||||
@@ -1402,5 +1492,43 @@ const UI = {
|
||||
const el = document.getElementById('chatMessages');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Appearance (scale + font) ────────────
|
||||
// Shared across ALL surfaces. Previously in ui-settings.js
|
||||
// which only loaded on chat/settings — editor surface was skipped.
|
||||
|
||||
applyAppearance(scale, msgFont) {
|
||||
const z = scale === 100 ? '' : scale / 100;
|
||||
// Zoom content areas — covers both chat and editor surfaces
|
||||
document.querySelectorAll(
|
||||
'.sidebar, .workspace-primary, .workspace-secondary, ' +
|
||||
'.modal-overlay, .admin-panel, ' +
|
||||
'.surface-editor, .surface-notes'
|
||||
).forEach(el => el.style.zoom = z);
|
||||
const splash = document.getElementById('splashGate');
|
||||
if (splash) splash.style.zoom = z;
|
||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
||||
// Recheck tab overflow after layout settles
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => {
|
||||
if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
restoreAppearance() {
|
||||
try {
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
const scale = prefs.scale || 100;
|
||||
const msgFont = prefs.msgFont || 14;
|
||||
if (scale !== 100 || msgFont !== 14) {
|
||||
UI.applyAppearance(scale, msgFont);
|
||||
} else {
|
||||
// Still set the CSS variable for default
|
||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||
}
|
||||
} catch (_) { /* corrupt localStorage — ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,19 +256,7 @@ Object.assign(UI, {
|
||||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
},
|
||||
|
||||
applyAppearance(scale, msgFont) {
|
||||
const z = scale === 100 ? '' : scale / 100;
|
||||
// Zoom content areas + modals + panels (but NOT .app or banners)
|
||||
document.querySelectorAll('.sidebar, .workspace-primary, .workspace-secondary, .modal-overlay, .admin-panel').forEach(el => el.style.zoom = z);
|
||||
const splash = document.getElementById('splashGate');
|
||||
if (splash) splash.style.zoom = z;
|
||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
||||
// Recheck tab overflow after layout settles with new zoom
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => { if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t); });
|
||||
});
|
||||
},
|
||||
// applyAppearance — moved to ui-core.js (v0.25.0-cs11.1) so all surfaces can use it.
|
||||
|
||||
async checkUserProvidersAllowed() {
|
||||
const notice = document.getElementById('userProvidersDisabled');
|
||||
|
||||
154
src/js/user-menu.js
Normal file
154
src/js/user-menu.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — UserMenu Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from chat.html + ui-core.js.
|
||||
// Reusable across all authenticated surfaces.
|
||||
// Pattern: Go template partial (user-menu.html) + JS factory (this file).
|
||||
//
|
||||
// Usage:
|
||||
// const menu = UserMenu.create({ id: 'global' });
|
||||
// menu.setUser({ display_name: 'Jeff', username: 'jeff', avatar: null });
|
||||
// menu.showAdmin(true);
|
||||
// menu.destroy();
|
||||
|
||||
const UserMenu = {
|
||||
primary: null,
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || '';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
btnEl: document.getElementById(pfx + 'userMenuBtn'),
|
||||
avatarEl: document.getElementById(pfx + 'userAvatar'),
|
||||
letterEl: document.getElementById(pfx + 'avatarLetter'),
|
||||
nameEl: document.getElementById(pfx + 'userName'),
|
||||
flyoutEl: document.getElementById(pfx + 'userFlyout'),
|
||||
settingsEl: document.getElementById(pfx + 'menuSettings'),
|
||||
adminEl: document.getElementById(pfx + 'menuAdmin'),
|
||||
teamAdminEl: document.getElementById(pfx + 'menuTeamAdmin'),
|
||||
debugEl: document.getElementById(pfx + 'menuDebug'),
|
||||
signoutEl: document.getElementById(pfx + 'menuSignout'),
|
||||
_open: false,
|
||||
_listeners: [],
|
||||
|
||||
// ── User display ────────────────────
|
||||
|
||||
setUser(user) {
|
||||
if (!user) return;
|
||||
const name = user.display_name || user.username || 'User';
|
||||
const letter = (name[0] || '?').toUpperCase();
|
||||
|
||||
// Avatar image or initial letter
|
||||
if (this.avatarEl) {
|
||||
let img = this.avatarEl.querySelector('.user-avatar-img');
|
||||
if (user.avatar) {
|
||||
if (this.letterEl) this.letterEl.style.display = 'none';
|
||||
if (img) {
|
||||
img.src = user.avatar;
|
||||
} else {
|
||||
img = document.createElement('img');
|
||||
img.src = user.avatar;
|
||||
img.alt = '';
|
||||
img.className = 'user-avatar-img';
|
||||
img.onerror = function() {
|
||||
this.remove();
|
||||
if (instance.letterEl) {
|
||||
instance.letterEl.style.display = '';
|
||||
instance.letterEl.textContent = letter;
|
||||
}
|
||||
};
|
||||
this.avatarEl.insertBefore(img, this.letterEl);
|
||||
}
|
||||
} else {
|
||||
if (img) img.remove();
|
||||
if (this.letterEl) {
|
||||
this.letterEl.style.display = '';
|
||||
this.letterEl.textContent = letter;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.nameEl) this.nameEl.textContent = name;
|
||||
},
|
||||
|
||||
// ── Visibility controls ─────────────
|
||||
|
||||
showAdmin(show) {
|
||||
if (this.adminEl) this.adminEl.style.display = show ? '' : 'none';
|
||||
if (this.debugEl) this.debugEl.style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
showTeamAdmin(show) {
|
||||
if (this.teamAdminEl) this.teamAdminEl.style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
// ── Flyout toggle ───────────────────
|
||||
|
||||
open() {
|
||||
if (this.flyoutEl) this.flyoutEl.classList.add('open');
|
||||
this._open = true;
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.flyoutEl) this.flyoutEl.classList.remove('open');
|
||||
this._open = false;
|
||||
},
|
||||
|
||||
toggle() {
|
||||
if (this._open) this.close(); else this.open();
|
||||
},
|
||||
|
||||
// ── Event wiring ────────────────────
|
||||
|
||||
/**
|
||||
* Wire event handlers. Accepts callbacks for menu actions.
|
||||
* Call once after create().
|
||||
*/
|
||||
bind(handlers) {
|
||||
handlers = handlers || {};
|
||||
|
||||
// Toggle flyout on avatar click
|
||||
this._on(this.btnEl, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
this._on(document, 'click', (e) => {
|
||||
if (this._open && !e.target.closest('#' + pfx + 'userMenuWrap')) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Menu items
|
||||
this._on(this.settingsEl, 'click', () => { this.close(); if (handlers.onSettings) handlers.onSettings(); });
|
||||
this._on(this.adminEl, 'click', () => { this.close(); if (handlers.onAdmin) handlers.onAdmin(); });
|
||||
this._on(this.teamAdminEl, 'click', () => { this.close(); if (handlers.onTeamAdmin) handlers.onTeamAdmin(); });
|
||||
this._on(this.debugEl, 'click', () => { this.close(); if (handlers.onDebug) handlers.onDebug(); });
|
||||
this._on(this.signoutEl, 'click', () => { this.close(); if (handlers.onSignout) handlers.onSignout(); });
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
UserMenu._instances.delete(this.id);
|
||||
if (UserMenu.primary === this) UserMenu.primary = null;
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
UserMenu._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
Reference in New Issue
Block a user