@hienlh/ppm 0.13.87 → 0.13.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{audio-preview-CGlpWhgr.js → audio-preview-CUIJFOwv.js} +1 -1
  5. package/dist/web/assets/chat-tab-Bq1I6z2m.js +16 -0
  6. package/dist/web/assets/{code-editor-DP88cKaY.js → code-editor-DJwyZl0r.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-BeoPXciT.js → conflict-editor-PGKAsN-h.js} +1 -1
  8. package/dist/web/assets/{database-viewer-Y8XylYBB.js → database-viewer-DAqD73p3.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-Cb3db8F6.js → diff-viewer-DRlDATQT.js} +1 -1
  10. package/dist/web/assets/{docx-preview-DjTD6P6D.js → docx-preview-DbaPlCqB.js} +1 -1
  11. package/dist/web/assets/{extension-webview-Dk8ddm19.js → extension-webview-DMj9G5Po.js} +1 -1
  12. package/dist/web/assets/{git-log-panel-LXe9nyBs.js → git-log-panel-Dy6gNl-B.js} +1 -1
  13. package/dist/web/assets/{glide-data-grid-DZQDtmiO.js → glide-data-grid-BbnDo-8v.js} +1 -1
  14. package/dist/web/assets/{image-preview-KEBnOWRV.js → image-preview-BS_AtRhx.js} +1 -1
  15. package/dist/web/assets/index-D-rGwbQ3.css +2 -0
  16. package/dist/web/assets/{index-DFsZOUXU.js → index-Z19QnKM_.js} +3 -3
  17. package/dist/web/assets/keybindings-store-BRyZRYax.js +1 -0
  18. package/dist/web/assets/{markdown-renderer-C1aREZeW.js → markdown-renderer-D-tteKCI.js} +1 -1
  19. package/dist/web/assets/notification-store-gHx4anzy.js +1 -0
  20. package/dist/web/assets/{pdf-preview-DpD6lwbe.js → pdf-preview-B4iy8M0h.js} +1 -1
  21. package/dist/web/assets/{port-forwarding-tab-CeTcNsRi.js → port-forwarding-tab-CNHUZvG5.js} +1 -1
  22. package/dist/web/assets/{postgres-viewer-aszM5Fcd.js → postgres-viewer-USxLRNuo.js} +1 -1
  23. package/dist/web/assets/{settings-tab-Degb8F2N.js → settings-tab-DuhQTnn5.js} +1 -1
  24. package/dist/web/assets/{sql-query-editor-Dd7g17iv.js → sql-query-editor-DFCopLXf.js} +1 -1
  25. package/dist/web/assets/{sqlite-viewer-DoUIUYFP.js → sqlite-viewer-Dmztw-o5.js} +1 -1
  26. package/dist/web/assets/{system-monitor-tab-ByFvt-X1.js → system-monitor-tab-OkICyNSb.js} +1 -1
  27. package/dist/web/assets/{terminal-tab-CbIyX35G.js → terminal-tab-Dsbs_Tnl.js} +1 -1
  28. package/dist/web/assets/{video-preview-B7FgYduA.js → video-preview-Dr00uBM7.js} +1 -1
  29. package/dist/web/index.html +2 -2
  30. package/dist/web/sw.js +1 -1
  31. package/package.json +1 -1
  32. package/src/providers/claude-agent-sdk.ts +1 -1
  33. package/src/server/ws/chat.ts +55 -4
  34. package/src/services/db.service.ts +17 -0
  35. package/src/types/api.ts +3 -2
  36. package/src/types/chat.ts +6 -0
  37. package/src/web/components/chat/chat-tab.tsx +4 -0
  38. package/src/web/components/chat/message-input.tsx +25 -0
  39. package/src/web/components/chat/model-selector.tsx +138 -0
  40. package/src/web/hooks/use-chat.ts +19 -0
  41. package/dist/web/assets/chat-tab-ClgbHbv9.js +0 -16
  42. package/dist/web/assets/index-DXxsPKPw.css +0 -2
  43. package/dist/web/assets/keybindings-store-C1GQSO5O.js +0 -1
  44. package/dist/web/assets/notification-store-CQPEClB9.js +0 -1
@@ -3,10 +3,20 @@ import { providerRegistry } from "../../providers/registry.ts";
3
3
  import { resolveProjectPath } from "../helpers/resolve-project.ts";
4
4
  import { logSessionEvent } from "../../services/session-log.service.ts";
5
5
  import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk";
6
- import { getSessionTitle, incrementSessionUnread, clearSessionUnread } from "../../services/db.service.ts";
6
+ import { getSessionTitle, incrementSessionUnread, clearSessionUnread, getSessionModel, setSessionModel } from "../../services/db.service.ts";
7
7
  import type { ChatWsClientMessage, SessionPhase } from "../../types/api.ts";
8
8
  import { startWatching, stopWatching, onFileChange } from "../../services/file-watcher.service.ts";
9
9
  import { bashOutputSpy } from "../../services/bash-output-spy.ts";
10
+ import { configService } from "../../services/config.service.ts";
11
+
12
+ /** Resolve the model shown in session_state: per-session override, else provider default. */
13
+ function resolveSessionModel(sessionId: string): string | undefined {
14
+ const override = getSessionModel(sessionId);
15
+ if (override) return override;
16
+ const ai = configService.get("ai");
17
+ const pid = ai.default_provider ?? "claude";
18
+ return ai.providers[pid]?.model;
19
+ }
10
20
 
11
21
  // Broadcast file changes to all WS clients for real-time editor reload
12
22
  onFileChange((projectName, path) => {
@@ -42,6 +52,8 @@ interface SessionEntry {
42
52
  currentUserMessage?: string;
43
53
  streamPromise?: Promise<void>;
44
54
  permissionMode?: string;
55
+ /** Per-session model override; falls back to provider default when undefined */
56
+ model?: string;
45
57
  /** Whether the persistent event consumer loop is running */
46
58
  isStreamingActive: boolean;
47
59
  /** Active team watchers keyed by team name */
@@ -203,7 +215,7 @@ function startCleanupTimer(sessionId: string): void {
203
215
  * First message creates the query; follow-ups push into the provider's
204
216
  * message channel. Events from ALL turns flow through this single loop.
205
217
  */
206
- async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }>): Promise<void> {
218
+ async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }>, model?: string): Promise<void> {
207
219
  const entry = activeSessions.get(sessionId);
208
220
  if (!entry) {
209
221
  console.error(`[chat] session=${sessionId} startSessionConsumer: no entry — aborting`);
@@ -255,7 +267,7 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
255
267
  broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
256
268
  }, 5_000);
257
269
 
258
- for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images })) {
270
+ for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images, ...(model && { model }) })) {
259
271
  eventCount++;
260
272
  const ev = event as any;
261
273
  const evType = ev.type ?? "unknown";
@@ -520,6 +532,7 @@ export const chatWebSocket = {
520
532
  pendingApproval: existing.pendingApprovalEvent ?? null,
521
533
  sessionTitle: session?.title || null,
522
534
  compactStatus: existing.compactStatus ?? null,
535
+ model: resolveSessionModel(sessionId),
523
536
  }));
524
537
 
525
538
  // If actively streaming, send buffered turn events for reconnect sync
@@ -561,6 +574,7 @@ export const chatWebSocket = {
561
574
  teamWatchers: new Map(),
562
575
  teamNames: new Set(),
563
576
  compactStatus: null,
577
+ model: getSessionModel(sessionId) ?? undefined,
564
578
  };
565
579
  activeSessions.set(sessionId, newEntry);
566
580
  setupClientPing(newEntry, ws);
@@ -573,6 +587,7 @@ export const chatWebSocket = {
573
587
  pendingApproval: null,
574
588
  sessionTitle: session?.title || null,
575
589
  compactStatus: null,
590
+ model: resolveSessionModel(sessionId),
576
591
  }));
577
592
 
578
593
  // Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
@@ -615,6 +630,7 @@ export const chatWebSocket = {
615
630
  providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
616
631
  pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
617
632
  teamWatchers: new Map(), teamNames: new Set(), compactStatus: null,
633
+ model: getSessionModel(sessionId) ?? undefined,
618
634
  };
619
635
  activeSessions.set(sessionId, newEntry);
620
636
  setupClientPing(newEntry, ws);
@@ -640,6 +656,7 @@ export const chatWebSocket = {
640
656
  pendingApproval: entry.pendingApprovalEvent ?? null,
641
657
  sessionTitle: chatService.getSession(sessionId)?.title || null,
642
658
  compactStatus: entry.compactStatus ?? null,
659
+ model: resolveSessionModel(sessionId),
643
660
  }));
644
661
  if (entry.phase !== "idle") {
645
662
  sendTurnEvents(sessionId, ws);
@@ -675,6 +692,11 @@ export const chatWebSocket = {
675
692
  if (parsed.permissionMode) {
676
693
  entry.permissionMode = parsed.permissionMode;
677
694
  }
695
+ // Store model override — sticky for this session
696
+ if (parsed.model) {
697
+ entry.model = parsed.model;
698
+ setSessionModel(sessionId, parsed.model);
699
+ }
678
700
 
679
701
  // Intercept PPM-handled built-in commands (e.g. /skills, /version)
680
702
  const content = parsed.content.trim();
@@ -717,10 +739,11 @@ export const chatWebSocket = {
717
739
  setPhase(sessionId, "initializing");
718
740
 
719
741
  const permMode = entry.permissionMode;
742
+ const msgModel = entry.model;
720
743
  const msgImages = parsed.type === "message" ? parsed.images : undefined;
721
744
  entry.streamPromise = new Promise<void>((resolve) => {
722
745
  setTimeout(() => {
723
- startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages).then(resolve, resolve);
746
+ startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages, msgModel).then(resolve, resolve);
724
747
  }, 0);
725
748
  });
726
749
  } else {
@@ -737,6 +760,34 @@ export const chatWebSocket = {
737
760
  setPhase(sessionId, "thinking");
738
761
  console.log(`[chat] session=${sessionId} follow-up pushed to generator`);
739
762
  }
763
+ } else if (parsed.type === "set_model") {
764
+ // Persist per-session model override. If an idle subprocess is alive,
765
+ // abort it so the next message recreates the query with the new model
766
+ // (history preserved via the resume path). No-op if already streaming.
767
+ if (!parsed.model || typeof parsed.model !== "string") {
768
+ ws.send(JSON.stringify({ type: "error", message: "model is required" }));
769
+ return;
770
+ }
771
+ entry.model = parsed.model;
772
+ setSessionModel(sessionId, parsed.model);
773
+ const provider = providerRegistry.get(providerId);
774
+ const hasLiveStream = provider?.hasStreamingSession?.(sessionId) ?? false;
775
+ // Only abort when idle between turns — never interrupt an active turn.
776
+ // Aborting the idle-but-alive subprocess forces the next message to take
777
+ // the resume path, recreating the query with the new model.
778
+ if (hasLiveStream && entry.phase === "idle") {
779
+ provider?.abortQuery?.(sessionId, "set_model");
780
+ }
781
+ logSessionEvent(sessionId, "INFO", `Model switched to ${parsed.model}`);
782
+ ws.send(JSON.stringify({
783
+ type: "session_state",
784
+ sessionId,
785
+ phase: entry.phase,
786
+ pendingApproval: entry.pendingApprovalEvent ?? null,
787
+ sessionTitle: chatService.getSession(sessionId)?.title || null,
788
+ compactStatus: entry.compactStatus ?? null,
789
+ model: resolveSessionModel(sessionId),
790
+ }));
740
791
  } else if (parsed.type === "cancel") {
741
792
  // Fully teardown streaming session — user must resume to continue
742
793
  const provider = providerRegistry.get(providerId);
@@ -645,6 +645,11 @@ function runMigrations(database: Database): void {
645
645
  PRAGMA user_version = 26;
646
646
  `);
647
647
  }
648
+
649
+ if (current < 27) {
650
+ try { database.exec("ALTER TABLE session_metadata ADD COLUMN model TEXT"); } catch {}
651
+ database.exec("PRAGMA user_version = 27;");
652
+ }
648
653
  }
649
654
 
650
655
  // ---------------------------------------------------------------------------
@@ -801,6 +806,18 @@ export function deleteSessionMetadata(sessionId: string): void {
801
806
  getDb().query("DELETE FROM session_metadata WHERE session_id = ?").run(sessionId);
802
807
  }
803
808
 
809
+ /** Per-session model override; null when session uses provider default */
810
+ export function getSessionModel(sessionId: string): string | null {
811
+ const row = getDb().query("SELECT model FROM session_metadata WHERE session_id = ?").get(sessionId) as { model: string | null } | null;
812
+ return row?.model ?? null;
813
+ }
814
+
815
+ export function setSessionModel(sessionId: string, model: string): void {
816
+ getDb().query(
817
+ "INSERT INTO session_metadata (session_id, model) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET model = excluded.model",
818
+ ).run(sessionId, model);
819
+ }
820
+
804
821
  // ---------------------------------------------------------------------------
805
822
  // Unread tracking
806
823
  // ---------------------------------------------------------------------------
package/src/types/api.ts CHANGED
@@ -23,8 +23,9 @@ export type TerminalWsMessage =
23
23
 
24
24
  /** WebSocket message types (chat) */
25
25
  export type ChatWsClientMessage =
26
- | { type: "message"; content: string; permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }
26
+ | { type: "message"; content: string; permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }>; model?: string }
27
27
  | { type: "cancel" }
28
+ | { type: "set_model"; model: string }
28
29
  | { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown }
29
30
  | { type: "ready" };
30
31
 
@@ -42,7 +43,7 @@ export type ChatWsServerMessage =
42
43
  | { type: "error"; message: string }
43
44
  | { type: "account_info"; accountId: string; accountLabel: string }
44
45
  | { type: "phase_changed"; phase: SessionPhase; elapsed?: number }
45
- | { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null }
46
+ | { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null; model?: string }
46
47
  | { type: "turn_events"; events: unknown[] }
47
48
  | { type: "title_updated"; title: string }
48
49
  | { type: "compact_status"; status: "compacting" | "done" }
package/src/types/chat.ts CHANGED
@@ -2,6 +2,8 @@ export interface SendMessageOpts {
2
2
  permissionMode?: import("./config").PermissionMode | string;
3
3
  priority?: 'now' | 'next' | 'later';
4
4
  images?: Array<{ data: string; mediaType: string }>;
5
+ /** Per-session model override; falls back to provider config model when absent */
6
+ model?: string;
5
7
  }
6
8
 
7
9
  export interface AIProvider {
@@ -33,6 +35,8 @@ export interface AIProvider {
33
35
  markAsResumed?(sessionId: string): void;
34
36
  isAvailable?(): Promise<boolean>;
35
37
  listModels?(): Promise<ModelOption[]>;
38
+ /** True when a live streaming subprocess exists for this session */
39
+ hasStreamingSession?(sessionId: string): boolean;
36
40
  }
37
41
 
38
42
  export interface ModelOption {
@@ -47,6 +51,8 @@ export interface Session {
47
51
  projectName?: string;
48
52
  projectPath?: string;
49
53
  createdAt: string;
54
+ /** Per-session model override (e.g. claude-opus-4-8); falls back to provider config default */
55
+ model?: string;
50
56
  }
51
57
 
52
58
  export interface SessionConfig {
@@ -106,6 +106,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
106
106
  compactStatus,
107
107
  statusMessage,
108
108
  sessionTitle,
109
+ model,
110
+ setModel,
109
111
  sendMessage,
110
112
  respondToApproval,
111
113
  cancelStreaming,
@@ -516,6 +518,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
516
518
  onModeChange={setPermissionMode}
517
519
  providerId={providerId}
518
520
  onProviderChange={!sessionId ? setProviderId : undefined}
521
+ model={model}
522
+ onModelChange={setModel}
519
523
  />
520
524
  )}
521
525
  </div>
@@ -7,6 +7,7 @@ import { isImageFile } from "@/lib/file-support";
7
7
  import { AttachmentChips } from "./attachment-chips";
8
8
  import { ModeSelector, getModeLabel, getModeIcon } from "./mode-selector";
9
9
  import { ProviderSelector } from "./provider-selector";
10
+ import { ModelSelector } from "./model-selector";
10
11
  import type { SlashItem } from "./slash-command-picker";
11
12
  import type { FileNode } from "../../../types/project";
12
13
  import { useFileStore } from "@/stores/file-store";
@@ -62,6 +63,10 @@ interface MessageInputProps {
62
63
  providerId?: string;
63
64
  /** Provider change handler — undefined when session is active (locked) */
64
65
  onProviderChange?: (providerId: string) => void;
66
+ /** Current per-session model (null = provider default) */
67
+ model?: string | null;
68
+ /** Model change handler — undefined when no active session */
69
+ onModelChange?: (model: string) => void;
65
70
  }
66
71
 
67
72
  export const MessageInput = memo(function MessageInput({
@@ -86,6 +91,8 @@ export const MessageInput = memo(function MessageInput({
86
91
  onModeChange,
87
92
  providerId,
88
93
  onProviderChange,
94
+ model,
95
+ onModelChange,
89
96
  }: MessageInputProps) {
90
97
  // Uncontrolled textarea: value lives in DOM + ref, not React state.
91
98
  // Only `hasText` state triggers re-renders (empty↔non-empty for send button).
@@ -643,6 +650,15 @@ export const MessageInput = memo(function MessageInput({
643
650
  projectName={projectName}
644
651
  />
645
652
  )}
653
+ {onModelChange && projectName && (
654
+ <ModelSelector
655
+ value={model ?? null}
656
+ onChange={onModelChange}
657
+ projectName={projectName}
658
+ providerId={providerId ?? "claude"}
659
+ disabled={isStreaming}
660
+ />
661
+ )}
646
662
  {isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
647
663
  </div>
648
664
  {/* Mobile: single row — attach + textarea + mic + send */}
@@ -751,6 +767,15 @@ export const MessageInput = memo(function MessageInput({
751
767
  projectName={projectName}
752
768
  />
753
769
  )}
770
+ {onModelChange && projectName && (
771
+ <ModelSelector
772
+ value={model ?? null}
773
+ onChange={onModelChange}
774
+ projectName={projectName}
775
+ providerId={providerId ?? "claude"}
776
+ disabled={isStreaming}
777
+ />
778
+ )}
754
779
  {isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
755
780
  </div>
756
781
  <div className="flex items-center gap-1">
@@ -0,0 +1,138 @@
1
+ import { useState, useEffect, useRef, useCallback, type KeyboardEvent } from "react";
2
+ import { Check, Sparkles } from "lucide-react";
3
+ import { api, projectUrl } from "@/lib/api-client";
4
+
5
+ interface ModelOption {
6
+ value: string;
7
+ label: string;
8
+ }
9
+
10
+ interface ModelSelectorProps {
11
+ value: string | null;
12
+ onChange: (model: string) => void;
13
+ projectName: string;
14
+ providerId: string;
15
+ /** When true, the chip is shown but not interactive (e.g. while streaming) */
16
+ disabled?: boolean;
17
+ }
18
+
19
+ /** Strip the leading "Claude " so the chip stays compact: "Claude Opus 4.8" → "Opus 4.8" */
20
+ function shortLabel(label: string): string {
21
+ return label.replace(/^Claude\s+/i, "");
22
+ }
23
+
24
+ /**
25
+ * Model selector chip + popup — matches ProviderSelector style.
26
+ * Hidden when only 1 (or no) model is available.
27
+ * Interactive only when not disabled (model can't change mid-turn).
28
+ */
29
+ export function ModelSelector({ value, onChange, projectName, providerId, disabled }: ModelSelectorProps) {
30
+ const [models, setModels] = useState<ModelOption[]>([]);
31
+ const [open, setOpen] = useState(false);
32
+ const panelRef = useRef<HTMLDivElement>(null);
33
+ const focusedRef = useRef(0);
34
+
35
+ useEffect(() => {
36
+ if (!projectName || !providerId) return;
37
+ api.get<ModelOption[]>(`${projectUrl(projectName)}/chat/providers/${providerId}/models`)
38
+ .then(setModels)
39
+ .catch(() => {});
40
+ }, [projectName, providerId]);
41
+
42
+ // Close popup if it becomes disabled mid-open
43
+ useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
44
+
45
+ // Close on click outside
46
+ useEffect(() => {
47
+ if (!open) return;
48
+ const handler = (e: MouseEvent) => {
49
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
50
+ setOpen(false);
51
+ }
52
+ };
53
+ document.addEventListener("mousedown", handler);
54
+ return () => document.removeEventListener("mousedown", handler);
55
+ }, [open]);
56
+
57
+ // Focus current on open
58
+ useEffect(() => {
59
+ if (open) {
60
+ focusedRef.current = Math.max(0, models.findIndex((m) => m.value === value));
61
+ }
62
+ }, [open, value, models]);
63
+
64
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
65
+ if (e.key === "Escape") { setOpen(false); return; }
66
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
67
+ e.preventDefault();
68
+ const dir = e.key === "ArrowDown" ? 1 : -1;
69
+ focusedRef.current = (focusedRef.current + dir + models.length) % models.length;
70
+ const el = panelRef.current?.querySelector(`[data-idx="${focusedRef.current}"]`) as HTMLElement;
71
+ el?.focus();
72
+ }
73
+ if (e.key === "Enter") {
74
+ e.preventDefault();
75
+ const m = models[focusedRef.current];
76
+ if (m) { onChange(m.value); setOpen(false); }
77
+ }
78
+ }, [onChange, models]);
79
+
80
+ // Hide when ≤1 model
81
+ if (models.length <= 1) return null;
82
+
83
+ const current = models.find((m) => m.value === value);
84
+ const display = current ? shortLabel(current.label) : (value ?? "Model");
85
+
86
+ return (
87
+ <div className="relative">
88
+ {/* Chip — same style as ProviderSelector */}
89
+ <button
90
+ type="button"
91
+ disabled={disabled}
92
+ onClick={(e) => { e.stopPropagation(); if (!disabled) setOpen((v) => !v); }}
93
+ className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-text-subtle hover:text-text-primary hover:bg-surface-elevated transition-colors border border-transparent hover:border-border disabled:opacity-50 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent disabled:hover:text-text-subtle"
94
+ aria-label={`Model: ${current?.label ?? value ?? "default"}`}
95
+ title={disabled ? "Model can't change while running" : current?.label ?? undefined}
96
+ >
97
+ <Sparkles className="h-3.5 w-3.5 shrink-0" />
98
+ <span className="max-w-[90px] truncate">{display}</span>
99
+ </button>
100
+
101
+ {/* Popup panel */}
102
+ {open && !disabled && (
103
+ <div
104
+ ref={panelRef}
105
+ role="listbox"
106
+ aria-label="Models"
107
+ onKeyDown={handleKeyDown}
108
+ onMouseDown={(e) => e.stopPropagation()}
109
+ onClick={(e) => e.stopPropagation()}
110
+ className="absolute bottom-full left-0 mb-1 z-50 w-56 rounded-lg border border-border bg-surface shadow-lg"
111
+ >
112
+ <div className="px-3 py-2 border-b border-border">
113
+ <span className="text-xs font-medium text-text-secondary">Model</span>
114
+ </div>
115
+ <div className="py-1">
116
+ {models.map((m, idx) => {
117
+ const isActive = m.value === value;
118
+ return (
119
+ <button
120
+ key={m.value}
121
+ data-idx={idx}
122
+ role="option"
123
+ aria-selected={isActive}
124
+ tabIndex={0}
125
+ onClick={() => { onChange(m.value); setOpen(false); }}
126
+ className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-surface-elevated focus:bg-surface-elevated focus:outline-none ${isActive ? "bg-surface-elevated" : ""}`}
127
+ >
128
+ <span className="flex-1 text-sm font-medium text-text-primary">{m.label}</span>
129
+ {isActive && <Check className="size-4 shrink-0 text-primary" />}
130
+ </button>
131
+ );
132
+ })}
133
+ </div>
134
+ </div>
135
+ )}
136
+ </div>
137
+ );
138
+ }
@@ -58,6 +58,10 @@ interface UseChatReturn {
58
58
  compactStatus: "compacting" | null;
59
59
  statusMessage: string | null;
60
60
  sessionTitle: string | null;
61
+ /** Per-session model override (null = provider default) */
62
+ model: string | null;
63
+ /** Switch the per-session model (persists + recreates query on next message) */
64
+ setModel: (model: string) => void;
61
65
  /** Team activity state from WS events */
62
66
  teamActivity: TeamActivityState;
63
67
  /** All team messages (ref-backed, updated live) */
@@ -99,6 +103,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
99
103
  const [statusMessage, setStatusMessage] = useState<string | null>(null);
100
104
  const [sessionTitle, setSessionTitle] = useState<string | null>(null);
101
105
  const [isConnected, setIsConnected] = useState(false);
106
+ const [model, setModelState] = useState<string | null>(null);
107
+ const modelRef = useRef<string | null>(null);
102
108
  const streamingContentRef = useRef("");
103
109
  const streamingEventsRef = useRef<ChatEvent[]>([]);
104
110
  const bashOutputRef = useRef<Map<string, BashPartialEntry>>(new Map());
@@ -540,6 +546,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
540
546
  setPhase(p);
541
547
  phaseRef.current = p;
542
548
  if (state.sessionTitle) setSessionTitle(state.sessionTitle);
549
+ if (state.model) { setModelState(state.model); modelRef.current = state.model; }
543
550
  if (state.pendingApproval) {
544
551
  setPendingApproval({
545
552
  requestId: state.pendingApproval.requestId,
@@ -737,11 +744,21 @@ export function useChat(sessionId: string | null, providerId = "claude", project
737
744
  permissionMode: opts?.permissionMode,
738
745
  priority: opts?.priority,
739
746
  images: opts?.images,
747
+ ...(modelRef.current && { model: modelRef.current }),
740
748
  }));
741
749
  },
742
750
  [send],
743
751
  );
744
752
 
753
+ const setModel = useCallback(
754
+ (nextModel: string) => {
755
+ setModelState(nextModel); // optimistic
756
+ modelRef.current = nextModel;
757
+ send(JSON.stringify({ type: "set_model", model: nextModel }));
758
+ },
759
+ [send],
760
+ );
761
+
745
762
  const respondToApproval = useCallback(
746
763
  (requestId: string, approved: boolean, data?: unknown) => {
747
764
  send(
@@ -879,6 +896,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
879
896
  compactStatus,
880
897
  statusMessage,
881
898
  sessionTitle,
899
+ model,
900
+ setModel,
882
901
  teamActivity,
883
902
  teamMessages,
884
903
  markTeamRead,