From 2ed1a73d3c15ba4ebc7ae28698b15312f0a250b8 Mon Sep 17 00:00:00 2001 From: Nelson Pires Date: Fri, 30 Jan 2026 06:13:37 -0300 Subject: [PATCH] Add new mobile chat controls (#247) * chore: upgrade @opencode-ai/sdk to 1.1.40 * chore(ui): bump @opencode-ai/sdk to 1.1.40 * feat(chat): add mobile controls drawer and panel switching Add mobile-only controls drawer with open/close and panel switching Introduce state and handlers for mobile controls and panels Reset mobile UI state when switching to non-mobile or returning to unified controls * feat: add mobile chat controls utilities Add utilities to compute and display the selected agent and model names in mobile chat controls. Add effort variant formatting, serialization, parsing, and ranking helpers for quick options. Expose helpers to build quick effort option lists for the UI * feat(ModelControls): allow external mobile panel control Initialize mobile panel state from external props when provided Fall back to internal mobile panel state when external control is absent Notify parent on panel changes via onMobilePanelChange callback * feat(chat): add StatusChip component Add StatusChip button that displays agent, model, and effort Show marquee animation when the label is truncated Bind to config and session stores to reflect current context * feat: add UnifiedControlsDrawer chat controls Add a side panel to switch agent, model, and effort quickly Show recent agents and models for faster reselect Persist agent/model/variant selections in session * fix(openchamber): correct base64 to Uint8Array typing * feat: add reduced-motion support for marquee animations Add marquee-text--auto to enable continuous scrolling Introduce prefers-reduced-motion media query to disable animations Apply reduced-motion rules to active marquee and hover states * feat[worktrees]: enhance sdk worktree removal with fallbacks Add dynamic resolution of remove/delete/archive methods for worktrees Fallback to delete or archive when remove is not available Throw clear error when SDK version does not support worktree removal * feat(ui): track recent agents and efforts in UI store Add recentAgents array to UI state for quick access Track up to 5 variants per provider/model in recentEfforts Expose addRecentAgent and addRecentEffort actions to update history * fix(deps): upgrade @opencode-ai/sdk to 1.1.42 Upgrade the OpenCode AI SDK to 1.1.42 across packages Refresh lockfile entries to reflect the new SDK version and integrity hash Ensure downstream packages consume the latest SDK and remain compatible * refactor: simplify agent overflow logic in UnifiedControlsDrawer --- bun.lock | 18 +- package.json | 2 +- packages/ui/package.json | 2 +- packages/ui/src/components/chat/ChatInput.tsx | 91 ++++- .../ui/src/components/chat/ModelControls.tsx | 74 +++- .../ui/src/components/chat/StatusChip.tsx | 62 +++ .../components/chat/UnifiedControlsDrawer.tsx | 368 ++++++++++++++++++ .../components/chat/mobileControlsUtils.ts | 105 +++++ .../openchamber/NotificationSettings.tsx | 4 +- packages/ui/src/index.css | 13 + .../ui/src/lib/worktrees/worktreeManager.ts | 33 +- packages/ui/src/stores/useUIStore.ts | 47 +++ packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 14 files changed, 788 insertions(+), 35 deletions(-) create mode 100644 packages/ui/src/components/chat/StatusChip.tsx create mode 100644 packages/ui/src/components/chat/UnifiedControlsDrawer.tsx create mode 100644 packages/ui/src/components/chat/mobileControlsUtils.ts 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",