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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user