From 234342a06161e716ab7a3d9e9993346cf01fe567 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 16 Aug 2026 20:57:10 -0400 Subject: [PATCH 01/16] feat(web): split the model menu into a model and an effort list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat menu showed every model of the provider plus every effort level at once, so it was tall before it had anything to say. It now opens on two rows — Model and Effort, each with its current value — and drills into one list at a time, with a back row to return. --- .../pythinker-web/src/components/Composer.vue | 92 +++++++++++++++---- .../src/i18n/locales/en/status.ts | 2 +- apps/pythinker-web/test/composer.test.ts | 53 +++++++++++ 3 files changed, 127 insertions(+), 20 deletions(-) diff --git a/apps/pythinker-web/src/components/Composer.vue b/apps/pythinker-web/src/components/Composer.vue index 3216fcdef..ff1bf16ec 100644 --- a/apps/pythinker-web/src/components/Composer.vue +++ b/apps/pythinker-web/src/components/Composer.vue @@ -741,12 +741,15 @@ const hasUpload = computed(() => !!props.uploadImage); const dropdownOpen = ref(false); const modelPillRef = ref(null); const modelDropdownStyle = ref>({}); +/** The dropdown opens on a two-row root menu and drills into one list at a time. */ +const dropdownView = ref<'root' | 'model' | 'effort'>('root'); const permDropdownOpen = ref(false); const toolbarRef = ref(null); function toggleDropdown(): void { dropdownOpen.value = !dropdownOpen.value; if (dropdownOpen.value) { + dropdownView.value = 'root'; const rect = modelPillRef.value?.getBoundingClientRect(); modelDropdownStyle.value = rect ? { maxHeight: `${Math.min(360, Math.max(0, rect.top - 4 - 12))}px` } @@ -1210,8 +1213,46 @@ function selectModel(modelId: string): void { - - @@ -1827,6 +1852,35 @@ function selectModel(modelId: string): void { font-size: var(--ui-font-size-xs); flex: none; } + +/* Root menu: two rows, so it sizes to its content and never scrolls. */ +.model-dropdown.is-root { + min-width: 240px; + overflow-y: visible; +} + +.md-value { + color: var(--muted); + flex: none; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.md-chevron { + flex: none; + color: var(--faint); +} + +.md-row-nav:disabled { + cursor: default; + opacity: 0.6; +} + +.md-row-back { + color: var(--muted); +} .md-star { color: var(--star); flex: none; diff --git a/apps/pythinker-web/src/i18n/locales/en/status.ts b/apps/pythinker-web/src/i18n/locales/en/status.ts index dabe21ae6..2ba72b0a0 100644 --- a/apps/pythinker-web/src/i18n/locales/en/status.ts +++ b/apps/pythinker-web/src/i18n/locales/en/status.ts @@ -32,7 +32,7 @@ export default { // Thinking selector thinkingLabel: 'thinking', thinkingTooltip: 'Toggle thinking mode', - effortLabel: 'Thinking effort', + effortRow: 'Effort', effortLevels: { off: 'Off', minimal: 'Minimal', diff --git a/apps/pythinker-web/test/composer.test.ts b/apps/pythinker-web/test/composer.test.ts index bd187766e..a618dbcc9 100644 --- a/apps/pythinker-web/test/composer.test.ts +++ b/apps/pythinker-web/test/composer.test.ts @@ -35,10 +35,21 @@ function mountComposer(props: Record = {}) { compact: { desc: 'Compact context' }, }, status: { + modelLabel: 'Model', modelTooltip: 'Switch model', starredModels: 'Starred', moreModels: 'More models…', thinkingLabel: 'thinking', + effortRow: 'Effort', + effortLevels: { + off: 'Off', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'xHigh', + max: 'Max', + }, }, }, }, @@ -54,6 +65,12 @@ function mountComposer(props: Record = {}) { }); } +/** The dropdown opens on the root menu; step into the model list. */ +async function openModelList(wrapper: ReturnType): Promise { + const modelRow = wrapper.findAll('.md-row-nav').find((row) => row.text().includes('Model')); + await modelRow!.trigger('click'); +} + function waitForCompositionEndTimer(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } @@ -371,6 +388,7 @@ describe('Composer model dropdown', () => { }); await wrapper.find('.model-pill').trigger('click'); + await openModelList(wrapper); const rows = wrapper.findAll('.md-row'); expect(rows.length).toBeGreaterThan(0); @@ -387,6 +405,7 @@ describe('Composer model dropdown', () => { }); await wrapper.find('.model-pill').trigger('click'); + await openModelList(wrapper); const starredRow = wrapper.findAll('.md-row').find((row) => row.text().includes('GPT-5')); expect(starredRow).toBeDefined(); await starredRow!.trigger('click'); @@ -426,6 +445,40 @@ describe('Composer model dropdown', () => { expect(wrapper.get('.model-dropdown').element.style.maxHeight).toBe('360px'); }); + it('opens on a two-row root menu and drills into the effort list', async () => { + const wrapper = mountComposer({ + status: { model: 'Pythinker K2', modelId: 'pythinker/k2', ctxUsed: 0, ctxMax: 128000, permission: 'manual' }, + models: [ + { + id: 'pythinker/k2', + provider: 'pythinker', + model: 'k2', + displayName: 'Pythinker K2', + maxContextSize: 128000, + capabilities: ['thinking'], + }, + ], + thinking: 'medium', + }); + + await wrapper.find('.model-pill').trigger('click'); + expect(wrapper.findAll('.md-row-nav')).toHaveLength(2); + expect(wrapper.text()).toContain('Pythinker K2'); + expect(wrapper.text()).toContain('Medium'); + // The model list stays behind the Model row. + expect(wrapper.text()).not.toContain('More models…'); + + const effortRow = wrapper.findAll('.md-row-nav').find((row) => row.text().includes('Effort')); + await effortRow!.trigger('click'); + + const levels = wrapper.findAll('.md-row').map((row) => row.text()); + expect(levels).toEqual(['Effort', 'Off', 'Low', 'Medium', 'High']); + + const high = wrapper.findAll('.md-row').find((row) => row.text() === 'High'); + await high!.trigger('click'); + expect(wrapper.emitted('setThinking')).toEqual([['high']]); + }); + it('replaces the binary thinking toggle with the effort list', () => { expect(composerSource).not.toContain('toggleThinking'); expect(composerSource).not.toContain('md-row-toggle'); From 0aea73551416bb1ee6882eadc923ba421a87ae65 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 16 Aug 2026 21:05:17 -0400 Subject: [PATCH 02/16] fix(web): float the task chips above the composer The chips and their panel sat in the dock's flow, so the composer moved down whenever a task appeared and moved again when a panel opened. They now stack upwards from the top edge of the composer, outside the flow, and the gap between them passes clicks through to the conversation. --- .../pythinker-web/src/components/ChatDock.vue | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/pythinker-web/src/components/ChatDock.vue b/apps/pythinker-web/src/components/ChatDock.vue index 7858839c9..74485b79a 100644 --- a/apps/pythinker-web/src/components/ChatDock.vue +++ b/apps/pythinker-web/src/components/ChatDock.vue @@ -119,6 +119,9 @@ defineExpose({ loadForEdit }); - - diff --git a/apps/pythinker-web/src/components/Sidebar.vue b/apps/pythinker-web/src/components/Sidebar.vue index e5e213d6d..93b8d4ae0 100644 --- a/apps/pythinker-web/src/components/Sidebar.vue +++ b/apps/pythinker-web/src/components/Sidebar.vue @@ -6,8 +6,10 @@ import { nextTick, onBeforeUnmount, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import type { Session, WorkspaceGroup, WorkspaceView } from '../types'; +import type { SettingsTab } from '../composables/useSettingsNav'; import SessionRow from './SessionRow.vue'; import PythinkerLogo from './PythinkerLogo.vue'; +import SettingsNav from './settings/SettingsNav.vue'; const { t } = useI18n(); @@ -24,6 +26,8 @@ const props = withDefaults( unreadBySession?: Record; /** Width (px) of the session column, driven by the App resize handle. */ colWidth?: number; + mode?: 'sessions' | 'settings'; + activeSettingsTab?: SettingsTab; }>(), { activeWorkspace: null, @@ -32,6 +36,8 @@ const props = withDefaults( pendingBySession: () => ({}), unreadBySession: () => ({}), colWidth: 220, + mode: 'sessions', + activeSettingsTab: 'general', }, ); @@ -48,6 +54,8 @@ const emit = defineEmits<{ renameWorkspace: [id: string, name: string]; deleteWorkspace: [id: string]; openSettings: []; + closeSettings: []; + selectSettingsTab: [tab: SettingsTab]; collapse: []; }>(); @@ -485,6 +493,9 @@ onBeforeUnmount(() => { + + + +
{ } /* Pinned settings action */ +.settings-nav-body { + display: flex; + flex: 1; + min-height: 0; +} .side-foot { flex: none; border-top: none; diff --git a/apps/pythinker-web/src/components/settings/ListingRow.vue b/apps/pythinker-web/src/components/settings/ListingRow.vue new file mode 100644 index 000000000..72f1e55d0 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/ListingRow.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/SettingsNav.vue b/apps/pythinker-web/src/components/settings/SettingsNav.vue new file mode 100644 index 000000000..7fd8a03f7 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/SettingsNav.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/SettingsPane.vue b/apps/pythinker-web/src/components/settings/SettingsPane.vue new file mode 100644 index 000000000..99cfa81ce --- /dev/null +++ b/apps/pythinker-web/src/components/settings/SettingsPane.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/AdvancedPage.vue b/apps/pythinker-web/src/components/settings/pages/AdvancedPage.vue new file mode 100644 index 000000000..2f963a175 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/AdvancedPage.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/AgentPage.vue b/apps/pythinker-web/src/components/settings/pages/AgentPage.vue new file mode 100644 index 000000000..265453cd6 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/AgentPage.vue @@ -0,0 +1,221 @@ + + + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/ConnectorsPage.vue b/apps/pythinker-web/src/components/settings/pages/ConnectorsPage.vue new file mode 100644 index 000000000..a5da29532 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/ConnectorsPage.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/ExperimentalPage.vue b/apps/pythinker-web/src/components/settings/pages/ExperimentalPage.vue new file mode 100644 index 000000000..4d414b6cf --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/ExperimentalPage.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue b/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue new file mode 100644 index 000000000..2bc8fad5c --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue @@ -0,0 +1,241 @@ + + + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/HooksPage.vue b/apps/pythinker-web/src/components/settings/pages/HooksPage.vue new file mode 100644 index 000000000..36a92e1e4 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/HooksPage.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/PluginsPage.vue b/apps/pythinker-web/src/components/settings/pages/PluginsPage.vue new file mode 100644 index 000000000..6c9bf7105 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/PluginsPage.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/SkillsPage.vue b/apps/pythinker-web/src/components/settings/pages/SkillsPage.vue new file mode 100644 index 000000000..23a69a830 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/SkillsPage.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/SubagentsPage.vue b/apps/pythinker-web/src/components/settings/pages/SubagentsPage.vue new file mode 100644 index 000000000..388255d13 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/SubagentsPage.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/UsagePage.vue b/apps/pythinker-web/src/components/settings/pages/UsagePage.vue new file mode 100644 index 000000000..329da9cd8 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/UsagePage.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/settings.css b/apps/pythinker-web/src/components/settings/settings.css new file mode 100644 index 000000000..3f4ffd2b5 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/settings.css @@ -0,0 +1,260 @@ +.panel { display: block; } +.sec { padding: 12px 0; border-bottom: 1px solid var(--line); } +.sec:last-child { border-bottom: none; } +.sec-title { + margin: 0 0 10px; + color: var(--muted); + font-family: var(--mono); + font-size: calc(var(--ui-font-size) - 3px); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.sec-note { + margin: -4px 0 12px; + color: var(--muted); + font-size: calc(var(--ui-font-size) - 2px); +} +.sec-empty { + margin: 0; + color: var(--faint); + font-size: calc(var(--ui-font-size) - 1px); +} +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 34px; + padding: 3px 0; +} +.rlabel { + display: flex; + flex-direction: column; + gap: 2px; + color: var(--ink); + font-family: var(--sans); + font-size: calc(var(--ui-font-size) - 0.5px); +} +.rvalue { + max-width: 60%; + overflow: hidden; + color: var(--muted); + font-family: var(--sans); + font-size: calc(var(--ui-font-size) - 1.5px); + text-overflow: ellipsis; + white-space: nowrap; +} +.rvalue.mono { font-family: var(--mono); font-size: var(--ui-font-size-xs); } +.hint { color: var(--faint); font-family: var(--sans); font-size: calc(var(--ui-font-size) - 3px); } +.act { + padding: 6px 12px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + font-size: calc(var(--ui-font-size) - 1.5px); + cursor: pointer; +} +.act:hover { background: var(--soft); border-color: var(--bd); } +.act.signin { background: var(--blue); color: var(--bg); border-color: var(--blue); } +.act.signin:hover { background: var(--blue2); } +.switch { + position: relative; + flex: none; + width: 40px; + height: 22px; + padding: 0; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel2); + cursor: pointer; + transition: background 0.16s; +} +.switch.on { background: var(--blue); border-color: var(--blue); } +.switch:disabled { opacity: 0.5; cursor: not-allowed; } +.knob { + position: absolute; + top: 1px; + left: 1px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--bg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + transition: transform 0.16s; +} +.switch.on .knob { transform: translateX(18px); } +.switch.sm { width: 30px; height: 17px; } +.switch.sm .knob { width: 13px; height: 13px; } +.switch.sm.on .knob { transform: translateX(13px); } +.tag { + flex: none; + padding: 1px 6px; + border-radius: 5px; + background: var(--soft); + color: var(--muted); + font-size: calc(var(--ui-font-size) - 3px); +} +.dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--faint); +} +.dot.s-connected { background: var(--ok); } +.dot.s-connecting { background: var(--warn); } +.dot.s-error { background: var(--err); } +.page-title { + margin: 0 0 6px; + color: var(--ink); + font-size: calc(var(--ui-font-size) + 10px); + font-weight: 700; +} +.page-search { + box-sizing: border-box; + width: 100%; + margin: 0 0 12px; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + font-size: var(--ui-font-size); +} +.listing-count { + margin: 0 0 8px; + color: var(--faint); + font-size: calc(var(--ui-font-size) - 2px); +} +.listing { display: flex; flex-direction: column; gap: 2px; } +.listing-head { + margin: 14px 0 6px; + color: var(--muted); + font-size: calc(var(--ui-font-size) - 2px); + font-weight: 600; +} +/* Rows are flat rather than carded: the leading glyph, the name and the right + action cluster carry the structure, so a border would only add noise. */ +.listing-row { + display: flex; + flex-direction: column; + padding: 5px 0; +} +.listing-top { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +/* Only the content fades when an entry is off — the switch that turns it back + on has to stay at full contrast. */ +.listing-row.off .listing-main { opacity: 0.5; } +.listing-main { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} +.row-actions { + display: flex; + flex: none; + align-items: center; + gap: 4px; +} +.listing-glyph { + flex: none; + width: 15px; + height: 15px; + color: var(--faint); +} +.listing-name { + flex: none; + font-weight: 500; + color: var(--ink); +} +.listing-meta { + flex: none; + font-size: calc(var(--ui-font-size) - 2px); + color: var(--faint); +} +.listing-desc, +.listing-path, +.listing-error { + margin: 2px 0 0; + font-size: calc(var(--ui-font-size) - 2px); + color: var(--muted); +} +/* Inside a row the description shares the line with the name, so it truncates + instead of wrapping and pushes the meta text to the right edge. */ +.listing-main .listing-desc { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--faint); +} +.listing-main .listing-meta { margin-left: auto; } +.listing-path { + padding-left: 27px; + color: var(--faint); + word-break: break-all; +} +.listing-error { padding-left: 27px; } +.listing-indent { padding-left: 27px; } + +/* Ghost icon button for the per-row actions — no chrome until hover. */ +.icon-btn { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 7px; + background: transparent; + color: var(--faint); + cursor: pointer; +} +.icon-btn svg { width: 15px; height: 15px; } +.icon-btn:hover { background: var(--soft); color: var(--ink); } +.listing-error { + color: var(--err); +} +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; + margin-bottom: 6px; +} +.stat-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); +} +.stat-label { color: var(--muted); font-size: calc(var(--ui-font-size) - 2px); } +.stat-value { color: var(--ink); font-size: calc(var(--ui-font-size) + 8px); font-weight: 700; } +.usage-bar { + height: 4px; + margin-top: 6px; + overflow: hidden; + border-radius: 999px; + background: var(--line2); +} +.usage-bar span { display: block; height: 100%; background: var(--blue); } +.mono { font-family: var(--mono); } + +@media (max-width: 640px) { + .row { align-items: flex-start; flex-direction: column; } +} diff --git a/apps/pythinker-web/src/composables/useSettingsNav.ts b/apps/pythinker-web/src/composables/useSettingsNav.ts new file mode 100644 index 000000000..3c1f49e37 --- /dev/null +++ b/apps/pythinker-web/src/composables/useSettingsNav.ts @@ -0,0 +1,68 @@ +import { shallowRef, toValue, type MaybeRefOrGetter } from 'vue'; + +export type SettingsTab = + | 'general' + | 'agent' + | 'skills' + | 'connectors' + | 'plugins' + | 'subagents' + | 'hooks' + | 'usage' + | 'advanced' + | 'experimental'; + +export const tabGroups: Array<{ + titleKey: string; + tabs: Array<{ id: SettingsTab; labelKey: string }>; +}> = [ + { + titleKey: 'settings.groups.basics', + tabs: [ + { id: 'general', labelKey: 'settings.tabs.general' }, + { id: 'agent', labelKey: 'settings.tabs.agent' }, + ], + }, + { + titleKey: 'settings.groups.capabilities', + tabs: [ + { id: 'plugins', labelKey: 'settings.tabs.plugins' }, + { id: 'skills', labelKey: 'settings.tabs.skills' }, + { id: 'subagents', labelKey: 'settings.tabs.subagents' }, + { id: 'connectors', labelKey: 'settings.tabs.connectors' }, + { id: 'hooks', labelKey: 'settings.tabs.hooks' }, + ], + }, + { + titleKey: 'settings.groups.data', + tabs: [ + { id: 'usage', labelKey: 'settings.tabs.usage' }, + { id: 'advanced', labelKey: 'settings.tabs.advanced' }, + { id: 'experimental', labelKey: 'settings.tabs.experimental' }, + ], + }, +]; + +type UseSettingsNavOptions = { + counts: { + connectors: MaybeRefOrGetter; + plugins: MaybeRefOrGetter; + subagents: MaybeRefOrGetter; + }; + onLoadConnectors: () => void; + onLoadPlugins: () => void; + onLoadSubagents: () => void; +}; + +export function useSettingsNav(options: UseSettingsNavOptions) { + const activeTab = shallowRef('general'); + + function setTab(tab: SettingsTab): void { + if (tab === 'connectors' && toValue(options.counts.connectors) === 0) options.onLoadConnectors(); + if (tab === 'plugins' && toValue(options.counts.plugins) === 0) options.onLoadPlugins(); + if (tab === 'subagents' && toValue(options.counts.subagents) === 0) options.onLoadSubagents(); + activeTab.value = tab; + } + + return { activeTab, setTab }; +} diff --git a/apps/pythinker-web/src/i18n/locales/en/settings.ts b/apps/pythinker-web/src/i18n/locales/en/settings.ts index 0fa9be942..c880e8cf7 100644 --- a/apps/pythinker-web/src/i18n/locales/en/settings.ts +++ b/apps/pythinker-web/src/i18n/locales/en/settings.ts @@ -1,5 +1,7 @@ export default { title: 'Settings', + /** Sidebar control that leaves the settings route and restores the session list. */ + backToSessions: 'Back to sessions', groups: { basics: 'Basics', capabilities: 'Agent capabilities', diff --git a/apps/pythinker-web/test/settings-dialog.test.ts b/apps/pythinker-web/test/settings-dialog.test.ts deleted file mode 100644 index cb5fef7fd..000000000 --- a/apps/pythinker-web/test/settings-dialog.test.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import SettingsDialog from '../src/components/SettingsDialog.vue'; -import enSettings from '../src/i18n/locales/en/settings'; -import type { AppConfig, AppConnector, AppModel, AppSkill } from '../src/api/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - settings: enSettings, - theme: { - label: 'Theme', - modern: 'Modern', - pythinker: 'Pythinker', - colorSchemeLabel: 'Color scheme', - light: 'Light', - dark: 'Dark', - system: 'System', - }, - sidebar: { - daemon: 'Daemon', - language: 'Language', - notSignedIn: 'Not signed in', - signIn: 'Sign in', - signOut: 'Sign out', - }, - onboarding: { reopen: 'Open onboarding' }, - newSession: { close: 'Close' }, - }, - }, - missingWarn: false, - fallbackWarn: false, -}); - -const config: AppConfig = { - providers: { - pythinker: { - type: 'pythoughts', - defaultModel: 'pythinker/k2', - hasApiKey: true, - }, - openai: { - type: 'openai', - hasApiKey: false, - }, - }, - defaultModel: 'pythinker/k2', - models: { - 'pythinker/k2': { provider: 'pythinker', model: 'k2' }, - 'openai/gpt-5': { provider: 'openai', model: 'gpt-5' }, - }, - defaultPermissionMode: 'manual', - defaultThinking: true, - defaultPlanMode: false, - mergeAllAvailableSkills: false, - telemetry: true, - raw: { secret: 'must-not-render' }, -}; - -const models: AppModel[] = [ - { - id: 'pythinker/k2', - provider: 'pythinker', - model: 'k2', - displayName: 'Pythinker K2', - maxContextSize: 128000, - }, - { - id: 'openai/gpt-5', - provider: 'openai', - model: 'gpt-5', - displayName: 'GPT-5', - maxContextSize: 256000, - }, -]; - -const skills: AppSkill[] = [ - { name: 'gen-changesets', description: 'Write the changesets for a PR', source: 'project', path: '.pythinker/skills/gen-changesets' }, - { name: 'brainstorm', description: 'Explore a problem first', source: 'builtin' }, - { name: 'archive', description: 'Archive a session', source: 'builtin', disableModelInvocation: true }, -]; - -const connectors: AppConnector[] = [ - { id: 'mcp_1', name: 'context7', transport: 'http', status: 'connected', toolCount: 2 }, - { id: 'mcp_2', name: 'tavily', transport: 'stdio', status: 'error', toolCount: 0, lastError: 'spawn ENOENT' }, -]; - -function mountDialog(extraProps: Record = {}) { - return mount(SettingsDialog, { - props: { - theme: 'modern', - colorScheme: 'system', - uiFontSize: 15, - authReady: true, - accountModel: 'pythinker/k2', - notify: true, - notifyPermission: 'granted', - betaToc: false, - config, - models, - configSaving: false, - ...extraProps, - }, - global: { - plugins: [i18n], - }, - }); -} - -async function openTab(wrapper: ReturnType, label: string): Promise { - const tab = wrapper.findAll('.tab').find((button) => button.text() === label); - await tab!.trigger('click'); -} - -afterEach(() => { - document.body.innerHTML = ''; - delete window.pythinkerDesktop; -}); - -describe('SettingsDialog tabs', () => { - it('renders side tabs and switches panels', async () => { - const wrapper = mountDialog(); - - expect(wrapper.text()).toContain('General'); - - const generalTab = wrapper.findAll('.tab').find((button) => button.text() === 'General'); - const agentTab = wrapper.findAll('.tab').find((button) => button.text() === 'Agent'); - const advancedTab = wrapper.findAll('.tab').find((button) => button.text() === 'Advanced'); - const experimentalTab = wrapper.findAll('.tab').find((button) => button.text() === 'Experimental'); - - expect(generalTab!.classes('on')).toBe(true); - expect(agentTab!.classes('on')).toBe(false); - - await agentTab!.trigger('click'); - expect(generalTab!.classes('on')).toBe(false); - expect(agentTab!.classes('on')).toBe(true); - - const agentPanel = wrapper.find('#settings-panel-agent'); - expect(agentPanel.isVisible()).toBe(true); - const generalPanel = wrapper.find('#settings-panel-general'); - expect(generalPanel.isVisible()).toBe(false); - - await advancedTab!.trigger('click'); - expect(advancedTab!.classes('on')).toBe(true); - expect(agentTab!.classes('on')).toBe(false); - - await experimentalTab!.trigger('click'); - expect(experimentalTab!.classes('on')).toBe(true); - expect(advancedTab!.classes('on')).toBe(false); - }); -}); - -describe('SettingsDialog config controls', () => { - it('renders redacted daemon config and emits partial config patches', async () => { - const wrapper = mountDialog(); - - const agentTab = wrapper.findAll('.tab').find((button) => button.text() === 'Agent'); - await agentTab!.trigger('click'); - - expect(wrapper.text()).toContain('Agent defaults'); - expect(wrapper.text()).toContain('Pythinker K2'); - expect(wrapper.text()).toContain('Credential configured'); - expect(wrapper.text()).toContain('Missing credential'); - expect(wrapper.text()).not.toContain('must-not-render'); - - await wrapper.find('.select-field').setValue('openai/gpt-5'); - expect(wrapper.emitted('updateConfig')?.[0]?.[0]).toEqual({ defaultModel: 'openai/gpt-5' }); - - const auto = wrapper.findAll('.opt').find((button) => button.text() === 'Auto'); - await auto!.trigger('click'); - expect(wrapper.emitted('updateConfig')?.[1]?.[0]).toEqual({ defaultPermissionMode: 'auto' }); - - const planRow = wrapper.findAll('.row').find((row) => row.text().includes('Plan mode by default')); - await planRow!.find('button.switch').trigger('click'); - expect(wrapper.emitted('updateConfig')?.[2]?.[0]).toEqual({ defaultPlanMode: true }); - }); - - it('groups default model options by provider', async () => { - const wrapper = mountDialog(); - - const agentTab = wrapper.findAll('.tab').find((button) => button.text() === 'Agent'); - await agentTab!.trigger('click'); - - const groups = wrapper.findAll('optgroup'); - expect(groups.length).toBe(2); - expect(groups[0]!.attributes('label')).toBe('openai'); - expect(groups[1]!.attributes('label')).toBe('pythinker'); - - const openaiOptions = groups[0]!.findAll('option'); - expect(openaiOptions.some((o) => o.attributes('value') === 'openai/gpt-5')).toBe(true); - - const pythinkerOptions = groups[1]!.findAll('option'); - expect(pythinkerOptions.some((o) => o.attributes('value') === 'pythinker/k2')).toBe(true); - }); -}); - -describe('SettingsDialog desktop updates', () => { - it('renders desktop update controls and checks for updates', async () => { - const checkForUpdates = vi.fn().mockResolvedValue(undefined); - window.pythinkerDesktop = { - platform: 'darwin', - getUpdateState: vi.fn().mockResolvedValue({ status: 'idle', autoUpdate: true }), - setAutoUpdate: vi.fn().mockResolvedValue({ status: 'idle', autoUpdate: true }), - checkForUpdates, - quitAndInstall: vi.fn().mockResolvedValue(undefined), - onUpdateState: vi.fn().mockReturnValue(() => undefined), - }; - - const wrapper = mountDialog(); - - expect(wrapper.text()).toContain('Desktop app'); - const checkButton = wrapper.findAll('button').find((button) => button.text() === 'Check for updates'); - expect(checkButton).toBeDefined(); - - await checkButton!.trigger('click'); - expect(checkForUpdates).toHaveBeenCalledOnce(); - }); - - it('hides desktop update controls outside the desktop app', () => { - const wrapper = mountDialog(); - - expect(wrapper.text()).not.toContain('Desktop app'); - }); -}); - -describe('SettingsDialog dialog focus', () => { - it('is a modal that takes focus on open and restores it on close', async () => { - const opener = document.createElement('button'); - document.body.append(opener); - opener.focus(); - expect(document.activeElement).toBe(opener); - - const wrapper = mount(SettingsDialog, { - props: { - theme: 'modern', - colorScheme: 'system', - uiFontSize: 15, - authReady: true, - accountModel: 'pythinker/k2', - notify: true, - notifyPermission: 'granted', - betaToc: false, - config, - models, - configSaving: false, - }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - - const dialog = wrapper.find('.dialog'); - expect(dialog.attributes('aria-modal')).toBe('true'); - - await nextTick(); - // Opening moves focus into the dialog. - expect(document.activeElement).toBe(dialog.element); - - wrapper.unmount(); - await nextTick(); - // Closing returns focus to the opener. - expect(document.activeElement).toBe(opener); - - opener.remove(); - }); -}); - -describe('SettingsDialog skills page', () => { - it('groups skills by source and marks the slash-only ones', async () => { - const wrapper = mountDialog({ skills }); - await openTab(wrapper, 'Skills'); - - const panel = wrapper.get('#settings-panel-skills'); - expect(panel.findAll('.listing-head').map((head) => head.text())).toEqual([ - 'builtin', - 'project', - ]); - // Sorted by name inside each group. - expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual([ - 'archive', - 'brainstorm', - 'gen-changesets', - ]); - expect(panel.findAll('.tag').map((tag) => tag.text())).toEqual(['slash only']); - }); - - it('says so when no skill is available', async () => { - const wrapper = mountDialog(); - await openTab(wrapper, 'Skills'); - - expect(wrapper.get('#settings-panel-skills').text()).toContain('No skills are available'); - }); -}); - -describe('SettingsDialog connectors page', () => { - it('loads the connectors the first time the page is opened', async () => { - const wrapper = mountDialog(); - await openTab(wrapper, 'Connectors'); - - expect(wrapper.emitted('loadConnectors')).toHaveLength(1); - - await openTab(wrapper, 'General'); - await openTab(wrapper, 'Connectors'); - expect(wrapper.emitted('loadConnectors')).toHaveLength(2); - }); - - it('does not reload when connectors are already known', async () => { - const wrapper = mountDialog({ connectors }); - await openTab(wrapper, 'Connectors'); - - expect(wrapper.emitted('loadConnectors')).toBeUndefined(); - }); - - it('shows each server status and restarts one on demand', async () => { - const wrapper = mountDialog({ connectors }); - await openTab(wrapper, 'Connectors'); - - const panel = wrapper.get('#settings-panel-connectors'); - expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual([ - 'context7', - 'tavily', - ]); - expect(panel.findAll('.dot').map((dot) => dot.classes().join(' '))).toEqual([ - 'dot s-connected', - 'dot s-error', - ]); - expect(panel.text()).toContain('spawn ENOENT'); - expect(panel.text()).toContain('2 tools'); - - // Restart is a ghost icon button in the row's action cluster. - await panel.findAll('.icon-btn')[1]!.trigger('click'); - expect(wrapper.emitted('restartConnector')).toEqual([['mcp_2']]); - }); -}); - -describe('SettingsDialog hooks page', () => { - it('groups hooks by event and shows what each one runs', async () => { - const wrapper = mountDialog({ - config: { - ...config, - hooks: [ - { event: 'PreToolUse', matcher: 'Bash', type: 'command', command: 'block-no-verify.sh' }, - { event: 'PreToolUse', type: 'command', command: 'observe.sh pre', timeout: 30 }, - { event: 'SessionStart', type: 'command', command: 'agent-state.sh', async: true }, - ], - }, - }); - await openTab(wrapper, 'Hooks'); - - const panel = wrapper.get('#settings-panel-hooks'); - expect(panel.findAll('.listing-head').map((head) => head.text())).toEqual([ - 'PreToolUse', - 'SessionStart', - ]); - // The matcher labels the row; a hook without one is the catch-all. - expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual([ - 'Bash', - '*', - '*', - ]); - expect(panel.text()).toContain('block-no-verify.sh'); - expect(panel.text()).toContain('30s timeout'); - expect(panel.text()).toContain('async'); - }); - - it('says so when no hook is configured', async () => { - const wrapper = mountDialog(); - await openTab(wrapper, 'Hooks'); - - expect(wrapper.get('#settings-panel-hooks').text()).toContain('No hooks are configured'); - }); -}); - -describe('SettingsDialog usage page', () => { - const sessions = [ - { - id: 'ses_1', - model: 'Pythinker K2', - usage: { inputTokens: 600, outputTokens: 400, turnCount: 3, totalCostUsd: 1.5 }, - }, - { - id: 'ses_2', - model: 'GPT-5', - usage: { inputTokens: 800, outputTokens: 200, turnCount: 2, totalCostUsd: 0.75 }, - }, - ] as unknown as Parameters[0]['sessions']; - - it('totals tokens, sessions, turns and cost', async () => { - const wrapper = mountDialog({ sessions }); - await openTab(wrapper, 'Usage stats'); - - const values = wrapper.get('#settings-panel-usage').findAll('.stat-value').map((v) => v.text()); - expect(values).toEqual(['2k', '2', '5', '$2.25']); - }); - - it('splits the token share per model, largest first', async () => { - const wrapper = mountDialog({ sessions }); - await openTab(wrapper, 'Usage stats'); - - const panel = wrapper.get('#settings-panel-usage'); - expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual([ - 'Pythinker K2', - 'GPT-5', - ]); - expect(panel.findAll('.listing-meta').map((meta) => meta.text())).toEqual(['50%', '50%']); - }); -}); - -describe('SettingsDialog plugins page', () => { - const plugins = [ - { - id: 'plg_1', - displayName: 'Cloudflare', - version: '1.2.0', - enabled: true, - state: 'loaded', - skillCount: 3, - mcpServerCount: 2, - hasErrors: false, - source: 'github', - }, - { - id: 'plg_2', - displayName: 'Designer', - enabled: false, - state: 'disabled', - skillCount: 1, - mcpServerCount: 0, - hasErrors: true, - source: 'local', - }, - ]; - - it('loads plugins the first time the page is opened', async () => { - const wrapper = mountDialog(); - await openTab(wrapper, 'Plugins'); - - expect(wrapper.emitted('loadPlugins')).toHaveLength(1); - }); - - it('shows each plugin with its counts and toggles one', async () => { - const wrapper = mountDialog({ plugins }); - await openTab(wrapper, 'Plugins'); - - const panel = wrapper.get('#settings-panel-plugins'); - expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual([ - 'Cloudflare', - 'Designer', - ]); - expect(panel.text()).toContain('3 skills · 2 servers'); - expect(panel.text()).toContain('reported errors'); - // The disabled plugin's row recedes and its switch is off. - expect(panel.findAll('.listing-row')[1]!.classes()).toContain('off'); - - await panel.findAll('.switch')[1]!.trigger('click'); - expect(wrapper.emitted('setPluginEnabled')).toEqual([ - [{ pluginId: 'plg_2', enabled: true }], - ]); - }); -}); - -describe('SettingsDialog subagents page', () => { - const subagents = [ - { - name: 'Explore', - description: 'Read-only search agent', - source: 'built-in' as const, - tools: ['Read', 'Grep', 'Glob'], - model: 'Pythinker K2', - effort: 'max', - }, - ]; - - it('loads subagents the first time the page is opened', async () => { - const wrapper = mountDialog(); - await openTab(wrapper, 'Subagents'); - - expect(wrapper.emitted('loadSubagents')).toHaveLength(1); - }); - - it('shows the profile source, tool count, model and effort', async () => { - const wrapper = mountDialog({ subagents }); - await openTab(wrapper, 'Subagents'); - - const panel = wrapper.get('#settings-panel-subagents'); - expect(panel.get('.listing-name').text()).toBe('Explore'); - expect(panel.findAll('.tag').map((tag) => tag.text())).toEqual([ - 'built-in', - '3 tools', - 'max', - ]); - expect(panel.text()).toContain('Pythinker K2'); - expect(panel.text()).toContain('Read-only search agent'); - }); -}); diff --git a/apps/pythinker-web/test/settings-pane.test.ts b/apps/pythinker-web/test/settings-pane.test.ts new file mode 100644 index 000000000..18cf47b3e --- /dev/null +++ b/apps/pythinker-web/test/settings-pane.test.ts @@ -0,0 +1,436 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { createI18n } from 'vue-i18n'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { AppConfig, AppConnector, AppModel, AppSession, AppSkill } from '../src/api/types'; +import SettingsNav from '../src/components/settings/SettingsNav.vue'; +import SettingsPane from '../src/components/settings/SettingsPane.vue'; +import { messages } from '../src/i18n/locales'; +import { useSettingsNav, type SettingsTab } from '../src/composables/useSettingsNav'; + +vi.mock('../src/composables/useIsMobile', async () => { + const { ref: vueRef } = await import('vue'); + return { useIsMobile: () => vueRef(false) }; +}); + +vi.mock('../src/composables/useIsDark', async () => { + const { ref: vueRef } = await import('vue'); + return { useIsDark: () => vueRef(false) }; +}); + +vi.mock('../src/composables/usePythinkerWebClient', async () => { + const { ref: vueRef } = await import('vue'); + const arrayKeys = [ + 'activationBadges', 'changes', 'connectors', 'dynamicWorkflows', 'models', 'pendingApprovals', + 'plugins', 'providers', 'questions', 'queued', 'recentCwds', 'sessions', 'sessionsForView', + 'sessionsWithUsage', 'sideChatTurns', 'skills', 'starredModelIds', 'subagents', 'tasks', 'todos', + 'turns', 'warnings', 'workspaceGroups', 'workspacesView', + ]; + const recordKeys = ['attentionBySession', 'attentionByWorkspace', 'pendingBySession', 'unreadBySession']; + const client: Record = { + activePullRequest: vueRef(null), + activeSessionId: vueRef(''), + activeWorkspaceId: vueRef(null), + activity: vueRef('idle'), + authReady: vueRef(true), + betaToc: vueRef(false), + colorScheme: vueRef('system'), + compaction: vueRef(null), + config: vueRef(null), + connectorsLoading: vueRef(false), + defaultModel: vueRef(null), + dynamicWorkflowMode: vueRef(false), + fastSpinner: vueRef(false), + fileDiff: vueRef(null), + fileDiffLoading: vueRef(false), + gitDiffStats: vueRef(null), + gitInfo: vueRef(null), + goal: vueRef(null), + goalMode: vueRef(false), + initialized: vueRef(true), + isSending: vueRef(false), + notifyOnComplete: vueRef(false), + notifyPermission: vueRef('default'), + onboarded: vueRef(true), + permission: vueRef('manual'), + planMode: vueRef(false), + selectedDiffPath: vueRef(null), + sessionCost: vueRef(0), + sessionLoading: vueRef(false), + sideChatRunning: vueRef(false), + sideChatSending: vueRef(false), + sideChatVisible: vueRef(false), + status: vueRef({ branch: '', cwd: '/workspace', ctxMax: 0, ctxUsed: 0, model: '', modelId: '', permission: 'manual' }), + theme: vueRef('modern'), + thinking: vueRef('off'), + uiFontSize: vueRef(15), + visibleWorkspace: vueRef(null), + resolveImageUrl: vi.fn(), + }; + for (const key of arrayKeys) client[key] = vueRef([]); + for (const key of recordKeys) client[key] = vueRef({}); + return { + usePythinkerWebClient: () => new Proxy(client, { + get(target, property) { + if (property in target) return Reflect.get(target, property); + const method = vi.fn(); + target[String(property)] = method; + return method; + }, + }), + }; +}); + +import App from '../src/App.vue'; +import ConversationPane from '../src/components/ConversationPane.vue'; + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages, + missingWarn: false, + fallbackWarn: false, +}); + +const config: AppConfig = { + providers: { + pythinker: { type: 'pythoughts', defaultModel: 'pythinker/k2', hasApiKey: true }, + openai: { type: 'openai', hasApiKey: false }, + }, + defaultModel: 'pythinker/k2', + models: { + 'pythinker/k2': { provider: 'pythinker', model: 'k2' }, + 'openai/gpt-5': { provider: 'openai', model: 'gpt-5' }, + }, + defaultPermissionMode: 'manual', + defaultThinking: true, + defaultPlanMode: false, + mergeAllAvailableSkills: false, + telemetry: true, + raw: { secret: 'must-not-render' }, +}; + +const models: AppModel[] = [ + { id: 'pythinker/k2', provider: 'pythinker', model: 'k2', displayName: 'Pythinker K2', maxContextSize: 128000 }, + { id: 'openai/gpt-5', provider: 'openai', model: 'gpt-5', displayName: 'GPT-5', maxContextSize: 256000 }, +]; + +const skills: AppSkill[] = [ + { name: 'gen-changesets', description: 'Write the changesets for a PR', source: 'project', path: '.pythinker/skills/gen-changesets' }, + { name: 'brainstorm', description: 'Explore a problem first', source: 'builtin' }, + { name: 'archive', description: 'Archive a session', source: 'builtin', disableModelInvocation: true }, +]; + +const connectors: AppConnector[] = [ + { id: 'mcp_1', name: 'context7', transport: 'http', status: 'connected', toolCount: 2 }, + { id: 'mcp_2', name: 'tavily', transport: 'stdio', status: 'error', toolCount: 0, lastError: 'spawn ENOENT' }, +]; + +function mountPane(activeTab: SettingsTab, extraProps: Record = {}) { + return mount(SettingsPane, { + props: { + activeTab, + theme: 'modern', + colorScheme: 'system', + uiFontSize: 15, + authReady: true, + accountModel: 'pythinker/k2', + notify: true, + notifyPermission: 'granted', + betaToc: false, + config, + models, + configSaving: false, + ...extraProps, + }, + global: { plugins: [i18n] }, + }); +} + +afterEach(() => { + document.body.innerHTML = ''; + delete window.pythinkerDesktop; +}); + +describe('settings navigation', () => { + it('renders the ten grouped tabs and emits a selected tab', async () => { + const wrapper = mount(SettingsNav, { + props: { activeTab: 'general' }, + global: { plugins: [i18n] }, + }); + + expect(wrapper.findAll('.tab')).toHaveLength(10); + expect(wrapper.findAll('.tab-group').map((group) => group.text())).toEqual([ + 'Basics', + 'Agent capabilities', + 'Data and statistics', + ]); + expect(wrapper.get('#settings-tab-general').classes()).toContain('on'); + + await wrapper.get('#settings-tab-agent').trigger('click'); + expect(wrapper.emitted('select')).toEqual([['agent']]); + await wrapper.setProps({ activeTab: 'agent' }); + expect(wrapper.get('#settings-tab-agent').attributes('aria-selected')).toBe('true'); + }); + + it('renders only the active settings page without modal markup', async () => { + const wrapper = mountPane('general'); + + expect(wrapper.get('#settings-panel-general').isVisible()).toBe(true); + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + expect(wrapper.find('.backdrop').exists()).toBe(false); + + await wrapper.setProps({ activeTab: 'agent' }); + expect(wrapper.get('#settings-panel-general').attributes('style')).toContain('display: none'); + expect(wrapper.get('#settings-panel-agent').isVisible()).toBe(true); + }); + + it('loads connectors each time the empty page is opened', () => { + const onLoadConnectors = vi.fn(); + const { setTab } = useSettingsNav({ + counts: { connectors: 0, plugins: 0, subagents: 0 }, + onLoadConnectors, + onLoadPlugins: vi.fn(), + onLoadSubagents: vi.fn(), + }); + + setTab('connectors'); + expect(onLoadConnectors).toHaveBeenCalledOnce(); + setTab('general'); + setTab('connectors'); + expect(onLoadConnectors).toHaveBeenCalledTimes(2); + }); + + it('does not load connectors when they are already known', () => { + const onLoadConnectors = vi.fn(); + const { setTab } = useSettingsNav({ + counts: { connectors: 2, plugins: 0, subagents: 0 }, + onLoadConnectors, + onLoadPlugins: vi.fn(), + onLoadSubagents: vi.fn(), + }); + + setTab('connectors'); + expect(onLoadConnectors).not.toHaveBeenCalled(); + }); + + it('loads plugins when the empty page is opened', () => { + const onLoadPlugins = vi.fn(); + const { setTab } = useSettingsNav({ + counts: { connectors: 0, plugins: 0, subagents: 0 }, + onLoadConnectors: vi.fn(), + onLoadPlugins, + onLoadSubagents: vi.fn(), + }); + + setTab('plugins'); + expect(onLoadPlugins).toHaveBeenCalledOnce(); + }); + + it('loads subagents when the empty page is opened', () => { + const onLoadSubagents = vi.fn(); + const { setTab } = useSettingsNav({ + counts: { connectors: 0, plugins: 0, subagents: 0 }, + onLoadConnectors: vi.fn(), + onLoadPlugins: vi.fn(), + onLoadSubagents, + }); + + setTab('subagents'); + expect(onLoadSubagents).toHaveBeenCalledOnce(); + }); +}); + +describe('SettingsPane config controls', () => { + it('renders redacted daemon config and emits partial config patches', async () => { + const wrapper = mountPane('agent'); + + expect(wrapper.text()).toContain('Agent defaults'); + expect(wrapper.text()).toContain('Pythinker K2'); + expect(wrapper.text()).toContain('Credential configured'); + expect(wrapper.text()).toContain('Missing credential'); + expect(wrapper.text()).not.toContain('must-not-render'); + + await wrapper.find('.select-field').setValue('openai/gpt-5'); + expect(wrapper.emitted('updateConfig')?.[0]?.[0]).toEqual({ defaultModel: 'openai/gpt-5' }); + const auto = wrapper.findAll('.opt').find((button) => button.text() === 'Auto'); + await auto!.trigger('click'); + expect(wrapper.emitted('updateConfig')?.[1]?.[0]).toEqual({ defaultPermissionMode: 'auto' }); + const planRow = wrapper.findAll('.row').find((row) => row.text().includes('Plan mode by default')); + await planRow!.find('button.switch').trigger('click'); + expect(wrapper.emitted('updateConfig')?.[2]?.[0]).toEqual({ defaultPlanMode: true }); + }); + + it('groups default model options by provider', () => { + const groups = mountPane('agent').findAll('optgroup'); + + expect(groups).toHaveLength(2); + expect(groups[0]!.attributes('label')).toBe('openai'); + expect(groups[1]!.attributes('label')).toBe('pythinker'); + expect(groups[0]!.findAll('option').some((option) => option.attributes('value') === 'openai/gpt-5')).toBe(true); + expect(groups[1]!.findAll('option').some((option) => option.attributes('value') === 'pythinker/k2')).toBe(true); + }); +}); + +describe('SettingsPane desktop updates', () => { + it('renders desktop update controls and checks for updates', async () => { + const checkForUpdates = vi.fn().mockResolvedValue(undefined); + window.pythinkerDesktop = { + platform: 'darwin', + getUpdateState: vi.fn().mockResolvedValue({ status: 'idle', autoUpdate: true }), + setAutoUpdate: vi.fn().mockResolvedValue({ status: 'idle', autoUpdate: true }), + checkForUpdates, + quitAndInstall: vi.fn().mockResolvedValue(undefined), + onUpdateState: vi.fn().mockReturnValue(() => undefined), + }; + const wrapper = mountPane('general'); + + expect(wrapper.text()).toContain('Desktop app'); + const checkButton = wrapper.findAll('button').find((button) => button.text() === 'Check for updates'); + await checkButton!.trigger('click'); + expect(checkForUpdates).toHaveBeenCalledOnce(); + }); + + it('hides desktop update controls outside the desktop app', () => { + expect(mountPane('general').text()).not.toContain('Desktop app'); + }); +}); + +describe('SettingsPane skills page', () => { + it('groups skills by source and marks the slash-only ones', () => { + const panel = mountPane('skills', { skills }).get('#settings-panel-skills'); + + expect(panel.findAll('.listing-head').map((head) => head.text())).toEqual(['builtin', 'project']); + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['archive', 'brainstorm', 'gen-changesets']); + expect(panel.findAll('.tag').map((tag) => tag.text())).toEqual(['slash only']); + }); + + it('says so when no skill is available', () => { + expect(mountPane('skills').get('#settings-panel-skills').text()).toContain('No skills are available'); + }); +}); + +describe('SettingsPane connectors page', () => { + it('shows each server status and restarts one on demand', async () => { + const wrapper = mountPane('connectors', { connectors }); + const panel = wrapper.get('#settings-panel-connectors'); + + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['context7', 'tavily']); + expect(panel.findAll('.dot').map((dot) => dot.classes().join(' '))).toEqual(['dot s-connected', 'dot s-error']); + expect(panel.text()).toContain('spawn ENOENT'); + expect(panel.text()).toContain('2 tools'); + await panel.findAll('.icon-btn')[1]!.trigger('click'); + expect(wrapper.emitted('restartConnector')).toEqual([['mcp_2']]); + }); +}); + +describe('SettingsPane hooks page', () => { + it('groups hooks by event and shows what each one runs', () => { + const panel = mountPane('hooks', { + config: { + ...config, + hooks: [ + { event: 'PreToolUse', matcher: 'Bash', type: 'command', command: 'block-no-verify.sh' }, + { event: 'PreToolUse', type: 'command', command: 'observe.sh pre', timeout: 30 }, + { event: 'SessionStart', type: 'command', command: 'agent-state.sh', async: true }, + ], + }, + }).get('#settings-panel-hooks'); + + expect(panel.findAll('.listing-head').map((head) => head.text())).toEqual(['PreToolUse', 'SessionStart']); + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['Bash', '*', '*']); + expect(panel.text()).toContain('block-no-verify.sh'); + expect(panel.text()).toContain('30s timeout'); + expect(panel.text()).toContain('async'); + }); + + it('says so when no hook is configured', () => { + expect(mountPane('hooks').get('#settings-panel-hooks').text()).toContain('No hooks are configured'); + }); +}); + +describe('SettingsPane usage page', () => { + const sessions = [ + { id: 'ses_1', model: 'Pythinker K2', usage: { inputTokens: 600, outputTokens: 400, turnCount: 3, totalCostUsd: 1.5 } }, + { id: 'ses_2', model: 'GPT-5', usage: { inputTokens: 800, outputTokens: 200, turnCount: 2, totalCostUsd: 0.75 } }, + ] as AppSession[]; + + it('totals tokens, sessions, turns and cost', () => { + const values = mountPane('usage', { sessions }).get('#settings-panel-usage').findAll('.stat-value').map((value) => value.text()); + expect(values).toEqual(['2k', '2', '5', '$2.25']); + }); + + it('splits the token share per model, largest first', () => { + const panel = mountPane('usage', { sessions }).get('#settings-panel-usage'); + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['Pythinker K2', 'GPT-5']); + expect(panel.findAll('.listing-meta').map((meta) => meta.text())).toEqual(['50%', '50%']); + }); +}); + +describe('SettingsPane plugins page', () => { + const plugins = [ + { id: 'plg_1', displayName: 'Cloudflare', version: '1.2.0', enabled: true, state: 'loaded', skillCount: 3, mcpServerCount: 2, hasErrors: false, source: 'github' }, + { id: 'plg_2', displayName: 'Designer', enabled: false, state: 'disabled', skillCount: 1, mcpServerCount: 0, hasErrors: true, source: 'local' }, + ]; + + it('shows each plugin with its counts and toggles one', async () => { + const wrapper = mountPane('plugins', { plugins }); + const panel = wrapper.get('#settings-panel-plugins'); + + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['Cloudflare', 'Designer']); + expect(panel.text()).toContain('3 skills · 2 servers'); + expect(panel.text()).toContain('reported errors'); + expect(panel.findAll('.listing-row')[1]!.classes()).toContain('off'); + await panel.findAll('.switch')[1]!.trigger('click'); + expect(wrapper.emitted('setPluginEnabled')).toEqual([[{ pluginId: 'plg_2', enabled: true }]]); + }); +}); + +describe('SettingsPane subagents page', () => { + const subagents = [{ + name: 'Explore', + description: 'Read-only search agent', + source: 'built-in' as const, + tools: ['Read', 'Grep', 'Glob'], + model: 'Pythinker K2', + effort: 'max', + }]; + + it('shows the profile source, tool count, model and effort', () => { + const panel = mountPane('subagents', { subagents }).get('#settings-panel-subagents'); + expect(panel.get('.listing-name').text()).toBe('Explore'); + expect(panel.findAll('.tag').map((tag) => tag.text())).toEqual(['built-in', '3 tools', 'max']); + expect(panel.text()).toContain('Pythinker K2'); + expect(panel.text()).toContain('Read-only search agent'); + }); +}); + +describe('desktop settings route', () => { + it('swaps the sidebar body and main content while settings is open', async () => { + const wrapper = shallowMount(App, { + global: { + plugins: [i18n], + stubs: { Sidebar: false, SettingsNav: false, SettingsPane: false }, + }, + }); + + expect(wrapper.find('.sessions').exists()).toBe(true); + expect(wrapper.findComponent(ConversationPane).exists()).toBe(true); + + await wrapper.get('.side-foot .settings-row').trigger('click'); + await nextTick(); + + expect(wrapper.find('.settings-tabs').exists()).toBe(true); + expect(wrapper.find('.sessions').exists()).toBe(false); + expect(wrapper.findComponent(SettingsPane).exists()).toBe(true); + expect(wrapper.findComponent(ConversationPane).exists()).toBe(false); + + await wrapper.findAll('.sidebar-rail .rail-btn').at(-1)!.trigger('click'); + await nextTick(); + + expect.soft(wrapper.find('.settings-tabs').exists()).toBe(false); + expect.soft(wrapper.find('.sessions').exists()).toBe(true); + expect.soft(wrapper.findComponent(ConversationPane).exists()).toBe(true); + }); +}); From 45431c55e3efac68af4c86d8837529bdc996563a Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 16 Aug 2026 23:11:02 -0400 Subject: [PATCH 10/16] fix(web): leave the settings route on new session and move the gear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Session and Back to sessions both exit the route now — the sidebar shows one body at a time, so starting a draft while the settings pane held the content area left the new session invisible. The sessions-mode gear moves to the trailing edge of the footer; the settings-mode back arrow keeps the leading edge. --- apps/pythinker-web/src/App.vue | 4 ++++ apps/pythinker-web/src/components/Sidebar.vue | 8 ++++++- apps/pythinker-web/test/settings-pane.test.ts | 21 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/pythinker-web/src/App.vue b/apps/pythinker-web/src/App.vue index ec74b97da..85ab9eb39 100644 --- a/apps/pythinker-web/src/App.vue +++ b/apps/pythinker-web/src/App.vue @@ -880,6 +880,9 @@ function handleCloseAddWorkspace(): void { // right pane shows the onboarding composer. The session is only created when // the user sends the first message. function handleCreateSession(): void { + // Starting a session leaves the settings route — the new draft has to be + // visible, and the content area can only show one of the two. + showSettings.value = false; const wsId = client.activeWorkspaceId.value; if (wsId) { client.openWorkspaceDraft(wsId); @@ -892,6 +895,7 @@ function handleCreateSession(): void { // state in the chosen workspace. No backend session is created until the user // actually sends a message. function handleCreateSessionInWorkspace(workspaceId: string): void { + showSettings.value = false; client.openWorkspaceDraft(workspaceId); } diff --git a/apps/pythinker-web/src/components/Sidebar.vue b/apps/pythinker-web/src/components/Sidebar.vue index 93b8d4ae0..2c462defe 100644 --- a/apps/pythinker-web/src/components/Sidebar.vue +++ b/apps/pythinker-web/src/components/Sidebar.vue @@ -669,7 +669,7 @@ onBeforeUnmount(() => {
-