From f1a59dcfd916363755308eca77f81243dbec0d6b Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:24:21 +0900 Subject: [PATCH 1/5] fix(sdk): resume session root resolution and reasoning-effort plumbing - resolveGjcResumeSessionRoot returns the session's own directory instead of the global sessions root; SessionManager.list scans only the supplied directory, so managed sessions one level below the root failed every resume with the sanitized 'GJC SDK configuration is invalid' error (4x on 08-15). - Thread an 'effort' run option through enrichGjcSdkRunOptions into the SDK session as thinkingLevel, validated against the known effort levels. --- server/gjc-bun-sdk-adapter.ts | 9 +++++++++ server/gjc-sdk-contract.bun.test.ts | 14 ++++++++++++++ server/gjc-worker-client.test.ts | 7 ++++--- server/gjc-worker-client.ts | 7 ++++++- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/server/gjc-bun-sdk-adapter.ts b/server/gjc-bun-sdk-adapter.ts index bc5a3a1..b3030b5 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -28,6 +28,7 @@ export type SdkRunConfig = { credential: ExactCredentialRef; modelId: string; modelProfile?: string; + effort?: 'default' | 'inherit' | 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; toolNames: string[]; spawns: string; bashPolicy: AppBashPolicy; @@ -84,6 +85,9 @@ function configFromOptions(value: Record): SdkRunConfig { || !exactCredentialRef(candidate.credential) || typeof candidate.modelId !== 'string' || !candidate.modelId || (candidate.modelProfile !== undefined && (typeof candidate.modelProfile !== 'string' || !candidate.modelProfile)) + || (candidate.effort !== undefined && ![ + 'default', 'inherit', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', + ].includes(String(candidate.effort))) || !Array.isArray(candidate.toolNames) || candidate.toolNames.some((name) => typeof name !== 'string' || !name) || typeof candidate.spawns !== 'string' || !object(candidate.bashPolicy) || !Array.isArray(candidate.bashPolicy.allowedPrefixes) @@ -305,6 +309,11 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { authStorage: this.authStorage, modelRegistry: this.modelRegistry, model, + ...( + config.effort && config.effort !== 'default' && config.effort !== 'inherit' + ? { thinkingLevel: config.effort } + : {} + ), providerSessionId: resumedId ?? sessionManager.getSessionId(), ...(resolvedCredential.credentialSelector ? { credentialSelector: resolvedCredential.credentialSelector } diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index b3dd07b..689bf1e 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -883,6 +883,20 @@ test('resume opens the sole exact session file and never re-emits session.create assert.equal(methods(f.frames).includes('session.created'), false); } finally { await f.close(); } }); +test('session effort is passed to the SDK as the turn thinking level', async () => { + const f = await fixture(); + try { + const run = f.host.handle(request('session.start', 'reasoning-effort', { + message: 'reason', + options: { ...f.options, effort: 'high' }, + })); + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + session.complete(); + await run; + assert.equal(f.factoryOptions[0]!.thinkingLevel, 'high'); + } finally { await f.close(); } +}); test('sequential runs clone global settings for each cwd while retaining the session root', async () => { const f = await fixture(); const firstCwd = await mkdtemp(join(tmpdir(), 'gjc-cwd-one-')); diff --git a/server/gjc-worker-client.test.ts b/server/gjc-worker-client.test.ts index 432be8c..5de56b5 100644 --- a/server/gjc-worker-client.test.ts +++ b/server/gjc-worker-client.test.ts @@ -58,10 +58,11 @@ test('resume root resolution selects either allowlisted store from indexed sessi await Promise.all([writeFile(paths.live, '{}\n'), writeFile(paths.saved, '{}\n')]); const lookup = async (sessionId: string) => paths[sessionId as keyof typeof paths]; - // The resolver returns the canonical (realpath) root; macOS resolves the - // temp store under /var to /private/var, so normalize expectations too. + // The resolver returns the directory SessionManager must scan. macOS + // resolves the temp store under /var to /private/var, so normalize + // expectations too. assert.equal(await resolveGjcResumeSessionRoot('live', liveRoot, lookup), await realpath(liveRoot)); - assert.equal(await resolveGjcResumeSessionRoot('saved', liveRoot, lookup), await realpath(savedRoot)); + assert.equal(await resolveGjcResumeSessionRoot('saved', liveRoot, lookup), await realpath(savedDirectory)); } finally { await Promise.all([ rm(tempDirectory, { recursive: true, force: true }), diff --git a/server/gjc-worker-client.ts b/server/gjc-worker-client.ts index caf7e52..2c716a7 100644 --- a/server/gjc-worker-client.ts +++ b/server/gjc-worker-client.ts @@ -220,6 +220,7 @@ export async function resolveGjcResumeSessionRoot( ): Promise { try { const sessionPath = await realpath(await lookup(sessionId) ?? ''); + const sessionDirectory = dirname(sessionPath); const roots = [ join(homedir(), '.gjc', 'agent', 'sessions'), liveSessionRoot, @@ -227,7 +228,10 @@ export async function resolveGjcResumeSessionRoot( for (const root of roots) { try { const canonicalRoot = await realpath(root); - if (containedBy(canonicalRoot, sessionPath)) return canonicalRoot; + // SessionManager.list() scans only the supplied directory. Managed + // sessions live one level below the global sessions root, so returning + // that global root makes every historical resume look missing. + if (containedBy(canonicalRoot, sessionPath)) return sessionDirectory; } catch { // A missing or inaccessible allowlist root cannot contain a resumable session. } @@ -270,6 +274,7 @@ export async function enrichGjcSdkRunOptions(options: GjcWorkerOptions): Promise credential: options.credential ?? { kind: 'stored' }, modelId, ...(modelProfile ? { modelProfile } : {}), + effort: typeof options.effort === 'string' && options.effort ? options.effort : 'default', toolNames: options.toolNames ?? [...GJC_AGENT_TOOL_NAMES], spawns: options.spawns ?? '*', bashPolicy: options.bashPolicy ?? { allowedPrefixes: [] }, From 8854bfcba4250ca1d51377fd48d425a68974f51a Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:25:27 +0900 Subject: [PATCH 2/5] feat(chat): per-session model selection and composer toolbar overhaul Server: - chat.send resolves the session's persisted active-model choice and lets it outrank the client's global default, wiring the previously dead resolveResumeModel store into the only run dispatch path (injectable for tests, exported through the providers barrel). - Regenerate the built-in profile snapshot from SDK 0.13.1 (30 -> 46 profiles, adds claude-opus-5/sonnet-5, grok-45, open-weights) and bump the provider models cache version so stale catalogs do not outlive the upgrade. - Resolve profile-name references in config.yml roles (modelProfile: default: ) to real model selectors so a profile name never surfaces as a model id. Client: - New SessionModelPicker: changes only this session's default-agent model, derived from preset role mappings (effort suffixes stripped, provider groups ordered codex/claude/kimi/glm/grok first). - ModelPresetPicker gains an icon-only mode and moves next to the skill picker; preset groups follow the same ordering with Current pinned. - ReasoningEffortPicker and SkillPicker land in the toolbar; all composer popups render through body portals because the composer form clips overflow. - ContextUsageBadge shows context fullness beside the skill icon; the redundant model/effort/cwd/context status strip is removed. - Composer submits an effort option per message; the provider-entry guard regex is bounded to one line so the reasoningEffort passthrough is not a false positive. --- server/modules/providers/index.ts | 2 + .../list/gjc/gjc-builtin-model-profiles.ts | 915 +++++++++++------- .../providers/list/gjc/gjc-models.provider.ts | 24 +- .../services/provider-models.service.ts | 2 +- .../tests/gjc-models.provider.test.ts | 49 + .../services/chat-websocket.service.ts | 36 + .../tests/chat-websocket.service.test.ts | 80 ++ .../chat/hooks/useChatComposerState.ts | 7 +- .../tests/chatComposerJobModeRemoval.test.tsx | 22 +- .../chat/tests/providerEntryCleanup.test.tsx | 5 +- .../chat/tests/sessionModelPicker.test.ts | 139 +++ .../chat/tests/sessionStatusSummary.test.tsx | 86 -- src/components/chat/view/ChatInterface.tsx | 40 +- .../chat/view/subcomponents/ChatComposer.tsx | 87 +- .../view/subcomponents/ContextUsageBadge.tsx | 41 + .../view/subcomponents/ModelPresetPicker.tsx | 62 +- .../subcomponents/ReasoningEffortPicker.tsx | 103 ++ .../view/subcomponents/SessionModelPicker.tsx | 224 +++++ .../subcomponents/SessionStatusSummary.tsx | 89 -- .../chat/view/subcomponents/SkillPicker.tsx | 130 +++ 20 files changed, 1528 insertions(+), 615 deletions(-) create mode 100644 src/components/chat/tests/sessionModelPicker.test.ts delete mode 100644 src/components/chat/tests/sessionStatusSummary.test.tsx create mode 100644 src/components/chat/view/subcomponents/ContextUsageBadge.tsx create mode 100644 src/components/chat/view/subcomponents/ReasoningEffortPicker.tsx create mode 100644 src/components/chat/view/subcomponents/SessionModelPicker.tsx delete mode 100644 src/components/chat/view/subcomponents/SessionStatusSummary.tsx create mode 100644 src/components/chat/view/subcomponents/SkillPicker.tsx diff --git a/server/modules/providers/index.ts b/server/modules/providers/index.ts index 6d46a31..55d627e 100644 --- a/server/modules/providers/index.ts +++ b/server/modules/providers/index.ts @@ -1,4 +1,6 @@ export { sessionSynchronizerService } from './services/session-synchronizer.service.js'; +export { providerModelsService } from './services/provider-models.service.js'; + export { initializeSessionsWatcher } from './services/sessions-watcher.service.js'; export { closeSessionsWatcher } from './services/sessions-watcher.service.js'; diff --git a/server/modules/providers/list/gjc/gjc-builtin-model-profiles.ts b/server/modules/providers/list/gjc/gjc-builtin-model-profiles.ts index 483abdd..0ec49f6 100644 --- a/server/modules/providers/list/gjc/gjc-builtin-model-profiles.ts +++ b/server/modules/providers/list/gjc/gjc-builtin-model-profiles.ts @@ -8,372 +8,565 @@ export type GjcBuiltinModelProfile = { }; /** - * GJC 0.11.8 built-in model profiles. + * GJC 0.13.1 built-in model profiles. * * The upstream runtime module currently imports Bun-only utilities, so the * Node sidecar cannot load it directly. This catalog is generated from the - * exact @gajae-code/coding-agent dependency used by the app; `label` and - * `group` are app-authored display strings the SDK does not carry. + * exact @gajae-code/coding-agent dependency used by the app (regenerate by + * dumping BUILTIN_MODEL_PROFILES with dist-native/bun after an SDK bump); + * label and group are app-authored display strings the SDK does not carry. */ export const GJC_BUILTIN_MODEL_PROFILES: readonly GjcBuiltinModelProfile[] = [ { - "name": "codex-eco", - "label": "Codex Eco", - "group": "CODEX", - "roles": { - "default": "openai-codex/gpt-5.6-terra:low", - "executor": "openai-codex/gpt-5.6-luna:low", - "planner": "openai-codex/gpt-5.6-luna:high", - "critic": "openai-codex/gpt-5.6-terra:xhigh", - "architect": "openai-codex/gpt-5.6-terra:high" - } - }, - { - "name": "codex-medium", - "label": "Codex Medium", - "group": "CODEX", - "roles": { - "default": "openai-codex/gpt-5.6-sol:low", - "executor": "openai-codex/gpt-5.6-terra:low", - "planner": "openai-codex/gpt-5.6-terra:high", - "critic": "openai-codex/gpt-5.6-sol:xhigh", - "architect": "openai-codex/gpt-5.6-sol:high" - } - }, - { - "name": "codex-pro", - "label": "Codex Pro", - "group": "CODEX", - "roles": { - "default": "openai-codex/gpt-5.6-sol:medium", - "executor": "openai-codex/gpt-5.6-terra:medium", - "planner": "openai-codex/gpt-5.6-sol:high", - "critic": "openai-codex/gpt-5.6-sol:max", - "architect": "openai-codex/gpt-5.6-sol:xhigh" - } - }, - { - "name": "opencodego", - "label": "OpenCodeGo", - "group": "OPENCODEGO", - "roles": { - "default": "opencode-go/kimi-k2.6", - "executor": "opencode-go/deepseek-v4-flash", - "planner": "opencode-go/qwen3.7-max", - "critic": "opencode-go/mimo-v2.5-pro", - "architect": "opencode-go/deepseek-v4-pro" - } - }, - { - "name": "claude-opus", - "label": "Claude Opus", - "group": "CLAUDE", - "roles": { - "default": "anthropic/claude-opus-4-8:xhigh", - "executor": "anthropic/claude-sonnet-5", - "planner": "anthropic/claude-opus-4-8:low", - "critic": "anthropic/claude-opus-4-8:high", - "architect": "anthropic/claude-opus-4-8:xhigh" - } - }, - { - "name": "claude-fable", - "label": "Claude Fable", - "group": "CLAUDE", - "roles": { - "default": "anthropic/claude-fable-5:xhigh", - "executor": "anthropic/claude-sonnet-5", - "planner": "anthropic/claude-fable-5:low", - "critic": "anthropic/claude-fable-5:high", - "architect": "anthropic/claude-fable-5:xhigh" - } - }, - { - "name": "glm-eco", - "label": "GLM Eco", - "group": "GLM", - "roles": { - "default": "zai/glm-5.2:low", - "executor": "zai/glm-5.2:minimal", - "planner": "zai/glm-5.2:low", - "critic": "zai/glm-5.2:medium", - "architect": "zai/glm-5.2:high" - } - }, - { - "name": "glm-medium", - "label": "GLM Medium", - "group": "GLM", - "roles": { - "default": "zai/glm-5.2:medium", - "executor": "zai/glm-5.2:low", - "planner": "zai/glm-5.2:medium", - "critic": "zai/glm-5.2:high", - "architect": "zai/glm-5.2:xhigh" - } - }, - { - "name": "glm-pro", - "label": "GLM Pro", - "group": "GLM", - "roles": { - "default": "zai/glm-5.2:xhigh", - "executor": "zai/glm-5.2:medium", - "planner": "zai/glm-5.2:high", - "critic": "zai/glm-5.2:xhigh", - "architect": "zai/glm-5.2:xhigh" - } - }, - { - "name": "kimi-coding-plan-eco", - "label": "Kimi Coding Plan Eco", - "group": "KIMI CODING PLAN", - "roles": { - "default": "kimi-code/k3:low", - "executor": "kimi-code/k3:low", - "planner": "kimi-code/k3:low", - "critic": "kimi-code/k3:high", - "architect": "kimi-code/k3:high" - } - }, - { - "name": "kimi-coding-plan-medium", - "label": "Kimi Coding Plan Medium", - "group": "KIMI CODING PLAN", - "roles": { - "default": "kimi-code/k3:high", - "executor": "kimi-code/k3:low", - "planner": "kimi-code/k3:high", - "critic": "kimi-code/k3:high", - "architect": "kimi-code/k3:max" - } - }, - { - "name": "kimi-coding-plan-pro", - "label": "Kimi Coding Plan Pro", - "group": "KIMI CODING PLAN", - "roles": { - "default": "kimi-code/k3:max", - "executor": "kimi-code/k3:high", - "planner": "kimi-code/k3:high", - "critic": "kimi-code/k3:max", - "architect": "kimi-code/k3:max" - } - }, - { - "name": "mimo-eco", - "label": "Mimo Eco", - "group": "MIMO", - "roles": { - "default": "xiaomi/mimo-v2.5-pro:low", - "executor": "xiaomi/mimo-v2.5-pro:minimal", - "planner": "xiaomi/mimo-v2.5-pro:low", - "critic": "xiaomi/mimo-v2.5-pro:medium", - "architect": "xiaomi/mimo-v2.5-pro:high" - } - }, - { - "name": "mimo-medium", - "label": "Mimo Medium", - "group": "MIMO", - "roles": { - "default": "xiaomi/mimo-v2.5-pro:medium", - "executor": "xiaomi/mimo-v2.5-pro:low", - "planner": "xiaomi/mimo-v2.5-pro:medium", - "critic": "xiaomi/mimo-v2.5-pro:high", - "architect": "xiaomi/mimo-v2.5-pro:xhigh" - } - }, - { - "name": "mimo-pro", - "label": "Mimo Pro", - "group": "MIMO", - "roles": { - "default": "xiaomi/mimo-v2.5-pro:xhigh", - "executor": "xiaomi/mimo-v2.5-pro:medium", - "planner": "xiaomi/mimo-v2.5-pro:high", - "critic": "xiaomi/mimo-v2.5-pro:xhigh", - "architect": "xiaomi/mimo-v2.5-pro:xhigh" - } - }, - { - "name": "grok-eco", - "label": "Grok Eco", - "group": "GROK", - "roles": { - "default": "xai/grok-4.3:low", - "executor": "xai/grok-4.3:minimal", - "planner": "xai/grok-4.3:low", - "critic": "xai/grok-4.3:medium", - "architect": "xai/grok-4.3:high" - } - }, - { - "name": "grok-medium", - "label": "Grok Medium", - "group": "GROK", - "roles": { - "default": "xai/grok-4.3:medium", - "executor": "xai/grok-4.3:low", - "planner": "xai/grok-4.3:medium", - "critic": "xai/grok-4.3:high", - "architect": "xai/grok-4.3:xhigh" - } - }, - { - "name": "grok-pro", - "label": "Grok Pro", - "group": "GROK", - "roles": { - "default": "xai/grok-4.3:xhigh", - "executor": "xai/grok-4.3:medium", - "planner": "xai/grok-4.3:high", - "critic": "xai/grok-4.3:xhigh", - "architect": "xai/grok-4.3:xhigh" - } - }, - { - "name": "grok-build-pro", - "label": "Grok Build Pro", - "group": "GROK", - "roles": { - "default": "grok-build/grok-composer-2.5-fast", - "executor": "grok-build/grok-build", - "planner": "grok-build/grok-composer-2.5-fast", - "critic": "grok-build/grok-composer-2.5-fast", - "architect": "grok-build/grok-build" - } - }, - { - "name": "cursor-eco", - "label": "Cursor Eco", - "group": "CURSOR", - "roles": { - "default": "cursor/composer-1.5:low", - "executor": "cursor/composer-1.5:minimal", - "planner": "cursor/composer-1.5:low", - "critic": "cursor/composer-1.5:medium", - "architect": "cursor/composer-1.5:high" - } - }, - { - "name": "cursor-medium", - "label": "Cursor Medium", - "group": "CURSOR", - "roles": { - "default": "cursor/composer-1.5:medium", - "executor": "cursor/composer-1.5:low", - "planner": "cursor/composer-1.5:medium", - "critic": "cursor/composer-1.5:high", - "architect": "cursor/composer-1.5:xhigh" - } - }, - { - "name": "cursor-pro", - "label": "Cursor Pro", - "group": "CURSOR", - "roles": { - "default": "cursor/composer-1.5:xhigh", - "executor": "cursor/composer-1.5:medium", - "planner": "cursor/composer-1.5:high", - "critic": "cursor/composer-1.5:xhigh", - "architect": "cursor/composer-1.5:xhigh" - } - }, - { - "name": "minimax-eco", - "label": "MiniMax Eco", - "group": "MINIMAX", - "roles": { - "default": "minimax-code/minimax-m3:low", - "executor": "minimax-code/minimax-m3:minimal", - "planner": "minimax-code/minimax-m3:low", - "critic": "minimax-code/minimax-m3:medium", - "architect": "minimax-code/minimax-m3:high" - } - }, - { - "name": "minimax-medium", - "label": "MiniMax Medium", - "group": "MINIMAX", - "roles": { - "default": "minimax-code/minimax-m3:medium", - "executor": "minimax-code/minimax-m3:low", - "planner": "minimax-code/minimax-m3:medium", - "critic": "minimax-code/minimax-m3:high", - "architect": "minimax-code/minimax-m3:xhigh" - } - }, - { - "name": "minimax-pro", - "label": "MiniMax Pro", - "group": "MINIMAX", - "roles": { - "default": "minimax-code/minimax-m3:xhigh", - "executor": "minimax-code/minimax-m3:medium", - "planner": "minimax-code/minimax-m3:high", - "critic": "minimax-code/minimax-m3:xhigh", - "architect": "minimax-code/minimax-m3:xhigh" - } - }, - { - "name": "alibaba-token-plan-balanced", - "label": "Alibaba Token Plan Balanced", - "group": "ALIBABA TOKEN PLAN", - "roles": { - "default": "alibaba-token-plan/qwen3.8-max-preview:medium", - "executor": "alibaba-token-plan/deepseek-v4-pro:xhigh", - "planner": "alibaba-token-plan/glm-5.2:high", - "critic": "alibaba-token-plan/glm-5.2:high", - "architect": "alibaba-token-plan/qwen3.8-max-preview:xhigh" - } - }, - { - "name": "alibaba-token-plan-qwenmaxxing", - "label": "Alibaba Token Plan Qwenmaxxing", - "group": "ALIBABA TOKEN PLAN", - "roles": { - "default": "alibaba-token-plan/qwen3.8-max-preview:medium", - "executor": "alibaba-token-plan/qwen3.8-max-preview:low", - "planner": "alibaba-token-plan/qwen3.8-max-preview:medium", - "critic": "alibaba-token-plan/qwen3.8-max-preview:xhigh", - "architect": "alibaba-token-plan/qwen3.8-max-preview:xhigh" - } - }, - { - "name": "opus-codex", - "label": "Opus + Codex", - "group": "COMBOS", - "roles": { - "default": "anthropic/claude-opus-4-8:xhigh", - "executor": "openai-codex/gpt-5.6-terra:low", - "planner": "anthropic/claude-sonnet-5", - "critic": "openai-codex/gpt-5.6-sol:xhigh", - "architect": "openai-codex/gpt-5.6-sol:high" - } - }, - { - "name": "codex-opencodego", - "label": "Codex + OpenCodeGo", - "group": "COMBOS", - "roles": { - "default": "openai-codex/gpt-5.6-sol:low", - "executor": "opencode-go/deepseek-v4-pro", - "planner": "opencode-go/kimi-k2.6", - "critic": "opencode-go/mimo-v2.5-pro", - "architect": "openai-codex/gpt-5.6-sol:high" - } - }, - { - "name": "fable-opus-codex", - "label": "Fable + Opus + Codex", - "group": "COMBOS", - "roles": { - "default": "anthropic/claude-fable-5:high", - "executor": "openai-codex/gpt-5.6-terra:medium", - "planner": "anthropic/claude-opus-4-8:medium", - "critic": "anthropic/claude-opus-4-8:high", - "architect": "openai-codex/gpt-5.6-sol:xhigh" - } + "name": "codex-eco", + "label": "Codex Eco", + "group": "CODEX", + "roles": { + "default": "openai-codex/gpt-5.6-terra:low", + "planner": "openai-codex/gpt-5.6-luna:high", + "executor": "openai-codex/gpt-5.6-luna:low", + "architect": "openai-codex/gpt-5.6-terra:high", + "critic": "openai-codex/gpt-5.6-terra:xhigh" + } + }, + { + "name": "codex-medium", + "label": "Codex Medium", + "group": "CODEX", + "roles": { + "default": "openai-codex/gpt-5.6-sol:low", + "planner": "openai-codex/gpt-5.6-terra:high", + "executor": "openai-codex/gpt-5.6-terra:low", + "architect": "openai-codex/gpt-5.6-sol:high", + "critic": "openai-codex/gpt-5.6-sol:xhigh" + } + }, + { + "name": "codex-pro", + "label": "Codex Pro", + "group": "CODEX", + "roles": { + "default": "openai-codex/gpt-5.6-sol:medium", + "planner": "openai-codex/gpt-5.6-sol:high", + "executor": "openai-codex/gpt-5.6-terra:medium", + "architect": "openai-codex/gpt-5.6-sol:xhigh", + "critic": "openai-codex/gpt-5.6-sol:max" + } + }, + { + "name": "lunamaxxing", + "label": "Lunamaxxing", + "group": "CODEX", + "roles": { + "default": "openai-codex/gpt-5.6-luna:medium", + "planner": "openai-codex/gpt-5.6-luna:max", + "executor": "openai-codex/gpt-5.6-luna:xhigh", + "architect": "openai-codex/gpt-5.6-luna:max", + "critic": "openai-codex/gpt-5.6-luna:max" + } + }, + { + "name": "opencodego", + "label": "OpenCodeGo", + "group": "OPENCODEGO", + "roles": { + "default": "opencode-go/kimi-k3", + "planner": "opencode-go/kimi-k3", + "executor": "opencode-go/deepseek-v4-flash", + "architect": "opencode-go/deepseek-v4-pro", + "critic": "opencode-go/mimo-v2.5-pro" + } + }, + { + "name": "open-weights-glm", + "label": "Open Weights GLM", + "group": "OPEN WEIGHTS", + "roles": { + "default": "glm-5.2:medium", + "planner": "glm-5.2:high", + "executor": "glm-5.2:low", + "architect": "glm-5.2:xhigh", + "critic": "glm-5.2:high" + } + }, + { + "name": "open-weights-deepseek", + "label": "Open Weights DeepSeek", + "group": "OPEN WEIGHTS", + "roles": { + "default": "deepseek-v4-flash:high", + "planner": "deepseek-v4-flash:high", + "executor": "deepseek-v4-flash:medium", + "architect": "deepseek-v4-flash:xhigh", + "critic": "deepseek-v4-flash:xhigh" + } + }, + { + "name": "open-weights-kimi", + "label": "Open Weights Kimi", + "group": "OPEN WEIGHTS", + "roles": { + "default": "kimi-k3:high", + "planner": "kimi-k3:xhigh", + "executor": "kimi-k3:high", + "architect": "kimi-k3:xhigh", + "critic": "kimi-k3:high" + } + }, + { + "name": "open-weights-luna", + "label": "Open Weights Luna", + "group": "OPEN WEIGHTS", + "roles": { + "default": "gpt-5.6-luna:high", + "planner": "gpt-5.6-luna:xhigh", + "executor": "gpt-5.6-luna:high", + "architect": "gpt-5.6-luna:xhigh", + "critic": "gpt-5.6-luna:xhigh" + } + }, + { + "name": "open-weights-glm-deepseek", + "label": "Open Weights GLM + DeepSeek", + "group": "OPEN WEIGHTS", + "roles": { + "default": "glm-5.2:medium", + "planner": "glm-5.2:high", + "executor": "deepseek-v4-flash:high", + "architect": "glm-5.2:xhigh", + "critic": "deepseek-v4-flash:xhigh" + } + }, + { + "name": "open-weights-kimi-deepseek", + "label": "Open Weights Kimi + DeepSeek", + "group": "OPEN WEIGHTS", + "roles": { + "default": "kimi-k3:high", + "planner": "kimi-k3:xhigh", + "executor": "deepseek-v4-flash:high", + "architect": "kimi-k3:xhigh", + "critic": "deepseek-v4-flash:xhigh" + } + }, + { + "name": "open-weights-kimi-glm", + "label": "Open Weights Kimi + GLM", + "group": "OPEN WEIGHTS", + "roles": { + "default": "glm-5.2:high", + "planner": "kimi-k3:high", + "executor": "glm-5.2:high", + "architect": "kimi-k3:xhigh", + "critic": "glm-5.2:xhigh" + } + }, + { + "name": "open-weights-kimi-glm-deepseek", + "label": "Open Weights Kimi + GLM + DeepSeek", + "group": "OPEN WEIGHTS", + "roles": { + "default": "glm-5.2:medium", + "planner": "kimi-k3:high", + "executor": "deepseek-v4-flash:high", + "architect": "kimi-k3:xhigh", + "critic": "glm-5.2:high" + } + }, + { + "name": "open-weights-all", + "label": "Open Weights All", + "group": "OPEN WEIGHTS", + "roles": { + "default": "gpt-5.6-luna:high", + "planner": "kimi-k3:high", + "executor": "deepseek-v4-flash:high", + "architect": "gpt-5.6-luna:xhigh", + "critic": "glm-5.2:high" + } + }, + { + "name": "claude-opus", + "label": "Claude Opus", + "group": "CLAUDE", + "roles": { + "default": "anthropic/claude-opus-5:xhigh", + "planner": "anthropic/claude-opus-5:low", + "executor": "anthropic/claude-sonnet-5", + "architect": "anthropic/claude-opus-5:xhigh", + "critic": "anthropic/claude-opus-5:high" + } + }, + { + "name": "claude-fable", + "label": "Claude Fable", + "group": "CLAUDE", + "roles": { + "default": "anthropic/claude-fable-5:xhigh", + "planner": "anthropic/claude-fable-5:low", + "executor": "anthropic/claude-sonnet-5", + "architect": "anthropic/claude-fable-5:xhigh", + "critic": "anthropic/claude-fable-5:high" + } + }, + { + "name": "glm-eco", + "label": "GLM Eco", + "group": "GLM", + "roles": { + "default": "zai/glm-5.2:low", + "planner": "zai/glm-5.2:low", + "executor": "zai/glm-5.2:minimal", + "architect": "zai/glm-5.2:high", + "critic": "zai/glm-5.2:medium" + } + }, + { + "name": "glm-medium", + "label": "GLM Medium", + "group": "GLM", + "roles": { + "default": "zai/glm-5.2:medium", + "planner": "zai/glm-5.2:medium", + "executor": "zai/glm-5.2:low", + "architect": "zai/glm-5.2:xhigh", + "critic": "zai/glm-5.2:high" + } + }, + { + "name": "glm-pro", + "label": "GLM Pro", + "group": "GLM", + "roles": { + "default": "zai/glm-5.2:xhigh", + "planner": "zai/glm-5.2:high", + "executor": "zai/glm-5.2:medium", + "architect": "zai/glm-5.2:xhigh", + "critic": "zai/glm-5.2:xhigh" + } + }, + { + "name": "kimi-coding-plan-eco", + "label": "Kimi Coding Plan Eco", + "group": "KIMI CODING PLAN", + "roles": { + "default": "kimi-code/k3:low", + "planner": "kimi-code/k3:low", + "executor": "kimi-code/k3:low", + "architect": "kimi-code/k3:high", + "critic": "kimi-code/k3:high" + } + }, + { + "name": "kimi-coding-plan-medium", + "label": "Kimi Coding Plan Medium", + "group": "KIMI CODING PLAN", + "roles": { + "default": "kimi-code/k3:high", + "planner": "kimi-code/k3:high", + "executor": "kimi-code/k3:low", + "architect": "kimi-code/k3:max", + "critic": "kimi-code/k3:high" + } + }, + { + "name": "kimi-coding-plan-pro", + "label": "Kimi Coding Plan Pro", + "group": "KIMI CODING PLAN", + "roles": { + "default": "kimi-code/k3:max", + "planner": "kimi-code/k3:high", + "executor": "kimi-code/k3:high", + "architect": "kimi-code/k3:max", + "critic": "kimi-code/k3:max" + } + }, + { + "name": "mimo-eco", + "label": "Mimo Eco", + "group": "MIMO", + "roles": { + "default": "xiaomi/mimo-v2.5-pro:low", + "planner": "xiaomi/mimo-v2.5-pro:low", + "executor": "xiaomi/mimo-v2.5-pro:minimal", + "architect": "xiaomi/mimo-v2.5-pro:high", + "critic": "xiaomi/mimo-v2.5-pro:medium" + } + }, + { + "name": "mimo-medium", + "label": "Mimo Medium", + "group": "MIMO", + "roles": { + "default": "xiaomi/mimo-v2.5-pro:medium", + "planner": "xiaomi/mimo-v2.5-pro:medium", + "executor": "xiaomi/mimo-v2.5-pro:low", + "architect": "xiaomi/mimo-v2.5-pro:xhigh", + "critic": "xiaomi/mimo-v2.5-pro:high" + } + }, + { + "name": "mimo-pro", + "label": "Mimo Pro", + "group": "MIMO", + "roles": { + "default": "xiaomi/mimo-v2.5-pro:xhigh", + "planner": "xiaomi/mimo-v2.5-pro:high", + "executor": "xiaomi/mimo-v2.5-pro:medium", + "architect": "xiaomi/mimo-v2.5-pro:xhigh", + "critic": "xiaomi/mimo-v2.5-pro:xhigh" + } + }, + { + "name": "grok-eco", + "label": "Grok Eco", + "group": "GROK", + "roles": { + "default": "xai/grok-4.3:low", + "planner": "xai/grok-4.3:low", + "executor": "xai/grok-4.3:minimal", + "architect": "xai/grok-4.3:high", + "critic": "xai/grok-4.3:medium" + } + }, + { + "name": "grok-medium", + "label": "Grok Medium", + "group": "GROK", + "roles": { + "default": "xai/grok-4.3:medium", + "planner": "xai/grok-4.3:medium", + "executor": "xai/grok-4.3:low", + "architect": "xai/grok-4.3:xhigh", + "critic": "xai/grok-4.3:high" + } + }, + { + "name": "grok-pro", + "label": "Grok Pro", + "group": "GROK", + "roles": { + "default": "xai/grok-4.3:xhigh", + "planner": "xai/grok-4.3:high", + "executor": "xai/grok-4.3:medium", + "architect": "xai/grok-4.3:xhigh", + "critic": "xai/grok-4.3:xhigh" + } + }, + { + "name": "grok-45-eco", + "label": "Grok 4.5 Eco", + "group": "GROK", + "roles": { + "default": "xai/grok-4.5:low", + "planner": "xai/grok-4.5:low", + "executor": "xai/grok-4.5:minimal", + "architect": "xai/grok-4.5:high", + "critic": "xai/grok-4.5:medium" + } + }, + { + "name": "grok-45-medium", + "label": "Grok 4.5 Medium", + "group": "GROK", + "roles": { + "default": "xai/grok-4.5:medium", + "planner": "xai/grok-4.5:medium", + "executor": "xai/grok-4.5:low", + "architect": "xai/grok-4.5:high", + "critic": "xai/grok-4.5:high" + } + }, + { + "name": "grok-45-pro", + "label": "Grok 4.5 Pro", + "group": "GROK", + "roles": { + "default": "xai/grok-4.5:high", + "planner": "xai/grok-4.5:high", + "executor": "xai/grok-4.5:medium", + "architect": "xai/grok-4.5:high", + "critic": "xai/grok-4.5:high" + } + }, + { + "name": "grok-build-pro", + "label": "Grok Build Pro", + "group": "GROK", + "roles": { + "default": "grok-build/grok-composer-2.5-fast", + "planner": "grok-build/grok-composer-2.5-fast", + "executor": "grok-build/grok-build", + "architect": "grok-build/grok-build", + "critic": "grok-build/grok-composer-2.5-fast" + } + }, + { + "name": "cursor-eco", + "label": "Cursor Eco", + "group": "CURSOR", + "roles": { + "default": "cursor/composer-2.5", + "planner": "cursor/composer-2.5", + "executor": "cursor/composer-2.5", + "architect": "cursor/composer-2.5", + "critic": "cursor/composer-2.5" + } + }, + { + "name": "cursor-medium", + "label": "Cursor Medium", + "group": "CURSOR", + "roles": { + "default": "cursor/composer-2.5", + "planner": "cursor/composer-2.5", + "executor": "cursor/composer-2.5-fast", + "architect": "cursor/composer-2.5-fast", + "critic": "cursor/composer-2.5-fast" + } + }, + { + "name": "cursor-pro", + "label": "Cursor Pro", + "group": "CURSOR", + "roles": { + "default": "cursor/composer-2.5-fast", + "planner": "cursor/composer-2.5-fast", + "executor": "cursor/composer-2.5-fast", + "architect": "cursor/composer-2.5-fast", + "critic": "cursor/composer-2.5-fast" + } + }, + { + "name": "minimax-eco", + "label": "MiniMax Eco", + "group": "MINIMAX", + "roles": { + "default": "minimax-code/MiniMax-M3:low", + "planner": "minimax-code/MiniMax-M3:low", + "executor": "minimax-code/MiniMax-M3:minimal", + "architect": "minimax-code/MiniMax-M3:high", + "critic": "minimax-code/MiniMax-M3:medium" + } + }, + { + "name": "minimax-medium", + "label": "MiniMax Medium", + "group": "MINIMAX", + "roles": { + "default": "minimax-code/MiniMax-M3:medium", + "planner": "minimax-code/MiniMax-M3:medium", + "executor": "minimax-code/MiniMax-M3:low", + "architect": "minimax-code/MiniMax-M3:xhigh", + "critic": "minimax-code/MiniMax-M3:high" + } + }, + { + "name": "minimax-pro", + "label": "MiniMax Pro", + "group": "MINIMAX", + "roles": { + "default": "minimax-code/MiniMax-M3:xhigh", + "planner": "minimax-code/MiniMax-M3:high", + "executor": "minimax-code/MiniMax-M3:medium", + "architect": "minimax-code/MiniMax-M3:xhigh", + "critic": "minimax-code/MiniMax-M3:xhigh" + } + }, + { + "name": "alibaba-token-plan-balanced", + "label": "Alibaba Token Plan Balanced", + "group": "ALIBABA TOKEN PLAN", + "roles": { + "default": "alibaba-token-plan/qwen3.8-max-preview:medium", + "planner": "alibaba-token-plan/glm-5.2:high", + "executor": "alibaba-token-plan/deepseek-v4-pro:xhigh", + "architect": "alibaba-token-plan/qwen3.8-max-preview:xhigh", + "critic": "alibaba-token-plan/glm-5.2:high" + } + }, + { + "name": "alibaba-token-plan-pro", + "label": "Alibaba Token Plan Pro", + "group": "ALIBABA TOKEN PLAN", + "roles": { + "default": "alibaba-token-plan/qwen3.8-max-preview:medium", + "planner": "alibaba-token-plan/glm-5.2:high", + "executor": "alibaba-token-plan/deepseek-v4-flash-0731:max", + "architect": "alibaba-token-plan/qwen3.8-max-preview:xhigh", + "critic": "alibaba-token-plan/glm-5.2:xhigh" + } + }, + { + "name": "alibaba-token-plan-qwenmaxxing", + "label": "Alibaba Token Plan Qwenmaxxing", + "group": "ALIBABA TOKEN PLAN", + "roles": { + "default": "alibaba-token-plan/qwen3.8-max-preview:medium", + "planner": "alibaba-token-plan/qwen3.8-max-preview:medium", + "executor": "alibaba-token-plan/qwen3.8-max-preview:low", + "architect": "alibaba-token-plan/qwen3.8-max-preview:xhigh", + "critic": "alibaba-token-plan/qwen3.8-max-preview:xhigh" + } + }, + { + "name": "alibaba-token-plan-qwen-deepseek", + "label": "Alibaba Token Plan Qwen + DeepSeek", + "group": "ALIBABA TOKEN PLAN", + "roles": { + "default": "alibaba-token-plan/qwen3.8-max:high", + "planner": "alibaba-token-plan/deepseek-v4-flash-0731:max", + "executor": "alibaba-token-plan/deepseek-v4-flash-0731:high", + "architect": "alibaba-token-plan/qwen3.8-max:xhigh", + "critic": "alibaba-token-plan/qwen3.8-max:xhigh" + } + }, + { + "name": "alibaba-token-plan-glm-deepseek", + "label": "Alibaba Token Plan GLM + DeepSeek", + "group": "ALIBABA TOKEN PLAN", + "roles": { + "default": "alibaba-token-plan/glm-5.2:high", + "planner": "alibaba-token-plan/deepseek-v4-flash-0731:max", + "executor": "alibaba-token-plan/deepseek-v4-flash-0731:high", + "architect": "alibaba-token-plan/glm-5.2:xhigh", + "critic": "alibaba-token-plan/glm-5.2:xhigh" + } + }, + { + "name": "opus-codex", + "label": "Opus + Codex", + "group": "COMBOS", + "roles": { + "default": "anthropic/claude-opus-5:xhigh", + "planner": "anthropic/claude-sonnet-5", + "executor": "openai-codex/gpt-5.6-terra:low", + "architect": "openai-codex/gpt-5.6-sol:high", + "critic": "openai-codex/gpt-5.6-sol:xhigh" + } + }, + { + "name": "codex-opencodego", + "label": "Codex + OpenCodeGo", + "group": "COMBOS", + "roles": { + "default": "openai-codex/gpt-5.6-sol:low", + "planner": "opencode-go/kimi-k3", + "executor": "opencode-go/deepseek-v4-pro", + "architect": "openai-codex/gpt-5.6-sol:high", + "critic": "opencode-go/mimo-v2.5-pro" + } + }, + { + "name": "fable-opus-codex", + "label": "Fable + Opus + Codex", + "group": "COMBOS", + "roles": { + "default": "anthropic/claude-fable-5:high", + "planner": "anthropic/claude-opus-5:medium", + "executor": "openai-codex/gpt-5.6-terra:medium", + "architect": "openai-codex/gpt-5.6-sol:xhigh", + "critic": "anthropic/claude-opus-5:high" + } } ]; diff --git a/server/modules/providers/list/gjc/gjc-models.provider.ts b/server/modules/providers/list/gjc/gjc-models.provider.ts index 997de63..5a1c315 100644 --- a/server/modules/providers/list/gjc/gjc-models.provider.ts +++ b/server/modules/providers/list/gjc/gjc-models.provider.ts @@ -84,6 +84,28 @@ function parseProfiles(source: string): Array<{ name: string; label: string; rol return profiles.filter((profile) => Object.keys(profile.roles).length > 0); } +/** + * `config.yml` roles may reference a profile name (e.g. `modelProfile: + * default: fable-opus-codex`) instead of a `provider/model` selector. Expand + * such references through the profile map so the "Current" option surfaces + * real model ids; keep direct selectors as explicit overrides and drop values + * that are neither. + */ +function resolveConfiguredRoles( + configured: RoleMap, + profiles: Map, +): RoleMap { + const resolved: RoleMap = {}; + const referenced = configured.default && !configured.default.includes('/') + ? profiles.get(configured.default) + : undefined; + if (referenced) Object.assign(resolved, referenced.roles); + for (const [role, value] of Object.entries(configured)) { + if (value.includes('/')) resolved[role as ProfileRole] = value; + } + return resolved; +} + async function getGjcPresetCatalog(homeDir: string): Promise { const agentDir = path.join(homeDir, '.gjc', 'agent'); const [configSource, modelsSource] = await Promise.all([ @@ -114,7 +136,7 @@ async function getGjcPresetCatalog(homeDir: string): Promise ({ value: `profile:${profile.name}`, diff --git a/server/modules/providers/services/provider-models.service.ts b/server/modules/providers/services/provider-models.service.ts index 587274f..a411273 100644 --- a/server/modules/providers/services/provider-models.service.ts +++ b/server/modules/providers/services/provider-models.service.ts @@ -19,7 +19,7 @@ export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; // Bumped whenever the built-in preset catalog changes or the option shape gains // a field, so an SDK upgrade or a new field is not hidden behind an existing // cache entry for the rest of its 3-day TTL. -const PROVIDER_MODELS_CACHE_VERSION = 6; +const PROVIDER_MODELS_CACHE_VERSION = 7; type ProviderModelsServiceDependencies = { resolveProvider?: (provider: LLMProvider) => Pick; diff --git a/server/modules/providers/tests/gjc-models.provider.test.ts b/server/modules/providers/tests/gjc-models.provider.test.ts index 48df9c3..fa948cd 100644 --- a/server/modules/providers/tests/gjc-models.provider.test.ts +++ b/server/modules/providers/tests/gjc-models.provider.test.ts @@ -34,6 +34,55 @@ test('GJC model catalog merges built-in and custom profiles with custom override assert.equal(catalog.OPTIONS.filter((option) => option.value === 'profile:codex-medium').length, 1); }); +test('a profile-name reference in config.yml resolves Current to real model selectors', async (t) => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'gajae-model-config-ref-')); + t.after(() => rm(homeDir, { recursive: true, force: true })); + const agentDir = path.join(homeDir, '.gjc', 'agent'); + await mkdir(agentDir, { recursive: true }); + await writeFile(path.join(agentDir, 'models.yml'), `profiles: + personal: + display_name: Personal + model_mapping: + default: custom/daily-driver + planner: custom/planner +`, 'utf8'); + await writeFile(path.join(agentDir, 'config.yml'), `modelProfile: + default: personal +configSchemaVersion: 1 +`, 'utf8'); + + const catalog = await new GjcProviderModels(homeDir).getSupportedModels(); + const current = catalog.OPTIONS.find((option) => option.value === 'default'); + + // The profile name itself must never surface as a "model". + assert.equal(current?.roles?.default, 'custom/daily-driver'); + assert.equal(current?.roles?.planner, 'custom/planner'); +}); + +test('a direct selector in config.yml overrides the referenced profile role', async (t) => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'gajae-model-config-mix-')); + t.after(() => rm(homeDir, { recursive: true, force: true })); + const agentDir = path.join(homeDir, '.gjc', 'agent'); + await mkdir(agentDir, { recursive: true }); + await writeFile(path.join(agentDir, 'models.yml'), `profiles: + personal: + display_name: Personal + model_mapping: + default: custom/daily-driver + critic: custom/critic +`, 'utf8'); + await writeFile(path.join(agentDir, 'config.yml'), `modelProfile: + default: personal + critic: custom/override-critic +`, 'utf8'); + + const catalog = await new GjcProviderModels(homeDir).getSupportedModels(); + const current = catalog.OPTIONS.find((option) => option.value === 'default'); + + assert.equal(current?.roles?.default, 'custom/daily-driver'); + assert.equal(current?.roles?.critic, 'custom/override-critic'); +}); + test('every catalog option carries a group so clients can collapse the preset list', async (t) => { const homeDir = await mkdtemp(path.join(os.tmpdir(), 'gajae-model-groups-')); t.after(() => rm(homeDir, { recursive: true, force: true })); diff --git a/server/modules/websocket/services/chat-websocket.service.ts b/server/modules/websocket/services/chat-websocket.service.ts index 871770a..5401c61 100644 --- a/server/modules/websocket/services/chat-websocket.service.ts +++ b/server/modules/websocket/services/chat-websocket.service.ts @@ -131,10 +131,30 @@ type ChatWebSocketDependencies = { ) => void; /** Provider-runtime approvals included in `chat_subscribed` after reconnect. */ getPendingApprovalsForSession: (providerSessionId: string) => unknown[]; + /** + * Per-session model resolution (injectable for tests). The default consults + * the persisted active-model change store so a model picked for a session + * survives page reloads and session switches instead of depending on the + * client's global localStorage value. + */ + resolveSessionModel?: ( + provider: LLMProvider, + sessionId: string, + requestedModel?: string | null, + ) => Promise; gjcProjection?: GjcJobProjectionService; oauthSupervisor?: OAuthSupervisor; }; +async function defaultResolveSessionModel( + provider: LLMProvider, + sessionId: string, + requestedModel?: string | null, +): Promise { + const { providerModelsService } = await import('@/modules/providers/index.js'); + return providerModelsService.resolveResumeModel(provider, sessionId, requestedModel); +} + /** * Extracts the authenticated request user id in the formats currently produced * by platform and OSS auth code paths. @@ -257,12 +277,28 @@ async function handleChatSend( const clientOptions = (data.options ?? {}) as AnyRecord; + // The session's persisted model choice outranks the client's global default: + // the active-model POST stores per-session picks under the app session id, + // and this is the only place runs are dispatched. + const requestedModel = typeof clientOptions.model === 'string' ? clientOptions.model : null; + let resolvedModel: string | undefined; + try { + resolvedModel = await (dependencies.resolveSessionModel ?? defaultResolveSessionModel)( + provider, + sessionId, + requestedModel, + ); + } catch { + resolvedModel = requestedModel ?? undefined; + } + // The provider runtimes receive the provider-native session id (that is the // id their CLI/SDK understands for resume). Brand-new sessions have no // provider id yet, so the runtime starts fresh and announces one, which the // gateway writer captures and maps back to the app session id. const runtimeOptions: AnyRecord = { ...clientOptions, + ...(resolvedModel ? { model: resolvedModel } : {}), // Image attachments are re-validated server-side: only files inside the // global upload store may reach the provider runtimes' file reads. images: filterImagesToUploadStore(clientOptions.images), diff --git a/server/modules/websocket/tests/chat-websocket.service.test.ts b/server/modules/websocket/tests/chat-websocket.service.test.ts index 4b1bf77..68ee2ea 100644 --- a/server/modules/websocket/tests/chat-websocket.service.test.ts +++ b/server/modules/websocket/tests/chat-websocket.service.test.ts @@ -71,6 +71,86 @@ async function withIsolatedDatabase(runTest: () => void | Promise): Promis } } +test('chat.send prefers the session\'s persisted model choice over the client\'s global default', async () => { + await withIsolatedDatabase(async () => { + sessionsDb.createAppSession('model-override-session', 'gjc', '/workspace/model-project'); + let receivedOptions: Record | undefined; + let resolverArgs: unknown[] = []; + + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + try { + await once(server, 'listening'); + server.on('connection', (socket, request) => { + handleChatConnection( + socket, + Object.assign(request, { user: { id: 'test-user' } }), + { + spawnFns: { + gjc: (_command, options, writer) => { + receivedOptions = options; + (writer as { sendComplete(options: { exitCode: number }): void }).sendComplete({ exitCode: 0 }); + return Promise.resolve(); + }, + }, + abortFns: { gjc: async () => false }, + resolveToolApproval() {}, + getPendingApprovalsForSession: () => [], + resolveSessionModel: async (...args: unknown[]) => { + resolverArgs = args; + return 'anthropic/claude-opus-5'; + }, + }, + ); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the websocket test server to bind a TCP port.'); + } + + const client = new WebSocket(`ws://127.0.0.1:${address.port}`); + try { + await once(client, 'open'); + const completed = new Promise((resolve, reject) => { + client.on('message', (raw) => { + try { + if (parseOutboundFrame(String(raw)).kind === 'complete') resolve(); + } catch (error) { + reject(error); + } + }); + }); + + client.send(JSON.stringify({ + type: 'chat.send', + sessionId: 'model-override-session', + content: 'use my session model', + options: { model: 'default' }, + })); + await completed; + + assert.deepEqual(resolverArgs, ['gjc', 'model-override-session', 'default']); + assert.equal(receivedOptions?.model, 'anthropic/claude-opus-5'); + } finally { + client.terminate(); + } + } finally { + for (const client of server.clients) { + client.terminate(); + } + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); +}); + test('chat.send dispatches a non-Git GJC session directly in its persisted project directory', async () => { await withIsolatedDatabase(async () => { sessionsDb.createAppSession('non-git-session', 'gjc', '/workspace/non-git-project'); diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 892f5aa..6d53a06 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -47,6 +47,7 @@ interface UseChatComposerStateArgs { selectedSession: ProjectSession | null; currentSessionId: string | null; gjcModel: string; + reasoningEffort?: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; @@ -197,6 +198,7 @@ export function useChatComposerState({ selectedSession, currentSessionId, gjcModel, + reasoningEffort = 'default', isLoading, canAbortSession, tokenBudget, @@ -681,13 +683,13 @@ export function useChatComposerState({ const toolsSettings = getToolsSettings(); return { model: gjcModel, - effort: 'default', + effort: reasoningEffort, permissionMode: 'default', toolsSettings, skipPermissions: toolsSettings?.skipPermissions || false, sessionSummary: getNotificationSessionSummary(selectedSession, currentInput), }; - }, [gjcModel, selectedSession]); + }, [gjcModel, reasoningEffort, selectedSession]); const handleSubmit = useCallback( async ( @@ -1280,6 +1282,7 @@ export function useChatComposerState({ inputHighlightRef, isTextareaExpanded, slashCommandsCount, + skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, diff --git a/src/components/chat/tests/chatComposerJobModeRemoval.test.tsx b/src/components/chat/tests/chatComposerJobModeRemoval.test.tsx index b6d53cd..90b6b31 100644 --- a/src/components/chat/tests/chatComposerJobModeRemoval.test.tsx +++ b/src/components/chat/tests/chatComposerJobModeRemoval.test.tsx @@ -44,10 +44,6 @@ const baseComposerProps = { tokenBudget: null, sessionState: null, onShowTokenUsage: () => undefined, - slashCommandsCount: 2, - onToggleCommandMenu: () => undefined, - hasInput: true, - onClearInput: () => undefined, onSubmit: () => undefined, isDragActive: false, queuedDraft: null as QueuedDraft | null, @@ -65,6 +61,12 @@ const baseComposerProps = { selectedFileIndex: 0, onSelectFile: () => undefined, filteredCommands: [], + skillCommands: [{ + name: '/skill:ralplan', + description: 'Plan with consensus', + type: 'skill', + metadata: { skillName: 'ralplan' }, + }], selectedCommandIndex: 0, onCommandSelect: () => undefined, onCloseCommandMenu: () => undefined, @@ -86,6 +88,10 @@ const baseComposerProps = { placeholder: 'Message Gajae Code', isTextareaExpanded: false, sendByCtrlEnter: false, + modelPreset: 'current', + modelPresetOptions: [{ value: 'current', label: 'Current' }], + reasoningEffort: 'high' as const, + onSelectReasoningEffort: () => undefined, }; test('normal chat submit sends one chat message and never creates a GJC job', async () => { @@ -189,6 +195,10 @@ test('chat composer renders normal tools without background Job controls', () => assert.doesNotMatch(html, /Background job/i); assert.doesNotMatch(html, /Delegate background job/i); assert.doesNotMatch(html, /Back to chat/i); - assert.match(html, /lucide-image/); - assert.match(html, /lucide-message-square/); + assert.match(html, /lucide-plus/); + assert.match(html, /모델 프리셋 선택/); + assert.match(html, /Reasoning effort 선택/); + assert.match(html, /스킬 선택/); + assert.doesNotMatch(html, /lucide-image/); + assert.doesNotMatch(html, /lucide-x/); }); diff --git a/src/components/chat/tests/providerEntryCleanup.test.tsx b/src/components/chat/tests/providerEntryCleanup.test.tsx index 25bff6d..3411aef 100644 --- a/src/components/chat/tests/providerEntryCleanup.test.tsx +++ b/src/components/chat/tests/providerEntryCleanup.test.tsx @@ -118,7 +118,10 @@ test('provider state and composer remain GJC-only at the static boundary', () => } assert.doesNotMatch(source, /\/api\/providers\/(?:\$\{[^}]+\}|[^/'"`]+)\/capabilities/i); assert.doesNotMatch(source, /\b(?:providerEfforts?|providerEffortTable|PROVIDER_EFFORTS?|ProviderEffort(?:Table)?)\b/); - assert.doesNotMatch(source, /^import .*['"][^'"]*effort[^'"]*['"]/im); + // Bounded to one line: an unanchored [^'"]* spans newlines, which turned any + // later use of the word "effort" (e.g. the reasoningEffort passthrough) into + // a false positive. The guard's target is effort-table module imports only. + assert.doesNotMatch(source, /^import[^\n]*['"][^'"\n]*effort[^'"\n]*['"]/im); assert.doesNotMatch( source, /\b(?:providerModels|modelCatalog|modelsByProvider)\s*\[[^\]]+\]\s*\?\?/, diff --git a/src/components/chat/tests/sessionModelPicker.test.ts b/src/components/chat/tests/sessionModelPicker.test.ts new file mode 100644 index 0000000..61b24ab --- /dev/null +++ b/src/components/chat/tests/sessionModelPicker.test.ts @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + deriveSessionModelOptions, + resolveDisplayModel, + stripEffortSuffix, +} from '../view/subcomponents/SessionModelPicker'; +import type { ProviderModelOption } from '../../../types/app'; + +const catalog: ProviderModelOption[] = [ + { + value: 'default', + label: 'Current', + roles: { default: 'openai/gpt-5.6-sol:medium', planner: 'anthropic/claude-opus-4' }, + }, + { + value: 'profile:codex-medium', + label: 'My Codex', + group: 'CODEX', + roles: { default: 'custom/codex', executor: 'openai/gpt-5.6-sol' }, + }, + { value: 'profile:empty', label: 'No roles' }, +]; + +test('deriveSessionModelOptions unions role models, dedupes, and groups by provider', () => { + const groups = deriveSessionModelOptions(catalog); + + assert.deepEqual(groups.map((group) => group.group), ['anthropic', 'custom', 'openai']); + assert.deepEqual(groups.find((group) => group.group === 'openai')?.models, ['openai/gpt-5.6-sol']); + assert.deepEqual(groups.find((group) => group.group === 'custom')?.models, ['custom/codex']); + // Duplicate across roles/presets collapses to a single entry. + const all = groups.flatMap((group) => group.models); + assert.equal(new Set(all).size, all.length); +}); + +test('effort suffixes never leak into the model list: variants collapse to one base id', () => { + const groups = deriveSessionModelOptions([ + { + value: 'profile:a', + label: 'A', + roles: { default: 'anthropic/claude-fable-5:medium', critic: 'anthropic/claude-fable-5:high' }, + }, + { + value: 'profile:b', + label: 'B', + roles: { default: 'anthropic/claude-fable-5:xhigh', executor: 'openai-codex/gpt-5.6-terra:xhigh' }, + }, + ]); + + assert.deepEqual(groups.find((group) => group.group === 'anthropic')?.models, ['anthropic/claude-fable-5']); + assert.deepEqual(groups.find((group) => group.group === 'openai-codex')?.models, ['openai-codex/gpt-5.6-terra']); +}); + +test('stripEffortSuffix removes only trailing known effort levels', () => { + assert.equal(stripEffortSuffix('anthropic/claude-fable-5:medium'), 'anthropic/claude-fable-5'); + assert.equal(stripEffortSuffix('kimi-code/k3:high'), 'kimi-code/k3'); + assert.equal(stripEffortSuffix('custom/codex'), 'custom/codex'); + // Unknown suffixes are part of the model id, not an effort level. + assert.equal(stripEffortSuffix('vendor/model:preview'), 'vendor/model:preview'); +}); + +test('resolveDisplayModel prefers the live session model over every fallback', () => { + assert.equal( + resolveDisplayModel('profile:codex-medium', 'openai/live-model', catalog), + 'openai/live-model', + ); +}); + +test('resolveDisplayModel shows a raw selection when the session has not reported yet', () => { + assert.equal(resolveDisplayModel('custom/codex', undefined, catalog), 'custom/codex'); +}); + +test('resolveDisplayModel falls back to the selected preset default role, then Current', () => { + assert.equal(resolveDisplayModel('profile:codex-medium', undefined, catalog), 'custom/codex'); + // The default role selector carries `:medium`; the display strips it. + assert.equal(resolveDisplayModel('default', undefined, catalog), 'openai/gpt-5.6-sol'); + // Unknown selection resolves through the Current preset. + assert.equal(resolveDisplayModel('profile:missing', undefined, catalog), 'openai/gpt-5.6-sol'); +}); + +test('provider groups follow the requested order: codex, claude, kimi, glm, grok, then rest', () => { + const groups = deriveSessionModelOptions([ + { + value: 'profile:mix', + label: 'Mix', + roles: { + default: 'anthropic/claude-opus-5:xhigh', + planner: 'kimi-code/k3:high', + executor: 'openai-codex/gpt-5.6-terra:xhigh', + architect: 'zai/glm-5', + critic: 'xai/grok-5:high', + }, + }, + { + value: 'profile:rest', + label: 'Rest', + roles: { default: 'cursor/composer-2', planner: 'alibaba-token-plan/qwen3.7-max' }, + }, + ]); + + assert.deepEqual( + groups.map((group) => group.group), + ['openai-codex', 'anthropic', 'kimi-code', 'zai', 'xai', 'alibaba-token-plan', 'cursor'], + ); +}); + +test('resolveDisplayModel strips an effort suffix from the live session report', () => { + assert.equal( + resolveDisplayModel('default', 'anthropic/claude-fable-5:high', catalog), + 'anthropic/claude-fable-5', + ); +}); + +test('a profile-name reference never renders as a model and resolves through the preset', () => { + const referencing: ProviderModelOption[] = [ + { value: 'default', label: 'Current', roles: { default: 'fable-opus-codex' } }, + { + value: 'profile:fable-opus-codex', + label: 'Fable + Opus + Codex', + roles: { default: 'anthropic/claude-fable-5:medium' }, + }, + ]; + + assert.equal( + resolveDisplayModel('default', undefined, referencing), + 'anthropic/claude-fable-5', + ); + // The bare profile name is filtered out of the selectable model list. + const models = deriveSessionModelOptions(referencing).flatMap((group) => group.models); + assert.ok(!models.includes('fable-opus-codex')); + // An unresolvable reference falls back to no display rather than a bogus id. + assert.equal( + resolveDisplayModel('default', undefined, [ + { value: 'default', label: 'Current', roles: { default: 'missing-profile' } }, + ]), + undefined, + ); +}); diff --git a/src/components/chat/tests/sessionStatusSummary.test.tsx b/src/components/chat/tests/sessionStatusSummary.test.tsx deleted file mode 100644 index a22df2b..0000000 --- a/src/components/chat/tests/sessionStatusSummary.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createElement } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; - -import SessionStatusSummary from '../view/subcomponents/SessionStatusSummary'; - -/* - * The composer footer's session summary. - * - * The rule worth testing is what it refuses to show. A percentage needs a real - * context window behind it — the same 12.3K is comfortable in a 200k window and - * nearly fatal in a 32k one, so inventing a default would print a confident - * number that is wrong for the model actually answering. - */ - -const render = (sessionState: Record | null): string => - renderToStaticMarkup(createElement(SessionStatusSummary, { sessionState })); - -const full = { - modelId: 'openai/gpt-5-codex', - thinkingLevel: 'high', - cwd: '/Users/dev/repos/gajae-code-app', - contextTokens: 42_000, - contextWindow: 200_000, - contextPercent: 21, -}; - -test('renders model, reasoning, directory and context share', () => { - const html = render(full); - - assert.match(html, />gpt-5-codexhigh21% { - const html = render(full); - - // The footer truncates; the untruncated value has to remain recoverable. - assert.match(html, /title="openai\/gpt-5-codex"/); - assert.match(html, /title="\/Users\/dev\/repos\/gajae-code-app"/); - // Both figures, so the percentage can be checked against real numbers. - assert.match(html, /title="42,000 \/ 200,000 tokens"/); -}); - -test('no percentage is shown without a real context window', () => { - // This is the whole point. A token count with no denominator must not become - // a percentage against an assumed window. - const html = render({ modelId: 'gpt-5', contextTokens: 42_000, contextPercent: 21 }); - - assert.match(html, />gpt-5 { - const modelOnly = render({ modelId: 'gpt-5' }); - assert.match(modelOnly, />gpt-563%gpt-5 { - // Every session has one; printing "default" spends footer width on nothing. - assert.doesNotMatch(render({ modelId: 'gpt-5', thinkingLevel: 'default' }), />defaultlow { - const html = render({ cwd: '/var/opt/builds/team/project/service' }); - - assert.match(html, /…\/project\/service/); - assert.match(html, /title="\/var\/opt\/builds\/team\/project\/service"/); -}); - -test('nothing renders before the first turn reports anything', () => { - assert.equal(render(null), ''); - assert.equal(render({}), ''); - // Malformed values are dropped rather than printed raw. - assert.equal(render({ modelId: ' ', contextPercent: 'lots' }), ''); -}); diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 4b696b6..83a7413 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ArrowDownIcon } from 'lucide-react'; @@ -18,6 +18,11 @@ import OAuthLoginDialog from '../OAuthLoginDialog'; import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; import CommandResultModal from './subcomponents/CommandResultModal'; +import type { ReasoningEffort } from './subcomponents/ReasoningEffortPicker'; + +const REASONING_EFFORTS = new Set([ + 'default', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', +]); export function isHistoricalNonGjcReadOnlySession(selectedSession: ProjectSession | null): boolean { const provider = selectedSession?.provider ?? selectedSession?.__provider; @@ -82,6 +87,8 @@ function ChatInterface({ selectedProject, }); const oauthLogin = useOAuthLogin(); + const [reasoningEffort, setReasoningEffort] = useState('default'); + const reasoningSessionRef = useRef(selectedSession?.id ?? null); useEffect(() => { if (oauthLogin.attempt?.phase === 'completed') { @@ -152,7 +159,7 @@ function ChatInterface({ textareaRef, inputHighlightRef, isTextareaExpanded, - slashCommandsCount, + skillCommands, filteredCommands, frequentCommands, commandQuery, @@ -160,7 +167,6 @@ function ChatInterface({ selectedCommandIndex, resetCommandMenuState, handleCommandSelect, - handleToggleCommandMenu, showFileDropdown, filteredFiles, selectedFileIndex, @@ -189,7 +195,6 @@ function ChatInterface({ handleTextareaClick, handleTextareaInput, syncInputOverlayScroll, - handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, @@ -202,6 +207,7 @@ function ChatInterface({ selectedSession, currentSessionId, gjcModel, + reasoningEffort, isLoading: isProcessing, canAbortSession, tokenBudget, @@ -219,6 +225,25 @@ function ChatInterface({ setPendingPermissionRequests, }); + useEffect(() => { + const previousSessionId = reasoningSessionRef.current; + const nextSessionId = selectedSession?.id ?? null; + // A brand-new chat transitions from no selected row to its freshly + // allocated session id after the first send. Preserve the effort chosen + // in the landing composer across that handoff. + if (previousSessionId && previousSessionId !== nextSessionId) { + setReasoningEffort('default'); + } + reasoningSessionRef.current = nextSessionId; + }, [selectedSession?.id]); + + useEffect(() => { + const reported = sessionState?.thinkingLevel; + if (typeof reported === 'string' && REASONING_EFFORTS.has(reported as ReasoningEffort)) { + setReasoningEffort(reported as ReasoningEffort); + } + }, [sessionState?.thinkingLevel]); + // On WebSocket reconnect, re-fetch the current session's messages from the // server so missed streaming events are shown, then re-subscribe — the // `chat_subscribed` ack restores or clears the activity indicator, replays @@ -328,10 +353,6 @@ function ChatInterface({ tokenBudget={tokenBudget} sessionState={sessionState} onShowTokenUsage={showCostModal} - slashCommandsCount={slashCommandsCount} - onToggleCommandMenu={handleToggleCommandMenu} - hasInput={Boolean(input.trim())} - onClearInput={handleClearInput} onSubmit={handleSubmit} isDragActive={isDragActive} queuedDraft={queuedDraft} @@ -353,6 +374,7 @@ function ChatInterface({ selectedFileIndex={selectedFileIndex} onSelectFile={selectFile} filteredCommands={filteredCommands} + skillCommands={skillCommands} selectedCommandIndex={selectedCommandIndex} onCommandSelect={handleCommandSelect} onCloseCommandMenu={resetCommandMenuState} @@ -386,6 +408,8 @@ function ChatInterface({ model, currentSessionId || selectedSession?.id || null, )} + reasoningEffort={reasoningEffort} + onSelectReasoningEffort={setReasoningEffort} /> ); diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 889465f..a4dcca2 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -11,7 +11,7 @@ import type { TouchEvent, } from 'react'; import type { DropzoneInputProps, DropzoneRootProps } from 'react-dropzone'; -import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ArrowUpIcon } from 'lucide-react'; +import { PlusIcon, Loader2, ArrowUpIcon } from 'lucide-react'; import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; @@ -38,8 +38,11 @@ import PermissionRequestsBanner from './PermissionRequestsBanner'; import TokenUsageSummary from './TokenUsageSummary'; import QueuedMessageCard from './QueuedMessageCard'; import CommandGateCard from './CommandGateCard'; -import SessionStatusSummary from './SessionStatusSummary'; import ModelPresetPicker from './ModelPresetPicker'; +import SessionModelPicker from './SessionModelPicker'; +import ContextUsageBadge from './ContextUsageBadge'; +import ReasoningEffortPicker, { type ReasoningEffort } from './ReasoningEffortPicker'; +import SkillPicker from './SkillPicker'; interface MentionableFile { name: string; @@ -68,10 +71,6 @@ interface ChatComposerProps { tokenBudget: Record | null; sessionState: Record | null; onShowTokenUsage: () => void; - slashCommandsCount: number; - onToggleCommandMenu: () => void; - hasInput: boolean; - onClearInput: () => void; onSubmit: ( event: FormEvent | MouseEvent @@ -94,6 +93,7 @@ interface ChatComposerProps { selectedFileIndex: number; onSelectFile: (file: MentionableFile) => void; filteredCommands: SlashCommand[]; + skillCommands: SlashCommand[]; selectedCommandIndex: number; onCommandSelect: (command: SlashCommand, index: number, isHover: boolean) => void; onCloseCommandMenu: () => void; @@ -124,6 +124,8 @@ interface ChatComposerProps { /** Monotonic signal: each increment opens the model preset popup. */ modelPickerOpenTrigger?: number; onSelectModelPreset?: (value: string) => Promise | unknown; + reasoningEffort?: ReasoningEffort; + onSelectReasoningEffort?: (value: ReasoningEffort) => void; } export default function ChatComposer({ @@ -135,10 +137,6 @@ export default function ChatComposer({ tokenBudget, sessionState, onShowTokenUsage, - slashCommandsCount, - onToggleCommandMenu, - hasInput, - onClearInput, onSubmit, isDragActive, queuedDraft, @@ -156,6 +154,7 @@ export default function ChatComposer({ selectedFileIndex, onSelectFile, filteredCommands, + skillCommands, selectedCommandIndex, onCommandSelect, onCloseCommandMenu, @@ -185,6 +184,8 @@ export default function ChatComposer({ modelPresetsLoading, modelPickerOpenTrigger, onSelectModelPreset = () => {}, + reasoningEffort = 'default', + onSelectReasoningEffort = () => {}, }: ChatComposerProps) { const { t } = useTranslation('chat'); const commandMenuPosition = useMemo(() => { @@ -397,42 +398,47 @@ export default function ChatComposer({ tooltip={{ content: t('input.attachImages') }} onClick={openImagePicker} > - + {onVoiceTranscript && voiceAvailable && ( )} + {modelPresetOptions.length > 0 && ( + + )} - + + + {modelPresetOptions.length > 0 && ( + + )} - + onCommandSelect(skill, index, false)} + /> - - - {slashCommandsCount > 0 && ( - - {slashCommandsCount} - - )} - + - {hasInput && ( - - - - )} + @@ -446,15 +452,6 @@ export default function ChatComposer({ {submitHint} )} - {modelPresetOptions.length > 0 && ( - - )} | null; +}; + +const finite = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value : undefined; + +/** + * Compact context-fullness pill for the composer toolbar. Renders only when + * the session actually reported a context window; there is no fallback size + * because guessing one would print a confidently wrong percentage. + */ +export default function ContextUsageBadge({ sessionState }: ContextUsageBadgeProps) { + const percent = finite(sessionState?.contextPercent); + const contextWindow = finite(sessionState?.contextWindow); + if (percent === undefined || contextWindow === undefined) return null; + + const used = finite(sessionState?.contextTokens); + const rounded = Math.round(percent); + const tone = rounded >= 90 + ? 'text-red-500' + : rounded >= 70 + ? 'text-amber-500' + : 'text-muted-foreground'; + + return ( + + + {rounded}% + + ); +} diff --git a/src/components/chat/view/subcomponents/ModelPresetPicker.tsx b/src/components/chat/view/subcomponents/ModelPresetPicker.tsx index 31bd039..9dc20f3 100644 --- a/src/components/chat/view/subcomponents/ModelPresetPicker.tsx +++ b/src/components/chat/view/subcomponents/ModelPresetPicker.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Check, ChevronDown, ChevronRight, Loader2, Search } from 'lucide-react'; +import { Boxes, Check, ChevronDown, ChevronRight, Loader2, Search } from 'lucide-react'; import { cn } from '../../../../lib/utils'; import type { ProviderModelOption } from '../../../../types/app'; @@ -11,6 +11,8 @@ type ModelPresetPickerProps = { loading?: boolean; /** Monotonic signal (e.g. from the /model app command): each increment opens the popup. */ openTrigger?: number; + /** Compact icon trigger for toolbar placement next to the skill picker. */ + iconOnly?: boolean; onSelect: (value: string) => Promise | unknown; }; @@ -24,6 +26,15 @@ const ROLE_LABELS = { const UNGROUPED = '__ungrouped__'; +/** Display order requested for preset groups; unlisted groups keep catalog order after these. */ +const GROUP_ORDER = ['CODEX', 'CLAUDE', 'KIMI CODING PLAN', 'GLM', 'GROK']; + +const groupRank = (group: string): number => { + if (group === UNGROUPED) return -1; + const index = GROUP_ORDER.indexOf(group); + return index === -1 ? GROUP_ORDER.length : index; +}; + function compactModelLabel(selector: string): string { const withoutProvider = selector.includes('/') ? selector.slice(selector.indexOf('/') + 1) : selector; return withoutProvider.replace(/:/, ' · '); @@ -65,10 +76,17 @@ function groupOptions(options: ProviderModelOption[]): Array<{ group: string; op } } - return groups; + // Stable sort: pinned "Current" first, then the requested group order, + // then remaining groups in catalog order. + return groups + .map((entry, index) => ({ entry, index })) + .sort((left, right) => ( + groupRank(left.entry.group) - groupRank(right.entry.group) || left.index - right.index + )) + .map(({ entry }) => entry); } -export default function ModelPresetPicker({ value, options, loading = false, openTrigger, onSelect }: ModelPresetPickerProps) { +export default function ModelPresetPicker({ value, options, loading = false, openTrigger, iconOnly = false, onSelect }: ModelPresetPickerProps) { const [open, setOpen] = useState(false); const [selecting, setSelecting] = useState(false); const [query, setQuery] = useState(''); @@ -165,18 +183,32 @@ export default function ModelPresetPicker({ value, options, loading = false, ope return (
- + {iconOnly ? ( + + ) : ( + + )} {open && createPortal(
void; +}; + +const OPTIONS: Array<{ value: ReasoningEffort; label: string }> = [ + { value: 'default', label: 'Default' }, + { value: 'off', label: 'Off' }, + { value: 'minimal', label: 'Minimal' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'Extra high' }, + { value: 'max', label: 'Max' }, +]; + +export default function ReasoningEffortPicker({ value, onSelect }: ReasoningEffortPickerProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const popupRef = useRef(null); + const [popupPosition, setPopupPosition] = useState({ bottom: 0, left: 0 }); + const selected = OPTIONS.find((option) => option.value === value) ?? OPTIONS[0]; + + // The composer form clips its children (overflow-hidden rounded corners), so + // the popup must escape through a body portal with fixed positioning. + useEffect(() => { + if (!open) return; + const rect = rootRef.current?.getBoundingClientRect(); + if (rect) { + setPopupPosition({ + bottom: window.innerHeight - rect.top + 8, + left: Math.max(8, Math.min(rect.left, window.innerWidth - 160 - 8)), + }); + } + const close = (event: MouseEvent) => { + const target = event.target as Node; + if (!rootRef.current?.contains(target) && !popupRef.current?.contains(target)) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + return ( +
+ + + {open && createPortal( +
+

+ Reasoning +

+ {OPTIONS.map((option) => ( + + ))} +
, + document.body, + )} +
+ ); +} diff --git a/src/components/chat/view/subcomponents/SessionModelPicker.tsx b/src/components/chat/view/subcomponents/SessionModelPicker.tsx new file mode 100644 index 0000000..71d8268 --- /dev/null +++ b/src/components/chat/view/subcomponents/SessionModelPicker.tsx @@ -0,0 +1,224 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Check, ChevronDown, Loader2 } from 'lucide-react'; + +import { cn } from '../../../../lib/utils'; +import type { ProviderModelOption } from '../../../../types/app'; + +export const DEFAULT_MODEL_VALUE = 'default'; + +type SessionModelPickerProps = { + /** Session-scoped selection: raw model id, `profile:*` preset, or `default`. */ + value: string; + /** Model the session runtime last reported; wins for display when present. */ + currentModel?: string; + /** Preset catalog; the raw model choices are derived from its role mappings. */ + presetOptions: ProviderModelOption[]; + loading?: boolean; + onSelect: (modelId: string) => Promise | unknown; +}; + +const compactModel = (modelId: string): string => modelId.split('/').pop() ?? modelId; + +const providerOf = (modelId: string): string => ( + modelId.includes('/') ? modelId.slice(0, modelId.indexOf('/')) : '' +); + +/** + * Preset role selectors read `provider/model:effort`. Reasoning is chosen by + * the effort picker next door, so the model list must offer only the base + * model id — otherwise every effort variant shows up as its own "model". + */ +export const stripEffortSuffix = (selector: string): string => + selector.replace(/:(?:off|minimal|low|medium|high|xhigh|max)$/, ''); + +/** Display order requested for provider groups; unlisted providers follow alphabetically. */ +const PROVIDER_ORDER = ['openai-codex', 'anthropic', 'kimi-code', 'zai', 'xai', 'grok-build']; + +const providerRank = (provider: string): number => { + const index = PROVIDER_ORDER.indexOf(provider); + return index === -1 ? PROVIDER_ORDER.length : index; +}; + +/** + * Unique raw model ids mentioned by any preset role, grouped by provider + * prefix. The preset catalog is the only model inventory the server exposes, + * and every model a preset can route to is by definition runnable, so the + * union of role mappings is exactly the set of valid direct choices. + */ +export function deriveSessionModelOptions( + presetOptions: ProviderModelOption[], +): Array<{ group: string; models: string[] }> { + const seen = new Set(); + for (const option of presetOptions) { + for (const selector of Object.values(option.roles ?? {})) { + // A model selector always reads provider/model; anything else (e.g. a + // profile name leaking out of config.yml) is not a selectable model. + if (typeof selector === 'string' && selector.includes('/')) seen.add(stripEffortSuffix(selector.trim())); + } + } + const groups = new Map(); + for (const model of [...seen].sort()) { + const group = providerOf(model) || 'other'; + const bucket = groups.get(group); + if (bucket) bucket.push(model); + else groups.set(group, [model]); + } + return [...groups.entries()] + .sort(([left], [right]) => providerRank(left) - providerRank(right) || left.localeCompare(right)) + .map(([group, models]) => ({ group, models })); +} + +/** + * Resolves which model id the trigger button should display: the live session + * report wins, then an explicit raw selection, then the default-role model of + * the selected (or current) preset. + */ +export function resolveDisplayModel( + value: string, + currentModel: string | undefined, + presetOptions: ProviderModelOption[], +): string | undefined { + if (currentModel?.trim()) return stripEffortSuffix(currentModel.trim()); + if (value && value !== DEFAULT_MODEL_VALUE && !value.startsWith('profile:')) return value; + const preset = presetOptions.find((option) => option.value === value) + ?? presetOptions.find((option) => option.value === DEFAULT_MODEL_VALUE); + const role = preset?.roles?.default; + if (!role) return undefined; + if (role.includes('/')) return stripEffortSuffix(role); + // A profile-name reference (no provider prefix): resolve one level through + // the referenced preset's own default role. + const referenced = presetOptions.find((option) => option.value === `profile:${role}`)?.roles?.default; + return referenced?.includes('/') ? stripEffortSuffix(referenced) : undefined; +} + +/** + * Session default-model picker. Unlike the preset picker (which swaps the + * whole five-role agent configuration), this changes only the model answering + * this session's default agent, via the same per-session active-model store. + */ +export default function SessionModelPicker({ + value, + currentModel, + presetOptions, + loading = false, + onSelect, +}: SessionModelPickerProps) { + const [open, setOpen] = useState(false); + const [selecting, setSelecting] = useState(false); + const rootRef = useRef(null); + const popupRef = useRef(null); + const [popupPosition, setPopupPosition] = useState({ bottom: 0, left: 0 }); + + const groups = useMemo(() => deriveSessionModelOptions(presetOptions), [presetOptions]); + const displayModel = resolveDisplayModel(value, currentModel, presetOptions); + const isRawSelection = value !== DEFAULT_MODEL_VALUE && !value.startsWith('profile:'); + + // The composer form clips its children (overflow-hidden rounded corners), so + // the popup must escape through a body portal with fixed positioning. + useEffect(() => { + if (!open) return; + const rect = rootRef.current?.getBoundingClientRect(); + if (rect) { + setPopupPosition({ + bottom: window.innerHeight - rect.top + 8, + left: Math.max(8, Math.min(rect.left, window.innerWidth - 288 - 8)), + }); + } + const close = (event: MouseEvent) => { + const target = event.target as Node; + if (!rootRef.current?.contains(target) && !popupRef.current?.contains(target)) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + const choose = async (modelId: string) => { + if (modelId === value) { + setOpen(false); + return; + } + setSelecting(true); + try { + await onSelect(modelId); + setOpen(false); + } finally { + setSelecting(false); + } + }; + + return ( +
+ + + {open && createPortal( +
+
+

세션 모델

+

+ 이 세션의 기본 에이전트가 사용할 모델만 바꿉니다. +

+
+ +
+ + + {groups.map(({ group, models }) => ( +
+

+ {group} +

+ {models.map((model) => { + const isSelected = value === model; + return ( + + ); + })} +
+ ))} +
+
, + document.body, + )} +
+ ); +} diff --git a/src/components/chat/view/subcomponents/SessionStatusSummary.tsx b/src/components/chat/view/subcomponents/SessionStatusSummary.tsx deleted file mode 100644 index 0fee0e0..0000000 --- a/src/components/chat/view/subcomponents/SessionStatusSummary.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -type SessionStatusSummaryProps = { - /** Snapshot read off the live session at each turn end; null before the first turn. */ - sessionState: Record | null; -}; - -const text = (value: unknown): string | undefined => - typeof value === 'string' && value.trim() ? value : undefined; - -const finite = (value: unknown): number | undefined => - typeof value === 'number' && Number.isFinite(value) ? value : undefined; - -/** `/Users/me/repos/app` -> `~/repos/app`, then the last two segments. */ -function compactPath(path: string): string { - const home = path.replace(/^\/(?:Users|home)\/[^/]+/, '~'); - const segments = home.split('/').filter(Boolean); - if (home.startsWith('~') || segments.length <= 2) return home; - return `…/${segments.slice(-2).join('/')}`; -} - -/** Drops a provider prefix so `openai/gpt-5-codex` reads as `gpt-5-codex`. */ -const compactModel = (modelId: string): string => modelId.split('/').pop() ?? modelId; - -/** - * The facts the TUI keeps in its footer: which model is answering, at what - * reasoning level, in which directory, and how full the context is. - * - * The app showed only a raw token count, because the context window it is a - * fraction of never left the server. `12.3K` says nothing on its own — the same - * number is comfortable in a 200k window and nearly fatal in a 32k one. - * - * Each field renders only when the session actually reported it. There is no - * fallback context size: guessing one would print a confident percentage that - * is simply wrong for the model in use. - */ -export default function SessionStatusSummary({ sessionState }: SessionStatusSummaryProps) { - const { t } = useTranslation('chat'); - if (!sessionState) return null; - - const model = text(sessionState.modelId); - const thinking = text(sessionState.thinkingLevel); - const cwd = text(sessionState.cwd); - const percent = finite(sessionState.contextPercent); - const contextWindow = finite(sessionState.contextWindow); - - const parts: Array<{ key: string; label: string; title: string }> = []; - - if (model) { - parts.push({ key: 'model', label: compactModel(model), title: model }); - } - if (thinking && thinking !== 'default') { - parts.push({ - key: 'thinking', - label: thinking, - title: t('input.status.reasoning', { defaultValue: 'Reasoning effort' }), - }); - } - if (cwd) { - parts.push({ key: 'cwd', label: compactPath(cwd), title: cwd }); - } - if (percent !== undefined && contextWindow !== undefined) { - const used = finite(sessionState.contextTokens); - // Built without interpolation on purpose: these are numbers, they need no - // translation, and a `{{window}}` placeholder leaks verbatim anywhere i18n - // has not initialised. Showing both figures also beats restating the - // percentage already on screen. - parts.push({ - key: 'context', - label: `${Math.round(percent)}%`, - title: used !== undefined - ? `${used.toLocaleString()} / ${contextWindow.toLocaleString()} tokens` - : `${contextWindow.toLocaleString()} token context`, - }); - } - - if (parts.length === 0) return null; - - return ( -
- {parts.map((part, index) => ( - - {index > 0 && ·} - {part.label} - - ))} -
- ); -} diff --git a/src/components/chat/view/subcomponents/SkillPicker.tsx b/src/components/chat/view/subcomponents/SkillPicker.tsx new file mode 100644 index 0000000..0e82230 --- /dev/null +++ b/src/components/chat/view/subcomponents/SkillPicker.tsx @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Search, Sparkles } from 'lucide-react'; + +export type SelectableSkill = { + name: string; + description?: string; + path?: string; + type?: string; + metadata?: Record; +}; + +type SkillPickerProps = { + skills: SelectableSkill[]; + onSelect: (skill: SelectableSkill, index: number) => void; +}; + +const displayName = (skill: SelectableSkill): string => + String(skill.metadata?.skillName ?? skill.name.replace(/^\/skill:/, '')); + +export default function SkillPicker({ skills, onSelect }: SkillPickerProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const rootRef = useRef(null); + const popupRef = useRef(null); + const searchRef = useRef(null); + const [popupPosition, setPopupPosition] = useState({ bottom: 0, left: 0 }); + + // The composer form clips its children (overflow-hidden rounded corners), so + // the popup must escape through a body portal with fixed positioning. + useEffect(() => { + if (!open) return; + setQuery(''); + const rect = rootRef.current?.getBoundingClientRect(); + if (rect) { + setPopupPosition({ + bottom: window.innerHeight - rect.top + 8, + left: Math.max(8, Math.min(rect.left, window.innerWidth - 320 - 8)), + }); + } + window.requestAnimationFrame(() => searchRef.current?.focus()); + const close = (event: MouseEvent) => { + const target = event.target as Node; + if (!rootRef.current?.contains(target) && !popupRef.current?.contains(target)) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + const filteredSkills = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return skills; + return skills.filter((skill) => + `${displayName(skill)} ${skill.description ?? ''}`.toLowerCase().includes(normalized), + ); + }, [query, skills]); + + return ( +
+ + + {open && createPortal( +
+
+

스킬

+

현재 프로젝트에서 사용할 스킬을 선택합니다.

+
+
+ + setQuery(event.target.value)} + placeholder="스킬 검색" + aria-label="스킬 검색" + className="h-7 w-full rounded-md border border-input bg-background pl-7 pr-2 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" + /> +
+
+ {filteredSkills.length > 0 ? filteredSkills.map((skill) => { + const originalIndex = skills.indexOf(skill); + return ( + + ); + }) : ( +

+ 일치하는 스킬이 없습니다. +

+ )} +
+
, + document.body, + )} +
+ ); +} From f62f9ff07d8ade085d97803332db368e1bb8c812 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:25:37 +0900 Subject: [PATCH 3/5] fix(oauth): stop closed tabs from cancelling other clients' login attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oauth.phase events broadcast to every connected client, and any tab with the login dialog closed cancelled every non-terminal attempt it heard about — starting a sign-in with a second app tab open killed the attempt instantly and bounced the dialog back to the provider list with no error. Closed tabs now ignore broadcasts; the owning tab still cancels on dialog close/unmount and the server times abandoned attempts out. Also open the browser when a callback-server flow lands directly on awaiting_input (the localhost listener races manual paste), and label the input for what it accepts: an authorization code or the full localhost callback URL. --- src/components/chat/OAuthLoginDialog.tsx | 13 +++++++++++-- src/components/chat/hooks/useOAuthLogin.ts | 14 ++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/chat/OAuthLoginDialog.tsx b/src/components/chat/OAuthLoginDialog.tsx index 6dc7bc6..8f24dcc 100644 --- a/src/components/chat/OAuthLoginDialog.tsx +++ b/src/components/chat/OAuthLoginDialog.tsx @@ -99,6 +99,7 @@ function OAuthLoginDialog({ const isBusy = isStarting || Boolean(attempt && !isTerminal && attempt.phase !== 'awaiting_browser' && attempt.phase !== 'awaiting_input'); const authorizationUrl = safeOAuthAuthorizationUrl(attempt?.authorizationUrl); const passwordInput = attempt?.password === true || attempt?.valueKind === 'password'; + const manualCodeInput = attempt?.valueKind === 'manual_code'; const showAuthorizationLink = shouldDisplayOAuthAuthorizationLink(attempt); const showProviderPicker = !attempt || (isTerminal && attempt.phase !== 'completed'); @@ -254,7 +255,11 @@ function OAuthLoginDialog({ {attempt.instruction || phaseLabel(attempt.phase)}