steps 5-8: deep gut — purge chat/notes/projects/providers from code
main.go: remove ~300 lines of stale routes referencing deleted handlers (channels, messages, folders, personas, notes, projects, memories, models, providers, tasks, roles, usage, routing, capabilities, etc.) Fix branding: "Chat Switchboard" → "Switchboard Core" pages: remove chat/notes/projects surface manifests and templates Keep: admin, settings, team-admin, workflow, workflow-landing frontend: delete chat/, notes/, projects/ surface directories (19 files) Delete 5 CSS files (4,144 lines): sw-chat-*, sw-notes-*, sw-projects-* SDK: strip gutted API domains (594→287 lines), remove chatPane/notesPane tests: remove integration_test.go (5,194 lines, broken imports to deleted packages) and perm_enforcement_test.go (551 lines, depended on deleted test helpers). Fix testmain_test.go (remove providers import). openapi.yaml: replace 12,491-line stale spec with kernel auth stub. Full ICD rebuild is Step 8 proper. Auth (builtin, mTLS, OIDC) untouched throughout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,847 +0,0 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Preact ChatPane Kit CSS
|
||||
==========================================
|
||||
v0.37.8: Styles for the composable ChatPane kit.
|
||||
sw- prefixed, no conflicts with legacy chat-pane.css.
|
||||
========================================== */
|
||||
|
||||
/* ── Container ─────────────────────────────── */
|
||||
|
||||
.sw-chat-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Header Bar (standalone mode) ──────────── */
|
||||
|
||||
.sw-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;
|
||||
}
|
||||
|
||||
.sw-chat-pane__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-chat-pane__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Chat History Select ──────────────────── */
|
||||
|
||||
.sw-chat-history__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;
|
||||
}
|
||||
|
||||
.sw-chat-history__select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sw-chat-history__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;
|
||||
}
|
||||
|
||||
.sw-chat-history__new-btn:hover {
|
||||
color: var(--accent, #b38a4e);
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
}
|
||||
|
||||
/* ── Model Selector ───────────────────────── */
|
||||
|
||||
.sw-model-selector__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;
|
||||
min-width: 100px;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sw-model-selector__select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sw-model-selector__select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
/* ── Messages Area ─────────────────────────── */
|
||||
|
||||
.sw-chat-pane__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
padding: 12px 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Message Bubbles ───────────────────────── */
|
||||
|
||||
.sw-chat-msg {
|
||||
padding: 8px 16px;
|
||||
font-size: var(--msg-font, 14px);
|
||||
line-height: 1.65;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-chat-msg + .sw-chat-msg {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* User messages — right-aligned bubble */
|
||||
.sw-chat-msg--user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.sw-chat-msg--user .sw-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;
|
||||
}
|
||||
|
||||
/* Other user messages — left-aligned bubble */
|
||||
.sw-chat-msg--other {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.sw-chat-msg--other .sw-chat-msg__content {
|
||||
background: var(--bg-2, #1a1a1e);
|
||||
color: var(--text, #eee);
|
||||
padding: 8px 14px;
|
||||
border-radius: 12px 12px 12px 2px;
|
||||
max-width: 85%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.sw-chat-msg__sender {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3, #555);
|
||||
margin-bottom: 2px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* ── @Mention highlights in rendered messages ─ */
|
||||
|
||||
.sw-mention {
|
||||
color: var(--accent, #6366f1);
|
||||
background: rgba(var(--accent-rgb, 99, 102, 241), 0.1);
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Typing Indicator ─────────────────────── */
|
||||
|
||||
.sw-typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #888);
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.sw-typing-indicator__dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.sw-typing-indicator__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3, #888);
|
||||
animation: sw-typing-bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.sw-typing-indicator__dot:nth-child(1) { animation-delay: 0s; }
|
||||
.sw-typing-indicator__dot:nth-child(2) { animation-delay: 0.16s; }
|
||||
.sw-typing-indicator__dot:nth-child(3) { animation-delay: 0.32s; }
|
||||
|
||||
@keyframes sw-typing-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Assistant messages — left-aligned */
|
||||
.sw-chat-msg--assistant {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.sw-chat-msg--assistant .sw-chat-msg__content {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* System/error messages */
|
||||
.sw-chat-msg--system {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sw-chat-msg--system .sw-chat-msg__content {
|
||||
color: var(--danger, #f44336);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Streaming indicator ───────────────────── */
|
||||
|
||||
.sw-chat-msg--streaming .sw-chat-msg__content::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 14px;
|
||||
background: var(--accent, #b38a4e);
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: sw-cursor-blink 0.8s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes sw-cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Typing dots ───────────────────────────── */
|
||||
|
||||
.sw-chat-msg--typing {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.sw-chat-msg--typing .sw-typing-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sw-chat-msg--typing .sw-typing-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3, #555);
|
||||
animation: sw-dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sw-chat-msg--typing .sw-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.sw-chat-msg--typing .sw-typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes sw-dot-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Markdown content ──────────────────────── */
|
||||
|
||||
.sw-chat-msg__content p { margin: 0 0 0.5em; }
|
||||
.sw-chat-msg__content p:last-child { margin-bottom: 0; }
|
||||
|
||||
.sw-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);
|
||||
}
|
||||
|
||||
.sw-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);
|
||||
}
|
||||
|
||||
.sw-chat-msg__content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content ul, .sw-chat-msg__content ol {
|
||||
margin: 4px 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content blockquote {
|
||||
border-left: 3px solid var(--accent, #b38a4e);
|
||||
padding: 4px 12px;
|
||||
margin: 8px 0;
|
||||
color: var(--text-2, #999);
|
||||
}
|
||||
|
||||
.sw-chat-msg__content a {
|
||||
color: var(--accent, #b38a4e);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content table {
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content th,
|
||||
.sw-chat-msg__content td {
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content th {
|
||||
background: var(--bg-secondary, #151517);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Welcome / Empty State ─────────────────── */
|
||||
|
||||
.sw-chat-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sw-chat-welcome__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.sw-chat-welcome__hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #555);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Input Bar ─────────────────────────────── */
|
||||
|
||||
.sw-chat-pane__input-bar {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.sw-msg-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;
|
||||
}
|
||||
|
||||
.sw-msg-input__wrap:focus-within {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.sw-msg-input__textarea {
|
||||
flex: 1;
|
||||
min-height: 20px;
|
||||
max-height: 160px;
|
||||
width: 100%;
|
||||
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 ───────────────────────────── */
|
||||
|
||||
.sw-msg-input__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;
|
||||
}
|
||||
|
||||
.sw-msg-input__send:hover {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--accent-hover, #c9a05e);
|
||||
}
|
||||
|
||||
.sw-msg-input__send:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Stop Button ──────────────────────────── */
|
||||
|
||||
.sw-msg-input__stop {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--danger, #f44336);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-msg-input__stop:hover {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
/* ── Input Buttons Row ────────────────────── */
|
||||
|
||||
.sw-msg-input__buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Emoji Button + Picker ────────────────── */
|
||||
|
||||
.sw-msg-input__emoji-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-msg-input__emoji-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sw-msg-input__emoji-btn:hover {
|
||||
color: var(--text-2, #999);
|
||||
}
|
||||
|
||||
.sw-emoji-picker {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
right: 0;
|
||||
width: 320px;
|
||||
max-height: 360px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
padding: 4px 4px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 6px 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__tab--active {
|
||||
border-bottom-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.sw-emoji-picker__search {
|
||||
margin: 6px 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__search:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.sw-emoji-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__emoji {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.1s;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__emoji:hover {
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.sw-emoji-picker__empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── CM6 Chat Input ──────────────────────── */
|
||||
|
||||
.sw-msg-input__cm-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-msg-input__cm-container .cm-editor {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-msg-input__cm-container .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Error Banner ─────────────────────────── */
|
||||
|
||||
.sw-chat-pane__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-bottom: 1px solid rgba(244, 67, 54, 0.2);
|
||||
color: var(--danger, #f44336);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-chat-pane__error-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-chat-pane__error-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger, #f44336);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sw-chat-pane__error-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Load More ────────────────────────────── */
|
||||
|
||||
.sw-chat-pane__load-more {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sw-chat-pane__load-more-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 12px;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-pane__load-more-btn:hover {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-chat-pane__load-more-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Message Footer (time + actions) ──────── */
|
||||
|
||||
.sw-chat-msg__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-msg:hover .sw-chat-msg__footer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sw-chat-msg__time {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
/* ── Message Actions Toolbar ──────────────── */
|
||||
|
||||
.sw-chat-msg__actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sw-chat-msg__action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sw-chat-msg__action-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg-secondary, #151517);
|
||||
}
|
||||
|
||||
.sw-chat-msg__action-btn--danger:hover {
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
/* ── Code Block ───────────────────────────── */
|
||||
|
||||
.sw-code-block {
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-code-block__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.sw-code-block__lang {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3, #555);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.sw-code-block__copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sw-code-block__copy:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg-secondary, #151517);
|
||||
}
|
||||
|
||||
.sw-code-block__copy-text {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sw-code-block__pre {
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.sw-code-block__pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ── Task List Checkboxes ─────────────────── */
|
||||
|
||||
.sw-task-item {
|
||||
list-style: none;
|
||||
margin-left: -1.5em;
|
||||
}
|
||||
|
||||
.sw-task-checkbox {
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
accent-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* ── Thinking / Reasoning Blocks ────────── */
|
||||
|
||||
.sw-chat-msg__thinking {
|
||||
margin-bottom: 8px;
|
||||
border-left: 3px solid var(--border, rgba(255,255,255,0.1));
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.sw-chat-msg__thinking-toggle {
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sw-chat-msg__thinking-toggle:hover { opacity: 1; }
|
||||
|
||||
.sw-chat-msg__thinking[open] .sw-chat-msg__thinking-toggle {
|
||||
border-bottom: 1px solid var(--border, rgba(255,255,255,0.06));
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-chat-msg__thinking-content {
|
||||
padding: 6px 10px 10px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,897 +0,0 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Preact NotesPane Kit CSS
|
||||
==========================================
|
||||
v0.37.9: Styles for the composable NotesPane kit.
|
||||
sw-notes-pane prefixed, no conflicts with legacy.
|
||||
========================================== */
|
||||
|
||||
/* ── Container ─────────────────────────────── */
|
||||
|
||||
.sw-notes-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
background: var(--bg-surface, #18181b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-notes-pane__toolbar-spacer { flex: 1; }
|
||||
|
||||
.sw-notes-pane__toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #9898a8);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-notes-pane__toolbar-btn:hover {
|
||||
color: var(--accent, #6c9fff);
|
||||
border-color: var(--accent, #6c9fff);
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
}
|
||||
|
||||
/* ── Search + Filters ──────────────────────── */
|
||||
|
||||
.sw-notes-pane__search-row {
|
||||
display: flex;
|
||||
padding: 6px 10px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-notes-pane__search-input {
|
||||
width: 100%;
|
||||
background: var(--input-bg, #1a1a1f);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 4px;
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 5px 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__search-input:focus {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
.sw-notes-pane__filter-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 10px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-notes-pane__filter-select {
|
||||
flex: 1;
|
||||
background: var(--input-bg, #1a1a1f);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #9898a8);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sw-notes-pane__filter-select:focus {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
/* ── Tag Cloud ─────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__tag-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
padding: 0 10px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-notes-pane__tag-chip {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
background: var(--purple-dim, rgba(167,139,250,0.1));
|
||||
color: var(--purple, #a78bfa);
|
||||
font-size: 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__tag-chip:hover,
|
||||
.sw-notes-pane__tag-chip--active {
|
||||
border-color: var(--purple, #a78bfa);
|
||||
background: rgba(167,139,250,0.18);
|
||||
}
|
||||
|
||||
/* ── Note List ─────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sw-notes-pane__list-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__pinned-divider {
|
||||
padding: 2px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
background: var(--bg-surface, #18181b);
|
||||
}
|
||||
|
||||
/* ── Note List Item ────────────────────────── */
|
||||
|
||||
.sw-notes-pane__item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
.sw-notes-pane__item--selected {
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-checkbox {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text, #e8e8ed);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-preview {
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #9898a8);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-preview mark {
|
||||
background: var(--warning-dim, rgba(234,179,8,0.2));
|
||||
color: var(--warning-light, #fbbf24);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-meta {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-folder {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
background: var(--bg-raised, #222227);
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-tag {
|
||||
font-size: 10px;
|
||||
color: var(--purple, #a78bfa);
|
||||
background: var(--purple-dim, rgba(167,139,250,0.1));
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-pin {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item:hover .sw-notes-pane__item-pin,
|
||||
.sw-notes-pane__item-pin--active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sw-notes-pane__item-pin--active {
|
||||
color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
/* ── Selection Bar ─────────────────────────── */
|
||||
|
||||
.sw-notes-pane__selection-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-surface, #18181b);
|
||||
border-top: 1px solid var(--border, #2e2e35);
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #9898a8);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-notes-pane__selection-bar label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Load More ─────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__load-more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border, #2e2e35);
|
||||
color: var(--accent, #6c9fff);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__load-more:hover {
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
}
|
||||
|
||||
/* ── Editor ────────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
background: var(--bg-surface, #18181b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-header-spacer { flex: 1; }
|
||||
|
||||
.sw-notes-pane__editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-title {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 4px 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-title:focus {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-meta input {
|
||||
flex: 1;
|
||||
background: var(--input-bg, #1a1a1f);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #9898a8);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
padding: 3px 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-meta input:focus {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
background: var(--input-bg, #1a1a1f);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 4px;
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 13px;
|
||||
font-family: var(--mono, 'JetBrains Mono', monospace);
|
||||
line-height: 1.6;
|
||||
padding: 8px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-textarea:focus {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
.sw-notes-pane__editor-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
padding-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Reader ────────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__reader {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* With outline: side-by-side layout */
|
||||
.sw-notes-pane__reader--with-outline {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sw-notes-pane__reader-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Outline Sidebar (sticky, right side) ─── */
|
||||
|
||||
.sw-notes-pane__outline-sidebar {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 8px;
|
||||
border-left: 1px solid var(--border, #2e2e35);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: flex-start;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.sw-notes-pane__reader--with-outline { flex-direction: column; }
|
||||
.sw-notes-pane__outline-sidebar {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border, #2e2e35);
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sw-notes-pane__reader-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text, #e8e8ed);
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sw-notes-pane__reader-meta {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__reader-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
/* Reader markdown */
|
||||
.sw-notes-pane__reader-content p { margin: 0 0 0.6em; }
|
||||
.sw-notes-pane__reader-content p:last-child { margin-bottom: 0; }
|
||||
|
||||
.sw-notes-pane__reader-content pre {
|
||||
background: var(--bg-surface, #18181b);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-family: var(--mono, 'JetBrains Mono', monospace);
|
||||
}
|
||||
|
||||
.sw-notes-pane__reader-content code {
|
||||
background: var(--bg-surface, #18181b);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.88em;
|
||||
font-family: var(--mono, 'JetBrains Mono', monospace);
|
||||
}
|
||||
|
||||
.sw-notes-pane__reader-content pre code {
|
||||
background: none; padding: 0; border-radius: 0; font-size: inherit;
|
||||
}
|
||||
|
||||
.sw-notes-pane__reader-content ul,
|
||||
.sw-notes-pane__reader-content ol { margin: 4px 0; padding-left: 1.5em; }
|
||||
|
||||
.sw-notes-pane__reader-content blockquote {
|
||||
border-left: 3px solid var(--accent, #6c9fff);
|
||||
padding: 4px 12px;
|
||||
margin: 8px 0;
|
||||
color: var(--text-2, #9898a8);
|
||||
}
|
||||
|
||||
.sw-notes-pane__reader-content a {
|
||||
color: var(--accent, #6c9fff);
|
||||
text-decoration: none;
|
||||
}
|
||||
.sw-notes-pane__reader-content a:hover { text-decoration: underline; }
|
||||
|
||||
.sw-notes-pane__reader-content table { border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||
.sw-notes-pane__reader-content th,
|
||||
.sw-notes-pane__reader-content td { border: 1px solid var(--border, #2e2e35); padding: 4px 8px; }
|
||||
.sw-notes-pane__reader-content th { background: var(--bg-surface, #18181b); font-weight: 600; }
|
||||
|
||||
/* ── Daily Nav ─────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__daily-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #9898a8);
|
||||
}
|
||||
|
||||
.sw-notes-pane__daily-nav button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Outline / TOC ─────────────────────────── */
|
||||
|
||||
/* Outline items shared by sidebar + inline fallback */
|
||||
.sw-notes-pane__outline-sidebar,
|
||||
.sw-notes-pane__outline {
|
||||
/* shared base */
|
||||
}
|
||||
|
||||
.sw-notes-pane__outline-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__outline-item {
|
||||
display: block;
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #9898a8);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__outline-item:hover {
|
||||
color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
.sw-notes-pane__outline-item--h2 { padding-left: 12px; }
|
||||
.sw-notes-pane__outline-item--h3 { padding-left: 24px; }
|
||||
|
||||
/* ── Backlinks ─────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__backlinks {
|
||||
border-top: 1px solid var(--border, #2e2e35);
|
||||
padding: 8px 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__backlinks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sw-notes-pane__backlinks-badge {
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
color: var(--accent, #6c9fff);
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sw-notes-pane__backlinks-list {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__backlink-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 3px 0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--accent, #6c9fff);
|
||||
transition: color 0.12s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__backlink-item:hover {
|
||||
color: var(--accent-hover, #84b0ff);
|
||||
}
|
||||
|
||||
.sw-notes-pane__backlink-folder {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
/* ── Wikilinks ─────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__wikilink {
|
||||
display: inline;
|
||||
padding: 1px 5px;
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
color: var(--accent, #6c9fff);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.92em;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__wikilink:hover {
|
||||
background: rgba(108,159,255,0.22);
|
||||
}
|
||||
|
||||
.sw-notes-pane__wikilink--transclusion {
|
||||
color: var(--purple, #a78bfa);
|
||||
background: var(--purple-dim, rgba(167,139,250,0.1));
|
||||
}
|
||||
|
||||
.sw-notes-pane__wikilink--transclusion:hover {
|
||||
background: rgba(167,139,250,0.2);
|
||||
}
|
||||
|
||||
.sw-notes-pane__transclusion {
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-left: 3px solid var(--purple, #a78bfa);
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-notes-pane__transclusion-header {
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-surface, #18181b);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__transclusion-content {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-2, #9898a8);
|
||||
}
|
||||
|
||||
/* ── Unlinked Mentions ─────────────────────── */
|
||||
|
||||
.sw-notes-pane__unlinked {
|
||||
border-top: 1px solid var(--border, #2e2e35);
|
||||
padding: 8px 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__unlinked-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__unlinked-item {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
margin: 1px 2px;
|
||||
background: var(--bg-raised, #222227);
|
||||
color: var(--text-2, #9898a8);
|
||||
font-size: 11px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--border, #2e2e35);
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__unlinked-item:hover {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
/* ── Graph ─────────────────────────────────── */
|
||||
|
||||
.sw-notes-pane__graph {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-notes-pane__graph-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
background: var(--bg-surface, #18181b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-notes-pane__graph-stats {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
.sw-notes-pane__graph-canvas-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-notes-pane__graph-canvas-wrap canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── Quick Switcher ────────────────────────── */
|
||||
|
||||
.sw-notes-pane__switcher-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--overlay, rgba(0,0,0,0.55));
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher {
|
||||
width: min(420px, 90vw);
|
||||
background: var(--bg-surface, #18181b);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
padding: 12px 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-results {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-section {
|
||||
padding: 4px 12px 2px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-item:hover,
|
||||
.sw-notes-pane__switcher-item--active {
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-item-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text, #e8e8ed);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-item-folder {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-create {
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid var(--border, #2e2e35);
|
||||
font-size: 12px;
|
||||
color: var(--accent, #6c9fff);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-create:hover {
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
}
|
||||
|
||||
.sw-notes-pane__switcher-hint {
|
||||
padding: 6px 14px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
text-align: right;
|
||||
border-top: 1px solid var(--border, #2e2e35);
|
||||
}
|
||||
|
||||
/* ── Save-to-Note Dialog ───────────────────── */
|
||||
|
||||
.sw-notes-pane__save-mode {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__save-mode label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-2, #9898a8);
|
||||
}
|
||||
|
||||
.sw-notes-pane__save-search-results {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sw-notes-pane__save-search-item {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text, #e8e8ed);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sw-notes-pane__save-search-item:hover,
|
||||
.sw-notes-pane__save-search-item--selected {
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
}
|
||||
|
||||
.sw-notes-pane__save-preview {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background: var(--bg-raised, #222227);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2, #9898a8);
|
||||
}
|
||||
|
||||
/* ── Shared: loading spinner ───────────────── */
|
||||
|
||||
.sw-notes-pane__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Notes Surface CSS
|
||||
==========================================
|
||||
v0.37.11: Preact notes surface layout.
|
||||
sw-notes-surface__ prefixed, no legacy conflicts.
|
||||
========================================== */
|
||||
|
||||
/* ── Root Layout ──────────────────────────── */
|
||||
|
||||
.sw-notes-surface {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1 1 0%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────── */
|
||||
|
||||
.sw-notes-surface__sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sw-notes-surface__sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-notes-surface__new-note-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
border: 1px solid var(--accent, #b38a4e);
|
||||
border-radius: 8px;
|
||||
color: var(--accent, #b38a4e);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-notes-surface__new-note-btn:hover {
|
||||
background: rgba(179, 138, 78, 0.2);
|
||||
}
|
||||
|
||||
.sw-notes-surface__collapse-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-notes-surface__collapse-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────── */
|
||||
|
||||
.sw-notes-surface__search-wrap {
|
||||
position: relative;
|
||||
padding: 4px 12px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-notes-surface__search-icon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3, #555);
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
input.sw-notes-surface__search {
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 30px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input.sw-notes-surface__search:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* ── Sidebar Body (scrollable) ────────────── */
|
||||
|
||||
.sw-notes-surface__sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Section ──────────────────────────────── */
|
||||
|
||||
.sw-notes-surface__section {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sw-notes-surface__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sw-notes-surface__section-header:hover {
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-notes-surface__chevron {
|
||||
display: flex;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.sw-notes-surface__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sw-notes-surface__section-count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sw-notes-surface__section-body {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.sw-notes-surface__empty {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
/* ── Sidebar Item ─────────────────────────── */
|
||||
|
||||
.sw-notes-surface__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-notes-surface__item:hover {
|
||||
background: var(--bg, #0e0e10);
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-notes-surface__item--active {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-notes-surface__item-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
.sw-notes-surface__item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-notes-surface__item-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Tag Cloud ───────────────────────────── */
|
||||
|
||||
.sw-notes-surface__tag-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.sw-notes-surface__tag-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 12px;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sw-notes-surface__tag-chip:hover {
|
||||
color: var(--text, #eee);
|
||||
border-color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
.sw-notes-surface__tag-chip--active {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
border-color: var(--accent, #b38a4e);
|
||||
color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* ── Sidebar Footer ──────────────────────── */
|
||||
|
||||
.sw-notes-surface__sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Main Area ───────────────────────────── */
|
||||
|
||||
.sw-notes-surface__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Workspace ───────────────────────────── */
|
||||
|
||||
.sw-notes-surface__workspace {
|
||||
flex: 1 1 0%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-notes-surface__workspace-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.sw-notes-surface__workspace-header-left,
|
||||
.sw-notes-surface__workspace-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sw-notes-surface__sidebar-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-notes-surface__sidebar-toggle:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── NotesPane Container ─────────────────── */
|
||||
|
||||
.sw-notes-surface__notes-pane {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar Overlay (mobile) ────────────── */
|
||||
|
||||
.sw-notes-surface__sidebar-overlay {
|
||||
display: none; /* Hidden on desktop */
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* ── Responsive: Mobile ──────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sw-notes-surface__sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.sw-notes-surface__sidebar-overlay {
|
||||
display: block;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
.sw-notes-surface__collapse-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Touch targets */
|
||||
.sw-notes-surface__item {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Prevent iOS zoom */
|
||||
.sw-notes-surface__search {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Projects Surface CSS
|
||||
==========================================
|
||||
v0.37.16: Card grid list + detail with inline chat.
|
||||
sw-projects__ prefixed, BEM convention.
|
||||
========================================== */
|
||||
|
||||
/* ── Root ────────────────────────────────── */
|
||||
|
||||
.sw-projects {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── List View ───────────────────────────── */
|
||||
|
||||
.sw-projects__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px 40px;
|
||||
max-width: 940px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sw-projects__list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sw-projects__list-header .sw-projects__new-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sw-projects__list-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e8ed);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sw-projects__new-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-raised, #222227);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__new-btn:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
border-color: var(--border-light, #3a3a42);
|
||||
}
|
||||
|
||||
.sw-projects__search {
|
||||
width: 100%;
|
||||
padding: 10px 14px 10px 36px;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-surface, #18181b);
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__search:focus {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
.sw-projects__search-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-projects__search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sw-projects__sort {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
.sw-projects__sort select {
|
||||
background: var(--bg-raised, #222227);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 6px;
|
||||
color: var(--text-2, #9898a8);
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Card Grid ───────────────────────────── */
|
||||
|
||||
.sw-projects__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sw-projects__card {
|
||||
background: var(--bg-surface, #18181b);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.sw-projects__card:hover {
|
||||
border-color: var(--border-light, #3a3a42);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sw-projects__card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e8ed);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sw-projects__card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-2, #9898a8);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sw-projects__card-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sw-projects__card-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-raised, #222227);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
.sw-projects__empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* ── Detail View ─────────────────────────── */
|
||||
|
||||
.sw-projects__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-projects__detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.sw-projects__back {
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__back:hover {
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
.sw-projects__detail-name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e8ed);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sw-projects__detail-name-input {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e8ed);
|
||||
background: var(--bg-surface, #18181b);
|
||||
border: 1px solid var(--accent, #6c9fff);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sw-projects__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-projects__icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sw-projects__icon-btn:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
.sw-projects__icon-btn--active {
|
||||
color: var(--warning, #eab308);
|
||||
}
|
||||
|
||||
/* ── Detail Body (2-column) ──────────────── */
|
||||
|
||||
.sw-projects__detail-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-projects__conversations {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-projects__conv-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-projects__conv-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 20px;
|
||||
}
|
||||
|
||||
.sw-projects__conv-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.sw-projects__conv-item:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
.sw-projects__conv-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text, #e8e8ed);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-projects__conv-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sw-projects__conv-empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Chat Embed ──────────────────────────── */
|
||||
|
||||
.sw-projects__chat-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-projects__chat-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__chat-back:hover {
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
/* ── Right Sidebar ───────────────────────── */
|
||||
|
||||
.sw-projects__sidebar {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border, #2e2e35);
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface, #18181b);
|
||||
}
|
||||
|
||||
/* ── Collapsible Sections ────────────────── */
|
||||
|
||||
.sw-projects__section {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-projects__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sw-projects__section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2, #9898a8);
|
||||
}
|
||||
|
||||
.sw-projects__section-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.sw-projects__section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sw-projects__section-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__section-add:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
.sw-projects__section-chevron {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__section-chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sw-projects__section-body {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-projects__section-textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-raised, #222227);
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sw-projects__section-textarea:focus {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
.sw-projects__section-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ── Section Items (KBs, Notes, Files) ──── */
|
||||
|
||||
.sw-projects__section-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text, #e8e8ed);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.sw-projects__section-item:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
.sw-projects__section-item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-projects__section-item-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-projects__section-item-remove {
|
||||
display: none;
|
||||
font-size: 11px;
|
||||
color: var(--danger, #ef4444);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__section-item:hover .sw-projects__section-item-remove {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sw-projects__section-item-remove:hover {
|
||||
background: var(--danger-dim, rgba(239, 68, 68, 0.2));
|
||||
}
|
||||
|
||||
/* ── File Drop Zone ──────────────────────── */
|
||||
|
||||
.sw-projects__dropzone {
|
||||
border: 2px dashed var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sw-projects__dropzone:hover {
|
||||
border-color: var(--border-light, #3a3a42);
|
||||
}
|
||||
|
||||
.sw-projects__dropzone--active {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
background: var(--accent-dim, rgba(108, 159, 255, 0.12));
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
/* ── Picker Dropdown ─────────────────────── */
|
||||
|
||||
.sw-projects__picker {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 10;
|
||||
width: 280px;
|
||||
margin-top: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-raised, #222227);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-projects__picker-item {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text, #e8e8ed);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.sw-projects__picker-item:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
.sw-projects__picker-hint {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
/* ── Context Menu (... menu) ─────────────── */
|
||||
|
||||
.sw-projects__ctx-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 20;
|
||||
min-width: 180px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-raised, #222227);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.sw-projects__ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text, #e8e8ed);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sw-projects__ctx-item:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
.sw-projects__ctx-item--danger {
|
||||
color: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
.sw-projects__ctx-divider {
|
||||
height: 1px;
|
||||
background: var(--border, #2e2e35);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Color Swatches (in menu) ────────────── */
|
||||
|
||||
.sw-projects__color-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.sw-projects__color-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__color-swatch--active {
|
||||
border-color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
/* ── File Browser (v0.37.17) ──────────────── */
|
||||
|
||||
.sw-projects__file-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sw-projects__file-actions-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 0 8px;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-projects__file-action-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
color: var(--text-3, #999);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.sw-projects__file-action-btn:hover {
|
||||
color: var(--text-1, #fff);
|
||||
border-color: var(--text-3, #999);
|
||||
}
|
||||
|
||||
.sw-projects__file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sw-projects__file-row:hover {
|
||||
background: var(--bg-3, rgba(255,255,255,0.04));
|
||||
}
|
||||
|
||||
.sw-projects__file-chevron {
|
||||
font-size: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-3, #999);
|
||||
transition: transform 0.15s;
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sw-projects__file-chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sw-projects__file-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sw-projects__file-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #ccc);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sw-projects__file-name:hover {
|
||||
color: var(--text-1, #fff);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sw-projects__file-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #999);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-projects__file-delete {
|
||||
opacity: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger, #ef4444);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.sw-projects__file-row:hover .sw-projects__file-delete {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.sw-projects__file-delete:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.sw-projects__dropzone {
|
||||
margin-top: 8px;
|
||||
padding: 16px;
|
||||
border: 2px dashed var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #999);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.sw-projects__dropzone:hover {
|
||||
border-color: var(--text-3, #999);
|
||||
color: var(--text-2, #ccc);
|
||||
}
|
||||
.sw-projects__dropzone--active {
|
||||
border-color: var(--accent, #4f8cff);
|
||||
color: var(--accent, #4f8cff);
|
||||
background: rgba(79, 140, 255, 0.05);
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sw-projects__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sw-projects__list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sw-projects__detail-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sw-projects__sidebar {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border, #2e2e35);
|
||||
max-height: 40vh;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK: API Domain Namespaces
|
||||
// Switchboard Core — SDK: API Domain Namespaces
|
||||
// ==========================================
|
||||
// 18 namespaced domain clients wrapping restClient.
|
||||
// Namespaced domain clients wrapping restClient.
|
||||
// Each domain is a plain object with typed methods.
|
||||
//
|
||||
// Factory: createDomains(restClient)
|
||||
@@ -65,136 +65,13 @@ export function createDomains(restClient) {
|
||||
logout: (refreshToken) => rc.post('/api/v1/auth/logout', { refresh_token: refreshToken }),
|
||||
},
|
||||
|
||||
// ── 2. Channels ────────────────────────
|
||||
channels: {
|
||||
...crud(rc, '/api/v1/channels'),
|
||||
messages: (id, opts) => rc.get(`/api/v1/channels/${id}/messages` + _qs(opts)),
|
||||
send: (id, data) => rc.post(`/api/v1/channels/${id}/messages`, data),
|
||||
complete: (data, signal) => rc.stream('/api/v1/chat/completions', data, signal),
|
||||
regenerate: (id, msgId, data, signal) => rc.stream(`/api/v1/channels/${id}/messages/${msgId}/regenerate`, data, signal),
|
||||
editMessage: (id, msgId, content) => rc.post(`/api/v1/channels/${id}/messages/${msgId}/edit`, { content }),
|
||||
deleteMessage: (id, msgId) => rc.del(`/api/v1/channels/${id}/messages/${msgId}`),
|
||||
siblings: (id, msgId) => rc.get(`/api/v1/channels/${id}/messages/${msgId}/siblings`),
|
||||
path: (id) => rc.get(`/api/v1/channels/${id}/path`),
|
||||
cursor: (id, leafId) => rc.put(`/api/v1/channels/${id}/cursor`, { active_leaf_id: leafId }),
|
||||
markRead: (id) => rc.post(`/api/v1/channels/${id}/mark-read`, {}),
|
||||
generateTitle: (id) => rc.post(`/api/v1/channels/${id}/generate-title`, {}),
|
||||
summarize: (id) => rc.post(`/api/v1/channels/${id}/summarize`, {}),
|
||||
// Participants
|
||||
participants: (id) => rc.get(`/api/v1/channels/${id}/participants`),
|
||||
addParticipant: (id, data) => rc.post(`/api/v1/channels/${id}/participants`, data),
|
||||
updateParticipant: (id, pid, data) => rc.patch(`/api/v1/channels/${id}/participants/${pid}`, data),
|
||||
removeParticipant: (id, pid) => rc.del(`/api/v1/channels/${id}/participants/${pid}`),
|
||||
// Channel models
|
||||
models: (id) => rc.get(`/api/v1/channels/${id}/models`),
|
||||
addModel: (id, data) => rc.post(`/api/v1/channels/${id}/models`, data),
|
||||
updateModel: (id, modelId, data) => rc.patch(`/api/v1/channels/${id}/models/${modelId}`, data),
|
||||
removeModel: (id, modelId) => rc.del(`/api/v1/channels/${id}/models/${modelId}`),
|
||||
// Knowledge bases
|
||||
kbs: (id) => rc.get(`/api/v1/channels/${id}/knowledge-bases`),
|
||||
setKbs: (id, kbIds) => rc.put(`/api/v1/channels/${id}/knowledge-bases`, { kb_ids: kbIds }),
|
||||
// Files
|
||||
files: (id, opts) => rc.get(`/api/v1/channels/${id}/files` + _qs(opts)),
|
||||
uploadFile: (id, file) => rc.upload(`/api/v1/channels/${id}/files`, file),
|
||||
},
|
||||
|
||||
// ── 3. Personas ────────────────────────
|
||||
personas: {
|
||||
...crud(rc, '/api/v1/personas'),
|
||||
kbs: (id) => rc.get(`/api/v1/personas/${id}/knowledge-bases`),
|
||||
setKbs: (id, data) => rc.put(`/api/v1/personas/${id}/knowledge-bases`, data),
|
||||
},
|
||||
|
||||
// ── 4. Knowledge Bases ─────────────────
|
||||
knowledge: {
|
||||
...crud(rc, '/api/v1/knowledge-bases'),
|
||||
discoverable: () => rc.get('/api/v1/knowledge-bases-discoverable'),
|
||||
search: (id, query, limit) => rc.post(`/api/v1/knowledge-bases/${id}/search`, { query, limit }),
|
||||
documents: (id) => rc.get(`/api/v1/knowledge-bases/${id}/documents`),
|
||||
upload: (id, file) => rc.upload(`/api/v1/knowledge-bases/${id}/documents`, file),
|
||||
docStatus: (id, docId) => rc.get(`/api/v1/knowledge-bases/${id}/documents/${docId}/status`),
|
||||
delDoc: (id, docId) => rc.del(`/api/v1/knowledge-bases/${id}/documents/${docId}`),
|
||||
},
|
||||
|
||||
// ── 5. Notes ───────────────────────────
|
||||
notes: {
|
||||
...crud(rc, '/api/v1/notes'),
|
||||
search: (query, limit) => rc.get(`/api/v1/notes/search` + _qs({ q: query, limit })),
|
||||
searchTitles: (query, limit) => rc.get(`/api/v1/notes/search-titles` + _qs({ q: query, limit })),
|
||||
folders: () => rc.get('/api/v1/notes/folders'),
|
||||
backlinks: (id) => rc.get(`/api/v1/notes/${id}/backlinks`),
|
||||
graph: () => rc.get('/api/v1/notes/graph'),
|
||||
bulkDelete: (ids) => rc.post('/api/v1/notes/bulk-delete', { ids }),
|
||||
},
|
||||
|
||||
// ── 6. Projects ────────────────────────
|
||||
projects: {
|
||||
...crud(rc, '/api/v1/projects'),
|
||||
channels: (id) => rc.get(`/api/v1/projects/${id}/channels`),
|
||||
addChannel: (id, channelId, pos) => rc.post(`/api/v1/projects/${id}/channels`, { channel_id: channelId, position: pos || 0 }),
|
||||
removeChannel: (id, channelId) => rc.del(`/api/v1/projects/${id}/channels/${channelId}`),
|
||||
reorderChannels:(id, channelIds) => rc.put(`/api/v1/projects/${id}/channels/reorder`, { channel_ids: channelIds }),
|
||||
kbs: (id) => rc.get(`/api/v1/projects/${id}/knowledge-bases`),
|
||||
addKb: (id, kbId, autoSearch) => rc.post(`/api/v1/projects/${id}/knowledge-bases`, { kb_id: kbId, auto_search: autoSearch || false }),
|
||||
removeKb: (id, kbId) => rc.del(`/api/v1/projects/${id}/knowledge-bases/${kbId}`),
|
||||
notes: (id) => rc.get(`/api/v1/projects/${id}/notes`),
|
||||
addNote: (id, noteId) => rc.post(`/api/v1/projects/${id}/notes`, { note_id: noteId }),
|
||||
removeNote: (id, noteId) => rc.del(`/api/v1/projects/${id}/notes/${noteId}`),
|
||||
files: (id, opts) => rc.get(`/api/v1/projects/${id}/files` + _qs(opts)),
|
||||
uploadFile: (id, file, path) => rc.upload(`/api/v1/projects/${id}/files` + (path ? _qs({ path }) : ''), file),
|
||||
deleteFile: (id, path) => rc.del(`/api/v1/projects/${id}/files` + _qs({ path })),
|
||||
mkdir: (id, path) => rc.post(`/api/v1/projects/${id}/files/mkdir`, { path }),
|
||||
uploadArchive: (id, file) => rc.upload(`/api/v1/projects/${id}/archive/upload`, file),
|
||||
},
|
||||
|
||||
// ── 7. Workspaces ──────────────────────
|
||||
workspaces: {
|
||||
...crud(rc, '/api/v1/workspaces'),
|
||||
update: (id, data) => rc.patch(`/api/v1/workspaces/${id}`, data),
|
||||
getDefault: () => rc.get('/api/v1/workspaces/default'),
|
||||
files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)),
|
||||
readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })),
|
||||
writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content),
|
||||
deleteFile: (id, path) => rc.del(`/api/v1/workspaces/${id}/files/delete` + _qs({ path })),
|
||||
mkdir: (id, path) => rc.post(`/api/v1/workspaces/${id}/files/mkdir`, { path }),
|
||||
gitStatus: (id) => rc.get(`/api/v1/workspaces/${id}/git/status`),
|
||||
gitBranches: (id) => rc.get(`/api/v1/workspaces/${id}/git/branches`),
|
||||
credentials: () => rc.get('/api/v1/git-credentials'),
|
||||
},
|
||||
|
||||
// ── 8. Memory ──────────────────────────
|
||||
memory: {
|
||||
list: (opts) => rc.get('/api/v1/memories' + _qs(opts)),
|
||||
get: (id) => rc.get(`/api/v1/memories/${id}`),
|
||||
update: (id, data) => rc.put(`/api/v1/memories/${id}`, data),
|
||||
del: (id) => rc.del(`/api/v1/memories/${id}`),
|
||||
count: () => rc.get('/api/v1/memories/count'),
|
||||
approve: (id) => rc.post(`/api/v1/memories/${id}/approve`, {}),
|
||||
reject: (id) => rc.post(`/api/v1/memories/${id}/reject`, {}),
|
||||
},
|
||||
|
||||
// ── 9. Models ──────────────────────────
|
||||
models: {
|
||||
enabled: () => rc.get('/api/v1/models/enabled'),
|
||||
preferences: () => rc.get('/api/v1/models/preferences'),
|
||||
setPref: (modelId, provId, hidden) => rc.put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: provId, hidden }),
|
||||
bulkSetPref: (entries, hidden) => rc.post('/api/v1/models/preferences/bulk', { entries, hidden }),
|
||||
},
|
||||
|
||||
// ── 10. Providers (user BYOK) ──────────
|
||||
providers: {
|
||||
...crud(rc, '/api/v1/api-configs'),
|
||||
models: (id) => rc.get(`/api/v1/api-configs/${id}/models`),
|
||||
fetchModels: (id) => rc.post(`/api/v1/api-configs/${id}/models/fetch`),
|
||||
},
|
||||
|
||||
// ── 10b. Connections (v0.38.1) ────────
|
||||
// ── 2. Connections ────────────────────
|
||||
connections: {
|
||||
...crud(rc, '/api/v1/connections'),
|
||||
resolve: (type, name) => rc.get('/api/v1/connections/resolve' + _qs({ type, name })),
|
||||
},
|
||||
|
||||
// ── 10c. Connection Types (v0.38.4) ──
|
||||
// ── 3. Connection Types ────────────────
|
||||
connectionTypes: {
|
||||
list: () => rc.get('/api/v1/connection-types'),
|
||||
},
|
||||
@@ -239,30 +116,14 @@ export function createDomains(restClient) {
|
||||
addMember: (id, userId, role) => rc.post(`/api/v1/teams/${id}/members`, { user_id: userId, role }),
|
||||
updateMember:(id, memberId, role) => rc.put(`/api/v1/teams/${id}/members/${memberId}`, { role }),
|
||||
removeMember:(id, memberId) => rc.del(`/api/v1/teams/${id}/members/${memberId}`),
|
||||
personas: (id) => rc.get(`/api/v1/teams/${id}/personas`),
|
||||
createPersona:(id, data) => rc.post(`/api/v1/teams/${id}/personas`, data),
|
||||
updatePersona:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}`, data),
|
||||
deletePersona:(id, pid) => rc.del(`/api/v1/teams/${id}/personas/${pid}`),
|
||||
personaKbs: (id, pid) => rc.get(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`),
|
||||
setPersonaKbs:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`, data),
|
||||
models: (id) => rc.get(`/api/v1/teams/${id}/models`),
|
||||
groups: (id) => rc.get(`/api/v1/teams/${id}/groups`),
|
||||
providers: (id) => rc.get(`/api/v1/teams/${id}/providers`),
|
||||
createProvider:(id, data) => rc.post(`/api/v1/teams/${id}/providers`, data),
|
||||
updateProvider:(id, pid, data) => rc.put(`/api/v1/teams/${id}/providers/${pid}`, data),
|
||||
deleteProvider:(id, pid) => rc.del(`/api/v1/teams/${id}/providers/${pid}`),
|
||||
providerModels:(id, pid) => rc.get(`/api/v1/teams/${id}/providers/${pid}/models`),
|
||||
// Team connections (v0.38.1)
|
||||
// Team connections
|
||||
connections: (id) => rc.get(`/api/v1/teams/${id}/connections`),
|
||||
createConnection: (id, data) => rc.post(`/api/v1/teams/${id}/connections`, data),
|
||||
updateConnection: (id, cid, data) => rc.put(`/api/v1/teams/${id}/connections/${cid}`, data),
|
||||
deleteConnection: (id, cid) => rc.del(`/api/v1/teams/${id}/connections/${cid}`),
|
||||
roles: (id) => rc.get(`/api/v1/teams/${id}/roles`),
|
||||
updateRole: (id, role, config) => rc.put(`/api/v1/teams/${id}/roles/${role}`, config),
|
||||
deleteRole: (id, role) => rc.del(`/api/v1/teams/${id}/roles/${role}`),
|
||||
audit: (id, opts) => rc.get(`/api/v1/teams/${id}/audit` + _qs(opts)),
|
||||
auditActions:(id) => rc.get(`/api/v1/teams/${id}/audit/actions`),
|
||||
usage: (id, opts) => rc.get(`/api/v1/teams/${id}/usage` + _qs(opts)),
|
||||
// Team workflows
|
||||
workflows: (id, opts) => rc.get(`/api/v1/teams/${id}/workflows` + _qs(opts)),
|
||||
createWorkflow: (id, data) => rc.post(`/api/v1/teams/${id}/workflows`, data),
|
||||
@@ -276,22 +137,9 @@ export function createDomains(restClient) {
|
||||
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
|
||||
deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`),
|
||||
reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }),
|
||||
// Team assignments + monitor (v0.37.15)
|
||||
assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)),
|
||||
workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`),
|
||||
cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}),
|
||||
// Team tasks
|
||||
tasks: (id, opts) => rc.get(`/api/v1/teams/${id}/tasks` + _qs(opts)),
|
||||
createTask: (id, data) => rc.post(`/api/v1/teams/${id}/tasks`, data),
|
||||
updateTask: (id, taskId, data) => rc.put(`/api/v1/teams/${id}/tasks/${taskId}`, data),
|
||||
deleteTask: (id, taskId) => rc.del(`/api/v1/teams/${id}/tasks/${taskId}`),
|
||||
runTask: (id, taskId) => rc.post(`/api/v1/teams/${id}/tasks/${taskId}/run`, {}),
|
||||
killTask: (id, taskId) => rc.post(`/api/v1/teams/${id}/tasks/${taskId}/kill`, {}),
|
||||
// Team knowledge bases
|
||||
knowledgeBases:(id) => rc.get(`/api/v1/teams/${id}/knowledge-bases`),
|
||||
},
|
||||
|
||||
// ── 15. Workflows ──────────────────────
|
||||
// ── 9. Workflows ───────────────────────
|
||||
workflows: {
|
||||
...crud(rc, '/api/v1/workflows'),
|
||||
update: (id, data) => rc.patch(`/api/v1/workflows/${id}`, data),
|
||||
@@ -302,27 +150,7 @@ export function createDomains(restClient) {
|
||||
cancel: (channelId) => rc.post(`/api/v1/channels/${channelId}/workflow/cancel`, {}),
|
||||
},
|
||||
|
||||
// ── 15b. Workflow Assignments (v0.37.15) ─
|
||||
workflowAssignments: {
|
||||
mine: () => rc.get('/api/v1/workflow-assignments/mine'),
|
||||
get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`),
|
||||
claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}),
|
||||
unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}),
|
||||
complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}),
|
||||
reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }),
|
||||
cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}),
|
||||
comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }),
|
||||
},
|
||||
|
||||
// ── 16. Tasks ──────────────────────────
|
||||
tasks: {
|
||||
...crud(rc, '/api/v1/tasks'),
|
||||
runs: (id, opts) => rc.get(`/api/v1/tasks/${id}/runs` + _qs(opts)),
|
||||
start: (id, data) => rc.post(`/api/v1/tasks/${id}/run`, data || {}),
|
||||
stop: (id) => rc.post(`/api/v1/tasks/${id}/kill`, {}),
|
||||
},
|
||||
|
||||
// ── 17. Surfaces ───────────────────────
|
||||
// ── 10. Surfaces ──────────────────────
|
||||
surfaces: {
|
||||
list: (opts) => rc.get('/api/v1/surfaces' + _qs(opts)),
|
||||
get: (id) => rc.get(`/api/v1/surfaces/${id}`),
|
||||
@@ -332,7 +160,6 @@ export function createDomains(restClient) {
|
||||
// ── 18. Admin ──────────────────────────
|
||||
admin: {
|
||||
stats: () => rc.get('/api/v1/admin/stats'),
|
||||
dashboard: () => rc.get('/api/v1/admin/dashboard'),
|
||||
|
||||
users: {
|
||||
list: (opts) => rc.get('/api/v1/admin/users' + _qs(opts)),
|
||||
@@ -351,38 +178,6 @@ export function createDomains(restClient) {
|
||||
public: () => rc.get('/api/v1/settings/public'),
|
||||
},
|
||||
|
||||
configs: {
|
||||
list: () => rc.get('/api/v1/admin/configs'),
|
||||
create: (data) => rc.post('/api/v1/admin/configs', data),
|
||||
update: (id, data) => rc.put(`/api/v1/admin/configs/${id}`, data),
|
||||
del: (id) => rc.del(`/api/v1/admin/configs/${id}`),
|
||||
},
|
||||
|
||||
models: {
|
||||
list: () => rc.get('/api/v1/admin/models'),
|
||||
fetch: () => rc.post('/api/v1/admin/models/fetch', {}),
|
||||
update: (id, data) => rc.put(`/api/v1/admin/models/${id}`, data),
|
||||
bulkUpdate: (visibility) => rc.put('/api/v1/admin/models/bulk', { visibility, is_enabled: visibility === 'enabled' }),
|
||||
del: (id) => rc.del(`/api/v1/admin/models/${id}`),
|
||||
capabilities: (modelId) => rc.get(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`),
|
||||
setCapability: (modelId, d) => rc.put(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`, d),
|
||||
delCapability: (modelId, oId) => rc.del(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities/${oId}`),
|
||||
overrides: () => rc.get('/api/v1/admin/capability-overrides'),
|
||||
},
|
||||
|
||||
personas: {
|
||||
list: () => rc.get('/api/v1/admin/personas'),
|
||||
create: (data) => rc.post('/api/v1/admin/personas', data),
|
||||
update: (id, data) => rc.put(`/api/v1/admin/personas/${id}`, data),
|
||||
del: (id) => rc.del(`/api/v1/admin/personas/${id}`),
|
||||
avatar: (id, base64) => rc.post(`/api/v1/admin/personas/${id}/avatar`, { image: base64 }),
|
||||
delAvatar: (id) => rc.del(`/api/v1/admin/personas/${id}/avatar`),
|
||||
kbs: (id) => rc.get(`/api/v1/admin/personas/${id}/knowledge-bases`),
|
||||
setKbs: (id, data) => rc.put(`/api/v1/admin/personas/${id}/knowledge-bases`, data),
|
||||
toolGrants: (id) => rc.get(`/api/v1/admin/personas/${id}/tool-grants`),
|
||||
setToolGrants: (id, data) => rc.put(`/api/v1/admin/personas/${id}/tool-grants`, data),
|
||||
},
|
||||
|
||||
teams: {
|
||||
list: () => rc.get('/api/v1/admin/teams'),
|
||||
get: (id) => rc.get(`/api/v1/admin/teams/${id}`),
|
||||
@@ -421,71 +216,19 @@ export function createDomains(restClient) {
|
||||
actions: () => rc.get('/api/v1/admin/audit/actions'),
|
||||
},
|
||||
|
||||
roles: {
|
||||
list: () => rc.get('/api/v1/admin/roles'),
|
||||
get: (role) => rc.get(`/api/v1/admin/roles/${role}`),
|
||||
update: (role, config) => rc.put(`/api/v1/admin/roles/${role}`, config),
|
||||
test: (role) => rc.post(`/api/v1/admin/roles/${role}/test`, {}),
|
||||
},
|
||||
|
||||
usage: {
|
||||
get: (opts) => rc.get('/api/v1/admin/usage' + _qs(opts)),
|
||||
user: (id, opts) => rc.get(`/api/v1/admin/usage/users/${id}` + _qs(opts)),
|
||||
team: (id, opts) => rc.get(`/api/v1/admin/usage/teams/${id}` + _qs(opts)),
|
||||
},
|
||||
|
||||
pricing: {
|
||||
list: () => rc.get('/api/v1/admin/pricing'),
|
||||
upsert: (entry) => rc.put('/api/v1/admin/pricing', entry),
|
||||
del: (provId, modelId) => rc.del(`/api/v1/admin/pricing/${provId}/${encodeURIComponent(modelId)}`),
|
||||
},
|
||||
|
||||
providers: {
|
||||
health: () => rc.get('/api/v1/admin/providers/health'),
|
||||
healthOne: (id) => rc.get(`/api/v1/admin/providers/${id}/health`),
|
||||
types: () => rc.get('/api/v1/admin/provider-types'),
|
||||
},
|
||||
|
||||
routing: {
|
||||
policies: () => rc.get('/api/v1/admin/routing/policies'),
|
||||
getPolicy: (id) => rc.get(`/api/v1/admin/routing/policies/${id}`),
|
||||
createPolicy:(data) => rc.post('/api/v1/admin/routing/policies', data),
|
||||
updatePolicy:(id, data) => rc.put(`/api/v1/admin/routing/policies/${id}`, data),
|
||||
deletePolicy:(id) => rc.del(`/api/v1/admin/routing/policies/${id}`),
|
||||
test: (data) => rc.post('/api/v1/admin/routing/test', data),
|
||||
},
|
||||
|
||||
storage: {
|
||||
status: () => rc.get('/api/v1/admin/storage/status'),
|
||||
orphans: () => rc.get('/api/v1/admin/storage/orphans'),
|
||||
cleanup: () => rc.post('/api/v1/admin/storage/cleanup', {}),
|
||||
extraction: () => rc.get('/api/v1/admin/storage/extraction'),
|
||||
},
|
||||
|
||||
vault: {
|
||||
status: () => rc.get('/api/v1/admin/vault/status'),
|
||||
},
|
||||
|
||||
projects: {
|
||||
list: (opts) => rc.get('/api/v1/admin/projects' + _qs(opts)),
|
||||
del: (id) => rc.del(`/api/v1/admin/projects/${id}`),
|
||||
},
|
||||
|
||||
memories: {
|
||||
pending: () => rc.get('/api/v1/admin/memories/pending'),
|
||||
bulkApprove: (ids) => rc.post('/api/v1/admin/memories/bulk-approve', { ids }),
|
||||
},
|
||||
|
||||
notifications: {
|
||||
testEmail: () => rc.post('/api/v1/admin/notifications/test-email', {}),
|
||||
broadcast: (data) => rc.post('/api/v1/admin/notifications/broadcast', data),
|
||||
},
|
||||
|
||||
channels: {
|
||||
archived: (opts) => rc.get('/api/v1/admin/channels/archived' + _qs(opts)),
|
||||
purge: (id) => rc.del(`/api/v1/admin/channels/${id}/purge`),
|
||||
},
|
||||
|
||||
extensions: {
|
||||
list: () => rc.get('/api/v1/admin/extensions'),
|
||||
create: (data) => rc.post('/api/v1/admin/extensions', data),
|
||||
@@ -516,13 +259,6 @@ export function createDomains(restClient) {
|
||||
list: () => rc.get('/api/v1/admin/dependencies'),
|
||||
},
|
||||
|
||||
tasks: {
|
||||
list: () => rc.get('/api/v1/admin/tasks'),
|
||||
run: (id) => rc.post(`/api/v1/admin/tasks/${id}/run`, {}),
|
||||
kill: (id) => rc.post(`/api/v1/admin/tasks/${id}/kill`, {}),
|
||||
del: (id) => rc.del(`/api/v1/admin/tasks/${id}`),
|
||||
},
|
||||
|
||||
surfaces: {
|
||||
list: () => rc.get('/api/v1/admin/surfaces'),
|
||||
get: (id) => rc.get(`/api/v1/admin/surfaces/${id}`),
|
||||
@@ -533,42 +269,6 @@ export function createDomains(restClient) {
|
||||
},
|
||||
},
|
||||
|
||||
// ── 19. Git Credentials ──────────────────
|
||||
git: {
|
||||
credentials: {
|
||||
list: () => rc.get('/api/v1/git-credentials'),
|
||||
create: (data) => rc.post('/api/v1/git-credentials', data),
|
||||
del: (id) => rc.del(`/api/v1/git-credentials/${id}`),
|
||||
},
|
||||
},
|
||||
|
||||
// ── 20. Data Portability ─────────────────
|
||||
dataPortability: {
|
||||
exportMe: () => rc.get('/api/v1/export/me'),
|
||||
deleteAccount: (data) => rc.post('/api/v1/profile/delete', data),
|
||||
},
|
||||
|
||||
// ── Misc (not domain-specific) ─────────
|
||||
folders: {
|
||||
list: () => rc.get('/api/v1/folders'),
|
||||
create: (name, opts) => rc.post('/api/v1/folders', { name, parent_id: opts?.parent_id || null, sort_order: opts?.sort_order || 0 }),
|
||||
update: (id, data) => rc.put(`/api/v1/folders/${id}`, data),
|
||||
del: (id) => rc.del(`/api/v1/folders/${id}`),
|
||||
},
|
||||
|
||||
// NOTE: notifications is defined in the main domain block above (§11).
|
||||
// The duplicate here was overwriting prefs/setPref/delPref methods.
|
||||
// Removed — see line ~190 for the canonical definition.
|
||||
|
||||
files: {
|
||||
get: (id) => rc.get(`/api/v1/files/${id}`),
|
||||
del: (id) => rc.del(`/api/v1/files/${id}`),
|
||||
},
|
||||
|
||||
tools: {
|
||||
list: () => rc.get('/api/v1/tools'),
|
||||
},
|
||||
|
||||
// ── Users ──────────────────────────────
|
||||
users: {
|
||||
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
|
||||
@@ -578,16 +278,10 @@ export function createDomains(restClient) {
|
||||
heartbeat: () => rc.post('/api/v1/presence/heartbeat', {}),
|
||||
},
|
||||
|
||||
usage: {
|
||||
mine: (opts) => rc.get('/api/v1/usage' + _qs(opts)),
|
||||
},
|
||||
|
||||
groups: {
|
||||
mine: () => rc.get('/api/v1/groups/mine'),
|
||||
},
|
||||
|
||||
health: () => rc.get('/api/v1/health'),
|
||||
|
||||
export: (content, format, filename) => rc.post('/api/v1/export', { content, format, filename }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK Entry Point
|
||||
// Switchboard Core — SDK Entry Point
|
||||
// ==========================================
|
||||
// Assembles all SDK modules into the `sw` object
|
||||
// and runs the boot sequence.
|
||||
@@ -129,28 +129,6 @@ export async function boot() {
|
||||
});
|
||||
};
|
||||
|
||||
// ChatPane render helper — surfaces call sw.chatPane(container, opts)
|
||||
// Returns Promise<imperative handle> for backward-compat with old ChatPane API.
|
||||
sw.chatPane = function (container, opts = {}) {
|
||||
return import('../components/chat-pane/index.js').then(({ ChatPane }) => {
|
||||
const handleRef = { current: null };
|
||||
const { render } = preact;
|
||||
render(html`<${ChatPane} handleRef=${handleRef} ...${opts} />`, container);
|
||||
return handleRef.current;
|
||||
});
|
||||
};
|
||||
|
||||
// NotesPane render helper — surfaces call sw.notesPane(container, opts)
|
||||
// Returns Promise<imperative handle>.
|
||||
sw.notesPane = function (container, opts = {}) {
|
||||
return import('../components/notes-pane/index.js').then(({ NotesPane }) => {
|
||||
const handleRef = { current: null };
|
||||
const { render } = preact;
|
||||
render(html`<${NotesPane} handleRef=${handleRef} ...${opts} />`, container);
|
||||
return handleRef.current;
|
||||
});
|
||||
};
|
||||
|
||||
// Marker for idempotency
|
||||
sw._sdk = '0.38.1';
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — Channel Members Panel
|
||||
// ==========================================
|
||||
// Drawer showing channel participants with role management.
|
||||
// Uses Drawer primitive + channels.participants API.
|
||||
|
||||
import { Drawer } from '../../primitives/drawer.js';
|
||||
import { UserPicker } from '../../primitives/user-picker.js';
|
||||
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const ROLES = ['owner', 'member', 'observer'];
|
||||
|
||||
function _initials(name) {
|
||||
if (!name) return '?';
|
||||
return name.split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ open: boolean, channelId: string, onClose: () => void }} props
|
||||
*/
|
||||
export function ChannelMembersPanel({ open, channelId, onClose }) {
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!channelId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await sw.api.channels.participants(channelId);
|
||||
setMembers(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && channelId) load();
|
||||
}, [open, channelId, load]);
|
||||
|
||||
async function updateRole(pid, role) {
|
||||
try {
|
||||
await sw.api.channels.updateParticipant(channelId, pid, { role });
|
||||
sw.toast('Role updated', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function remove(pid) {
|
||||
const ok = await sw.confirm('Remove this participant?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.channels.removeParticipant(channelId, pid);
|
||||
sw.toast('Participant removed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
const _onUserSelect = useCallback(async (user) => {
|
||||
setAdding(true);
|
||||
try {
|
||||
await sw.api.channels.addParticipant(channelId, {
|
||||
participant_type: 'user',
|
||||
participant_id: user.id,
|
||||
});
|
||||
sw.toast('Participant added', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setAdding(false); }
|
||||
}, [channelId, load]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return html`
|
||||
<${Drawer} open=${open} side="right" width="340px" title="Members" onClose=${onClose}>
|
||||
${loading && !members.length
|
||||
? html`<div class="sw-panel-loading">Loading\u2026</div>`
|
||||
: html`
|
||||
<div class="sw-members-list">
|
||||
${members.map(m => html`
|
||||
<div class="sw-members-row" key=${m.id}>
|
||||
<span class="sw-members-avatar">${_initials(m.display_name || m.participant_id)}</span>
|
||||
<div class="sw-members-info">
|
||||
<span class="sw-members-name">${m.display_name || m.participant_id}</span>
|
||||
<span class="sw-members-type">${m.participant_type}</span>
|
||||
</div>
|
||||
<select class="sw-members-role" value=${m.role || 'member'}
|
||||
onChange=${e => updateRole(m.id, e.target.value)}>
|
||||
${ROLES.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
<button class="sw-members-remove" onClick=${() => remove(m.id)}
|
||||
title="Remove">\u00d7</button>
|
||||
</div>`)}
|
||||
${members.length === 0 && html`
|
||||
<div class="sw-panel-empty">No participants</div>`}
|
||||
</div>
|
||||
<div class="sw-members-add">
|
||||
<${UserPicker}
|
||||
onSelect=${_onUserSelect}
|
||||
placeholder=${adding ? 'Adding\u2026' : 'Add participant\u2026'} />
|
||||
</div>`}
|
||||
<//>`;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — Channel Settings Panel
|
||||
// ==========================================
|
||||
// Drawer for editing channel title, description, topic, ai_mode.
|
||||
// Uses Drawer primitive + channels.update API.
|
||||
|
||||
import { Drawer } from '../../primitives/drawer.js';
|
||||
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const AI_MODES = [
|
||||
{ value: 'auto', label: 'Auto (always reply)' },
|
||||
{ value: 'mention_only', label: 'Mention only' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {{ open: boolean, channelId: string, onClose: () => void }} props
|
||||
*/
|
||||
export function ChannelSettingsPanel({ open, channelId, onClose }) {
|
||||
const [channel, setChannel] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ title: '', description: '', topic: '', ai_mode: 'auto' });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!channelId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await sw.api.channels.get(channelId);
|
||||
setChannel(data);
|
||||
setForm({
|
||||
title: data.title || '',
|
||||
description: data.description || '',
|
||||
topic: data.topic || '',
|
||||
ai_mode: data.ai_mode || 'auto',
|
||||
});
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && channelId) load();
|
||||
}, [open, channelId, load]);
|
||||
|
||||
function _set(key, value) {
|
||||
setForm(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await sw.api.channels.update(channelId, {
|
||||
title: form.title || undefined,
|
||||
description: form.description || undefined,
|
||||
topic: form.topic || undefined,
|
||||
ai_mode: form.ai_mode || undefined,
|
||||
});
|
||||
sw.toast('Settings saved', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return html`
|
||||
<${Drawer} open=${open} side="right" width="340px" title="Channel Settings" onClose=${onClose}>
|
||||
${loading && !channel
|
||||
? html`<div class="sw-panel-loading">Loading\u2026</div>`
|
||||
: html`
|
||||
<form class="sw-settings-form" onSubmit=${save}>
|
||||
<div class="sw-settings-field">
|
||||
<label>Title</label>
|
||||
<input type="text" value=${form.title}
|
||||
onInput=${e => _set('title', e.target.value)} />
|
||||
</div>
|
||||
<div class="sw-settings-field">
|
||||
<label>Description</label>
|
||||
<textarea rows="3" value=${form.description}
|
||||
onInput=${e => _set('description', e.target.value)} />
|
||||
</div>
|
||||
<div class="sw-settings-field">
|
||||
<label>Topic</label>
|
||||
<input type="text" value=${form.topic}
|
||||
onInput=${e => _set('topic', e.target.value)} />
|
||||
</div>
|
||||
<div class="sw-settings-field">
|
||||
<label>AI Mode</label>
|
||||
<select value=${form.ai_mode}
|
||||
onChange=${e => _set('ai_mode', e.target.value)}>
|
||||
${AI_MODES.map(m => html`
|
||||
<option key=${m.value} value=${m.value}>${m.label}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
${channel && html`
|
||||
<div class="sw-settings-info">
|
||||
<div><strong>Type:</strong> ${channel.channel_type || channel.type || '\u2014'}</div>
|
||||
<div><strong>Created:</strong> ${channel.created_at ? new Date(channel.created_at).toLocaleDateString() : '\u2014'}</div>
|
||||
</div>`}
|
||||
<button type="submit" class="btn-md btn-primary sw-settings-save"
|
||||
disabled=${saving}>
|
||||
${saving ? 'Saving\u2026' : 'Save'}
|
||||
</button>
|
||||
</form>`}
|
||||
<//>`;
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — ChatWorkspace Component
|
||||
// ==========================================
|
||||
// Header bar (model selector, tools, sidebar toggle) + ChatPane.
|
||||
// ChatPane runs standalone=false — the surface manages navigation.
|
||||
|
||||
import { ChatPane, ModelSelector, useChat } from '../../components/chat-pane/index.js';
|
||||
import { NotificationBell } from '../../shell/notification-bell.js';
|
||||
import { ChannelMembersPanel } from './channel-members-panel.js';
|
||||
import { ChannelSettingsPanel } from './channel-settings-panel.js';
|
||||
import { UserPicker } from '../../primitives/user-picker.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useRef, useEffect, useCallback } = window.hooks;
|
||||
|
||||
|
||||
const PANEL_SVG = html`
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
const PEOPLE_SVG = html`
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>`;
|
||||
|
||||
const GEAR_SVG = html`
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* activeId: string|null,
|
||||
* activeType: 'chat'|'channel'|null,
|
||||
* onChannelChange: (id: string) => void,
|
||||
* onNewChat: () => void,
|
||||
* sidebarOpen: boolean,
|
||||
* onToggleSidebar: () => void,
|
||||
* toolsButton?: any,
|
||||
* }} props
|
||||
*/
|
||||
export function ChatWorkspace({
|
||||
activeId, activeType, channelType, onChannelChange, onNewChat, onCreateChannel,
|
||||
sidebarOpen, onToggleSidebar, toolsButton, sidebar,
|
||||
}) {
|
||||
const chatRef = useRef(null);
|
||||
const [selectedModel, setSelectedModel] = useState(null);
|
||||
const [membersOpen, setMembersOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const activeChannel = sidebar?.items?.find(c => c.id === activeId);
|
||||
const showModelSelector = (activeChannel?.ai_mode || 'auto') !== 'off';
|
||||
|
||||
// Close panels on channel switch
|
||||
useEffect(() => {
|
||||
setMembersOpen(false);
|
||||
setSettingsOpen(false);
|
||||
}, [activeId]);
|
||||
|
||||
// Sync model selection into ChatPane's useChat
|
||||
useEffect(() => {
|
||||
if (selectedModel && chatRef.current?.setModel) {
|
||||
chatRef.current.setModel(selectedModel);
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// When activeId changes, tell ChatPane to switch channel
|
||||
useEffect(() => {
|
||||
if (chatRef.current) {
|
||||
chatRef.current.setChannel(activeId);
|
||||
}
|
||||
}, [activeId]);
|
||||
|
||||
// Dashboard view when no channel selected
|
||||
if (!activeId) {
|
||||
return html`
|
||||
<div class="sw-chat-surface__workspace">
|
||||
<div class="sw-chat-surface__workspace-header">
|
||||
<div class="sw-chat-surface__workspace-header-left">
|
||||
${!sidebarOpen && html`
|
||||
<button class="sw-chat-surface__sidebar-toggle"
|
||||
onClick=${onToggleSidebar}
|
||||
title="Show sidebar"
|
||||
aria-label="Show sidebar">
|
||||
${PANEL_SVG}
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="sw-chat-surface__workspace-header-right">
|
||||
<${NotificationBell} onNavigate=${onChannelChange} />
|
||||
</div>
|
||||
</div>
|
||||
<${ChatDashboard} onNewChat=${onNewChat} onCreateChannel=${onCreateChannel}
|
||||
items=${sidebar?.items} onNavigate=${onChannelChange} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__workspace">
|
||||
<div class="sw-chat-surface__workspace-header">
|
||||
<div class="sw-chat-surface__workspace-header-left">
|
||||
${!sidebarOpen && html`
|
||||
<button class="sw-chat-surface__sidebar-toggle"
|
||||
onClick=${onToggleSidebar}
|
||||
title="Show sidebar"
|
||||
aria-label="Show sidebar">
|
||||
${PANEL_SVG}
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="sw-chat-surface__workspace-header-right">
|
||||
${showModelSelector && html`
|
||||
<${ModelSelector}
|
||||
value=${selectedModel}
|
||||
onChange=${setSelectedModel} />`}
|
||||
<button class="sw-chat-surface__header-btn"
|
||||
onClick=${() => setMembersOpen(v => !v)}
|
||||
title="Members"
|
||||
aria-label="Members">
|
||||
${PEOPLE_SVG}
|
||||
</button>
|
||||
<button class="sw-chat-surface__header-btn"
|
||||
onClick=${() => setSettingsOpen(v => !v)}
|
||||
title="Channel settings"
|
||||
aria-label="Channel settings">
|
||||
${GEAR_SVG}
|
||||
</button>
|
||||
<${NotificationBell} onNavigate=${onChannelChange} />
|
||||
${toolsButton}
|
||||
</div>
|
||||
</div>
|
||||
<${ChatPane}
|
||||
channelId=${activeId}
|
||||
standalone=${false}
|
||||
modelId=${selectedModel}
|
||||
handleRef=${chatRef}
|
||||
onChannelChange=${onChannelChange}
|
||||
className="sw-chat-surface__chat-pane" />
|
||||
<${ChannelMembersPanel}
|
||||
open=${membersOpen}
|
||||
channelId=${activeId}
|
||||
onClose=${() => setMembersOpen(false)} />
|
||||
<${ChannelSettingsPanel}
|
||||
open=${settingsOpen}
|
||||
channelId=${activeId}
|
||||
onClose=${() => setSettingsOpen(false)} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Dashboard (no chat selected) ────────────
|
||||
|
||||
function ChatDashboard({ onNewChat, onCreateChannel, items, onNavigate }) {
|
||||
const recentItems = (items || []).slice(0, 8);
|
||||
const [showDmPicker, setShowDmPicker] = useState(false);
|
||||
|
||||
async function _create(type) {
|
||||
if (type === 'dm') {
|
||||
setShowDmPicker(true);
|
||||
return;
|
||||
}
|
||||
const label = type === 'channel' ? 'Channel' : 'Group';
|
||||
const name = await window.sw.prompt(label + ' name:', '');
|
||||
if (name && name.trim()) onCreateChannel(type, name.trim());
|
||||
}
|
||||
|
||||
const _onDmSelect = useCallback((user) => {
|
||||
setShowDmPicker(false);
|
||||
const name = user.display_name || user.username;
|
||||
onCreateChannel('dm', 'DM: ' + name, { participant: user.id });
|
||||
}, [onCreateChannel]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-dashboard">
|
||||
<div class="sw-chat-dashboard__welcome">
|
||||
<h2 class="sw-chat-dashboard__title">Chat Switchboard</h2>
|
||||
<p class="sw-chat-dashboard__hint">Start a conversation or browse your channels.</p>
|
||||
</div>
|
||||
<div class="sw-chat-dashboard__actions">
|
||||
<button class="sw-chat-dashboard__card" onClick=${onNewChat}>
|
||||
<span class="sw-chat-dashboard__card-icon">\u{1F4AC}</span>
|
||||
<span class="sw-chat-dashboard__card-label">New AI Chat</span>
|
||||
</button>
|
||||
${sw.can('channel.create') && html`
|
||||
<button class="sw-chat-dashboard__card" onClick=${() => _create('channel')}>
|
||||
<span class="sw-chat-dashboard__card-icon">#</span>
|
||||
<span class="sw-chat-dashboard__card-label">New Channel</span>
|
||||
</button>`}
|
||||
${sw.can('channel.create') && html`
|
||||
<button class="sw-chat-dashboard__card" onClick=${() => _create('group')}>
|
||||
<span class="sw-chat-dashboard__card-icon">\u{1F465}</span>
|
||||
<span class="sw-chat-dashboard__card-label">New Group</span>
|
||||
</button>`}
|
||||
<button class="sw-chat-dashboard__card" onClick=${() => _create('dm')}>
|
||||
<span class="sw-chat-dashboard__card-icon">\u{1F4E8}</span>
|
||||
<span class="sw-chat-dashboard__card-label">Direct Message</span>
|
||||
</button>
|
||||
</div>
|
||||
${showDmPicker && html`
|
||||
<div class="sw-chat-dashboard__dm-picker">
|
||||
<h3 class="sw-chat-dashboard__section-title">Start a DM</h3>
|
||||
<${UserPicker}
|
||||
onSelect=${_onDmSelect}
|
||||
placeholder="Search for a user\u2026"
|
||||
autoFocus=${true} />
|
||||
<button class="btn-small"
|
||||
style="margin-top: 8px;"
|
||||
onClick=${() => setShowDmPicker(false)}>Cancel</button>
|
||||
</div>`}
|
||||
${recentItems.length > 0 && html`
|
||||
<div class="sw-chat-dashboard__recent">
|
||||
<h3 class="sw-chat-dashboard__section-title">Recent</h3>
|
||||
<div class="sw-chat-dashboard__recent-list">
|
||||
${recentItems.map(ch => html`
|
||||
<div class="sw-chat-dashboard__recent-item" key=${ch.id}
|
||||
onClick=${() => onNavigate?.(ch.id)}
|
||||
style="cursor: pointer">
|
||||
<span class="sw-chat-dashboard__recent-icon">
|
||||
${ch.type === 'channel' || ch.type === 'group' ? '#' : '\u{1F4AC}'}
|
||||
</span>
|
||||
<span class="sw-chat-dashboard__recent-title">${ch.title || 'Untitled'}</span>
|
||||
</div>`)}
|
||||
</div>
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — Root Component
|
||||
// ==========================================
|
||||
// Main chat surface: sidebar + workspace.
|
||||
// Follows the settings/admin mount pattern.
|
||||
//
|
||||
// v0.37.10: New Preact surface replacing the legacy SPA.
|
||||
|
||||
import { ToastContainer } from '../../primitives/toast.js';
|
||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
import { useWorkspace } from './use-workspace.js';
|
||||
import { useSidebar } from './use-sidebar.js';
|
||||
import { Sidebar } from './sidebar.js';
|
||||
import { ChatWorkspace } from './chat-workspace.js';
|
||||
import { useTools, ToolsPopup } from './tools-popup.js';
|
||||
|
||||
const { render } = window.preact;
|
||||
const html = window.html;
|
||||
const { useState, useCallback } = window.hooks;
|
||||
|
||||
const WRENCH_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0
|
||||
0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1
|
||||
7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>`;
|
||||
|
||||
function ChatSurface() {
|
||||
const workspace = useWorkspace();
|
||||
const sidebar = useSidebar({ activeId: workspace.activeId });
|
||||
const tools = useTools();
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
|
||||
const _onNewChat = useCallback(async () => {
|
||||
try {
|
||||
const resp = await window.sw.api.channels.create({ type: 'direct', title: 'New Chat' });
|
||||
const ch = resp;
|
||||
if (ch?.id) {
|
||||
sidebar.reload();
|
||||
workspace.select(ch.id, 'direct');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[chat] New chat failed:', err);
|
||||
window.sw?.toast?.('Failed to create chat', 'error');
|
||||
}
|
||||
}, [workspace.select, sidebar.reload]);
|
||||
|
||||
const _onCreateChannel = useCallback(async (type, title, opts) => {
|
||||
try {
|
||||
const body = { type, title };
|
||||
if (opts?.participant) body.participants = [opts.participant];
|
||||
const resp = await window.sw.api.channels.create(body);
|
||||
const ch = resp;
|
||||
if (ch?.id) {
|
||||
sidebar.reload();
|
||||
workspace.select(ch.id, type);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[chat] Create channel failed:', err);
|
||||
window.sw?.toast?.('Failed to create ' + type, 'error');
|
||||
}
|
||||
}, [workspace.select, sidebar.reload]);
|
||||
|
||||
const _onDeleteChat = useCallback(async (id) => {
|
||||
await sidebar.deleteChat(id);
|
||||
if (id === workspace.activeId) workspace.clearSelection();
|
||||
}, [sidebar.deleteChat, workspace.activeId, workspace.clearSelection]);
|
||||
|
||||
const _onChannelChange = useCallback((id) => {
|
||||
if (id) workspace.select(id, 'direct');
|
||||
else workspace.clearSelection();
|
||||
}, [workspace.select, workspace.clearSelection]);
|
||||
|
||||
const _toggleSidebar = useCallback(() => {
|
||||
workspace.setSidebarOpen(!workspace.sidebarOpen);
|
||||
}, [workspace.sidebarOpen, workspace.setSidebarOpen]);
|
||||
|
||||
const _toggleTools = useCallback(() => {
|
||||
setToolsOpen(v => !v);
|
||||
}, []);
|
||||
|
||||
const _closeTools = useCallback(() => {
|
||||
setToolsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Tools button — shared between mobile bar and workspace header
|
||||
const toolsButton = html`
|
||||
<button class="sw-chat-surface__tools-btn"
|
||||
onClick=${_toggleTools}
|
||||
title="Tools"
|
||||
aria-label="Toggle tools"
|
||||
aria-expanded=${toolsOpen}>
|
||||
${WRENCH_SVG}
|
||||
${tools.disabled.size > 0 && html`
|
||||
<span class="sw-chat-surface__tools-badge">${tools.disabled.size}</span>`}
|
||||
</button>`;
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface">
|
||||
${/* Mobile sidebar overlay */''}
|
||||
${workspace.sidebarOpen && html`
|
||||
<div class="sw-chat-surface__sidebar-overlay"
|
||||
onClick=${_toggleSidebar} />`}
|
||||
|
||||
${/* Sidebar */''}
|
||||
${workspace.sidebarOpen && html`
|
||||
<${Sidebar}
|
||||
sidebar=${sidebar}
|
||||
activeId=${workspace.activeId}
|
||||
onSelect=${workspace.select}
|
||||
onNewChat=${_onNewChat}
|
||||
onCreateChannel=${_onCreateChannel}
|
||||
onDeleteChat=${_onDeleteChat}
|
||||
onCollapse=${_toggleSidebar} />`}
|
||||
|
||||
${/* Main workspace */''}
|
||||
<div class="sw-chat-surface__main">
|
||||
<${ChatWorkspace}
|
||||
activeId=${workspace.activeId}
|
||||
activeType=${workspace.activeType}
|
||||
channelType=${workspace.channelType}
|
||||
onChannelChange=${_onChannelChange}
|
||||
onNewChat=${_onNewChat}
|
||||
onCreateChannel=${_onCreateChannel}
|
||||
sidebarOpen=${workspace.sidebarOpen}
|
||||
onToggleSidebar=${_toggleSidebar}
|
||||
toolsButton=${toolsButton}
|
||||
sidebar=${sidebar} />
|
||||
<${ToolsPopup}
|
||||
open=${toolsOpen}
|
||||
onClose=${_closeTools}
|
||||
categories=${tools.categories}
|
||||
disabled=${tools.disabled}
|
||||
onToggle=${tools.toggle}
|
||||
onToggleCategory=${tools.toggleCategory} />
|
||||
</div>
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Mount ────────────────────────────────────
|
||||
const mount = document.getElementById('chat-mount');
|
||||
if (mount) {
|
||||
render(html`<${ChatSurface} />`, mount);
|
||||
console.log('[chat] Surface mounted (v0.37.10)');
|
||||
}
|
||||
|
||||
export { ChatSurface };
|
||||
@@ -1,361 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — SidebarItems Component
|
||||
// ==========================================
|
||||
// Unified channel list with folder grouping, context menus,
|
||||
// nested folders, and HTML5 drag-and-drop.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback, useRef, useEffect } = window.hooks;
|
||||
|
||||
// ── Icons ────────────────────────────────
|
||||
|
||||
const CHAT_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>`;
|
||||
|
||||
const HASH_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="9" x2="20" y2="9"/>
|
||||
<line x1="4" y1="15" x2="20" y2="15"/>
|
||||
<line x1="10" y1="3" x2="8" y2="21"/>
|
||||
<line x1="16" y1="3" x2="14" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
const FOLDER_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>`;
|
||||
|
||||
const DOTS_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
|
||||
</svg>`;
|
||||
|
||||
const CHEVRON_SVG = html`
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>`;
|
||||
|
||||
function _iconForType(type) {
|
||||
if (type === 'channel' || type === 'group') return HASH_SVG;
|
||||
return CHAT_SVG;
|
||||
}
|
||||
|
||||
// Max nesting depth for folders
|
||||
const MAX_DEPTH = 3;
|
||||
|
||||
// ── Main Component ───────────────────────
|
||||
|
||||
export function SidebarItems({
|
||||
itemsByFolder, folders, folderTree, activeId, onSelect,
|
||||
onRename, onDelete, onMoveToFolder, onMoveFolderTo, onCreateFolder,
|
||||
onRenameFolder, onDeleteFolder,
|
||||
}) {
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(null); // { id, type } of drop target
|
||||
const [dragging, setDragging] = useState(false); // true while any drag in progress
|
||||
const [collapsedFolders, setCollapsedFolders] = useState({});
|
||||
const menuRef = useRef(null);
|
||||
|
||||
// ── Context menu close ──────────────
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
function _close(e) {
|
||||
if (e.type === 'keydown' && e.key !== 'Escape') return;
|
||||
if (e.type === 'click' && menuRef.current?.contains(e.target)) return;
|
||||
setContextMenu(null);
|
||||
}
|
||||
document.addEventListener('click', _close);
|
||||
document.addEventListener('keydown', _close);
|
||||
return () => {
|
||||
document.removeEventListener('click', _close);
|
||||
document.removeEventListener('keydown', _close);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
const _openMenu = useCallback((e, type, item) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const scale = sw.shell.getScale();
|
||||
// The dots button may be display:none (zero rect) — fall back to parent
|
||||
let rect = e.currentTarget.getBoundingClientRect();
|
||||
if (!rect.width && !rect.height) {
|
||||
const parent = e.currentTarget.closest('.sw-chat-surface__item, .sw-chat-surface__folder-header');
|
||||
if (parent) rect = parent.getBoundingClientRect();
|
||||
}
|
||||
const x = rect.right / scale;
|
||||
const y = rect.bottom / scale;
|
||||
setContextMenu({ type, item, x, y });
|
||||
}, []);
|
||||
|
||||
const _bodyContextMenu = useCallback((e) => {
|
||||
if (e.target.closest('.sw-chat-surface__item') || e.target.closest('.sw-chat-surface__folder-header')) return;
|
||||
e.preventDefault();
|
||||
const scale = sw.shell.getScale();
|
||||
setContextMenu({ type: 'body', item: null, x: e.clientX / scale, y: e.clientY / scale });
|
||||
}, []);
|
||||
|
||||
const _action = useCallback(async (action, item) => {
|
||||
setContextMenu(null);
|
||||
if (action === 'rename') {
|
||||
const newTitle = await window.sw.prompt('Rename:', item.title || item.name || '');
|
||||
if (newTitle && newTitle.trim()) {
|
||||
if (item.name !== undefined) await onRenameFolder(item.id, newTitle.trim());
|
||||
else await onRename(item.id, newTitle.trim());
|
||||
}
|
||||
} else if (action === 'delete') {
|
||||
const label = item.name !== undefined ? 'folder' : 'conversation';
|
||||
const ok = await window.sw.confirm('Delete this ' + label + '?', true);
|
||||
if (ok) {
|
||||
if (item.name !== undefined) await onDeleteFolder(item.id);
|
||||
else await onDelete(item.id);
|
||||
}
|
||||
} else if (action === 'move-out') {
|
||||
await onMoveToFolder(item.id, null);
|
||||
} else if (action === 'new-folder') {
|
||||
const name = await window.sw.prompt('Folder name:', '');
|
||||
if (name && name.trim()) await onCreateFolder(name.trim());
|
||||
} else if (action === 'new-subfolder') {
|
||||
const name = await window.sw.prompt('Subfolder name:', '');
|
||||
if (name && name.trim()) await onCreateFolder(name.trim(), item.id);
|
||||
} else if (action === 'unnest-folder') {
|
||||
await onMoveFolderTo(item.id, null);
|
||||
} else if (action.startsWith('move-to:')) {
|
||||
const fid = action.slice(8);
|
||||
await onMoveToFolder(item.id, fid);
|
||||
}
|
||||
}, [onRename, onDelete, onMoveToFolder, onMoveFolderTo, onCreateFolder, onRenameFolder, onDeleteFolder]);
|
||||
|
||||
// ── Folder collapse toggle ──────────
|
||||
const _toggleFolder = useCallback((folderId) => {
|
||||
setCollapsedFolders(prev => ({ ...prev, [folderId]: !prev[folderId] }));
|
||||
}, []);
|
||||
|
||||
// ── Drag & Drop ─────────────────────
|
||||
const _onDragStart = useCallback((e, dragType, item) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ dragType, id: item.id }));
|
||||
setDragging(true);
|
||||
}, []);
|
||||
|
||||
const _onDragOver = useCallback((e, targetId, targetType) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOver({ id: targetId, type: targetType });
|
||||
}, []);
|
||||
|
||||
const _onDragLeave = useCallback(() => {
|
||||
setDragOver(null);
|
||||
}, []);
|
||||
|
||||
const _onDropOnFolder = useCallback((e, folderId) => {
|
||||
e.preventDefault();
|
||||
setDragOver(null);
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
||||
if (data.dragType === 'channel') {
|
||||
onMoveToFolder(data.id, folderId);
|
||||
} else if (data.dragType === 'folder' && data.id !== folderId) {
|
||||
onMoveFolderTo(data.id, folderId);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, [onMoveToFolder, onMoveFolderTo]);
|
||||
|
||||
const _onDropOnRoot = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(null);
|
||||
setDragging(false);
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
||||
if (data.dragType === 'channel') {
|
||||
onMoveToFolder(data.id, null);
|
||||
} else if (data.dragType === 'folder') {
|
||||
onMoveFolderTo(data.id, null);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, [onMoveToFolder, onMoveFolderTo]);
|
||||
|
||||
const _onDragEnd = useCallback(() => {
|
||||
setDragging(false);
|
||||
setDragOver(null);
|
||||
}, []);
|
||||
|
||||
// ── Render ───────────────────────────
|
||||
const unfolderedItems = itemsByFolder[''] || [];
|
||||
const totalItems = Object.values(itemsByFolder).reduce((sum, arr) => sum + arr.length, 0);
|
||||
const tree = folderTree || [];
|
||||
|
||||
const rootDropActive = dragging && dragOver?.id === '__root' && dragOver?.type === 'root';
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__section-body"
|
||||
role="listbox"
|
||||
onContextMenu=${_bodyContextMenu}
|
||||
onDragOver=${(e) => { e.preventDefault(); }}
|
||||
onDrop=${_onDropOnRoot}
|
||||
onDragEnd=${_onDragEnd}>
|
||||
${/* Render folder tree recursively */''}
|
||||
${tree.map(node => html`
|
||||
<${FolderNode}
|
||||
key=${'f-' + node.id}
|
||||
node=${node}
|
||||
depth=${0}
|
||||
itemsByFolder=${itemsByFolder}
|
||||
activeId=${activeId}
|
||||
collapsedFolders=${collapsedFolders}
|
||||
dragOver=${dragOver}
|
||||
onSelect=${onSelect}
|
||||
onOpenMenu=${_openMenu}
|
||||
onToggleFolder=${_toggleFolder}
|
||||
onDragStart=${_onDragStart}
|
||||
onDragOver=${_onDragOver}
|
||||
onDragLeave=${_onDragLeave}
|
||||
onDropOnFolder=${_onDropOnFolder} />`)}
|
||||
${/* Unfoldered items */''}
|
||||
${unfolderedItems.map(ch => html`
|
||||
<${ChannelItem} key=${ch.id} channel=${ch} activeId=${activeId}
|
||||
onSelect=${onSelect} onOpenMenu=${_openMenu}
|
||||
onDragStart=${_onDragStart} />`)}
|
||||
${totalItems === 0 && tree.length === 0 && html`
|
||||
<div class="sw-chat-surface__empty">No conversations yet</div>`}
|
||||
${/* Visible root drop zone — shown during drag */''}
|
||||
${dragging && tree.length > 0 && html`
|
||||
<div class=${'sw-chat-surface__root-drop' + (rootDropActive ? ' sw-chat-surface__root-drop--active' : '')}
|
||||
onDragOver=${(e) => _onDragOver(e, '__root', 'root')}
|
||||
onDragLeave=${_onDragLeave}
|
||||
onDrop=${_onDropOnRoot}>
|
||||
Drop here to remove from folder
|
||||
</div>`}
|
||||
</div>
|
||||
|
||||
${/* Context Menu */''}
|
||||
${contextMenu && html`
|
||||
<div class="sw-chat-surface__context-menu" ref=${menuRef}
|
||||
style=${'left:' + contextMenu.x + 'px;top:' + contextMenu.y + 'px;'}>
|
||||
${contextMenu.type === 'chat' && html`
|
||||
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
||||
${folders.length > 0 && folders.map(f => html`
|
||||
<button key=${f.id} onClick=${() => _action('move-to:' + f.id, contextMenu.item)}>
|
||||
Move to ${f.name}
|
||||
</button>`)}
|
||||
${contextMenu.item.folder_id && html`
|
||||
<button onClick=${() => _action('move-out', contextMenu.item)}>Remove from folder</button>`}
|
||||
<button class="sw-chat-surface__ctx-danger"
|
||||
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
||||
`}
|
||||
${contextMenu.type === 'folder' && html`
|
||||
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
||||
<button onClick=${() => _action('new-subfolder', contextMenu.item)}>New subfolder</button>
|
||||
${contextMenu.item.parent_id && html`
|
||||
<button onClick=${() => _action('unnest-folder', contextMenu.item)}>Move to root</button>`}
|
||||
<button class="sw-chat-surface__ctx-danger"
|
||||
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
||||
`}
|
||||
${contextMenu.type === 'body' && html`
|
||||
<button onClick=${() => _action('new-folder', null)}>New Folder</button>
|
||||
`}
|
||||
</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
// ── FolderNode (recursive) ───────────────
|
||||
|
||||
function FolderNode({
|
||||
node, depth, itemsByFolder, activeId, collapsedFolders, dragOver,
|
||||
onSelect, onOpenMenu, onToggleFolder,
|
||||
onDragStart, onDragOver, onDragLeave, onDropOnFolder,
|
||||
}) {
|
||||
const folderItems = itemsByFolder[node.id] || [];
|
||||
const totalCount = folderItems.length + (node.children || []).length;
|
||||
const isCollapsed = !!collapsedFolders[node.id];
|
||||
const isDragTarget = dragOver?.id === node.id && dragOver?.type === 'folder';
|
||||
const hasChildren = (node.children?.length > 0) || folderItems.length > 0;
|
||||
const indent = depth * 12;
|
||||
|
||||
return html`
|
||||
<div key=${'f-' + node.id}
|
||||
class=${'sw-chat-surface__folder' + (isDragTarget ? ' sw-chat-surface__folder--drag-over' : '')}
|
||||
style=${indent ? 'padding-left:' + indent + 'px' : ''}
|
||||
draggable=${depth < MAX_DEPTH}
|
||||
onDragStart=${(e) => { e.stopPropagation(); onDragStart(e, 'folder', node); }}
|
||||
onDragOver=${(e) => { if (depth < MAX_DEPTH - 1) onDragOver(e, node.id, 'folder'); }}
|
||||
onDragLeave=${onDragLeave}
|
||||
onDrop=${(e) => { e.stopPropagation(); onDropOnFolder(e, node.id); }}>
|
||||
<div class="sw-chat-surface__folder-header"
|
||||
onClick=${() => onToggleFolder(node.id)}>
|
||||
<span class=${'sw-chat-surface__chevron' + (isCollapsed ? '' : ' sw-chat-surface__chevron--open')}>
|
||||
${CHEVRON_SVG}
|
||||
</span>
|
||||
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
|
||||
<span class="sw-chat-surface__folder-name">${node.name}</span>
|
||||
<span class="sw-chat-surface__folder-count">${totalCount}</span>
|
||||
<button class="sw-chat-surface__item-menu"
|
||||
onClick=${(e) => { e.stopPropagation(); onOpenMenu(e, 'folder', node); }}
|
||||
title="Folder actions"
|
||||
aria-label="Folder actions">
|
||||
${DOTS_SVG}
|
||||
</button>
|
||||
</div>
|
||||
${!isCollapsed && html`
|
||||
${/* Child folders */''}
|
||||
${(node.children || []).map(child => html`
|
||||
<${FolderNode}
|
||||
key=${'f-' + child.id}
|
||||
node=${child}
|
||||
depth=${depth + 1}
|
||||
itemsByFolder=${itemsByFolder}
|
||||
activeId=${activeId}
|
||||
collapsedFolders=${collapsedFolders}
|
||||
dragOver=${dragOver}
|
||||
onSelect=${onSelect}
|
||||
onOpenMenu=${onOpenMenu}
|
||||
onToggleFolder=${onToggleFolder}
|
||||
onDragStart=${onDragStart}
|
||||
onDragOver=${onDragOver}
|
||||
onDragLeave=${onDragLeave}
|
||||
onDropOnFolder=${onDropOnFolder} />`)}
|
||||
${/* Channel items in this folder */''}
|
||||
${folderItems.map(ch => html`
|
||||
<${ChannelItem} key=${ch.id} channel=${ch} activeId=${activeId}
|
||||
onSelect=${onSelect} onOpenMenu=${onOpenMenu}
|
||||
onDragStart=${onDragStart}
|
||||
indent=${true} />`)}
|
||||
`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── ChannelItem ──────────────────────────
|
||||
|
||||
function ChannelItem({ channel, activeId, onSelect, onOpenMenu, onDragStart, indent }) {
|
||||
const icon = _iconForType(channel.type);
|
||||
return html`
|
||||
<button class=${'sw-chat-surface__item' + (channel.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
|
||||
onClick=${() => onSelect(channel.id, channel.type)}
|
||||
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', channel); }}
|
||||
draggable=${true}
|
||||
onDragStart=${(e) => onDragStart(e, 'channel', channel)}
|
||||
role="option"
|
||||
aria-selected=${channel.id === activeId}
|
||||
title=${channel.title || 'Untitled'}>
|
||||
<span class="sw-chat-surface__item-icon">${icon}</span>
|
||||
<span class="sw-chat-surface__item-title">${channel.title || 'Untitled'}</span>
|
||||
${(channel.unread_count || 0) > 0 && html`
|
||||
<span class="sw-chat-surface__unread">${channel.unread_count}</span>`}
|
||||
<button class="sw-chat-surface__item-menu"
|
||||
onClick=${(e) => { e.stopPropagation(); onOpenMenu(e, 'chat', channel); }}
|
||||
title="Actions"
|
||||
aria-label="Actions">
|
||||
${DOTS_SVG}
|
||||
</button>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// Scale detection moved to sw.shell.getScale() in v0.37.19 (CR P3-3).
|
||||
|
||||
// Folder count = direct children only (subfolders + channels in this folder).
|
||||
@@ -1,186 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — Sidebar Component
|
||||
// ==========================================
|
||||
// Search bar, new chat, unified channel list with folders.
|
||||
// Footer has UserMenu (avatar) — all navigation lives in the menu flyout.
|
||||
|
||||
import { SidebarItems } from './sidebar-chats.js';
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
import { UserPicker } from '../../primitives/user-picker.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback, useRef, useEffect } = window.hooks;
|
||||
|
||||
const PLUS_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>`;
|
||||
|
||||
const SEARCH_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>`;
|
||||
|
||||
const COLLAPSE_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
const FOLDER_PLUS_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* sidebar: ReturnType<import('./use-sidebar.js').useSidebar>,
|
||||
* activeId: string|null,
|
||||
* onSelect: (id: string, type: string) => void,
|
||||
* onNewChat: () => void,
|
||||
* onCollapse: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function Sidebar({ sidebar, activeId, onSelect, onNewChat, onCreateChannel, onDeleteChat, onCollapse }) {
|
||||
const [newMenuOpen, setNewMenuOpen] = useState(false);
|
||||
const [showDmPicker, setShowDmPicker] = useState(false);
|
||||
const newMenuRef = useRef(null);
|
||||
|
||||
const _onSearch = useCallback((e) => {
|
||||
sidebar.setSearch(e.target.value);
|
||||
}, [sidebar.setSearch]);
|
||||
|
||||
const _onNewFolder = useCallback(async () => {
|
||||
const name = await window.sw.prompt('Folder name:', '');
|
||||
if (name && name.trim()) sidebar.createFolder(name.trim());
|
||||
}, [sidebar.createFolder]);
|
||||
|
||||
// Close new-channel dropdown on outside click / Escape
|
||||
useEffect(() => {
|
||||
if (!newMenuOpen) return;
|
||||
function _close(e) {
|
||||
if (e.type === 'keydown' && e.key !== 'Escape') return;
|
||||
if (e.type === 'click' && newMenuRef.current?.contains(e.target)) return;
|
||||
setNewMenuOpen(false);
|
||||
}
|
||||
document.addEventListener('click', _close);
|
||||
document.addEventListener('keydown', _close);
|
||||
return () => {
|
||||
document.removeEventListener('click', _close);
|
||||
document.removeEventListener('keydown', _close);
|
||||
};
|
||||
}, [newMenuOpen]);
|
||||
|
||||
const _newChannel = useCallback(async (type) => {
|
||||
setNewMenuOpen(false);
|
||||
if (type === 'direct') {
|
||||
onNewChat();
|
||||
} else if (type === 'dm') {
|
||||
setShowDmPicker(true);
|
||||
} else {
|
||||
const name = await window.sw.prompt((type === 'channel' ? 'Channel' : 'Group') + ' name:', '');
|
||||
if (name && name.trim()) onCreateChannel(type, name.trim());
|
||||
}
|
||||
}, [onNewChat, onCreateChannel]);
|
||||
|
||||
const _onDmSelect = useCallback((user) => {
|
||||
setShowDmPicker(false);
|
||||
const name = user.display_name || user.username;
|
||||
onCreateChannel('dm', 'DM: ' + name, { participant: user.id });
|
||||
}, [onCreateChannel]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__sidebar" role="navigation" aria-label="Chat sidebar">
|
||||
${/* Header: new chat + collapse */''}
|
||||
<div class="sw-chat-surface__sidebar-header">
|
||||
<div class="sw-chat-surface__new-menu-wrap" ref=${newMenuRef}>
|
||||
<button class="sw-chat-surface__new-chat-btn"
|
||||
onClick=${() => setNewMenuOpen(!newMenuOpen)}
|
||||
title="New conversation"
|
||||
aria-label="New conversation">
|
||||
${PLUS_SVG}
|
||||
<span>New</span>
|
||||
</button>
|
||||
${newMenuOpen && html`
|
||||
<div class="sw-chat-surface__new-menu">
|
||||
<button onClick=${() => _newChannel('direct')}>
|
||||
<span class="sw-chat-surface__new-menu-icon">\u{1F4AC}</span> AI Chat
|
||||
</button>
|
||||
${sw.can('channel.create') && html`<button onClick=${() => _newChannel('channel')}>
|
||||
<span class="sw-chat-surface__new-menu-icon">#</span> Channel
|
||||
</button>`}
|
||||
${sw.can('channel.create') && html`<button onClick=${() => _newChannel('group')}>
|
||||
<span class="sw-chat-surface__new-menu-icon">\u{1F465}</span> Group
|
||||
</button>`}
|
||||
<button onClick=${() => _newChannel('dm')}>
|
||||
<span class="sw-chat-surface__new-menu-icon">\u{1F4E8}</span> Direct Message
|
||||
</button>
|
||||
</div>`}
|
||||
</div>
|
||||
<button class="sw-chat-surface__new-folder-btn"
|
||||
onClick=${_onNewFolder}
|
||||
title="New folder"
|
||||
aria-label="Create new folder">
|
||||
${FOLDER_PLUS_SVG}
|
||||
</button>
|
||||
<button class="sw-chat-surface__collapse-btn"
|
||||
onClick=${onCollapse}
|
||||
title="Collapse sidebar"
|
||||
aria-label="Collapse sidebar">
|
||||
${COLLAPSE_SVG}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${showDmPicker && html`
|
||||
<div style="padding: 6px 8px;">
|
||||
<${UserPicker}
|
||||
onSelect=${_onDmSelect}
|
||||
placeholder="Search for a user\u2026"
|
||||
autoFocus=${true} />
|
||||
<button class="btn-small"
|
||||
style="margin-top: 4px; width: 100%;"
|
||||
onClick=${() => setShowDmPicker(false)}>Cancel</button>
|
||||
</div>`}
|
||||
|
||||
${/* Search */''}
|
||||
<div class="sw-chat-surface__search-wrap">
|
||||
<span class="sw-chat-surface__search-icon">${SEARCH_SVG}</span>
|
||||
<input class="sw-chat-surface__search"
|
||||
type="text"
|
||||
placeholder="Search\u2026"
|
||||
value=${sidebar.search}
|
||||
onInput=${_onSearch}
|
||||
aria-label="Search chats and channels" />
|
||||
</div>
|
||||
|
||||
${/* Scrollable content */''}
|
||||
<div class="sw-chat-surface__sidebar-body">
|
||||
<${SidebarItems}
|
||||
itemsByFolder=${sidebar.itemsByFolder}
|
||||
folders=${sidebar.folders}
|
||||
folderTree=${sidebar.folderTree}
|
||||
activeId=${activeId}
|
||||
onSelect=${onSelect}
|
||||
onRename=${sidebar.renameChat}
|
||||
onDelete=${onDeleteChat || sidebar.deleteChat}
|
||||
onMoveToFolder=${sidebar.moveToFolder}
|
||||
onMoveFolderTo=${sidebar.moveFolderTo}
|
||||
onCreateFolder=${sidebar.createFolder}
|
||||
onRenameFolder=${sidebar.renameFolder}
|
||||
onDeleteFolder=${sidebar.deleteFolder} />
|
||||
</div>
|
||||
|
||||
${/* Footer: UserMenu only */''}
|
||||
<div class="sw-chat-surface__sidebar-footer">
|
||||
<${UserMenu} placement="up-right" />
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — ToolsPopup Component
|
||||
// ==========================================
|
||||
// Scrollable popup anchored to a toolbar icon.
|
||||
// Category-based tool toggles with persistence to localStorage.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useEffect, useRef, useCallback, useMemo } = window.hooks;
|
||||
|
||||
const STORAGE_KEY = 'sw-disabled-tools';
|
||||
|
||||
/**
|
||||
* Load tools list from API and organize by category.
|
||||
* @returns {{ categories: Array, disabled: Set, toggle: Function, getDisabled: Function }}
|
||||
*/
|
||||
export function useTools() {
|
||||
const [tools, setTools] = useState([]);
|
||||
const [disabled, setDisabled] = useState(_loadDisabled);
|
||||
|
||||
useEffect(() => {
|
||||
// Load available tools from API
|
||||
if (window.sw?.api?.tools?.list) {
|
||||
window.sw.api.tools.list().then(resp => {
|
||||
setTools(resp || []);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const map = {};
|
||||
for (const t of tools) {
|
||||
const cat = t.category || 'Other';
|
||||
if (!map[cat]) map[cat] = [];
|
||||
map[cat].push(t);
|
||||
}
|
||||
return Object.entries(map).map(([name, items]) => ({ name, items }));
|
||||
}, [tools]);
|
||||
|
||||
const toggle = useCallback((toolName) => {
|
||||
setDisabled(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(toolName)) next.delete(toolName);
|
||||
else next.add(toolName);
|
||||
_saveDisabled(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCategory = useCallback((catName) => {
|
||||
setDisabled(prev => {
|
||||
const cat = categories.find(c => c.name === catName);
|
||||
if (!cat) return prev;
|
||||
const next = new Set(prev);
|
||||
const allDisabled = cat.items.every(t => next.has(t.name));
|
||||
for (const t of cat.items) {
|
||||
if (allDisabled) next.delete(t.name);
|
||||
else next.add(t.name);
|
||||
}
|
||||
_saveDisabled(next);
|
||||
return next;
|
||||
});
|
||||
}, [categories]);
|
||||
|
||||
const getDisabled = useCallback(() => [...disabled], [disabled]);
|
||||
|
||||
return { categories, disabled, toggle, toggleCategory, getDisabled };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* open: boolean,
|
||||
* onClose: () => void,
|
||||
* categories: Array<{name: string, items: Array<{name: string, description?: string}>}>,
|
||||
* disabled: Set<string>,
|
||||
* onToggle: (name: string) => void,
|
||||
* onToggleCategory: (catName: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function ToolsPopup({ open, onClose, categories, disabled, onToggle, onToggleCategory }) {
|
||||
const panelRef = useRef(null);
|
||||
const [expandedCats, setExpandedCats] = useState({});
|
||||
|
||||
// Close on Escape or outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onKey(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
||||
}
|
||||
function _onClick(e) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target)) onClose();
|
||||
}
|
||||
document.addEventListener('keydown', _onKey, true);
|
||||
setTimeout(() => document.addEventListener('click', _onClick), 0);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', _onKey, true);
|
||||
document.removeEventListener('click', _onClick);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const _toggleCat = useCallback((name) => {
|
||||
setExpandedCats(prev => ({ ...prev, [name]: !prev[name] }));
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const disabledCount = disabled.size;
|
||||
|
||||
return html`
|
||||
<div class="sw-tools-popup" ref=${panelRef} role="dialog" aria-label="Tools toggle">
|
||||
<div class="sw-tools-popup__header">
|
||||
<span class="sw-tools-popup__title">Tools</span>
|
||||
${disabledCount > 0 && html`
|
||||
<span class="sw-tools-popup__badge">${disabledCount} disabled</span>`}
|
||||
</div>
|
||||
<div class="sw-tools-popup__body">
|
||||
${categories.length === 0 && html`
|
||||
<div class="sw-tools-popup__empty">No tools available</div>`}
|
||||
${categories.map(cat => {
|
||||
const allDisabled = cat.items.every(t => disabled.has(t.name));
|
||||
const someDisabled = cat.items.some(t => disabled.has(t.name));
|
||||
const isExpanded = expandedCats[cat.name] !== false; // default open
|
||||
|
||||
return html`
|
||||
<div key=${cat.name} class="sw-tools-popup__category">
|
||||
<div class="sw-tools-popup__cat-header">
|
||||
<button class="sw-tools-popup__cat-toggle"
|
||||
onClick=${() => _toggleCat(cat.name)}
|
||||
aria-expanded=${isExpanded}>
|
||||
<span class=${'sw-tools-popup__chevron' + (isExpanded ? ' sw-tools-popup__chevron--open' : '')}>
|
||||
\u25B6
|
||||
</span>
|
||||
<span>${cat.name}</span>
|
||||
<span class="sw-tools-popup__cat-count">${cat.items.length}</span>
|
||||
</button>
|
||||
<label class="sw-tools-popup__cat-switch">
|
||||
<input type="checkbox"
|
||||
checked=${!allDisabled}
|
||||
indeterminate=${someDisabled && !allDisabled}
|
||||
onChange=${() => onToggleCategory(cat.name)} />
|
||||
</label>
|
||||
</div>
|
||||
${isExpanded && cat.items.map(tool => html`
|
||||
<label key=${tool.name} class="sw-tools-popup__tool">
|
||||
<input type="checkbox"
|
||||
checked=${!disabled.has(tool.name)}
|
||||
onChange=${() => onToggle(tool.name)} />
|
||||
<span class="sw-tools-popup__tool-name">${tool.name}</span>
|
||||
${tool.description && html`
|
||||
<span class="sw-tools-popup__tool-desc">${tool.description}</span>`}
|
||||
</label>`)}
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _loadDisabled() {
|
||||
try {
|
||||
const arr = JSON.parse(localStorage.getItem(STORAGE_KEY));
|
||||
return new Set(arr || []);
|
||||
} catch (_) { return new Set(); }
|
||||
}
|
||||
|
||||
function _saveDisabled(set) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); } catch (_) {}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — useSidebar Hook
|
||||
// ==========================================
|
||||
// Loads and manages sidebar data: all channels (unified)
|
||||
// and folders. Subscribes to WS events for live updates.
|
||||
|
||||
const { useState, useEffect, useCallback, useMemo, useRef } = window.hooks;
|
||||
|
||||
const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
|
||||
|
||||
/**
|
||||
* @param {{ activeId: string|null }} opts
|
||||
*/
|
||||
export function useSidebar(opts = {}) {
|
||||
const { activeId } = opts;
|
||||
|
||||
const [allChannels, setAllChannels] = useState([]);
|
||||
const [folders, setFolders] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [collapsed, setCollapsed] = useState(_loadCollapsed);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const activeIdRef = useRef(activeId);
|
||||
activeIdRef.current = activeId;
|
||||
|
||||
// ── Load data ───────────────────────────
|
||||
const reload = useCallback(async () => {
|
||||
if (!window.sw?.api?.channels?.list) return;
|
||||
|
||||
try {
|
||||
const foldersPromise = window.sw.api.folders?.list
|
||||
? window.sw.api.folders.list().catch(() => [])
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [allResp, fResp] = await Promise.all([
|
||||
window.sw.api.channels.list({ page: 1, per_page: 200, types: 'direct,dm,group,channel' }).catch(() => []),
|
||||
foldersPromise,
|
||||
]);
|
||||
|
||||
const fresh = _extract(allResp);
|
||||
// Zero unread for the active channel (mark-read already fired)
|
||||
const aid = activeIdRef.current;
|
||||
if (aid) {
|
||||
const idx = fresh.findIndex(c => c.id === aid);
|
||||
if (idx !== -1) fresh[idx] = { ...fresh[idx], unread_count: 0 };
|
||||
}
|
||||
setAllChannels(fresh);
|
||||
setFolders(_extract(fResp));
|
||||
} catch (_) {}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// ── Clear unread badge when channel becomes active ──
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
setAllChannels(prev => prev.map(c =>
|
||||
c.id === activeId && c.unread_count > 0
|
||||
? { ...c, unread_count: 0 }
|
||||
: c
|
||||
));
|
||||
}, [activeId]);
|
||||
|
||||
// ── WebSocket live updates ──────────────
|
||||
useEffect(() => {
|
||||
if (!window.sw?.on) return;
|
||||
|
||||
const handlers = {
|
||||
'channel.created': reload,
|
||||
'channel.updated': reload,
|
||||
'channel.deleted': reload,
|
||||
'folder.created': reload,
|
||||
'folder.updated': reload,
|
||||
'folder.deleted': reload,
|
||||
};
|
||||
|
||||
Object.entries(handlers).forEach(([ev, fn]) => window.sw.on(ev, fn));
|
||||
return () => {
|
||||
Object.entries(handlers).forEach(([ev, fn]) => window.sw.off?.(ev, fn));
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
// ── Search filter ───────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return allChannels;
|
||||
const q = search.toLowerCase();
|
||||
return allChannels.filter(c => (c.title || '').toLowerCase().includes(q));
|
||||
}, [allChannels, search]);
|
||||
|
||||
// Group all channels by folder
|
||||
const itemsByFolder = useMemo(() => {
|
||||
const map = { '': [] };
|
||||
for (const f of (folders || [])) map[f.id] = [];
|
||||
for (const c of filtered) {
|
||||
const fid = c.folder_id || '';
|
||||
if (!map[fid]) map[fid] = [];
|
||||
map[fid].push(c);
|
||||
}
|
||||
return map;
|
||||
}, [filtered, folders]);
|
||||
|
||||
// Build nested folder tree (max 3 levels)
|
||||
const folderTree = useMemo(() => {
|
||||
if (!folders || !folders.length) return [];
|
||||
const byId = {};
|
||||
for (const f of folders) byId[f.id] = { ...f, children: [] };
|
||||
const roots = [];
|
||||
for (const f of folders) {
|
||||
const node = byId[f.id];
|
||||
if (f.parent_id && byId[f.parent_id]) {
|
||||
byId[f.parent_id].children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}, [folders]);
|
||||
|
||||
// ── Collapse folders ────────────────────
|
||||
const toggleCollapsed = useCallback((key) => {
|
||||
setCollapsed(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── CRUD helpers ────────────────────────
|
||||
const createFolder = useCallback(async (name, parentId) => {
|
||||
if (!window.sw?.api?.folders?.create) return;
|
||||
await window.sw.api.folders.create(name, parentId ? { parent_id: parentId } : undefined);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const moveFolderTo = useCallback(async (folderId, parentId) => {
|
||||
if (!window.sw?.api?.folders?.update) return;
|
||||
await window.sw.api.folders.update(folderId, { parent_id: parentId || null });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const renameChat = useCallback(async (id, title) => {
|
||||
await window.sw.api.channels.update(id, { title });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const deleteChat = useCallback(async (id) => {
|
||||
await window.sw.api.channels.del(id);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const moveToFolder = useCallback(async (chatId, folderId) => {
|
||||
await window.sw.api.channels.update(chatId, { folder_id: folderId || null });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const renameFolder = useCallback(async (id, name) => {
|
||||
await window.sw.api.folders.update(id, { name });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const deleteFolder = useCallback(async (id) => {
|
||||
await window.sw.api.folders.del(id);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
return {
|
||||
items: filtered,
|
||||
itemsByFolder,
|
||||
folders,
|
||||
folderTree,
|
||||
search,
|
||||
setSearch,
|
||||
collapsed,
|
||||
toggleCollapsed,
|
||||
loading,
|
||||
reload,
|
||||
// CRUD
|
||||
createFolder,
|
||||
renameChat,
|
||||
deleteChat,
|
||||
moveToFolder,
|
||||
moveFolderTo,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
};
|
||||
}
|
||||
|
||||
function _extract(resp) {
|
||||
if (!resp) return [];
|
||||
if (Array.isArray(resp)) return resp;
|
||||
if (Array.isArray(resp.data)) return resp.data;
|
||||
// Handle wrapped responses like { folders: [], channels: [] }
|
||||
const vals = Object.values(resp);
|
||||
for (const v of vals) {
|
||||
if (Array.isArray(v)) return v;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function _loadCollapsed() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — useWorkspace Hook
|
||||
// ==========================================
|
||||
// Coordinator hook: manages active conversation, sidebar state.
|
||||
// Persists active selection to sessionStorage for reload restore.
|
||||
|
||||
const { useState, useCallback, useEffect } = window.hooks;
|
||||
|
||||
const STORAGE_KEY = 'sw-chat-active';
|
||||
|
||||
/**
|
||||
* @returns {{
|
||||
* activeId: string|null,
|
||||
* activeType: 'chat'|'channel'|null,
|
||||
* sidebarOpen: boolean,
|
||||
* setSidebarOpen: (v: boolean) => void,
|
||||
* selectChat: (id: string) => void,
|
||||
* selectChannel: (id: string) => void,
|
||||
* clearSelection: () => void,
|
||||
* }}
|
||||
*/
|
||||
export function useWorkspace() {
|
||||
// Restore from sessionStorage
|
||||
const stored = _loadStored();
|
||||
const [activeId, setActiveId] = useState(stored.id);
|
||||
const [activeType, setActiveType] = useState(stored.type);
|
||||
const [channelType, setChannelType] = useState(stored.channelType || null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
// Persist on change
|
||||
useEffect(() => {
|
||||
if (activeId) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType, channelType }));
|
||||
} catch (_) {}
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}, [activeId, activeType, channelType]);
|
||||
|
||||
const select = useCallback((id, chType) => {
|
||||
setActiveId(id);
|
||||
setChannelType(chType || 'direct');
|
||||
// Map channel model type to workspace type
|
||||
const wsType = (chType === 'channel' || chType === 'group') ? 'channel' : 'chat';
|
||||
setActiveType(wsType);
|
||||
// Auto-close sidebar on mobile
|
||||
if (window.innerWidth < 768) setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
// Keep legacy aliases for any other callers
|
||||
const selectChat = useCallback((id) => select(id, 'direct'), [select]);
|
||||
const selectChannel = useCallback((id) => select(id, 'channel'), [select]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
setChannelType(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeId,
|
||||
activeType,
|
||||
channelType,
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
select,
|
||||
selectChat,
|
||||
selectChannel,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
|
||||
function _loadStored() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw);
|
||||
return { id: obj.id || null, type: obj.type || null, channelType: obj.channelType || null };
|
||||
}
|
||||
} catch (_) {}
|
||||
return { id: null, type: null, channelType: null };
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// ==========================================
|
||||
// Notes Surface — Root Component
|
||||
// ==========================================
|
||||
// Main notes surface: sidebar + workspace.
|
||||
// Follows the chat surface mount pattern.
|
||||
//
|
||||
// v0.37.11: New Preact surface replacing the legacy template mount.
|
||||
|
||||
import { ToastContainer } from '../../primitives/toast.js';
|
||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
import { useWorkspace } from './use-workspace.js';
|
||||
import { useSidebar } from './use-sidebar.js';
|
||||
import { Sidebar } from './sidebar.js';
|
||||
import { NotesWorkspace } from './notes-workspace.js';
|
||||
|
||||
const { render } = window.preact;
|
||||
const html = window.html;
|
||||
const { useRef, useCallback } = window.hooks;
|
||||
|
||||
function NotesSurface() {
|
||||
const workspace = useWorkspace();
|
||||
const sidebar = useSidebar();
|
||||
const notesRef = useRef(null);
|
||||
|
||||
const _onNewNote = useCallback(() => {
|
||||
if (notesRef.current) notesRef.current.newNote();
|
||||
}, []);
|
||||
|
||||
const _toggleSidebar = useCallback(() => {
|
||||
workspace.setSidebarOpen(!workspace.sidebarOpen);
|
||||
}, [workspace.sidebarOpen, workspace.setSidebarOpen]);
|
||||
|
||||
const _onFolderSelect = useCallback((path) => {
|
||||
if (notesRef.current?.setFolder) notesRef.current.setFolder(path);
|
||||
}, []);
|
||||
|
||||
const _onTagSelect = useCallback((tag) => {
|
||||
if (notesRef.current?.setTagFilter) notesRef.current.setTagFilter(tag);
|
||||
}, []);
|
||||
|
||||
const _onSearch = useCallback((query) => {
|
||||
if (notesRef.current?.setSearchQuery) notesRef.current.setSearchQuery(query);
|
||||
}, []);
|
||||
|
||||
const _onNoteChange = useCallback((note) => {
|
||||
if (note?.id) {
|
||||
workspace.selectNote(note.id);
|
||||
}
|
||||
}, [workspace.selectNote]);
|
||||
|
||||
return html`
|
||||
<div class="sw-notes-surface">
|
||||
${/* Mobile sidebar overlay */''}
|
||||
${workspace.sidebarOpen && html`
|
||||
<div class="sw-notes-surface__sidebar-overlay"
|
||||
onClick=${_toggleSidebar} />`}
|
||||
|
||||
${/* Sidebar */''}
|
||||
${workspace.sidebarOpen && html`
|
||||
<${Sidebar}
|
||||
sidebar=${sidebar}
|
||||
onNewNote=${_onNewNote}
|
||||
onCollapse=${_toggleSidebar}
|
||||
onFolderSelect=${_onFolderSelect}
|
||||
onTagSelect=${_onTagSelect}
|
||||
onSearch=${_onSearch} />`}
|
||||
|
||||
${/* Main workspace */''}
|
||||
<div class="sw-notes-surface__main">
|
||||
<${NotesWorkspace}
|
||||
sidebarOpen=${workspace.sidebarOpen}
|
||||
onToggleSidebar=${_toggleSidebar}
|
||||
initialNoteId=${workspace.activeNoteId}
|
||||
onNoteChange=${_onNoteChange}
|
||||
handleRef=${notesRef} />
|
||||
</div>
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Mount ────────────────────────────────────
|
||||
const mount = document.getElementById('notes-mount');
|
||||
if (mount) {
|
||||
render(html`<${NotesSurface} />`, mount);
|
||||
console.log('[notes] Surface mounted (v0.37.11)');
|
||||
}
|
||||
|
||||
export { NotesSurface };
|
||||
@@ -1,56 +0,0 @@
|
||||
// ==========================================
|
||||
// Notes Surface — NotesWorkspace Component
|
||||
// ==========================================
|
||||
// Header bar (sidebar toggle) + NotesPane.
|
||||
// NotesPane runs standalone=false — the surface manages filters.
|
||||
//
|
||||
// v0.37.11
|
||||
|
||||
import { NotesPane } from '../../components/notes-pane/index.js';
|
||||
import { NotificationBell } from '../../shell/notification-bell.js';
|
||||
|
||||
const html = window.html;
|
||||
|
||||
const PANEL_SVG = html`
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* sidebarOpen: boolean,
|
||||
* onToggleSidebar: () => void,
|
||||
* initialNoteId?: string,
|
||||
* onNoteChange?: (note: object|null) => void,
|
||||
* handleRef?: { current: any },
|
||||
* }} props
|
||||
*/
|
||||
export function NotesWorkspace({
|
||||
sidebarOpen, onToggleSidebar, initialNoteId, onNoteChange, handleRef,
|
||||
}) {
|
||||
return html`
|
||||
<div class="sw-notes-surface__workspace">
|
||||
<div class="sw-notes-surface__workspace-header">
|
||||
<div class="sw-notes-surface__workspace-header-left">
|
||||
${!sidebarOpen && html`
|
||||
<button class="sw-notes-surface__sidebar-toggle"
|
||||
onClick=${onToggleSidebar}
|
||||
title="Show sidebar"
|
||||
aria-label="Show sidebar">
|
||||
${PANEL_SVG}
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="sw-notes-surface__workspace-header-right">
|
||||
<${NotificationBell} />
|
||||
</div>
|
||||
</div>
|
||||
<${NotesPane}
|
||||
standalone=${false}
|
||||
initialNoteId=${initialNoteId}
|
||||
handleRef=${handleRef}
|
||||
onNoteChange=${onNoteChange}
|
||||
className="sw-notes-surface__notes-pane" />
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// ==========================================
|
||||
// Notes Surface — SidebarFolders Component
|
||||
// ==========================================
|
||||
// Collapsible folder tree for the notes sidebar.
|
||||
// v0.37.11
|
||||
|
||||
const html = window.html;
|
||||
|
||||
const CHEVRON_SVG = html`
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>`;
|
||||
|
||||
const FOLDER_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* folders: Array<{ path: string, count: number }>,
|
||||
* activeFolder: string,
|
||||
* collapsed: boolean,
|
||||
* onToggle: () => void,
|
||||
* onSelect: (path: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function SidebarFolders({ folders, activeFolder, collapsed, onToggle, onSelect }) {
|
||||
const totalCount = folders.reduce((sum, f) => sum + (f.count || 0), 0);
|
||||
|
||||
return html`
|
||||
<div class="sw-notes-surface__section">
|
||||
<button class="sw-notes-surface__section-header"
|
||||
onClick=${onToggle}
|
||||
aria-expanded=${!collapsed}>
|
||||
<span class=${'sw-notes-surface__chevron' + (collapsed ? '' : ' sw-notes-surface__chevron--open')}>
|
||||
${CHEVRON_SVG}
|
||||
</span>
|
||||
<span class="sw-notes-surface__section-label">Folders</span>
|
||||
${totalCount > 0 && html`
|
||||
<span class="sw-notes-surface__section-count">${totalCount}</span>`}
|
||||
</button>
|
||||
${!collapsed && html`
|
||||
<div class="sw-notes-surface__section-body" role="listbox">
|
||||
${/* "All Notes" clears folder filter */''}
|
||||
<button class=${'sw-notes-surface__item' + (!activeFolder ? ' sw-notes-surface__item--active' : '')}
|
||||
onClick=${() => onSelect('')}
|
||||
role="option"
|
||||
aria-selected=${!activeFolder}>
|
||||
<span class="sw-notes-surface__item-icon">${FOLDER_SVG}</span>
|
||||
<span class="sw-notes-surface__item-title">All Notes</span>
|
||||
</button>
|
||||
${folders.map(f => html`
|
||||
<button key=${f.path}
|
||||
class=${'sw-notes-surface__item' + (activeFolder === f.path ? ' sw-notes-surface__item--active' : '')}
|
||||
onClick=${() => onSelect(f.path)}
|
||||
role="option"
|
||||
aria-selected=${activeFolder === f.path}
|
||||
title=${f.path}>
|
||||
<span class="sw-notes-surface__item-icon">${FOLDER_SVG}</span>
|
||||
<span class="sw-notes-surface__item-title">${f.path}</span>
|
||||
${f.count > 0 && html`
|
||||
<span class="sw-notes-surface__item-count">${f.count}</span>`}
|
||||
</button>`)}
|
||||
${folders.length === 0 && html`
|
||||
<div class="sw-notes-surface__empty">No folders</div>`}
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// ==========================================
|
||||
// Notes Surface — SidebarTags Component
|
||||
// ==========================================
|
||||
// Collapsible tag cloud for the notes sidebar.
|
||||
// v0.37.11
|
||||
|
||||
const html = window.html;
|
||||
|
||||
const CHEVRON_SVG = html`
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* tags: Array<[string, number]>,
|
||||
* activeTag: string,
|
||||
* collapsed: boolean,
|
||||
* onToggle: () => void,
|
||||
* onSelect: (tag: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function SidebarTags({ tags, activeTag, collapsed, onToggle, onSelect }) {
|
||||
return html`
|
||||
<div class="sw-notes-surface__section">
|
||||
<button class="sw-notes-surface__section-header"
|
||||
onClick=${onToggle}
|
||||
aria-expanded=${!collapsed}>
|
||||
<span class=${'sw-notes-surface__chevron' + (collapsed ? '' : ' sw-notes-surface__chevron--open')}>
|
||||
${CHEVRON_SVG}
|
||||
</span>
|
||||
<span class="sw-notes-surface__section-label">Tags</span>
|
||||
${tags.length > 0 && html`
|
||||
<span class="sw-notes-surface__section-count">${tags.length}</span>`}
|
||||
</button>
|
||||
${!collapsed && html`
|
||||
<div class="sw-notes-surface__section-body">
|
||||
<div class="sw-notes-surface__tag-cloud">
|
||||
${tags.length === 0 && html`
|
||||
<div class="sw-notes-surface__empty">No tags</div>`}
|
||||
${tags.map(([tag, count]) => html`
|
||||
<span key=${tag}
|
||||
class=${'sw-notes-surface__tag-chip' + (activeTag === tag ? ' sw-notes-surface__tag-chip--active' : '')}
|
||||
onClick=${() => onSelect(activeTag === tag ? '' : tag)}
|
||||
title=${tag + ' (' + count + ')'}>
|
||||
${tag}
|
||||
</span>`)}
|
||||
</div>
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// ==========================================
|
||||
// Notes Surface — Sidebar Component
|
||||
// ==========================================
|
||||
// Search bar, new note, folder tree, tag cloud.
|
||||
// Footer has UserMenu — same pattern as chat sidebar.
|
||||
//
|
||||
// v0.37.11
|
||||
|
||||
import { SidebarFolders } from './sidebar-folders.js';
|
||||
import { SidebarTags } from './sidebar-tags.js';
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useCallback, useRef } = window.hooks;
|
||||
|
||||
const PLUS_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>`;
|
||||
|
||||
const SEARCH_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>`;
|
||||
|
||||
const COLLAPSE_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* sidebar: ReturnType<import('./use-sidebar.js').useSidebar>,
|
||||
* onNewNote: () => void,
|
||||
* onCollapse: () => void,
|
||||
* onFolderSelect: (path: string) => void,
|
||||
* onTagSelect: (tag: string) => void,
|
||||
* onSearch: (query: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function Sidebar({ sidebar, onNewNote, onCollapse, onFolderSelect, onTagSelect, onSearch }) {
|
||||
const searchTimerRef = useRef(null);
|
||||
|
||||
const _onSearchInput = useCallback((e) => {
|
||||
const val = e.target.value;
|
||||
sidebar.setSearch(val);
|
||||
clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
onSearch(val.trim());
|
||||
}, 300);
|
||||
}, [sidebar.setSearch, onSearch]);
|
||||
|
||||
const _onFolderSelect = useCallback((path) => {
|
||||
sidebar.setActiveFolder(path);
|
||||
onFolderSelect(path);
|
||||
}, [sidebar.setActiveFolder, onFolderSelect]);
|
||||
|
||||
const _onTagSelect = useCallback((tag) => {
|
||||
sidebar.setActiveTag(tag);
|
||||
onTagSelect(tag);
|
||||
}, [sidebar.setActiveTag, onTagSelect]);
|
||||
|
||||
return html`
|
||||
<div class="sw-notes-surface__sidebar" role="navigation" aria-label="Notes sidebar">
|
||||
${/* Header: new note + collapse */''}
|
||||
<div class="sw-notes-surface__sidebar-header">
|
||||
<button class="sw-notes-surface__new-note-btn"
|
||||
onClick=${onNewNote}
|
||||
title="New note"
|
||||
aria-label="Create new note">
|
||||
${PLUS_SVG}
|
||||
<span>New Note</span>
|
||||
</button>
|
||||
<button class="sw-notes-surface__collapse-btn"
|
||||
onClick=${onCollapse}
|
||||
title="Collapse sidebar"
|
||||
aria-label="Collapse sidebar">
|
||||
${COLLAPSE_SVG}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${/* Search */''}
|
||||
<div class="sw-notes-surface__search-wrap">
|
||||
<span class="sw-notes-surface__search-icon">${SEARCH_SVG}</span>
|
||||
<input class="sw-notes-surface__search"
|
||||
type="text"
|
||||
placeholder="Search notes\u2026"
|
||||
value=${sidebar.search}
|
||||
onInput=${_onSearchInput}
|
||||
aria-label="Search notes" />
|
||||
</div>
|
||||
|
||||
${/* Scrollable content */''}
|
||||
<div class="sw-notes-surface__sidebar-body">
|
||||
<${SidebarFolders}
|
||||
folders=${sidebar.folders}
|
||||
activeFolder=${sidebar.activeFolder}
|
||||
collapsed=${sidebar.collapsed.folders}
|
||||
onToggle=${() => sidebar.toggleCollapsed('folders')}
|
||||
onSelect=${_onFolderSelect} />
|
||||
|
||||
<${SidebarTags}
|
||||
tags=${sidebar.tags}
|
||||
activeTag=${sidebar.activeTag}
|
||||
collapsed=${sidebar.collapsed.tags}
|
||||
onToggle=${() => sidebar.toggleCollapsed('tags')}
|
||||
onSelect=${_onTagSelect} />
|
||||
</div>
|
||||
|
||||
${/* Footer: UserMenu only */''}
|
||||
<div class="sw-notes-surface__sidebar-footer">
|
||||
<${UserMenu} placement="up-right" />
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// ==========================================
|
||||
// Notes Surface — useSidebar Hook
|
||||
// ==========================================
|
||||
// Loads folders and tags for the sidebar.
|
||||
// Subscribes to WS events for live updates.
|
||||
//
|
||||
// v0.37.11: New Preact surface.
|
||||
|
||||
const { useState, useEffect, useCallback, useMemo } = window.hooks;
|
||||
|
||||
const COLLAPSE_KEY = 'sw-notes-sidebar-collapsed';
|
||||
|
||||
/**
|
||||
* @returns {{
|
||||
* folders: Array<{ path: string, count: number }>,
|
||||
* tags: Array<[string, number]>,
|
||||
* activeFolder: string,
|
||||
* activeTag: string,
|
||||
* search: string,
|
||||
* setActiveFolder: (f: string) => void,
|
||||
* setActiveTag: (t: string) => void,
|
||||
* setSearch: (q: string) => void,
|
||||
* collapsed: { folders: boolean, tags: boolean },
|
||||
* toggleCollapsed: (key: string) => void,
|
||||
* loading: boolean,
|
||||
* }}
|
||||
*/
|
||||
export function useSidebar() {
|
||||
const [folders, setFolders] = useState([]);
|
||||
const [allNotes, setAllNotes] = useState([]);
|
||||
const [activeFolder, setActiveFolder] = useState('');
|
||||
const [activeTag, setActiveTag] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [collapsed, setCollapsed] = useState(_loadCollapsed);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ── Load data ───────────────────────────
|
||||
const reload = useCallback(async () => {
|
||||
if (!window.sw?.api?.notes) return;
|
||||
|
||||
try {
|
||||
const [fResp, nResp] = await Promise.all([
|
||||
window.sw.api.notes.folders().catch(() => []),
|
||||
window.sw.api.notes.list({ per_page: 200 }).catch(() => []),
|
||||
]);
|
||||
|
||||
setFolders(_extract(fResp));
|
||||
setAllNotes(_extract(nResp));
|
||||
} catch (_) {}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// ── WebSocket live updates ──────────────
|
||||
useEffect(() => {
|
||||
if (!window.sw?.on) return;
|
||||
|
||||
const events = [
|
||||
'note.created', 'note.updated', 'note.deleted',
|
||||
];
|
||||
|
||||
events.forEach(ev => window.sw.on(ev, reload));
|
||||
return () => {
|
||||
events.forEach(ev => window.sw.off?.(ev, reload));
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
// ── Aggregate tags from notes ───────────
|
||||
const tags = useMemo(() => {
|
||||
const counts = {};
|
||||
for (const n of allNotes) {
|
||||
for (const t of (n.tags || [])) {
|
||||
counts[t] = (counts[t] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 30);
|
||||
}, [allNotes]);
|
||||
|
||||
// ── Collapse sections ───────────────────
|
||||
const toggleCollapsed = useCallback((key) => {
|
||||
setCollapsed(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
folders,
|
||||
tags,
|
||||
activeFolder,
|
||||
activeTag,
|
||||
search,
|
||||
setActiveFolder,
|
||||
setActiveTag,
|
||||
setSearch,
|
||||
collapsed,
|
||||
toggleCollapsed,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
function _extract(resp) {
|
||||
if (!resp) return [];
|
||||
if (Array.isArray(resp)) return resp;
|
||||
if (Array.isArray(resp.data)) return resp.data;
|
||||
const vals = Object.values(resp);
|
||||
for (const v of vals) {
|
||||
if (Array.isArray(v)) return v;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function _loadCollapsed() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// ==========================================
|
||||
// Notes Surface — useWorkspace Hook
|
||||
// ==========================================
|
||||
// Coordinator hook: manages sidebar state and active note.
|
||||
// Persists active selection to sessionStorage for reload restore.
|
||||
//
|
||||
// v0.37.11: New Preact surface.
|
||||
|
||||
const { useState, useCallback, useEffect } = window.hooks;
|
||||
|
||||
const STORAGE_KEY = 'sw-notes-active';
|
||||
|
||||
/**
|
||||
* @returns {{
|
||||
* activeNoteId: string|null,
|
||||
* sidebarOpen: boolean,
|
||||
* setSidebarOpen: (v: boolean) => void,
|
||||
* selectNote: (id: string) => void,
|
||||
* clearSelection: () => void,
|
||||
* }}
|
||||
*/
|
||||
export function useWorkspace() {
|
||||
const stored = _loadStored();
|
||||
const [activeNoteId, setActiveNoteId] = useState(stored);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
// Persist on change
|
||||
useEffect(() => {
|
||||
if (activeNoteId) {
|
||||
try { sessionStorage.setItem(STORAGE_KEY, activeNoteId); } catch (_) {}
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}, [activeNoteId]);
|
||||
|
||||
const selectNote = useCallback((id) => {
|
||||
setActiveNoteId(id);
|
||||
// Auto-close sidebar on mobile
|
||||
if (window.innerWidth < 768) setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setActiveNoteId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeNoteId,
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
selectNote,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
|
||||
function _loadStored() {
|
||||
try {
|
||||
return sessionStorage.getItem(STORAGE_KEY) || null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
@@ -1,580 +0,0 @@
|
||||
/**
|
||||
* ProjectDetail — two-column detail view with inline chat + metadata sidebar.
|
||||
*
|
||||
* Props:
|
||||
* project — project object
|
||||
* onBack — () => void (return to list)
|
||||
* onUpdate — (deleted?: boolean) => void
|
||||
*
|
||||
* Left panel toggles: conversation list ↔ embedded ChatPane.
|
||||
* Right sidebar: collapsible sections (Description, Instructions, KBs, Notes, Files).
|
||||
*/
|
||||
const html = window.html;
|
||||
const h = window.preact.h;
|
||||
const { useState, useEffect, useCallback, useRef } = window.hooks;
|
||||
|
||||
import { ChatPane } from '../../components/chat-pane/index.js';
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
const COLORS = ['#4f8cff','#34d399','#f59e0b','#ef4444','#a78bfa','#ec4899','#06b6d4','#8b5cf6'];
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
function relTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const DIVS = [[60,'second'],[60,'minute'],[24,'hour'],[30,'day'],[12,'month'],[Infinity,'year']];
|
||||
let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (val < 10) return 'just now';
|
||||
for (const [div, unit] of DIVS) {
|
||||
if (val < div) { const n = Math.floor(val); return `${n} ${unit}${n!==1?'s':''} ago`; }
|
||||
val /= div;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function ProjectDetail({ project, onBack, onUpdate }) {
|
||||
const BASE = window.__BASE__ || '';
|
||||
const chatRef = useRef(null);
|
||||
|
||||
// ── State ──
|
||||
const [activeChannelId, setActiveChannelId] = useState(null);
|
||||
const [assoc, setAssoc] = useState({ channels: [], kbs: [], notes: [], files: [] });
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState(project.name || '');
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [sections, setSections] = useState({ desc: true, instructions: false, kbs: false, notes: false, files: false });
|
||||
const [desc, setDesc] = useState(project.description || '');
|
||||
const [instructions, setInstructions] = useState(project.settings?.instructions || '');
|
||||
const [descDirty, setDescDirty] = useState(false);
|
||||
const [instrDirty, setInstrDirty] = useState(false);
|
||||
const [starred, setStarred] = useState(!!project.settings?.starred);
|
||||
|
||||
// Picker state
|
||||
const [pickerOpen, setPickerOpen] = useState('');
|
||||
const [pickerItems, setPickerItems] = useState([]);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
|
||||
// Drag state for files
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
// ── Load associations ──
|
||||
const loadAssoc = useCallback(async () => {
|
||||
const id = project.id;
|
||||
const [ch, kb, nt, fi] = await Promise.all([
|
||||
sw.api.projects.channels(id).then(r => r?.data || r || []).catch(() => []),
|
||||
sw.api.projects.kbs(id).then(r => r?.data || r || []).catch(() => []),
|
||||
sw.api.projects.notes(id).then(r => r?.data || r || []).catch(() => []),
|
||||
sw.api.projects.files(id).then(r => r?.data || r?.files || r || []).catch(() => []),
|
||||
]);
|
||||
setAssoc({ channels: ch, kbs: kb, notes: nt, files: fi });
|
||||
}, [project.id]);
|
||||
|
||||
useEffect(() => { loadAssoc(); }, [loadAssoc]);
|
||||
|
||||
// ── Toggle section ──
|
||||
function toggleSection(key) {
|
||||
setSections(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
// ── Name editing ──
|
||||
async function saveName() {
|
||||
const name = nameVal.trim();
|
||||
if (!name || name === project.name) { setEditingName(false); return; }
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { name });
|
||||
sw.toast('Renamed', 'success');
|
||||
setEditingName(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Description / Instructions save ──
|
||||
async function saveDesc() {
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { description: desc });
|
||||
sw.toast('Description saved', 'success');
|
||||
setDescDirty(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function saveInstructions() {
|
||||
try {
|
||||
const settings = { ...(project.settings || {}), instructions };
|
||||
await sw.api.projects.update(project.id, { settings });
|
||||
sw.toast('Instructions saved', 'success');
|
||||
setInstrDirty(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Star toggle ──
|
||||
async function toggleStar() {
|
||||
const next = !starred;
|
||||
setStarred(next);
|
||||
try {
|
||||
const settings = { ...(project.settings || {}), starred: next };
|
||||
await sw.api.projects.update(project.id, { settings });
|
||||
} catch (e) { sw.toast(e.message, 'error'); setStarred(!next); }
|
||||
}
|
||||
|
||||
// ── Context menu actions ──
|
||||
async function handleColor(c) {
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { color: c });
|
||||
sw.toast('Color updated', 'success');
|
||||
setMenuOpen(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleArchive() {
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { is_archived: !project.is_archived });
|
||||
sw.toast(project.is_archived ? 'Unarchived' : 'Archived', 'success');
|
||||
setMenuOpen(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
setMenuOpen(false);
|
||||
if (!await sw.confirm('Delete this project? This cannot be undone.', true)) return;
|
||||
try {
|
||||
await sw.api.projects.del(project.id);
|
||||
sw.toast('Project deleted', 'success');
|
||||
onUpdate(true);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Picker helpers ──
|
||||
async function openPicker(type) {
|
||||
if (pickerOpen === type) { setPickerOpen(''); return; }
|
||||
setPickerOpen(type);
|
||||
setSections(prev => ({ ...prev, [type]: true }));
|
||||
setPickerLoading(true);
|
||||
setPickerItems([]);
|
||||
try {
|
||||
let items = [];
|
||||
if (type === 'kbs') {
|
||||
const r = await sw.api.knowledge.list();
|
||||
const all = r?.data || r || [];
|
||||
const attached = new Set(assoc.kbs.map(k => k.id || k.kb_id));
|
||||
items = all.filter(k => !attached.has(k.id));
|
||||
} else if (type === 'notes') {
|
||||
const r = await sw.api.notes.list();
|
||||
const all = r?.data || r || [];
|
||||
const attached = new Set(assoc.notes.map(n => n.id || n.note_id));
|
||||
items = all.filter(n => !attached.has(n.id));
|
||||
}
|
||||
setPickerItems(items);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
setPickerLoading(false);
|
||||
}
|
||||
|
||||
async function pickItem(type, item) {
|
||||
try {
|
||||
if (type === 'kbs') await sw.api.projects.addKb(project.id, item.id, false);
|
||||
else if (type === 'notes') await sw.api.projects.addNote(project.id, item.id);
|
||||
sw.toast('Added', 'success');
|
||||
setPickerOpen('');
|
||||
loadAssoc();
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Remove handlers ──
|
||||
async function removeKb(kbId) {
|
||||
try { await sw.api.projects.removeKb(project.id, kbId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
async function removeNote(noteId) {
|
||||
try { await sw.api.projects.removeNote(project.id, noteId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
async function removeChannel(chId) {
|
||||
try { await sw.api.projects.removeChannel(project.id, chId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── File state ──
|
||||
const [expandedDirs, setExpandedDirs] = useState({});
|
||||
const [uploading, setUploading] = useState('');
|
||||
|
||||
// ── File upload ──
|
||||
const ARCHIVE_EXTS = ['.zip', '.tar.gz', '.tgz'];
|
||||
|
||||
async function uploadFiles(files) {
|
||||
const total = files.length;
|
||||
let done = 0;
|
||||
setUploading(`Uploading 0 of ${total}\u2026`);
|
||||
for (const file of files) {
|
||||
try {
|
||||
const isArchive = ARCHIVE_EXTS.some(e => file.name.toLowerCase().endsWith(e));
|
||||
if (isArchive && await sw.confirm(`Extract "${file.name}" contents into the project?`)) {
|
||||
const r = await sw.api.projects.uploadArchive(project.id, file);
|
||||
sw.toast(`Extracted ${r?.files_extracted || 0} files`, 'success');
|
||||
} else {
|
||||
await sw.api.projects.uploadFile(project.id, file);
|
||||
sw.toast(`Uploaded ${file.name}`, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e.status === 413 ? 'Storage quota exceeded' : e.message;
|
||||
sw.toast(`${file.name}: ${msg}`, 'error');
|
||||
}
|
||||
done++;
|
||||
setUploading(`Uploading ${done} of ${total}\u2026`);
|
||||
}
|
||||
setUploading('');
|
||||
loadAssoc();
|
||||
}
|
||||
|
||||
function handleUploadClick() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = () => { if (input.files.length) uploadFiles(input.files); };
|
||||
input.click();
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
// ── File actions ──
|
||||
async function downloadFile(path) {
|
||||
try {
|
||||
const token = sw.auth?.token?.();
|
||||
const base = window.__BASE__ || '';
|
||||
const url = `${base}/api/v1/projects/${project.id}/files/download?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = path.split('/').pop() || 'download';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteFile(path) {
|
||||
const name = path.split('/').pop() || path;
|
||||
if (!await sw.confirm(`Delete "${name}"?`, true)) return;
|
||||
try {
|
||||
await sw.api.projects.deleteFile(project.id, path);
|
||||
sw.toast('Deleted', 'success');
|
||||
loadAssoc();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const name = await sw.prompt('Folder name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
await sw.api.projects.mkdir(project.id, name);
|
||||
sw.toast(`Created "${name}"`, 'success');
|
||||
loadAssoc();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function downloadAll() {
|
||||
try {
|
||||
const token = sw.auth?.token?.();
|
||||
const base = window.__BASE__ || '';
|
||||
const url = `${base}/api/v1/projects/${project.id}/archive/download?format=zip`;
|
||||
const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${project.name || 'project'}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── File tree builder ──
|
||||
function buildTree(files) {
|
||||
if (!files || !files.length) return [];
|
||||
// Sort: directories first, then alpha
|
||||
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 || '');
|
||||
});
|
||||
// Group into top-level entries (split by first path segment)
|
||||
const root = [];
|
||||
const dirMap = {};
|
||||
for (const f of sorted) {
|
||||
const parts = (f.path || f.filename || '').split('/').filter(Boolean);
|
||||
if (parts.length <= 1) {
|
||||
root.push({ ...f, name: parts[0] || f.filename, children: f.is_directory ? [] : null, depth: 0 });
|
||||
if (f.is_directory) dirMap[f.path] = root[root.length - 1];
|
||||
} else {
|
||||
// Find parent dir in root
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
const parent = dirMap[parentPath];
|
||||
const entry = { ...f, name: parts[parts.length - 1], children: f.is_directory ? [] : null, depth: parts.length - 1 };
|
||||
if (parent) {
|
||||
parent.children.push(entry);
|
||||
} else {
|
||||
root.push(entry);
|
||||
}
|
||||
if (f.is_directory) dirMap[f.path] = entry;
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function fileIcon(f) {
|
||||
if (f.is_directory) return '\uD83D\uDCC1';
|
||||
const ct = f.content_type || '';
|
||||
if (ct.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
||||
if (ct === 'application/pdf') return '\uD83D\uDCC4';
|
||||
if (ct.startsWith('text/')) return '\uD83D\uDCC3';
|
||||
return '\uD83D\uDCCE';
|
||||
}
|
||||
|
||||
function renderFileTree(entries) {
|
||||
if (!entries || !entries.length) return null;
|
||||
return entries.map(f => {
|
||||
const isDir = f.is_directory;
|
||||
const expanded = expandedDirs[f.path];
|
||||
const indent = (f.depth || 0) * 16;
|
||||
return html`<div key=${f.path || f.name}>
|
||||
<div class="sw-projects__file-row" style=${'padding-left:' + (8 + indent) + 'px'}>
|
||||
${isDir && html`<span class=${'sw-projects__file-chevron' + (expanded ? ' sw-projects__file-chevron--open' : '')}
|
||||
onClick=${() => setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))}>\u25B6</span>`}
|
||||
<span class="sw-projects__file-icon">${fileIcon(f)}</span>
|
||||
<span class="sw-projects__file-name"
|
||||
onClick=${isDir
|
||||
? () => setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))
|
||||
: () => downloadFile(f.path)}
|
||||
title=${isDir ? 'Toggle folder' : 'Click to download'}>
|
||||
${f.name}
|
||||
</span>
|
||||
${!isDir && html`<span class="sw-projects__file-meta">${formatBytes(f.size_bytes)}</span>`}
|
||||
${isDir && f.children && html`<span class="sw-projects__file-meta">${f.children.length} items</span>`}
|
||||
<button class="sw-projects__file-delete" onClick=${() => deleteFile(f.path)} title="Delete">\u00D7</button>
|
||||
</div>
|
||||
${isDir && expanded && f.children && renderFileTree(f.children)}
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── New conversation ──
|
||||
async function newConversation() {
|
||||
try {
|
||||
const ch = await sw.api.channels.create({ type: 'direct', title: 'New Chat' });
|
||||
const chId = ch?.id || ch?.data?.id;
|
||||
if (chId) {
|
||||
await sw.api.projects.addChannel(project.id, chId);
|
||||
await loadAssoc();
|
||||
setActiveChannelId(chId);
|
||||
}
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Render: Picker dropdown ──
|
||||
function renderPicker(type, emptyHint) {
|
||||
if (pickerOpen !== type) return null;
|
||||
return html`<div class="sw-projects__picker">
|
||||
${pickerLoading && html`<div class="sw-projects__picker-hint">Loading\u2026</div>`}
|
||||
${!pickerLoading && pickerItems.length === 0 && html`<div class="sw-projects__picker-hint">${emptyHint}</div>`}
|
||||
${!pickerLoading && pickerItems.map(item => html`
|
||||
<div key=${item.id} class="sw-projects__picker-item" onClick=${() => pickItem(type, item)}>
|
||||
${item.title || item.name || item.id}
|
||||
</div>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: Collapsible section ──
|
||||
function renderSection(key, title, count, content, addAction) {
|
||||
const open = sections[key];
|
||||
return html`<div class="sw-projects__section">
|
||||
<div class="sw-projects__section-header" onClick=${e => { if (e.target.closest('.sw-projects__section-add')) return; toggleSection(key); }}>
|
||||
<div>
|
||||
<span class="sw-projects__section-title">${title}</span>
|
||||
${count != null && html`<span class="sw-projects__section-count">${count}</span>`}
|
||||
</div>
|
||||
<div class="sw-projects__section-actions">
|
||||
${addAction && html`<button class="sw-projects__section-add"
|
||||
onClick=${e => { e.stopPropagation(); addAction(); }}>+</button>`}
|
||||
<span class=${'sw-projects__section-chevron' + (open ? ' sw-projects__section-chevron--open' : '')}>\u25B6</span>
|
||||
</div>
|
||||
</div>
|
||||
${open && html`<div class="sw-projects__section-body">${content}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: Left panel ──
|
||||
function renderLeftPanel() {
|
||||
if (activeChannelId) {
|
||||
return html`<div class="sw-projects__chat-wrap">
|
||||
<div class="sw-projects__chat-back" onClick=${() => setActiveChannelId(null)}>
|
||||
\u2190 Back to conversations
|
||||
</div>
|
||||
<${ChatPane}
|
||||
channelId=${activeChannelId}
|
||||
standalone=${false}
|
||||
handleRef=${chatRef}
|
||||
className="sw-projects__chat-pane" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<div class="sw-projects__conversations">
|
||||
<div class="sw-projects__conv-header">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-2);">Conversations</span>
|
||||
<button class="sw-projects__new-btn" style="font-size:12px;padding:5px 12px;" onClick=${newConversation}>+ New</button>
|
||||
</div>
|
||||
<div class="sw-projects__conv-list">
|
||||
${assoc.channels.length === 0 && html`
|
||||
<div class="sw-projects__conv-empty">
|
||||
No conversations yet.<br/>
|
||||
<button class="sw-projects__new-btn" style="margin-top:12px;font-size:12px;padding:5px 12px;" onClick=${newConversation}>Start a conversation</button>
|
||||
</div>
|
||||
`}
|
||||
${assoc.channels.map(ch => {
|
||||
const chId = ch.id || ch.channel_id;
|
||||
return html`<div key=${chId} class="sw-projects__conv-item" onClick=${() => setActiveChannelId(chId)}>
|
||||
<div class="sw-projects__conv-title">${ch.title || ch.name || chId}</div>
|
||||
<div class="sw-projects__conv-meta">Last message ${relTime(ch.updated_at)}</div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: Right sidebar ──
|
||||
function renderSidebar() {
|
||||
return html`<div class="sw-projects__sidebar">
|
||||
${renderSection('desc', 'Description', null, html`
|
||||
<textarea class="sw-projects__section-textarea" rows="3"
|
||||
value=${desc}
|
||||
onInput=${e => { setDesc(e.target.value); setDescDirty(true); }}
|
||||
placeholder="Add a project description\u2026" />
|
||||
${descDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveDesc}>Save</button>`}
|
||||
`)}
|
||||
|
||||
${renderSection('instructions', 'Instructions', null, html`
|
||||
<textarea class="sw-projects__section-textarea" rows="4"
|
||||
value=${instructions}
|
||||
onInput=${e => { setInstructions(e.target.value); setInstrDirty(true); }}
|
||||
placeholder="Add instructions to tailor responses\u2026" />
|
||||
${instrDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveInstructions}>Save</button>`}
|
||||
`)}
|
||||
|
||||
${renderSection('kbs', 'Knowledge Bases', assoc.kbs.length, html`
|
||||
${assoc.kbs.length === 0 && html`<div class="sw-projects__section-hint">No knowledge bases linked.</div>`}
|
||||
${assoc.kbs.map(kb => html`
|
||||
<div key=${kb.id || kb.kb_id} class="sw-projects__section-item">
|
||||
<span class="sw-projects__section-item-name">${kb.name || kb.kb_id}</span>
|
||||
<button class="sw-projects__section-item-remove" onClick=${() => removeKb(kb.id || kb.kb_id)}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
${renderPicker('kbs', 'No knowledge bases available.')}
|
||||
`, () => openPicker('kbs'))}
|
||||
|
||||
${renderSection('notes', 'Notes', assoc.notes.length, html`
|
||||
${assoc.notes.length === 0 && html`<div class="sw-projects__section-hint">No notes linked.</div>`}
|
||||
${assoc.notes.map(n => html`
|
||||
<div key=${n.id || n.note_id} class="sw-projects__section-item">
|
||||
<span class="sw-projects__section-item-name">${n.title || n.note_id}</span>
|
||||
<span class="sw-projects__section-item-meta">${fmtDate(n.created_at || n.added_at)}</span>
|
||||
<button class="sw-projects__section-item-remove" onClick=${() => removeNote(n.id || n.note_id)}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
${renderPicker('notes', 'No notes available.')}
|
||||
`, () => openPicker('notes'))}
|
||||
|
||||
${renderSection('files', 'Files', assoc.files.filter(f => !f.is_directory).length, html`
|
||||
<div class="sw-projects__file-browser">
|
||||
${assoc.files.length > 0 && html`
|
||||
<div class="sw-projects__file-actions-bar">
|
||||
<button class="sw-projects__file-action-btn" onClick=${createFolder}
|
||||
title="New folder">\uD83D\uDCC1 New folder</button>
|
||||
<button class="sw-projects__file-action-btn" onClick=${downloadAll}
|
||||
title="Download all as ZIP">\u2B07 Download all</button>
|
||||
</div>
|
||||
`}
|
||||
${renderFileTree(buildTree(assoc.files))}
|
||||
</div>
|
||||
<div class=${'sw-projects__dropzone' + (dragActive ? ' sw-projects__dropzone--active' : '')}
|
||||
onClick=${handleUploadClick}
|
||||
onDragOver=${e => { e.preventDefault(); setDragActive(true); }}
|
||||
onDragLeave=${() => setDragActive(false)}
|
||||
onDrop=${handleDrop}>
|
||||
${uploading ? uploading
|
||||
: dragActive ? 'Drop files here'
|
||||
: assoc.files.length === 0
|
||||
? 'Add PDFs, documents, or other text to reference in this project.'
|
||||
: 'Drop files or click to upload more'}
|
||||
</div>
|
||||
`, handleUploadClick)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Main render ──
|
||||
return html`<div class="sw-projects__detail">
|
||||
<div class="sw-projects__detail-header">
|
||||
<${UserMenu} placement="down-right" />
|
||||
<a class="sw-projects__back" onClick=${onBack}>\u2190 All projects</a>
|
||||
${editingName
|
||||
? html`<input class="sw-projects__detail-name-input"
|
||||
value=${nameVal}
|
||||
onInput=${e => setNameVal(e.target.value)}
|
||||
onBlur=${saveName}
|
||||
onKeyDown=${e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false); }}
|
||||
autoFocus />`
|
||||
: html`<h2 class="sw-projects__detail-name"
|
||||
onClick=${() => { setNameVal(project.name || ''); setEditingName(true); }}>
|
||||
${(project.icon ? project.icon + ' ' : '') + project.name}
|
||||
</h2>`
|
||||
}
|
||||
<div class="sw-projects__header-actions">
|
||||
<button class=${'sw-projects__icon-btn' + (starred ? ' sw-projects__icon-btn--active' : '')}
|
||||
onClick=${toggleStar} title="Star project">
|
||||
${starred ? '\u2605' : '\u2606'}
|
||||
</button>
|
||||
<div style="position:relative">
|
||||
<button class="sw-projects__icon-btn"
|
||||
onClick=${() => setMenuOpen(!menuOpen)} title="More options">\u22EF</button>
|
||||
${menuOpen && html`
|
||||
<div class="sw-projects__ctx-menu">
|
||||
<button class="sw-projects__ctx-item"
|
||||
onClick=${() => { setMenuOpen(false); setNameVal(project.name || ''); setEditingName(true); }}>Rename</button>
|
||||
<div class="sw-projects__color-row">
|
||||
${COLORS.map(c => html`
|
||||
<span key=${c}
|
||||
class=${'sw-projects__color-swatch' + (project.color === c ? ' sw-projects__color-swatch--active' : '')}
|
||||
style=${'background:' + c}
|
||||
onClick=${() => handleColor(c)} />
|
||||
`)}
|
||||
</div>
|
||||
<div class="sw-projects__ctx-divider" />
|
||||
<button class="sw-projects__ctx-item" onClick=${toggleArchive}>
|
||||
${project.is_archived ? 'Unarchive' : 'Archive'}</button>
|
||||
<button class="sw-projects__ctx-item sw-projects__ctx-item--danger"
|
||||
onClick=${deleteProject}>Delete</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sw-projects__detail-body">
|
||||
${renderLeftPanel()}
|
||||
${renderSidebar()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* ProjectsSurface — root view-switching component.
|
||||
*
|
||||
* No selectedId → card grid list (ProjectsList)
|
||||
* With selectedId → detail view (ProjectDetail)
|
||||
*
|
||||
* Globals: __PROJECT_ID__ (deep-link), __BASE__ (base path)
|
||||
*/
|
||||
const html = window.html;
|
||||
const { useState, useEffect, useCallback } = window.hooks;
|
||||
const { render } = window.preact;
|
||||
|
||||
import { ToastContainer } from '../../primitives/toast.js';
|
||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
import { ProjectsList } from './list.js';
|
||||
import { ProjectDetail } from './detail.js';
|
||||
|
||||
function ProjectsSurface() {
|
||||
const BASE = window.__BASE__ || '';
|
||||
const deepLinkId = window.__PROJECT_ID__ || '';
|
||||
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(deepLinkId || '');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.projects.list();
|
||||
const list = resp?.data || resp || [];
|
||||
setProjects(list);
|
||||
if (deepLinkId && !list.find(p => p.id === deepLinkId)) {
|
||||
setSelectedId('');
|
||||
}
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [deepLinkId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const selected = projects.find(p => p.id === selectedId) || null;
|
||||
|
||||
function selectProject(id) {
|
||||
setSelectedId(id);
|
||||
history.replaceState(null, '', BASE + '/projects/' + id);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setSelectedId('');
|
||||
history.replaceState(null, '', BASE + '/projects');
|
||||
}
|
||||
|
||||
async function createProject(name) {
|
||||
try {
|
||||
const proj = await sw.api.projects.create({ name });
|
||||
sw.toast('Project created', 'success');
|
||||
await load();
|
||||
if (proj?.id) selectProject(proj.id);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
const handleUpdate = useCallback((deleted) => {
|
||||
if (deleted) { setSelectedId(''); history.replaceState(null, '', BASE + '/projects'); }
|
||||
load();
|
||||
}, [load, BASE]);
|
||||
|
||||
return html`
|
||||
<div class="sw-projects">
|
||||
${selected
|
||||
? html`<${ProjectDetail}
|
||||
key=${selectedId}
|
||||
project=${selected}
|
||||
onBack=${goBack}
|
||||
onUpdate=${handleUpdate} />`
|
||||
: html`<${ProjectsList}
|
||||
projects=${projects}
|
||||
loading=${loading}
|
||||
onSelect=${selectProject}
|
||||
onCreate=${createProject} />`
|
||||
}
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Mount ────────────────────────────────
|
||||
const mount = document.getElementById('projects-mount');
|
||||
if (mount) {
|
||||
render(html`<${ProjectsSurface} />`, mount);
|
||||
console.log('[projects] Surface mounted');
|
||||
}
|
||||
|
||||
export { ProjectsSurface };
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* ProjectsList — card grid view for all projects.
|
||||
*
|
||||
* Props:
|
||||
* projects — array of project objects
|
||||
* loading — boolean
|
||||
* onSelect — (id: string) => void
|
||||
* onCreate — (name: string) => void
|
||||
*/
|
||||
const html = window.html;
|
||||
const { useState, useMemo } = window.hooks;
|
||||
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
// ── Relative time helper ────────────────────
|
||||
const DIVISIONS = [
|
||||
[60, 'second'], [60, 'minute'], [24, 'hour'],
|
||||
[30, 'day'], [12, 'month'], [Infinity, 'year'],
|
||||
];
|
||||
function relativeTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (val < 10) return 'just now';
|
||||
for (const [div, unit] of DIVISIONS) {
|
||||
if (val < div) {
|
||||
const n = Math.floor(val);
|
||||
return `Updated ${n} ${unit}${n !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
val /= div;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function ProjectsList({ projects, loading, onSelect, onCreate }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState('activity');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
let list = projects;
|
||||
if (q) {
|
||||
list = list.filter(p =>
|
||||
(p.name || '').toLowerCase().includes(q) ||
|
||||
(p.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (sort === 'activity') {
|
||||
list = [...list].sort((a, b) =>
|
||||
new Date(b.updated_at || 0) - new Date(a.updated_at || 0)
|
||||
);
|
||||
} else {
|
||||
list = [...list].sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || '')
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [projects, search, sort]);
|
||||
|
||||
async function handleCreate() {
|
||||
const name = await sw.prompt('Project name:');
|
||||
if (name && name.trim()) onCreate(name.trim());
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return html`<div class="sw-projects__list">
|
||||
<div class="sw-projects__empty">Loading projects\u2026</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sw-projects__list">
|
||||
<div class="sw-projects__list-header">
|
||||
<${UserMenu} placement="down-right" />
|
||||
<h1 class="sw-projects__list-title">Projects</h1>
|
||||
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ New project</button>
|
||||
</div>
|
||||
|
||||
<div class="sw-projects__search-wrap">
|
||||
<span class="sw-projects__search-icon">\u{1F50D}</span>
|
||||
<input class="sw-projects__search"
|
||||
type="text"
|
||||
placeholder="Search projects\u2026"
|
||||
value=${search}
|
||||
onInput=${e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div class="sw-projects__sort">
|
||||
<span>Sort by</span>
|
||||
<select value=${sort} onChange=${e => setSort(e.target.value)}>
|
||||
<option value="activity">Activity</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${filtered.length === 0 && !loading && html`
|
||||
<div class="sw-projects__empty">
|
||||
${search ? 'No projects match your search.' : 'No projects yet.'}
|
||||
${!search && html`<div style="margin-top:12px;">
|
||||
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ Create your first project</button>
|
||||
</div>`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="sw-projects__grid">
|
||||
${filtered.map(p => html`
|
||||
<div key=${p.id}
|
||||
class="sw-projects__card"
|
||||
style=${p.color ? `border-left: 3px solid ${p.color}` : ''}
|
||||
onClick=${() => onSelect(p.id)}>
|
||||
<div class="sw-projects__card-name">
|
||||
${p.icon ? html`<span>${p.icon}</span>` : ''}
|
||||
<span>${p.name}</span>
|
||||
${p.is_archived && html`<span class="sw-projects__card-badge">archived</span>`}
|
||||
</div>
|
||||
${p.description && html`
|
||||
<div class="sw-projects__card-desc">${p.description}</div>
|
||||
`}
|
||||
<div class="sw-projects__card-meta">${relativeTime(p.updated_at)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user