Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -472,3 +472,125 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
.sw-tabs-content { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.sw-tab-panel { height: 100%; overflow-y: auto; }
|
||||
|
||||
/* ── Responsive: Mobile ───────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Flyout items: larger touch targets */
|
||||
.sw-menu-flyout .flyout-item {
|
||||
padding: 10px 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Flyout: constrain to viewport */
|
||||
.sw-menu-flyout {
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
/* Dialog/modal: full width on mobile, max with margins */
|
||||
.sw-dialog__content {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
/* Toast: full width, centered */
|
||||
.sw-toast {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── User Picker (autocomplete) ──────────── */
|
||||
|
||||
.sw-user-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-user-picker__input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius, 6px);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg, #0e0e10);
|
||||
color: var(--text, #eee);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.sw-user-picker__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.sw-user-picker__spinner {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border, #2a2a2e);
|
||||
border-top-color: var(--accent, #b38a4e);
|
||||
border-radius: 50%;
|
||||
animation: sw-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sw-spin { to { transform: translateY(-50%) rotate(360deg); } }
|
||||
|
||||
.sw-user-picker__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-elevated, #2a2a2e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 300;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-user-picker__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sw-user-picker__option:hover,
|
||||
.sw-user-picker__option--active {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
.sw-user-picker__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #b38a4e);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-user-picker__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-user-picker__name {
|
||||
font-size: 0.85em;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-user-picker__handle {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,11 +98,17 @@
|
||||
font-family: inherit;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
max-width: 140px;
|
||||
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;
|
||||
@@ -148,6 +154,72 @@
|
||||
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;
|
||||
@@ -527,31 +599,22 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Markdown Toolbar ─────────────────────── */
|
||||
/* ── CM6 Chat Input ──────────────────────── */
|
||||
|
||||
.sw-md-toolbar {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 4px 12px 2px;
|
||||
flex-shrink: 0;
|
||||
.sw-msg-input__cm-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-md-toolbar__btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
line-height: 1;
|
||||
.sw-msg-input__cm-container .cm-editor {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-md-toolbar__btn:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg, #0e0e10);
|
||||
.sw-msg-input__cm-container .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Error Banner ─────────────────────────── */
|
||||
@@ -750,3 +813,35 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
border: 1px solid var(--accent, #b38a4e);
|
||||
@@ -53,6 +53,7 @@
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-folder-btn,
|
||||
.sw-chat-surface__collapse-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -68,6 +69,7 @@
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-folder-btn:hover,
|
||||
.sw-chat-surface__collapse-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg, #0e0e10);
|
||||
@@ -77,6 +79,52 @@
|
||||
background: rgba(179, 138, 78, 0.2);
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-menu-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--surface, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 130;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-menu button:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-menu-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────── */
|
||||
|
||||
.sw-chat-surface__search-wrap {
|
||||
@@ -95,9 +143,9 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sw-chat-surface__search {
|
||||
input.sw-chat-surface__search {
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 30px;
|
||||
padding: 6px 8px 6px 34px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
@@ -108,7 +156,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sw-chat-surface__search:focus {
|
||||
input.sw-chat-surface__search:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
@@ -222,7 +270,7 @@
|
||||
}
|
||||
|
||||
.sw-chat-surface__item-menu {
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
@@ -234,10 +282,14 @@
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sw-chat-surface__item:hover .sw-chat-surface__item-menu {
|
||||
display: flex;
|
||||
.sw-chat-surface__item:hover .sw-chat-surface__item-menu,
|
||||
.sw-chat-surface__folder-header:hover .sw-chat-surface__item-menu {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sw-chat-surface__item-menu:hover {
|
||||
@@ -261,6 +313,29 @@
|
||||
|
||||
.sw-chat-surface__folder {
|
||||
margin: 2px 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__folder--drag-over {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__root-drop {
|
||||
margin: 4px 8px;
|
||||
padding: 10px;
|
||||
border: 2px dashed var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #555);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__root-drop--active {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
color: var(--text-2, #999);
|
||||
}
|
||||
|
||||
.sw-chat-surface__folder-header {
|
||||
@@ -270,6 +345,25 @@
|
||||
padding: 4px 12px;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__folder-header:hover {
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* Draggable items */
|
||||
.sw-chat-surface__item[draggable="true"],
|
||||
.sw-chat-surface__folder[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sw-chat-surface__item[draggable="true"]:active,
|
||||
.sw-chat-surface__folder[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sw-chat-surface__folder-name {
|
||||
@@ -380,6 +474,180 @@
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Header Buttons (members, settings) ───── */
|
||||
|
||||
.sw-chat-surface__header-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, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__header-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Channel Panel Shared ─────────────────── */
|
||||
|
||||
.sw-panel-loading,
|
||||
.sw-panel-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ── Members Panel ────────────────────────── */
|
||||
|
||||
.sw-members-list {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.sw-members-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.sw-members-row:hover {
|
||||
background: var(--bg, rgba(255,255,255,0.03));
|
||||
}
|
||||
|
||||
.sw-members-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #b38a4e);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-members-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sw-members-name {
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sw-members-type {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.sw-members-role {
|
||||
font-size: 0.8em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
color: var(--text, #eee);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.sw-members-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #888);
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-members-remove:hover {
|
||||
color: #e55;
|
||||
background: rgba(238,85,85,0.1);
|
||||
}
|
||||
|
||||
.sw-members-add {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.sw-members-add input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg, #0e0e10);
|
||||
color: var(--text, #eee);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ── Settings Panel ───────────────────────── */
|
||||
|
||||
.sw-settings-form {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sw-settings-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sw-settings-field label {
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sw-settings-field input,
|
||||
.sw-settings-field textarea,
|
||||
.sw-settings-field select {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg, #0e0e10);
|
||||
color: var(--text, #eee);
|
||||
font-size: 0.9em;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.sw-settings-info {
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--border, rgba(255,255,255,0.06));
|
||||
font-size: 0.82em;
|
||||
color: var(--text-muted, #888);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sw-settings-save {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ── Tools Button ─────────────────────────── */
|
||||
|
||||
.sw-chat-surface__tools-btn {
|
||||
@@ -595,14 +863,169 @@
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
z-index: 20;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.sw-chat-surface__sidebar-overlay {
|
||||
display: block;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
.sw-chat-surface__collapse-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Ensure touch targets are >= 44px */
|
||||
.sw-chat-surface__item {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__folder-header {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Prevent iOS zoom on search */
|
||||
input.sw-chat-surface__search {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Context menu: constrain to viewport */
|
||||
.sw-chat-surface__context-menu {
|
||||
max-width: calc(100vw - 16px);
|
||||
right: 8px;
|
||||
left: auto !important;
|
||||
}
|
||||
|
||||
/* Header buttons: tighter on mobile */
|
||||
.sw-chat-surface__sidebar-header {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-chat-btn {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dashboard (no chat selected) ─────────── */
|
||||
|
||||
.sw-chat-dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 40px 24px;
|
||||
gap: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__welcome {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #555);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 12px;
|
||||
background: var(--surface, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 10px;
|
||||
color: var(--text, #eee);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__card:hover {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.08));
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__card-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__card-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__recent {
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-3, #555);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__recent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #999);
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__recent-icon {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sw-chat-dashboard__recent-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sw-chat-dashboard {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.sw-chat-dashboard__actions {
|
||||
gap: 8px;
|
||||
}
|
||||
.sw-chat-dashboard__card {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
}
|
||||
@@ -353,14 +353,25 @@
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
z-index: 20;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,11 @@
|
||||
.sw-menu__arrow { font-size: 0.6em; color: var(--text-3); }
|
||||
.sw-menu__divider { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sw-menu { max-width: calc(100vw - 16px); }
|
||||
.sw-menu__item { min-height: 44px; padding: 0.5rem 0.75rem; }
|
||||
}
|
||||
|
||||
/* ── Drawer ────────────────────────────────── */
|
||||
|
||||
.sw-drawer-overlay {
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
/* Safe-area insets for notched devices */
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
padding-left: env(safe-area-inset-left, 0);
|
||||
padding-right: env(safe-area-inset-right, 0);
|
||||
}
|
||||
|
||||
/* ── Banner (fixed top / bottom — identical component) ── */
|
||||
@@ -137,3 +142,184 @@
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Notification Bell ───────────────────── */
|
||||
|
||||
.sw-notification-bell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-notification-bell__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-notification-bell__trigger:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.sw-notification-bell__badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: var(--danger, #ef4444);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
.sw-notification-bell__panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 6px;
|
||||
width: 320px;
|
||||
max-height: 420px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sw-notification-bell__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-notification-bell__mark-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent, #b38a4e);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sw-notification-bell__mark-all:hover {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
}
|
||||
|
||||
.sw-notification-bell__list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sw-notification-bell__empty {
|
||||
padding: 24px 14px;
|
||||
text-align: center;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sw-notification-bell__item {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-notification-bell__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sw-notification-bell__item:hover {
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.sw-notification-bell__item--unread {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.05));
|
||||
}
|
||||
|
||||
.sw-notification-bell__item--unread::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #b38a4e);
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sw-notification-bell__item-text {
|
||||
font-size: 12px;
|
||||
color: var(--text, #eee);
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sw-notification-bell__item-body {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #555);
|
||||
margin-top: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sw-notification-bell__item-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
margin-top: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sw-notification-bell__item--navigable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sw-notification-bell__item-action {
|
||||
font-size: 10px;
|
||||
color: var(--accent, #4a9eff);
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.sw-notification-bell__item:hover .sw-notification-bell__item-action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Notification Bell — Mobile ──────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sw-notification-bell__panel {
|
||||
width: calc(100vw - 24px);
|
||||
max-width: 320px;
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.sw-notification-bell__trigger {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,3 +95,18 @@
|
||||
width: 200px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive: Mobile ───────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* User menu button: ensure 44px touch target */
|
||||
.user-menu-wrap .user-btn {
|
||||
padding: 8px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Flyout: open upward and constrain width */
|
||||
.user-menu-wrap .sw-menu-flyout {
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,13 @@
|
||||
--warning-dim: rgba(234,179,8,0.2);
|
||||
|
||||
/* ── Surfaces ────────────────────────────── */
|
||||
--bg-primary: #0e0e10;
|
||||
--bg-secondary: #151517;
|
||||
--overlay: rgba(0,0,0,0.55);
|
||||
--glass: rgba(255,255,255,0.03);
|
||||
--input-bg: #1a1a1f;
|
||||
--user-bubble: rgba(108,159,255,0.08);
|
||||
--danger-bg: rgba(239,68,68,0.12);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
||||
|
||||
/* ── Layout ──────────────────────────────── */
|
||||
@@ -105,10 +108,13 @@
|
||||
--success-dim: rgba(22,163,74,0.09);
|
||||
--warning-dim: rgba(202,138,4,0.09);
|
||||
|
||||
--bg-primary: #f7f7fa;
|
||||
--bg-secondary: #ecedf2;
|
||||
--overlay: rgba(0,0,0,0.25);
|
||||
--glass: rgba(0,0,0,0.02);
|
||||
--input-bg: #f2f3f6;
|
||||
--user-bubble: rgba(74,124,219,0.06);
|
||||
--danger-bg: #fef2f2;
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.1);
|
||||
|
||||
color-scheme: light;
|
||||
|
||||
@@ -147,29 +147,36 @@ function tripleBacktickHandler() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keybinding: Ctrl/Cmd+E wraps selection in inline code backticks,
|
||||
* or inserts a pair and places cursor between them.
|
||||
* Wrap the selection with `before`/`after` markers (or insert an empty
|
||||
* pair and place the cursor between them). Used for bold, italic, code.
|
||||
*/
|
||||
function inlineCodeKeymap() {
|
||||
return keymap.of([{
|
||||
key: 'Mod-e',
|
||||
run: (view) => {
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (from !== to) {
|
||||
const text = view.state.doc.sliceString(from, to);
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: '`' + text + '`' },
|
||||
selection: { anchor: from + 1, head: to + 1 },
|
||||
});
|
||||
} else {
|
||||
view.dispatch({
|
||||
changes: { from, to: from, insert: '``' },
|
||||
selection: { anchor: from + 1 },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}]);
|
||||
function _wrapWith(view, before, after) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (from !== to) {
|
||||
const text = view.state.doc.sliceString(from, to);
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: before + text + after },
|
||||
selection: { anchor: from + before.length, head: to + before.length },
|
||||
});
|
||||
} else {
|
||||
view.dispatch({
|
||||
changes: { from, to: from, insert: before + after },
|
||||
selection: { anchor: from + before.length },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keybindings: Ctrl/Cmd+B (bold), Ctrl/Cmd+I (italic), Ctrl/Cmd+E (inline code).
|
||||
* Wraps selection or inserts an empty pair with cursor between markers.
|
||||
*/
|
||||
function formattingKeymap() {
|
||||
return keymap.of([
|
||||
{ key: 'Mod-b', run: (view) => _wrapWith(view, '**', '**') },
|
||||
{ key: 'Mod-i', run: (view) => _wrapWith(view, '_', '_') },
|
||||
{ key: 'Mod-e', run: (view) => _wrapWith(view, '`', '`') },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Chat Input Factory ──────────────────────
|
||||
@@ -193,6 +200,7 @@ export function chatInput(target, opts = {}) {
|
||||
onChange = null,
|
||||
maxHeight = 200,
|
||||
darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
extensions: extraExtensions = [],
|
||||
} = opts;
|
||||
|
||||
// Build extensions
|
||||
@@ -201,9 +209,9 @@ export function chatInput(target, opts = {}) {
|
||||
markdown({ base: markdownLanguage }),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
|
||||
// Code block shortcuts
|
||||
// Markdown shortcuts
|
||||
tripleBacktickHandler(), // ``` → fenced code block
|
||||
inlineCodeKeymap(), // Ctrl/Cmd+E → inline code
|
||||
formattingKeymap(), // Ctrl/Cmd+B, I, E → bold, italic, code
|
||||
|
||||
// Code block visual decorations (WYSIWYG style)
|
||||
codeBlockDeco,
|
||||
@@ -275,6 +283,11 @@ export function chatInput(target, opts = {}) {
|
||||
// Single-line display mode: wrap long lines
|
||||
extensions.push(EditorView.lineWrapping);
|
||||
|
||||
// Extra extensions (e.g. @mention autocomplete)
|
||||
if (extraExtensions.length) {
|
||||
extensions.push(...extraExtensions);
|
||||
}
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc: '', extensions }),
|
||||
parent: target,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { emacs } from '@replit/codemirror-emacs';
|
||||
import { codeEditor } from './code-editor.mjs';
|
||||
import { chatInput } from './chat-input.mjs';
|
||||
import { noteEditor } from './note-editor.mjs';
|
||||
import { mentionExtension } from './mention.mjs';
|
||||
|
||||
// ── Expose on window ────────────────────────
|
||||
|
||||
@@ -54,6 +55,10 @@ window.CM = {
|
||||
// CM.noteEditor(container, { value, darkMode, onChange, onLink, linkCompleter })
|
||||
noteEditor,
|
||||
|
||||
// Extension: @mention autocomplete + decoration for chat input
|
||||
// CM.mentionExtension({ completer: async (query) => [{ label, handle, type }] })
|
||||
mentionExtension,
|
||||
|
||||
// Bundled language modes (for reference / dynamic use)
|
||||
languages: {
|
||||
javascript, json, markdown, sql, html, css, yaml, go, python, rust,
|
||||
|
||||
156
src/editor/mention.mjs
Normal file
156
src/editor/mention.mjs
Normal file
@@ -0,0 +1,156 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — CM6 @Mention Extension
|
||||
// ==========================================
|
||||
// Provides:
|
||||
// 1. Autocomplete triggered by @ for personas/users
|
||||
// 2. ViewPlugin that decorates @handle as styled marks
|
||||
// 3. Theme for mention styling in the editor
|
||||
//
|
||||
// Modeled on wikilink.mjs — same pattern adapted for
|
||||
// @mention tokens instead of [[wikilink]] syntax.
|
||||
//
|
||||
// Usage:
|
||||
// import { mentionExtension } from './mention.mjs';
|
||||
// const ext = mentionExtension({
|
||||
// completer: async (query) => [{ label, handle, type }],
|
||||
// });
|
||||
// ==========================================
|
||||
|
||||
import {
|
||||
ViewPlugin, Decoration, EditorView,
|
||||
} from '@codemirror/view';
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
|
||||
// ── Mention Regex ───────────────────────────
|
||||
|
||||
// Matches @handle tokens: @ preceded by start-of-string or whitespace,
|
||||
// followed by one or more word chars or hyphens.
|
||||
const MENTION_RE = /(?:^|(?<=\s))@([\w-]+)/g;
|
||||
|
||||
// ── ViewPlugin: Decorate @mentions ──────────
|
||||
|
||||
function mentionDecoPlugin() {
|
||||
return ViewPlugin.fromClass(class {
|
||||
decorations;
|
||||
|
||||
constructor(view) {
|
||||
this.decorations = this.build(view);
|
||||
}
|
||||
|
||||
update(update) {
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.decorations = this.build(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
build(view) {
|
||||
const builder = new RangeSetBuilder();
|
||||
const doc = view.state.doc;
|
||||
const text = doc.toString();
|
||||
const sel = view.state.selection.main;
|
||||
|
||||
let match;
|
||||
MENTION_RE.lastIndex = 0;
|
||||
while ((match = MENTION_RE.exec(text)) !== null) {
|
||||
// match[0] includes any leading whitespace captured by lookbehind,
|
||||
// but lookbehind is zero-width so match.index points to @
|
||||
const atIdx = text.indexOf('@', match.index);
|
||||
const from = atIdx;
|
||||
const to = from + 1 + match[1].length; // @handle
|
||||
|
||||
// Don't decorate if cursor is inside the mention (let user edit)
|
||||
if (sel.from >= from && sel.from <= to) continue;
|
||||
|
||||
builder.add(from, to, Decoration.mark({ class: 'cm-mention' }));
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
}, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Autocomplete: @ trigger ─────────────────
|
||||
|
||||
function mentionAutocomplete(completer) {
|
||||
return autocompletion({
|
||||
override: [
|
||||
async (ctx) => {
|
||||
// Look for @ before the cursor (at line start or after whitespace)
|
||||
const line = ctx.state.doc.lineAt(ctx.pos);
|
||||
const textBefore = ctx.state.doc.sliceString(line.from, ctx.pos);
|
||||
const triggerMatch = textBefore.match(/(?:^|\s)@([\w-]*)$/);
|
||||
|
||||
if (!triggerMatch) return null;
|
||||
|
||||
const query = triggerMatch[1];
|
||||
// from = position of the first char after @
|
||||
const from = ctx.pos - query.length;
|
||||
|
||||
if (!completer) return null;
|
||||
|
||||
try {
|
||||
const results = await completer(query);
|
||||
if (!results || results.length === 0) return null;
|
||||
|
||||
return {
|
||||
from,
|
||||
options: results.map(r => ({
|
||||
label: r.handle,
|
||||
displayLabel: r.label,
|
||||
detail: r.type === 'all' ? 'all users' : r.type,
|
||||
apply: (view, completion, from, to) => {
|
||||
// Insert handle + trailing space
|
||||
const insert = completion.label + ' ';
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
},
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[mention] autocomplete error:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
],
|
||||
activateOnTyping: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Theme: Mention Styles ───────────────────
|
||||
|
||||
const mentionTheme = EditorView.baseTheme({
|
||||
'.cm-mention': {
|
||||
color: 'var(--accent, #6366f1)',
|
||||
backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.1)',
|
||||
borderRadius: '3px',
|
||||
padding: '0 2px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
// ── Public: Combined Extension ──────────────
|
||||
|
||||
/**
|
||||
* Create the @mention CM6 extension bundle.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {Function} opts.completer - async (query) => [{ label, handle, type }]
|
||||
* @returns {Extension[]} Array of CM6 extensions
|
||||
*/
|
||||
export function mentionExtension(opts = {}) {
|
||||
const extensions = [
|
||||
mentionDecoPlugin(),
|
||||
mentionTheme,
|
||||
];
|
||||
|
||||
if (opts.completer) {
|
||||
extensions.push(mentionAutocomplete(opts.completer));
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
51
src/favicon-light.svg
Normal file
51
src/favicon-light.svg
Normal file
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f5f2ef"/>
|
||||
<stop offset="100%" stop-color="#e8e4e0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Panel background -->
|
||||
<rect x="4" y="4" width="56" height="56" rx="10" fill="url(#panel)" stroke="#c8c4c0" stroke-width="1.5"/>
|
||||
|
||||
<!-- Static jack holes (light rings) -->
|
||||
<circle cx="22" cy="23" r="8" fill="#d0ccc8"/>
|
||||
<circle cx="42" cy="23" r="8" fill="#d0ccc8"/>
|
||||
<circle cx="42" cy="43" r="8" fill="#d0ccc8"/>
|
||||
<circle cx="22" cy="43" r="8" fill="#d0ccc8"/>
|
||||
|
||||
<!--
|
||||
Cascading color rotation (clockwise: TL → TR → BR → BL)
|
||||
dur=12s, each cascade round=3s, fade=0.4s out + 0.4s in
|
||||
Same animation as dark favicon, fade-through color = #d0ccc8
|
||||
-->
|
||||
|
||||
<!-- TL: Blue → Purple → Green → Orange (offset 0s) -->
|
||||
<circle cx="22" cy="23" r="5.5" fill="#2D7DD2">
|
||||
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||
keyTimes="0;0.0333;0.0667;0.25;0.2833;0.3167;0.5;0.5333;0.5667;0.75;0.7833;0.8167;1"
|
||||
values="#2D7DD2;#d0ccc8;#9B59B6;#9B59B6;#d0ccc8;#2EAA4E;#2EAA4E;#d0ccc8;#E8852E;#E8852E;#d0ccc8;#2D7DD2;#2D7DD2"/>
|
||||
</circle>
|
||||
|
||||
<!-- TR: Orange → Blue → Purple → Green (offset 0.4s) -->
|
||||
<circle cx="42" cy="23" r="5.5" fill="#E8852E">
|
||||
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||
keyTimes="0;0.0333;0.0667;0.1;0.2833;0.3167;0.35;0.5333;0.5667;0.6;0.7833;0.8167;0.85;1"
|
||||
values="#E8852E;#E8852E;#d0ccc8;#2D7DD2;#2D7DD2;#d0ccc8;#9B59B6;#9B59B6;#d0ccc8;#2EAA4E;#2EAA4E;#d0ccc8;#E8852E;#E8852E"/>
|
||||
</circle>
|
||||
|
||||
<!-- BR: Purple → Green → Orange → Blue (offset 0.8s) -->
|
||||
<circle cx="42" cy="43" r="5.5" fill="#9B59B6">
|
||||
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||
keyTimes="0;0.0667;0.1;0.1333;0.3167;0.35;0.3833;0.5667;0.6;0.6333;0.8167;0.85;0.8833;1"
|
||||
values="#9B59B6;#9B59B6;#d0ccc8;#2EAA4E;#2EAA4E;#d0ccc8;#E8852E;#E8852E;#d0ccc8;#2D7DD2;#2D7DD2;#d0ccc8;#9B59B6;#9B59B6"/>
|
||||
</circle>
|
||||
|
||||
<!-- BL: Green → Orange → Blue → Purple (offset 1.2s) -->
|
||||
<circle cx="22" cy="43" r="5.5" fill="#2EAA4E">
|
||||
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||
keyTimes="0;0.1;0.1333;0.1667;0.35;0.3833;0.4167;0.6;0.6333;0.6667;0.85;0.8833;0.9167;1"
|
||||
values="#2EAA4E;#2EAA4E;#d0ccc8;#E8852E;#E8852E;#d0ccc8;#2D7DD2;#2D7DD2;#d0ccc8;#9B59B6;#9B59B6;#d0ccc8;#2EAA4E;#2EAA4E"/>
|
||||
</circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -13,34 +13,32 @@ const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// ── Admin /users response ────────────────────
|
||||
// Bug: Frontend read resp.data, backend sends resp.users
|
||||
// Fix: Read resp.users || resp.data
|
||||
// Normalized: uses standard {data: [...], total: N} envelope.
|
||||
// Has siblings → _unwrap does NOT strip. Frontend reads .data and .total.
|
||||
|
||||
describe('GET /admin/users response contract', () => {
|
||||
// This is the ACTUAL backend response shape from ListUsers handler:
|
||||
// c.JSON(200, gin.H{"users": users, "total": total})
|
||||
// ACTUAL backend response shape from ListUsers handler:
|
||||
// c.JSON(200, gin.H{"data": users, "total": total})
|
||||
const backendResponse = {
|
||||
users: [
|
||||
data: [
|
||||
{ id: 'u1', username: 'alice', email: 'alice@test.com', role: 'user', is_active: true },
|
||||
{ id: 'u2', username: 'admin', email: 'admin@test.com', role: 'admin', is_active: true },
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
|
||||
it('response has "users" key (not "data")', () => {
|
||||
assert.ok(Array.isArray(backendResponse.users), 'response.users must be an array');
|
||||
assert.equal(backendResponse.data, undefined, 'response.data must NOT exist');
|
||||
it('response has "data" array', () => {
|
||||
assert.ok(Array.isArray(backendResponse.data), 'response.data must be an array');
|
||||
});
|
||||
|
||||
it('frontend extraction reads .users with .data fallback', () => {
|
||||
// This mirrors the fixed loadMemberUserDropdown logic
|
||||
const users = backendResponse.users || backendResponse.data || [];
|
||||
it('frontend extraction reads .data (siblings present, _unwrap passes through)', () => {
|
||||
const users = backendResponse.data || [];
|
||||
assert.equal(users.length, 2);
|
||||
assert.equal(users[0].id, 'u1');
|
||||
});
|
||||
|
||||
it('each user has required fields for member dropdown', () => {
|
||||
for (const u of backendResponse.users) {
|
||||
for (const u of backendResponse.data) {
|
||||
assert.ok(u.id, 'user must have id');
|
||||
assert.ok(u.username || u.email, 'user must have username or email');
|
||||
assert.equal(typeof u.is_active, 'boolean', 'is_active must be boolean');
|
||||
@@ -49,7 +47,7 @@ describe('GET /admin/users response contract', () => {
|
||||
|
||||
it('total is numeric', () => {
|
||||
assert.equal(typeof backendResponse.total, 'number');
|
||||
assert.ok(backendResponse.total >= backendResponse.users.length);
|
||||
assert.ok(backendResponse.total >= backendResponse.data.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -319,9 +317,10 @@ describe('GET /teams/:id/members response contract', () => {
|
||||
// All models regardless of visibility.
|
||||
|
||||
describe('GET /admin/models response contract', () => {
|
||||
// This is the ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler.
|
||||
// ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler.
|
||||
// Normalized: {data: [...]} sole key → _unwrap strips → frontend gets bare array.
|
||||
const backendResponse = {
|
||||
models: [
|
||||
data: [
|
||||
{
|
||||
id: 'catalog-uuid-1',
|
||||
provider_config_id: 'provider-uuid',
|
||||
@@ -337,36 +336,31 @@ describe('GET /admin/models response contract', () => {
|
||||
],
|
||||
};
|
||||
|
||||
it('response has "models" array (not null)', () => {
|
||||
assert.ok(Array.isArray(backendResponse.models),
|
||||
'CRITICAL: models must be [] not null — null causes frontend fallback chain to pick wrong object');
|
||||
it('response has "data" array (not null)', () => {
|
||||
assert.ok(Array.isArray(backendResponse.data),
|
||||
'CRITICAL: data must be [] not null');
|
||||
});
|
||||
|
||||
it('each model has id and model_id', () => {
|
||||
for (const m of backendResponse.models) {
|
||||
for (const m of backendResponse.data) {
|
||||
assert.ok(m.id, 'must have id (UUID) for visibility toggle onclick');
|
||||
assert.ok(m.model_id, 'must have model_id for display');
|
||||
}
|
||||
});
|
||||
|
||||
it('each model has visibility', () => {
|
||||
for (const m of backendResponse.models) {
|
||||
for (const m of backendResponse.data) {
|
||||
assert.ok(['enabled', 'disabled', 'team'].includes(m.visibility),
|
||||
`visibility must be enabled/disabled/team, got: ${m.visibility}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('frontend extraction handles both null and empty array', () => {
|
||||
// This is the exact fallback chain from loadAdminModels
|
||||
const nullResp = { models: null };
|
||||
const list1 = nullResp.models || nullResp.data || nullResp || [];
|
||||
const arr1 = Array.isArray(list1) ? list1 : [];
|
||||
assert.equal(arr1.length, 0, 'null models must produce empty array');
|
||||
|
||||
const emptyResp = { models: [] };
|
||||
const list2 = emptyResp.models || emptyResp.data || emptyResp || [];
|
||||
const arr2 = Array.isArray(list2) ? list2 : [];
|
||||
assert.equal(arr2.length, 0, 'empty models must produce empty array');
|
||||
it('_unwrap strips sole-key {data:[]} → frontend gets bare array', () => {
|
||||
// After _unwrap, frontend receives the bare array, uses `resp || []`
|
||||
const unwrapped = backendResponse.data; // simulates _unwrap
|
||||
const list = unwrapped || [];
|
||||
assert.ok(Array.isArray(list));
|
||||
assert.equal(list.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,30 +403,30 @@ describe('POST /admin/models/fetch response contract', () => {
|
||||
|
||||
// ── Cross-endpoint consistency ───────────────
|
||||
|
||||
describe('Response shape consistency', () => {
|
||||
it('admin/users uses {users:[]} not {data:[]}', () => {
|
||||
// This is the contract the team member dropdown depends on
|
||||
const shape = { users: [], total: 0 };
|
||||
assert.ok('users' in shape, 'admin/users must use "users" key');
|
||||
describe('Response shape consistency — all endpoints use {data:[...]} envelope', () => {
|
||||
it('admin/users uses {data:[], total:N} (siblings → not unwrapped)', () => {
|
||||
const shape = { data: [], total: 0 };
|
||||
assert.ok('data' in shape);
|
||||
assert.ok('total' in shape);
|
||||
});
|
||||
|
||||
it('teams/members uses {data:[]}', () => {
|
||||
const shape = { data: [] };
|
||||
assert.ok('data' in shape, 'teams/members uses "data" key');
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
|
||||
it('models/enabled uses {data:[]}', () => {
|
||||
it('models/enabled uses {data:[], default_model:""} (siblings → not unwrapped)', () => {
|
||||
const shape = { data: [], default_model: '' };
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
|
||||
it('admin/models uses {data:[]} (sole key → unwrapped)', () => {
|
||||
const shape = { data: [] };
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
|
||||
it('admin/models uses {models:[]}', () => {
|
||||
const shape = { models: [] };
|
||||
assert.ok('models' in shape, 'admin/models must use "models" key');
|
||||
});
|
||||
|
||||
it('personas uses {personas:[]}', () => {
|
||||
const shape = { personas: [] };
|
||||
assert.ok('personas' in shape);
|
||||
it('personas uses {data:[]} (sole key → unwrapped)', () => {
|
||||
const shape = { data: [] };
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,3 +241,82 @@ describe('init() profile gate', () => {
|
||||
// Without guard: UI renders. With guard: splash shown.
|
||||
});
|
||||
});
|
||||
|
||||
// ── Boot-time 401 redirect suppression ──────
|
||||
// v0.37.14: Stale cookie blank-login-page fix.
|
||||
// When the login page boots the SDK with stale localStorage tokens,
|
||||
// the REST client's 401 handler must NOT redirect to /login (we're
|
||||
// already there). boot() handles 401 gracefully on its own.
|
||||
|
||||
describe('on401Failure redirect suppression', () => {
|
||||
|
||||
// Model of the _on401Failure guard logic
|
||||
function make401Handler() {
|
||||
let _booting = false;
|
||||
let redirectedTo = null;
|
||||
|
||||
return {
|
||||
set booting(v) { _booting = v; },
|
||||
get booting() { return _booting; },
|
||||
get redirectedTo() { return redirectedTo; },
|
||||
reset() { redirectedTo = null; },
|
||||
|
||||
on401Failure(pathname, basePath) {
|
||||
// Mirrors the real _on401Failure logic
|
||||
if (_booting) return;
|
||||
const loginPath = basePath + '/login';
|
||||
const here = pathname.replace(/\/+$/, '');
|
||||
if (here === loginPath) return;
|
||||
redirectedTo = loginPath;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('suppresses redirect during boot', () => {
|
||||
const h = make401Handler();
|
||||
h.booting = true;
|
||||
h.on401Failure('/chat', '');
|
||||
assert.equal(h.redirectedTo, null, 'must NOT redirect during boot');
|
||||
});
|
||||
|
||||
it('allows redirect after boot completes', () => {
|
||||
const h = make401Handler();
|
||||
h.booting = true;
|
||||
h.on401Failure('/chat', '');
|
||||
assert.equal(h.redirectedTo, null);
|
||||
|
||||
h.booting = false;
|
||||
h.on401Failure('/chat', '');
|
||||
assert.equal(h.redirectedTo, '/login', 'must redirect after boot');
|
||||
});
|
||||
|
||||
it('suppresses redirect when already on /login', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/login', '');
|
||||
assert.equal(h.redirectedTo, null, 'must NOT redirect to /login from /login');
|
||||
});
|
||||
|
||||
it('suppresses redirect when on /login with trailing slash', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/login/', '');
|
||||
assert.equal(h.redirectedTo, null, 'must NOT redirect from /login/');
|
||||
});
|
||||
|
||||
it('handles basePath correctly', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/app/login', '/app');
|
||||
assert.equal(h.redirectedTo, null, 'must NOT redirect from basePath + /login');
|
||||
});
|
||||
|
||||
it('redirects from non-login paths normally', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/chat', '');
|
||||
assert.equal(h.redirectedTo, '/login');
|
||||
});
|
||||
|
||||
it('redirects from non-login paths with basePath', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/app/chat', '/app');
|
||||
assert.equal(h.redirectedTo, '/app/login');
|
||||
});
|
||||
});
|
||||
|
||||
149
src/js/__tests__/channel-contracts.test.js
Normal file
149
src/js/__tests__/channel-contracts.test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// ==========================================
|
||||
// Channel API Contract Tests
|
||||
// ==========================================
|
||||
// Validates channel listing, type filtering, and
|
||||
// the sidebar's data flow. Catches the channel_type
|
||||
// vs type parameter mismatch (v0.37.14.2 fix).
|
||||
//
|
||||
// Run: node --test src/js/__tests__/channel-contracts.test.js
|
||||
// ==========================================
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// ── Channel list response shape ──────────────
|
||||
|
||||
describe('GET /channels response contract', () => {
|
||||
// Backend response from ListChannels handler:
|
||||
// c.JSON(200, gin.H{"data": channels, "total": total, ...})
|
||||
const backendResponse = {
|
||||
data: [
|
||||
{ id: 'c1', title: 'General', type: 'channel', folder_id: null },
|
||||
{ id: 'c2', title: 'AI Chat', type: 'direct', folder_id: 'f1' },
|
||||
{ id: 'c3', title: 'Dev Group', type: 'group', folder_id: null },
|
||||
{ id: 'c4', title: 'DM: Alice', type: 'dm', folder_id: null },
|
||||
],
|
||||
total: 4,
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
};
|
||||
|
||||
it('response has "data" array', () => {
|
||||
assert.ok(Array.isArray(backendResponse.data), 'response.data must be an array');
|
||||
});
|
||||
|
||||
it('each channel has required fields', () => {
|
||||
for (const ch of backendResponse.data) {
|
||||
assert.ok(ch.id, 'channel must have id');
|
||||
assert.ok(ch.type, 'channel must have type');
|
||||
assert.ok(['direct', 'dm', 'group', 'channel', 'workflow'].includes(ch.type),
|
||||
`unknown type: ${ch.type}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type filter parameter naming ──────────────
|
||||
|
||||
describe('Channel list type filter', () => {
|
||||
// Simulates URL parameter building (like _qs in api-domains.js)
|
||||
function buildQueryString(opts) {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts)) {
|
||||
if (v != null) p.set(k, String(v));
|
||||
}
|
||||
const s = p.toString();
|
||||
return s ? '?' + s : '';
|
||||
}
|
||||
|
||||
it('should use "type" param (not "channel_type")', () => {
|
||||
// The Go handler expects "type" or "types"
|
||||
const qs = buildQueryString({ page: 1, per_page: 100, type: 'direct' });
|
||||
assert.ok(qs.includes('type=direct'), `query should contain type=direct, got: ${qs}`);
|
||||
assert.ok(!qs.includes('channel_type'), 'should NOT use channel_type param');
|
||||
});
|
||||
|
||||
it('should use "types" for multi-type filter', () => {
|
||||
const qs = buildQueryString({ page: 1, per_page: 200, types: 'direct,dm,group,channel' });
|
||||
assert.ok(qs.includes('types=direct'), `query should contain types param, got: ${qs}`);
|
||||
});
|
||||
|
||||
it('single type results should all match requested type', () => {
|
||||
const channels = [
|
||||
{ id: 'c1', type: 'direct' },
|
||||
{ id: 'c2', type: 'direct' },
|
||||
];
|
||||
for (const ch of channels) {
|
||||
assert.equal(ch.type, 'direct', `type filter leaked: ${ch.type}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('multi-type results should only contain requested types', () => {
|
||||
const channels = [
|
||||
{ id: 'c1', type: 'direct' },
|
||||
{ id: 'c2', type: 'group' },
|
||||
{ id: 'c3', type: 'channel' },
|
||||
{ id: 'c4', type: 'dm' },
|
||||
];
|
||||
const allowed = new Set(['direct', 'dm', 'group', 'channel']);
|
||||
for (const ch of channels) {
|
||||
assert.ok(allowed.has(ch.type), `unexpected type: ${ch.type}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sidebar merge deduplication ───────────────
|
||||
|
||||
describe('Sidebar channel merge', () => {
|
||||
it('single request should not produce duplicates', () => {
|
||||
// After fix: sidebar uses a single request with types=direct,dm,group,channel
|
||||
const apiResponse = [
|
||||
{ id: 'c1', title: 'Chat 1', type: 'direct' },
|
||||
{ id: 'c2', title: 'General', type: 'channel' },
|
||||
{ id: 'c3', title: 'DM: Bob', type: 'dm' },
|
||||
];
|
||||
const ids = apiResponse.map(ch => ch.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
assert.equal(ids.length, uniqueIds.size, 'no duplicate ids expected');
|
||||
});
|
||||
|
||||
it('_extract handles nested .data or flat array', () => {
|
||||
// Mirrors use-sidebar.js _extract pattern
|
||||
function extract(resp) {
|
||||
if (!resp) return [];
|
||||
if (Array.isArray(resp)) return resp;
|
||||
if (resp.data && Array.isArray(resp.data)) return resp.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
assert.deepEqual(extract({ data: [{ id: '1' }] }), [{ id: '1' }]);
|
||||
assert.deepEqual(extract([{ id: '2' }]), [{ id: '2' }]);
|
||||
assert.deepEqual(extract(null), []);
|
||||
assert.deepEqual(extract({}), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel type feature matrix ───────────────
|
||||
|
||||
describe('Channel type feature matrix', () => {
|
||||
const AI_TYPES = new Set(['direct', 'workflow']);
|
||||
|
||||
it('direct type shows model selector', () => {
|
||||
assert.ok(AI_TYPES.has('direct'));
|
||||
});
|
||||
|
||||
it('workflow type shows model selector', () => {
|
||||
assert.ok(AI_TYPES.has('workflow'));
|
||||
});
|
||||
|
||||
it('dm type hides model selector', () => {
|
||||
assert.ok(!AI_TYPES.has('dm'));
|
||||
});
|
||||
|
||||
it('group type hides model selector', () => {
|
||||
assert.ok(!AI_TYPES.has('group'));
|
||||
});
|
||||
|
||||
it('channel type hides model selector', () => {
|
||||
assert.ok(!AI_TYPES.has('channel'));
|
||||
});
|
||||
});
|
||||
@@ -78,60 +78,53 @@ describe('Policy evaluation logic', () => {
|
||||
// ── Team member dropdown logic ───────────────
|
||||
|
||||
describe('Team member dropdown population', () => {
|
||||
function getAvailableUsers(usersResp, membersResp) {
|
||||
const users = usersResp.users || usersResp.data || [];
|
||||
const existingIds = new Set((membersResp.data || []).map(m => m.user_id));
|
||||
// admin/users returns {data:[...], total:N} — has siblings, _unwrap passes through.
|
||||
// teams/members returns {data:[...]} — sole key, _unwrap strips to bare array.
|
||||
function getAvailableUsers(usersResp, membersArr) {
|
||||
const users = usersResp.data || [];
|
||||
const existingIds = new Set((membersArr || []).map(m => m.user_id));
|
||||
return users.filter(u => u.is_active && !existingIds.has(u.id));
|
||||
}
|
||||
|
||||
it('extracts users from {users:[]} response', () => {
|
||||
const usersResp = { users: [
|
||||
it('extracts users from {data:[...], total:N} response', () => {
|
||||
const usersResp = { data: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
{ id: 'u2', username: 'bob', is_active: true },
|
||||
]};
|
||||
const membersResp = { data: [] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
], total: 2};
|
||||
const membersArr = [];
|
||||
const available = getAvailableUsers(usersResp, membersArr);
|
||||
assert.equal(available.length, 2);
|
||||
});
|
||||
|
||||
it('falls back to {data:[]} response (backward compat)', () => {
|
||||
it('excludes existing team members', () => {
|
||||
const usersResp = { data: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
]};
|
||||
const membersResp = { data: [] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
assert.equal(available.length, 1);
|
||||
});
|
||||
|
||||
it('excludes existing team members', () => {
|
||||
const usersResp = { users: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
{ id: 'u2', username: 'bob', is_active: true },
|
||||
]};
|
||||
const membersResp = { data: [{ user_id: 'u1', role: 'admin' }] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
], total: 2};
|
||||
const membersArr = [{ user_id: 'u1', role: 'admin' }];
|
||||
const available = getAvailableUsers(usersResp, membersArr);
|
||||
assert.equal(available.length, 1);
|
||||
assert.equal(available[0].id, 'u2');
|
||||
});
|
||||
|
||||
it('excludes inactive users', () => {
|
||||
const usersResp = { users: [
|
||||
const usersResp = { data: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
{ id: 'u2', username: 'disabled', is_active: false },
|
||||
]};
|
||||
const membersResp = { data: [] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
], total: 2};
|
||||
const membersArr = [];
|
||||
const available = getAvailableUsers(usersResp, membersArr);
|
||||
assert.equal(available.length, 1);
|
||||
assert.equal(available[0].id, 'u1');
|
||||
});
|
||||
|
||||
it('returns empty for empty users response', () => {
|
||||
const available = getAvailableUsers({ users: [] }, { data: [] });
|
||||
const available = getAvailableUsers({ data: [] }, []);
|
||||
assert.equal(available.length, 0);
|
||||
});
|
||||
|
||||
it('handles completely missing response fields', () => {
|
||||
const available = getAvailableUsers({}, {});
|
||||
const available = getAvailableUsers({}, []);
|
||||
assert.equal(available.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
111
src/js/debug.js
111
src/js/debug.js
@@ -222,33 +222,17 @@ const DebugLog = {
|
||||
url: location.href,
|
||||
};
|
||||
|
||||
// API client state (redact tokens)
|
||||
if (typeof API !== 'undefined') {
|
||||
snap.api = {
|
||||
hasAccessToken: !!API.accessToken,
|
||||
hasRefreshToken: !!API.refreshToken,
|
||||
user: API.user ? {
|
||||
username: API.user.username,
|
||||
role: API.user.role,
|
||||
display_name: API.user.display_name
|
||||
// Preact SDK state (v0.37.14: replaces old API/App globals)
|
||||
if (window.sw) {
|
||||
snap.sdk = {
|
||||
user: window.sw.auth?.user ? {
|
||||
username: window.sw.auth.user.username,
|
||||
role: window.sw.auth.user.role,
|
||||
display_name: window.sw.auth.user.display_name
|
||||
} : null,
|
||||
isAuthed: API.isAuthed,
|
||||
isAdmin: API.isAdmin
|
||||
};
|
||||
}
|
||||
|
||||
// App state
|
||||
if (typeof App !== 'undefined') {
|
||||
snap.app = {
|
||||
chatCount: App.chats?.length || 0,
|
||||
activeConversation: App.activeConversation,
|
||||
modelCount: App.models?.length || 0,
|
||||
isGenerating: App.isGenerating,
|
||||
settings: {
|
||||
model: App.settings?.model || '?',
|
||||
stream: App.settings?.stream,
|
||||
showThinking: App.settings?.showThinking
|
||||
}
|
||||
isAuthed: !!window.sw.auth?.token,
|
||||
wsConnected: window.sw.events?.connected ?? false,
|
||||
theme: window.sw.theme?.resolved ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,25 +245,6 @@ const DebugLog = {
|
||||
snap.storageKeys = '(error reading)';
|
||||
}
|
||||
|
||||
// EventBus state
|
||||
if (typeof Events !== 'undefined') {
|
||||
snap.eventBus = {
|
||||
wsConnected: Events.connected,
|
||||
subscriptions: Events.debug(),
|
||||
queueLength: Events._wsQueue?.length || 0
|
||||
};
|
||||
}
|
||||
|
||||
// Extensions state
|
||||
if (typeof Extensions !== 'undefined') {
|
||||
snap.extensions = Extensions.debug();
|
||||
}
|
||||
|
||||
// CM6 editor state
|
||||
snap.cm6 = window.CM
|
||||
? { version: window.CM.version, languages: Object.keys(window.CM.languages || {}) }
|
||||
: { available: false };
|
||||
|
||||
return snap;
|
||||
},
|
||||
|
||||
@@ -337,13 +302,13 @@ const DebugLog = {
|
||||
this.log('DIAG', ` ❌ OPTIONS failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Test 3: Token validity
|
||||
if (typeof API !== 'undefined' && API.accessToken) {
|
||||
// Test 3: Token validity via Preact SDK
|
||||
if (window.sw?.auth?.token) {
|
||||
this.log('DIAG', 'Test 3: Token validation');
|
||||
try {
|
||||
const resp = await this._origFetch(baseUrl + '/api/v1/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${API.accessToken}`,
|
||||
'Authorization': `Bearer ${window.sw.auth.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
signal: AbortSignal.timeout(5000)
|
||||
@@ -359,29 +324,10 @@ const DebugLog = {
|
||||
this.log('DIAG', 'Test 3: Skipped (no token)');
|
||||
}
|
||||
|
||||
// Test 4: WebSocket connectivity
|
||||
this.log('DIAG', `Test 4: EventBus WebSocket = ${typeof Events !== 'undefined' ? (Events.connected ? 'connected' : 'disconnected') : 'N/A'}`);
|
||||
if (typeof Events !== 'undefined') {
|
||||
this.log('DIAG', ` Subscriptions: ${JSON.stringify(Events.debug())}`);
|
||||
this.log('DIAG', ` Queue depth: ${Events._wsQueue?.length || 0}`);
|
||||
}
|
||||
// Test 4: WebSocket connectivity via Preact SDK
|
||||
this.log('DIAG', `Test 4: SDK WebSocket = ${window.sw?.events?.connected ? 'connected' : 'disconnected'}`);
|
||||
|
||||
// Test 5: Extensions
|
||||
this.log('DIAG', 'Test 5: Browser extensions');
|
||||
if (typeof Extensions !== 'undefined') {
|
||||
const info = Extensions.debug();
|
||||
const extNames = Object.keys(info.extensions);
|
||||
this.log('DIAG', ` Loaded: ${extNames.length} extension(s)`);
|
||||
for (const name of extNames) {
|
||||
const ext = info.extensions[name];
|
||||
this.log('DIAG', ` ${name}: ${ext.active ? '✅ active' : '❌ inactive'}, renderers: [${ext.renderers.join(', ')}]`);
|
||||
}
|
||||
this.log('DIAG', ` Total renderers: ${info.rendererCount}, tool handlers: ${Extensions._toolHandlers?.size || 0}`);
|
||||
} else {
|
||||
this.log('DIAG', ' Extensions not loaded');
|
||||
}
|
||||
|
||||
// Test 6: Service Worker & Cache
|
||||
// Test 5: Service Worker & Cache
|
||||
this.log('DIAG', 'Test 6: Service Worker cache');
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -652,8 +598,8 @@ async function clearDebugLog() {
|
||||
function copyDebugLog() {
|
||||
const text = DebugLog.exportText();
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => { if (typeof showToast === 'function') showToast('📋 Debug log copied', 'success'); })
|
||||
.catch(() => { UI.toast('Copy failed', 'error'); });
|
||||
.then(() => { DebugLog.log('DEBUG', '📋 Debug log copied to clipboard'); })
|
||||
.catch(() => { DebugLog.log('ERROR', 'Copy to clipboard failed'); });
|
||||
}
|
||||
|
||||
function exportDebugLog() {
|
||||
@@ -712,12 +658,15 @@ async function purgeCache() {
|
||||
// Initialize ASAP — before app.js init() so we capture everything
|
||||
DebugLog.init();
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('DebugLog', DebugLog);
|
||||
sb.register('openDebugModal', openDebugModal);
|
||||
sb.register('switchDebugTab', switchDebugTab);
|
||||
sb.register('clearDebugLog', clearDebugLog);
|
||||
sb.register('copyDebugLog', copyDebugLog);
|
||||
sb.register('exportDebugLog', exportDebugLog);
|
||||
sb.register('runDebugDiagnostics', runDebugDiagnostics);
|
||||
sb.register('purgeCache', purgeCache);
|
||||
// ── Exports (v0.37.14: window.* replaces sb.register) ──
|
||||
window.DebugLog = DebugLog;
|
||||
window.openDebugModal = openDebugModal;
|
||||
window.closeModal = closeModal;
|
||||
window.openModal = openModal;
|
||||
window.showConfirm = showConfirm;
|
||||
window.switchDebugTab = switchDebugTab;
|
||||
window.clearDebugLog = clearDebugLog;
|
||||
window.copyDebugLog = copyDebugLog;
|
||||
window.exportDebugLog = exportDebugLog;
|
||||
window.runDebugDiagnostics = runDebugDiagnostics;
|
||||
window.purgeCache = purgeCache;
|
||||
|
||||
361
src/js/events.js
361
src/js/events.js
@@ -1,361 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – EventBus
|
||||
// ==========================================
|
||||
// Labeled event bus with WebSocket bridge.
|
||||
// Components subscribe by label (supports * wildcard).
|
||||
// Events route: local-only, FE→BE, BE→FE, or both.
|
||||
//
|
||||
// Usage:
|
||||
// Events.on('chat.message.*', (payload, meta) => { ... });
|
||||
// Events.emit('chat.typing.abc123', { user: 'jeff' });
|
||||
// Events.connect('/ws');
|
||||
//
|
||||
// Exports: window.Events
|
||||
// ==========================================
|
||||
|
||||
|
||||
const Events = {
|
||||
|
||||
_handlers: new Map(), // label → Set<{fn, once}>
|
||||
_ws: null,
|
||||
_wsUrl: null,
|
||||
_wsReconnectMs: 1000,
|
||||
_wsMaxReconnectMs: 30000,
|
||||
_wsReconnectTimer: null,
|
||||
_wsConnected: false,
|
||||
_wsQueue: [], // buffered while disconnected
|
||||
|
||||
// Labels that should NOT cross the wire
|
||||
_localOnly: new Set([
|
||||
'ui.modal.open', 'ui.modal.close',
|
||||
'ui.sidebar.toggle', 'ui.sidebar.collapse',
|
||||
'ui.toast',
|
||||
]),
|
||||
|
||||
// Labels the FE should never receive from BE
|
||||
// (enforced server-side, this is defense-in-depth)
|
||||
_serverOnly: new Set([
|
||||
'plugin.hook.pre_completion',
|
||||
'plugin.hook.post_completion',
|
||||
'internal.',
|
||||
'db.',
|
||||
]),
|
||||
|
||||
// ── Subscribe ────────────────────────────
|
||||
|
||||
/**
|
||||
* Subscribe to events matching a label pattern.
|
||||
* Supports exact match and trailing wildcard:
|
||||
* 'chat.message.abc123' — exact
|
||||
* 'chat.message.*' — any chat.message.{id}
|
||||
* 'chat.*' — any chat.{anything}
|
||||
*
|
||||
* @param {string} label - Event label or pattern
|
||||
* @param {Function} fn - Handler(payload, meta)
|
||||
* @returns {Function} unsubscribe function
|
||||
*/
|
||||
on(label, fn) {
|
||||
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
|
||||
const entry = { fn, once: false };
|
||||
this._handlers.get(label).add(entry);
|
||||
return () => this._handlers.get(label)?.delete(entry);
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe for a single event, then auto-unsubscribe.
|
||||
*/
|
||||
once(label, fn) {
|
||||
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
|
||||
const entry = { fn, once: true };
|
||||
this._handlers.get(label).add(entry);
|
||||
return () => this._handlers.get(label)?.delete(entry);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all handlers for a label, or a specific handler.
|
||||
*/
|
||||
off(label, fn) {
|
||||
if (!fn) {
|
||||
this._handlers.delete(label);
|
||||
} else {
|
||||
const set = this._handlers.get(label);
|
||||
if (set) {
|
||||
for (const entry of set) {
|
||||
if (entry.fn === fn) { set.delete(entry); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Emit ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit an event. Dispatches locally and sends via WebSocket
|
||||
* unless the label is in _localOnly.
|
||||
*
|
||||
* @param {string} label - Event label
|
||||
* @param {*} payload - Event data
|
||||
* @param {object} opts - { localOnly: bool, room: string }
|
||||
*/
|
||||
emit(label, payload, opts = {}) {
|
||||
const meta = {
|
||||
event: label,
|
||||
room: opts.room || null,
|
||||
ts: Date.now(),
|
||||
local: true,
|
||||
};
|
||||
|
||||
// Local dispatch
|
||||
this._dispatch(label, payload, meta);
|
||||
|
||||
// Send over WebSocket unless local-only
|
||||
if (!opts.localOnly && !this._isLocalOnly(label)) {
|
||||
this._send({ event: label, room: meta.room, payload, ts: meta.ts });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Internal dispatch ────────────────────
|
||||
|
||||
_dispatch(label, payload, meta) {
|
||||
// Check every registered pattern against this label
|
||||
for (const [pattern, entries] of this._handlers) {
|
||||
if (this._match(label, pattern)) {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
entry.fn(payload, meta);
|
||||
} catch (e) {
|
||||
console.error(`[EventBus] Handler error for ${pattern}:`, e);
|
||||
}
|
||||
if (entry.once) entries.delete(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Match a concrete label against a subscription pattern.
|
||||
* 'chat.message.abc' matches:
|
||||
* 'chat.message.abc' (exact)
|
||||
* 'chat.message.*' (wildcard last segment)
|
||||
* 'chat.*' (wildcard all after chat.)
|
||||
* '*' (match everything)
|
||||
*/
|
||||
_match(label, pattern) {
|
||||
if (pattern === '*') return true;
|
||||
if (pattern === label) return true;
|
||||
if (!pattern.endsWith('*')) return false;
|
||||
const prefix = pattern.slice(0, -1); // 'chat.message.' from 'chat.message.*'
|
||||
return label.startsWith(prefix);
|
||||
},
|
||||
|
||||
_isLocalOnly(label) {
|
||||
if (this._localOnly.has(label)) return true;
|
||||
for (const prefix of this._localOnly) {
|
||||
if (prefix.endsWith('.') && label.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// ── WebSocket Bridge ─────────────────────
|
||||
|
||||
/**
|
||||
* Connect to WebSocket endpoint. Auto-reconnects on drop.
|
||||
* @param {string} url - WebSocket URL (e.g. '/ws' or 'wss://host/ws')
|
||||
*/
|
||||
connect(url) {
|
||||
this._wsUrl = url;
|
||||
this._wsReconnectMs = 1000;
|
||||
this._wsRetries = 0;
|
||||
this._wsMaxRetries = 5;
|
||||
this._doConnect();
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
if (this._wsReconnectTimer) clearTimeout(this._wsReconnectTimer);
|
||||
this._wsReconnectTimer = null;
|
||||
this._wsRetries = 0;
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // prevent reconnect
|
||||
this._ws.close();
|
||||
this._ws = null;
|
||||
}
|
||||
this._wsConnected = false;
|
||||
this._dispatch('ws.disconnected', {}, { event: 'ws.disconnected', ts: Date.now(), local: true });
|
||||
},
|
||||
|
||||
async _doConnect() {
|
||||
if (!this._wsUrl) return;
|
||||
|
||||
// Build full URL
|
||||
let url = this._wsUrl;
|
||||
if (url.startsWith('/')) {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url = `${proto}//${location.host}${url}`;
|
||||
}
|
||||
|
||||
// Authenticate: prefer ticket exchange, fall back to legacy ?token=
|
||||
const authParam = await this._acquireWsAuth();
|
||||
if (!authParam) {
|
||||
console.warn('[EventBus] No auth available — skipping WebSocket');
|
||||
return;
|
||||
}
|
||||
url += (url.includes('?') ? '&' : '?') + authParam;
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
} catch (e) {
|
||||
console.warn('[EventBus] WebSocket creation failed:', e);
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws.onopen = () => {
|
||||
console.log('[EventBus] WebSocket connected');
|
||||
this._wsConnected = true;
|
||||
this._wsReconnectMs = 1000;
|
||||
this._wsRetries = 0;
|
||||
|
||||
// Flush queued events
|
||||
while (this._wsQueue.length > 0) {
|
||||
const msg = this._wsQueue.shift();
|
||||
this._ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
this._dispatch('ws.connected', {}, { event: 'ws.connected', ts: Date.now(), local: true });
|
||||
};
|
||||
|
||||
this._ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
|
||||
// Server pong
|
||||
if (msg.event === 'pong') return;
|
||||
|
||||
// Drop events that shouldn't reach the frontend
|
||||
for (const prefix of this._serverOnly) {
|
||||
if (msg.event.startsWith(prefix)) return;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
event: msg.event,
|
||||
room: msg.room || null,
|
||||
ts: msg.ts || Date.now(),
|
||||
local: false,
|
||||
};
|
||||
|
||||
this._dispatch(msg.event, msg.payload, meta);
|
||||
} catch (e) {
|
||||
console.warn('[EventBus] Bad message:', evt.data);
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onclose = (evt) => {
|
||||
this._wsConnected = false;
|
||||
this._ws = null;
|
||||
if (evt.code !== 1000) {
|
||||
// Abnormal close — log with context
|
||||
console.log(`[EventBus] WebSocket closed (code=${evt.code}, reason=${evt.reason || 'none'})`);
|
||||
}
|
||||
this._dispatch('ws.disconnected', { code: evt.code }, { event: 'ws.disconnected', ts: Date.now(), local: true });
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
// onclose will fire after this — don't double-log
|
||||
};
|
||||
|
||||
// Heartbeat
|
||||
this._startHeartbeat();
|
||||
},
|
||||
|
||||
/**
|
||||
* Acquire WebSocket auth parameter.
|
||||
* Preferred: POST /api/v1/ws/ticket → ?ticket=<opaque> (v0.28.8+)
|
||||
* Fallback: JWT access token → ?token=<jwt> (deprecated)
|
||||
* @returns {string|null} query parameter string or null if no auth
|
||||
*/
|
||||
async _acquireWsAuth() {
|
||||
// Try ticket exchange first
|
||||
try {
|
||||
if (typeof API !== 'undefined' && API._post) {
|
||||
const data = await API._post('/api/v1/ws/ticket', {});
|
||||
if (data && data.ticket) {
|
||||
return `ticket=${encodeURIComponent(data.ticket)}`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ticket endpoint unavailable (pre-v0.28.8 server) or auth error.
|
||||
// Fall through to legacy path silently.
|
||||
console.debug('[EventBus] Ticket exchange failed, falling back to ?token=', e.message || e);
|
||||
}
|
||||
|
||||
// Legacy fallback: JWT in query param
|
||||
const token = (typeof API !== 'undefined' && API.accessToken) ? API.accessToken : null;
|
||||
if (token) {
|
||||
return `token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (!this._wsUrl) return;
|
||||
if (this._wsReconnectTimer) return;
|
||||
|
||||
this._wsRetries++;
|
||||
if (this._wsRetries > this._wsMaxRetries) {
|
||||
console.warn(`[EventBus] WebSocket failed after ${this._wsMaxRetries} attempts — giving up. Real-time features unavailable.`);
|
||||
this._dispatch('ws.failed', { retries: this._wsRetries }, { event: 'ws.failed', ts: Date.now(), local: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[EventBus] Reconnecting in ${this._wsReconnectMs}ms (attempt ${this._wsRetries}/${this._wsMaxRetries})`);
|
||||
this._wsReconnectTimer = setTimeout(() => {
|
||||
this._wsReconnectTimer = null;
|
||||
this._doConnect();
|
||||
}, this._wsReconnectMs);
|
||||
|
||||
// Exponential backoff with cap
|
||||
this._wsReconnectMs = Math.min(this._wsReconnectMs * 2, this._wsMaxReconnectMs);
|
||||
},
|
||||
|
||||
_send(msg) {
|
||||
if (this._wsConnected && this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify(msg));
|
||||
} else {
|
||||
// Buffer up to 100 events while disconnected
|
||||
if (this._wsQueue.length < 100) this._wsQueue.push(msg);
|
||||
}
|
||||
},
|
||||
|
||||
_heartbeatInterval: null,
|
||||
_startHeartbeat() {
|
||||
if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
|
||||
this._heartbeatInterval = setInterval(() => {
|
||||
if (this._wsConnected) {
|
||||
this._send({ event: 'ping', payload: {}, ts: Date.now() });
|
||||
}
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
// ── Utility ──────────────────────────────
|
||||
|
||||
/** Check if WebSocket is connected */
|
||||
get connected() { return this._wsConnected; },
|
||||
|
||||
/** Remove all handlers (for cleanup / logout) */
|
||||
clear() {
|
||||
this._handlers.clear();
|
||||
},
|
||||
|
||||
/** Debug: list all subscriptions */
|
||||
debug() {
|
||||
const subs = {};
|
||||
for (const [label, entries] of this._handlers) {
|
||||
subs[label] = entries.size;
|
||||
}
|
||||
return subs;
|
||||
}
|
||||
};
|
||||
|
||||
sb.ns('Events', Events);
|
||||
@@ -7,7 +7,7 @@
|
||||
//
|
||||
// Features:
|
||||
// - AsyncFunction wrapper (top-level await)
|
||||
// - Injected globals: API, Events, Extensions, DebugLog, Surfaces, Stores
|
||||
// - Injected globals: sw, DebugLog (v0.37.14: old globals removed)
|
||||
// - Pretty-print results (collapsible JSON, red errors with stack)
|
||||
// - Command history: ↑/↓ with sessionStorage
|
||||
// - Tab-completion on live object graphs
|
||||
@@ -57,7 +57,7 @@ const REPL = {
|
||||
const tab = document.querySelector('.debug-tab[data-tab="repl"]');
|
||||
if (!tab) return;
|
||||
|
||||
const isAdmin = typeof API !== 'undefined' && API.user?.role === 'admin';
|
||||
const isAdmin = window.sw?.auth?.user?.role === 'admin';
|
||||
const debugParam = new URLSearchParams(window.location.search).has('debug');
|
||||
|
||||
if (!isAdmin && !debugParam) {
|
||||
@@ -144,13 +144,9 @@ const REPL = {
|
||||
_getGlobals() {
|
||||
const globals = {};
|
||||
|
||||
// Core app objects
|
||||
if (typeof API !== 'undefined') globals.API = API;
|
||||
if (typeof Events !== 'undefined') globals.Events = Events;
|
||||
if (typeof Extensions !== 'undefined') globals.Extensions = Extensions;
|
||||
// Preact SDK (v0.37.14: replaces old API/Events/Extensions globals)
|
||||
if (window.sw) globals.sw = window.sw;
|
||||
if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog;
|
||||
if (typeof PanelRegistry !== 'undefined') globals.Panels = PanelRegistry;
|
||||
if (typeof UI !== 'undefined') globals.UI = UI;
|
||||
|
||||
// Convenience aliases
|
||||
globals.$ = (sel) => document.querySelector(sel);
|
||||
@@ -246,13 +242,6 @@ const REPL = {
|
||||
const cursor = input.selectionStart;
|
||||
const text = input.value.substring(0, cursor);
|
||||
|
||||
// Event label completion: Events.on(' or Events.emit('
|
||||
const eventMatch = text.match(/Events\.(on|once|emit|off)\s*\(\s*['"]([^'"]*)?$/);
|
||||
if (eventMatch) {
|
||||
this._completeEventLabels(input, cursor, eventMatch[2] || '');
|
||||
return;
|
||||
}
|
||||
|
||||
// Object property completion: obj.prop or obj.prop.sub
|
||||
const propMatch = text.match(/([\w$.]+)\.([\w]*)$/);
|
||||
if (propMatch) {
|
||||
@@ -267,25 +256,6 @@ const REPL = {
|
||||
}
|
||||
},
|
||||
|
||||
_completeEventLabels(input, cursor, partial) {
|
||||
// Gather known event labels from the Events handler map
|
||||
if (typeof Events === 'undefined') return;
|
||||
const labels = Array.from(Events._handlers?.keys() || []);
|
||||
const matches = labels.filter(l => l.startsWith(partial)).sort();
|
||||
|
||||
if (matches.length === 0) return;
|
||||
if (matches.length === 1) {
|
||||
this._insertCompletion(input, cursor, partial, matches[0]);
|
||||
} else {
|
||||
this._appendInfo('Completions: ' + matches.join(', '));
|
||||
// Complete common prefix
|
||||
const common = this._commonPrefix(matches);
|
||||
if (common.length > partial.length) {
|
||||
this._insertCompletion(input, cursor, partial, common);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_completeProperty(input, cursor, objExpr, partial) {
|
||||
try {
|
||||
// Resolve the object in global scope
|
||||
@@ -538,7 +508,7 @@ const REPL = {
|
||||
'Injected globals: ' + globals.join(', '),
|
||||
'',
|
||||
'Top-level await is supported:',
|
||||
' const r = await API._get("/api/v1/me")',
|
||||
' const r = await sw.api.get("/api/v1/profile")',
|
||||
'',
|
||||
'Shortcuts:',
|
||||
' $(sel) → document.querySelector(sel)',
|
||||
@@ -551,7 +521,7 @@ const REPL = {
|
||||
};
|
||||
|
||||
// Initialized from app.js startApp() after auth is confirmed,
|
||||
// ensuring API.user.role is available for admin gate check.
|
||||
// ensuring auth is available for admin gate check.
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('REPL', REPL);
|
||||
// ── Exports (v0.37.14: window.* replaces sb.ns) ──
|
||||
window.REPL = REPL;
|
||||
|
||||
133
src/js/sb.js
133
src/js/sb.js
@@ -1,133 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Action Registry (sb.js)
|
||||
// ==========================================
|
||||
// Central registry for all dispatchable actions. Loaded first
|
||||
// in base.html, before all other JS files.
|
||||
//
|
||||
// Files register their exports:
|
||||
// sb.ns('UI', UI); // namespace object
|
||||
// sb.register('deleteChat', fn); // standalone action
|
||||
//
|
||||
// Dispatch (from data-action attributes):
|
||||
// sb.resolve('deleteChat') → fn
|
||||
// sb.resolve('UI.copyMessage') → UI.copyMessage.bind(UI)
|
||||
//
|
||||
// Template bridge (Go onclick handlers):
|
||||
// sb.call('deleteChat', id) → fn(id)
|
||||
// sb.call('UI.toggleFolder', id) → UI.toggleFolder(id)
|
||||
//
|
||||
// Introspection:
|
||||
// sb.list() → { actions: [...], namespaces: [...] }
|
||||
//
|
||||
// Extension hook (future):
|
||||
// Extensions register actions into the same registry.
|
||||
// Platform and extension actions are dispatched identically.
|
||||
//
|
||||
// Exports: window.sb
|
||||
|
||||
const sb = {
|
||||
_actions: new Map(),
|
||||
_namespaces: new Map(),
|
||||
|
||||
/**
|
||||
* Register a standalone action function.
|
||||
* Also sets window[name] for backward compatibility with cross-file
|
||||
* direct calls. Template onclick handlers are fully migrated to sb.call()
|
||||
* (Phase 3b). This side-effect is removed in Phase 4 when files convert
|
||||
* to ES module imports.
|
||||
*/
|
||||
register(name, fn) {
|
||||
if (typeof fn !== 'function') {
|
||||
console.warn('[sb] register: not a function:', name);
|
||||
return;
|
||||
}
|
||||
this._actions.set(name, fn);
|
||||
window[name] = fn; // backward compat — remove when ES modules land
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a namespace object (API, UI, App, etc.).
|
||||
* Methods are accessible via dot notation: sb.resolve('UI.toast').
|
||||
* Also sets window[name] for backward compatibility with cross-file
|
||||
* direct calls (e.g. API.listProjects()). Removed in Phase 4.
|
||||
*/
|
||||
ns(name, obj) {
|
||||
if (obj == null || typeof obj !== 'object') {
|
||||
console.warn('[sb] ns: not an object:', name);
|
||||
return;
|
||||
}
|
||||
this._namespaces.set(name, obj);
|
||||
window[name] = obj; // backward compat
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve an action by name. Returns the function or null.
|
||||
* Supports flat names ('deleteChat') and dot notation ('UI.copyMessage').
|
||||
*/
|
||||
resolve(name) {
|
||||
// Flat action
|
||||
const flat = this._actions.get(name);
|
||||
if (flat) return flat;
|
||||
|
||||
// Dot notation: namespace.method
|
||||
const dot = name.indexOf('.');
|
||||
if (dot > 0) {
|
||||
const nsName = name.slice(0, dot);
|
||||
const method = name.slice(dot + 1);
|
||||
const obj = this._namespaces.get(nsName);
|
||||
if (obj && typeof obj[method] === 'function') {
|
||||
return obj[method].bind(obj);
|
||||
}
|
||||
// Try nested: UI._deleteRoutingPolicy → UI['_deleteRoutingPolicy']
|
||||
if (obj && typeof obj[method] !== 'undefined') {
|
||||
return typeof obj[method] === 'function' ? obj[method].bind(obj) : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check window (catches un-migrated globals)
|
||||
if (typeof window[name] === 'function') return window[name];
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Call an action by name. Template bridge.
|
||||
* Go templates use: onclick="sb.call('action', arg1, arg2)"
|
||||
*/
|
||||
call(name, ...args) {
|
||||
const fn = this.resolve(name);
|
||||
if (fn) return fn(...args);
|
||||
console.warn('[sb] Unknown action:', name);
|
||||
},
|
||||
|
||||
/**
|
||||
* Call with event passthrough (for context menu positioning).
|
||||
* Go templates use: onclick="sb.callEvent(event, 'showProjectMenu', id)"
|
||||
*/
|
||||
callEvent(event, name, ...args) {
|
||||
event?.stopPropagation?.();
|
||||
const fn = this.resolve(name);
|
||||
if (fn) return fn(event, ...args);
|
||||
console.warn('[sb] Unknown action:', name);
|
||||
},
|
||||
|
||||
/**
|
||||
* Introspection: list all registered actions and namespaces.
|
||||
*/
|
||||
list() {
|
||||
return {
|
||||
actions: [...this._actions.keys()].sort(),
|
||||
namespaces: [...this._namespaces.keys()].sort(),
|
||||
total: this._actions.size + this._namespaces.size,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an action is registered.
|
||||
*/
|
||||
has(name) {
|
||||
return this.resolve(name) !== null;
|
||||
},
|
||||
};
|
||||
|
||||
window.sb = sb;
|
||||
@@ -27,7 +27,7 @@ export function ChatHistory({ channelId, onSelect, onNew }) {
|
||||
if (!window.sw?.api?.channels?.list) return;
|
||||
window.sw.api.channels.list({ page: 1, per_page: 20, channel_type: 'direct' })
|
||||
.then(resp => {
|
||||
setChannels(resp?.data || resp || []);
|
||||
setChannels(resp || []);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import { ChatHistory } from './chat-history.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
import { MessageActions } from './message-actions.js';
|
||||
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
|
||||
import { EmojiPicker } from './emoji-picker.js';
|
||||
import { TypingIndicator } from './typing-indicator.js';
|
||||
|
||||
const { useRef, useEffect, useCallback } = window.hooks;
|
||||
const html = window.html;
|
||||
@@ -59,9 +59,11 @@ export function ChatPane(props) {
|
||||
initialChannelId,
|
||||
getContext,
|
||||
onChannelChange,
|
||||
externalModel: props.modelId,
|
||||
});
|
||||
|
||||
// Expose imperative handle via handleRef prop
|
||||
// Intentional: no deps — handle methods must reference latest chat closures
|
||||
useEffect(() => {
|
||||
if (handleRef) {
|
||||
handleRef.current = {
|
||||
@@ -73,6 +75,8 @@ export function ChatPane(props) {
|
||||
clearInput() { inputHandle.current?.clear(); },
|
||||
clear() { chat.newChat(); },
|
||||
stop() { chat.stop(); },
|
||||
setModel(id) { chat.setModel(id); },
|
||||
getModel() { return chat.model; },
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -103,18 +107,22 @@ export function ChatPane(props) {
|
||||
<${MessageList}
|
||||
messages=${chat.messages}
|
||||
streamContent=${chat.streamContent}
|
||||
streamThinking=${chat.streamThinking}
|
||||
streaming=${chat.streaming}
|
||||
hasMore=${chat.hasMore}
|
||||
loadingMore=${chat.loadingMore}
|
||||
onLoadMore=${chat.loadMore}
|
||||
onRegenerate=${chat.regenerate}
|
||||
onDelete=${chat.deleteMessage} />
|
||||
<${TypingIndicator} users=${chat.typingUsers} />
|
||||
<${MessageInput}
|
||||
handleRef=${inputHandle}
|
||||
onSend=${chat.sendMessage}
|
||||
onStop=${chat.stop}
|
||||
disabled=${chat.sending}
|
||||
streaming=${chat.streaming} />
|
||||
streaming=${chat.streaming}
|
||||
channelId=${chat.channelId}
|
||||
onTyping=${chat.emitTyping} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -129,5 +137,5 @@ export { ChatHistory } from './chat-history.js';
|
||||
export { renderMarkdown } from './markdown.js';
|
||||
export { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
export { MessageActions } from './message-actions.js';
|
||||
export { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
|
||||
export { EmojiPicker } from './emoji-picker.js';
|
||||
export { TypingIndicator } from './typing-indicator.js';
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MarkdownToolbar Component
|
||||
// ==========================================
|
||||
// Formatting toolbar for the message input.
|
||||
// Wraps selection with markdown syntax. Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MarkdownToolbar} textareaRef=${ref} onInput=${fn} />`
|
||||
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* Insert markdown wrapper around the textarea's selection.
|
||||
*
|
||||
* @param {HTMLTextAreaElement} ta
|
||||
* @param {string} before — prefix (e.g. '**')
|
||||
* @param {string} after — suffix (e.g. '**')
|
||||
* @param {string} placeholder — text if nothing selected
|
||||
* @param {Function} onInput — trigger resize after edit
|
||||
*/
|
||||
function _wrap(ta, before, after, placeholder, onInput) {
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = ta.value.slice(start, end) || placeholder;
|
||||
const replacement = before + selected + after;
|
||||
|
||||
// Use execCommand for undo support, fallback to direct edit
|
||||
const hasExecCommand = document.queryCommandSupported?.('insertText');
|
||||
if (hasExecCommand) {
|
||||
document.execCommand('insertText', false, replacement);
|
||||
} else {
|
||||
ta.value = ta.value.slice(0, start) + replacement + ta.value.slice(end);
|
||||
}
|
||||
|
||||
// Select the inserted text (without wrappers) for quick overtype
|
||||
const selStart = start + before.length;
|
||||
const selEnd = selStart + selected.length;
|
||||
ta.setSelectionRange(selStart, selEnd);
|
||||
if (onInput) onInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at cursor (no wrapping).
|
||||
*/
|
||||
function _insert(ta, text, onInput) {
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const start = ta.selectionStart;
|
||||
const hasExecCommand = document.queryCommandSupported?.('insertText');
|
||||
if (hasExecCommand) {
|
||||
document.execCommand('insertText', false, text);
|
||||
} else {
|
||||
ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd);
|
||||
}
|
||||
const pos = start + text.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
if (onInput) onInput();
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{ key: 'bold', label: 'B', title: 'Bold (Ctrl+B)', fn: (ta, cb) => _wrap(ta, '**', '**', 'bold text', cb) },
|
||||
{ key: 'italic', label: 'I', title: 'Italic (Ctrl+I)', fn: (ta, cb) => _wrap(ta, '_', '_', 'italic text', cb), style: 'font-style:italic' },
|
||||
{ key: 'code', label: '<>', title: 'Inline code', fn: (ta, cb) => _wrap(ta, '`', '`', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:11px' },
|
||||
{ key: 'codeblk', label: '```',title: 'Code block', fn: (ta, cb) => _wrap(ta, '```\n', '\n```', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:10px' },
|
||||
{ key: 'link', label: '🔗', title: 'Link', fn: (ta, cb) => _wrap(ta, '[', '](url)', 'link text', cb) },
|
||||
{ key: 'list', label: '•', title: 'Bullet list', fn: (ta, cb) => _insert(ta, '\n- ', cb) },
|
||||
{ key: 'olist', label: '1.', title: 'Numbered list', fn: (ta, cb) => _insert(ta, '\n1. ', cb) },
|
||||
{ key: 'heading', label: 'H', title: 'Heading', fn: (ta, cb) => _insert(ta, '\n## ', cb), style: 'font-weight:700' },
|
||||
{ key: 'quote', label: '>', title: 'Blockquote', fn: (ta, cb) => _insert(ta, '\n> ', cb) },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {{ textareaRef: { current: HTMLTextAreaElement }, onInput?: () => void }} props
|
||||
*/
|
||||
export function MarkdownToolbar({ textareaRef, onInput }) {
|
||||
return html`
|
||||
<div class="sw-md-toolbar" role="toolbar" aria-label="Formatting">
|
||||
${ACTIONS.map(a => html`
|
||||
<button key=${a.key}
|
||||
class="sw-md-toolbar__btn"
|
||||
title=${a.title}
|
||||
style=${a.style || ''}
|
||||
onClick=${() => a.fn(textareaRef.current, onInput)}
|
||||
tabindex="-1"
|
||||
type="button">
|
||||
${a.label}
|
||||
</button>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard shortcuts for formatting.
|
||||
* Call from textarea onKeyDown handler.
|
||||
*
|
||||
* @param {KeyboardEvent} e
|
||||
* @param {HTMLTextAreaElement} ta
|
||||
* @param {Function} onInput
|
||||
* @returns {boolean} true if handled
|
||||
*/
|
||||
export function handleFormatShortcut(e, ta, onInput) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return false;
|
||||
switch (e.key) {
|
||||
case 'b': e.preventDefault(); _wrap(ta, '**', '**', 'bold', onInput); return true;
|
||||
case 'i': e.preventDefault(); _wrap(ta, '_', '_', 'italic', onInput); return true;
|
||||
case 'e': e.preventDefault(); _wrap(ta, '`', '`', 'code', onInput); return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
// Wraps window.marked + window.DOMPurify with fallback.
|
||||
// Independently importable utility.
|
||||
//
|
||||
// Note: This module uses innerHTML via DOMPurify (sanitized) and imperative
|
||||
// DOM patterns intentionally for rendering performance. This is a documented
|
||||
// exception from the "no raw DOM" convention (Critical Review P3-4).
|
||||
//
|
||||
// v0.37.10: Task list rendering, code block language extraction.
|
||||
//
|
||||
// Usage:
|
||||
@@ -33,21 +37,21 @@ function _ensureConfigured() {
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
// Custom renderer for task list items
|
||||
const renderer = new marked.Renderer();
|
||||
const origListItem = renderer.listitem?.bind(renderer);
|
||||
renderer.listitem = function(text, task, checked) {
|
||||
if (task) {
|
||||
const checkbox = checked
|
||||
? '<input type="checkbox" checked disabled class="sw-task-checkbox" />'
|
||||
: '<input type="checkbox" disabled class="sw-task-checkbox" />';
|
||||
return '<li class="sw-task-item">' + checkbox + ' ' + text + '</li>\n';
|
||||
}
|
||||
if (origListItem) return origListItem(text, task, checked);
|
||||
return '<li>' + text + '</li>\n';
|
||||
};
|
||||
|
||||
marked.use({ renderer });
|
||||
// Custom renderer for task list items (marked v16 token-based API)
|
||||
marked.use({
|
||||
renderer: {
|
||||
listitem(token) {
|
||||
const body = this.parser.parse(token.tokens);
|
||||
if (token.task) {
|
||||
const checkbox = token.checked
|
||||
? '<input type="checkbox" checked disabled class="sw-task-checkbox" />'
|
||||
: '<input type="checkbox" disabled class="sw-task-checkbox" />';
|
||||
return '<li class="sw-task-item">' + checkbox + ' ' + body + '</li>\n';
|
||||
}
|
||||
return '<li>' + body + '</li>\n';
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +73,17 @@ export function renderMarkdown(text) {
|
||||
result = _escapeHtml(text);
|
||||
}
|
||||
|
||||
// Highlight @mentions as styled spans (runs after sanitization — safe)
|
||||
result = result.replace(/(^|[\s>])@([\w-]+)/g, '$1<span class="sw-mention">@$2</span>');
|
||||
|
||||
// Run pipe render filters (extensions can transform final HTML)
|
||||
if (window.sw?.pipe?._runRender) {
|
||||
const pipeCtx = window.sw.pipe._runRender({ html: result, raw: text });
|
||||
if (pipeCtx !== null && pipeCtx !== undefined) {
|
||||
result = pipeCtx.html;
|
||||
}
|
||||
}
|
||||
|
||||
_lastInput = text;
|
||||
_lastOutput = result;
|
||||
return result;
|
||||
|
||||
@@ -38,6 +38,7 @@ function _relTime(isoDate) {
|
||||
* @param {{
|
||||
* role: string,
|
||||
* content: string,
|
||||
* thinking?: string,
|
||||
* id?: string,
|
||||
* created_at?: string,
|
||||
* streaming?: boolean,
|
||||
@@ -45,8 +46,11 @@ function _relTime(isoDate) {
|
||||
* onDelete?: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageBubble({ role, content, id, created_at, streaming, onRegenerate, onDelete }) {
|
||||
const cls = 'sw-chat-msg sw-chat-msg--' + role
|
||||
export function MessageBubble({ role, content, thinking, id, created_at, streaming, onRegenerate, onDelete, isMine, senderName }) {
|
||||
// isMine: true = right-aligned (current user), false = left-aligned (other user/assistant)
|
||||
const alignment = role === 'assistant' ? 'assistant'
|
||||
: (isMine === false ? 'other' : 'user');
|
||||
const cls = 'sw-chat-msg sw-chat-msg--' + alignment
|
||||
+ (streaming ? ' sw-chat-msg--streaming' : '');
|
||||
|
||||
// Parse markdown and extract code blocks for enhanced rendering
|
||||
@@ -55,10 +59,26 @@ export function MessageBubble({ role, content, id, created_at, streaming, onRege
|
||||
return extractCodeBlocks(rendered);
|
||||
}, [content]);
|
||||
|
||||
const thinkingHtml = useMemo(() => {
|
||||
if (!thinking) return null;
|
||||
return renderMarkdown(thinking);
|
||||
}, [thinking]);
|
||||
|
||||
const timeStr = _relTime(created_at);
|
||||
|
||||
// While streaming, show thinking open if content hasn't started yet
|
||||
const thinkingOpen = streaming && thinking && !content;
|
||||
|
||||
return html`
|
||||
<div class=${cls}>
|
||||
${senderName && alignment === 'other' && html`
|
||||
<div class="sw-chat-msg__sender">${senderName}</div>`}
|
||||
${thinkingHtml && html`
|
||||
<details class="sw-chat-msg__thinking" open=${thinkingOpen || undefined}>
|
||||
<summary class="sw-chat-msg__thinking-toggle">Thinking\u2026</summary>
|
||||
<div class="sw-chat-msg__thinking-content"
|
||||
dangerouslySetInnerHTML=${{ __html: thinkingHtml }} />
|
||||
</details>`}
|
||||
<div class="sw-chat-msg__content">
|
||||
${segments.map((seg, i) =>
|
||||
seg.type === 'code'
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageInput Component
|
||||
// ==========================================
|
||||
// Auto-resize textarea with Enter-to-send, markdown toolbar,
|
||||
// CM6-powered chat input with markdown highlighting,
|
||||
// emoji picker, and stop button.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added MarkdownToolbar, EmojiPicker, stop overlay, aria.
|
||||
// v0.37.14: Replaced textarea + toolbar with CM.chatInput().
|
||||
//
|
||||
// Usage:
|
||||
// const inputHandle = useRef(null);
|
||||
// html`<${MessageInput} onSend=${fn} onStop=${fn} disabled=${false}
|
||||
// streaming=${false} handleRef=${inputHandle} />`
|
||||
|
||||
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
|
||||
import { EmojiPicker } from './emoji-picker.js';
|
||||
|
||||
const { useRef, useState, useCallback, useEffect } = window.hooks;
|
||||
@@ -40,6 +40,11 @@ const EMOJI_SVG = html`
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"/>
|
||||
</svg>`;
|
||||
|
||||
// Convert display name to @mention handle (mirrors Go HandleFromName)
|
||||
function _toHandle(name) {
|
||||
return (name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* onSend: (text: string) => void,
|
||||
@@ -47,12 +52,136 @@ const EMOJI_SVG = html`
|
||||
* disabled?: boolean,
|
||||
* streaming?: boolean,
|
||||
* handleRef?: { current: any },
|
||||
* channelId?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageInput({ onSend, onStop, disabled, streaming, handleRef }) {
|
||||
const taRef = useRef(null);
|
||||
export function MessageInput({ onSend, onStop, disabled, streaming, handleRef, channelId, onTyping }) {
|
||||
const containerRef = useRef(null);
|
||||
const editorRef = useRef(null);
|
||||
const taRef = useRef(null); // fallback textarea
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const onSendRef = useRef(onSend);
|
||||
onSendRef.current = onSend;
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
const participantsCache = useRef({ channelId: null, list: null });
|
||||
const onTypingRef = useRef(onTyping);
|
||||
onTypingRef.current = onTyping;
|
||||
const typingTimerRef = useRef(null);
|
||||
const isTypingRef = useRef(false);
|
||||
|
||||
// ── CM6 editor lifecycle ────────────────────
|
||||
useEffect(() => {
|
||||
if (!window.CM?.chatInput || !containerRef.current) return;
|
||||
|
||||
// Build @mention completer bound to current channelId
|
||||
const mentionCompleter = async (query) => {
|
||||
if (!channelId || !window.sw?.api?.channels?.participants) return [];
|
||||
|
||||
// Fetch and cache participants per channel
|
||||
if (participantsCache.current.channelId !== channelId) {
|
||||
try {
|
||||
const resp = await window.sw.api.channels.participants(channelId);
|
||||
participantsCache.current = {
|
||||
channelId,
|
||||
list: (resp || []).map(p => ({
|
||||
label: p.display_name || p.participant_id,
|
||||
handle: _toHandle(p.display_name || p.participant_id),
|
||||
type: p.participant_type || 'user',
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[mention] Failed to fetch participants:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const results = participantsCache.current.list.filter(p =>
|
||||
p.handle.startsWith(q) || p.label.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
// Add @all option
|
||||
if (!q || 'all'.startsWith(q)) {
|
||||
results.unshift({ label: 'All users', handle: 'all', type: 'all' });
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// Build extensions array with @mention support
|
||||
const extraExtensions = window.CM.mentionExtension
|
||||
? window.CM.mentionExtension({ completer: mentionCompleter })
|
||||
: [];
|
||||
|
||||
const editor = window.CM.chatInput(containerRef.current, {
|
||||
placeholder: 'Type a message\u2026',
|
||||
maxHeight: 160,
|
||||
extensions: extraExtensions,
|
||||
onSubmit: (text) => {
|
||||
const trimmed = text?.trim();
|
||||
if (trimmed && !disabledRef.current) {
|
||||
onSendRef.current(trimmed);
|
||||
editor.setValue('');
|
||||
// Stop typing on send
|
||||
if (isTypingRef.current) {
|
||||
isTypingRef.current = false;
|
||||
onTypingRef.current?.(false);
|
||||
}
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
}
|
||||
},
|
||||
onChange: () => {
|
||||
// Emit typing start (debounced: stop after 3s of inactivity)
|
||||
if (!isTypingRef.current) {
|
||||
isTypingRef.current = true;
|
||||
onTypingRef.current?.(true);
|
||||
}
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = setTimeout(() => {
|
||||
isTypingRef.current = false;
|
||||
onTypingRef.current?.(false);
|
||||
}, 3000);
|
||||
},
|
||||
});
|
||||
editorRef.current = editor;
|
||||
|
||||
return () => {
|
||||
editor.destroy();
|
||||
editorRef.current = null;
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
if (isTypingRef.current) {
|
||||
isTypingRef.current = false;
|
||||
onTypingRef.current?.(false);
|
||||
}
|
||||
};
|
||||
}, [channelId]);
|
||||
|
||||
// ── Imperative handle ───────────────────────
|
||||
useEffect(() => {
|
||||
if (!handleRef) return;
|
||||
handleRef.current = {
|
||||
getValue() {
|
||||
if (editorRef.current) return editorRef.current.getValue();
|
||||
return taRef.current?.value || '';
|
||||
},
|
||||
setValue(val) {
|
||||
if (editorRef.current) { editorRef.current.setValue(val || ''); return; }
|
||||
if (taRef.current) { taRef.current.value = val || ''; _autoResize(); }
|
||||
},
|
||||
focus() {
|
||||
if (editorRef.current) { editorRef.current.focus(); return; }
|
||||
taRef.current?.focus();
|
||||
},
|
||||
clear() {
|
||||
if (editorRef.current) { editorRef.current.setValue(''); return; }
|
||||
if (taRef.current) { taRef.current.value = ''; _autoResize(); }
|
||||
},
|
||||
};
|
||||
return () => { handleRef.current = null; };
|
||||
}, [handleRef]);
|
||||
|
||||
// ── Fallback textarea helpers ───────────────
|
||||
function _autoResize() {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
@@ -60,26 +189,9 @@ export function MessageInput({ onSend, onStop, disabled, streaming, handleRef })
|
||||
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
||||
}
|
||||
|
||||
// Expose imperative handle via handleRef prop
|
||||
useEffect(() => {
|
||||
if (handleRef) {
|
||||
handleRef.current = {
|
||||
getValue() { return taRef.current?.value || ''; },
|
||||
setValue(val) { if (taRef.current) { taRef.current.value = val; _autoResize(); } },
|
||||
focus() { taRef.current?.focus(); },
|
||||
clear() { if (taRef.current) { taRef.current.value = ''; _autoResize(); } },
|
||||
getTextarea() { return taRef.current; },
|
||||
};
|
||||
}
|
||||
return () => { if (handleRef) handleRef.current = null; };
|
||||
}, [handleRef]);
|
||||
|
||||
const _onInput = useCallback(() => _autoResize(), []);
|
||||
|
||||
const _onKeyDown = useCallback((e) => {
|
||||
// Formatting shortcuts (Ctrl+B, Ctrl+I, Ctrl+E)
|
||||
if (handleFormatShortcut(e, taRef.current, _autoResize)) return;
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const text = taRef.current?.value?.trim();
|
||||
@@ -91,14 +203,26 @@ export function MessageInput({ onSend, onStop, disabled, streaming, handleRef })
|
||||
}, [onSend, disabled]);
|
||||
|
||||
const _onClickSend = useCallback(() => {
|
||||
const text = taRef.current?.value?.trim();
|
||||
const text = editorRef.current
|
||||
? editorRef.current.getValue()?.trim()
|
||||
: taRef.current?.value?.trim();
|
||||
if (text && !disabled) {
|
||||
onSend(text);
|
||||
if (taRef.current) { taRef.current.value = ''; _autoResize(); }
|
||||
if (editorRef.current) editorRef.current.setValue('');
|
||||
else if (taRef.current) { taRef.current.value = ''; _autoResize(); }
|
||||
}
|
||||
}, [onSend, disabled]);
|
||||
|
||||
// ── Emoji ───────────────────────────────────
|
||||
const _insertEmoji = useCallback((emoji) => {
|
||||
if (editorRef.current) {
|
||||
const view = editorRef.current.getView();
|
||||
const pos = view.state.selection.main.head;
|
||||
view.dispatch({ changes: { from: pos, insert: emoji } });
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
// Fallback: textarea
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
@@ -117,20 +241,24 @@ export function MessageInput({ onSend, onStop, disabled, streaming, handleRef })
|
||||
const _toggleEmoji = useCallback(() => setEmojiOpen(v => !v), []);
|
||||
const _closeEmoji = useCallback(() => setEmojiOpen(false), []);
|
||||
|
||||
// ── Render ──────────────────────────────────
|
||||
const useCM = !!window.CM?.chatInput;
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-pane__input-bar">
|
||||
<${MarkdownToolbar} textareaRef=${taRef} onInput=${_autoResize} />
|
||||
<div class="sw-msg-input__wrap">
|
||||
<textarea
|
||||
ref=${taRef}
|
||||
class="sw-msg-input__textarea"
|
||||
placeholder="Type a message\u2026"
|
||||
rows="1"
|
||||
disabled=${disabled}
|
||||
onInput=${_onInput}
|
||||
onKeyDown=${_onKeyDown}
|
||||
aria-label="Message input"
|
||||
/>
|
||||
${useCM
|
||||
? html`<div ref=${containerRef} class="sw-msg-input__cm-container" />`
|
||||
: html`<textarea
|
||||
ref=${taRef}
|
||||
class="sw-msg-input__textarea"
|
||||
placeholder="Type a message\u2026"
|
||||
rows="1"
|
||||
disabled=${disabled}
|
||||
onInput=${_onInput}
|
||||
onKeyDown=${_onKeyDown}
|
||||
aria-label="Message input"
|
||||
/>`}
|
||||
<div class="sw-msg-input__buttons">
|
||||
<div class="sw-msg-input__emoji-wrap">
|
||||
<button
|
||||
|
||||
@@ -23,6 +23,7 @@ const SCROLL_THRESHOLD = 60;
|
||||
* @param {{
|
||||
* messages: Array<{role: string, content: string, id?: string, created_at?: string}>,
|
||||
* streamContent?: string,
|
||||
* streamThinking?: string,
|
||||
* streaming?: boolean,
|
||||
* hasMore?: boolean,
|
||||
* loadingMore?: boolean,
|
||||
@@ -33,7 +34,7 @@ const SCROLL_THRESHOLD = 60;
|
||||
* }} props
|
||||
*/
|
||||
export function MessageList({
|
||||
messages, streamContent, streaming, hasMore, loadingMore,
|
||||
messages, streamContent, streamThinking, streaming, hasMore, loadingMore,
|
||||
onLoadMore, onRegenerate, onDelete, className,
|
||||
}) {
|
||||
const scrollRef = useRef(null);
|
||||
@@ -68,6 +69,7 @@ export function MessageList({
|
||||
}, [messages.length]);
|
||||
|
||||
const hasContent = messages.length > 0 || streaming;
|
||||
const myUserId = window.sw?.auth?.user?.id;
|
||||
|
||||
return html`
|
||||
<div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')}
|
||||
@@ -90,16 +92,25 @@ export function MessageList({
|
||||
<p class="sw-chat-welcome__title">Start a conversation</p>
|
||||
<p class="sw-chat-welcome__hint">Type a message below to get started.</p>
|
||||
</div>`}
|
||||
${messages.map((m, i) => html`
|
||||
${messages.map((m, i) => {
|
||||
// Determine if this message was sent by the current user
|
||||
const senderId = m.user_id || m.participant_id;
|
||||
const isMine = m.role === 'user' && (!senderId || senderId === myUserId);
|
||||
return html`
|
||||
<${MessageBubble}
|
||||
key=${m.id || i}
|
||||
role=${m.role}
|
||||
content=${m.content}
|
||||
thinking=${m.thinking}
|
||||
id=${m.id}
|
||||
created_at=${m.created_at}
|
||||
isMine=${isMine}
|
||||
senderName=${m.role === 'user' && !isMine ? (m.display_name || m.sender_name || m.username || '') : ''}
|
||||
onRegenerate=${m.role === 'assistant' && m.id && onRegenerate ? () => onRegenerate(m.id) : undefined}
|
||||
onDelete=${m.id && onDelete ? () => onDelete(m.id) : undefined} />`)}
|
||||
onDelete=${m.id && onDelete ? () => onDelete(m.id) : undefined} />`;
|
||||
})}
|
||||
${streaming && html`
|
||||
<${MessageBubble} role="assistant" content=${streamContent} streaming=${true} />`}
|
||||
<${MessageBubble} role="assistant" content=${streamContent}
|
||||
thinking=${streamThinking} streaming=${true} />`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -21,14 +21,18 @@ export function ModelSelector({ value, onChange }) {
|
||||
let cancelled = false;
|
||||
window.sw.api.models.enabled().then(resp => {
|
||||
if (cancelled) return;
|
||||
const list = resp?.data || resp || [];
|
||||
setModels(list.filter(m => !m.hidden));
|
||||
const list = (resp.data || []).filter(m => !m.hidden);
|
||||
setModels(list);
|
||||
// Auto-select first model if no value set
|
||||
if (!value && list.length && onChange) {
|
||||
const first = list[0];
|
||||
const id = first.isPersona ? (first.personaId || first.id) : first.id;
|
||||
onChange(id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (!models.length) return null;
|
||||
|
||||
function _onChange(e) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
@@ -36,8 +40,11 @@ export function ModelSelector({ value, onChange }) {
|
||||
return html`
|
||||
<select class="sw-model-selector__select"
|
||||
title="Select model"
|
||||
value=${value}
|
||||
onChange=${_onChange}>
|
||||
value=${value || ''}
|
||||
onChange=${_onChange}
|
||||
disabled=${!models.length}>
|
||||
${!models.length && html`
|
||||
<option value="" disabled selected>No models</option>`}
|
||||
${models.map(m => {
|
||||
const id = m.isPersona ? (m.personaId || m.id) : m.id;
|
||||
const label = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id);
|
||||
|
||||
38
src/js/sw/components/chat-pane/typing-indicator.js
Normal file
38
src/js/sw/components/chat-pane/typing-indicator.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — TypingIndicator Component
|
||||
// ==========================================
|
||||
// Shows "X is typing..." below the message list.
|
||||
// Handles 1, 2, or 3+ typers.
|
||||
//
|
||||
// v0.37.14: Initial implementation.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${TypingIndicator} users=${[{id, name}]} />`
|
||||
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* @param {{ users: Array<{id: string, name: string}> }} props
|
||||
*/
|
||||
export function TypingIndicator({ users }) {
|
||||
if (!users || users.length === 0) return null;
|
||||
|
||||
let text;
|
||||
if (users.length === 1) {
|
||||
text = users[0].name + ' is typing';
|
||||
} else if (users.length === 2) {
|
||||
text = users[0].name + ' and ' + users[1].name + ' are typing';
|
||||
} else {
|
||||
text = users[0].name + ' and ' + (users.length - 1) + ' others are typing';
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sw-typing-indicator" aria-live="polite">
|
||||
<span class="sw-typing-indicator__dots">
|
||||
<span class="sw-typing-indicator__dot" />
|
||||
<span class="sw-typing-indicator__dot" />
|
||||
<span class="sw-typing-indicator__dot" />
|
||||
</span>
|
||||
<span class="sw-typing-indicator__text">${text}</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -12,15 +12,35 @@
|
||||
|
||||
import { useStream } from './use-stream.js';
|
||||
|
||||
const { useState, useRef, useCallback } = window.hooks;
|
||||
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/** Extract <think>…</think> blocks from raw content.
|
||||
* Returns { content, thinking } with tags stripped. */
|
||||
function extractThinking(raw) {
|
||||
if (!raw || !raw.includes('<think>')) return { content: raw, thinking: '' };
|
||||
let thinking = '';
|
||||
const content = raw.replace(/<think>([\s\S]*?)<\/think>/g, (_, t) => {
|
||||
thinking += (thinking ? '\n' : '') + t.trim();
|
||||
return '';
|
||||
});
|
||||
return { content: content.trim(), thinking };
|
||||
}
|
||||
|
||||
/** Apply extractThinking to an array of message objects. */
|
||||
function hydrateThinking(msgs) {
|
||||
return msgs.map(m => {
|
||||
const { content, thinking } = extractThinking(m.content);
|
||||
return thinking ? { ...m, content, thinking } : m;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts
|
||||
*/
|
||||
export function useChat(opts = {}) {
|
||||
const { initialChannelId, getContext, onChannelChange } = opts;
|
||||
const { initialChannelId, getContext, onChannelChange, externalModel } = opts;
|
||||
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [channelId, setChannelId] = useState(initialChannelId || null);
|
||||
@@ -32,15 +52,33 @@ export function useChat(opts = {}) {
|
||||
|
||||
const channelRef = useRef(channelId);
|
||||
const pageRef = useRef(1);
|
||||
const { streamContent, streaming, startStream, stopStream } = useStream();
|
||||
const { streamContent, streamThinking, streaming, startStream, stopStream } = useStream();
|
||||
|
||||
// Keep ref in sync
|
||||
channelRef.current = channelId;
|
||||
|
||||
// ── Init model from session ─────────────
|
||||
// Use external model (from parent surface) when provided
|
||||
const effectiveModel = externalModel || model;
|
||||
const modelRef = useRef(effectiveModel);
|
||||
modelRef.current = effectiveModel;
|
||||
|
||||
// ── Init model from session or first enabled ─────────────
|
||||
if (!model && window.sw?.auth?.session?.settings?.model) {
|
||||
setTimeout(() => setModel(window.sw.auth.session.settings.model), 0);
|
||||
}
|
||||
// Auto-load first enabled model if none set
|
||||
const modelInitRef = useRef(false);
|
||||
if (!effectiveModel && !modelInitRef.current && window.sw?.api?.models?.enabled) {
|
||||
modelInitRef.current = true;
|
||||
window.sw.api.models.enabled().then(resp => {
|
||||
const list = resp.data || [];
|
||||
const visible = list.filter(m => !m.hidden);
|
||||
if (visible.length && !model && !externalModel) {
|
||||
const first = visible[0];
|
||||
setModel(first.isPersona ? (first.personaId || first.id) : first.id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Send Message ────────────────────────
|
||||
const sendMessage = useCallback(async (text) => {
|
||||
@@ -48,13 +86,27 @@ export function useChat(opts = {}) {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
// Resolve model: use current ref value, or fetch first enabled model on the fly
|
||||
let resolvedModel = modelRef.current;
|
||||
if (!resolvedModel && window.sw?.api?.models?.enabled) {
|
||||
try {
|
||||
const resp = await window.sw.api.models.enabled();
|
||||
const list = (resp.data || []).filter(m => !m.hidden);
|
||||
if (list.length) {
|
||||
const first = list[0];
|
||||
resolvedModel = first.isPersona ? (first.personaId || first.id) : first.id;
|
||||
setModel(resolvedModel);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
let cid = channelRef.current;
|
||||
|
||||
// Create channel if needed
|
||||
if (!cid) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : '');
|
||||
const resp = await window.sw.api.channels.create({ title, model_id: model, channel_type: 'direct' });
|
||||
const resp = await window.sw.api.channels.create({ title, model_id: resolvedModel, channel_type: 'direct' });
|
||||
cid = resp.id;
|
||||
channelRef.current = cid;
|
||||
setChannelId(cid);
|
||||
@@ -67,7 +119,8 @@ export function useChat(opts = {}) {
|
||||
}
|
||||
|
||||
// Append user message optimistically
|
||||
const userMsg = { role: 'user', content: text };
|
||||
const myId = window.sw?.auth?.user?.id;
|
||||
const userMsg = { id: 'tmp-' + Date.now(), role: 'user', content: text, user_id: myId };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
|
||||
try {
|
||||
@@ -80,9 +133,22 @@ export function useChat(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const data = { channel_id: cid, content, model };
|
||||
// Run pipe pre-send filters (extensions can transform or block)
|
||||
if (window.sw?.pipe?._runPre) {
|
||||
const pipeCtx = window.sw.pipe._runPre({ content, channel: { id: cid }, model: resolvedModel });
|
||||
if (pipeCtx === null) { setSending(false); return; }
|
||||
content = pipeCtx.content;
|
||||
}
|
||||
|
||||
const data = { channel_id: cid, content, model: resolvedModel };
|
||||
const resp = await window.sw.api.channels.complete(data);
|
||||
await startStream(resp);
|
||||
// ai_mode off/mention_only returns JSON, not SSE — skip streaming
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
// Message persisted server-side, no AI response expected
|
||||
} else {
|
||||
await startStream(resp);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setError('Error: ' + e.message);
|
||||
@@ -90,13 +156,15 @@ export function useChat(opts = {}) {
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
}, [sending, model, getContext, onChannelChange, startStream]);
|
||||
}, [sending, effectiveModel, getContext, onChannelChange, startStream]);
|
||||
|
||||
// Finalize: when streaming ends, capture the final content into messages
|
||||
const prevStreamingRef = useRef(false);
|
||||
if (prevStreamingRef.current && !streaming && streamContent) {
|
||||
prevStreamingRef.current = false;
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: streamContent }]);
|
||||
const msg = { role: 'assistant', content: streamContent };
|
||||
if (streamThinking) msg.thinking = streamThinking;
|
||||
setMessages(prev => [...prev, msg]);
|
||||
}
|
||||
prevStreamingRef.current = streaming;
|
||||
|
||||
@@ -109,7 +177,7 @@ export function useChat(opts = {}) {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.regenerate(cid, msgId, { model });
|
||||
const resp = await window.sw.api.channels.regenerate(cid, msgId, { model: modelRef.current });
|
||||
// Remove the old assistant message being regenerated
|
||||
setMessages(prev => {
|
||||
const idx = prev.findIndex(m => m.id === msgId);
|
||||
@@ -123,7 +191,7 @@ export function useChat(opts = {}) {
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
}, [sending, model, startStream]);
|
||||
}, [sending, effectiveModel, startStream]);
|
||||
|
||||
// ── Delete Message ──────────────────────
|
||||
const deleteMessage = useCallback(async (msgId) => {
|
||||
@@ -152,12 +220,24 @@ export function useChat(opts = {}) {
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: PAGE_SIZE });
|
||||
const data = resp?.data || resp || [];
|
||||
const data = hydrateThinking(resp.data || resp || []);
|
||||
setMessages(data);
|
||||
setHasMore(data.length >= PAGE_SIZE);
|
||||
} catch (e) {
|
||||
// Channel deleted/not found — clear selection
|
||||
if ((e.status === 404 || e.message?.includes('404')) && onChannelChange) {
|
||||
channelRef.current = null;
|
||||
setChannelId(null);
|
||||
onChannelChange(null);
|
||||
return;
|
||||
}
|
||||
setError('Failed to load messages: ' + e.message);
|
||||
}
|
||||
|
||||
// Mark channel as read
|
||||
if (window.sw?.api?.channels?.markRead) {
|
||||
window.sw.api.channels.markRead(id).catch(() => {});
|
||||
}
|
||||
}, [onChannelChange]);
|
||||
|
||||
// ── Load More (pagination) ──────────────
|
||||
@@ -170,7 +250,7 @@ export function useChat(opts = {}) {
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.messages(cid, { page: nextPage, per_page: PAGE_SIZE });
|
||||
const data = resp?.data || resp || [];
|
||||
const data = hydrateThinking(resp.data || resp || []);
|
||||
if (data.length > 0) {
|
||||
pageRef.current = nextPage;
|
||||
// Prepend older messages
|
||||
@@ -207,6 +287,116 @@ export function useChat(opts = {}) {
|
||||
// ── Clear Error ─────────────────────────
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
// ── Typing indicators ───────────────────
|
||||
const [typingUsers, setTypingUsers] = useState([]); // [{ id, name }]
|
||||
const typingTimers = useRef({}); // id → timeout
|
||||
|
||||
// ── WebSocket: listen for messages from other users ──
|
||||
useEffect(() => {
|
||||
if (!window.sw?.on) return;
|
||||
const handler = (evt) => {
|
||||
try {
|
||||
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
|
||||
// Only append if it's for the active channel
|
||||
if (data.channel_id !== channelRef.current) return;
|
||||
// Skip messages from self (already added optimistically)
|
||||
const myId = window.sw?.auth?.user?.id;
|
||||
if (data.user_id && data.user_id === myId) return;
|
||||
const { content: cleanContent, thinking } = extractThinking(data.content);
|
||||
const msg = {
|
||||
id: data.id,
|
||||
role: data.role || 'user',
|
||||
content: cleanContent,
|
||||
user_id: data.user_id,
|
||||
participant_id: data.participant_id || data.user_id,
|
||||
participant_type: data.participant_type || 'user',
|
||||
display_name: data.display_name,
|
||||
created_at: data.created_at || new Date().toISOString(),
|
||||
};
|
||||
if (thinking) msg.thinking = thinking;
|
||||
setMessages(prev => prev.some(m => m.id && m.id === msg.id) ? prev : [...prev, msg]);
|
||||
// Clear typing for this user/persona when their message arrives
|
||||
const pid = data.participant_id || data.user_id;
|
||||
if (pid) _clearTyping(pid);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// Typing start handler (persona chaining + user typing)
|
||||
const typingStartHandler = (evt) => {
|
||||
try {
|
||||
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
|
||||
if (data.channel_id !== channelRef.current) return;
|
||||
const pid = data.participant_id || data.user_id;
|
||||
if (!pid) return;
|
||||
const myId = window.sw?.auth?.user?.id;
|
||||
if (pid === myId) return;
|
||||
const name = data.display_name || pid;
|
||||
setTypingUsers(prev => {
|
||||
if (prev.some(t => t.id === pid)) return prev;
|
||||
return [...prev, { id: pid, name }];
|
||||
});
|
||||
// Auto-clear after 10s (safety net)
|
||||
if (typingTimers.current[pid]) clearTimeout(typingTimers.current[pid]);
|
||||
typingTimers.current[pid] = setTimeout(() => _clearTyping(pid), 10000);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// Typing stop handler
|
||||
const typingStopHandler = (evt) => {
|
||||
try {
|
||||
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
|
||||
const pid = data.participant_id || data.user_id;
|
||||
if (pid) _clearTyping(pid);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// message.deleted handler (other clients' deletes)
|
||||
const deleteHandler = (evt) => {
|
||||
try {
|
||||
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
|
||||
if (data.channel_id !== channelRef.current) return;
|
||||
setMessages(prev => prev.filter(m => m.id !== data.id));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
window.sw.on('message.created', handler);
|
||||
window.sw.on('message.deleted', deleteHandler);
|
||||
window.sw.on('typing.start', typingStartHandler);
|
||||
window.sw.on('typing.stop', typingStopHandler);
|
||||
window.sw.on('chat.typing.start', typingStartHandler);
|
||||
window.sw.on('chat.typing.stop', typingStopHandler);
|
||||
return () => {
|
||||
window.sw.off?.('message.created', handler);
|
||||
window.sw.off?.('message.deleted', deleteHandler);
|
||||
window.sw.off?.('typing.start', typingStartHandler);
|
||||
window.sw.off?.('typing.stop', typingStopHandler);
|
||||
window.sw.off?.('chat.typing.start', typingStartHandler);
|
||||
window.sw.off?.('chat.typing.stop', typingStopHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function _clearTyping(pid) {
|
||||
if (typingTimers.current[pid]) {
|
||||
clearTimeout(typingTimers.current[pid]);
|
||||
delete typingTimers.current[pid];
|
||||
}
|
||||
setTypingUsers(prev => prev.filter(t => t.id !== pid));
|
||||
}
|
||||
|
||||
// Emit typing event (called from MessageInput via prop)
|
||||
const emitTyping = useCallback((isTyping) => {
|
||||
const cid = channelRef.current;
|
||||
if (!cid || !window.sw?.emit) return;
|
||||
const myId = window.sw?.auth?.user?.id;
|
||||
const myName = window.sw?.auth?.user?.display_name || window.sw?.auth?.user?.username || '';
|
||||
window.sw.emit(isTyping ? 'chat.typing.start' : 'chat.typing.stop', {
|
||||
channel_id: cid,
|
||||
participant_id: myId,
|
||||
participant_type: 'user',
|
||||
display_name: myName,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
channelId,
|
||||
@@ -221,6 +411,7 @@ export function useChat(opts = {}) {
|
||||
clearError,
|
||||
// Streaming state (passthrough from useStream)
|
||||
streamContent,
|
||||
streamThinking,
|
||||
streaming,
|
||||
// v0.37.10: new methods
|
||||
regenerate,
|
||||
@@ -228,5 +419,8 @@ export function useChat(opts = {}) {
|
||||
loadMore,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
// v0.37.14: typing indicators
|
||||
typingUsers,
|
||||
emitTyping,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,13 +18,16 @@ const { useState, useRef, useCallback } = window.hooks;
|
||||
*/
|
||||
export function useStream() {
|
||||
const [streamContent, setStreamContent] = useState('');
|
||||
const [streamThinking, setStreamThinking] = useState('');
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const contentRef = useRef('');
|
||||
const thinkingRef = useRef('');
|
||||
const abortRef = useRef(null);
|
||||
const rafRef = useRef(null);
|
||||
|
||||
const _flush = useCallback(() => {
|
||||
setStreamContent(contentRef.current);
|
||||
setStreamThinking(thinkingRef.current);
|
||||
rafRef.current = null;
|
||||
}, []);
|
||||
|
||||
@@ -44,13 +47,16 @@ export function useStream() {
|
||||
rafRef.current = null;
|
||||
}
|
||||
setStreamContent(contentRef.current);
|
||||
setStreamThinking(thinkingRef.current);
|
||||
setStreaming(false);
|
||||
}, []);
|
||||
|
||||
const startStream = useCallback(async (response) => {
|
||||
// Reset
|
||||
contentRef.current = '';
|
||||
thinkingRef.current = '';
|
||||
setStreamContent('');
|
||||
setStreamThinking('');
|
||||
setStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -75,9 +81,20 @@ export function useStream() {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
try {
|
||||
const delta = JSON.parse(data).choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
contentRef.current += delta;
|
||||
const parsed = JSON.parse(data).choices?.[0]?.delta;
|
||||
if (parsed?.content) {
|
||||
let chunk = parsed.content;
|
||||
// Run pipe stream filters (extensions can transform or suppress chunks)
|
||||
if (window.sw?.pipe?._runStream) {
|
||||
const pipeCtx = window.sw.pipe._runStream({ content: chunk });
|
||||
if (pipeCtx === null) continue;
|
||||
chunk = pipeCtx.content;
|
||||
}
|
||||
contentRef.current += chunk;
|
||||
_scheduleFlush();
|
||||
}
|
||||
if (parsed?.reasoning_content) {
|
||||
thinkingRef.current += parsed.reasoning_content;
|
||||
_scheduleFlush();
|
||||
}
|
||||
} catch (_) { /* skip malformed JSON */ }
|
||||
@@ -95,9 +112,10 @@ export function useStream() {
|
||||
rafRef.current = null;
|
||||
}
|
||||
setStreamContent(contentRef.current);
|
||||
setStreamThinking(thinkingRef.current);
|
||||
setStreaming(false);
|
||||
abortRef.current = null;
|
||||
}, [_scheduleFlush]);
|
||||
|
||||
return { streamContent, streaming, startStream, stopStream };
|
||||
return { streamContent, streamThinking, streaming, startStream, stopStream };
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function resolveTransclusions(container, onLinkClick) {
|
||||
let note = cache[title.toLowerCase()];
|
||||
if (!note) {
|
||||
const resp = await api.searchTitles(title, 1);
|
||||
const match = (resp.data || resp || []).find(
|
||||
const match = (resp || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (!match) {
|
||||
|
||||
@@ -69,7 +69,7 @@ export function NoteEditor(props) {
|
||||
if (!query || query.length < 1) return [];
|
||||
try {
|
||||
const resp = await window.sw.api.notes.searchTitles(query, 8);
|
||||
return (resp.data || resp || []).map(n => ({ label: n.title, id: n.id }));
|
||||
return (resp || []).map(n => ({ label: n.title, id: n.id }));
|
||||
} catch { return []; }
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ export function NoteReader(props) {
|
||||
if (!api) return;
|
||||
// Get all note titles (limited)
|
||||
const resp = await api.list({ limit: 200, offset: 0 });
|
||||
const allNotes = resp.data || resp || [];
|
||||
const allNotes = resp.data || [];
|
||||
const content = note.content.toLowerCase();
|
||||
|
||||
// Find titles mentioned in content but not wikilinked
|
||||
|
||||
@@ -66,7 +66,7 @@ export function QuickSwitcher(props) {
|
||||
searchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await window.sw.api.notes.searchTitles(query, 10);
|
||||
setResults(resp.data || resp || []);
|
||||
setResults(resp || []);
|
||||
setActiveIndex(0);
|
||||
} catch { setResults([]); }
|
||||
}, 150);
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SaveToNoteDialog(props) {
|
||||
searchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await window.sw.api.notes.searchTitles(searchQuery, 8);
|
||||
setSearchResults(resp.data || resp || []);
|
||||
setSearchResults(resp || []);
|
||||
} catch { setSearchResults([]); }
|
||||
}, 250);
|
||||
}, [searchQuery, mode]);
|
||||
|
||||
@@ -104,15 +104,15 @@ export function useNotes(opts = {}) {
|
||||
let result, isSearch = false;
|
||||
if (searchQuery && searchQuery.length >= 2) {
|
||||
isSearch = true;
|
||||
const data = await api().search(searchQuery);
|
||||
result = data.data || data || [];
|
||||
const resp = await api().search(searchQuery);
|
||||
result = resp.data || [];
|
||||
setHasMore(false);
|
||||
} else {
|
||||
const page = append ? pageRef.current : 1;
|
||||
if (!append) pageRef.current = 1;
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
const data = await api().list({ limit: PAGE_SIZE, offset, folder, tag: tagFilter, sort });
|
||||
result = data.data || data || [];
|
||||
const resp = await api().list({ limit: PAGE_SIZE, offset, folder, tag: tagFilter, sort });
|
||||
result = resp.data || [];
|
||||
setHasMore(result.length >= PAGE_SIZE);
|
||||
}
|
||||
if (append) {
|
||||
@@ -224,7 +224,7 @@ export function useNotes(opts = {}) {
|
||||
// Load backlinks
|
||||
try {
|
||||
const bl = await api().backlinks(id);
|
||||
setBacklinks(bl.data || bl || []);
|
||||
setBacklinks(bl || []);
|
||||
} catch { setBacklinks([]); }
|
||||
} catch (e) {
|
||||
toast('Failed to load note: ' + e.message, 'error');
|
||||
@@ -260,7 +260,7 @@ export function useNotes(opts = {}) {
|
||||
// Reload backlinks
|
||||
try {
|
||||
const bl = await api().backlinks(editingId);
|
||||
setBacklinks(bl.data || bl || []);
|
||||
setBacklinks(bl || []);
|
||||
} catch {}
|
||||
} else {
|
||||
const created = await api().create({ title, content, folder_path, tags });
|
||||
@@ -305,7 +305,7 @@ export function useNotes(opts = {}) {
|
||||
const navigateToLink = useCallback(async (title) => {
|
||||
try {
|
||||
const resp = await api().searchTitles(title, 1);
|
||||
const match = (resp.data || resp || []).find(
|
||||
const match = (resp || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (match) {
|
||||
@@ -337,7 +337,7 @@ export function useNotes(opts = {}) {
|
||||
const title = `Daily \u2014 ${today}`;
|
||||
try {
|
||||
const resp = await api().searchTitles(title, 1);
|
||||
const existing = (resp.data || resp || []).find(
|
||||
const existing = (resp || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
@@ -362,7 +362,7 @@ export function useNotes(opts = {}) {
|
||||
const title = `Daily \u2014 ${newDate}`;
|
||||
try {
|
||||
const resp = await api().searchTitles(title, 1);
|
||||
const existing = (resp.data || resp || []).find(
|
||||
const existing = (resp || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
|
||||
@@ -11,7 +11,8 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState(null);
|
||||
const [focusIdx, setFocusIdx] = useState(-1);
|
||||
const [submenu, setSubmenu] = useState(null);
|
||||
const [submenu, setSubmenu] = useState(null); // { item, anchorEl }
|
||||
const itemRefs = useRef({}); // selIdx → DOM element
|
||||
|
||||
const selectableItems = items.filter(i => !i.divider && !i.disabled);
|
||||
|
||||
@@ -90,14 +91,14 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
|
||||
e.preventDefault();
|
||||
if (focusIdx >= 0 && focusIdx < count) {
|
||||
const item = selectableItems[focusIdx];
|
||||
if (item.children) setSubmenu(item);
|
||||
if (item.children) setSubmenu({ item, anchorEl: itemRefs.current[focusIdx] });
|
||||
else if (onSelect) { onSelect(item.action || item.label); if (onClose) onClose(); }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (focusIdx >= 0 && selectableItems[focusIdx]?.children) {
|
||||
setSubmenu(selectableItems[focusIdx]);
|
||||
setSubmenu({ item: selectableItems[focusIdx], anchorEl: itemRefs.current[focusIdx] });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -130,11 +131,12 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
|
||||
return html`
|
||||
<div class="sw-menu__item ${item.disabled ? 'sw-menu__item--disabled' : ''} ${focused ? 'sw-menu__item--focused' : ''}"
|
||||
role="menuitem"
|
||||
ref=${el => { if (el) itemRefs.current[mySelIdx] = el; }}
|
||||
aria-disabled=${item.disabled || false}
|
||||
onMouseEnter=${() => { setFocusIdx(mySelIdx); if (item.children) setSubmenu(item); }}
|
||||
onMouseEnter=${(e) => { setFocusIdx(mySelIdx); if (item.children) setSubmenu({ item, anchorEl: e.currentTarget }); }}
|
||||
onClick=${() => {
|
||||
if (item.disabled) return;
|
||||
if (item.children) { setSubmenu(item); return; }
|
||||
if (item.children) { setSubmenu({ item, anchorEl: itemRefs.current[mySelIdx] }); return; }
|
||||
if (onSelect) onSelect(item.action || item.label);
|
||||
if (onClose) onClose();
|
||||
}}>
|
||||
@@ -146,9 +148,9 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
|
||||
})}
|
||||
${submenu && html`
|
||||
<${Menu}
|
||||
items=${submenu.children}
|
||||
items=${submenu.item.children}
|
||||
open
|
||||
anchor=${menuRef.current}
|
||||
anchor=${submenu.anchorEl}
|
||||
direction="down-right"
|
||||
onSelect=${(action) => { if (onSelect) onSelect(action); if (onClose) onClose(); }}
|
||||
onClose=${() => setSubmenu(null)}
|
||||
|
||||
130
src/js/sw/primitives/user-picker.js
Normal file
130
src/js/sw/primitives/user-picker.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// ==========================================
|
||||
// Primitive — UserPicker (autocomplete)
|
||||
// ==========================================
|
||||
// Debounced search input with dropdown results.
|
||||
// Calls sw.api.users.search(q) and shows matching users.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${UserPicker} onSelect=${(user) => ...} placeholder="Search users..." />`
|
||||
|
||||
const { html } = window;
|
||||
const { useState, useRef, useEffect, useCallback } = hooks;
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
function _initials(name) {
|
||||
if (!name) return '?';
|
||||
return name.split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* onSelect: (user: {id: string, username: string, display_name?: string}) => void,
|
||||
* placeholder?: string,
|
||||
* autoFocus?: boolean,
|
||||
* className?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function UserPicker({ onSelect, placeholder = 'Search users\u2026', autoFocus, className }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIdx, setActiveIdx] = useState(-1);
|
||||
const timerRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
const q = query.trim();
|
||||
if (!q || q.length < 1) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
timerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await sw.api.users.search(q);
|
||||
const list = resp || [];
|
||||
setResults(Array.isArray(list) ? list : []);
|
||||
setOpen(list.length > 0);
|
||||
setActiveIdx(-1);
|
||||
} catch (_) {
|
||||
setResults([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}, [query]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onClick(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', _onClick);
|
||||
return () => document.removeEventListener('mousedown', _onClick);
|
||||
}, [open]);
|
||||
|
||||
function _select(user) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
onSelect(user);
|
||||
}
|
||||
|
||||
function _onKeyDown(e) {
|
||||
if (!open || !results.length) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIdx(i => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIdx(i => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
||||
e.preventDefault();
|
||||
_select(results[activeIdx]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class=${'sw-user-picker' + (className ? ' ' + className : '')}
|
||||
ref=${containerRef}>
|
||||
<input class="sw-user-picker__input"
|
||||
ref=${inputRef}
|
||||
type="text"
|
||||
value=${query}
|
||||
placeholder=${placeholder}
|
||||
autoFocus=${autoFocus}
|
||||
onInput=${e => setQuery(e.target.value)}
|
||||
onFocus=${() => results.length && setOpen(true)}
|
||||
onKeyDown=${_onKeyDown}
|
||||
autocomplete="off" />
|
||||
${loading && html`<span class="sw-user-picker__spinner" />`}
|
||||
${open && html`
|
||||
<div class="sw-user-picker__dropdown" role="listbox">
|
||||
${results.map((u, i) => html`
|
||||
<div key=${u.id}
|
||||
class=${'sw-user-picker__option' + (i === activeIdx ? ' sw-user-picker__option--active' : '')}
|
||||
role="option"
|
||||
aria-selected=${i === activeIdx}
|
||||
onMouseDown=${() => _select(u)}
|
||||
onMouseEnter=${() => setActiveIdx(i)}>
|
||||
<span class="sw-user-picker__avatar">${_initials(u.display_name || u.username)}</span>
|
||||
<div class="sw-user-picker__label">
|
||||
<span class="sw-user-picker__name">${u.display_name || u.username}</span>
|
||||
${u.display_name && u.username && html`
|
||||
<span class="sw-user-picker__handle">@${u.username}</span>`}
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export function createDomains(restClient) {
|
||||
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 }),
|
||||
@@ -184,11 +185,13 @@ export function createDomains(restClient) {
|
||||
|
||||
// ── 11. Notifications ──────────────────
|
||||
notifications: {
|
||||
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
|
||||
markRead: (id) => rc.post(`/api/v1/notifications/${id}/read`, {}),
|
||||
prefs: () => rc.get('/api/v1/notifications/preferences'),
|
||||
setPref: (type, data) => rc.put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data),
|
||||
delPref: (type) => rc.del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`),
|
||||
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
|
||||
unreadCount: () => rc.get('/api/v1/notifications/unread-count'),
|
||||
markRead: (id) => rc.post(`/api/v1/notifications/${id}/read`, {}),
|
||||
markAllRead: () => rc.post('/api/v1/notifications/mark-all-read', {}),
|
||||
prefs: () => rc.get('/api/v1/notifications/preferences'),
|
||||
setPref: (type, data) => rc.put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data),
|
||||
delPref: (type) => rc.del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`),
|
||||
},
|
||||
|
||||
// ── 12. Extensions ─────────────────────
|
||||
@@ -443,6 +446,19 @@ export function createDomains(restClient) {
|
||||
del: (id) => rc.del(`/api/v1/admin/extensions/${id}`),
|
||||
},
|
||||
|
||||
packages: {
|
||||
list: (opts) => rc.get('/api/v1/admin/packages' + _qs(opts)),
|
||||
get: (id) => rc.get(`/api/v1/admin/packages/${id}`),
|
||||
install: (file) => rc.upload('/api/v1/admin/packages/install', file),
|
||||
enable: (id) => rc.put(`/api/v1/admin/packages/${id}/enable`, {}),
|
||||
disable: (id) => rc.put(`/api/v1/admin/packages/${id}/disable`, {}),
|
||||
del: (id) => rc.del(`/api/v1/admin/packages/${id}`),
|
||||
settings: (id) => rc.get(`/api/v1/admin/packages/${id}/settings`),
|
||||
updateSettings: (id, data) => rc.put(`/api/v1/admin/packages/${id}/settings`, data),
|
||||
registry: () => rc.get('/api/v1/admin/packages/registry'),
|
||||
registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }),
|
||||
},
|
||||
|
||||
tasks: {
|
||||
list: () => rc.get('/api/v1/admin/tasks'),
|
||||
run: (id) => rc.post(`/api/v1/admin/tasks/${id}/run`, {}),
|
||||
@@ -478,11 +494,19 @@ export function createDomains(restClient) {
|
||||
// ── Misc (not domain-specific) ─────────
|
||||
folders: {
|
||||
list: () => rc.get('/api/v1/folders'),
|
||||
create: (name, sort) => rc.post('/api/v1/folders', { name, sort_order: sort || 0 }),
|
||||
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}`),
|
||||
},
|
||||
|
||||
notifications: {
|
||||
list: (params) => rc.get('/api/v1/notifications', params),
|
||||
unreadCount: () => rc.get('/api/v1/notifications/unread-count'),
|
||||
markRead: (id) => rc.patch(`/api/v1/notifications/${id}/read`),
|
||||
markAllRead: () => rc.post('/api/v1/notifications/mark-all-read'),
|
||||
del: (id) => rc.del(`/api/v1/notifications/${id}`),
|
||||
},
|
||||
|
||||
files: {
|
||||
get: (id) => rc.get(`/api/v1/files/${id}`),
|
||||
del: (id) => rc.del(`/api/v1/files/${id}`),
|
||||
@@ -492,6 +516,11 @@ export function createDomains(restClient) {
|
||||
list: () => rc.get('/api/v1/tools'),
|
||||
},
|
||||
|
||||
// ── Users ──────────────────────────────
|
||||
users: {
|
||||
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
|
||||
},
|
||||
|
||||
presence: {
|
||||
heartbeat: () => rc.post('/api/v1/presence/heartbeat', {}),
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ export function createAuth() {
|
||||
let _policies = {};
|
||||
let _refreshTimer = null;
|
||||
let _refreshPromise = null;
|
||||
let _booting = false;
|
||||
let _restClient = null;
|
||||
let _emit = () => {};
|
||||
|
||||
@@ -69,6 +70,7 @@ export function createAuth() {
|
||||
_groups = [];
|
||||
_policies = {};
|
||||
localStorage.removeItem(_storageKey);
|
||||
sessionStorage.removeItem('sw-chat-active');
|
||||
document.cookie = 'sb_token=; path=/; max-age=0';
|
||||
if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; }
|
||||
}
|
||||
@@ -194,11 +196,19 @@ export function createAuth() {
|
||||
*/
|
||||
async boot() {
|
||||
_loadTokens();
|
||||
if (!_refreshToken) return;
|
||||
if (!_refreshToken) {
|
||||
// No tokens in localStorage — clear any stale sb_token cookie
|
||||
// so Go SSR middleware doesn't trust a leftover cookie.
|
||||
document.cookie = 'sb_token=; path=/; max-age=0';
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown token age — schedule refresh soon
|
||||
_scheduleRefresh(60);
|
||||
|
||||
// Suppress _on401Failure redirect during boot — boot handles 401 itself.
|
||||
_booting = true;
|
||||
|
||||
// Fetch permissions (validates the access token)
|
||||
try {
|
||||
await _fetchPermissions();
|
||||
@@ -215,6 +225,8 @@ export function createAuth() {
|
||||
// Network error or other — keep tokens, user may be offline
|
||||
console.warn('[sw.auth] Boot: permissions fetch failed (keeping tokens):', e.message);
|
||||
}
|
||||
} finally {
|
||||
_booting = false;
|
||||
}
|
||||
|
||||
if (_accessToken) {
|
||||
@@ -233,8 +245,15 @@ export function createAuth() {
|
||||
|
||||
_on401Failure() {
|
||||
_clearTokens();
|
||||
// During boot(), auth.boot() handles 401 gracefully — don't redirect.
|
||||
if (_booting) return;
|
||||
// Already on login page — don't redirect (prevents blank-page reload loop
|
||||
// when stale localStorage tokens trigger 401 during SDK boot on /login).
|
||||
const base = window.__BASE__ || '';
|
||||
window.location.href = base + '/login';
|
||||
const loginPath = base + '/login';
|
||||
const here = window.location.pathname.replace(/\/+$/, '');
|
||||
if (here === loginPath) return;
|
||||
window.location.href = loginPath;
|
||||
},
|
||||
|
||||
_setAuth(data) { _setAuth(data); },
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function boot() {
|
||||
};
|
||||
|
||||
// Marker for idempotency
|
||||
sw._sdk = '0.37.9';
|
||||
sw._sdk = '0.37.14';
|
||||
|
||||
// 8. Expose globally
|
||||
window.sw = sw;
|
||||
@@ -161,7 +161,7 @@ export async function boot() {
|
||||
// 10. Signal ready
|
||||
events.emit('sdk.ready', {}, { localOnly: true });
|
||||
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
||||
console.log('[sw] SDK v0.37.9 ready');
|
||||
console.log(`[sw] SDK v${sw._sdk} ready`);
|
||||
|
||||
return sw;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,12 @@ export function createPipe() {
|
||||
entry.stats.calls++;
|
||||
entry.stats.totalMs += performance.now() - t0;
|
||||
|
||||
if (result instanceof Promise) {
|
||||
console.error(`[sw.pipe] ${stage}: filter '${entry.source}' returned Promise (async not supported)`);
|
||||
entry.stats.errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result === null || result === undefined) {
|
||||
return null; // halt chain
|
||||
}
|
||||
|
||||
@@ -97,8 +97,10 @@ export function createRestClient(getToken, onRefresh, on401Failure) {
|
||||
if (!text) return null;
|
||||
const json = JSON.parse(text);
|
||||
|
||||
// List envelope: { data: Array, ... }
|
||||
if (json && Array.isArray(json.data) && ('page' in json || 'total' in json || 'per_page' in json)) {
|
||||
// List envelope: { data: Array } with no sibling keys → unwrap.
|
||||
// If siblings exist (total, page, etc.) return full object so
|
||||
// callers can access metadata alongside the list.
|
||||
if (json && Array.isArray(json.data) && Object.keys(json).length === 1) {
|
||||
return json.data;
|
||||
}
|
||||
return json;
|
||||
|
||||
152
src/js/sw/shell/notification-bell.js
Normal file
152
src/js/sw/shell/notification-bell.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// ==========================================
|
||||
// Shell — Notification Bell Component
|
||||
// ==========================================
|
||||
// Bell icon with unread count badge and dropdown panel.
|
||||
// Fetches notifications on mount and listens for WS events.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useEffect, useCallback, useRef } = window.hooks;
|
||||
|
||||
const BELL_SVG = html`
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>`;
|
||||
|
||||
export function NotificationBell({ onNavigate } = {}) {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read_at).length;
|
||||
|
||||
// ── Fetch notifications ─────────────
|
||||
const reload = useCallback(async () => {
|
||||
if (!window.sw?.api?.notifications?.list) return;
|
||||
try {
|
||||
const resp = await window.sw.api.notifications.list({ per_page: 20 });
|
||||
const items = Array.isArray(resp) ? resp
|
||||
: Array.isArray(resp?.data) ? resp.data
|
||||
: Array.isArray(resp?.notifications) ? resp.notifications
|
||||
: [];
|
||||
setNotifications(items);
|
||||
} catch (_) {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// ── WebSocket live updates ──────────
|
||||
useEffect(() => {
|
||||
if (!window.sw?.on) return;
|
||||
const handler = () => reload();
|
||||
window.sw.on('notification.created', handler);
|
||||
return () => window.sw.off?.('notification.created', handler);
|
||||
}, [reload]);
|
||||
|
||||
// ── Close on outside click ──────────
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _close(e) {
|
||||
if (ref.current?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
document.addEventListener('click', _close);
|
||||
return () => document.removeEventListener('click', _close);
|
||||
}, [open]);
|
||||
|
||||
// ── Mark as read ────────────────────
|
||||
const _markRead = useCallback(async (id) => {
|
||||
if (!window.sw?.api?.notifications?.markRead) return;
|
||||
try {
|
||||
await window.sw.api.notifications.markRead(id);
|
||||
setNotifications(prev => prev.map(n =>
|
||||
n.id === id ? { ...n, read_at: new Date().toISOString() } : n
|
||||
));
|
||||
} catch (_) {}
|
||||
}, []);
|
||||
|
||||
const _markAllRead = useCallback(async () => {
|
||||
if (window.sw?.api?.notifications?.markAllRead) {
|
||||
try {
|
||||
await window.sw.api.notifications.markAllRead();
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read_at: n.read_at || new Date().toISOString() })));
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
// Fallback: mark one by one
|
||||
const unread = notifications.filter(n => !n.read_at);
|
||||
for (const n of unread) {
|
||||
await _markRead(n.id);
|
||||
}
|
||||
}, [notifications, _markRead]);
|
||||
|
||||
const _toggle = useCallback(() => {
|
||||
setOpen(v => !v);
|
||||
}, []);
|
||||
|
||||
// ── Click-to-navigate ────────────────
|
||||
const _onClick = useCallback((n) => {
|
||||
if (!n.read_at) _markRead(n.id);
|
||||
if (n.resource_type === 'channel' && n.resource_id && onNavigate) {
|
||||
onNavigate(n.resource_id);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [_markRead, onNavigate]);
|
||||
|
||||
return html`
|
||||
<div class="sw-notification-bell" ref=${ref}>
|
||||
<button class="sw-notification-bell__trigger"
|
||||
onClick=${_toggle}
|
||||
title="Notifications"
|
||||
aria-label="Notifications"
|
||||
aria-expanded=${open}>
|
||||
${BELL_SVG}
|
||||
${unreadCount > 0 && html`
|
||||
<span class="sw-notification-bell__badge">${unreadCount > 9 ? '9+' : unreadCount}</span>`}
|
||||
</button>
|
||||
${open && html`
|
||||
<div class="sw-notification-bell__panel">
|
||||
<div class="sw-notification-bell__header">
|
||||
<span>Notifications</span>
|
||||
${unreadCount > 0 && html`
|
||||
<button class="sw-notification-bell__mark-all"
|
||||
onClick=${_markAllRead}>
|
||||
Mark all read
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="sw-notification-bell__list">
|
||||
${notifications.length === 0 && html`
|
||||
<div class="sw-notification-bell__empty">No notifications</div>`}
|
||||
${notifications.map(n => {
|
||||
const navigable = n.resource_type === 'channel' && n.resource_id && onNavigate;
|
||||
return html`
|
||||
<div key=${n.id}
|
||||
class=${'sw-notification-bell__item'
|
||||
+ (n.read_at ? '' : ' sw-notification-bell__item--unread')
|
||||
+ (navigable ? ' sw-notification-bell__item--navigable' : '')}
|
||||
onClick=${() => _onClick(n)}>
|
||||
<div class="sw-notification-bell__item-text">${n.title || n.message || 'Notification'}</div>
|
||||
${n.body && html`<div class="sw-notification-bell__item-body">${n.body}</div>`}
|
||||
<div class="sw-notification-bell__item-time">
|
||||
${_timeAgo(n.created_at)}
|
||||
${navigable && html`<span class="sw-notification-bell__item-action">Open</span>`}
|
||||
</div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _timeAgo(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return mins + 'm ago';
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return hrs + 'h ago';
|
||||
const days = Math.floor(hrs / 24);
|
||||
return days + 'd ago';
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
* extraItems — Additional menu items inserted after surface links
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useRef, useMemo } = hooks;
|
||||
const { useState, useRef, useMemo, useEffect } = hooks;
|
||||
|
||||
import { Avatar } from '../primitives/avatar.js';
|
||||
import { Menu } from '../primitives/menu.js';
|
||||
@@ -27,12 +27,23 @@ function _currentSurface() {
|
||||
|
||||
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [extSurfaces, setExtSurfaces] = useState([]);
|
||||
const btnRef = useRef(null);
|
||||
|
||||
const sw = window.sw;
|
||||
const user = sw?.auth?.user;
|
||||
const authenticated = sw?.auth?.isAuthenticated;
|
||||
|
||||
// Fetch extension surfaces once on mount
|
||||
useEffect(() => {
|
||||
if (!sw?.api?.surfaces?.list) return;
|
||||
sw.api.surfaces.list().then(data => {
|
||||
const all = data || [];
|
||||
const CORE = new Set(['chat', 'admin', 'notes', 'settings', 'team-admin', 'workflow', 'workflow-landing']);
|
||||
setExtSurfaces(all.filter(s => !CORE.has(s.id)));
|
||||
}).catch(() => {});
|
||||
}, [authenticated]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const current = _currentSurface();
|
||||
const list = [];
|
||||
@@ -44,10 +55,9 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
{ key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' },
|
||||
];
|
||||
|
||||
// Add extension surfaces from page data if available
|
||||
const extSurfaces = window.__EXTENSION_SURFACES__ || [];
|
||||
// Add extension surfaces fetched from API
|
||||
for (const ext of extSurfaces) {
|
||||
surfaces.push({ key: ext.id || ext.route, label: ext.title, icon: '\ud83e\udde9' });
|
||||
surfaces.push({ key: ext.id, label: ext.title, icon: '\ud83e\udde9', route: ext.route });
|
||||
}
|
||||
|
||||
const surfaceItems = surfaces
|
||||
@@ -72,18 +82,18 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
}
|
||||
|
||||
// Admin — requires admin role
|
||||
if ((!authenticated || sw?.isAdmin) && current !== 'admin') {
|
||||
if (authenticated && sw?.isAdmin && current !== 'admin') {
|
||||
list.push({ label: 'Admin', action: 'admin', icon: '\ud83d\udee1\ufe0f' });
|
||||
}
|
||||
|
||||
// Team Admin — requires admin role on at least one team
|
||||
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
|
||||
if ((!authenticated || hasTeamAdmin) && current !== 'team-admin') {
|
||||
if (authenticated && hasTeamAdmin && current !== 'team-admin') {
|
||||
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
|
||||
}
|
||||
|
||||
// Debug — admin only
|
||||
if (!authenticated || sw?.isAdmin) {
|
||||
if (authenticated && sw?.isAdmin) {
|
||||
list.push({ label: 'Debug', action: 'debug', icon: '\ud83d\udc1b' });
|
||||
}
|
||||
|
||||
@@ -91,7 +101,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
list.push({ label: 'Sign Out', action: 'sign-out' });
|
||||
|
||||
return list;
|
||||
}, [user, authenticated, extraItems]);
|
||||
}, [user, authenticated, extraItems, extSurfaces]);
|
||||
|
||||
function handleSelect(action) {
|
||||
setOpen(false);
|
||||
@@ -101,7 +111,9 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
const BASE = window.__BASE__ || '';
|
||||
switch (action) {
|
||||
case 'sign-out':
|
||||
window.sw?.auth?.logout();
|
||||
window.sw?.auth?.logout().then(() => {
|
||||
location.href = BASE + '/login';
|
||||
});
|
||||
break;
|
||||
case 'debug':
|
||||
const dbg = document.getElementById('debugModal');
|
||||
@@ -110,8 +122,12 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
case 'chat':
|
||||
location.href = BASE + '/';
|
||||
break;
|
||||
default:
|
||||
location.href = BASE + '/' + action;
|
||||
default: {
|
||||
// Extension surfaces use /s/{id} routes
|
||||
const ext = extSurfaces.find(s => s.id === action);
|
||||
location.href = BASE + (ext?.route || '/' + action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,17 +135,20 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
const avatar = user?.avatar || null;
|
||||
|
||||
return html`
|
||||
<button class="sw-user-menu__trigger" ref=${btnRef}
|
||||
onClick=${() => setOpen(!open)} aria-label="User menu">
|
||||
<${Avatar} src=${avatar} name=${displayName} size="sm" />
|
||||
</button>
|
||||
<${Menu}
|
||||
open=${open}
|
||||
anchor=${btnRef.current}
|
||||
direction=${placement}
|
||||
items=${items}
|
||||
onSelect=${handleSelect}
|
||||
onClose=${() => setOpen(false)}
|
||||
/>
|
||||
<div class="user-menu-wrap">
|
||||
<button class="user-btn" ref=${btnRef}
|
||||
onClick=${() => setOpen(!open)} aria-label="User menu">
|
||||
<${Avatar} src=${avatar} name=${displayName} size="sm" />
|
||||
<span class="sb-label">${displayName}</span>
|
||||
</button>
|
||||
<${Menu}
|
||||
open=${open}
|
||||
anchor=${btnRef.current}
|
||||
direction=${placement}
|
||||
items=${items}
|
||||
onSelect=${handleSelect}
|
||||
onClose=${() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ export default function CapabilitiesSection() {
|
||||
sw.api.admin.models.overrides(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
]);
|
||||
setOverrides(Array.isArray(o) ? o : o.data || []);
|
||||
setModels(Array.isArray(m) ? m : m.models || m.data || []);
|
||||
setOverrides(o.data || []);
|
||||
setModels(m || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Admin > System > Channels (Archived)
|
||||
* Admin > System > Channels (Archived + Retention)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
@@ -7,21 +7,34 @@ const { useState, useEffect, useCallback } = hooks;
|
||||
export default function ChannelsSection() {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [retentionMode, setRetentionMode] = useState('');
|
||||
const [retentionTTL, setRetentionTTL] = useState(0);
|
||||
const [ttlDraft, setTTLDraft] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.channels.archived();
|
||||
setChannels(data.channels || data.data || []);
|
||||
setChannels(data.data || []);
|
||||
setTotal(data.total || 0);
|
||||
setRetentionMode(data.retention_mode || '');
|
||||
const ttl = data.retention_ttl_days || 0;
|
||||
setRetentionTTL(ttl);
|
||||
setTTLDraft(String(ttl));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function saveTTL() {
|
||||
const val = parseInt(ttlDraft, 10);
|
||||
if (isNaN(val) || val < 0) { sw.toast('TTL must be 0 or more', 'error'); return; }
|
||||
try {
|
||||
await sw.api.admin.settings.update('retention_ttl_days', { value: { value: val } });
|
||||
setRetentionTTL(val);
|
||||
sw.toast('Retention TTL saved', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function purge(id, title) {
|
||||
const ok = await sw.confirm(`Permanently delete "${title || id}"? This cannot be undone.`, true);
|
||||
if (!ok) return;
|
||||
@@ -38,9 +51,27 @@ export default function ChannelsSection() {
|
||||
<div>
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${total}</div><div class="stat-label">Archived</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${retentionMode || '\u2014'}</div><div class="stat-label">Retention Mode</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${retentionTTL || 'Off'}</div><div class="stat-label">Retention TTL (days)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section" style="margin-bottom:20px;">
|
||||
<h4 style="margin:0 0 8px;">Retention Policy</h4>
|
||||
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">
|
||||
Channels are archived on delete and purged after this many days.
|
||||
Only Personal (BYOK) provider channels are exempt. Set to 0 to disable retention.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="number" min="0" value=${ttlDraft}
|
||||
onInput=${e => setTTLDraft(e.target.value)}
|
||||
style="width:80px;padding:6px 8px;background:var(--bg-2);color:var(--text);border:1px solid var(--border);border-radius:4px;"
|
||||
placeholder="0" />
|
||||
<span class="text-muted" style="font-size:12px;">days</span>
|
||||
<button class="btn-small" onClick=${saveTTL}
|
||||
disabled=${String(retentionTTL) === ttlDraft}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin:0 0 8px;">Archived Channels</h4>
|
||||
<div class="admin-list">
|
||||
${channels.length === 0 && html`<div class="empty-hint">No archived channels</div>`}
|
||||
${channels.map(c => html`
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function GroupsSection() {
|
||||
const loadGroups = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.groups.list();
|
||||
setGroups(Array.isArray(data) ? data : data.data || []);
|
||||
setGroups(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
@@ -41,10 +41,10 @@ export default function GroupsSection() {
|
||||
sw.api.admin.users.list(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
]);
|
||||
setMembers(Array.isArray(m) ? m : m.data || []);
|
||||
setMembers(m || []);
|
||||
setAllPerms(p.permissions || []);
|
||||
setAllUsers(Array.isArray(u) ? u : u.users || []);
|
||||
setAllModels(Array.isArray(models) ? models : models.models || models.data || []);
|
||||
setAllUsers(u || []);
|
||||
setAllModels(models || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function GroupsSection() {
|
||||
sw.toast('Member added', 'success');
|
||||
setShowAddMember(false);
|
||||
const m = await sw.api.admin.groups.members(detail.id);
|
||||
setMembers(Array.isArray(m) ? m : m.data || []);
|
||||
setMembers(m || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function HealthSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.providers.health();
|
||||
setProviders(Array.isArray(data) ? data : data.data || []);
|
||||
setProviders(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -46,30 +46,32 @@ const CATEGORY_META = {
|
||||
};
|
||||
|
||||
// ── Lazy section imports ────────────────────
|
||||
// Version query busts browser module cache on upgrades.
|
||||
const _v = window.__VERSION__ ? `?v=${window.__VERSION__}` : '';
|
||||
const sectionModules = {
|
||||
users: () => import('./users.js'),
|
||||
teams: () => import('./teams.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
models: () => import('./models.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
roles: () => import('./roles.js'),
|
||||
knowledgeBases: () => import('./knowledge.js'),
|
||||
memory: () => import('./memory.js'),
|
||||
workflows: () => import('./workflows.js'),
|
||||
tasks: () => import('./tasks.js'),
|
||||
health: () => import('./health.js'),
|
||||
routing: () => import('./routing.js'),
|
||||
capabilities: () => import('./capabilities.js'),
|
||||
settings: () => import('./settings.js'),
|
||||
storage: () => import('./storage.js'),
|
||||
packages: () => import('./packages.js'),
|
||||
channels: () => import('./channels.js'),
|
||||
broadcast: () => import('./broadcast.js'),
|
||||
dashboard: () => import('./dashboard.js'),
|
||||
usage: () => import('./usage.js'),
|
||||
audit: () => import('./audit.js'),
|
||||
stats: () => import('./stats.js'),
|
||||
users: () => import(`./users.js${_v}`),
|
||||
teams: () => import(`./teams.js${_v}`),
|
||||
groups: () => import(`./groups.js${_v}`),
|
||||
providers: () => import(`./providers.js${_v}`),
|
||||
models: () => import(`./models.js${_v}`),
|
||||
personas: () => import(`./personas.js${_v}`),
|
||||
roles: () => import(`./roles.js${_v}`),
|
||||
knowledgeBases: () => import(`./knowledge.js${_v}`),
|
||||
memory: () => import(`./memory.js${_v}`),
|
||||
workflows: () => import(`./workflows.js${_v}`),
|
||||
tasks: () => import(`./tasks.js${_v}`),
|
||||
health: () => import(`./health.js${_v}`),
|
||||
routing: () => import(`./routing.js${_v}`),
|
||||
capabilities: () => import(`./capabilities.js${_v}`),
|
||||
settings: () => import(`./settings.js${_v}`),
|
||||
storage: () => import(`./storage.js${_v}`),
|
||||
packages: () => import(`./packages.js${_v}`),
|
||||
channels: () => import(`./channels.js${_v}`),
|
||||
broadcast: () => import(`./broadcast.js${_v}`),
|
||||
dashboard: () => import(`./dashboard.js${_v}`),
|
||||
usage: () => import(`./usage.js${_v}`),
|
||||
audit: () => import(`./audit.js${_v}`),
|
||||
stats: () => import(`./stats.js${_v}`),
|
||||
};
|
||||
|
||||
// ── Derive category from section ────────────
|
||||
|
||||
@@ -12,8 +12,8 @@ export default function KnowledgeSection() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.knowledge.list();
|
||||
setKbs(Array.isArray(data) ? data : data.data || []);
|
||||
const resp = await sw.api.knowledge.list();
|
||||
setKbs(resp.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
@@ -24,7 +24,7 @@ export default function KnowledgeSection() {
|
||||
setDetail(kb);
|
||||
try {
|
||||
const d = await sw.api.knowledge.documents(kb.id);
|
||||
setDocs(Array.isArray(d) ? d : d.data || []);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function KnowledgeSection() {
|
||||
await sw.api.knowledge.upload(detail.id, file);
|
||||
sw.toast('Document uploaded', 'success');
|
||||
const d = await sw.api.knowledge.documents(detail.id);
|
||||
setDocs(Array.isArray(d) ? d : d.data || []);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
};
|
||||
input.click();
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function MemorySection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.memories.pending();
|
||||
setPending(Array.isArray(data) ? data : data.data || []);
|
||||
setPending(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function ModelsSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.models.list();
|
||||
setModels(Array.isArray(data) ? data : data.models || data.data || []);
|
||||
setModels(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -1,69 +1,274 @@
|
||||
/**
|
||||
* Admin > System > Packages (Extensions)
|
||||
* Admin > System > Packages
|
||||
*
|
||||
* Full package lifecycle: list, install (upload + registry), enable/disable,
|
||||
* settings, export, delete. Uses /api/v1/admin/packages endpoints.
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function PackagesSection() {
|
||||
const [extensions, setExtensions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const TYPE_OPTIONS = ['all', 'surface', 'extension', 'full', 'workflow'];
|
||||
const CORE_IDS = new Set(['chat', 'admin']);
|
||||
|
||||
function typeBadge(type) {
|
||||
const cls = type === 'surface' ? 'badge-active'
|
||||
: type === 'extension' ? 'badge-pending' : '';
|
||||
return html`<span class="badge ${cls}">${type || '?'}</span>`;
|
||||
}
|
||||
|
||||
function tierBadge(tier) {
|
||||
const cls = tier === 'browser' ? 'badge-active' : tier === 'starlark' ? 'badge-pending' : '';
|
||||
return html`<span class="badge ${cls}">${tier || 'unknown'}</span>`;
|
||||
}
|
||||
|
||||
function sourceBadge(source) {
|
||||
if (!source || source === 'extension') return null;
|
||||
const cls = source === 'core' ? 'badge-active' : source === 'registry' ? 'badge-pending' : '';
|
||||
return html`<span class="badge ${cls}">${source}</span>`;
|
||||
}
|
||||
|
||||
export default function PackagesSection() {
|
||||
const [packages, setPackages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [pkgSettings, setPkgSettings] = useState(null);
|
||||
const [settingsDraft, setSettingsDraft] = useState({});
|
||||
const [registryOpen, setRegistryOpen] = useState(false);
|
||||
const [registryPkgs, setRegistryPkgs] = useState([]);
|
||||
const [registryLoading, setRegistryLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
const BASE = window.__BASE__ || '';
|
||||
|
||||
// ── Load packages ───────────────────────────
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.extensions.list();
|
||||
setExtensions(Array.isArray(data) ? data : data.data || []);
|
||||
const opts = typeFilter !== 'all' ? { type: typeFilter } : undefined;
|
||||
const data = await sw.api.admin.packages.list(opts);
|
||||
setPackages(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
}, [typeFilter]);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => { setLoading(true); load(); }, [typeFilter]);
|
||||
|
||||
async function toggleEnabled(ext) {
|
||||
// ── Actions ─────────────────────────────────
|
||||
async function toggleEnabled(pkg) {
|
||||
if (CORE_IDS.has(pkg.id)) return;
|
||||
try {
|
||||
await sw.api.admin.extensions.update(ext.id, { is_enabled: !ext.is_enabled });
|
||||
sw.toast(`Extension ${ext.is_enabled ? 'disabled' : 'enabled'}`, 'success');
|
||||
if (pkg.enabled) {
|
||||
await sw.api.admin.packages.disable(pkg.id);
|
||||
sw.toast(`${pkg.title || pkg.id} disabled`, 'success');
|
||||
} else {
|
||||
await sw.api.admin.packages.enable(pkg.id);
|
||||
sw.toast(`${pkg.title || pkg.id} enabled`, 'success');
|
||||
}
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteExt(id) {
|
||||
const ok = await sw.confirm('Delete this extension?', true);
|
||||
async function deletePkg(pkg) {
|
||||
if (pkg.source === 'core') return;
|
||||
const ok = await sw.confirm(`Delete "${pkg.title || pkg.id}"? This cannot be undone.`, true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.extensions.del(id);
|
||||
sw.toast('Extension deleted', 'success');
|
||||
await sw.api.admin.packages.del(pkg.id);
|
||||
sw.toast('Package deleted', 'success');
|
||||
if (expandedId === pkg.id) { setExpandedId(null); setPkgSettings(null); }
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function tierBadge(tier) {
|
||||
const cls = tier === 'browser' ? 'badge-active' : tier === 'starlark' ? 'badge-pending' : '';
|
||||
return html`<span class="badge ${cls}">${tier || 'unknown'}</span>`;
|
||||
function uploadPackage() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pkg,.surface,.zip';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
setInstalling(true);
|
||||
try {
|
||||
await sw.api.admin.packages.install(file);
|
||||
sw.toast('Package installed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setInstalling(false); }
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function exportPkg(id) {
|
||||
window.open(`${BASE}/api/v1/admin/packages/${id}/export`, '_blank');
|
||||
}
|
||||
|
||||
// ── Settings drawer ─────────────────────────
|
||||
async function toggleSettings(pkgId) {
|
||||
if (expandedId === pkgId) {
|
||||
setExpandedId(null); setPkgSettings(null); setSettingsDraft({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await sw.api.admin.packages.settings(pkgId);
|
||||
const settings = data?.data || {};
|
||||
setPkgSettings(settings);
|
||||
setSettingsDraft({ ...settings });
|
||||
setExpandedId(pkgId);
|
||||
} catch (e) {
|
||||
sw.toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(pkgId) {
|
||||
try {
|
||||
await sw.api.admin.packages.updateSettings(pkgId, settingsDraft);
|
||||
sw.toast('Settings saved', 'success');
|
||||
setPkgSettings({ ...settingsDraft });
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Registry ────────────────────────────────
|
||||
async function toggleRegistry() {
|
||||
if (registryOpen) { setRegistryOpen(false); return; }
|
||||
setRegistryOpen(true);
|
||||
setRegistryLoading(true);
|
||||
try {
|
||||
const data = await sw.api.admin.packages.registry();
|
||||
setRegistryPkgs(data?.data || []);
|
||||
} catch (e) {
|
||||
sw.toast('Registry unavailable: ' + e.message, 'error');
|
||||
setRegistryPkgs([]);
|
||||
}
|
||||
finally { setRegistryLoading(false); }
|
||||
}
|
||||
|
||||
async function installFromRegistry(url) {
|
||||
try {
|
||||
await sw.api.admin.packages.registryInstall(url);
|
||||
sw.toast('Package installed from registry', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
const enabled = packages.filter(p => p.enabled).length;
|
||||
const hasManifestSettings = (pkg) =>
|
||||
pkg.manifest?.settings || pkg.package_settings;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${/* ── Stat cards ─────────────── */``}
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${packages.length}</div><div class="stat-label">Total</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${enabled}</div><div class="stat-label">Enabled</div></div>
|
||||
</div>
|
||||
|
||||
${/* ── Action bar ─────────────── */``}
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:14px;">
|
||||
${TYPE_OPTIONS.map(t => html`
|
||||
<button key=${t}
|
||||
class="btn-small ${typeFilter === t ? 'btn-primary' : ''}"
|
||||
onClick=${() => setTypeFilter(t)}
|
||||
style="text-transform:capitalize;">${t}</button>
|
||||
`)}
|
||||
<span style="flex:1;" />
|
||||
<button class="btn-small" onClick=${uploadPackage} disabled=${installing}>
|
||||
${installing ? 'Installing\u2026' : 'Install Package'}
|
||||
</button>
|
||||
<button class="btn-small" onClick=${toggleRegistry}>
|
||||
${registryOpen ? 'Close Registry' : 'Browse Registry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${/* ── Package list ────────────── */``}
|
||||
<div class="admin-list">
|
||||
${extensions.length === 0 && html`<div class="empty-hint">No extensions installed</div>`}
|
||||
${extensions.map(ext => html`
|
||||
<div class="admin-surface-row" key=${ext.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${ext.name}</strong>
|
||||
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${ext.version || '?'}</span>
|
||||
${' '}${tierBadge(ext.tier)}
|
||||
${ext.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
|
||||
</div>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small" onClick=${() => toggleEnabled(ext)}>
|
||||
${ext.is_enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
${!ext.is_system && html`<button class="btn-small btn-danger" onClick=${() => deleteExt(ext.id)}>Delete</button>`}
|
||||
${packages.length === 0 && html`<div class="empty-hint">No packages found</div>`}
|
||||
${packages.map(pkg => html`
|
||||
<div key=${pkg.id}>
|
||||
<div class="admin-surface-row">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${pkg.title || pkg.id}</strong>
|
||||
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${pkg.version || '?'}</span>
|
||||
${' '}${typeBadge(pkg.type)}
|
||||
${' '}${tierBadge(pkg.tier)}
|
||||
${sourceBadge(pkg.source)}
|
||||
${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
|
||||
</div>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small"
|
||||
onClick=${() => toggleEnabled(pkg)}
|
||||
disabled=${CORE_IDS.has(pkg.id)}>
|
||||
${pkg.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
${hasManifestSettings(pkg) && html`
|
||||
<button class="btn-small" onClick=${() => toggleSettings(pkg.id)}>
|
||||
${expandedId === pkg.id ? 'Close' : 'Settings'}
|
||||
</button>
|
||||
`}
|
||||
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
|
||||
${pkg.source !== 'core' && !pkg.is_system && html`
|
||||
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${/* ── Inline settings drawer ── */``}
|
||||
${expandedId === pkg.id && pkgSettings != null && html`
|
||||
<div style="padding:8px 12px 12px 24px;background:var(--bg-2);border-bottom:1px solid var(--border);">
|
||||
${Object.keys(pkgSettings).length === 0
|
||||
? html`<span class="text-muted" style="font-size:12px;">No configurable settings</span>`
|
||||
: html`
|
||||
${Object.entries(settingsDraft).map(([k, v]) => html`
|
||||
<div key=${k} style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
|
||||
<label style="min-width:120px;font-size:12px;color:var(--text-muted);">${k}</label>
|
||||
<input type="text"
|
||||
value=${v ?? ''}
|
||||
onInput=${e => setSettingsDraft(prev => ({ ...prev, [k]: e.target.value }))}
|
||||
style="flex:1;padding:4px 8px;background:var(--bg-1,var(--bg));color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:12px;" />
|
||||
</div>
|
||||
`)}
|
||||
<button class="btn-small" style="margin-top:4px;" onClick=${() => saveSettings(pkg.id)}>Save</button>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
${/* ── Registry panel ──────────── */``}
|
||||
${registryOpen && html`
|
||||
<div class="admin-section" style="margin-top:20px;padding:12px;border:1px solid var(--border);border-radius:6px;">
|
||||
<h4 style="margin:0 0 8px;">Package Registry</h4>
|
||||
${registryLoading
|
||||
? html`<div class="settings-placeholder">Loading registry\u2026</div>`
|
||||
: registryPkgs.length === 0
|
||||
? html`<div class="empty-hint">No packages available in registry</div>`
|
||||
: html`
|
||||
<div class="admin-list">
|
||||
${registryPkgs.map(rp => html`
|
||||
<div class="admin-surface-row" key=${rp.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${rp.title || rp.id}</strong>
|
||||
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${rp.version || '?'}</span>
|
||||
${rp.description && html`
|
||||
<div class="text-muted" style="font-size:11px;margin-top:2px;">${rp.description}</div>
|
||||
`}
|
||||
</div>
|
||||
<div class="admin-actions-cell">
|
||||
${typeBadge(rp.type)}
|
||||
<button class="btn-small btn-primary"
|
||||
onClick=${() => installFromRegistry(rp.download_url)}>Install</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ export default function PersonasSection() {
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
sw.api.knowledge.list().catch(() => []),
|
||||
]);
|
||||
setPersonas(Array.isArray(p) ? p : p.data || []);
|
||||
setModels(Array.isArray(m) ? m : m.models || m.data || []);
|
||||
setKbs(Array.isArray(k) ? k : k.data || []);
|
||||
setPersonas(p || []);
|
||||
setModels(m || []);
|
||||
setKbs(k.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -16,8 +16,8 @@ export default function ProvidersSection() {
|
||||
sw.api.admin.configs.list(),
|
||||
sw.api.admin.providers.types(),
|
||||
]);
|
||||
setConfigs(Array.isArray(c) ? c : c.configs || c.data || []);
|
||||
setProviderTypes(t.types || t.data || []);
|
||||
setConfigs(c || []);
|
||||
setProviderTypes(t || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
@@ -82,7 +82,7 @@ export default function ProvidersSection() {
|
||||
</div>
|
||||
<div class="form-group"><label>Type</label>
|
||||
<select name="provider">
|
||||
${providerTypes.map(t => html`<option key=${t.name} value=${t.name} selected=${editing !== 'new' && editing.provider === t.name}>${t.display_name || t.name}</option>`)}
|
||||
${providerTypes.map(t => html`<option key=${t.id} value=${t.id} selected=${editing !== 'new' && editing.provider === t.id}>${t.display_name || t.name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function RolesSection() {
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [roles, setRoles] = useState({});
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dirty, setDirty] = useState({});
|
||||
@@ -16,8 +16,8 @@ export default function RolesSection() {
|
||||
sw.api.admin.roles.list(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
]);
|
||||
setRoles(r.roles || r.data || (Array.isArray(r) ? r : []));
|
||||
setModels(Array.isArray(m) ? m : m.models || m.data || []);
|
||||
setRoles(r || {});
|
||||
setModels(m || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
@@ -49,10 +49,8 @@ export default function RolesSection() {
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${roles.length === 0 && html`<div class="empty-hint">No roles configured</div>`}
|
||||
${roles.map(r => {
|
||||
const name = typeof r === 'string' ? r : r.name || r.role;
|
||||
const config = typeof r === 'object' ? r : {};
|
||||
${Object.keys(roles).length === 0 && html`<div class="empty-hint">No roles configured</div>`}
|
||||
${Object.entries(roles).map(([name, config]) => {
|
||||
const changes = dirty[name] || {};
|
||||
const primary = changes.primary_model ?? config.primary_model ?? '';
|
||||
const fallback = changes.fallback_model ?? config.fallback_model ?? '';
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function RoutingSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.routing.policies();
|
||||
setPolicies(Array.isArray(data) ? data : data.data || []);
|
||||
setPolicies(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -18,31 +18,38 @@ export default function SettingsSection() {
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
sw.api.admin.vault.status().catch(() => null),
|
||||
]);
|
||||
const settings = s || {};
|
||||
const raw = s || {};
|
||||
const pol = raw.policies || {};
|
||||
const cfg_ = raw.settings || {};
|
||||
setCfg({
|
||||
allow_registration: settings.policies?.allow_registration === 'true',
|
||||
registration_default_state: settings.registration_default_state || 'pending',
|
||||
system_prompt: settings.system_prompt || '',
|
||||
default_model: settings.default_model || '',
|
||||
allow_user_byok: settings.policies?.allow_user_byok === 'true',
|
||||
allow_user_personas: settings.policies?.allow_user_personas === 'true',
|
||||
allow_kb_direct_access: settings.policies?.allow_kb_direct_access === 'true',
|
||||
banner_enabled: !!settings.banner?.enabled,
|
||||
banner_text: settings.banner?.text || '',
|
||||
banner_bg: settings.banner?.bg || '#007a33',
|
||||
banner_fg: settings.banner?.fg || '#ffffff',
|
||||
banner_position: settings.banner?.position || 'both',
|
||||
search_provider: settings.search_provider || 'duckduckgo',
|
||||
search_endpoint: settings.search_endpoint || '',
|
||||
search_api_key: settings.search_api_key || '',
|
||||
search_max_results: settings.search_max_results || 5,
|
||||
compaction_enabled: !!settings.auto_compaction_enabled,
|
||||
compaction_threshold: settings.compaction_threshold || 70,
|
||||
compaction_cooldown: settings.compaction_cooldown || 30,
|
||||
memory_extraction_enabled: !!settings.memory_extraction_enabled,
|
||||
memory_auto_approve: !!settings.memory_auto_approve,
|
||||
allow_registration: pol.allow_registration === 'true',
|
||||
registration_default_state: cfg_.registration_default_state || 'pending',
|
||||
system_prompt: cfg_.system_prompt || '',
|
||||
default_model: pol.default_model || '',
|
||||
allow_user_byok: pol.allow_user_byok === 'true',
|
||||
allow_user_personas: pol.allow_user_personas === 'true',
|
||||
allow_kb_direct_access: pol.allow_kb_direct_access === 'true' || pol.kb_direct_access === 'true',
|
||||
banner_enabled: !!cfg_.banner?.enabled,
|
||||
banner_text: cfg_.banner?.text || '',
|
||||
banner_bg: cfg_.banner?.bg || '#007a33',
|
||||
banner_fg: cfg_.banner?.fg || '#ffffff',
|
||||
banner_position: cfg_.banner?.position || 'both',
|
||||
message_enabled: !!cfg_.message?.enabled,
|
||||
message_text: cfg_.message?.text || '',
|
||||
message_variant: cfg_.message?.variant || 'info',
|
||||
footer_enabled: !!cfg_.footer?.enabled,
|
||||
footer_text: cfg_.footer?.text || '',
|
||||
search_provider: cfg_.search_provider || 'duckduckgo',
|
||||
search_endpoint: cfg_.search_endpoint || '',
|
||||
search_api_key: cfg_.search_api_key || '',
|
||||
search_max_results: cfg_.search_max_results || 5,
|
||||
compaction_enabled: !!cfg_.auto_compaction_enabled,
|
||||
compaction_threshold: cfg_.compaction_threshold || 70,
|
||||
compaction_cooldown: cfg_.compaction_cooldown || 30,
|
||||
memory_extraction_enabled: !!cfg_.memory_extraction_enabled,
|
||||
memory_auto_approve: !!cfg_.memory_auto_approve,
|
||||
});
|
||||
setModels(Array.isArray(m) ? m : m.models || m.data || []);
|
||||
setModels(m || []);
|
||||
setVault(v);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
@@ -75,6 +82,19 @@ export default function SettingsSection() {
|
||||
position: cfg.banner_position,
|
||||
}});
|
||||
|
||||
// Message bar
|
||||
await sw.api.admin.settings.update('message', { value: {
|
||||
enabled: cfg.message_enabled,
|
||||
text: cfg.message_text,
|
||||
variant: cfg.message_variant,
|
||||
}});
|
||||
|
||||
// Footer
|
||||
await sw.api.admin.settings.update('footer', { value: {
|
||||
enabled: cfg.footer_enabled,
|
||||
text: cfg.footer_text,
|
||||
}});
|
||||
|
||||
// Search
|
||||
await sw.api.admin.settings.update('search_provider', { value: cfg.search_provider });
|
||||
if (cfg.search_provider === 'searxng') {
|
||||
@@ -154,6 +174,32 @@ export default function SettingsSection() {
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="settings-section"><h3>Message Bar</h3>
|
||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.message_enabled} onChange=${e => set('message_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Show dismissible message bar</span></label>
|
||||
${cfg.message_enabled && html`
|
||||
<div style="margin-top:8px;">
|
||||
<div class="form-group"><label>Text</label><input value=${cfg.message_text} onInput=${e => set('message_text', e.target.value)} placeholder="System maintenance scheduled..." /></div>
|
||||
<div class="form-group"><label>Variant</label>
|
||||
<select value=${cfg.message_variant} onChange=${e => set('message_variant', e.target.value)}>
|
||||
<option value="info">Info</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="success">Success</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="settings-section"><h3>Footer</h3>
|
||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.footer_enabled} onChange=${e => set('footer_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Show footer bar</span></label>
|
||||
${cfg.footer_enabled && html`
|
||||
<div style="margin-top:8px;">
|
||||
<div class="form-group"><label>Text</label><input value=${cfg.footer_text} onInput=${e => set('footer_text', e.target.value)} placeholder="Powered by Chat Switchboard" /></div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="settings-section"><h3>Web Search</h3>
|
||||
<div class="form-group"><label>Provider</label>
|
||||
<select value=${cfg.search_provider} onChange=${e => set('search_provider', e.target.value)}>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function TasksSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.tasks.list();
|
||||
setTasks(Array.isArray(data) ? data : data.data || []);
|
||||
setTasks(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
@@ -50,7 +50,7 @@ export default function TasksSection() {
|
||||
}
|
||||
try {
|
||||
const data = await sw.api.tasks.runs(taskId);
|
||||
const runs = Array.isArray(data) ? data : data.data || [];
|
||||
const runs = data || [];
|
||||
setExpandedRuns(prev => ({ ...prev, [taskId]: runs }));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function TeamsSection() {
|
||||
const loadTeams = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.teams.list();
|
||||
setTeams(Array.isArray(data) ? data : data.data || []);
|
||||
setTeams(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
@@ -30,8 +30,8 @@ export default function TeamsSection() {
|
||||
sw.api.admin.teams.members(team.id),
|
||||
sw.api.admin.users.list(),
|
||||
]);
|
||||
setMembers(Array.isArray(m) ? m : m.data || []);
|
||||
const uList = Array.isArray(u) ? u : u.users || [];
|
||||
setMembers(m || []);
|
||||
const uList = u || [];
|
||||
setAllUsers(uList);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default function TeamsSection() {
|
||||
sw.toast('Member added', 'success');
|
||||
setShowAddMember(false);
|
||||
const m = await sw.api.admin.teams.members(detail.id);
|
||||
setMembers(Array.isArray(m) ? m : m.data || []);
|
||||
setMembers(m || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function UsageSection() {
|
||||
sw.api.admin.pricing.list().catch(() => []),
|
||||
]);
|
||||
setUsage(u);
|
||||
setPricing(Array.isArray(p) ? p : p.data || []);
|
||||
setPricing(p || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
export default function UsersSection() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -14,8 +12,8 @@ export default function UsersSection() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.users.list();
|
||||
setUsers(Array.isArray(data) ? data : data.users || []);
|
||||
const resp = await sw.api.admin.users.list();
|
||||
setUsers(resp.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function WorkflowsSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.workflows.list();
|
||||
setWorkflows(Array.isArray(data) ? data : data.data || []);
|
||||
setWorkflows(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
104
src/js/sw/surfaces/chat/channel-members-panel.js
Normal file
104
src/js/sw/surfaces/chat/channel-members-panel.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// ==========================================
|
||||
// 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>`}
|
||||
<//>`;
|
||||
}
|
||||
108
src/js/sw/surfaces/chat/channel-settings-panel.js
Normal file
108
src/js/sw/surfaces/chat/channel-settings-panel.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// ==========================================
|
||||
// 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>`}
|
||||
<//>`;
|
||||
}
|
||||
@@ -5,9 +5,14 @@
|
||||
// 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 { useRef, useEffect } = window.hooks;
|
||||
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"
|
||||
@@ -16,6 +21,22 @@ const PANEL_SVG = html`
|
||||
<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,
|
||||
@@ -28,10 +49,28 @@ const PANEL_SVG = html`
|
||||
* }} props
|
||||
*/
|
||||
export function ChatWorkspace({
|
||||
activeId, activeType, onChannelChange, onNewChat,
|
||||
sidebarOpen, onToggleSidebar, toolsButton,
|
||||
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(() => {
|
||||
@@ -40,6 +79,29 @@ export function ChatWorkspace({
|
||||
}
|
||||
}, [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">
|
||||
@@ -53,14 +115,115 @@ export function ChatWorkspace({
|
||||
</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.username || 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>
|
||||
<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>
|
||||
<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>`;
|
||||
}
|
||||
|
||||
@@ -32,13 +32,45 @@ function ChatSurface() {
|
||||
const tools = useTools();
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
|
||||
const _onNewChat = useCallback(() => {
|
||||
workspace.clearSelection();
|
||||
}, [workspace.clearSelection]);
|
||||
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.selectChat(id);
|
||||
}, [workspace.selectChat]);
|
||||
if (id) workspace.select(id, 'direct');
|
||||
else workspace.clearSelection();
|
||||
}, [workspace.select, workspace.clearSelection]);
|
||||
|
||||
const _toggleSidebar = useCallback(() => {
|
||||
workspace.setSidebarOpen(!workspace.sidebarOpen);
|
||||
@@ -76,9 +108,10 @@ function ChatSurface() {
|
||||
<${Sidebar}
|
||||
sidebar=${sidebar}
|
||||
activeId=${workspace.activeId}
|
||||
onSelectChat=${workspace.selectChat}
|
||||
onSelectChannel=${workspace.selectChannel}
|
||||
onSelect=${workspace.select}
|
||||
onNewChat=${_onNewChat}
|
||||
onCreateChannel=${_onCreateChannel}
|
||||
onDeleteChat=${_onDeleteChat}
|
||||
onCollapse=${_toggleSidebar} />`}
|
||||
|
||||
${/* Main workspace */''}
|
||||
@@ -86,11 +119,14 @@ function ChatSurface() {
|
||||
<${ChatWorkspace}
|
||||
activeId=${workspace.activeId}
|
||||
activeType=${workspace.activeType}
|
||||
channelType=${workspace.channelType}
|
||||
onChannelChange=${_onChannelChange}
|
||||
onNewChat=${_onNewChat}
|
||||
onCreateChannel=${_onCreateChannel}
|
||||
sidebarOpen=${workspace.sidebarOpen}
|
||||
onToggleSidebar=${_toggleSidebar}
|
||||
toolsButton=${toolsButton} />
|
||||
toolsButton=${toolsButton}
|
||||
sidebar=${sidebar} />
|
||||
<${ToolsPopup}
|
||||
open=${toolsOpen}
|
||||
onClose=${_closeTools}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Surface — SidebarChannels Component
|
||||
// ==========================================
|
||||
// Collapsible channels section: DMs and named channels.
|
||||
|
||||
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 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>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* channels: Array<{id: string, title: string, unread_count?: number}>,
|
||||
* activeId: string|null,
|
||||
* collapsed: boolean,
|
||||
* onToggle: () => void,
|
||||
* onSelect: (id: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function SidebarChannels({ channels, activeId, collapsed, onToggle, onSelect }) {
|
||||
if (!channels.length && collapsed) return null;
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__section">
|
||||
<button class="sw-chat-surface__section-header"
|
||||
onClick=${onToggle}
|
||||
aria-expanded=${!collapsed}>
|
||||
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
|
||||
${CHEVRON_SVG}
|
||||
</span>
|
||||
<span class="sw-chat-surface__section-label">Channels</span>
|
||||
${channels.length > 0 && html`
|
||||
<span class="sw-chat-surface__section-count">${channels.length}</span>`}
|
||||
</button>
|
||||
${!collapsed && html`
|
||||
<div class="sw-chat-surface__section-body" role="listbox">
|
||||
${channels.length === 0 && html`
|
||||
<div class="sw-chat-surface__empty">No channels</div>`}
|
||||
${channels.map(ch => html`
|
||||
<button key=${ch.id}
|
||||
class=${'sw-chat-surface__item' + (ch.id === activeId ? ' sw-chat-surface__item--active' : '')}
|
||||
onClick=${() => onSelect(ch.id)}
|
||||
role="option"
|
||||
aria-selected=${ch.id === activeId}
|
||||
title=${ch.title || 'Untitled'}>
|
||||
<span class="sw-chat-surface__item-icon">${HASH_SVG}</span>
|
||||
<span class="sw-chat-surface__item-title">${ch.title || 'Untitled'}</span>
|
||||
${(ch.unread_count || 0) > 0 && html`
|
||||
<span class="sw-chat-surface__unread">${ch.unread_count}</span>`}
|
||||
</button>`)}
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
// ==========================================
|
||||
// Chat Surface — SidebarChats Component
|
||||
// Chat Surface — SidebarItems Component
|
||||
// ==========================================
|
||||
// Personal AI chats with folder grouping and context menus.
|
||||
// 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;
|
||||
|
||||
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>`;
|
||||
// ── Icons ────────────────────────────────
|
||||
|
||||
const CHAT_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
@@ -18,6 +15,15 @@ const CHAT_SVG = html`
|
||||
<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">
|
||||
@@ -30,31 +36,34 @@ const DOTS_SVG = html`
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* chatsByFolder: Record<string, Array>,
|
||||
* folders: Array<{id: string, name: string}>,
|
||||
* activeId: string|null,
|
||||
* collapsed: boolean,
|
||||
* onToggle: () => void,
|
||||
* onSelect: (id: string) => void,
|
||||
* onRename: (id: string, title: string) => void,
|
||||
* onDelete: (id: string) => void,
|
||||
* onMoveToFolder: (chatId: string, folderId: string|null) => void,
|
||||
* onCreateFolder: (name: string) => void,
|
||||
* onRenameFolder: (id: string, name: string) => void,
|
||||
* onDeleteFolder: (id: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function SidebarChats({
|
||||
chatsByFolder, folders, activeId, collapsed, onToggle, onSelect,
|
||||
onRename, onDelete, onMoveToFolder, onCreateFolder,
|
||||
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);
|
||||
|
||||
// Close context menu on outside click or Escape
|
||||
// ── Context menu close ──────────────
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
function _close(e) {
|
||||
@@ -73,127 +82,290 @@ export function SidebarChats({
|
||||
const _openMenu = useCallback((e, type, item) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setContextMenu({ type, item, x: rect.right, y: rect.bottom });
|
||||
const scale = _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 = _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 = prompt('Rename:', item.title || item.name || '');
|
||||
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' : 'chat';
|
||||
if (confirm('Delete this ' + label + '?')) {
|
||||
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 = prompt('Folder name:');
|
||||
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, onCreateFolder, onRenameFolder, onDeleteFolder]);
|
||||
}, [onRename, onDelete, onMoveToFolder, onMoveFolderTo, onCreateFolder, onRenameFolder, onDeleteFolder]);
|
||||
|
||||
// Count total chats
|
||||
const totalChats = Object.values(chatsByFolder).reduce((sum, arr) => sum + arr.length, 0);
|
||||
const unfolderedChats = chatsByFolder[''] || [];
|
||||
// ── 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">
|
||||
<button class="sw-chat-surface__section-header"
|
||||
onClick=${onToggle}
|
||||
aria-expanded=${!collapsed}>
|
||||
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
|
||||
<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__section-label">Chats</span>
|
||||
${totalChats > 0 && html`
|
||||
<span class="sw-chat-surface__section-count">${totalChats}</span>`}
|
||||
</button>
|
||||
${!collapsed && html`
|
||||
<div class="sw-chat-surface__section-body" role="listbox">
|
||||
${/* Folders first */''}
|
||||
${folders.map(folder => {
|
||||
const folderChats = chatsByFolder[folder.id] || [];
|
||||
return html`
|
||||
<div key=${'f-' + folder.id} class="sw-chat-surface__folder">
|
||||
<div class="sw-chat-surface__folder-header">
|
||||
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
|
||||
<span class="sw-chat-surface__folder-name">${folder.name}</span>
|
||||
<span class="sw-chat-surface__folder-count">${folderChats.length}</span>
|
||||
<button class="sw-chat-surface__item-menu"
|
||||
onClick=${(e) => _openMenu(e, 'folder', folder)}
|
||||
title="Folder actions"
|
||||
aria-label="Folder actions">
|
||||
${DOTS_SVG}
|
||||
</button>
|
||||
</div>
|
||||
${folderChats.map(ch => html`
|
||||
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
|
||||
onSelect=${onSelect} onOpenMenu=${_openMenu}
|
||||
indent=${true} />`)}
|
||||
</div>`;
|
||||
})}
|
||||
${/* Unfoldered chats */''}
|
||||
${unfolderedChats.map(ch => html`
|
||||
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
|
||||
onSelect=${onSelect} onOpenMenu=${_openMenu} />`)}
|
||||
${totalChats === 0 && html`
|
||||
<div class="sw-chat-surface__empty">No chats yet</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 class="sw-chat-surface__ctx-danger"
|
||||
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
||||
`}
|
||||
</div>`}
|
||||
<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>`;
|
||||
}
|
||||
|
||||
function ChatItem({ chat, activeId, onSelect, onOpenMenu, indent }) {
|
||||
// ── ChannelItem ──────────────────────────
|
||||
|
||||
function ChannelItem({ channel, activeId, onSelect, onOpenMenu, onDragStart, indent }) {
|
||||
const icon = _iconForType(channel.type);
|
||||
return html`
|
||||
<button class=${'sw-chat-surface__item' + (chat.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
|
||||
onClick=${() => onSelect(chat.id)}
|
||||
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', chat); }}
|
||||
<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=${chat.id === activeId}
|
||||
title=${chat.title || 'Untitled'}>
|
||||
<span class="sw-chat-surface__item-icon">${CHAT_SVG}</span>
|
||||
<span class="sw-chat-surface__item-title">${chat.title || 'Untitled'}</span>
|
||||
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) => _stopAndOpen(e, onOpenMenu, 'chat', chat)}
|
||||
title="Chat actions"
|
||||
aria-label="Chat actions">
|
||||
onClick=${(e) => { e.stopPropagation(); onOpenMenu(e, 'chat', channel); }}
|
||||
title="Actions"
|
||||
aria-label="Actions">
|
||||
${DOTS_SVG}
|
||||
</button>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function _stopAndOpen(e, onOpenMenu, type, item) {
|
||||
e.stopPropagation();
|
||||
onOpenMenu(e, type, item);
|
||||
// Read the CSS scale transform on .surface-inner (set by appearance zoom).
|
||||
// Context menus use position:fixed which breaks under transforms,
|
||||
// so we divide getBoundingClientRect values by scale to compensate.
|
||||
function _getScale() {
|
||||
const el = document.getElementById('surfaceInner');
|
||||
if (!el) return 1;
|
||||
const t = getComputedStyle(el).transform;
|
||||
if (!t || t === 'none') return 1;
|
||||
const m = t.match(/matrix\(([^,]+)/);
|
||||
return m ? parseFloat(m[1]) || 1 : 1;
|
||||
}
|
||||
|
||||
// Folder count = direct children only (subfolders + channels in this folder).
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
// ==========================================
|
||||
// Chat Surface — Sidebar Component
|
||||
// ==========================================
|
||||
// Search bar, new chat, channels section, chats section with folders.
|
||||
// Search bar, new chat, unified channel list with folders.
|
||||
// Footer has UserMenu (avatar) — all navigation lives in the menu flyout.
|
||||
|
||||
import { SidebarChannels } from './sidebar-channels.js';
|
||||
import { SidebarChats } from './sidebar-chats.js';
|
||||
import { SidebarItems } from './sidebar-chats.js';
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useCallback } = window.hooks;
|
||||
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"
|
||||
@@ -32,31 +31,98 @@ const COLLAPSE_SVG = html`
|
||||
<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,
|
||||
* onSelectChat: (id: string) => void,
|
||||
* onSelectChannel: (id: string) => void,
|
||||
* onSelect: (id: string, type: string) => void,
|
||||
* onNewChat: () => void,
|
||||
* onCollapse: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function Sidebar({ sidebar, activeId, onSelectChat, onSelectChannel, onNewChat, onCollapse }) {
|
||||
export function Sidebar({ sidebar, activeId, onSelect, onNewChat, onCreateChannel, onDeleteChat, onCollapse }) {
|
||||
const [newMenuOpen, setNewMenuOpen] = 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') {
|
||||
const who = await window.sw.prompt('Username to DM:', '');
|
||||
if (who && who.trim()) onCreateChannel('dm', 'DM: ' + who.trim(), { participant: who.trim() });
|
||||
} else {
|
||||
const name = await window.sw.prompt((type === 'channel' ? 'Channel' : 'Group') + ' name:', '');
|
||||
if (name && name.trim()) onCreateChannel(type, name.trim());
|
||||
}
|
||||
}, [onNewChat, 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">
|
||||
<button class="sw-chat-surface__new-chat-btn"
|
||||
onClick=${onNewChat}
|
||||
title="New chat"
|
||||
aria-label="Start new chat">
|
||||
${PLUS_SVG}
|
||||
<span>New Chat</span>
|
||||
<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>
|
||||
<button onClick=${() => _newChannel('channel')}>
|
||||
<span class="sw-chat-surface__new-menu-icon">#</span> Channel
|
||||
</button>
|
||||
<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}
|
||||
@@ -79,23 +145,16 @@ export function Sidebar({ sidebar, activeId, onSelectChat, onSelectChannel, onNe
|
||||
|
||||
${/* Scrollable content */''}
|
||||
<div class="sw-chat-surface__sidebar-body">
|
||||
<${SidebarChannels}
|
||||
channels=${sidebar.channels}
|
||||
activeId=${activeId}
|
||||
collapsed=${sidebar.collapsed.channels}
|
||||
onToggle=${() => sidebar.toggleCollapsed('channels')}
|
||||
onSelect=${onSelectChannel} />
|
||||
|
||||
<${SidebarChats}
|
||||
chatsByFolder=${sidebar.chatsByFolder}
|
||||
<${SidebarItems}
|
||||
itemsByFolder=${sidebar.itemsByFolder}
|
||||
folders=${sidebar.folders}
|
||||
folderTree=${sidebar.folderTree}
|
||||
activeId=${activeId}
|
||||
collapsed=${sidebar.collapsed.chats}
|
||||
onToggle=${() => sidebar.toggleCollapsed('chats')}
|
||||
onSelect=${onSelectChat}
|
||||
onSelect=${onSelect}
|
||||
onRename=${sidebar.renameChat}
|
||||
onDelete=${sidebar.deleteChat}
|
||||
onDelete=${onDeleteChat || sidebar.deleteChat}
|
||||
onMoveToFolder=${sidebar.moveToFolder}
|
||||
onMoveFolderTo=${sidebar.moveFolderTo}
|
||||
onCreateFolder=${sidebar.createFolder}
|
||||
onRenameFolder=${sidebar.renameFolder}
|
||||
onDeleteFolder=${sidebar.deleteFolder} />
|
||||
|
||||
@@ -19,14 +19,9 @@ export function useTools() {
|
||||
|
||||
useEffect(() => {
|
||||
// Load available tools from API
|
||||
if (window.sw?.api?.admin?.tools?.list) {
|
||||
window.sw.api.admin.tools.list().then(resp => {
|
||||
setTools(resp?.data || resp || []);
|
||||
}).catch(() => {});
|
||||
} else if (window.sw?.api?.channels?.tools) {
|
||||
// Fallback — try the channels tools endpoint
|
||||
window.sw.api.channels.tools?.().then(resp => {
|
||||
setTools(resp?.data || resp || []);
|
||||
if (window.sw?.api?.tools?.list) {
|
||||
window.sw.api.tools.list().then(resp => {
|
||||
setTools(resp || []);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// ==========================================
|
||||
// Chat Surface — useSidebar Hook
|
||||
// ==========================================
|
||||
// Loads and manages sidebar data: channels (DMs + named),
|
||||
// personal chats, and folders. Subscribes to WS events
|
||||
// for live updates.
|
||||
// Loads and manages sidebar data: all channels (unified)
|
||||
// and folders. Subscribes to WS events for live updates.
|
||||
|
||||
const { useState, useEffect, useCallback, useMemo } = window.hooks;
|
||||
const { useState, useEffect, useCallback, useMemo, useRef } = window.hooks;
|
||||
|
||||
const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
|
||||
|
||||
@@ -15,12 +14,13 @@ const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
|
||||
export function useSidebar(opts = {}) {
|
||||
const { activeId } = opts;
|
||||
|
||||
const [channels, setChannels] = useState([]); // named channels + DMs
|
||||
const [chats, setChats] = useState([]); // personal AI chats (direct, no participants)
|
||||
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 () => {
|
||||
@@ -31,17 +31,19 @@ export function useSidebar(opts = {}) {
|
||||
? window.sw.api.folders.list().catch(() => [])
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [chResp, dmResp, fResp] = await Promise.all([
|
||||
// Named channels (group chats, DMs with participants)
|
||||
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'channel' }).catch(() => []),
|
||||
// Personal AI chats
|
||||
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'direct' }).catch(() => []),
|
||||
// Folders
|
||||
const [allResp, fResp] = await Promise.all([
|
||||
window.sw.api.channels.list({ page: 1, per_page: 200, types: 'direct,dm,group,channel' }).catch(() => []),
|
||||
foldersPromise,
|
||||
]);
|
||||
|
||||
setChannels(_extract(chResp));
|
||||
setChats(_extract(dmResp));
|
||||
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 (_) {}
|
||||
|
||||
@@ -50,6 +52,16 @@ export function useSidebar(opts = {}) {
|
||||
|
||||
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;
|
||||
@@ -70,31 +82,42 @@ export function useSidebar(opts = {}) {
|
||||
}, [reload]);
|
||||
|
||||
// ── Search filter ───────────────────────
|
||||
const filteredChannels = useMemo(() => {
|
||||
if (!search) return channels;
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return allChannels;
|
||||
const q = search.toLowerCase();
|
||||
return channels.filter(c => (c.title || '').toLowerCase().includes(q));
|
||||
}, [channels, search]);
|
||||
return allChannels.filter(c => (c.title || '').toLowerCase().includes(q));
|
||||
}, [allChannels, search]);
|
||||
|
||||
const filteredChats = useMemo(() => {
|
||||
if (!search) return chats;
|
||||
const q = search.toLowerCase();
|
||||
return chats.filter(c => (c.title || '').toLowerCase().includes(q));
|
||||
}, [chats, search]);
|
||||
|
||||
// Group chats by folder
|
||||
const chatsByFolder = useMemo(() => {
|
||||
// Group all channels by folder
|
||||
const itemsByFolder = useMemo(() => {
|
||||
const map = { '': [] };
|
||||
for (const f of (folders || [])) map[f.id] = [];
|
||||
for (const c of filteredChats) {
|
||||
for (const c of filtered) {
|
||||
const fid = c.folder_id || '';
|
||||
if (!map[fid]) map[fid] = [];
|
||||
map[fid].push(c);
|
||||
}
|
||||
return map;
|
||||
}, [filteredChats, folders]);
|
||||
}, [filtered, folders]);
|
||||
|
||||
// ── Collapse sections ───────────────────
|
||||
// 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] };
|
||||
@@ -104,9 +127,15 @@ export function useSidebar(opts = {}) {
|
||||
}, []);
|
||||
|
||||
// ── CRUD helpers ────────────────────────
|
||||
const createFolder = useCallback(async (name) => {
|
||||
const createFolder = useCallback(async (name, parentId) => {
|
||||
if (!window.sw?.api?.folders?.create) return;
|
||||
await window.sw.api.folders.create(name);
|
||||
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]);
|
||||
|
||||
@@ -136,10 +165,10 @@ export function useSidebar(opts = {}) {
|
||||
}, [reload]);
|
||||
|
||||
return {
|
||||
channels: filteredChannels,
|
||||
chats: filteredChats,
|
||||
chatsByFolder,
|
||||
items: filtered,
|
||||
itemsByFolder,
|
||||
folders,
|
||||
folderTree,
|
||||
search,
|
||||
setSearch,
|
||||
collapsed,
|
||||
@@ -151,6 +180,7 @@ export function useSidebar(opts = {}) {
|
||||
renameChat,
|
||||
deleteChat,
|
||||
moveToFolder,
|
||||
moveFolderTo,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
};
|
||||
|
||||
@@ -24,42 +24,47 @@ export function useWorkspace() {
|
||||
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 }));
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType, channelType }));
|
||||
} catch (_) {}
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}, [activeId, activeType]);
|
||||
}, [activeId, activeType, channelType]);
|
||||
|
||||
const selectChat = useCallback((id) => {
|
||||
const select = useCallback((id, chType) => {
|
||||
setActiveId(id);
|
||||
setActiveType('chat');
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const selectChannel = useCallback((id) => {
|
||||
setActiveId(id);
|
||||
setActiveType('channel');
|
||||
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,
|
||||
@@ -71,8 +76,8 @@ function _loadStored() {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw);
|
||||
return { id: obj.id || null, type: obj.type || null };
|
||||
return { id: obj.id || null, type: obj.type || null, channelType: obj.channelType || null };
|
||||
}
|
||||
} catch (_) {}
|
||||
return { id: null, type: null };
|
||||
return { id: null, type: null, channelType: null };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// v0.37.11
|
||||
|
||||
import { NotesPane } from '../../components/notes-pane/index.js';
|
||||
import { NotificationBell } from '../../shell/notification-bell.js';
|
||||
|
||||
const html = window.html;
|
||||
|
||||
@@ -41,7 +42,9 @@ export function NotesWorkspace({
|
||||
${PANEL_SVG}
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="sw-notes-surface__workspace-header-right" />
|
||||
<div class="sw-notes-surface__workspace-header-right">
|
||||
<${NotificationBell} />
|
||||
</div>
|
||||
</div>
|
||||
<${NotesPane}
|
||||
standalone=${false}
|
||||
|
||||
@@ -33,9 +33,22 @@ export function AppearanceSection() {
|
||||
p.msgFont = msgFont;
|
||||
localStorage.setItem('cs-appearance', JSON.stringify(p));
|
||||
|
||||
// Apply zoom + font size
|
||||
document.documentElement.style.zoom = scale !== 100 ? (scale / 100) : '';
|
||||
document.documentElement.style.setProperty('--msg-font-size', msgFont + 'px');
|
||||
// Apply scale to content area only (not shell/nav)
|
||||
const inner = document.getElementById('surfaceInner');
|
||||
if (inner) {
|
||||
if (scale !== 100) {
|
||||
const s = scale / 100;
|
||||
inner.style.transform = `scale(${s})`;
|
||||
inner.style.transformOrigin = 'top left';
|
||||
inner.style.width = (100 / s) + '%';
|
||||
inner.style.height = (100 / s) + '%';
|
||||
} else {
|
||||
inner.style.transform = '';
|
||||
inner.style.width = '';
|
||||
inner.style.height = '';
|
||||
}
|
||||
}
|
||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||
|
||||
sw.emit('toast', { message: 'Appearance saved', variant: 'success' });
|
||||
}, [scale, msgFont]);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function GeneralSection() {
|
||||
temperature: sObj.temperature ?? 0.7,
|
||||
show_thinking: sObj.show_thinking || false,
|
||||
});
|
||||
const modelList = (m?.data || m || []).filter(x => !x.hidden);
|
||||
const modelList = (m.data || []).filter(x => !x.hidden);
|
||||
setModels(modelList);
|
||||
|
||||
// Set initial max hint
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function GitKeysSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.git.credentials.list();
|
||||
setKeys(Array.isArray(data) ? data : data.data || []);
|
||||
setKeys(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -25,8 +25,8 @@ export default function KnowledgeSection() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.knowledge.list();
|
||||
setKbs(Array.isArray(data) ? data : data.data || []);
|
||||
const resp = await sw.api.knowledge.list();
|
||||
setKbs(resp.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
@@ -37,7 +37,7 @@ export default function KnowledgeSection() {
|
||||
setDetail(kb);
|
||||
try {
|
||||
const d = await sw.api.knowledge.documents(kb.id);
|
||||
setDocs(Array.isArray(d) ? d : d.data || []);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function KnowledgeSection() {
|
||||
await sw.api.knowledge.upload(detail.id, file);
|
||||
sw.toast('Document uploaded', 'success');
|
||||
const d = await sw.api.knowledge.documents(detail.id);
|
||||
setDocs(Array.isArray(d) ? d : d.data || []);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
};
|
||||
input.click();
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function MemorySection() {
|
||||
sw.api.memory.list(),
|
||||
sw.api.memory.count().catch(() => null),
|
||||
]);
|
||||
setMemories(Array.isArray(data) ? data : data.data || []);
|
||||
setMemories(data || []);
|
||||
if (c != null) setCount(typeof c === 'number' ? c : c.count || 0);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
|
||||
@@ -20,10 +20,10 @@ export function ModelsSection() {
|
||||
sw.api.models.enabled(),
|
||||
sw.api.models.preferences(),
|
||||
]);
|
||||
setModels(m?.data || m || []);
|
||||
setModels(m.data || []);
|
||||
// Build prefs map: model_id → { hidden }
|
||||
const map = {};
|
||||
(p?.data || p || []).forEach(x => { map[x.model_id] = x; });
|
||||
(p || []).forEach(x => { map[x.model_id] = x; });
|
||||
setPrefs(map);
|
||||
} catch (e) {
|
||||
console.warn('[settings/models]', e.message);
|
||||
|
||||
@@ -16,7 +16,7 @@ export function PersonasSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.personas.list();
|
||||
setPersonas(resp?.data || resp || []);
|
||||
setPersonas(resp || []);
|
||||
} catch (e) {
|
||||
setPersonas([]);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ProvidersSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.providers.list();
|
||||
setProviders(resp?.configs || resp?.data || resp || []);
|
||||
setProviders(resp || []);
|
||||
} catch (e) {
|
||||
setProviders([]);
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ export function RolesSection() {
|
||||
sw.api.models.enabled(),
|
||||
sw.api.profile.settings(),
|
||||
]);
|
||||
const provs = (provResp?.configs || provResp?.data || [])
|
||||
const provs = (provResp || [])
|
||||
.filter(c => c.scope === 'personal');
|
||||
setProviders(provs);
|
||||
setModels(modResp?.data || modResp || []);
|
||||
setModels(modResp.data || []);
|
||||
|
||||
// Extract role overrides from settings
|
||||
const overrides = settings?.model_roles || {};
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function TasksSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.tasks.list();
|
||||
setTasks(Array.isArray(data) ? data : data.data || []);
|
||||
setTasks(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -11,7 +11,7 @@ export function TeamsSection() {
|
||||
|
||||
useEffect(() => {
|
||||
sw.api.teams.mine().then(resp => {
|
||||
setTeams(resp?.data || resp || []);
|
||||
setTeams(resp || []);
|
||||
}).catch(() => setTeams([]));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function WorkflowsSection() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.workflows.list();
|
||||
setWorkflows(Array.isArray(data) ? data : data.data || []);
|
||||
setWorkflows(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function GroupsSection({ teamId }) {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.groups(teamId);
|
||||
setGroups(Array.isArray(data) ? data : data.data || []);
|
||||
setGroups(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function KnowledgeSection({ teamId }) {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.knowledgeBases(teamId);
|
||||
setKbs(Array.isArray(data) ? data : data.data || []);
|
||||
setKbs(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function MembersSection({ teamId }) {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.members(teamId);
|
||||
setMembers(Array.isArray(data) ? data : data.data || []);
|
||||
setMembers(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
@@ -23,8 +23,8 @@ export default function MembersSection({ teamId }) {
|
||||
async function openAdd() {
|
||||
setShowAdd(true);
|
||||
try {
|
||||
const data = await sw.api.admin.users.list();
|
||||
setUsers(Array.isArray(data) ? data : data.users || data.data || []);
|
||||
const resp = await sw.api.admin.users.list();
|
||||
setUsers(resp.data || []);
|
||||
} catch {
|
||||
// non-admin may not have access — leave empty
|
||||
setUsers([]);
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function PersonasSection({ teamId }) {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.personas(teamId);
|
||||
setPersonas(Array.isArray(data) ? data : data.data || []);
|
||||
setPersonas(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
@@ -16,8 +16,8 @@ export default function ProvidersSection({ teamId }) {
|
||||
sw.api.teams.providers(teamId),
|
||||
sw.api.admin.providers.types().catch(() => ({ types: [] })),
|
||||
]);
|
||||
setConfigs(Array.isArray(c) ? c : c.data || []);
|
||||
setProviderTypes(t.types || t.data || []);
|
||||
setConfigs(c.data || []);
|
||||
setProviderTypes(t || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function TasksSection({ teamId }) {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.tasks(teamId);
|
||||
setTasks(Array.isArray(data) ? data : data.data || []);
|
||||
setTasks(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function WorkflowsSection({ teamId }) {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.workflows(teamId);
|
||||
setWorkflows(Array.isArray(data) ? data : data.data || []);
|
||||
setWorkflows(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
@@ -27,7 +27,7 @@ export default function WorkflowsSection({ teamId }) {
|
||||
setEditing(wf);
|
||||
try {
|
||||
const detail = await sw.api.teams.workflows(teamId);
|
||||
const match = (Array.isArray(detail) ? detail : detail.data || []).find(w => w.id === wf.id);
|
||||
const match = (detail || []).find(w => w.id === wf.id);
|
||||
setStages(match?.stages || wf.stages || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
|
||||
}
|
||||
|
||||
@@ -1,697 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK (switchboard-sdk.js)
|
||||
// ==========================================
|
||||
// v0.28.5: Composition layer over platform globals. Surface and
|
||||
// extension authors consume this instead of hunting through 15 JS files.
|
||||
// v0.37.13: Scorched Earth III — removed dead wrappers (FileTree,
|
||||
// CodeEditor, UserMenu, NotePanel, PaneContainer, PaneLayout),
|
||||
// inlined Theme via Preact SDK module, inlined esc/modal helpers.
|
||||
//
|
||||
// Usage:
|
||||
// const sw = Switchboard.init();
|
||||
// sw.api.get('/api/v1/channels').then(console.log);
|
||||
// sw.on('chat.message.*', (payload) => { ... });
|
||||
// sw.pipe.render(50, (ctx) => { ... });
|
||||
//
|
||||
// Load order: after sb.js + events.js, before extensions.js.
|
||||
//
|
||||
// Exports: window.Switchboard, window.sw (convenience alias)
|
||||
// ==========================================
|
||||
|
||||
import { createTheme } from './sw/sdk/theme.js';
|
||||
|
||||
const Switchboard = {
|
||||
|
||||
_instance: null,
|
||||
|
||||
/**
|
||||
* Initialize the SDK. Idempotent — returns the same instance on
|
||||
* subsequent calls. Call early in the boot sequence (app.js init()).
|
||||
*
|
||||
* @param {object} [opts] — Reserved for future use ({ mount } etc.)
|
||||
* @returns {object} sw — The SDK instance
|
||||
*/
|
||||
init(opts) {
|
||||
if (this._instance) return this._instance;
|
||||
|
||||
const _opts = opts || {};
|
||||
|
||||
// ── Build the sw instance ────────────────────────────
|
||||
|
||||
const sw = Object.create(null);
|
||||
|
||||
// ── Flyout Positioning Helper ──────────────────────
|
||||
// Escapes overflow:hidden/auto ancestors by switching to position:fixed.
|
||||
|
||||
function _hasClippingAncestor(el) {
|
||||
let node = el.parentElement;
|
||||
while (node && node !== document.body) {
|
||||
const ov = getComputedStyle(node).overflow;
|
||||
if (ov === 'hidden' || ov === 'auto' || ov === 'scroll') return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _positionFlyout(flyoutEl, anchorEl, position) {
|
||||
if (!_hasClippingAncestor(flyoutEl)) {
|
||||
flyoutEl.removeAttribute('data-fixed');
|
||||
flyoutEl.style.top = '';
|
||||
flyoutEl.style.right = '';
|
||||
flyoutEl.style.bottom = '';
|
||||
flyoutEl.style.left = '';
|
||||
return;
|
||||
}
|
||||
flyoutEl.setAttribute('data-fixed', '');
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
flyoutEl.style.left = '';
|
||||
if (position === 'up') {
|
||||
flyoutEl.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
|
||||
flyoutEl.style.top = '';
|
||||
} else {
|
||||
flyoutEl.style.top = (rect.bottom + 4) + 'px';
|
||||
flyoutEl.style.bottom = '';
|
||||
}
|
||||
flyoutEl.style.right = Math.max(0, window.innerWidth - rect.right) + 'px';
|
||||
}
|
||||
|
||||
// ── Identity ─────────────────────────────────────────
|
||||
|
||||
Object.defineProperty(sw, 'user', {
|
||||
get() {
|
||||
if (typeof API === 'undefined') return null;
|
||||
const u = API.user;
|
||||
if (!u) return null;
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
display_name: u.display_name || u.username,
|
||||
email: u.email || null,
|
||||
role: u.role || 'user',
|
||||
avatar: u.avatar || null,
|
||||
};
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(sw, 'isAdmin', {
|
||||
get() {
|
||||
return typeof API !== 'undefined' && API.isAdmin === true;
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
// ── REST Client ──────────────────────────────────────
|
||||
|
||||
sw.api = {
|
||||
/**
|
||||
* GET request. Returns parsed JSON.
|
||||
* @param {string} path — API path (e.g. '/api/v1/channels')
|
||||
* @param {object} [opts] — { signal }
|
||||
*/
|
||||
async get(path, opts) {
|
||||
return API._get(path, opts?.signal);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST request. Returns parsed JSON.
|
||||
* @param {string} path
|
||||
* @param {object} body
|
||||
* @param {object} [opts] — { signal }
|
||||
*/
|
||||
async post(path, body, opts) {
|
||||
return API._post(path, body, false, opts?.signal);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT request. Returns parsed JSON.
|
||||
* @param {string} path
|
||||
* @param {object} body
|
||||
* @param {object} [opts] — { signal }
|
||||
*/
|
||||
async put(path, body, opts) {
|
||||
return API._put(path, body, opts?.signal);
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE request. Returns parsed JSON (or empty on 204).
|
||||
* @param {string} path
|
||||
* @param {object} [opts] — { signal }
|
||||
*/
|
||||
async del(path, opts) {
|
||||
return API._del(path, opts?.signal);
|
||||
},
|
||||
|
||||
/**
|
||||
* Streaming POST. Returns raw Response for SSE consumption.
|
||||
* Same contract as API.streamCompletion but generic.
|
||||
* @param {string} path
|
||||
* @param {object} body
|
||||
* @param {AbortSignal} [signal]
|
||||
*/
|
||||
async stream(path, body, signal) {
|
||||
const BASE = window.__BASE__ || '';
|
||||
let resp = await fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
headers: API._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
if (await API._handle401()) {
|
||||
resp = await fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
headers: API._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Events ───────────────────────────────────────────
|
||||
|
||||
sw.on = function (label, fn) {
|
||||
return Events.on(label, fn);
|
||||
};
|
||||
|
||||
sw.once = function (label, fn) {
|
||||
return Events.once(label, fn);
|
||||
};
|
||||
|
||||
sw.off = function (label, fn) {
|
||||
Events.off(label, fn);
|
||||
};
|
||||
|
||||
sw.emit = function (label, payload, opts) {
|
||||
Events.emit(label, payload, opts);
|
||||
};
|
||||
|
||||
// ── Theme (v0.37.13: uses Preact SDK module directly) ──
|
||||
|
||||
const _theme = createTheme((label, payload, opts) => Events.emit(label, payload, opts));
|
||||
sw.theme = _theme;
|
||||
|
||||
// ── UI Primitives ────────────────────────────────────
|
||||
|
||||
sw.toast = function (message, type) {
|
||||
if (typeof UI !== 'undefined' && UI.toast) {
|
||||
UI.toast(message, type || 'success');
|
||||
}
|
||||
};
|
||||
|
||||
sw.confirm = function (message, opts) {
|
||||
// v0.37.13: delegate to debug.js's showConfirm if loaded, else native
|
||||
if (typeof showConfirm === 'function') return showConfirm(message, opts);
|
||||
return Promise.resolve(window.confirm(message));
|
||||
};
|
||||
|
||||
sw.modal = {
|
||||
open(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.add('active');
|
||||
},
|
||||
close(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
},
|
||||
};
|
||||
|
||||
// ── UI Factories ─────────────────────────────────────
|
||||
// Composable building blocks for surfaces and packages.
|
||||
// Every factory returns a plain object with the DOM element + control methods.
|
||||
|
||||
/**
|
||||
* Create a flyout menu (dropdown with action items).
|
||||
* @param {HTMLElement} anchor — element the menu attaches to
|
||||
* @param {object} opts — { items: [{label, icon?, danger?, onClick}], position: 'down'|'up' }
|
||||
* @returns {{ el, open, close, toggle, destroy }}
|
||||
*/
|
||||
sw.menu = function (anchor, opts) {
|
||||
const _opts = opts || {};
|
||||
const items = _opts.items || [];
|
||||
const pos = _opts.position || 'down';
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'sw-menu-wrap';
|
||||
wrap.style.position = 'relative';
|
||||
|
||||
const flyout = document.createElement('div');
|
||||
flyout.className = 'sw-menu-flyout';
|
||||
flyout.dataset.position = pos;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.divider) {
|
||||
const hr = document.createElement('hr');
|
||||
hr.className = 'flyout-divider';
|
||||
flyout.appendChild(hr);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'flyout-item' + (item.danger ? ' flyout-danger' : '');
|
||||
const d = document.createElement('div'); d.textContent = item.label;
|
||||
btn.innerHTML = (item.icon || '') + '<span>' + d.innerHTML + '</span>';
|
||||
btn.addEventListener('click', () => {
|
||||
result.close();
|
||||
if (item.onClick) item.onClick();
|
||||
});
|
||||
flyout.appendChild(btn);
|
||||
});
|
||||
|
||||
wrap.appendChild(flyout);
|
||||
anchor.parentElement.insertBefore(wrap, anchor.nextSibling);
|
||||
wrap.insertBefore(anchor, flyout);
|
||||
|
||||
let _open = false;
|
||||
const result = {
|
||||
el: wrap,
|
||||
flyoutEl: flyout,
|
||||
open() { _positionFlyout(flyout, anchor, pos); flyout.classList.add('open'); _open = true; },
|
||||
close() { flyout.classList.remove('open'); _open = false; },
|
||||
toggle() { _open ? result.close() : result.open(); },
|
||||
isOpen() { return _open; },
|
||||
destroy() { wrap.remove(); },
|
||||
};
|
||||
|
||||
anchor.addEventListener('click', (e) => { e.stopPropagation(); result.toggle(); });
|
||||
document.addEventListener('click', (e) => { if (_open && !e.target.closest('.sw-menu-wrap')) result.close(); });
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a dropdown selector.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { items: [{value, label}], value?, onChange?, placeholder? }
|
||||
* @returns {{ el, getValue, setValue, setItems, destroy }}
|
||||
*/
|
||||
sw.dropdown = function (container, opts) {
|
||||
const _opts = opts || {};
|
||||
const el = document.createElement('select');
|
||||
el.className = 'sw-dropdown';
|
||||
|
||||
function _render(items) {
|
||||
el.innerHTML = '';
|
||||
if (_opts.placeholder) {
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = _opts.placeholder;
|
||||
ph.disabled = true;
|
||||
ph.selected = !_opts.value;
|
||||
el.appendChild(ph);
|
||||
}
|
||||
(items || []).forEach(item => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.value;
|
||||
opt.textContent = item.label || item.value;
|
||||
if (item.value === _opts.value) opt.selected = true;
|
||||
el.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
_render(_opts.items);
|
||||
container.appendChild(el);
|
||||
|
||||
if (_opts.onChange) el.addEventListener('change', () => _opts.onChange(el.value));
|
||||
|
||||
return {
|
||||
el,
|
||||
getValue() { return el.value; },
|
||||
setValue(v) { el.value = v; },
|
||||
setItems(items) { const cur = el.value; _render(items); el.value = cur; },
|
||||
destroy() { el.remove(); },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a toolbar (horizontal button row).
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { items: [{label, icon?, title?, onClick?, id?}], class? }
|
||||
* @returns {{ el, getButton, destroy }}
|
||||
*/
|
||||
sw.toolbar = function (container, opts) {
|
||||
const _opts = opts || {};
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sw-toolbar' + (_opts.class ? ' ' + _opts.class : '');
|
||||
const buttons = new Map();
|
||||
|
||||
(_opts.items || []).forEach(item => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-small';
|
||||
btn.title = item.title || item.label || '';
|
||||
btn.innerHTML = (item.icon || '') + (item.label || '');
|
||||
if (item.onClick) btn.addEventListener('click', item.onClick);
|
||||
if (item.id) buttons.set(item.id, btn);
|
||||
el.appendChild(btn);
|
||||
});
|
||||
|
||||
container.appendChild(el);
|
||||
|
||||
return {
|
||||
el,
|
||||
getButton(id) { return buttons.get(id); },
|
||||
destroy() { el.remove(); },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tabbed content container.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { tabs: [{id, label}], activeTab?, onActivate? }
|
||||
* @returns {{ el, activateTab, getPanel, activeTab, destroy }}
|
||||
*/
|
||||
sw.tabs = function (container, opts) {
|
||||
const _opts = opts || {};
|
||||
const tabDefs = _opts.tabs || [];
|
||||
let _active = _opts.activeTab || (tabDefs[0]?.id);
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sw-tabs';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'sw-tabs-bar';
|
||||
el.appendChild(bar);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'sw-tabs-content';
|
||||
el.appendChild(content);
|
||||
|
||||
const tabMap = new Map();
|
||||
|
||||
tabDefs.forEach(td => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'sw-tab-btn' + (td.id === _active ? ' sw-tab-btn--active' : '');
|
||||
btn.textContent = td.label;
|
||||
btn.addEventListener('click', () => result.activateTab(td.id));
|
||||
bar.appendChild(btn);
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'sw-tab-panel';
|
||||
panel.style.display = td.id === _active ? '' : 'none';
|
||||
content.appendChild(panel);
|
||||
|
||||
tabMap.set(td.id, { btn, panel });
|
||||
});
|
||||
|
||||
container.appendChild(el);
|
||||
|
||||
const result = {
|
||||
el,
|
||||
activateTab(id) {
|
||||
_active = id;
|
||||
tabMap.forEach((t, tid) => {
|
||||
t.btn.classList.toggle('sw-tab-btn--active', tid === id);
|
||||
t.panel.style.display = tid === id ? '' : 'none';
|
||||
});
|
||||
if (_opts.onActivate) _opts.onActivate(id);
|
||||
},
|
||||
getPanel(id) { return tabMap.get(id)?.panel || null; },
|
||||
get activeTab() { return _active; },
|
||||
destroy() { el.remove(); },
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// ── Components ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a ChatPane instance in a container element.
|
||||
* Wraps ChatPane.create() with SDK-aware defaults.
|
||||
*
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { channelId, standalone }
|
||||
* @returns {object} ChatPane instance (renderMessages, destroy, etc.)
|
||||
*/
|
||||
sw.chat = function (container, opts) {
|
||||
// v0.37.8: Prefer Preact ChatPane kit when new SDK is loaded
|
||||
if (window.sw?.chatPane) {
|
||||
return window.sw.chatPane(container, opts || {});
|
||||
}
|
||||
// Legacy fallback (DOM-binding ChatPane.mount)
|
||||
if (typeof ChatPane === 'undefined') {
|
||||
console.error('[Switchboard] ChatPane not available');
|
||||
return null;
|
||||
}
|
||||
return ChatPane.mount(container, opts || {});
|
||||
};
|
||||
|
||||
// v0.37.13: sw.notes, sw.panels, sw.fileTree, sw.codeEditor,
|
||||
// sw.layout removed (Scorched Earth III — globals deleted).
|
||||
|
||||
// ── Workflow ─────────────────────────────────────────
|
||||
// v0.30.2: Workflow stage surface management.
|
||||
|
||||
/**
|
||||
* Mount a workflow stage surface into a container.
|
||||
* Delegates to WorkflowSurfaces registry.
|
||||
*
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { channelId, stageMode, surfacePkgId?, formTemplate?, ... }
|
||||
* @returns {object|null} — mounted surface instance
|
||||
*/
|
||||
sw.workflow = function (container, opts) {
|
||||
if (typeof WorkflowSurfaces === 'undefined') {
|
||||
console.error('[Switchboard] WorkflowSurfaces not available');
|
||||
return null;
|
||||
}
|
||||
const _opts = opts || {};
|
||||
return WorkflowSurfaces.mount(container, _opts.stageMode || 'chat_only', _opts);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a custom stage surface.
|
||||
* @param {string} name — surface identifier
|
||||
* @param {Function} factory — (container, ctx) => { mount(), unmount() }
|
||||
*/
|
||||
sw.workflow.registerSurface = function (name, factory) {
|
||||
if (typeof WorkflowSurfaces !== 'undefined') {
|
||||
WorkflowSurfaces.register(name, factory);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get workflow instance context (status, stage data, etc.).
|
||||
* @param {string} channelId
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
sw.workflow.getContext = async function (channelId) {
|
||||
return sw.api.get('/api/v1/channels/' + channelId + '/workflow/status');
|
||||
};
|
||||
|
||||
/**
|
||||
* Advance a workflow stage.
|
||||
* @param {string} channelId
|
||||
* @param {object} [data] — stage data to merge
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
sw.workflow.advance = async function (channelId, data) {
|
||||
return sw.api.post('/api/v1/channels/' + channelId + '/workflow/advance', { data: data || {} });
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject a workflow stage (go back).
|
||||
* @param {string} channelId
|
||||
* @param {string} reason
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
sw.workflow.reject = async function (channelId, reason) {
|
||||
return sw.api.post('/api/v1/channels/' + channelId + '/workflow/reject', { reason: reason });
|
||||
};
|
||||
|
||||
// ── Pipe/Filter Pipeline ─────────────────────────────
|
||||
// CS1: registration + introspection. CS2: execution engine
|
||||
// wired into chat.js, ui-core.js, ui-format.js.
|
||||
|
||||
const _chains = {
|
||||
pre: [], // { priority, fn, scope, source, stats }
|
||||
stream: [],
|
||||
render: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a filter into a pipe stage.
|
||||
* @param {string} stage — 'pre' | 'stream' | 'render'
|
||||
* @param {number} priority — lower runs first
|
||||
* @param {Function} fn — filter function
|
||||
* @param {object} [opts] — { scope: { channelType: [...] }, source: string }
|
||||
*/
|
||||
function _registerFilter(stage, priority, fn, opts) {
|
||||
if (typeof fn !== 'function') {
|
||||
console.error(`[Switchboard] pipe.${stage}: filter must be a function`);
|
||||
return;
|
||||
}
|
||||
const chain = _chains[stage];
|
||||
if (!chain) {
|
||||
console.error(`[Switchboard] pipe.${stage}: unknown stage`);
|
||||
return;
|
||||
}
|
||||
const _opts = opts || {};
|
||||
const entry = {
|
||||
priority: priority,
|
||||
fn: fn,
|
||||
scope: _opts.scope || null,
|
||||
source: _opts.source || _inferSource(),
|
||||
stats: { calls: 0, totalMs: 0, errors: 0 },
|
||||
};
|
||||
|
||||
// Check for duplicate (same source + priority) — warn but allow
|
||||
const dup = chain.find(e => e.source === entry.source && e.priority === entry.priority);
|
||||
if (dup) {
|
||||
console.warn(`[Switchboard] pipe.${stage}: duplicate registration (source=${entry.source}, priority=${priority})`);
|
||||
}
|
||||
|
||||
chain.push(entry);
|
||||
chain.sort((a, b) => a.priority - b.priority || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the source label from the call stack.
|
||||
* Tries to extract extension ID from the script path.
|
||||
*/
|
||||
function _inferSource() {
|
||||
try {
|
||||
const stack = new Error().stack || '';
|
||||
// Look for extensions/{id}/ or ext:: patterns
|
||||
const extMatch = stack.match(/extensions\/([^/]+)\//);
|
||||
if (extMatch) return extMatch[1];
|
||||
} catch (_) { /* best effort */ }
|
||||
return 'anonymous';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a filter chain against a context. Returns modified context or null.
|
||||
* Sync-only in v0.28.5 — filters must not return Promises.
|
||||
*
|
||||
* @param {string} stage — chain name
|
||||
* @param {object} ctx — context object (mutated in place)
|
||||
* @returns {object|null} — modified context or null (halted)
|
||||
*/
|
||||
function _runChain(stage, ctx) {
|
||||
const chain = _chains[stage];
|
||||
if (!chain || chain.length === 0) return ctx;
|
||||
|
||||
const channelType = ctx.channel?.type || null;
|
||||
|
||||
for (const entry of chain) {
|
||||
// Scope check: skip if filter's channelType list doesn't include this channel
|
||||
if (entry.scope?.channelType) {
|
||||
if (!channelType || !entry.scope.channelType.includes(channelType)) {
|
||||
continue; // scoped out — zero overhead, no call, no stats
|
||||
}
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const result = entry.fn(ctx);
|
||||
entry.stats.calls++;
|
||||
entry.stats.totalMs += performance.now() - t0;
|
||||
|
||||
if (result === null || result === undefined) {
|
||||
// Halt the chain
|
||||
return null;
|
||||
}
|
||||
ctx = result;
|
||||
} catch (e) {
|
||||
entry.stats.calls++;
|
||||
entry.stats.errors++;
|
||||
entry.stats.totalMs += performance.now() - t0;
|
||||
console.error(`[Switchboard] pipe.${stage} filter '${entry.source}' (p=${entry.priority}) threw:`, e);
|
||||
// Continue chain — error isolation
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
sw.pipe = {
|
||||
/**
|
||||
* Register a pre-send filter.
|
||||
* @param {number} priority
|
||||
* @param {Function} fn — (PreSendContext) => PreSendContext | null
|
||||
* @param {object} [opts] — { scope, source }
|
||||
*/
|
||||
pre(priority, fn, opts) {
|
||||
_registerFilter('pre', priority, fn, opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a post-receive stream filter.
|
||||
* @param {number} priority
|
||||
* @param {Function} fn — (StreamContext) => StreamContext | null
|
||||
* @param {object} [opts] — { scope, source }
|
||||
*/
|
||||
stream(priority, fn, opts) {
|
||||
_registerFilter('stream', priority, fn, opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a post-render filter.
|
||||
* @param {number} priority
|
||||
* @param {Function} fn — (RenderContext) => RenderContext | null
|
||||
* @param {object} [opts] — { scope, source }
|
||||
*/
|
||||
render(priority, fn, opts) {
|
||||
_registerFilter('render', priority, fn, opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* List all registered filters with stats.
|
||||
* @returns {object} { pre: [...], stream: [...], render: [...] }
|
||||
*/
|
||||
list() {
|
||||
const result = {};
|
||||
for (const [stage, chain] of Object.entries(_chains)) {
|
||||
result[stage] = chain.map(e => ({
|
||||
priority: e.priority,
|
||||
source: e.source,
|
||||
scope: e.scope,
|
||||
calls: e.stats.calls,
|
||||
avgMs: e.stats.calls > 0
|
||||
? Math.round((e.stats.totalMs / e.stats.calls) * 100) / 100
|
||||
: 0,
|
||||
errors: e.stats.errors,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// ── Internal: called by patched chat.js / ui-core.js / ui-format.js ──
|
||||
|
||||
/** Run pre-send chain. Returns modified context or null. */
|
||||
_runPre(ctx) { return _runChain('pre', ctx); },
|
||||
|
||||
/** Run stream chain. Returns modified context or null. */
|
||||
_runStream(ctx) { return _runChain('stream', ctx); },
|
||||
|
||||
/** Run render chain. Returns modified context or null. */
|
||||
_runRender(ctx) { return _runChain('render', ctx); },
|
||||
};
|
||||
|
||||
// v0.37.13: sw.userMenu + _hydrateUserMenu removed
|
||||
// (Scorched Earth III — all surfaces use Preact UserMenu).
|
||||
|
||||
// ── Perform Init ─────────────────────────────────────
|
||||
|
||||
// Theme (v0.37.13: uses imported Preact SDK module)
|
||||
_theme.init();
|
||||
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
|
||||
|
||||
// Emit ready event
|
||||
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
||||
|
||||
this._instance = sw;
|
||||
window.sw = sw; // convenience alias
|
||||
|
||||
console.log('[Switchboard] SDK initialized');
|
||||
return sw;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Registration ─────────────────────────────────────────────
|
||||
sb.ns('Switchboard', Switchboard);
|
||||
@@ -1,481 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Workflow Surfaces
|
||||
// ==========================================
|
||||
// v0.30.2: Stage surface registry + built-in surface implementations.
|
||||
// Surfaces are composable UI components that render workflow stages.
|
||||
//
|
||||
// Built-in surfaces: form, chat, review
|
||||
// Custom surfaces can be registered via WorkflowSurfaces.register().
|
||||
//
|
||||
// Uses createComponentRegistry + componentMixin pattern from ui-primitives.js.
|
||||
// ==========================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const WorkflowSurfaces = {
|
||||
|
||||
...createComponentRegistry('WorkflowSurface'),
|
||||
|
||||
_types: new Map(),
|
||||
|
||||
/**
|
||||
* Register a stage surface type.
|
||||
* @param {string} name — surface identifier (e.g. 'form', 'chat', 'review')
|
||||
* @param {Function} factory — (container, ctx) => surface instance with { mount(), unmount() }
|
||||
*/
|
||||
register(name, factory) {
|
||||
this._types.set(name, factory);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a registered surface factory.
|
||||
* @param {string} name
|
||||
* @returns {Function|null}
|
||||
*/
|
||||
get(name) {
|
||||
return this._types.get(name) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mount the appropriate surface for a stage mode.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {string} stageMode — chat_only | form_only | form_chat | review
|
||||
* @param {object} ctx — { channelId, formTemplate, sessionId, basePath, sessionName, ... }
|
||||
* @returns {object|null} — mounted surface instance
|
||||
*/
|
||||
mount(container, stageMode, ctx) {
|
||||
if (!container) return null;
|
||||
|
||||
// Map stage_mode to surface type(s)
|
||||
switch (stageMode) {
|
||||
case 'form_only': {
|
||||
const factory = this._types.get('form');
|
||||
if (!factory) return null;
|
||||
const surface = factory(container, ctx);
|
||||
surface.mount();
|
||||
return surface;
|
||||
}
|
||||
case 'chat_only': {
|
||||
const factory = this._types.get('chat');
|
||||
if (!factory) return null;
|
||||
const surface = factory(container, ctx);
|
||||
surface.mount();
|
||||
return surface;
|
||||
}
|
||||
case 'form_chat': {
|
||||
const formFactory = this._types.get('form');
|
||||
const chatFactory = this._types.get('chat');
|
||||
if (!formFactory || !chatFactory) return null;
|
||||
|
||||
// Build split layout
|
||||
container.innerHTML = '';
|
||||
const splitWrap = document.createElement('div');
|
||||
splitWrap.className = 'wf-body-split';
|
||||
splitWrap.style.cssText = 'display:flex;flex:1;overflow:hidden;height:100%';
|
||||
|
||||
const formCol = document.createElement('div');
|
||||
formCol.className = 'wf-form';
|
||||
formCol.style.cssText = 'flex:1;border-right:1px solid var(--border);overflow-y:auto;padding:24px';
|
||||
|
||||
const chatCol = document.createElement('div');
|
||||
chatCol.className = 'wf-chat-col';
|
||||
chatCol.style.cssText = 'flex:1;display:flex;flex-direction:column';
|
||||
|
||||
splitWrap.appendChild(formCol);
|
||||
splitWrap.appendChild(chatCol);
|
||||
container.appendChild(splitWrap);
|
||||
|
||||
const formSurface = formFactory(formCol, ctx);
|
||||
const chatSurface = chatFactory(chatCol, ctx);
|
||||
formSurface.mount();
|
||||
chatSurface.mount();
|
||||
|
||||
return {
|
||||
mount() {},
|
||||
unmount() {
|
||||
formSurface.unmount();
|
||||
chatSurface.unmount();
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'review': {
|
||||
const factory = this._types.get('review');
|
||||
if (!factory) return null;
|
||||
const surface = factory(container, ctx);
|
||||
surface.mount();
|
||||
return surface;
|
||||
}
|
||||
default: {
|
||||
// Try custom surface
|
||||
const factory = this._types.get(stageMode);
|
||||
if (factory) {
|
||||
const surface = factory(container, ctx);
|
||||
surface.mount();
|
||||
return surface;
|
||||
}
|
||||
console.warn('[WorkflowSurfaces] Unknown stage mode:', stageMode);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// ── Built-in: Form Surface ──────────────────────
|
||||
|
||||
WorkflowSurfaces.register('form', function FormSurface(container, ctx) {
|
||||
const tpl = ctx.formTemplate;
|
||||
|
||||
function renderField(f) {
|
||||
var req = f.required ? '<span class="required">*</span>' : '';
|
||||
var ph = f.placeholder ? ' placeholder="' + _escAttr(f.placeholder) + '"' : '';
|
||||
var inner = '';
|
||||
|
||||
switch (f.type) {
|
||||
case 'text': case 'email': case 'date':
|
||||
inner = '<input type="' + f.type + '" id="ff_' + f.key + '"' + ph + '>';
|
||||
break;
|
||||
case 'number':
|
||||
var attrs = ph;
|
||||
if (f.validation) {
|
||||
if (f.validation.min != null) attrs += ' min="' + f.validation.min + '"';
|
||||
if (f.validation.max != null) attrs += ' max="' + f.validation.max + '"';
|
||||
if (f.validation.step != null) attrs += ' step="' + f.validation.step + '"';
|
||||
}
|
||||
inner = '<input type="number" id="ff_' + f.key + '"' + attrs + '>';
|
||||
break;
|
||||
case 'textarea':
|
||||
inner = '<textarea id="ff_' + f.key + '"' + ph + ' rows="4"></textarea>';
|
||||
break;
|
||||
case 'select':
|
||||
var opts = '<option value="">— Select —</option>';
|
||||
if (f.options) {
|
||||
for (var j = 0; j < f.options.length; j++) {
|
||||
opts += '<option value="' + _escAttr(f.options[j].value) + '">' + _escHtml(f.options[j].label) + '</option>';
|
||||
}
|
||||
}
|
||||
inner = '<select id="ff_' + f.key + '">' + opts + '</select>';
|
||||
break;
|
||||
case 'checkbox':
|
||||
return '<div class="wf-form-field" data-key="' + f.key + '">' +
|
||||
'<div class="wf-form-check">' +
|
||||
'<input type="checkbox" id="ff_' + f.key + '">' +
|
||||
'<label for="ff_' + f.key + '">' + _escHtml(f.label) + req + '</label>' +
|
||||
'</div>' +
|
||||
'<div class="field-error" id="err_' + f.key + '"></div>' +
|
||||
'</div>';
|
||||
case 'file':
|
||||
var accept = (f.validation && f.validation.accept) ? ' accept="' + _escAttr(f.validation.accept) + '"' : '';
|
||||
inner = '<input type="file" id="ff_' + f.key + '"' + accept + '>';
|
||||
break;
|
||||
default:
|
||||
inner = '<input type="text" id="ff_' + f.key + '"' + ph + '>';
|
||||
}
|
||||
|
||||
return '<div class="wf-form-field" data-key="' + f.key + '">' +
|
||||
'<label for="ff_' + f.key + '">' + _escHtml(f.label) + req + '</label>' +
|
||||
inner +
|
||||
'<div class="field-error" id="err_' + f.key + '"></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!tpl || !tpl.fields) return;
|
||||
var btn = container.querySelector('#formSubmitBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
container.querySelectorAll('.wf-form-field').forEach(el => el.classList.remove('has-error'));
|
||||
container.querySelectorAll('.field-error').forEach(el => { el.textContent = ''; });
|
||||
|
||||
var data = {};
|
||||
for (var i = 0; i < tpl.fields.length; i++) {
|
||||
var f = tpl.fields[i];
|
||||
var el = container.querySelector('#ff_' + f.key);
|
||||
if (!el) continue;
|
||||
if (f.type === 'checkbox') data[f.key] = el.checked;
|
||||
else if (f.type === 'number') data[f.key] = el.value !== '' ? parseFloat(el.value) : null;
|
||||
else data[f.key] = el.value;
|
||||
}
|
||||
|
||||
try {
|
||||
var basePath = ctx.basePath || '';
|
||||
var resp = await fetch(basePath + '/api/v1/w/' + ctx.channelId + '/form-submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: data }),
|
||||
});
|
||||
var result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
if (result.errors) {
|
||||
for (var i = 0; i < result.errors.length; i++) {
|
||||
var e = result.errors[i];
|
||||
var errEl = container.querySelector('#err_' + e.key);
|
||||
if (errEl) errEl.textContent = e.message;
|
||||
var fieldEl = container.querySelector('.wf-form-field[data-key="' + e.key + '"]');
|
||||
if (fieldEl) fieldEl.classList.add('has-error');
|
||||
}
|
||||
} else if (result.error) {
|
||||
alert(result.error);
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'advanced' || result.status === 'completed') {
|
||||
container.innerHTML = '<div class="wf-form-success"><h3>Submitted</h3><p>' +
|
||||
_escHtml(result.status === 'completed' ? 'Thank you! Your submission is complete.' : 'Submitted! Moving to the next step...') +
|
||||
'</p></div>';
|
||||
if (result.status === 'advanced') {
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
}
|
||||
} else {
|
||||
container.innerHTML = '<div class="wf-form-success"><h3>Submitted</h3><p>Your response has been submitted.</p></div>';
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Network error: ' + e.message);
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mount() {
|
||||
if (!tpl || !tpl.fields || tpl.fields.length === 0) {
|
||||
container.innerHTML = '<div class="wf-form-success"><p>No form fields configured for this stage.</p></div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
for (var i = 0; i < tpl.fields.length; i++) {
|
||||
html += renderField(tpl.fields[i]);
|
||||
}
|
||||
html += '<div class="wf-form-submit"><button id="formSubmitBtn">Submit</button></div>';
|
||||
container.innerHTML = html;
|
||||
container.querySelector('#formSubmitBtn').addEventListener('click', submitForm);
|
||||
},
|
||||
unmount() {
|
||||
container.innerHTML = '';
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// ── Built-in: Chat Surface ──────────────────────
|
||||
|
||||
WorkflowSurfaces.register('chat', function ChatSurface(container, ctx) {
|
||||
var chatEl, inputEl, sendBtn, sending = false;
|
||||
|
||||
function addMessage(role, content, name) {
|
||||
if (!chatEl) return;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'message ' + role;
|
||||
var meta = name ? '<div class="meta">' + _escHtml(name) + '</div>' : '';
|
||||
div.innerHTML = meta + '<div class="content">' + _escHtml(content) + '</div>';
|
||||
chatEl.appendChild(div);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!inputEl || !sendBtn) return;
|
||||
var text = inputEl.value.trim();
|
||||
if (!text || sending) return;
|
||||
sending = true;
|
||||
sendBtn.disabled = true;
|
||||
inputEl.value = '';
|
||||
|
||||
addMessage('user', text, ctx.sessionName || 'You');
|
||||
|
||||
try {
|
||||
var basePath = ctx.basePath || '';
|
||||
var resp = await fetch(basePath + '/api/v1/w/' + ctx.channelId + '/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel_id: ctx.channelId, content: text, stream: false }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
var data = await resp.json();
|
||||
if (data.content) {
|
||||
addMessage('assistant', data.content, data.persona_name || 'Assistant');
|
||||
}
|
||||
} else {
|
||||
var err = await resp.json().catch(() => ({}));
|
||||
addMessage('assistant', 'Error: ' + (err.error || resp.statusText));
|
||||
}
|
||||
} catch(e) {
|
||||
addMessage('assistant', 'Network error: ' + e.message);
|
||||
}
|
||||
|
||||
sending = false;
|
||||
sendBtn.disabled = false;
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
return {
|
||||
mount() {
|
||||
chatEl = document.createElement('div');
|
||||
chatEl.className = 'wf-chat';
|
||||
chatEl.style.cssText = 'flex:1;overflow-y:auto;padding:16px 24px';
|
||||
|
||||
var inputWrap = document.createElement('div');
|
||||
inputWrap.className = 'wf-input';
|
||||
|
||||
inputEl = document.createElement('textarea');
|
||||
inputEl.placeholder = 'Type a message\u2026';
|
||||
inputEl.rows = 1;
|
||||
inputEl.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
inputEl.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
|
||||
});
|
||||
|
||||
sendBtn = document.createElement('button');
|
||||
sendBtn.textContent = 'Send';
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
|
||||
inputWrap.appendChild(inputEl);
|
||||
inputWrap.appendChild(sendBtn);
|
||||
container.appendChild(chatEl);
|
||||
container.appendChild(inputWrap);
|
||||
},
|
||||
unmount() {
|
||||
container.innerHTML = '';
|
||||
chatEl = inputEl = sendBtn = null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// ── Built-in: Review Surface ────────────────────
|
||||
|
||||
WorkflowSurfaces.register('review', function ReviewSurface(container, ctx) {
|
||||
var mounted = false;
|
||||
|
||||
async function loadReviewData() {
|
||||
var basePath = ctx.basePath || '';
|
||||
var html = '<div style="padding:24px;max-width:640px;margin:0 auto">';
|
||||
html += '<h3 style="margin-bottom:16px">Review Stage</h3>';
|
||||
|
||||
try {
|
||||
// Load workflow status to get stage data
|
||||
var statusResp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/status', {
|
||||
headers: ctx.authHeaders ? ctx.authHeaders() : {},
|
||||
});
|
||||
|
||||
if (statusResp.ok) {
|
||||
var status = await statusResp.json();
|
||||
|
||||
html += '<div style="margin-bottom:16px">';
|
||||
html += '<h4 style="margin-bottom:8px;font-size:14px;color:var(--text-2)">Collected Data</h4>';
|
||||
|
||||
if (status.stage_data && typeof status.stage_data === 'object') {
|
||||
html += '<table style="width:100%;border-collapse:collapse">';
|
||||
for (var key in status.stage_data) {
|
||||
if (!status.stage_data.hasOwnProperty(key)) continue;
|
||||
html += '<tr style="border-bottom:1px solid var(--border)">';
|
||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%">' + _escHtml(key) + '</td>';
|
||||
html += '<td style="padding:8px;font-size:13px">' + _escHtml(String(status.stage_data[key])) + '</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
html += '<div style="margin-bottom:16px">';
|
||||
html += '<span style="font-size:12px;color:var(--text-3)">Status: ' + _escHtml(status.status) + '</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
html += '<div style="display:flex;gap:8px;margin-top:24px">';
|
||||
html += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer">Approve & Advance</button>';
|
||||
html += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer">Reject</button>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
var advBtn = container.querySelector('#reviewAdvanceBtn');
|
||||
var rejBtn = container.querySelector('#reviewRejectBtn');
|
||||
|
||||
if (advBtn) {
|
||||
advBtn.addEventListener('click', async function() {
|
||||
advBtn.disabled = true;
|
||||
try {
|
||||
var resp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/advance', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, ctx.authHeaders ? ctx.authHeaders() : {}),
|
||||
body: JSON.stringify({ data: {} }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced successfully.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await resp.json().catch(() => ({}));
|
||||
alert('Failed: ' + (err.error || 'unknown error'));
|
||||
advBtn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Network error: ' + e.message);
|
||||
advBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (rejBtn) {
|
||||
rejBtn.addEventListener('click', async function() {
|
||||
var reason = prompt('Rejection reason:');
|
||||
if (!reason) return;
|
||||
rejBtn.disabled = true;
|
||||
try {
|
||||
var resp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/reject', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, ctx.authHeaders ? ctx.authHeaders() : {}),
|
||||
body: JSON.stringify({ reason: reason }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Stage sent back for revision.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await resp.json().catch(() => ({}));
|
||||
alert('Failed: ' + (err.error || 'unknown error'));
|
||||
rejBtn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Network error: ' + e.message);
|
||||
rejBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mount() {
|
||||
if (mounted) return;
|
||||
mounted = true;
|
||||
container.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>';
|
||||
loadReviewData();
|
||||
},
|
||||
unmount() {
|
||||
container.innerHTML = '';
|
||||
mounted = false;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// ── Helpers ─────────────────────────────────────
|
||||
|
||||
function _escHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function _escAttr(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -34,11 +34,8 @@ const SHELL_FILES = [
|
||||
'./css/sw-chat-surface.css',
|
||||
'./css/sw-notes-pane.css',
|
||||
'./css/sw-notes-surface.css',
|
||||
// JS — platform globals (extension + debug deps)
|
||||
// v0.37.13: app-state, ui-primitives, user-menu, file-tree, code-editor removed
|
||||
'./js/sb.js',
|
||||
'./js/events.js',
|
||||
'./js/switchboard-sdk.js',
|
||||
// JS — debug tooling (standalone, no old-layer deps)
|
||||
// v0.37.14: sb.js, events.js, switchboard-sdk.js removed
|
||||
'./js/debug.js',
|
||||
'./js/repl.js',
|
||||
// Vendor
|
||||
@@ -46,6 +43,7 @@ const SHELL_FILES = [
|
||||
'./vendor/purify.min.js',
|
||||
// Static assets
|
||||
'./favicon.svg',
|
||||
'./favicon-light.svg',
|
||||
'./favicon.ico',
|
||||
'./favicon-32.png',
|
||||
'./favicon-256.png',
|
||||
|
||||
Reference in New Issue
Block a user