Skip to content

Commit cba1341

Browse files
authored
feat(web): make settings an inline route and add the capability pages (#100)
## Related Issue No issue — this continues the settings/desktop UI work that landed in #97, and the problem is described below. ## Problem Settings was a modal. `SettingsDialog.vue` had grown to 1,571 lines fusing four unrelated jobs — the fixed backdrop and focus trap, its own tab navigation, ten page bodies, and the derived state each page needed — behind an interface of 22 props and 12 emits. Because the modal chrome was welded to the content, the pages could only ever render as an overlay: opening Settings hid the whole app behind a scrim, including the sidebar the user had just been navigating. Three smaller problems came with it: - The settings surface only exposed appearance, account and connection. Plugins, skills, subagents, hooks and usage had no UI at all, and plugins and subagent profiles had no REST surface either — they existed only over the internal core RPC. - A disabled skill could not be turned off from anywhere. - The README had no desktop download badge, and the desktop artifacts are published to a different repository than the one the README lives in. ## What changed **Settings became a route.** `SettingsDialog.vue` is deleted and replaced by focused modules: - `composables/useSettingsNav.ts` owns the `SettingsTab` union, the three nav groups, the active tab, and the guarded lazy loads. Counts come in as `MaybeRefOrGetter` so it reads live list lengths instead of duplicating state. - `components/settings/SettingsNav.vue` renders the nav and is hosted by the real `Sidebar`. - `components/settings/SettingsPane.vue` hosts the pages inline in the content area. No backdrop, nothing `position: fixed`. - Ten page components under `components/settings/pages/`, each taking only the props it needs and owning its own derived state. - `components/settings/ListingRow.vue` and `settings.css` carry the shared row and rules. `Sidebar` gained `mode: 'sessions' | 'settings'`. Its header and New Session button stay mounted in both modes; only the body below swaps. `ConversationPane` yields the content area to `SettingsPane`. Mobile is untouched — it stays a sheet. **Five new settings pages**, plus the backend they needed: - Skills, with an enable/disable switch. Turning one off writes a `disabledSkills` config list that `SessionSkillRegistry.register` filters. That is the single funnel every skill source goes through, so a disabled skill is invisible to the model, the slash menu and the API alike — built-ins included. - Plugins, Subagents, Connectors and Hooks. - Usage stats: token, session, turn and cost totals with a per-model share bar. - `ICatalogService` behind `GET /plugins`, `POST /plugins/{id}:set-enabled` and `GET /agent-profiles`, with protocol schemas for each. One service rather than two, because both collections are global rather than session-scoped. **Row design** follows a flat, borderless idiom: a leading glyph, the name and its tags inline, and a right-hand action cluster. Only the row content fades when an entry is off, so the switch that turns it back on keeps full contrast. **Model menu** splits into a model list and an effort list, with the effort levels derived from the model's declared `supportEfforts`. **README** gains three desktop download badges in the hero row. Per-platform counts cannot come from shields.io directly: its asset wildcards silently report `0` (a tag whose `.dmg` had 39,877 downloads returns `0` for `*.dmg`), exact asset names embed the version so they break on every release, and `dynamic/json` rejects every filter expression. A daily workflow sums the counts from the desktop releases repo and publishes them as shields `endpoint` documents on the orphan `badges` branch, which `main`'s protection rules do not block. ### Verification - `pnpm -C apps/pythinker-web exec vitest run` — 322 passed (58 files) - `pnpm -C apps/pythinker-web exec vue-tsc --noEmit` — clean - `pnpm run lint` — 0 errors - `pnpm run build` — clean - agent-core, protocol and server suites — green Every new assertion was proven able to fail by breaking the implementation it covers and confirming the red before restoring. ### Note for reviewers The active settings tab now persists across close and reopen. The modal remounted on every open and reset to General; the composable lives in `App.vue` and does not. This is a deliberate behaviour change, called out here rather than buried. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. — two `minor` changesets, both listing `@pymodel/pythinker-code`: the changed packages are all ignored by changesets, but the web app and the server both ship inside the CLI release artifact, so the CLI is the package that carries them. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a routed settings experience for agents, plugins, skills, subagents, connectors, hooks, usage, account, and advanced options. - Added plugin enable/disable controls, connector restart actions, catalog listings, and skill management. - Added support for disabling selected skills. - Improved model selection with separate thinking-effort navigation. - Added desktop download badges for macOS and Windows releases. - **Improvements** - Improved dock panel positioning while keeping the message composer fixed. - Added clearer loading, empty, status, and error states throughout settings. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 2d5691c commit cba1341

52 files changed

Lines changed: 3223 additions & 1254 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Add server endpoints that list installed plugins, enable or disable one, and list subagent profiles, and let a named skill be turned off so it is hidden from the model, the slash menu and the API.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Open the web settings inside the app shell instead of over it, and add pages for plugins, skills, subagents, connectors, hooks and usage statistics.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Refreshes the per-platform desktop download counters behind the README badges.
2+
#
3+
# shields.io cannot do this on its own: its asset wildcards silently report 0
4+
# (a tag whose .dmg had 39,877 downloads returns 0 for `*.dmg`), exact asset
5+
# names embed the version so they break on every release, and `dynamic/json`
6+
# rejects every filter expression. So we sum the counts here and publish them
7+
# as shields `endpoint` documents.
8+
#
9+
# The documents land on the orphan `badges` branch, never on `main` — `main` is
10+
# protected and rejects pushes from everyone, CI included.
11+
name: Desktop Download Badges
12+
13+
on:
14+
schedule:
15+
# Once a day is plenty; the counters move slowly and the API is rate-limited.
16+
- cron: '17 4 * * *'
17+
workflow_dispatch: {}
18+
19+
permissions:
20+
contents: write
21+
22+
concurrency:
23+
group: desktop-download-badges
24+
cancel-in-progress: true
25+
26+
jobs:
27+
refresh:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- name: Checkout the badges branch
31+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4
32+
with:
33+
ref: badges
34+
persist-credentials: true
35+
36+
- name: Sum the .dmg and .exe download counts
37+
env:
38+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39+
# Desktop installers shipped from this repo up to v0.1.0 and move to
40+
# pythinker-desktop-releases from the next release on. Summing both
41+
# keeps the counters continuous across that move.
42+
RELEASES_REPOS: PyModel/pythinker-code PyModel/pythinker-desktop-releases
43+
run: |
44+
set -euo pipefail
45+
46+
# --paginate walks every release, so the totals cover the whole
47+
# history rather than just the latest tag. jq -s concatenates the one
48+
# array each repo produces into a single list of releases.
49+
for repo in ${RELEASES_REPOS}; do
50+
gh api --paginate "repos/${repo}/releases"
51+
done | jq -s 'add' > releases.json
52+
53+
# An empty suffix matches every asset, which is what the combined
54+
# counter wants; `endswith("")` is true for any string.
55+
write_badge() {
56+
local suffix="$1" label="$2" out="$3"
57+
local total
58+
total="$(jq --arg s "$suffix" '
59+
[ .[].assets[]
60+
| select(.name | ascii_downcase | endswith($s))
61+
| .download_count
62+
] | add // 0
63+
' releases.json)"
64+
jq -n --arg label "$label" --arg message "$total" '{
65+
schemaVersion: 1,
66+
label: $label,
67+
message: $message,
68+
color: "4D6BFE"
69+
}' > "$out"
70+
echo "${label}: ${total}"
71+
}
72+
73+
write_badge '.dmg' 'macOS .dmg' desktop-dmg.json
74+
write_badge '.exe' 'Windows .exe' desktop-exe.json
75+
# The shields `github/downloads/.../total` route reads one repository,
76+
# so the combined counter has to be summed here like the other two.
77+
write_badge '' 'desktop' desktop-total.json
78+
79+
rm -f releases.json
80+
81+
- name: Publish if the counts moved
82+
run: |
83+
set -euo pipefail
84+
git config user.name 'github-actions[bot]'
85+
git config user.email 'github-actions[bot]@users.noreply.github.com'
86+
# Stage before comparing: a document added in this run is untracked,
87+
# and `git diff` on an untracked path reports no change at all.
88+
git add desktop-dmg.json desktop-exe.json desktop-total.json
89+
if git diff --cached --quiet; then
90+
echo 'Counts unchanged; nothing to publish.'
91+
exit 0
92+
fi
93+
git commit -m 'chore: refresh desktop download counts'
94+
git push origin badges

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
[![npm version](https://img.shields.io/npm/v/@pymodel/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=CB3837&label=pythinker-code)](https://www.npmjs.com/package/@pymodel/pythinker-code)
1313
[![Downloads](https://img.shields.io/npm/dm/@pymodel/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=2b89ff&label=downloads)](https://www.npmjs.com/package/@pymodel/pythinker-code)
14+
[![Desktop downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FPyModel%2Fpythinker-code%2Fbadges%2Fdesktop-total.json&style=for-the-badge&logo=github&logoColor=white)](https://github.com/PyModel/pythinker-desktop-releases/releases)
15+
[![macOS .dmg](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FPyModel%2Fpythinker-code%2Fbadges%2Fdesktop-dmg.json&style=for-the-badge&logo=apple&logoColor=white)](https://github.com/PyModel/pythinker-desktop-releases/releases/latest)
16+
[![Windows .exe](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FPyModel%2Fpythinker-code%2Fbadges%2Fdesktop-exe.json&style=for-the-badge&logo=windows&logoColor=white)](https://github.com/PyModel/pythinker-desktop-releases/releases/latest)
1417
[![Node.js](https://img.shields.io/badge/Node.js-26%2B-339933?style=for-the-badge&logo=nodedotjs&logoColor=white)](https://github.com/PyModel/pythinker-code/blob/main/package.json)
1518
[![License: MIT](https://img.shields.io/badge/License-MIT-16a34a.svg?style=for-the-badge)](https://github.com/PyModel/pythinker-code/blob/main/LICENSE)
1619
[![CI](https://img.shields.io/github/actions/workflow/status/PyModel/pythinker-code/ci.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white)](https://github.com/PyModel/pythinker-code/actions/workflows/ci.yml?query=branch%3Amain)

apps/pythinker-web/src/App.vue

Lines changed: 82 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { AgentMember } from './types';
1414
import ModelPicker from './components/ModelPicker.vue';
1515
import ProviderManager from './components/ProviderManager.vue';
1616
import NewSessionDialog from './components/NewSessionDialog.vue';
17-
import SettingsDialog from './components/SettingsDialog.vue';
17+
import SettingsPane from './components/settings/SettingsPane.vue';
1818
import SessionsDialog from './components/SessionsDialog.vue';
1919
import AddWorkspaceDialog from './components/AddWorkspaceDialog.vue';
2020
import StatusPanel from './components/StatusPanel.vue';
@@ -30,6 +30,7 @@ import { isTraceEnabled } from './debug/trace';
3030
import { usePythinkerWebClient } from './composables/usePythinkerWebClient';
3131
import { useIsMobile } from './composables/useIsMobile';
3232
import { useIsDark } from './composables/useIsDark';
33+
import { useSettingsNav } from './composables/useSettingsNav';
3334
import type { AppConfig, ThinkingLevel } from './api/types';
3435
import type { FilePreviewRequest, ToolMedia } from './types';
3536
@@ -610,6 +611,43 @@ const showAddWorkspace = ref(false);
610611
const showStatusPanel = ref(false);
611612
const showSettings = ref(false);
612613
614+
const {
615+
activeTab: activeSettingsTab,
616+
setTab: selectSettingsTab,
617+
refreshActiveTab: refreshSettingsTab,
618+
} = useSettingsNav({
619+
counts: {
620+
connectors: () => client.connectors.value.length,
621+
plugins: () => client.plugins.value.length,
622+
subagents: () => client.subagents.value.length,
623+
},
624+
onLoadConnectors: () => { void client.loadConnectors(); },
625+
onLoadPlugins: () => { void client.loadPlugins(); },
626+
onLoadSubagents: () => { void client.loadSubagents(); },
627+
});
628+
629+
function openSettings(): void {
630+
showSettings.value = true;
631+
// The active tab persists across visits, so its data has to be refetched on
632+
// open — the session it was loaded for may no longer be the active one.
633+
refreshSettingsTab();
634+
}
635+
636+
function toggleSettings(): void {
637+
if (showSettings.value) showSettings.value = false;
638+
else openSettings();
639+
}
640+
641+
function loginFromSettings(): void {
642+
showSettings.value = false;
643+
openLogin();
644+
}
645+
646+
function openOnboardingFromSettings(): void {
647+
showSettings.value = false;
648+
openOnboarding();
649+
}
650+
613651
type SubmitPayload = {
614652
text: string;
615653
attachments: { fileId: string; kind: 'image' | 'video' }[];
@@ -854,6 +892,9 @@ function handleCloseAddWorkspace(): void {
854892
// right pane shows the onboarding composer. The session is only created when
855893
// the user sends the first message.
856894
function handleCreateSession(): void {
895+
// Starting a session leaves the settings route — the new draft has to be
896+
// visible, and the content area can only show one of the two.
897+
showSettings.value = false;
857898
const wsId = client.activeWorkspaceId.value;
858899
if (wsId) {
859900
client.openWorkspaceDraft(wsId);
@@ -866,6 +907,7 @@ function handleCreateSession(): void {
866907
// state in the chosen workspace. No backend session is created until the user
867908
// actually sends a message.
868909
function handleCreateSessionInWorkspace(workspaceId: string): void {
910+
showSettings.value = false;
869911
client.openWorkspaceDraft(workspaceId);
870912
}
871913
@@ -918,6 +960,8 @@ function openPr(url: string): void {
918960
:attention-by-session="client.attentionBySession.value"
919961
:pending-by-session="client.pendingBySession.value"
920962
:unread-by-session="client.unreadBySession.value"
963+
:mode="showSettings ? 'settings' : 'sessions'"
964+
:active-settings-tab="activeSettingsTab"
921965
@select="client.selectSession($event)"
922966
@create="handleCreateSession"
923967
@create-in-workspace="handleCreateSessionInWorkspace($event)"
@@ -929,7 +973,9 @@ function openPr(url: string): void {
929973
@rename-workspace="(id, name) => client.renameWorkspace(id, name)"
930974
@delete-workspace="(id) => client.deleteWorkspace(id)"
931975
@select-workspaces="handleSelectWorkspaces"
932-
@open-settings="showSettings = true"
976+
@open-settings="openSettings"
977+
@close-settings="showSettings = false"
978+
@select-settings-tab="selectSettingsTab($event)"
933979
@collapse="toggleSidebarCollapse"
934980
/>
935981
<ResizeHandle
@@ -992,7 +1038,7 @@ function openPr(url: string): void {
9921038
class="rail-btn"
9931039
:title="t('settings.title')"
9941040
:aria-label="t('settings.title')"
995-
@click="showSettings = true"
1041+
@click="toggleSettings"
9961042
>
9971043
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
9981044
<circle cx="12" cy="12" r="3" />
@@ -1014,8 +1060,40 @@ function openPr(url: string): void {
10141060
@open-settings="showMobileSettings = true"
10151061
/>
10161062

1063+
<SettingsPane
1064+
v-if="showSettings && !isMobile"
1065+
:active-tab="activeSettingsTab"
1066+
:theme="client.theme.value"
1067+
:color-scheme="client.colorScheme.value"
1068+
:ui-font-size="client.uiFontSize.value"
1069+
:auth-ready="client.authReady.value"
1070+
:account-model="client.defaultModel.value"
1071+
:notify="client.notifyOnComplete.value"
1072+
:notify-permission="client.notifyPermission.value"
1073+
:beta-toc="client.betaToc.value"
1074+
:config="client.config.value"
1075+
:models="client.models.value"
1076+
:config-saving="configSaving"
1077+
:skills="client.skills.value"
1078+
:connectors="client.connectors.value"
1079+
:connectors-loading="client.connectorsLoading.value"
1080+
:sessions="client.sessionsWithUsage.value"
1081+
:plugins="client.plugins.value"
1082+
:subagents="client.subagents.value"
1083+
@set-plugin-enabled="client.setPluginEnabled($event.pluginId, $event.enabled)"
1084+
@restart-connector="client.restartConnector($event)"
1085+
@set-theme="client.setTheme($event)"
1086+
@set-color-scheme="client.setColorScheme($event)"
1087+
@set-ui-font-size="client.setUiFontSize($event)"
1088+
@set-notify="client.setNotifyOnComplete($event)"
1089+
@set-beta-toc="client.setBetaToc($event)"
1090+
@update-config="handleUpdateConfig($event)"
1091+
@login="loginFromSettings"
1092+
@open-onboarding="openOnboardingFromSettings"
1093+
/>
1094+
10171095
<ConversationPane
1018-
v-if="!hasMultiSelect"
1096+
v-else-if="!hasMultiSelect"
10191097
ref="conversationPaneRef"
10201098
:mobile="isMobile"
10211099
:modern="client.theme.value === 'modern' || client.theme.value === 'pythinker'"
@@ -1188,31 +1266,6 @@ function openPr(url: string): void {
11881266
@close="showModelPicker = false"
11891267
/>
11901268

1191-
<!-- Settings page (modal) -->
1192-
<SettingsDialog
1193-
v-if="showSettings"
1194-
:theme="client.theme.value"
1195-
:color-scheme="client.colorScheme.value"
1196-
:ui-font-size="client.uiFontSize.value"
1197-
:auth-ready="client.authReady.value"
1198-
:account-model="client.defaultModel.value"
1199-
:notify="client.notifyOnComplete.value"
1200-
:notify-permission="client.notifyPermission.value"
1201-
:beta-toc="client.betaToc.value"
1202-
:config="client.config.value"
1203-
:models="client.models.value"
1204-
:config-saving="configSaving"
1205-
@set-theme="client.setTheme($event)"
1206-
@set-color-scheme="client.setColorScheme($event)"
1207-
@set-ui-font-size="client.setUiFontSize($event)"
1208-
@set-notify="client.setNotifyOnComplete($event)"
1209-
@set-beta-toc="client.setBetaToc($event)"
1210-
@update-config="handleUpdateConfig($event)"
1211-
@login="() => { showSettings = false; openLogin(); }"
1212-
@open-onboarding="() => { showSettings = false; openOnboarding(); }"
1213-
@close="showSettings = false"
1214-
/>
1215-
12161269
<!-- Provider Manager overlay -->
12171270
<ProviderManager
12181271
v-if="showProviders"

0 commit comments

Comments
 (0)