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
This commit is contained in:
Nelson Pires
2026-01-30 11:13:37 +02:00
committed by GitHub
parent ab431a04a6
commit 2ed1a73d3c
14 changed files with 788 additions and 35 deletions
+9 -9
View File
@@ -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=="],
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+82 -9
View File
@@ -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<ChatInputProps> = ({ 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<MobileControlsPanel>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const dropZoneRef = React.useRef<HTMLDivElement>(null);
const mentionRef = React.useRef<FileMentionHandle>(null);
@@ -203,6 +208,54 @@ export const ChatInput: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollToBo
data-chat-input-footer="true"
>
{isMobile ? (
<div className="flex w-full items-center gap-x-1.5">
<div className="flex items-center flex-shrink-0 gap-x-1">
{attachmentsControls}
</div>
<div className="flex flex-1 items-center justify-end gap-x-1 min-w-0">
<div className="flex flex-1 min-w-0 justify-end overflow-hidden">
<ModelControls className={cn('w-full flex items-center justify-end min-w-0')} />
<>
<div className="flex w-full items-center gap-x-1.5">
<div className="flex items-center flex-shrink-0 gap-x-1">
{attachmentsControls}
</div>
<div className="flex flex-1 items-center gap-x-1 min-w-0">
<StatusChip onClick={handleOpenMobileControls} className="min-w-0" />
{actionButton}
</div>
{actionButton}
</div>
</div>
<ModelControls
className="hidden"
mobilePanel={mobileControlsPanel}
onMobilePanelChange={setMobileControlsPanel}
onMobilePanelSelection={handleReturnToUnifiedControls}
/>
<UnifiedControlsDrawer
open={mobileControlsOpen}
onClose={handleCloseMobileControls}
onOpenAgent={() => handleOpenMobilePanel('agent')}
onOpenModel={() => handleOpenMobilePanel('model')}
onOpenEffort={() => handleOpenMobilePanel('variant')}
/>
</>
) : (
<>
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
@@ -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<any>;
@@ -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<ModelControlsProps> = ({ className }) => {
export const ModelControls: React.FC<ModelControlsProps> = ({
className,
mobilePanel,
onMobilePanelChange,
onMobilePanelSelection,
}) => {
const {
providers,
currentProviderId,
@@ -318,7 +327,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ 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<ModelControlsProps> = ({ 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<MobileControlsPanel>(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<NodeJS.Timeout | undefined>(undefined);
const [expandedMobileProviders, setExpandedMobileProviders] = React.useState<Set<string>>(() => {
@@ -868,16 +889,19 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ 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<ModelControlsProps> = ({ 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<ModelControlsProps> = ({ className }) => {
: undefined;
setCurrentVariant(resolvedSaved);
manualVariantSelectionRef.current = false;
}, [
availableVariants,
contextHydrated,
@@ -924,9 +949,18 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ 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<ModelControlsProps> = ({ className }) => {
);
}
}, [
addRecentEffort,
currentAgentName,
currentModelId,
currentProviderId,
@@ -948,6 +983,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const handleAgentChange = (agentName: string) => {
try {
setAgent(agentName);
addRecentAgent(agentName);
setAgentMenuOpen(false);
if (currentSessionId) {
@@ -955,6 +991,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ 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<ModelControlsProps> = ({ 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<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
}
// Restore focus to chat input after model selection
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('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<ModelControlsProps> = ({ className }) => {
const handleSelect = (variant: string | undefined) => {
handleVariantSelect(variant);
closeMobilePanel();
if (onMobilePanelSelection) {
requestAnimationFrame(() => {
onMobilePanelSelection();
});
return;
}
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
@@ -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<StatusChipProps> = ({ 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<HTMLSpanElement>(null);
const isTruncated = useIsTextTruncated(textRef, [label, currentProviderId, currentModelId, currentVariant, uiAgentName]);
return (
<button
type="button"
onClick={onClick}
className={cn(
'group flex h-9 min-w-0 flex-1 items-center rounded-xl border border-border/40 bg-muted/30 px-2.5',
'typography-meta font-medium text-foreground transition-colors hover:bg-muted/50',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
className
)}
aria-label={label}
>
<span ref={textRef} className="min-w-0 flex-1 overflow-hidden">
<span className={cn('marquee-text', isTruncated && 'marquee-text--auto')}>
{label}
</span>
</span>
</button>
);
};
export default StatusChip;
@@ -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<UnifiedControlsDrawerProps> = ({
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<HTMLSpanElement>(null);
const shouldScroll = useIsTextTruncated(descriptionRef, [description, name]);
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex min-h-[44px] w-full items-start justify-between gap-2 rounded-xl border px-2 py-2 text-left',
isSelected ? 'border-primary/30 bg-primary/10' : 'border-border/40 hover:bg-muted/50'
)}
aria-pressed={isSelected}
>
<div className="min-w-0 flex-1">
<div className="typography-meta font-medium text-foreground truncate">{name}</div>
{description ? (
<span ref={descriptionRef} className="block min-w-0 overflow-hidden typography-micro text-muted-foreground">
<span className={cn('marquee-text', shouldScroll && 'marquee-text--auto')}>{description}</span>
</span>
) : null}
</div>
</button>
);
};
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 (
<MobileOverlayPanel open={open} onClose={onClose} title="Controls">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
Agent
</div>
{agents.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/50 px-2 py-2 typography-meta text-muted-foreground">
No agents configured
</div>
) : (
<div className="flex flex-col gap-2">
{quickAgentNames.map((agentName) => {
const agent = agents.find((entry) => entry.name === agentName);
const displayName = getAgentDisplayName(agents, agentName);
return (
<AgentRow
key={agentName}
name={displayName}
description={agent?.description}
isSelected={agentName === uiAgentName}
onSelect={() => handleAgentSelect(agentName)}
/>
);
})}
{hasAgentOverflow && (
<button
type="button"
onClick={onOpenAgent}
className="flex min-h-[44px] w-full items-center justify-center rounded-xl border border-border/40 px-2 py-2 typography-meta font-medium text-muted-foreground hover:bg-muted/50"
aria-label="More agents"
>
...
</button>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
Model
</div>
<div className="flex flex-col gap-2">
{!hasCurrentInRecents && currentModelRow && (
<button
type="button"
onClick={() => handleModelSelect(currentModelRow.providerID, currentModelRow.modelID)}
className={cn(
'flex min-h-[44px] w-full items-center justify-between gap-2 rounded-xl border px-2 py-2 text-left',
currentProviderId === currentModelRow.providerID && currentModelId === currentModelRow.modelID
? 'border-primary/30 bg-primary/10'
: 'border-border/40 hover:bg-muted/50'
)}
>
<div className="min-w-0">
<div className="typography-meta font-medium text-foreground truncate">
{currentModelRow.modelName}
</div>
<div className="typography-micro text-muted-foreground">Current model</div>
</div>
<span className="typography-micro text-muted-foreground">{currentModelRow.providerName}</span>
</button>
)}
{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 (
<button
key={`recent-${providerID}-${modelID}`}
type="button"
onClick={() => handleModelSelect(providerID, modelID)}
className={cn(
'flex min-h-[44px] w-full items-center justify-between gap-2 rounded-xl border px-2 py-2 text-left',
isSelected ? 'border-primary/30 bg-primary/10' : 'border-border/40 hover:bg-muted/50'
)}
>
<span className="typography-meta font-medium text-foreground truncate min-w-0">
{modelName}
</span>
<span className="typography-micro text-muted-foreground">
{provider?.name || providerID}
</span>
</button>
);
})}
{recentModels.length === 0 && !currentModelRow && (
<div className="rounded-xl border border-dashed border-border/50 px-2 py-2 typography-meta text-muted-foreground">
Not selected
</div>
)}
<button
type="button"
onClick={onOpenModel}
className="flex min-h-[44px] w-full items-center justify-center rounded-xl border border-border/40 px-2 py-2 typography-meta font-medium text-muted-foreground hover:bg-muted/50"
aria-label="More models"
>
...
</button>
</div>
</div>
{hasEffort && (
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
Effort
</div>
<div className="flex flex-wrap gap-2">
{quickEfforts.map((variant) => {
const isSelected = variant === currentVariant || (!variant && !currentVariant);
return (
<button
key={variant ?? 'default'}
type="button"
onClick={() => handleEffortSelect(variant)}
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-1 typography-meta font-medium',
isSelected
? 'border-primary/30 bg-primary/10 text-foreground'
: 'border-border/40 text-muted-foreground hover:bg-muted/50'
)}
aria-pressed={isSelected}
>
{formatEffortLabel(variant)}
</button>
);
})}
{effortHasMore && (
<button
type="button"
onClick={onOpenEffort}
className="inline-flex items-center rounded-full border border-border/40 px-2.5 py-1 typography-meta font-medium text-muted-foreground hover:bg-muted/50"
aria-label="More effort options"
>
...
</button>
)}
</div>
</div>
)}
</div>
</MobileOverlayPanel>
);
};
export default UnifiedControlsDrawer;
@@ -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<string, number> = {
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<string, string | undefined>();
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];
};
@@ -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<ArrayBuffer> => {
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<ArrayBuffer>;
for (let i = 0; i < raw.length; i += 1) {
output[i] = raw.charCodeAt(i);
}
+13
View File
@@ -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;
}
}
@@ -61,6 +61,21 @@ const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
return parts[parts.length - 1] ?? normalized;
};
type WorktreeRemovalParams = Record<string, unknown>;
type WorktreeRemovalMethod = (params?: WorktreeRemovalParams) => Promise<unknown>;
const getWorktreeMethod = (client: unknown, key: string): WorktreeRemovalMethod | null => {
if (!client || (typeof client !== 'object' && typeof client !== 'function')) {
return null;
}
const record = client as Record<string, unknown>;
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();
+47
View File
@@ -56,6 +56,8 @@ interface UIStore {
favoriteModels: Array<{ providerID: string; modelID: string }>;
recentModels: Array<{ providerID: string; modelID: string }>;
recentAgents: string[];
recentEfforts: Record<string, string[]>;
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
@@ -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<UIStore>()(
inputBarOffset: 0,
favoriteModels: [],
recentModels: [],
recentAgents: [],
recentEfforts: {},
diffLayoutPreference: 'inline',
diffFileLayout: {},
diffWrapLines: false,
@@ -480,6 +486,45 @@ export const useUIStore = create<UIStore>()(
});
},
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<UIStore>()(
cornerRadius: state.cornerRadius,
favoriteModels: state.favoriteModels,
recentModels: state.recentModels,
recentAgents: state.recentAgents,
recentEfforts: state.recentEfforts,
diffLayoutPreference: state.diffLayoutPreference,
diffWrapLines: state.diffWrapLines,
diffViewMode: state.diffViewMode,
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",