diff --git a/.changeset/derive-model-capabilities.md b/.changeset/derive-model-capabilities.md new file mode 100644 index 00000000..41498147 --- /dev/null +++ b/.changeset/derive-model-capabilities.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Report each model's real capabilities in the catalog. Until now `capabilities` carried only what a user had typed into their config file by hand, so for almost every model it was empty. It is now derived from the model itself when the config says nothing, while an explicit list in the config still wins. A provider whose capabilities are genuinely unknown keeps omitting the field rather than claiming the model can do nothing. diff --git a/.changeset/desktop-update-toast.md b/.changeset/desktop-update-toast.md new file mode 100644 index 00000000..c77f0945 --- /dev/null +++ b/.changeset/desktop-update-toast.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Prompt for desktop updates with a toast that offers install or skip, centre the settings button in the sidebar footer, and start a new session when the sidebar brand is clicked. diff --git a/.changeset/preserve-provider-ids.md b/.changeset/preserve-provider-ids.md new file mode 100644 index 00000000..87065181 --- /dev/null +++ b/.changeset/preserve-provider-ids.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Keep provider and model ids exactly as written when a config patch is saved, so an id containing an underscore still resolves. diff --git a/.changeset/session-capability-wiring.md b/.changeset/session-capability-wiring.md new file mode 100644 index 00000000..1b446240 --- /dev/null +++ b/.changeset/session-capability-wiring.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Make `agent_config.tools` and `agent_config.mcp_servers` reach the running agent. A session profile update now persists the selection, merges each field independently so supplying one half does not clear the other, resumes an inactive session before the mutation, and applies the result through a single `setActiveTools` call. MCP server names are turned into tool patterns with the shared naming helper, so a server whose name needs sanitizing still matches its tools. diff --git a/.changeset/web-capability-menu-a11y.md b/.changeset/web-capability-menu-a11y.md new file mode 100644 index 00000000..0f742ce1 --- /dev/null +++ b/.changeset/web-capability-menu-a11y.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix web capability and retry controls: the capability panel takes keyboard focus when it opens, Retry stays reachable with Tab, rapid capability toggles reach the daemon in order, and retrying a prompt keeps its attachments. diff --git a/.changeset/web-capability-menu.md b/.changeset/web-capability-menu.md new file mode 100644 index 00000000..1e9252f0 --- /dev/null +++ b/.changeset/web-capability-menu.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add a capability menu to the composer. It picks which tools and MCP servers the current session may use, lists the session's skills, and turns plugins on or off. Each group states how far its change reaches, because the three differ: tool and MCP changes apply to this session at once, skills are read-only here, and plugin changes are global to the daemon. Selected tools and servers appear as chips beside the composer controls. diff --git a/.changeset/web-composer-shell.md b/.changeset/web-composer-shell.md new file mode 100644 index 00000000..cc0a9107 --- /dev/null +++ b/.changeset/web-composer-shell.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Widen the chat reading column to 928px and restyle the composer card: a 24px radius, a translucent blurred surface, a border that strengthens on hover and focus, and an input that grows to 384px before it scrolls. The toolbar controls are 30px circles with a divider after the attachment button. diff --git a/.changeset/web-empty-suggestions.md b/.changeset/web-empty-suggestions.md new file mode 100644 index 00000000..7aff4b08 --- /dev/null +++ b/.changeset/web-empty-suggestions.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': minor +--- + +Add starter suggestions to the empty conversation screen in the web UI. Each suggestion fills the composer for editing and does not send the message. diff --git a/.changeset/web-message-actions.md b/.changeset/web-message-actions.md new file mode 100644 index 00000000..65f8d717 --- /dev/null +++ b/.changeset/web-message-actions.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': minor +--- + +Add a Retry action to the last assistant reply and a copy button to user messages in the web UI. Retry asks for confirmation, then sends the original prompt again. diff --git a/.changeset/web-model-badges.md b/.changeset/web-model-badges.md new file mode 100644 index 00000000..5ad63da9 --- /dev/null +++ b/.changeset/web-model-badges.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Show model capabilities as badges in the model picker instead of a comma-separated string, and bring its rows and search field onto the app's row metrics, sized from `--ui-font-size` so the font-size setting still scales them. A model that reasons adaptively is now distinguishable from one that exposes an explicit thinking capability, and an unrecognised capability still renders rather than being dropped. diff --git a/.changeset/web-provider-routes.md b/.changeset/web-provider-routes.md new file mode 100644 index 00000000..14c09b3f --- /dev/null +++ b/.changeset/web-provider-routes.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Point the web provider calls at routes that exist. Adding a provider now writes through `POST /config`, refreshing reads `GET /providers/{id}`, and a new `DELETE /providers/{provider_id}` route removes a provider together with the model aliases that referenced it. diff --git a/.changeset/web-settings-tokens.md b/.changeset/web-settings-tokens.md new file mode 100644 index 00000000..c938b8d4 --- /dev/null +++ b/.changeset/web-settings-tokens.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': patch +--- + +Make the web settings surface use the app design tokens: token corner radii, a theme-aware switch shadow, and control sizes that grow with the UI font size. diff --git a/.changeset/web-sidebar-rows.md b/.changeset/web-sidebar-rows.md new file mode 100644 index 00000000..d97d6551 --- /dev/null +++ b/.changeset/web-sidebar-rows.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Quieten the sidebar session rows. Hover becomes a translucent wash instead of a solid fill, the selected row becomes a faint tint instead of a solid accent, and the radius and sizing match the shared menu row, so the row scales with the UI font-size setting. The same change is applied to the per-theme overrides, so all three themes agree. diff --git a/.changeset/web-ui-primitives.md b/.changeset/web-ui-primitives.md new file mode 100644 index 00000000..52279761 --- /dev/null +++ b/.changeset/web-ui-primitives.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Add four shared UI primitives to the web app: `Popover`, `MenuRow`, `SwitchToggle` and `Chip`. `Popover` holds the anchored-menu positioning that each menu used to write for itself, including the flip above the trigger and the viewport clamp. `MenuRow` carries the standard list row, sized from `--ui-font-size` so the font-size setting still scales it. All four style themselves only from theme tokens, and a guard test fails on any colour literal. diff --git a/apps/pythinker-web/src/App.vue b/apps/pythinker-web/src/App.vue index 7078b150..43fd9c8d 100644 --- a/apps/pythinker-web/src/App.vue +++ b/apps/pythinker-web/src/App.vue @@ -18,6 +18,7 @@ import SettingsPane from './components/settings/SettingsPane.vue'; import SessionsDialog from './components/SessionsDialog.vue'; import AddWorkspaceDialog from './components/AddWorkspaceDialog.vue'; import StatusPanel from './components/StatusPanel.vue'; +import UpdateToast from './components/UpdateToast.vue'; import WarningToasts from './components/WarningToasts.vue'; import MobileTopBar from './components/MobileTopBar.vue'; import MobileSwitcherSheet from './components/MobileSwitcherSheet.vue'; @@ -749,6 +750,17 @@ async function handleEditMessage(text: string): Promise { conversationPaneRef.value?.loadComposerForEdit(text); } +// Retry the last assistant reply: undo the exchange, then send its original +// user prompt as a new prompt. Undo reports any failure and returns null. +async function handleRegenerate(): Promise { + const prompt = await client.undo(1); + if (prompt === null) return; + await client.sendPrompt( + prompt.text, + prompt.attachments.length > 0 ? prompt.attachments : undefined, + ); +} + // Handler for slash commands emitted by Composer (via ConversationPane) function handleCommand(cmd: string): void { // `/compact ` carries an optional free-text instruction steering what @@ -1090,6 +1102,7 @@ function openPr(url: string): void { @update-config="handleUpdateConfig($event)" @login="loginFromSettings" @open-onboarding="openOnboardingFromSettings" + @close="showSettings = false" /> @@ -1335,6 +1349,9 @@ function openPr(url: string): void { + + + @@ -1411,6 +1428,11 @@ function openPr(url: string): void { height: env(titlebar-area-height, 44px); -webkit-app-region: drag; user-select: none; + /* Windows has no vibrancy, so the bar earns its own tone: a shade darker than + the shell with a hairline under it, which also frames the window buttons. */ + background: color-mix(in srgb, var(--ink) 4%, var(--panel)); + border-bottom: 1px solid var(--line); + box-sizing: border-box; } .auth-page { flex: 1; diff --git a/apps/pythinker-web/src/api/daemon/client.ts b/apps/pythinker-web/src/api/daemon/client.ts index 07a54b8a..2075915e 100644 --- a/apps/pythinker-web/src/api/daemon/client.ts +++ b/apps/pythinker-web/src/api/daemon/client.ts @@ -14,6 +14,7 @@ import type { AppConnector, AppPlugin, AppSkill, + AppTool, AppSubagent, AppSessionCursor, AppSessionRuntimeStatus, @@ -130,6 +131,14 @@ interface WireSkillDescriptor { disable_model_invocation?: boolean; } +interface WireToolDescriptor { + name: string; + description: string; + input_schema: unknown; + source: 'builtin' | 'skill' | 'mcp'; + mcp_server_id?: string; +} + interface WireMcpServer { id: string; name: string; @@ -381,6 +390,8 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; + tools?: string[]; + mcpServers?: string[]; }, ): Promise { const body: Record = {}; @@ -394,6 +405,8 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { if (input.goalObjective !== undefined) agentConfig['goal_objective'] = input.goalObjective; if (input.goalControl !== undefined) agentConfig['goal_control'] = input.goalControl; if (input.thinking !== undefined) agentConfig['thinking'] = input.thinking; + if (input.tools !== undefined) agentConfig['tools'] = input.tools; + if (input.mcpServers !== undefined) agentConfig['mcp_servers'] = input.mcpServers; if (Object.keys(agentConfig).length > 0) body['agent_config'] = agentConfig; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/profile`, @@ -729,6 +742,23 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { } // ------------------------------------------------------------------------- + // Tools — session-visible tool descriptors + // GET /tools?session_id={id} → { tools: WireToolDescriptor[] } + // ------------------------------------------------------------------------- + + async listTools(sessionId: string): Promise { + const data = await this.http.get<{ tools: WireToolDescriptor[] }>('/tools', { + session_id: sessionId, + }); + return (data.tools ?? []).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.input_schema, + source: tool.source, + mcpServerId: tool.mcp_server_id, + })); + } + // Skills — session-scoped slash-invocable skills // GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] } // POST /sessions/{id}/skills/{name}:activate body { args? } → { activated, skill_name } @@ -1106,17 +1136,14 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { // ------------------------------------------------------------------------- // Models + Providers - // PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. // ------------------------------------------------------------------------- async listModels(): Promise { - // PRESUMED endpoint: GET /v1/models → { items: WireModel[] } const data = await this.http.get<{ items: WireModel[] }>('/models'); return data.items.map(toAppModel); } async listProviders(): Promise { - // PRESUMED endpoint: GET /v1/providers → { items: WireProvider[] } const data = await this.http.get<{ items: WireProvider[] }>('/providers'); return data.items.map(toAppProvider); } @@ -1127,29 +1154,46 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { baseUrl?: string; defaultModel?: string; }): Promise { - // PRESUMED endpoint: POST /v1/providers → WireProvider - const body: Record = { type: input.type }; - if (input.apiKey !== undefined) body['api_key'] = input.apiKey; - if (input.baseUrl !== undefined) body['base_url'] = input.baseUrl; - if (input.defaultModel !== undefined) body['default_model'] = input.defaultModel; - const data = await this.http.post('/providers', body); + const providerId = input.type.replaceAll('_', '-'); + const modelId = input.defaultModel ?? providerId; + const modelAlias = `${providerId}/${modelId}`.replaceAll('_', '-'); + await this.http.post('/config', { + providers: { + [providerId]: { + type: input.type, + api_key: input.apiKey, + base_url: input.baseUrl, + default_model: input.defaultModel, + }, + }, + models: { + [modelAlias]: { + provider: providerId, + model: modelId, + max_context_size: 262_144, + }, + }, + default_model: modelAlias, + }); + const data = await this.http.get( + `/providers/${encodeURIComponent(providerId)}`, + ); return toAppProvider(data); } async deleteProvider(id: string): Promise<{ deleted: true }> { - // PRESUMED endpoint: DELETE /v1/providers/{id} → { deleted: true } return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`); } async refreshProvider(id: string): Promise { - // PRESUMED endpoint: POST /v1/providers/{id}:refresh → WireProvider - const data = await this.http.post( - `/providers/${encodeURIComponent(id)}:refresh`, + const data = await this.http.get( + `/providers/${encodeURIComponent(id)}`, ); return toAppProvider(data); } async refreshOAuthProviderModels(): Promise { + // No server route or core RPC currently backs this presumed endpoint. const data = await this.http.post('/providers:refresh_oauth'); return { changed: data.changed.map((item) => ({ diff --git a/apps/pythinker-web/src/api/daemon/mappers.ts b/apps/pythinker-web/src/api/daemon/mappers.ts index 3e81ce9d..da6ecde8 100644 --- a/apps/pythinker-web/src/api/daemon/mappers.ts +++ b/apps/pythinker-web/src/api/daemon/mappers.ts @@ -101,6 +101,8 @@ export function toAppSession(wire: WireSession): AppSession { currentPromptId: wire.current_prompt_id, cwd: wire.metadata.cwd, model: wire.agent_config.model, + tools: wire.agent_config.tools, + mcpServers: wire.agent_config.mcp_servers, usage: toAppSessionUsage(wire.usage), messageCount: wire.message_count, lastSeq: wire.last_seq, diff --git a/apps/pythinker-web/src/api/types.ts b/apps/pythinker-web/src/api/types.ts index 5f995fa0..2775f0d0 100644 --- a/apps/pythinker-web/src/api/types.ts +++ b/apps/pythinker-web/src/api/types.ts @@ -69,6 +69,10 @@ export interface AppSession { currentPromptId?: string; cwd: string; model: string; + /** Exact tool names configured for this session, when explicitly set. */ + tools?: string[]; + /** MCP server ids configured for this session, when explicitly set. */ + mcpServers?: string[]; usage: AppSessionUsage; messageCount: number; lastSeq: number; @@ -651,6 +655,15 @@ export interface AppConnector { lastError?: string; } +/** One tool available to the current session. */ +export interface AppTool { + name: string; + description: string; + inputSchema: unknown; + source: 'builtin' | 'skill' | 'mcp'; + mcpServerId?: string; +} + // --------------------------------------------------------------------------- // PythinkerWebApi — the app-facing interface // --------------------------------------------------------------------------- @@ -662,7 +675,7 @@ export interface PythinkerWebApi { createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; /** Fetch one session by id (deep links beyond the first listSessions page). */ getSession(sessionId: string): Promise; - updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; dynamicWorkflowMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise; + updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; dynamicWorkflowMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; tools?: string[]; mcpServers?: string[] }): Promise; getSessionStatus(sessionId: string): Promise; archiveSession(sessionId: string): Promise<{ archived: true }>; listMessages(sessionId: string, input?: PageRequest & { role?: AppMessageRole }): Promise>; @@ -687,6 +700,7 @@ export interface PythinkerWebApi { respondQuestion(sessionId: string, questionId: string, response: QuestionResponse): Promise<{ resolved: true; resolvedAt: string }>; dismissQuestion(sessionId: string, questionId: string): Promise<{ dismissed: true; dismissedAt: string }>; listSkills(sessionId: string): Promise; + listTools(sessionId: string): Promise; activateSkill(sessionId: string, skillName: string, args?: string): Promise<{ activated: true; skillName: string }>; /** Configured MCP servers — GET /mcp/servers. */ listConnectors(): Promise; diff --git a/apps/pythinker-web/src/components/CapabilityMenu.vue b/apps/pythinker-web/src/components/CapabilityMenu.vue new file mode 100644 index 00000000..2b1cc8ff --- /dev/null +++ b/apps/pythinker-web/src/components/CapabilityMenu.vue @@ -0,0 +1,498 @@ + + + + + diff --git a/apps/pythinker-web/src/components/ChatDock.vue b/apps/pythinker-web/src/components/ChatDock.vue index 242931fe..5cdbf9e4 100644 --- a/apps/pythinker-web/src/components/ChatDock.vue +++ b/apps/pythinker-web/src/components/ChatDock.vue @@ -332,8 +332,9 @@ defineExpose({ loadForEdit }); /* Use a different surface so the card radius reads against the page, especially in dark mode; --sh only exists on the modern and pythinker themes, hence the fallback. */ .chat-dock :deep(.composer-card) { - background: var(--panel); - box-shadow: var(--sh, 0 6px 18px rgba(0, 0, 0, 0.28)); + background: color-mix(in srgb, var(--panel) 78%, transparent); + backdrop-filter: blur(16px); + box-shadow: var(--sh, 0 6px 18px color-mix(in srgb, var(--ink) 28%, transparent)); } .chat-dock.align-center { margin-left: auto; margin-right: auto; } diff --git a/apps/pythinker-web/src/components/ChatPane.vue b/apps/pythinker-web/src/components/ChatPane.vue index 127d844f..f8b2b246 100644 --- a/apps/pythinker-web/src/components/ChatPane.vue +++ b/apps/pythinker-web/src/components/ChatPane.vue @@ -113,6 +113,8 @@ const emit = defineEmits<{ openAgent: [target: { turnId: string; blockIndex: number; memberId: string }]; /** Edit + resend the last user message (parent undoes, then refills composer). */ editMessage: [text: string]; + /** Undo the last exchange and send its user prompt again. */ + regenerate: []; }>(); // Id of the most recent user turn — the only one offered an "edit & resend" @@ -166,6 +168,7 @@ const copiedTurn = ref(null); // Undo/edit-and-resend confirmation state (keyed by turn id) const confirmingEditTurnId = ref(null); +const confirmingRetryTurnId = ref(null); const undoingTurnId = ref(null); let undoTimer: ReturnType | null = null; @@ -201,6 +204,12 @@ function confirmEditMessage(turn: ChatTurn): void { }, 240); } +function confirmRegenerate(): void { + if (confirmingRetryTurnId.value === null) return; + confirmingRetryTurnId.value = null; + emit('regenerate'); +} + // Copy-whole-conversation state const copiedConversation = ref(false); let copiedConversationTimer: ReturnType | null = null; @@ -302,6 +311,38 @@ function isAssistantRunEnd(index: number): boolean { return !next || next.role !== 'assistant'; } +function isFinalAssistantRun(index: number): boolean { + if (!isAssistantRunEnd(index)) return false; + for (let i = index + 1; i < props.turns.length; i += 1) { + if (props.turns[i]?.role === 'assistant') return false; + } + return true; +} + +function canRetryAssistantRun(index: number): boolean { + const turn = props.turns[index]; + if (!turn || turn.role !== 'assistant') return false; + + let precedingUser: ChatTurn | null = null; + for (let i = index - 1; i >= 0; i -= 1) { + if (props.turns[i]?.role === 'user') { + precedingUser = props.turns[i]!; + break; + } + } + + return ( + isFinalAssistantRun(index) && + turn.id !== streamingTurnId.value && + !props.running && + !props.sending && + precedingUser !== null && + precedingUser.id === lastUserTurnId.value && + !precedingUser.skillActivation && + assistantRunFinalText(index).trim().length > 0 + ); +} + // One shared timer: copying B within 1.4s of copying A must not let A's stale // timer hide B's checkmark early. Cleared on unmount. let copiedTimer: ReturnType | null = null; @@ -320,6 +361,18 @@ function copyAssistantRun(index: number): void { }).catch(() => {/* ignore */}); } +function copyUserTurn(turn: ChatTurn): void { + if (turn.skillActivation) return; + navigator.clipboard.writeText(turn.text).then(() => { + copiedTurn.value = turn.id; + if (copiedTimer !== null) clearTimeout(copiedTimer); + copiedTimer = setTimeout(() => { + copiedTimer = null; + copiedTurn.value = null; + }, 1400); + }).catch(() => {/* ignore */}); +} + // Ordered render blocks for an assistant turn. messagesToTurns supplies `blocks` // (thinking + text + tool cards in call order); fall back to deriving them from // the aggregate fields for any turn built without blocks (e.g. unit tests). @@ -473,7 +526,24 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
{{ turn.text }}
-
+
+
+ +
+ {{ t('conversation.retryConfirm') }} + + +
@@ -631,6 +723,17 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string { > + + + +
+ {{ t('conversation.retryConfirm') }} + + +
{{ formatDuration(turn.durationMs) }}
diff --git a/apps/pythinker-web/src/components/Composer.vue b/apps/pythinker-web/src/components/Composer.vue index ff1bf16e..7e3abf5e 100644 --- a/apps/pythinker-web/src/components/Composer.vue +++ b/apps/pythinker-web/src/components/Composer.vue @@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import SlashMenu from './SlashMenu.vue'; import MentionMenu from './MentionMenu.vue'; +import CapabilityMenu from './CapabilityMenu.vue'; import type { SlashCommand } from '../lib/slashCommands'; import { buildSlashItems, filterCommands, parseSlash } from '../lib/slashCommands'; import type { FileItem } from './MentionMenu.vue'; @@ -1078,6 +1079,8 @@ function selectModel(modelId: string): void { > +