diff --git a/bun.lock b/bun.lock index f174921e..862ca38c 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.36", + "@opencode-ai/sdk": "^1.1.40", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -95,7 +95,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.5.8", + "version": "1.5.9", "dependencies": { "@openchamber/ui": "workspace:*", "@tauri-apps/plugin-notification": "^2.3.3", @@ -118,7 +118,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.5.8", + "version": "1.5.9", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -148,7 +148,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.36", + "@opencode-ai/sdk": "^1.1.40", "@pierre/diffs": "^1.0.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -212,10 +212,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.5.8", + "version": "1.5.9", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.36", + "@opencode-ai/sdk": "^1.1.40", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -235,7 +235,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.5.8", + "version": "1.5.9", "bin": { "openchamber": "./bin/cli.js", }, @@ -244,7 +244,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.36", + "@opencode-ai/sdk": "^1.1.40", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -860,7 +860,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.36", "", {}, "sha512-feNHWnbxhg03TI2QrWnw3Chc0eYrWSDSmHIy/ejpSVfcKlfXREw1Tpg0L4EjrpeSc4jB1eM673dh+WM/Ko2SFQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.42", "", {}, "sha512-QuTJgnzmsQ+CF/mr9nu+FQGv2cMwGMsnWd7eOF0YLuXKOzpx+xlBkUGdZz8l0n3I+7ix8iiMZbTw4laAG8vIqA=="], "@pierre/diffs": ["@pierre/diffs@1.0.5", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-QcFhO6BW1Zz3BP+WFuH1tO2DjFJY5Sb6NmRjmEoVeEu1AVOd3HoUEFTnztxgxn+2c2ZFyFZP+6T4X/g8LDuZLw=="], diff --git a/package.json b/package.json index cbc7d76b..1c846b18 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.36", + "@opencode-ai/sdk": "^1.1.40", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/package.json b/packages/ui/package.json index a2748ad3..1e1c6d44 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.36", + "@opencode-ai/sdk": "^1.1.40", "@pierre/diffs": "^1.0.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 55551946..c0ae1e19 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -22,6 +22,8 @@ import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomp import { cn } from '@/lib/utils'; import { ServerFilePicker } from './ServerFilePicker'; import { ModelControls } from './ModelControls'; +import { StatusChip } from './StatusChip'; +import { UnifiedControlsDrawer } from './UnifiedControlsDrawer'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; @@ -31,6 +33,7 @@ import { useFileStore } from '@/stores/fileStore'; import { isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { StopIcon } from '@/components/icons/StopIcon'; +import type { MobileControlsPanel } from './mobileControlsUtils'; import { DropdownMenu, DropdownMenuContent, @@ -108,6 +111,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false); const [skillQuery, setSkillQuery] = React.useState(''); const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null); + const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false); + const [mobileControlsPanel, setMobileControlsPanel] = React.useState(null); const textareaRef = React.useRef(null); const dropZoneRef = React.useRef(null); const mentionRef = React.useRef(null); @@ -203,6 +208,54 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [isMobile]); + const handleOpenMobileControls = React.useCallback(() => { + if (!isMobile) { + return; + } + + if (mobileControlsOpen) { + setMobileControlsOpen(false); + return; + } + + setMobileControlsPanel(null); + + if (isKeyboardOpen) { + textareaRef.current?.blur(); + requestAnimationFrame(() => { + setMobileControlsOpen(true); + }); + return; + } + + setMobileControlsOpen(true); + }, [isMobile, isKeyboardOpen, mobileControlsOpen]); + + const handleCloseMobileControls = React.useCallback(() => { + setMobileControlsOpen(false); + }, []); + + const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => { + if (!isMobile) { + return; + } + setMobileControlsOpen(false); + textareaRef.current?.blur(); + requestAnimationFrame(() => { + setMobileControlsPanel(panel); + }); + }, [isMobile]); + + const handleReturnToUnifiedControls = React.useCallback(() => { + if (!isMobile) { + return; + } + setMobileControlsPanel(null); + requestAnimationFrame(() => { + setMobileControlsOpen(true); + }); + }, [isMobile]); + // Consume pending input text (e.g., from revert action) React.useEffect(() => { if (pendingInputText !== null) { @@ -974,6 +1027,13 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [currentSessionId, isMobile]); + React.useEffect(() => { + if (!isMobile) { + setMobileControlsOpen(false); + setMobileControlsPanel(null); + } + }, [isMobile]); + React.useEffect(() => { if (abortPromptSessionId && abortPromptSessionId !== currentSessionId) { clearAbortPrompt(); @@ -1462,17 +1522,30 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo data-chat-input-footer="true" > {isMobile ? ( -
-
- {attachmentsControls} -
-
-
- + <> +
+
+ {attachmentsControls} +
+
+ + {actionButton}
- {actionButton}
-
+ + handleOpenMobilePanel('agent')} + onOpenModel={() => handleOpenMobilePanel('model')} + onOpenEffort={() => handleOpenMobilePanel('variant')} + /> + ) : ( <>
diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 6e97a12e..e65ff04c 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -48,6 +48,7 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; +import type { MobileControlsPanel } from './mobileControlsUtils'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type IconComponent = ComponentType; @@ -251,9 +252,17 @@ const formatDate = (value?: string) => { interface ModelControlsProps { className?: string; + mobilePanel?: MobileControlsPanel; + onMobilePanelChange?: (panel: MobileControlsPanel) => void; + onMobilePanelSelection?: () => void; } -export const ModelControls: React.FC = ({ className }) => { +export const ModelControls: React.FC = ({ + className, + mobilePanel, + onMobilePanelChange, + onMobilePanelSelection, +}) => { const { providers, currentProviderId, @@ -318,7 +327,15 @@ export const ModelControls: React.FC = ({ className }) => { return state.getSessionAgentEditMode(sessionIdForEditMode, uiAgentName, 'ask'); }); const setSessionAgentEditMode = useContextStore((state) => state.setSessionAgentEditMode); - const { toggleFavoriteModel, isFavoriteModel, addRecentModel, isModelSelectorOpen, setModelSelectorOpen } = useUIStore(); + const { + toggleFavoriteModel, + isFavoriteModel, + addRecentModel, + addRecentAgent, + addRecentEffort, + isModelSelectorOpen, + setModelSelectorOpen, + } = useUIStore(); const { favoriteModelsList, recentModelsList } = useModelLists(); const { isMobile } = useDeviceInfo(); @@ -326,10 +343,14 @@ export const ModelControls: React.FC = ({ className }) => { const isVSCodeRuntime = useIsVSCodeRuntime(); // Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns const isCompact = isMobile; - const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | 'variant' | null>(null); + const [localMobilePanel, setLocalMobilePanel] = React.useState(null); + const usingExternalMobilePanel = mobilePanel !== undefined && typeof onMobilePanelChange === 'function'; + const activeMobilePanel = usingExternalMobilePanel ? mobilePanel : localMobilePanel; + const setActiveMobilePanel = usingExternalMobilePanel ? onMobilePanelChange : setLocalMobilePanel; const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null); const [mobileModelQuery, setMobileModelQuery] = React.useState(''); - const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), []); + const manualVariantSelectionRef = React.useRef(false); + const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), [setActiveMobilePanel]); const closeMobileTooltip = React.useCallback(() => setMobileTooltipOpen(null), []); const longPressTimerRef = React.useRef(undefined); const [expandedMobileProviders, setExpandedMobileProviders] = React.useState>(() => { @@ -868,16 +889,19 @@ export const ModelControls: React.FC = ({ className }) => { React.useEffect(() => { if (!contextHydrated || !currentAgentName) { + manualVariantSelectionRef.current = false; setCurrentVariant(undefined); return; } if (!currentProviderId || !currentModelId) { + manualVariantSelectionRef.current = false; setCurrentVariant(undefined); return; } if (availableVariants.length === 0) { + manualVariantSelectionRef.current = false; setCurrentVariant(undefined); return; } @@ -890,7 +914,7 @@ export const ModelControls: React.FC = ({ className }) => { // Draft state (no session yet): seed from settings default, but don't override // user selection while drafting. if (!currentSessionId) { - if (!currentVariant) { + if (!currentVariant && !manualVariantSelectionRef.current) { const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant) ? settingsDefaultVariant : undefined; @@ -911,6 +935,7 @@ export const ModelControls: React.FC = ({ className }) => { : undefined; setCurrentVariant(resolvedSaved); + manualVariantSelectionRef.current = false; }, [ availableVariants, contextHydrated, @@ -924,9 +949,18 @@ export const ModelControls: React.FC = ({ className }) => { settingsDefaultVariant, ]); + React.useEffect(() => { + manualVariantSelectionRef.current = false; + }, [currentProviderId, currentModelId]); + const handleVariantSelect = React.useCallback((variant: string | undefined) => { + manualVariantSelectionRef.current = true; setCurrentVariant(variant); + if (currentProviderId && currentModelId) { + addRecentEffort(currentProviderId, currentModelId, variant); + } + if (currentSessionId && currentAgentName && currentProviderId && currentModelId) { saveAgentModelVariantForSession( currentSessionId, @@ -937,6 +971,7 @@ export const ModelControls: React.FC = ({ className }) => { ); } }, [ + addRecentEffort, currentAgentName, currentModelId, currentProviderId, @@ -948,6 +983,7 @@ export const ModelControls: React.FC = ({ className }) => { const handleAgentChange = (agentName: string) => { try { setAgent(agentName); + addRecentAgent(agentName); setAgentMenuOpen(false); if (currentSessionId) { @@ -955,6 +991,11 @@ export const ModelControls: React.FC = ({ className }) => { } if (isCompact) { closeMobilePanel(); + if (onMobilePanelSelection) { + requestAnimationFrame(() => { + onMobilePanelSelection(); + }); + } } } catch (error) { console.error('[ModelControls] Handle agent change error:', error); @@ -977,12 +1018,19 @@ export const ModelControls: React.FC = ({ className }) => { setAgentMenuOpen(false); if (isCompact) { closeMobilePanel(); + if (onMobilePanelSelection) { + requestAnimationFrame(() => { + onMobilePanelSelection(); + }); + } + } + if (!isCompact || !onMobilePanelSelection) { + // Restore focus to chat input after model selection + requestAnimationFrame(() => { + const textarea = document.querySelector('textarea[data-chat-input="true"]'); + textarea?.focus(); + }); } - // Restore focus to chat input after model selection - requestAnimationFrame(() => { - const textarea = document.querySelector('textarea[data-chat-input="true"]'); - textarea?.focus(); - }); } catch (error) { console.error('[ModelControls] Handle model change error:', error); } @@ -1571,6 +1619,12 @@ export const ModelControls: React.FC = ({ className }) => { const handleSelect = (variant: string | undefined) => { handleVariantSelect(variant); closeMobilePanel(); + if (onMobilePanelSelection) { + requestAnimationFrame(() => { + onMobilePanelSelection(); + }); + return; + } requestAnimationFrame(() => { const textarea = document.querySelector('textarea[data-chat-input="true"]'); textarea?.focus(); diff --git a/packages/ui/src/components/chat/StatusChip.tsx b/packages/ui/src/components/chat/StatusChip.tsx new file mode 100644 index 00000000..ebc10836 --- /dev/null +++ b/packages/ui/src/components/chat/StatusChip.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useContextStore } from '@/stores/contextStore'; +import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; +import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils'; + +interface StatusChipProps { + onClick: () => void; + className?: string; +} + +export const StatusChip: React.FC = ({ onClick, className }) => { + const { + currentProviderId, + currentModelId, + currentVariant, + currentAgentName, + getCurrentProvider, + getCurrentModelVariants, + getVisibleAgents, + } = useConfigStore(); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const sessionAgentName = useContextStore((state) => + currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null + ); + + const agents = getVisibleAgents(); + const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName; + const agentLabel = getAgentDisplayName(agents, uiAgentName); + const currentProvider = getCurrentProvider(); + const modelLabel = getModelDisplayName(currentProvider, currentModelId); + const hasEffort = getCurrentModelVariants().length > 0; + const effortLabel = hasEffort ? formatEffortLabel(currentVariant) : null; + const segments = [agentLabel, modelLabel, effortLabel].filter((segment): segment is string => Boolean(segment)); + const label = segments.join(' ยท '); + const textRef = React.useRef(null); + const isTruncated = useIsTextTruncated(textRef, [label, currentProviderId, currentModelId, currentVariant, uiAgentName]); + + return ( + + ); +}; + +export default StatusChip; diff --git a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx new file mode 100644 index 00000000..9e8f582c --- /dev/null +++ b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx @@ -0,0 +1,368 @@ +import React from 'react'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { cn } from '@/lib/utils'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useContextStore } from '@/stores/contextStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useModelLists } from '@/hooks/useModelLists'; +import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; +import { + formatEffortLabel, + getAgentDisplayName, + getModelDisplayName, + getQuickEffortOptions, + isPrimaryMode, + parseEffortVariant, +} from './mobileControlsUtils'; + +interface UnifiedControlsDrawerProps { + open: boolean; + onClose: () => void; + onOpenAgent: () => void; + onOpenModel: () => void; + onOpenEffort: () => void; +} + +export const UnifiedControlsDrawer: React.FC = ({ + open, + onClose, + onOpenAgent, + onOpenModel, + onOpenEffort, +}) => { + const AgentRow: React.FC<{ + name: string; + isSelected: boolean; + description?: string | null; + onSelect: () => void; + }> = ({ name, isSelected, description, onSelect }) => { + const descriptionRef = React.useRef(null); + const shouldScroll = useIsTextTruncated(descriptionRef, [description, name]); + + return ( + + ); + }; + const { + providers, + currentProviderId, + currentModelId, + currentVariant, + currentAgentName, + setAgent, + setProvider, + setModel, + setCurrentVariant, + getCurrentProvider, + getCurrentModelVariants, + getVisibleAgents, + } = useConfigStore(); + const { addRecentModel, addRecentAgent, addRecentEffort, recentAgents, recentEfforts } = useUIStore(); + const { recentModelsList } = useModelLists(); + const { + currentSessionId, + saveSessionAgentSelection, + saveAgentModelForSession, + saveAgentModelVariantForSession, + } = useSessionStore(); + const sessionAgentName = useContextStore((state) => + currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null + ); + + const agents = getVisibleAgents(); + const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName; + const primaryAgents = agents.filter((agent) => isPrimaryMode(agent.mode)); + const recentAgentNames = React.useMemo(() => { + if (recentAgents.length === 0) { + return []; + } + const visibleAgents = new Set(agents.map((agent) => agent.name)); + return recentAgents.filter((name) => visibleAgents.has(name)); + }, [recentAgents, agents]); + + const quickAgentNames = React.useMemo(() => { + const fallback = primaryAgents.length > 0 ? primaryAgents.map((agent) => agent.name) : agents.map((agent) => agent.name); + const base = fallback.slice(0, 4); + const orderedRecents = recentAgentNames.slice().reverse(); + for (const recent of orderedRecents) { + if (!recent || base.includes(recent)) { + continue; + } + base.unshift(recent); + base.splice(4); + } + if (uiAgentName && !base.includes(uiAgentName)) { + if (base.length > 0) { + base[0] = uiAgentName; + } else { + base.push(uiAgentName); + } + } + return base; + }, [agents, primaryAgents, recentAgentNames, uiAgentName]); + + const hasAgentOverflow = agents.some((agent) => !quickAgentNames.includes(agent.name)); + + const currentProvider = getCurrentProvider(); + const currentModelLabel = getModelDisplayName(currentProvider, currentModelId); + const recentModels = recentModelsList.slice(0, 4); + const hasCurrentInRecents = recentModels.some( + (entry) => entry.providerID === currentProviderId && entry.modelID === currentModelId + ); + const currentModelRow = currentProviderId && currentModelId + ? { + providerID: currentProviderId, + modelID: currentModelId, + providerName: currentProvider?.name || currentProviderId, + modelName: currentModelLabel, + } + : null; + + const variants = getCurrentModelVariants(); + const hasEffort = variants.length > 0; + const effortKey = currentProviderId && currentModelId ? `${currentProviderId}/${currentModelId}` : null; + const recentEffortsForModel = effortKey ? (recentEfforts[effortKey] ?? []) : []; + const recentEffortOptions = recentEffortsForModel + .map((variant) => parseEffortVariant(variant)) + .filter((variant) => !variant || variants.includes(variant)); + const fallbackEfforts = getQuickEffortOptions(variants); + const baseEfforts = fallbackEfforts.length > 0 ? fallbackEfforts : recentEffortOptions; + const quickEfforts = React.useMemo(() => { + const base = baseEfforts.slice(0, 4); + const orderedRecents = recentEffortOptions.slice().reverse(); + for (const recent of orderedRecents) { + if (base.some((entry) => entry === recent)) { + continue; + } + base.unshift(recent); + base.splice(4); + } + if (!base.some((entry) => entry === currentVariant)) { + if (base.length > 0) { + base[0] = currentVariant; + } else { + base.push(currentVariant); + } + } + if (!base.some((entry) => entry === undefined)) { + base.push(undefined); + base.splice(4); + } + return base; + }, [baseEfforts, currentVariant, recentEffortOptions]); + const effortHasMore = variants.length + 1 > quickEfforts.length; + + const handleAgentSelect = (agentName: string) => { + setAgent(agentName); + addRecentAgent(agentName); + if (currentSessionId) { + saveSessionAgentSelection(currentSessionId, agentName); + } + }; + + const handleModelSelect = (providerId: string, modelId: string) => { + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + return; + } + const providerModels = Array.isArray(provider.models) ? provider.models : []; + const modelExists = providerModels.some((model) => model.id === modelId); + if (!modelExists) { + return; + } + + const isRecentAlready = recentModelsList.some( + (entry) => entry.providerID === providerId && entry.modelID === modelId + ); + + setProvider(providerId); + setModel(modelId); + if (!isRecentAlready) { + addRecentModel(providerId, modelId); + } + + if (currentSessionId && uiAgentName) { + saveAgentModelForSession(currentSessionId, uiAgentName, providerId, modelId); + } + }; + + const handleEffortSelect = (variant: string | undefined) => { + setCurrentVariant(variant); + if (currentProviderId && currentModelId) { + addRecentEffort(currentProviderId, currentModelId, variant); + } + if (currentSessionId && uiAgentName && currentProviderId && currentModelId) { + saveAgentModelVariantForSession(currentSessionId, uiAgentName, currentProviderId, currentModelId, variant); + } + }; + + return ( + +
+
+
+ Agent +
+ {agents.length === 0 ? ( +
+ No agents configured +
+ ) : ( +
+ {quickAgentNames.map((agentName) => { + const agent = agents.find((entry) => entry.name === agentName); + const displayName = getAgentDisplayName(agents, agentName); + return ( + handleAgentSelect(agentName)} + /> + ); + })} + {hasAgentOverflow && ( + + )} +
+ )} +
+ +
+
+ Model +
+
+ {!hasCurrentInRecents && currentModelRow && ( + + )} + + {recentModels.map(({ providerID, modelID, provider, model }) => { + const isSelected = providerID === currentProviderId && modelID === currentModelId; + const modelName = typeof model?.name === 'string' && model.name.trim().length > 0 + ? model.name + : modelID; + return ( + + ); + })} + + {recentModels.length === 0 && !currentModelRow && ( +
+ Not selected +
+ )} + + +
+
+ + {hasEffort && ( +
+
+ Effort +
+
+ {quickEfforts.map((variant) => { + const isSelected = variant === currentVariant || (!variant && !currentVariant); + return ( + + ); + })} + {effortHasMore && ( + + )} +
+
+ )} +
+
+ ); +}; + +export default UnifiedControlsDrawer; diff --git a/packages/ui/src/components/chat/mobileControlsUtils.ts b/packages/ui/src/components/chat/mobileControlsUtils.ts new file mode 100644 index 00000000..f262d4eb --- /dev/null +++ b/packages/ui/src/components/chat/mobileControlsUtils.ts @@ -0,0 +1,105 @@ +import type { Agent } from '@opencode-ai/sdk/v2'; + +export type MobileControlsPanel = 'model' | 'agent' | 'variant' | null; + +export const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null; + +export const capitalizeLabel = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); + +export const getAgentDisplayName = (agents: Agent[], agentName?: string) => { + if (agentName) { + const agent = agents.find((entry) => entry.name === agentName); + return agent ? capitalizeLabel(agent.name) : capitalizeLabel(agentName); + } + + const primaryAgents = agents.filter((agent) => isPrimaryMode(agent.mode)); + const buildAgent = primaryAgents.find((agent) => agent.name === 'build'); + const fallbackAgent = buildAgent || primaryAgents[0] || agents[0]; + return fallbackAgent ? capitalizeLabel(fallbackAgent.name) : 'Select agent'; +}; + +type ProviderModel = { id?: string; name?: string }; + +export const getModelDisplayName = ( + provider: { models?: ProviderModel[] } | undefined, + modelId: string | undefined, +) => { + if (!provider || !modelId) { + return 'Not selected'; + } + const models = Array.isArray(provider.models) ? provider.models : []; + const model = models.find((entry) => entry.id === modelId); + if (typeof model?.name === 'string' && model.name.trim().length > 0) { + return model.name; + } + if (typeof model?.id === 'string' && model.id.trim().length > 0) { + return model.id; + } + return modelId; +}; + +export const formatEffortLabel = (variant?: string) => { + if (!variant || variant.trim().length === 0) { + return 'Default'; + } + const trimmed = variant.trim(); + if (/^\d+(\.\d+)?$/.test(trimmed)) { + return trimmed; + } + return capitalizeLabel(trimmed); +}; + +export const DEFAULT_EFFORT_KEY = 'default'; + +export const serializeEffortVariant = (variant?: string) => { + const trimmed = typeof variant === 'string' ? variant.trim() : ''; + return trimmed.length > 0 ? trimmed : DEFAULT_EFFORT_KEY; +}; + +export const parseEffortVariant = (variant: string) => { + return variant === DEFAULT_EFFORT_KEY ? undefined : variant; +}; + +const EFFORT_RANKS: Record = { + max: 6, + maximum: 6, + xhigh: 5, + high: 4, + medium: 3, + default: 2, + low: 1, + min: 0, + minimal: 0, +}; + +export const getEffortRank = (variant?: string) => { + if (!variant || variant.trim().length === 0) { + return EFFORT_RANKS.default; + } + const normalized = variant.trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(EFFORT_RANKS, normalized)) { + return EFFORT_RANKS[normalized]; + } + const numeric = Number.parseFloat(normalized); + return Number.isFinite(numeric) ? numeric : 0; +}; + +export const getQuickEffortOptions = (variants: string[]) => { + const options = new Map(); + options.set('default', undefined); + for (const variant of variants) { + options.set(variant, variant); + } + + const ordered = Array.from(options.values()).sort((a, b) => getEffortRank(b) - getEffortRank(a)); + if (ordered.length <= 4) { + return ordered; + } + + const top = ordered.slice(0, 3); + const lowest = ordered[ordered.length - 1]; + if (top.some((item) => item === lowest)) { + return top; + } + return [...top, lowest]; +}; diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx index ce63297a..65acfbea 100644 --- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx @@ -76,13 +76,13 @@ export const NotificationSettings: React.FC = () => { const canShowNotifications = typeof Notification !== 'undefined' && Notification.permission === 'granted'; - const base64UrlToUint8Array = (base64Url: string): Uint8Array => { + const base64UrlToUint8Array = (base64Url: string): Uint8Array => { const padding = '='.repeat((4 - (base64Url.length % 4)) % 4); const base64 = (base64Url + padding) .replace(/-/g, '+') .replace(/_/g, '/'); const raw = atob(base64); - const output = new Uint8Array(raw.length); + const output = new Uint8Array(raw.length) as Uint8Array; for (let i = 0; i < raw.length; i += 1) { output[i] = raw.charCodeAt(i); } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 498ac9f1..af6bb1a9 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -661,7 +661,20 @@ html:not(.dark) .chat-scroll { white-space: nowrap; } +.marquee-text--auto { + animation: marquee-scroll 6s linear infinite alternate; + will-change: transform; +} + .group:hover .marquee-text--active, .marquee-text--active:hover { animation: marquee-scroll 5s linear infinite alternate; } + +@media (prefers-reduced-motion: reduce) { + .marquee-text--auto, + .group:hover .marquee-text--active, + .marquee-text--active:hover { + animation: none; + } +} diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 5dc82fae..8cebd170 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -61,6 +61,21 @@ const deriveSdkWorktreeNameFromDirectory = (directory: string): string => { return parts[parts.length - 1] ?? normalized; }; +type WorktreeRemovalParams = Record; +type WorktreeRemovalMethod = (params?: WorktreeRemovalParams) => Promise; + +const getWorktreeMethod = (client: unknown, key: string): WorktreeRemovalMethod | null => { + if (!client || (typeof client !== 'object' && typeof client !== 'function')) { + return null; + } + const record = client as Record; + const candidate = record[key]; + if (typeof candidate !== 'function') { + return null; + } + return candidate as WorktreeRemovalMethod; +}; + export const buildSdkStartCommand = (args: { projectDirectory: string; setupCommands: string[]; @@ -229,7 +244,23 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt if (worktree.source === 'sdk') { const scoped = opencodeClient.getScopedApiClient(projectDirectory); - await scoped.worktree.remove({ worktreeRemoveInput: { directory: worktree.path } }); + const worktreeClient = scoped.worktree as unknown; + const removeMethod = getWorktreeMethod(worktreeClient, 'remove'); + if (removeMethod) { + await removeMethod({ worktreeRemoveInput: { directory: worktree.path } }); + } else { + const deleteMethod = getWorktreeMethod(worktreeClient, 'delete'); + if (deleteMethod) { + await deleteMethod({ worktreeDeleteInput: { directory: worktree.path } }); + } else { + const archiveMethod = getWorktreeMethod(worktreeClient, 'archive'); + if (archiveMethod) { + await archiveMethod({ worktreeArchiveInput: { directory: worktree.path } }); + } else { + throw new Error('Worktree removal is not supported by this SDK version.'); + } + } + } // Best-effort branch cleanup. Some OpenCode builds may keep the branch. const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim(); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 4fb1459c..198aa3b1 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -56,6 +56,8 @@ interface UIStore { favoriteModels: Array<{ providerID: string; modelID: string }>; recentModels: Array<{ providerID: string; modelID: string }>; + recentAgents: string[]; + recentEfforts: Record; diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; diffFileLayout: Record; @@ -108,6 +110,8 @@ interface UIStore { toggleFavoriteModel: (providerID: string, modelID: string) => void; isFavoriteModel: (providerID: string, modelID: string) => boolean; addRecentModel: (providerID: string, modelID: string) => void; + addRecentAgent: (agentName: string) => void; + addRecentEffort: (providerID: string, modelID: string, variant: string | undefined) => void; setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void; setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void; setDiffWrapLines: (wrap: boolean) => void; @@ -162,6 +166,8 @@ export const useUIStore = create()( inputBarOffset: 0, favoriteModels: [], recentModels: [], + recentAgents: [], + recentEfforts: {}, diffLayoutPreference: 'inline', diffFileLayout: {}, diffWrapLines: false, @@ -480,6 +486,45 @@ export const useUIStore = create()( }); }, + addRecentAgent: (agentName) => { + const normalized = typeof agentName === 'string' ? agentName.trim() : ''; + if (!normalized) { + return; + } + set((state) => { + if (state.recentAgents.includes(normalized)) { + return state; + } + const filtered = state.recentAgents; + return { + recentAgents: [normalized, ...filtered].slice(0, 5), + }; + }); + }, + + addRecentEffort: (providerID, modelID, variant) => { + const provider = typeof providerID === 'string' ? providerID.trim() : ''; + const model = typeof modelID === 'string' ? modelID.trim() : ''; + if (!provider || !model) { + return; + } + const key = `${provider}/${model}`; + const normalizedVariant = typeof variant === 'string' && variant.trim().length > 0 ? variant.trim() : 'default'; + set((state) => { + const current = state.recentEfforts[key] ?? []; + if (current.includes(normalizedVariant)) { + return state; + } + const filtered = current; + return { + recentEfforts: { + ...state.recentEfforts, + [key]: [normalizedVariant, ...filtered].slice(0, 5), + }, + }; + }); + }, + updateProportionalSidebarWidths: () => { if (typeof window === 'undefined') { return; @@ -575,6 +620,8 @@ export const useUIStore = create()( cornerRadius: state.cornerRadius, favoriteModels: state.favoriteModels, recentModels: state.recentModels, + recentAgents: state.recentAgents, + recentEfforts: state.recentEfforts, diffLayoutPreference: state.diffLayoutPreference, diffWrapLines: state.diffWrapLines, diffViewMode: state.diffViewMode, diff --git a/packages/vscode/package.json b/packages/vscode/package.json index d933225c..01f1e183 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -224,7 +224,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.36", + "@opencode-ai/sdk": "^1.1.40", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index f0f107ca..38ed9843 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.36", + "@opencode-ai/sdk": "^1.1.40", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16",