mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-02 18:45:34 +08:00
feat: add opencode support (#762)
* feat: add opencode support
* fix: stabilize opencode session startup
* fix: /models
* fix: improveUI for commands
* fix: format commands.js
* feat: load models through provider adapters
Provider model selection had outgrown a single hardcoded service.
The old service mixed shared caching with provider catalogs and CLI lookup details.
That made stale model lists more likely as providers changed on separate schedules.
Move model discovery behind each provider so lookup lives next to the integration.
The shared service now focuses on provider resolution, caching, persistence, and dedupe.
Return cache metadata and add bypassCache because model availability changes outside the app.
The UI and /models command can show freshness and let users force a provider refresh.
Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.
* feat(models): resolve active session models through provider adapters
The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.
Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:
- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
provide a reliable active-session lookup
Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.
Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.
As part of this, centralize two supporting concerns:
- add a shared helper for building the default current-model result from
a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
readers and model lookup code enforce the same path-safety rule
Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.
Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
provider default when no true active model can be resolved
* feat: support session-scoped model overrides
Model selection was acting like a provider-level preference.
That made resumed sessions drift back to a default or request-time model.
Users expect /models changes made inside a conversation to affect that session.
Store explicit session choices in app-owned ~/.cloudcli state.
This avoids editing provider transcripts or native provider config.
Resolve the effective model before launching each provider runtime.
Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.
Expose a backend active-model change endpoint for existing sessions.
The models modal can now distinguish default changes from session overrides.
It also shows when a selected model will apply on the next response.
For Claude, stop probing active model state by resuming with a dummy prompt.
Read the indexed JSONL transcript from the end instead.
This preserves provider history while honoring /model stdout or model fields.
Add service tests for adapter delegation and resume-model precedence.
The tests keep cache state, override state, and requested fallback separate.
* feat: make command modal more compact
* fix: preserve opencode session creation events
OpenCode emits the real session id asynchronously on its first JSON output. The runner
registered that id from a helper that could not see the spawned process because
the process reference was scoped inside the model-resolution callback. That
ReferenceError was swallowed by the generic JSON parse fallback, so the client
never received session_created. Without that event, a new OpenCode chat stayed
on / and the assistant stream was not attached to the new session view.
Keep the process reference in the outer spawn scope so registration can update
the active-process map and websocket writer as soon as OpenCode announces the
session id. Split JSON parsing from event processing so malformed non-JSON
output can still stream as raw text, while registration or adapter failures are
surfaced as real errors instead of being hidden as assistant content.
Add a fake opencode executable regression test to lock in the expected lifecycle
ordering: session_created must be sent before live assistant messages, and the
same session id must carry through stream_end and complete.
* fix: clarify model refresh and onboarding providers
OpenCode is now a supported chat provider, but first-run onboarding still only offered
Claude, Cursor, Codex, and Gemini. That made OpenCode harder to discover and
forced users to finish setup before finding the provider in settings or chat.
Adding it to onboarding keeps first-run setup aligned with the providers the
application already supports elsewhere.
The model refresh control was also doing too much visual work. In the new chat
model picker, the previous Hard Refresh label looked like the dialog heading,
which made the primary task unclear. Users open that dialog to choose a model;
refreshing catalogs is only a secondary maintenance action for stale cached
provider model lists.
Rename and reposition the refresh affordance so the model picker reads as a
model picker first. The copy now explains why catalogs are cached, when a refresh
is useful, and that the refresh checks every provider. The /models modal gets the
same clarification so both model-selection surfaces describe the cache behavior
consistently.
* fix: format opencode model catalog labels
OpenCode returns provider-prefixed ids directly from the CLI. Passing those ids through as
labels made the model picker hard to scan: users saw values like
anthropic/claude-3-5-sonnet-20241022 or lowercased, hyphen-split text instead
of readable model names.
Keep the exact OpenCode id as the option value because that is what the CLI
expects, but derive a presentation label for the frontend. The formatter is
intentionally generic rather than a catalog of known providers. It handles common
identifier structure such as provider/model, hyphen-delimited words, v-prefixed
versions, adjacent numeric version tokens, and 8-digit date suffixes.
This keeps OpenCode usable as its model list expands across many upstream
providers without requiring code changes for every new provider or model family.
The description keeps the raw provider-prefixed id visible so users can still
confirm the precise model being selected.
* feat: add more fallback models for cursor
* docs: move model catalog out of shared
The model catalog is no longer a frontend/backend runtime contract.
Keeping it under shared made ownership misleading. It implied the catalog was
application code shared by runtime consumers, even though it now only supports
README links and public API documentation.
Move the catalog into public so it lives beside the docs surfaces that need it.
This gives the API docs a stable, served module and gives README readers a
linkable source without suggesting frontend or backend runtime dependency.
Render the API docs model list from the exported provider registry instead of a
hardcoded Claude/Cursor/Codex subset. That keeps Gemini and OpenCode visible and
makes future provider documentation changes flow through one docs-specific file.
Update README links, provider maintenance notes, and package files so published
artifacts include the standalone docs page and model catalog without relying on
the old shared path.
* fix: simplify empty-state model selector
Keep the provider empty state focused on the setup action users need there:
choosing a model.
The refresh control, cache timestamp, and refresh explanation made the dialog feel
like a cache-management surface.
That extra action is out of place in the empty state, where the goal is to start
a chat with the selected provider and model.
Remove the refresh-specific UI from ProviderSelectionEmptyState and drop the
now-unused refresh/cache props from the ChatMessagesPane pass-through.
Refresh behavior remains available in the dedicated command result flow.
This commit is contained in:
@@ -20,14 +20,13 @@ import type {
|
||||
PendingPermissionRequest,
|
||||
PermissionMode,
|
||||
} from '../types/types';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app';
|
||||
import { escapeRegExp } from '../utils/chatFormatting';
|
||||
|
||||
import { useFileMentions } from './useFileMentions';
|
||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -42,6 +41,7 @@ interface UseChatComposerStateArgs {
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
opencodeModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
@@ -55,8 +55,6 @@ interface UseChatComposerStateArgs {
|
||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||
scrollToBottom: () => void;
|
||||
addMessage: (msg: ChatMessage) => void;
|
||||
clearMessages: () => void;
|
||||
rewindMessages: (count: number) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
@@ -78,6 +76,76 @@ interface CommandExecutionResult {
|
||||
hasFileIncludes?: boolean;
|
||||
}
|
||||
|
||||
export type ModelCommandData = {
|
||||
current?: {
|
||||
provider?: string;
|
||||
providerLabel?: string;
|
||||
model?: string;
|
||||
};
|
||||
available?: Partial<Record<LLMProvider, string[]>>;
|
||||
availableModels?: string[];
|
||||
availableOptions?: Array<{
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}>;
|
||||
defaultModel?: string;
|
||||
cache?: ProviderModelsCacheInfo;
|
||||
};
|
||||
|
||||
export type CostCommandData = {
|
||||
tokenUsage?: {
|
||||
used?: number;
|
||||
total?: number;
|
||||
percentage?: number;
|
||||
};
|
||||
cost?: {
|
||||
input?: string;
|
||||
output?: string;
|
||||
total?: string;
|
||||
};
|
||||
tokenBreakdown?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache?: number;
|
||||
};
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type StatusCommandData = {
|
||||
version?: string;
|
||||
packageName?: string;
|
||||
uptime?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
nodeVersion?: string;
|
||||
platform?: string;
|
||||
pid?: number;
|
||||
memoryUsage?: {
|
||||
rssMb?: number;
|
||||
heapUsedMb?: number;
|
||||
heapTotalMb?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type HelpCommandData = {
|
||||
content?: string;
|
||||
format?: string;
|
||||
commands?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CommandModalKind = 'help' | 'models' | 'cost' | 'status';
|
||||
|
||||
export type CommandModalPayload = {
|
||||
kind: CommandModalKind;
|
||||
data: HelpCommandData | ModelCommandData | CostCommandData | StatusCommandData;
|
||||
};
|
||||
|
||||
const createFakeSubmitEvent = () => {
|
||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||
};
|
||||
@@ -111,6 +179,7 @@ export function useChatComposerState({
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -124,8 +193,6 @@ export function useChatComposerState({
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
@@ -145,6 +212,7 @@ export function useChatComposerState({
|
||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
const [thinkingMode, setThinkingMode] = useState('none');
|
||||
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
||||
@@ -158,35 +226,33 @@ export function useChatComposerState({
|
||||
(result: CommandExecutionResult) => {
|
||||
const { action, data } = result;
|
||||
switch (action) {
|
||||
case 'clear':
|
||||
clearMessages();
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
setCommandModalPayload({
|
||||
kind: 'help',
|
||||
data: (data || {}) as HelpCommandData,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'model':
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
|
||||
timestamp: Date.now(),
|
||||
case 'models':
|
||||
setCommandModalPayload({
|
||||
kind: 'models',
|
||||
data: (data || {}) as ModelCommandData,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'cost': {
|
||||
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
|
||||
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
|
||||
setCommandModalPayload({
|
||||
kind: 'cost',
|
||||
data: (data || {}) as CostCommandData,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
|
||||
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
|
||||
setCommandModalPayload({
|
||||
kind: 'status',
|
||||
data: (data || {}) as StatusCommandData,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -213,30 +279,17 @@ export function useChatComposerState({
|
||||
onShowSettings?.();
|
||||
break;
|
||||
|
||||
case 'rewind':
|
||||
if (data.error) {
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `Warning: ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
rewindMessages(data.steps * 2);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `Rewound ${data.steps} step(s). ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown built-in command action:', action);
|
||||
}
|
||||
},
|
||||
[onFileOpen, onShowSettings, addMessage, clearMessages, rewindMessages],
|
||||
[onFileOpen, onShowSettings, addMessage],
|
||||
);
|
||||
|
||||
const closeCommandModal = useCallback(() => {
|
||||
setCommandModalPayload(null);
|
||||
}, []);
|
||||
|
||||
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
|
||||
const { content, hasBashCommands } = result;
|
||||
|
||||
@@ -285,7 +338,15 @@ export function useChatComposerState({
|
||||
projectId: selectedProject.projectId,
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
||||
model: provider === 'cursor'
|
||||
? cursorModel
|
||||
: provider === 'codex'
|
||||
? codexModel
|
||||
: provider === 'gemini'
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
|
||||
@@ -337,6 +398,7 @@ export function useChatComposerState({
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
handleBuiltInCommand,
|
||||
handleCustomCommand,
|
||||
input,
|
||||
@@ -473,13 +535,26 @@ export function useChatComposerState({
|
||||
}
|
||||
|
||||
// Intercept slash commands only when "/" is the first input character.
|
||||
// Also accept exact "help" as a convenience alias for users who expect CLI-style help.
|
||||
const commandInput = currentInput.trimEnd();
|
||||
if (commandInput.startsWith('/')) {
|
||||
const isHelpAlias = commandInput.trim().toLowerCase() === 'help';
|
||||
if (commandInput.startsWith('/') || isHelpAlias) {
|
||||
const firstSpace = commandInput.indexOf(' ');
|
||||
const commandName = firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
|
||||
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
|
||||
const commandName = isHelpAlias
|
||||
? '/help'
|
||||
: firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
|
||||
const matchedCommand =
|
||||
slashCommands.find((cmd: SlashCommand) => cmd.name === commandName) ||
|
||||
(commandName === '/help'
|
||||
? ({
|
||||
name: '/help',
|
||||
description: 'Show help documentation for Claude Code',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' },
|
||||
} as SlashCommand)
|
||||
: undefined);
|
||||
if (matchedCommand && matchedCommand.type !== 'skill') {
|
||||
executeCommand(matchedCommand, commandInput);
|
||||
executeCommand(matchedCommand, isHelpAlias ? '/help' : commandInput);
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
setAttachedImages([]);
|
||||
@@ -555,13 +630,9 @@ export function useChatComposerState({
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
// For new sessions we intentionally keep this as `null` until the backend
|
||||
// emits `session_created` with the canonical provider session id.
|
||||
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
|
||||
// This tracks only that a request is in flight before the provider has
|
||||
// emitted its real session id; routing still waits for session_created.
|
||||
pendingViewSessionRef.current = { startedAt: Date.now() };
|
||||
}
|
||||
if (effectiveSessionId) {
|
||||
onSessionActive?.(effectiveSessionId);
|
||||
@@ -577,6 +648,8 @@ export function useChatComposerState({
|
||||
? 'codex-settings'
|
||||
: provider === 'gemini'
|
||||
? 'gemini-settings'
|
||||
: provider === 'opencode'
|
||||
? 'opencode-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
@@ -644,6 +717,20 @@ export function useChatComposerState({
|
||||
toolsSettings,
|
||||
},
|
||||
});
|
||||
} else if (provider === 'opencode') {
|
||||
sendMessage({
|
||||
type: 'opencode-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: opencodeModel,
|
||||
sessionSummary,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendMessage({
|
||||
type: 'claude-command',
|
||||
@@ -686,6 +773,7 @@ export function useChatComposerState({
|
||||
cursorModel,
|
||||
executeCommand,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
@@ -856,15 +944,11 @@ export function useChatComposerState({
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
||||
const cursorSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
|
||||
|
||||
const candidateSessionIds = [
|
||||
currentSessionId,
|
||||
pendingViewSessionRef.current?.sessionId || null,
|
||||
pendingSessionId,
|
||||
provider === 'cursor' ? cursorSessionId : null,
|
||||
selectedSession?.id || null,
|
||||
];
|
||||
@@ -882,7 +966,7 @@ export function useChatComposerState({
|
||||
sessionId: targetSessionId,
|
||||
provider,
|
||||
});
|
||||
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
|
||||
}, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
|
||||
|
||||
const handleGrantToolPermission = useCallback(
|
||||
(suggestion: { entry: string; toolName: string }) => {
|
||||
@@ -980,5 +1064,7 @@ export function useChatComposerState({
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
|
||||
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
|
||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type {
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
Project,
|
||||
ProviderModelsCacheInfo,
|
||||
ProviderModelsDefinition,
|
||||
} from '../../../types/app';
|
||||
|
||||
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
||||
claude: 'opus',
|
||||
cursor: 'gpt-5.3-codex',
|
||||
codex: 'gpt-5.4',
|
||||
gemini: 'gemini-3.1-pro-preview',
|
||||
opencode: 'anthropic/claude-sonnet-4-5',
|
||||
};
|
||||
|
||||
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
|
||||
if (provider === 'codex') {
|
||||
@@ -11,33 +24,242 @@ const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[]
|
||||
if (provider === 'claude') {
|
||||
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
}
|
||||
if (provider === 'opencode') {
|
||||
return ['default'];
|
||||
}
|
||||
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
};
|
||||
|
||||
interface UseChatProviderStateArgs {
|
||||
selectedSession: ProjectSession | null;
|
||||
selectedProject: Project | null;
|
||||
}
|
||||
|
||||
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
|
||||
type ProviderModelsApiResponse = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
models?: ProviderModelsDefinition;
|
||||
cache?: ProviderModelsCacheInfo;
|
||||
};
|
||||
};
|
||||
|
||||
type ChangeActiveModelApiResponse = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
provider?: LLMProvider;
|
||||
sessionId?: string;
|
||||
supported?: boolean;
|
||||
changed?: boolean;
|
||||
model?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
||||
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
||||
const [provider, setProvider] = useState<LLMProvider>(() => {
|
||||
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
||||
});
|
||||
const [cursorModel, setCursorModel] = useState<string>(() => {
|
||||
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
|
||||
return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor;
|
||||
});
|
||||
const [claudeModel, setClaudeModel] = useState<string>(() => {
|
||||
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
|
||||
return localStorage.getItem('claude-model') || FALLBACK_DEFAULT_MODEL.claude;
|
||||
});
|
||||
const [codexModel, setCodexModel] = useState<string>(() => {
|
||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
||||
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
|
||||
});
|
||||
const [geminiModel, setGeminiModel] = useState<string>(() => {
|
||||
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
|
||||
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
|
||||
});
|
||||
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
|
||||
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
||||
});
|
||||
|
||||
const [providerModelCatalog, setProviderModelCatalog] = useState<
|
||||
Partial<Record<LLMProvider, ProviderModelsDefinition>>
|
||||
>({});
|
||||
const [providerModelCacheCatalog, setProviderModelCacheCatalog] = useState<
|
||||
Partial<Record<LLMProvider, ProviderModelsCacheInfo>>
|
||||
>({});
|
||||
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
|
||||
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
|
||||
|
||||
const lastProviderRef = useRef(provider);
|
||||
const providerModelsRequestIdRef = useRef(0);
|
||||
|
||||
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
|
||||
if (targetProvider === 'claude') {
|
||||
setClaudeModel(model);
|
||||
localStorage.setItem('claude-model', model);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetProvider === 'cursor') {
|
||||
setCursorModel(model);
|
||||
localStorage.setItem('cursor-model', model);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetProvider === 'codex') {
|
||||
setCodexModel(model);
|
||||
localStorage.setItem('codex-model', model);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetProvider === 'gemini') {
|
||||
setGeminiModel(model);
|
||||
localStorage.setItem('gemini-model', model);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenCodeModel(model);
|
||||
localStorage.setItem('opencode-model', model);
|
||||
}, []);
|
||||
|
||||
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
|
||||
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
const requestId = providerModelsRequestIdRef.current + 1;
|
||||
providerModelsRequestIdRef.current = requestId;
|
||||
const isHardRefresh = options.bypassCache === true;
|
||||
|
||||
if (isHardRefresh) {
|
||||
setProviderModelsRefreshing(true);
|
||||
} else {
|
||||
setProviderModelsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
providers.map(async (p) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.bypassCache) {
|
||||
params.set('bypassCache', 'true');
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const response = await authenticatedFetch(`/api/providers/${p}/models${queryString ? `?${queryString}` : ''}`);
|
||||
const body = (await response.json()) as ProviderModelsApiResponse;
|
||||
if (!body.success || !body.data?.models || !body.data?.cache) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return body.data;
|
||||
}),
|
||||
);
|
||||
|
||||
if (providerModelsRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
|
||||
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
|
||||
|
||||
providers.forEach((p, i) => {
|
||||
const entry = results[i];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextCatalog[p] = entry.models;
|
||||
nextCacheCatalog[p] = entry.cache;
|
||||
});
|
||||
|
||||
setProviderModelCatalog(nextCatalog);
|
||||
setProviderModelCacheCatalog(nextCacheCatalog);
|
||||
} catch (error) {
|
||||
console.error('Error loading provider models:', error);
|
||||
} finally {
|
||||
if (providerModelsRequestIdRef.current === requestId) {
|
||||
setProviderModelsLoading(false);
|
||||
setProviderModelsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProviderModels();
|
||||
}, [loadProviderModels]);
|
||||
|
||||
const pickStoredOrCurrent = (
|
||||
storageKey: string,
|
||||
current: string,
|
||||
def: ProviderModelsDefinition,
|
||||
): string => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored && def.OPTIONS.some((o) => o.value === stored)) {
|
||||
return stored;
|
||||
}
|
||||
if (current && def.OPTIONS.some((o) => o.value === current)) {
|
||||
return current;
|
||||
}
|
||||
return def.DEFAULT;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const claude = providerModelCatalog.claude;
|
||||
if (claude) {
|
||||
const next = pickStoredOrCurrent('claude-model', claudeModel, claude);
|
||||
if (next !== claudeModel) {
|
||||
setClaudeModel(next);
|
||||
}
|
||||
if (localStorage.getItem('claude-model') !== next) {
|
||||
localStorage.setItem('claude-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.claude, claudeModel]);
|
||||
|
||||
useEffect(() => {
|
||||
const cursor = providerModelCatalog.cursor;
|
||||
if (cursor) {
|
||||
const next = pickStoredOrCurrent('cursor-model', cursorModel, cursor);
|
||||
if (next !== cursorModel) {
|
||||
setCursorModel(next);
|
||||
}
|
||||
if (localStorage.getItem('cursor-model') !== next) {
|
||||
localStorage.setItem('cursor-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.cursor, cursorModel]);
|
||||
|
||||
useEffect(() => {
|
||||
const codex = providerModelCatalog.codex;
|
||||
if (codex) {
|
||||
const next = pickStoredOrCurrent('codex-model', codexModel, codex);
|
||||
if (next !== codexModel) {
|
||||
setCodexModel(next);
|
||||
}
|
||||
if (localStorage.getItem('codex-model') !== next) {
|
||||
localStorage.setItem('codex-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.codex, codexModel]);
|
||||
|
||||
useEffect(() => {
|
||||
const gemini = providerModelCatalog.gemini;
|
||||
if (gemini) {
|
||||
const next = pickStoredOrCurrent('gemini-model', geminiModel, gemini);
|
||||
if (next !== geminiModel) {
|
||||
setGeminiModel(next);
|
||||
}
|
||||
if (localStorage.getItem('gemini-model') !== next) {
|
||||
localStorage.setItem('gemini-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.gemini, geminiModel]);
|
||||
|
||||
useEffect(() => {
|
||||
const opencode = providerModelCatalog.opencode;
|
||||
if (opencode) {
|
||||
const next = pickStoredOrCurrent('opencode-model', opencodeModel, opencode);
|
||||
if (next !== opencodeModel) {
|
||||
setOpenCodeModel(next);
|
||||
}
|
||||
if (localStorage.getItem('opencode-model') !== next) {
|
||||
localStorage.setItem('opencode-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.opencode, opencodeModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSession?.id) {
|
||||
@@ -107,6 +329,41 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||
}
|
||||
}, [permissionMode, provider, selectedSession?.id]);
|
||||
|
||||
const selectProviderModel = useCallback(async (
|
||||
targetProvider: LLMProvider,
|
||||
model: string,
|
||||
sessionId?: string | null,
|
||||
) => {
|
||||
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
|
||||
if (!normalizedSessionId) {
|
||||
setStoredProviderModel(targetProvider, model);
|
||||
return {
|
||||
scope: 'default' as const,
|
||||
changed: false,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`/api/providers/${targetProvider}/sessions/${encodeURIComponent(normalizedSessionId)}/active-model`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model }),
|
||||
},
|
||||
);
|
||||
|
||||
const body = (await response.json()) as ChangeActiveModelApiResponse;
|
||||
if (!response.ok || !body.success || !body.data?.supported) {
|
||||
throw new Error('Unable to change the active model for this session.');
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'session' as const,
|
||||
changed: body.data.changed === true,
|
||||
model: body.data.model || model,
|
||||
};
|
||||
}, [setStoredProviderModel]);
|
||||
|
||||
return {
|
||||
provider,
|
||||
setProvider,
|
||||
@@ -118,10 +375,18 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
permissionMode,
|
||||
setPermissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsLoading,
|
||||
providerModelsRefreshing,
|
||||
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
|
||||
selectProviderModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -63,6 +62,7 @@ interface UseChatRealtimeHandlersArgs {
|
||||
streamTimerRef: MutableRefObject<number | null>;
|
||||
accumulatedStreamRef: MutableRefObject<string>;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
||||
@@ -89,6 +89,7 @@ export function useChatRealtimeHandlers({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
@@ -104,7 +105,7 @@ export function useChatRealtimeHandlers({
|
||||
lastProcessedMessageRef.current = latestMessage;
|
||||
|
||||
const activeViewSessionId =
|
||||
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
||||
selectedSession?.id || currentSessionId || null;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Legacy messages (no `kind` field) — handle and return */
|
||||
@@ -151,10 +152,12 @@ export function useChatRealtimeHandlers({
|
||||
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
||||
|
||||
if (msg.isProcessing) {
|
||||
onSessionActive?.(statusSessionId);
|
||||
onSessionProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
||||
return;
|
||||
}
|
||||
|
||||
onSessionInactive?.(statusSessionId);
|
||||
onSessionNotProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) {
|
||||
@@ -235,15 +238,21 @@ export function useChatRealtimeHandlers({
|
||||
if (!currentSessionId) {
|
||||
console.log('Session created with ID:', newSessionId);
|
||||
console.log('Existing session ID:', currentSessionId);
|
||||
sessionStorage.setItem('pendingSessionId', newSessionId);
|
||||
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
|
||||
pendingViewSessionRef.current.sessionId = newSessionId;
|
||||
}
|
||||
setCurrentSessionId(newSessionId);
|
||||
setPendingPermissionRequests((prev) =>
|
||||
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
||||
);
|
||||
}
|
||||
pendingViewSessionRef.current = null;
|
||||
onSessionActive?.(newSessionId);
|
||||
onSessionProcessing?.(newSessionId);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
onNavigateToSession?.(newSessionId);
|
||||
break;
|
||||
}
|
||||
@@ -266,6 +275,7 @@ export function useChatRealtimeHandlers({
|
||||
setPendingPermissionRequests([]);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
|
||||
// Handle aborted case
|
||||
if (msg.aborted) {
|
||||
@@ -279,16 +289,10 @@ export function useChatRealtimeHandlers({
|
||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
||||
? msg.actualSessionId
|
||||
: null;
|
||||
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
const completedSuccessfully = msg.exitCode === undefined || msg.exitCode === 0;
|
||||
const isVisibleSession =
|
||||
Boolean(
|
||||
sid
|
||||
&& (
|
||||
sid === activeViewSessionId
|
||||
|| sid === pendingSessionId
|
||||
|| pendingViewSessionRef.current?.sessionId === sid
|
||||
),
|
||||
&& sid === activeViewSessionId,
|
||||
);
|
||||
|
||||
if (actualSessionId && sid && actualSessionId !== sid) {
|
||||
@@ -296,17 +300,6 @@ export function useChatRealtimeHandlers({
|
||||
|
||||
if (isVisibleSession) {
|
||||
setCurrentSessionId(actualSessionId);
|
||||
|
||||
if (pendingViewSessionRef.current) {
|
||||
const pendingSession = pendingViewSessionRef.current.sessionId;
|
||||
if (!pendingSession || pendingSession === sid) {
|
||||
pendingViewSessionRef.current.sessionId = actualSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (completedSuccessfully && pendingSessionId === sid) {
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
|
||||
if (isVisibleSession) {
|
||||
@@ -316,16 +309,6 @@ export function useChatRealtimeHandlers({
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear pending session
|
||||
if (pendingSessionId && !currentSessionId && completedSuccessfully) {
|
||||
const resolvedSessionId = actualSessionId || pendingSessionId;
|
||||
setCurrentSessionId(resolvedSessionId);
|
||||
if (actualSessionId) {
|
||||
onNavigateToSession?.(resolvedSessionId, { replace: true });
|
||||
}
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -335,6 +318,7 @@ export function useChatRealtimeHandlers({
|
||||
setClaudeStatus(null);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -399,6 +383,7 @@ export function useChatRealtimeHandlers({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
|
||||
@@ -13,7 +13,6 @@ const MESSAGES_PER_PAGE = 20;
|
||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -160,7 +159,7 @@ export function useChatSessionState({
|
||||
* Why this is essential:
|
||||
* - Chat keeps local state that is not fully derived from `selectedSession`:
|
||||
* `currentSessionId`, `pendingUserMessage`, streaming/status flags, message
|
||||
* pagination/scroll bookkeeping, and pending session IDs in sessionStorage.
|
||||
* pagination/scroll bookkeeping, and provider-specific sessionStorage keys.
|
||||
* - If the user clicks New Session while already on the same route with no
|
||||
* selected session, parent state updates can be idempotent and this local
|
||||
* state would otherwise persist, making the click appear to "do nothing".
|
||||
@@ -177,7 +176,6 @@ export function useChatSessionState({
|
||||
setIsLoading(false);
|
||||
setCurrentSessionId(null);
|
||||
setPendingUserMessage(null);
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
@@ -396,6 +394,12 @@ export function useChatSessionState({
|
||||
// Main session loading effect — store-based
|
||||
useEffect(() => {
|
||||
if (!selectedSession || !selectedProject) {
|
||||
// A new provider run can be in flight before the router has a canonical
|
||||
// selectedSession. Keep the processing banner alive until complete/error.
|
||||
if (pendingViewSessionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
@@ -537,10 +541,6 @@ export function useChatSessionState({
|
||||
}
|
||||
}, [selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSession?.id) pendingViewSessionRef.current = null;
|
||||
}, [pendingViewSessionRef, selectedSession?.id]);
|
||||
|
||||
// Scroll to search target
|
||||
useEffect(() => {
|
||||
if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return;
|
||||
|
||||
@@ -14,10 +14,10 @@ import { useSessionStore } from '../../../stores/useSessionStore';
|
||||
|
||||
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
|
||||
import ChatComposer from './subcomponents/ChatComposer';
|
||||
import CommandResultModal from './subcomponents/CommandResultModal';
|
||||
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -72,19 +72,26 @@ function ChatInterface({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
permissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsLoading,
|
||||
providerModelsRefreshing,
|
||||
hardRefreshProviderModels,
|
||||
selectProviderModel,
|
||||
} = useChatProviderState({
|
||||
selectedSession,
|
||||
selectedProject,
|
||||
});
|
||||
|
||||
const {
|
||||
chatMessages,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
currentSessionId,
|
||||
@@ -170,7 +177,9 @@ function ChatInterface({
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
isInputFocused: _isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
} = useChatComposerState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
@@ -182,6 +191,7 @@ function ChatInterface({
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -195,8 +205,6 @@ function ChatInterface({
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
@@ -234,6 +242,7 @@ function ChatInterface({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
@@ -280,6 +289,8 @@ function ChatInterface({
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude');
|
||||
|
||||
return (
|
||||
@@ -318,6 +329,10 @@ function ChatInterface({
|
||||
setCodexModel={setCodexModel}
|
||||
geminiModel={geminiModel}
|
||||
setGeminiModel={setGeminiModel}
|
||||
opencodeModel={opencodeModel}
|
||||
setOpenCodeModel={setOpenCodeModel}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelsLoading={providerModelsLoading}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
@@ -406,6 +421,8 @@ function ChatInterface({
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude'),
|
||||
})}
|
||||
isTextareaExpanded={isTextareaExpanded}
|
||||
@@ -414,6 +431,17 @@ function ChatInterface({
|
||||
</div>
|
||||
|
||||
<QuickSettingsPanel />
|
||||
|
||||
<CommandResultModal
|
||||
payload={commandModalPayload}
|
||||
onClose={closeCommandModal}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
||||
providerModelsRefreshing={providerModelsRefreshing}
|
||||
onHardRefreshProviderModels={hardRefreshProviderModels}
|
||||
currentSessionId={currentSessionId || selectedSession?.id || null}
|
||||
onSelectProviderModel={selectProviderModel}
|
||||
/>
|
||||
</PermissionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
ProviderModelsDefinition,
|
||||
} from '../../../../types/app';
|
||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
|
||||
@@ -26,6 +33,10 @@ interface ChatMessagesPaneProps {
|
||||
setCodexModel: (model: string) => void;
|
||||
geminiModel: string;
|
||||
setGeminiModel: (model: string) => void;
|
||||
opencodeModel: string;
|
||||
setOpenCodeModel: (model: string) => void;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelsLoading: boolean;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
@@ -71,6 +82,10 @@ export default function ChatMessagesPane({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
@@ -154,6 +169,10 @@ export default function ChatMessagesPane({
|
||||
setCodexModel={setCodexModel}
|
||||
geminiModel={geminiModel}
|
||||
setGeminiModel={setGeminiModel}
|
||||
opencodeModel={opencodeModel}
|
||||
setOpenCodeModel={setOpenCodeModel}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelsLoading={providerModelsLoading}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
|
||||
@@ -29,6 +29,7 @@ const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
codex: 'messageTypes.codex',
|
||||
cursor: 'messageTypes.cursor',
|
||||
gemini: 'messageTypes.gemini',
|
||||
opencode: 'messageTypes.opencode',
|
||||
};
|
||||
|
||||
function formatElapsedTime(totalSeconds: number) {
|
||||
@@ -126,4 +127,4 @@ export default function ClaudeStatus({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
731
src/components/chat/view/subcomponents/CommandResultModal.tsx
Normal file
731
src/components/chat/view/subcomponents/CommandResultModal.tsx
Normal file
@@ -0,0 +1,731 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
CircleHelp,
|
||||
Clipboard,
|
||||
Coins,
|
||||
Command as CommandIcon,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Package,
|
||||
Search,
|
||||
Server,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
Timer,
|
||||
RefreshCw,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui';
|
||||
import type { LLMProvider, ProviderModelsCacheInfo, ProviderModelsDefinition } from '../../../../types/app';
|
||||
import type {
|
||||
CommandModalPayload,
|
||||
CostCommandData,
|
||||
HelpCommandData,
|
||||
ModelCommandData,
|
||||
StatusCommandData,
|
||||
} from '../../hooks/useChatComposerState';
|
||||
|
||||
type CommandResultModalProps = {
|
||||
payload: CommandModalPayload | null;
|
||||
onClose: () => void;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
||||
providerModelsRefreshing: boolean;
|
||||
onHardRefreshProviderModels: () => void;
|
||||
currentSessionId: string | null;
|
||||
onSelectProviderModel: (
|
||||
provider: LLMProvider,
|
||||
model: string,
|
||||
sessionId?: string | null,
|
||||
) => Promise<{
|
||||
scope: 'default' | 'session';
|
||||
changed: boolean;
|
||||
model: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CommandEntry = {
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
type ModelOption = {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const formatUpdatedAt = (value?: string) => {
|
||||
if (!value) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
return parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
const FALLBACK_COMMANDS: CommandEntry[] = [
|
||||
{ name: '/models', description: 'Browse available models for the active provider.' },
|
||||
{ name: '/cost', description: 'Review context usage and estimated token spend.' },
|
||||
{ name: '/status', description: 'Inspect runtime, version, provider, and environment status.' },
|
||||
{ name: '/memory', description: 'Open the project CLAUDE.md memory file.' },
|
||||
{ name: '/config', description: 'Open settings and configuration.' },
|
||||
{ name: '/help', description: 'Show command documentation and syntax.' },
|
||||
];
|
||||
|
||||
const getProviderLabel = (provider: string | undefined, fallback = 'Unknown') => {
|
||||
if (!provider) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return PROVIDER_LABELS[provider] || provider;
|
||||
};
|
||||
|
||||
const clampPercentage = (value: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(100, value));
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0';
|
||||
}
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number | string | undefined) => {
|
||||
const numeric = Number(value ?? 0);
|
||||
return `$${Number.isFinite(numeric) ? numeric.toFixed(4) : '0.0000'}`;
|
||||
};
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
tone = 'neutral',
|
||||
compact = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: typeof Activity;
|
||||
tone?: 'neutral' | 'primary' | 'success';
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'primary'
|
||||
? 'border-primary/35 bg-primary/10 text-primary'
|
||||
: tone === 'success'
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
|
||||
: 'border-border/70 bg-background/75 text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group rounded-2xl border border-border/70 bg-background/75 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-md ${
|
||||
compact ? 'p-3' : 'p-4'
|
||||
}`}
|
||||
>
|
||||
<div className={`inline-flex rounded-xl border ${compact ? 'mb-2 p-1.5' : 'mb-3 p-2'} ${toneClass}`}>
|
||||
<Icon className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
|
||||
</div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
|
||||
<p className={`${compact ? 'mt-0.5 text-[13px]' : 'mt-1 text-sm'} break-all font-semibold text-foreground`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchField({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-10 rounded-xl border-border/70 bg-background/75 pl-9 pr-3 shadow-none focus-visible:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpContent({ data }: { data: HelpCommandData }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const commands = (Array.isArray(data.commands) && data.commands.length > 0
|
||||
? data.commands
|
||||
: FALLBACK_COMMANDS) as CommandEntry[];
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return commands;
|
||||
}
|
||||
|
||||
return commands.filter((command) => {
|
||||
const haystack = `${command.name} ${command.description || ''} ${command.namespace || ''}`.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
}, [commands, query]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 gap-4 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<SearchField value={query} onChange={setQuery} placeholder="Filter commands..." />
|
||||
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{filteredCommands.map((command, index) => (
|
||||
<div
|
||||
key={`${command.namespace || 'builtin'}-${command.name}`}
|
||||
className="settings-content-enter rounded-2xl border border-border/70 bg-background/75 p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/30 hover:bg-muted/25"
|
||||
style={{ animationDelay: `${Math.min(index * 18, 160)}ms` }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<code className="rounded-lg border border-primary/20 bg-primary/10 px-2 py-1 text-xs font-semibold text-primary">
|
||||
{command.name}
|
||||
</code>
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px] capitalize">
|
||||
{command.namespace || 'builtin'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-5 text-muted-foreground">
|
||||
{command.description || 'No description available.'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredCommands.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No commands match that filter.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<TerminalSquare className="h-4 w-4 text-primary" />
|
||||
Syntax
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p><code className="text-foreground">/command arg1 arg2</code></p>
|
||||
<p><code className="text-foreground">$ARGUMENTS</code> passes all args.</p>
|
||||
<p><code className="text-foreground">$1</code>, <code className="text-foreground">$2</code> pass positional args.</p>
|
||||
<p><code className="text-foreground">@file</code> includes file contents.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-primary/25 bg-primary/10 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
Quick tip
|
||||
</div>
|
||||
<p className="text-sm leading-5 text-muted-foreground">
|
||||
Type <code className="text-foreground">/</code> in the composer to open the command palette, then use arrows and Enter to run a command.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsContent({
|
||||
data,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
onSelectProviderModel,
|
||||
}: {
|
||||
data: ModelCommandData;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
||||
providerModelsRefreshing: boolean;
|
||||
onHardRefreshProviderModels: () => void;
|
||||
currentSessionId: string | null;
|
||||
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
||||
const [changingModel, setChangingModel] = useState<string | null>(null);
|
||||
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
||||
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
||||
const currentProvider = (data?.current?.provider || 'claude') as LLMProvider;
|
||||
const currentModel = data?.current?.model || 'Unknown';
|
||||
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
||||
const liveDefinition = providerModelCatalog[currentProvider];
|
||||
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
|
||||
const availableOptions = useMemo<ModelOption[]>(() => {
|
||||
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
||||
return liveDefinition.OPTIONS;
|
||||
}
|
||||
|
||||
if (Array.isArray(data?.availableOptions) && data.availableOptions.length > 0) {
|
||||
return data.availableOptions;
|
||||
}
|
||||
|
||||
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
||||
return availableModels.map((model) => ({ value: model, label: model }));
|
||||
}, [data, liveDefinition]);
|
||||
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return availableOptions;
|
||||
}
|
||||
|
||||
return availableOptions.filter((option) => {
|
||||
const haystack = `${option.value} ${option.label || ''} ${option.description || ''}`.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
}, [availableOptions, query]);
|
||||
|
||||
const activeOption = availableOptions.find((option) => option.value === currentModel);
|
||||
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
||||
|
||||
const copyModel = (model: string) => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(model).catch(() => undefined);
|
||||
}
|
||||
setCopiedModel(model);
|
||||
window.setTimeout(() => {
|
||||
setCopiedModel((current) => (current === model ? null : current));
|
||||
}, 1300);
|
||||
};
|
||||
|
||||
const handleSelectModel = async (model: string) => {
|
||||
setChangingModel(model);
|
||||
try {
|
||||
const result = await onSelectProviderModel(currentProvider, model, currentSessionId);
|
||||
if (result.scope === 'session') {
|
||||
setPendingSessionModel(result.model);
|
||||
setSelectionNotice(`Next response will resume with ${result.model}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingSessionModel(null);
|
||||
setSelectionNotice(`Default ${providerLabel} model set to ${result.model}.`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to change the model right now.';
|
||||
setSelectionNotice(message);
|
||||
} finally {
|
||||
setChangingModel(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-2.5">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
|
||||
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
{providerLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
|
||||
{availableOptions.length} models
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
|
||||
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
|
||||
{currentModel}
|
||||
</p>
|
||||
{activeOption?.label && activeOption.label !== currentModel && (
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
|
||||
)}
|
||||
{activeOption?.description && (
|
||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
|
||||
)}
|
||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Next response: {pendingSessionModel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
|
||||
Catalog Refresh
|
||||
</p>
|
||||
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
|
||||
All providers
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
|
||||
or when a new model is missing.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onHardRefreshProviderModels}
|
||||
disabled={providerModelsRefreshing}
|
||||
className="mt-2 h-8 w-full rounded-xl px-3"
|
||||
>
|
||||
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
|
||||
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
|
||||
{hasConcreteSessionId
|
||||
? 'Selecting a model stores a session override and applies it on the next response for this session.'
|
||||
: 'Selecting a model updates the default model used for new turns in this provider.'}
|
||||
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
|
||||
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||
</div>
|
||||
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
|
||||
{filteredOptions.length} shown
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{filteredOptions.map((option, index) => {
|
||||
const isCurrent = option.value === currentModel;
|
||||
const wasCopied = copiedModel === option.value;
|
||||
const isPendingSelection = option.value === pendingSessionModel;
|
||||
const isChanging = option.value === changingModel;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
|
||||
isCurrent
|
||||
? 'border-primary/45 bg-primary/10'
|
||||
: isPendingSelection
|
||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||
}`}
|
||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectModel(option.value)}
|
||||
disabled={Boolean(changingModel)}
|
||||
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Use model ${option.value}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</span>
|
||||
{option.label && option.label !== option.value && (
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
|
||||
)}
|
||||
{option.description && (
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
|
||||
{isPendingSelection && !isCurrent && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
|
||||
Next response selection
|
||||
</span>
|
||||
)}
|
||||
{isChanging && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Applying...
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyModel(option.value)}
|
||||
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
|
||||
aria-label={`Copy model id ${option.value}`}
|
||||
>
|
||||
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No models match that search.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CostContent({ data }: { data: CostCommandData }) {
|
||||
const used = Number(data.tokenUsage?.used ?? 0);
|
||||
const total = Number(data.tokenUsage?.total ?? 0);
|
||||
const percentage = clampPercentage(Number(data.tokenUsage?.percentage ?? 0));
|
||||
const model = data.model || 'Unknown';
|
||||
const provider = getProviderLabel(data.provider, data.provider || 'Unknown');
|
||||
const inputTokens = Number(data.tokenBreakdown?.input ?? 0);
|
||||
const outputTokens = Number(data.tokenBreakdown?.output ?? 0);
|
||||
const cacheTokens = Number(data.tokenBreakdown?.cache ?? 0);
|
||||
const totalCost = Number(data.cost?.total ?? 0);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[18rem_1fr]">
|
||||
<div className="rounded-3xl border border-primary/25 bg-primary/10 p-5 text-center">
|
||||
<div
|
||||
className="mx-auto grid h-40 w-40 place-items-center rounded-full p-2 shadow-inner"
|
||||
style={{
|
||||
background: `conic-gradient(hsl(var(--primary)) ${percentage * 3.6}deg, hsl(var(--muted)) 0deg)`,
|
||||
}}
|
||||
>
|
||||
<div className="grid h-full w-full place-items-center rounded-full border border-border/70 bg-popover">
|
||||
<div>
|
||||
<p className="font-mono text-3xl font-semibold text-foreground">{percentage.toFixed(1)}%</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">context</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{formatNumber(used)} of {formatNumber(total)} tokens used
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MetricCard label="Input" value={formatCurrency(data.cost?.input)} icon={Zap} />
|
||||
<MetricCard label="Output" value={formatCurrency(data.cost?.output)} icon={Activity} />
|
||||
<MetricCard label="Total" value={formatCurrency(totalCost)} icon={Coins} tone="primary" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MetricCard label="Input tokens" value={formatNumber(inputTokens)} icon={CommandIcon} />
|
||||
<MetricCard label="Output tokens" value={formatNumber(outputTokens)} icon={TerminalSquare} />
|
||||
<MetricCard label="Cache tokens" value={formatNumber(cacheTokens)} icon={Package} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
|
||||
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
Cost is an estimate based on the available token counters and default provider rates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusContent({ data }: { data: StatusCommandData }) {
|
||||
const memoryRssMb = data.memoryUsage?.rssMb;
|
||||
const rows = [
|
||||
{ label: 'Package', value: data.packageName || 'claude-code-ui', icon: Package },
|
||||
{ label: 'Version', value: data.version || 'Unknown', icon: BadgeCheck, tone: 'success' as const },
|
||||
{ label: 'Uptime', value: data.uptime || 'Unknown', icon: Timer },
|
||||
{ label: 'Provider', value: getProviderLabel(data.provider, data.provider || 'Unknown'), icon: Server, tone: 'primary' as const },
|
||||
{ label: 'Model', value: data.model || 'Unknown', icon: Cpu },
|
||||
{ label: 'Node.js', value: data.nodeVersion || 'Unknown', icon: TerminalSquare },
|
||||
{ label: 'Platform', value: data.platform || 'Unknown', icon: Activity },
|
||||
{ label: 'Memory', value: typeof memoryRssMb === 'number' ? `${memoryRssMb} MB RSS` : 'Unknown', icon: Gauge },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-3xl border border-emerald-500/25 bg-emerald-500/10 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Runtime online</p>
|
||||
<p className="text-xs text-muted-foreground">Process {data.pid ? `#${data.pid}` : 'status'} is responding.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="rounded-full bg-emerald-500 text-white hover:bg-emerald-500">Healthy</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{rows.map((row) => (
|
||||
<MetricCard key={row.label} label={row.label} value={String(row.value)} icon={row.icon} tone={row.tone} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CommandResultModal({
|
||||
payload,
|
||||
onClose,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
onSelectProviderModel,
|
||||
}: CommandResultModalProps) {
|
||||
const isOpen = Boolean(payload);
|
||||
const kind = payload?.kind;
|
||||
const isModelsModal = kind === 'models';
|
||||
|
||||
const modalMeta = {
|
||||
help: {
|
||||
eyebrow: 'Command center',
|
||||
title: 'Help & Shortcuts',
|
||||
subtitle: 'Search built-ins, syntax patterns, and command usage without leaving the chat.',
|
||||
icon: CircleHelp,
|
||||
},
|
||||
models: {
|
||||
eyebrow: 'Model inventory',
|
||||
title: 'Available Models',
|
||||
subtitle: 'Browse, search, and copy model IDs for the active provider.',
|
||||
icon: Cpu,
|
||||
},
|
||||
cost: {
|
||||
eyebrow: 'Session telemetry',
|
||||
title: 'Usage & Cost',
|
||||
subtitle: 'Token budget, context pressure, and estimated spend for this session.',
|
||||
icon: Coins,
|
||||
},
|
||||
status: {
|
||||
eyebrow: 'Runtime health',
|
||||
title: 'System Status',
|
||||
subtitle: 'Version, provider, runtime, and environment details in one place.',
|
||||
icon: Activity,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const activeMeta = kind ? modalMeta[kind] : null;
|
||||
const HeaderIcon = activeMeta?.icon || Sparkles;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex h-[min(92dvh,48rem)] w-[calc(100vw-1rem)] max-w-5xl flex-col overflow-hidden rounded-3xl border-border/80 bg-popover/95 p-0 shadow-2xl backdrop-blur-xl sm:w-[min(94vw,64rem)]">
|
||||
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
||||
|
||||
<div
|
||||
className={`relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 ${
|
||||
isModelsModal ? 'px-4 pb-3 pt-3 sm:px-5 sm:pb-4 sm:pt-4' : 'px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5'
|
||||
}`}
|
||||
>
|
||||
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" />
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-1/2 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_58%)]" />
|
||||
|
||||
<div className="relative flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3 sm:items-center">
|
||||
<div
|
||||
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
|
||||
isModelsModal ? 'p-2.5' : 'p-3'
|
||||
}`}
|
||||
>
|
||||
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-bold uppercase tracking-[0.22em] text-primary">
|
||||
{activeMeta?.eyebrow}
|
||||
</p>
|
||||
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-xl sm:text-2xl' : 'text-xl sm:text-2xl'}`}>
|
||||
{activeMeta?.title}
|
||||
</p>
|
||||
<p className={`mt-1 max-w-2xl ${isModelsModal ? 'text-sm leading-5 text-foreground/75' : 'text-sm leading-5 text-muted-foreground'}`}>
|
||||
{activeMeta?.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:bg-background/70 hover:text-foreground"
|
||||
aria-label="Close command result modal"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
||||
{payload?.kind === 'help' && <HelpContent data={payload.data as HelpCommandData} />}
|
||||
{payload?.kind === 'models' && (
|
||||
<ModelsContent
|
||||
data={payload.data as ModelCommandData}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
||||
providerModelsRefreshing={providerModelsRefreshing}
|
||||
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectProviderModel={onSelectProviderModel}
|
||||
/>
|
||||
)}
|
||||
{payload?.kind === 'cost' && <CostContent data={payload.data as CostCommandData} />}
|
||||
{payload?.kind === 'status' && <StatusContent data={payload.data as StatusCommandData} />}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-3 border-t border-border/70 bg-muted/20 px-4 py-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-3.5 w-3.5" />
|
||||
<span>Esc closes the modal.</span>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose} className="rounded-xl">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -176,7 +176,19 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
|
||||
{message.type === 'error'
|
||||
? t('messageTypes.error')
|
||||
: message.type === 'tool'
|
||||
? t('messageTypes.tool')
|
||||
: (provider === 'cursor'
|
||||
? t('messageTypes.cursor')
|
||||
: provider === 'codex'
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude'))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,15 +3,12 @@ import { Check, ChevronDown } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
|
||||
import type {
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
ProviderModelsDefinition,
|
||||
} from "../../../../types/app";
|
||||
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
CURSOR_MODELS,
|
||||
CODEX_MODELS,
|
||||
GEMINI_MODELS,
|
||||
PROVIDERS,
|
||||
} from "../../../../../shared/modelConstants";
|
||||
import type { ProjectSession, LLMProvider } from "../../../../types/app";
|
||||
import { NextTaskBanner } from "../../../task-master";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -27,6 +24,14 @@ import {
|
||||
Card,
|
||||
} from "../../../../shared/view/ui";
|
||||
|
||||
const PROVIDER_META: { id: LLMProvider; name: string }[] = [
|
||||
{ id: "claude", name: "Anthropic" },
|
||||
{ id: "codex", name: "OpenAI" },
|
||||
{ id: "gemini", name: "Google" },
|
||||
{ id: "cursor", name: "Cursor" },
|
||||
{ id: "opencode", name: "OpenCode" },
|
||||
];
|
||||
|
||||
const MOD_KEY =
|
||||
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
|
||||
|
||||
@@ -44,6 +49,10 @@ type ProviderSelectionEmptyStateProps = {
|
||||
setCodexModel: (model: string) => void;
|
||||
geminiModel: string;
|
||||
setGeminiModel: (model: string) => void;
|
||||
opencodeModel: string;
|
||||
setOpenCodeModel: (model: string) => void;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelsLoading: boolean;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
@@ -53,20 +62,15 @@ type ProviderSelectionEmptyStateProps = {
|
||||
type ProviderGroup = {
|
||||
id: LLMProvider;
|
||||
name: string;
|
||||
models: { value: string; label: string }[];
|
||||
models: { value: string; label: string; description?: string }[];
|
||||
};
|
||||
|
||||
const PROVIDER_GROUPS: ProviderGroup[] = PROVIDERS.map((p) => ({
|
||||
id: p.id as LLMProvider,
|
||||
name: p.name,
|
||||
models: p.models.OPTIONS,
|
||||
}));
|
||||
|
||||
function getModelConfig(p: LLMProvider) {
|
||||
if (p === "claude") return CLAUDE_MODELS;
|
||||
if (p === "codex") return CODEX_MODELS;
|
||||
if (p === "gemini") return GEMINI_MODELS;
|
||||
return CURSOR_MODELS;
|
||||
function getModelConfig(
|
||||
p: LLMProvider,
|
||||
catalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>,
|
||||
): ProviderModelsDefinition {
|
||||
const entry = catalog[p];
|
||||
return entry ?? { OPTIONS: [], DEFAULT: "" };
|
||||
}
|
||||
|
||||
function getCurrentModel(
|
||||
@@ -75,10 +79,12 @@ function getCurrentModel(
|
||||
cu: string,
|
||||
co: string,
|
||||
g: string,
|
||||
o: string,
|
||||
) {
|
||||
if (p === "claude") return c;
|
||||
if (p === "codex") return co;
|
||||
if (p === "gemini") return g;
|
||||
if (p === "opencode") return o;
|
||||
return cu;
|
||||
}
|
||||
|
||||
@@ -86,6 +92,7 @@ function getProviderDisplayName(p: LLMProvider) {
|
||||
if (p === "claude") return "Claude";
|
||||
if (p === "cursor") return "Cursor";
|
||||
if (p === "codex") return "Codex";
|
||||
if (p === "opencode") return "OpenCode";
|
||||
return "Gemini";
|
||||
}
|
||||
|
||||
@@ -103,6 +110,10 @@ export default function ProviderSelectionEmptyState({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
@@ -112,10 +123,14 @@ export default function ProviderSelectionEmptyState({
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const visibleProviderGroups = useMemo(
|
||||
() => (isWindowsServer ? PROVIDER_GROUPS.filter((p) => p.id !== "cursor") : PROVIDER_GROUPS),
|
||||
[isWindowsServer],
|
||||
);
|
||||
const visibleProviderGroups = useMemo(() => {
|
||||
const groups: ProviderGroup[] = PROVIDER_META.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
models: providerModelCatalog[p.id]?.OPTIONS ?? [],
|
||||
}));
|
||||
return isWindowsServer ? groups.filter((p) => p.id !== "cursor") : groups;
|
||||
}, [isWindowsServer, providerModelCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWindowsServer && provider === "cursor") {
|
||||
@@ -134,15 +149,16 @@ export default function ProviderSelectionEmptyState({
|
||||
cursorModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
);
|
||||
|
||||
const currentModelLabel = useMemo(() => {
|
||||
const config = getModelConfig(provider);
|
||||
const config = getModelConfig(provider, providerModelCatalog);
|
||||
const found = config.OPTIONS.find(
|
||||
(o: { value: string; label: string }) => o.value === currentModel,
|
||||
);
|
||||
return found?.label || currentModel;
|
||||
}, [provider, currentModel]);
|
||||
}, [provider, currentModel, providerModelCatalog]);
|
||||
|
||||
const setModelForProvider = useCallback(
|
||||
(providerId: LLMProvider, modelValue: string) => {
|
||||
@@ -155,12 +171,15 @@ export default function ProviderSelectionEmptyState({
|
||||
} else if (providerId === "gemini") {
|
||||
setGeminiModel(modelValue);
|
||||
localStorage.setItem("gemini-model", modelValue);
|
||||
} else if (providerId === "opencode") {
|
||||
setOpenCodeModel(modelValue);
|
||||
localStorage.setItem("opencode-model", modelValue);
|
||||
} else {
|
||||
setCursorModel(modelValue);
|
||||
localStorage.setItem("cursor-model", modelValue);
|
||||
}
|
||||
},
|
||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel],
|
||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
@@ -222,6 +241,9 @@ export default function ProviderSelectionEmptyState({
|
||||
|
||||
<DialogContent className="max-w-md overflow-hidden p-0">
|
||||
<DialogTitle>Model Selector</DialogTitle>
|
||||
<div className="border-b border-border/60 bg-muted/20 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-foreground">Choose a model</p>
|
||||
</div>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("providerSelection.searchModels", {
|
||||
@@ -249,16 +271,28 @@ export default function ProviderSelectionEmptyState({
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{group.models.length === 0 && providerModelsLoading ? (
|
||||
<CommandItem disabled className="ml-4 border-l border-border/40 pl-4 text-muted-foreground">
|
||||
{t("providerSelection.loadingModels", { defaultValue: "Loading models…" })}
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{group.models.map((model) => {
|
||||
const isSelected = provider === group.id && currentModel === model.value;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${group.id}-${model.value}`}
|
||||
value={`${group.name} ${model.label}`}
|
||||
value={`${group.name} ${model.label} ${model.description || ''}`}
|
||||
onSelect={() => handleModelSelect(group.id, model.value)}
|
||||
className="ml-4 border-l border-border/40 pl-4"
|
||||
>
|
||||
<span className="flex-1 truncate">{model.label}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{model.label}</div>
|
||||
{model.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
@@ -287,6 +321,10 @@ export default function ProviderSelectionEmptyState({
|
||||
gemini: t("providerSelection.readyPrompt.gemini", {
|
||||
model: geminiModel,
|
||||
}),
|
||||
opencode: t("providerSelection.readyPrompt.opencode", {
|
||||
model: opencodeModel,
|
||||
defaultValue: "Ready with OpenCode {{model}}",
|
||||
}),
|
||||
}[provider]
|
||||
}
|
||||
</p>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SessionsResponse {
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
}
|
||||
|
||||
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
|
||||
@@ -33,6 +34,7 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
|
||||
...(data.cursorSessions ?? []),
|
||||
...(data.codexSessions ?? []),
|
||||
...(data.geminiSessions ?? []),
|
||||
...(data.opencodeSessions ?? []),
|
||||
];
|
||||
return all.map<SessionResult>((s) => ({
|
||||
id: s.id,
|
||||
|
||||
25
src/components/llm-logo-provider/OpenCodeLogo.tsx
Normal file
25
src/components/llm-logo-provider/OpenCodeLogo.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
type OpenCodeLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const OpenCodeLogo = ({ className = 'w-5 h-5' }: OpenCodeLogoProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="OpenCode"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="2.5" y="2.5" width="19" height="19" rx="4" className="fill-foreground" />
|
||||
<path
|
||||
d="M8.1 8.1 4.9 12l3.2 3.9M15.9 8.1l3.2 3.9-3.2 3.9M13.2 6.9l-2.4 10.2"
|
||||
className="stroke-background"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default OpenCodeLogo;
|
||||
@@ -3,6 +3,7 @@ import ClaudeLogo from './ClaudeLogo';
|
||||
import CodexLogo from './CodexLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
import GeminiLogo from './GeminiLogo';
|
||||
import OpenCodeLogo from './OpenCodeLogo';
|
||||
|
||||
type SessionProviderLogoProps = {
|
||||
provider?: LLMProvider | string | null;
|
||||
@@ -25,5 +26,9 @@ export default function SessionProviderLogo({
|
||||
return <GeminiLogo className={className} />;
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
return <OpenCodeLogo className={className} />;
|
||||
}
|
||||
|
||||
return <ClaudeLogo className={className} />;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
||||
@@ -12,6 +13,7 @@ export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
||||
cursor: ['user', 'project'],
|
||||
codex: ['user', 'project'],
|
||||
gemini: ['user', 'project'],
|
||||
opencode: ['user', 'project'],
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
|
||||
@@ -19,6 +21,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
|
||||
cursor: ['stdio', 'http'],
|
||||
codex: ['stdio', 'http'],
|
||||
gemini: ['stdio', 'http', 'sse'],
|
||||
opencode: ['stdio', 'http'],
|
||||
};
|
||||
|
||||
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
|
||||
@@ -30,6 +33,7 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
||||
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
|
||||
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||
@@ -37,6 +41,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||
cursor: false,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_FORM: McpFormState = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LLMProvider } from '../../../../types/app';
|
||||
import type { ProviderAuthStatusMap } from '../../../provider-auth/types';
|
||||
|
||||
import AgentConnectionCard from './AgentConnectionCard';
|
||||
|
||||
type AgentConnectionsStepProps = {
|
||||
@@ -36,6 +37,13 @@ const providerCards = [
|
||||
iconContainerClassName: 'bg-teal-100 dark:bg-teal-900/30',
|
||||
loginButtonClassName: 'bg-teal-600 hover:bg-teal-700',
|
||||
},
|
||||
{
|
||||
provider: 'opencode' as const,
|
||||
title: 'OpenCode',
|
||||
connectedClassName: 'bg-zinc-100 dark:bg-zinc-800/50 border-zinc-300 dark:border-zinc-600',
|
||||
iconContainerClassName: 'bg-zinc-100 dark:bg-zinc-800',
|
||||
loginButtonClassName: 'bg-zinc-800 hover:bg-zinc-900 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AgentConnectionsStep({
|
||||
|
||||
@@ -10,13 +10,14 @@ export type ProviderAuthStatus = {
|
||||
|
||||
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
|
||||
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
|
||||
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
||||
claude: '/api/providers/claude/auth/status',
|
||||
cursor: '/api/providers/cursor/auth/status',
|
||||
codex: '/api/providers/codex/auth/status',
|
||||
gemini: '/api/providers/gemini/auth/status',
|
||||
opencode: '/api/providers/opencode/auth/status',
|
||||
};
|
||||
|
||||
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
|
||||
@@ -24,4 +25,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth
|
||||
cursor: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
codex: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
gemini: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
opencode: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
});
|
||||
|
||||
@@ -37,6 +37,10 @@ const getProviderCommand = ({
|
||||
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
return 'opencode auth login';
|
||||
}
|
||||
|
||||
return 'gemini status';
|
||||
};
|
||||
|
||||
@@ -44,6 +48,7 @@ const getProviderTitle = (provider: LLMProvider) => {
|
||||
if (provider === 'claude') return 'Claude CLI Login';
|
||||
if (provider === 'cursor') return 'Cursor CLI Login';
|
||||
if (provider === 'codex') return 'Codex CLI Login';
|
||||
if (provider === 'opencode') return 'OpenCode CLI Login';
|
||||
return 'Gemini CLI Configuration';
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
|
||||
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
|
||||
];
|
||||
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
|
||||
|
||||
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
||||
|
||||
@@ -12,7 +12,7 @@ type AgentListItemProps = {
|
||||
|
||||
type AgentConfig = {
|
||||
name: string;
|
||||
color: 'blue' | 'purple' | 'gray' | 'indigo';
|
||||
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
|
||||
};
|
||||
|
||||
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
@@ -31,7 +31,11 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
gemini: {
|
||||
name: 'Gemini',
|
||||
color: 'indigo',
|
||||
}
|
||||
},
|
||||
opencode: {
|
||||
name: 'OpenCode',
|
||||
color: 'zinc',
|
||||
},
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
@@ -47,6 +51,9 @@ const colorClasses = {
|
||||
indigo: {
|
||||
dot: 'bg-indigo-500',
|
||||
},
|
||||
zinc: {
|
||||
dot: 'bg-zinc-500',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function AgentListItem({
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AgentsSettingsTab({
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
|
||||
const visibleAgents = useMemo<AgentProvider[]>(() => {
|
||||
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
if (isWindowsServer) {
|
||||
return all.filter((id) => id !== 'cursor');
|
||||
}
|
||||
@@ -57,12 +57,17 @@ export default function AgentsSettingsTab({
|
||||
authStatus: providerAuthStatus.gemini,
|
||||
onLogin: () => onProviderLogin('gemini'),
|
||||
},
|
||||
opencode: {
|
||||
authStatus: providerAuthStatus.opencode,
|
||||
onLogin: () => onProviderLogin('opencode'),
|
||||
},
|
||||
}), [
|
||||
onProviderLogin,
|
||||
providerAuthStatus.claude,
|
||||
providerAuthStatus.codex,
|
||||
providerAuthStatus.cursor,
|
||||
providerAuthStatus.gemini,
|
||||
providerAuthStatus.opencode,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
export default function AgentSelectorSection({
|
||||
@@ -23,7 +24,8 @@ export default function AgentSelectorSection({
|
||||
const dotColor =
|
||||
agent === 'claude' ? 'bg-blue-500' :
|
||||
agent === 'cursor' ? 'bg-purple-500' :
|
||||
agent === 'gemini' ? 'bg-indigo-500' : 'bg-foreground/60';
|
||||
agent === 'gemini' ? 'bg-indigo-500' :
|
||||
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
|
||||
|
||||
return (
|
||||
<Pill
|
||||
|
||||
@@ -54,6 +54,15 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
||||
subtextClass: 'text-indigo-700 dark:text-indigo-300',
|
||||
buttonClass: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800',
|
||||
},
|
||||
opencode: {
|
||||
name: 'OpenCode',
|
||||
description: 'OpenCode CLI assistant',
|
||||
bgClass: 'bg-zinc-50 dark:bg-zinc-900/20',
|
||||
borderClass: 'border-zinc-200 dark:border-zinc-700',
|
||||
textClass: 'text-zinc-900 dark:text-zinc-100',
|
||||
subtextClass: 'text-zinc-700 dark:text-zinc-300',
|
||||
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
|
||||
@@ -66,7 +75,11 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
|
||||
<SessionProviderLogo provider={agent} className="h-6 w-6" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground">{config.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t(`agents.account.${agent}.description`)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`agents.account.${agent}.description`, {
|
||||
defaultValue: config.description || `${config.name} CLI assistant`,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export type SessionViewModel = {
|
||||
isCursorSession: boolean;
|
||||
isCodexSession: boolean;
|
||||
isGeminiSession: boolean;
|
||||
isOpenCodeSession: boolean;
|
||||
isActive: boolean;
|
||||
sessionName: string;
|
||||
sessionTime: string;
|
||||
|
||||
@@ -85,6 +85,7 @@ export const createSessionViewModel = (
|
||||
isCursorSession: session.__provider === 'cursor',
|
||||
isCodexSession: session.__provider === 'codex',
|
||||
isGeminiSession: session.__provider === 'gemini',
|
||||
isOpenCodeSession: session.__provider === 'opencode',
|
||||
isActive: diffInMinutes < 10,
|
||||
sessionName: getSessionName(session, t),
|
||||
sessionTime: getSessionTime(session),
|
||||
@@ -113,7 +114,12 @@ export const getAllSessions = (project: Project): SessionWithProvider[] => {
|
||||
__provider: 'gemini' as const,
|
||||
}));
|
||||
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions].sort(
|
||||
const opencodeSessions = (project.opencodeSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'opencode' as const,
|
||||
}));
|
||||
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort(
|
||||
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,8 @@ const projectsHaveChanges = (
|
||||
return (
|
||||
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
|
||||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
|
||||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions)
|
||||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) ||
|
||||
serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions)
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -98,6 +99,7 @@ const getProjectSessions = (project: Project): ProjectSession[] => {
|
||||
...(project.codexSessions ?? []),
|
||||
...(project.cursorSessions ?? []),
|
||||
...(project.geminiSessions ?? []),
|
||||
...(project.opencodeSessions ?? []),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -145,6 +147,7 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
|
||||
cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []),
|
||||
codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []),
|
||||
geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []),
|
||||
opencodeSessions: mergeSessionProviderLists(incomingProject.opencodeSessions ?? [], previousProject.opencodeSessions ?? []),
|
||||
};
|
||||
|
||||
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
|
||||
@@ -160,7 +163,7 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
|
||||
|
||||
const mergeProjectSessionPage = (
|
||||
existingProject: Project,
|
||||
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>,
|
||||
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>,
|
||||
): Project => {
|
||||
const mergedProject: Project = {
|
||||
...existingProject,
|
||||
@@ -168,6 +171,7 @@ const mergeProjectSessionPage = (
|
||||
cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []),
|
||||
codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []),
|
||||
geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []),
|
||||
opencodeSessions: mergeSessionProviderLists(existingProject.opencodeSessions ?? [], sessionsPage.opencodeSessions ?? []),
|
||||
};
|
||||
|
||||
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
|
||||
@@ -555,6 +559,21 @@ export function useProjectsState({
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const opencodeSession = project.opencodeSessions?.find((session) => session.id === sessionId);
|
||||
if (opencodeSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'opencode';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...opencodeSession, __provider: 'opencode' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Session id is in the URL but not yet present on any project payload (common
|
||||
@@ -583,6 +602,8 @@ export function useProjectsState({
|
||||
? 'codex'
|
||||
: providerFromStorage === 'gemini'
|
||||
? 'gemini'
|
||||
: providerFromStorage === 'opencode'
|
||||
? 'opencode'
|
||||
: 'claude';
|
||||
|
||||
setSelectedSession({
|
||||
@@ -665,12 +686,14 @@ export function useProjectsState({
|
||||
const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const opencodeSessions = project.opencodeSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
|
||||
const removedFromProject = (
|
||||
sessions.length !== (project.sessions?.length ?? 0)
|
||||
|| cursorSessions.length !== (project.cursorSessions?.length ?? 0)
|
||||
|| codexSessions.length !== (project.codexSessions?.length ?? 0)
|
||||
|| geminiSessions.length !== (project.geminiSessions?.length ?? 0)
|
||||
|| opencodeSessions.length !== (project.opencodeSessions?.length ?? 0)
|
||||
);
|
||||
|
||||
if (!removedFromProject) {
|
||||
@@ -683,6 +706,7 @@ export function useProjectsState({
|
||||
cursorSessions,
|
||||
codexSessions,
|
||||
geminiSessions,
|
||||
opencodeSessions,
|
||||
};
|
||||
|
||||
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
|
||||
@@ -776,7 +800,7 @@ export function useProjectsState({
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>;
|
||||
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>;
|
||||
|
||||
let mergedProjectForSelection: Project | null = null;
|
||||
setProjects((previousProjects) =>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"claude": "Claude",
|
||||
"cursor": "Cursor",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"tools": {
|
||||
"settings": "Tool Settings",
|
||||
@@ -189,6 +190,7 @@
|
||||
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
|
||||
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
|
||||
"gemini": "Ready to use Gemini with {{model}}. Start typing your message below.",
|
||||
"opencode": "Ready to use OpenCode with {{model}}. Start typing your message below.",
|
||||
"default": "Select a provider above to begin"
|
||||
},
|
||||
"pressToSearch": "Press <kbd>{{shortcut}}</kbd> to search sessions, files, and commits"
|
||||
|
||||
@@ -322,6 +322,9 @@
|
||||
},
|
||||
"gemini": {
|
||||
"description": "Google Gemini AI assistant"
|
||||
},
|
||||
"opencode": {
|
||||
"description": "OpenCode CLI assistant"
|
||||
}
|
||||
},
|
||||
"connectionStatus": "Connection Status",
|
||||
@@ -416,7 +419,8 @@
|
||||
"description": {
|
||||
"claude": "Model Context Protocol servers provide additional tools and data sources to Claude",
|
||||
"cursor": "Model Context Protocol servers provide additional tools and data sources to Cursor",
|
||||
"codex": "Model Context Protocol servers provide additional tools and data sources to Codex"
|
||||
"codex": "Model Context Protocol servers provide additional tools and data sources to Codex",
|
||||
"opencode": "Model Context Protocol servers provide additional tools and data sources to OpenCode"
|
||||
},
|
||||
"addButton": "Add MCP Server",
|
||||
"empty": "No MCP servers configured",
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
||||
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode';
|
||||
|
||||
export type ProviderModelOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ProviderModelsDefinition = {
|
||||
OPTIONS: ProviderModelOption[];
|
||||
DEFAULT: string;
|
||||
};
|
||||
|
||||
export type ProviderModelsCacheInfo = {
|
||||
updatedAt: string;
|
||||
expiresAt: string;
|
||||
source: 'memory' | 'disk' | 'fresh';
|
||||
};
|
||||
|
||||
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
|
||||
|
||||
@@ -46,6 +63,7 @@ export interface Project {
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
sessionMeta?: ProjectSessionMeta;
|
||||
taskmaster?: ProjectTaskmasterInfo;
|
||||
[key: string]: unknown;
|
||||
|
||||
Reference in New Issue
Block a user