From f6747486df8fd5ca86fae45828b761518b6b189f Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 27 Apr 2026 07:35:12 +0700 Subject: [PATCH] Refactor app shell and thread state modules --- src/App.vue | 2256 +++-------------- src/composables/useDesktopState.ts | 1031 +------- src/features/app-shell/constants.ts | 133 + src/features/app-shell/index.ts | 8 + src/features/app-shell/types.ts | 16 + src/features/app-shell/useAppShellAccounts.ts | 291 +++ .../app-shell/useAppShellIntegrations.ts | 132 + src/features/app-shell/useAppShellProjects.ts | 758 ++++++ src/features/app-shell/useAppShellRouting.ts | 139 + src/features/app-shell/useAppShellSettings.ts | 454 ++++ src/features/app-shell/useAppShellViewport.ts | 188 ++ src/features/thread-state/index.ts | 3 + src/features/thread-state/merge.ts | 205 ++ src/features/thread-state/messages.ts | 284 +++ src/features/thread-state/storage.ts | 440 ++++ tests.md | 75 + 16 files changed, 3503 insertions(+), 2910 deletions(-) create mode 100644 src/features/app-shell/constants.ts create mode 100644 src/features/app-shell/index.ts create mode 100644 src/features/app-shell/types.ts create mode 100644 src/features/app-shell/useAppShellAccounts.ts create mode 100644 src/features/app-shell/useAppShellIntegrations.ts create mode 100644 src/features/app-shell/useAppShellProjects.ts create mode 100644 src/features/app-shell/useAppShellRouting.ts create mode 100644 src/features/app-shell/useAppShellSettings.ts create mode 100644 src/features/app-shell/useAppShellViewport.ts create mode 100644 src/features/thread-state/index.ts create mode 100644 src/features/thread-state/merge.ts create mode 100644 src/features/thread-state/messages.ts create mode 100644 src/features/thread-state/storage.ts diff --git a/src/App.vue b/src/App.vue index 44c5dde4..d176c7da 100644 --- a/src/App.vue +++ b/src/App.vue @@ -850,193 +850,32 @@ import IconTablerTerminal from './components/icons/IconTablerTerminal.vue' import IconTablerX from './components/icons/IconTablerX.vue' import { useDesktopState } from './composables/useDesktopState' import { useMobile } from './composables/useMobile' -import { useUiLanguage } from './composables/useUiLanguage' import { checkoutGitBranch, - configureTelegramBot, - createWorktree, getGitBranchState, - getWorktreeBranchOptions, - getAccounts, - createLocalDirectory, - getFirstLaunchPluginsCardPreference, - getHomeDirectory, - getTelegramConfig, - getProjectRootSuggestion, - getTelegramStatus, getThreadTerminalStatus, - getWorkspaceRootsState, - listLocalDirectories, - openProjectRoot, persistFirstLaunchPluginsCardPreference, - removeAccount, - refreshAccountsFromAuth, - searchThreads, - switchAccount, } from './api/codexGateway' -import type { ReasoningEffort, SpeedMode, ThreadScrollState, UiAccountEntry, UiRateLimitWindow, UiServerRequest, UiServerRequestReply, UiThreadTokenUsage } from './types/codex' +import type { ReasoningEffort, SpeedMode, ThreadScrollState, UiServerRequest, UiServerRequestReply, UiThreadTokenUsage } from './types/codex' import type { ComposerDraftPayload, ThreadComposerExposed } from './components/content/ThreadComposer.vue' -import type { LocalDirectoryEntry, TelegramStatus, WorktreeBranchOption } from './api/codexGateway' -import { getFreeModeStatus, setFreeMode, setFreeModeCustomKey, setCustomProvider } from './api/codexGateway' -import { getPathLeafName, getPathParent, normalizePathForUi } from './pathUtils.js' +import { + CHAT_WIDTH_PRESETS, + useAppShellAccounts, + useAppShellIntegrations, + useAppShellProjects, + useAppShellRouting, + useAppShellSettings, + useAppShellViewport, + type DirectoryTryItemPayload, +} from './features/app-shell' +import { getPathLeafName } from './pathUtils.js' const ThreadConversation = defineAsyncComponent(() => import('./components/content/ThreadConversation.vue')) const ThreadTerminalPanel = defineAsyncComponent(() => import('./components/content/ThreadTerminalPanel.vue')) const ReviewPane = defineAsyncComponent(() => import('./components/content/ReviewPane.vue')) const DirectoryHub = defineAsyncComponent(() => import('./components/content/DirectoryHub.vue')) -const { t, uiLanguage, uiLanguageOptions, setUiLanguage } = useUiLanguage() - -const SIDEBAR_COLLAPSED_STORAGE_KEY = 'codex-web-local.sidebar-collapsed.v1' -const ACCOUNTS_SECTION_COLLAPSED_STORAGE_KEY = 'codex-web-local.accounts-section-collapsed.v1' const worktreeName = import.meta.env.VITE_WORKTREE_NAME ?? 'unknown' const appVersion = import.meta.env.VITE_APP_VERSION ?? 'unknown' -const SETTINGS_HELP = { - sendWithEnter: t('When enabled, press Enter to send. When disabled, use Command+Enter to send.'), - inProgressSendMode: t('If a turn is still running, choose whether a new prompt should steer the current turn or be queued.'), - appearance: t('Switch between system theme, light mode, and dark mode.'), - chatWidth: t('Choose how wide the conversation column and composer can grow on desktop screens.'), - dictationClickToToggle: t('Use click-to-start and click-to-stop dictation instead of hold-to-talk.'), - dictationAutoSend: t('Automatically send transcribed dictation when recording stops.'), - dictationLanguage: t('Choose transcription language or keep auto-detect.'), -} as const - -type ChatWidthMode = 'standard' | 'wide' | 'extra-wide' - -type DirectoryTryItemPayload = { - kind: 'app' | 'plugin' | 'skill' | 'composio' - name: string - displayName: string - skillPath?: string - prompt?: string - attachedSkills?: Array<{ name: string; path: string }> -} - -type ChatWidthPreset = { - label: string - columnMax: string - cardMax: string -} - -const CHAT_WIDTH_PRESETS: Record = { - standard: { - label: 'Standard', - columnMax: '45rem', - cardMax: '76ch', - }, - wide: { - label: 'Wide', - columnMax: '72rem', - cardMax: '88ch', - }, - 'extra-wide': { - label: 'Extra wide', - columnMax: '96rem', - cardMax: '96ch', - }, -} - -const WHISPER_LANGUAGES: Record = { - en: 'english', - zh: 'chinese', - de: 'german', - es: 'spanish', - ru: 'russian', - ko: 'korean', - fr: 'french', - ja: 'japanese', - pt: 'portuguese', - tr: 'turkish', - pl: 'polish', - ca: 'catalan', - nl: 'dutch', - ar: 'arabic', - sv: 'swedish', - it: 'italian', - id: 'indonesian', - hi: 'hindi', - fi: 'finnish', - vi: 'vietnamese', - he: 'hebrew', - uk: 'ukrainian', - el: 'greek', - ms: 'malay', - cs: 'czech', - ro: 'romanian', - da: 'danish', - hu: 'hungarian', - ta: 'tamil', - no: 'norwegian', - th: 'thai', - ur: 'urdu', - hr: 'croatian', - bg: 'bulgarian', - lt: 'lithuanian', - la: 'latin', - mi: 'maori', - ml: 'malayalam', - cy: 'welsh', - sk: 'slovak', - te: 'telugu', - fa: 'persian', - lv: 'latvian', - bn: 'bengali', - sr: 'serbian', - az: 'azerbaijani', - sl: 'slovenian', - kn: 'kannada', - et: 'estonian', - mk: 'macedonian', - br: 'breton', - eu: 'basque', - is: 'icelandic', - hy: 'armenian', - ne: 'nepali', - mn: 'mongolian', - bs: 'bosnian', - kk: 'kazakh', - sq: 'albanian', - sw: 'swahili', - gl: 'galician', - mr: 'marathi', - pa: 'punjabi', - si: 'sinhala', - km: 'khmer', - sn: 'shona', - yo: 'yoruba', - so: 'somali', - af: 'afrikaans', - oc: 'occitan', - ka: 'georgian', - be: 'belarusian', - tg: 'tajik', - sd: 'sindhi', - gu: 'gujarati', - am: 'amharic', - yi: 'yiddish', - lo: 'lao', - uz: 'uzbek', - fo: 'faroese', - ht: 'haitian creole', - ps: 'pashto', - tk: 'turkmen', - nn: 'nynorsk', - mt: 'maltese', - sa: 'sanskrit', - lb: 'luxembourgish', - my: 'myanmar', - bo: 'tibetan', - tl: 'tagalog', - mg: 'malagasy', - as: 'assamese', - tt: 'tatar', - haw: 'hawaiian', - ln: 'lingala', - ha: 'hausa', - ba: 'bashkir', - jw: 'javanese', - su: 'sundanese', - yue: 'cantonese', -} const { projectGroups, @@ -1101,132 +940,121 @@ const { const route = useRoute() const router = useRouter() const { isMobile } = useMobile() +const routeName = computed(() => (typeof route.name === 'string' ? route.name : undefined)) +const isHomeRoute = computed(() => routeName.value === 'home') +const isSkillsRoute = computed(() => routeName.value === 'skills') +const routeThreadId = computed(() => { + const rawThreadId = route.params.threadId + return typeof rawThreadId === 'string' ? rawThreadId : '' +}) const homeThreadComposerRef = ref(null) const threadComposerRef = ref(null) const threadConversationRef = ref<{ jumpToLatest: () => void } | null>(null) -const homeTerminalOpen = ref(false) -const isTerminalInputFocused = ref(false) -const isTerminalKeyboardFocusFallbackActive = ref(false) -const isThreadTerminalAvailable = ref(true) const editingQueuedMessageState = ref<{ threadId: string; queueIndex: number } | null>(null) -const isRouteSyncInProgress = ref(false) const directoryTryInFlightKey = ref('') -let hasPendingRouteSync = false const hasInitialized = ref(false) -const newThreadCwd = ref('') -const newThreadRuntime = ref<'local' | 'worktree'>('local') -const newWorktreeBaseBranch = ref('') -const worktreeBranchOptions = ref([]) -const isLoadingWorktreeBranches = ref(false) -const workspaceRootOptionsState = ref<{ order: string[]; labels: Record }>({ order: [], labels: {} }) -const worktreeInitStatus = ref<{ phase: 'idle' | 'running' | 'error'; title: string; message: string }>({ - phase: 'idle', - title: '', - message: '', -}) -const isSidebarCollapsed = ref(loadSidebarCollapsed()) -const sidebarSearchQuery = ref('') -const isSidebarSearchVisible = ref(false) -const sidebarSearchInputRef = ref(null) -const settingsAreaRef = ref(null) -const settingsPanelRef = ref(null) -const settingsButtonRef = ref(null) -const serverMatchedThreadIds = ref(null) -let threadSearchTimer: ReturnType | null = null -let terminalKeyboardFocusFallbackTimer: ReturnType | null = null -const defaultNewProjectName = ref('New Project (1)') -const homeDirectory = ref('') -const isSettingsOpen = ref(false) -const isAccountsSectionCollapsed = ref(loadAccountsSectionCollapsed()) const isReviewPaneOpen = ref(false) -const threadBranchOptions = ref([]) -const currentThreadBranch = ref(null) -const isLoadingThreadBranches = ref(false) -const isSwitchingThreadBranch = ref(false) -const createFolderInputRef = ref(null) -const accounts = ref([]) -const isRefreshingAccounts = ref(false) -const isSwitchingAccounts = ref(false) -const removingAccountId = ref('') -const confirmingRemoveAccountId = ref('') -const hoveredAccountId = ref('') -const accountActionError = ref('') -const SEND_WITH_ENTER_KEY = 'codex-web-local.send-with-enter.v1' -const IN_PROGRESS_SEND_MODE_KEY = 'codex-web-local.in-progress-send-mode.v1' -const DARK_MODE_KEY = 'codex-web-local.dark-mode.v1' -const DICTATION_CLICK_TO_TOGGLE_KEY = 'codex-web-local.dictation-click-to-toggle.v1' -const DICTATION_AUTO_SEND_KEY = 'codex-web-local.dictation-auto-send.v1' -const DICTATION_LANGUAGE_KEY = 'codex-web-local.dictation-language.v1' - -const CHAT_WIDTH_KEY = 'codex-web-local.chat-width.v1' -const MOBILE_RESUME_RELOAD_MIN_HIDDEN_MS = 400 -const sendWithEnter = ref(loadBoolPref(SEND_WITH_ENTER_KEY, true)) -const inProgressSendMode = ref<'steer' | 'queue'>(loadInProgressSendModePref()) -const darkMode = ref<'system' | 'light' | 'dark'>(loadDarkModePref()) -const chatWidth = ref(loadChatWidthPref()) -const dictationClickToToggle = ref(loadBoolPref(DICTATION_CLICK_TO_TOGGLE_KEY, false)) -const dictationAutoSend = ref(loadBoolPref(DICTATION_AUTO_SEND_KEY, true)) -const dictationLanguage = ref(loadDictationLanguagePref()) -const dictationLanguageOptions = computed(() => buildDictationLanguageOptions()) -const showFirstLaunchPluginsCard = ref(false) -const freeModeEnabled = ref(false) -const freeModeLoading = ref(false) -const freeModeCustomKey = ref('') -const freeModeHasCustomKey = ref(false) -const freeModeCustomKeyMasked = ref(null) -const freeModeCustomKeySaving = ref(false) -const providerError = ref('') -const selectedProvider = ref<'codex' | 'openrouter' | 'opencode-zen' | 'custom'>('codex') -const customEndpointUrl = ref('') -const customEndpointKey = ref('') -const customEndpointWireApi = ref<'responses' | 'chat'>('responses') -const openRouterWireApi = ref<'responses' | 'chat'>('responses') -const opencodeZenKey = ref('') -const isTelegramConfigOpen = ref(false) -const telegramBotTokenDraft = ref('') -const telegramAllowedUserIdsDraft = ref('') -const telegramConfigError = ref('') -const isTelegramSaving = ref(false) -const isCreateFolderOpen = ref(false) -const createFolderDraft = ref('') -const createFolderError = ref('') -const isCreatingFolder = ref(false) -const isExistingFolderPickerOpen = ref(false) -const existingFolderFilterInputRef = ref(null) -const existingFolderBrowsePath = ref('') -const existingFolderParentPath = ref('') -const existingFolderEntries = ref([]) -const existingFolderError = ref('') -const isExistingFolderLoading = ref(false) -const isOpeningExistingFolder = ref(false) -const showHiddenFolders = ref(false) -const existingFolderFilter = ref('') -const telegramStatus = ref({ - configured: false, - active: false, - mappedChats: 0, - mappedThreads: 0, - allowedUsers: 0, - allowAllUsers: false, - lastError: '', +const settings = useAppShellSettings({ + routeName, + refreshAll, + goHome: () => { + void router.push({ name: 'home' }) + }, }) -const mobileHiddenAtMs = ref(null) -const mobileResumeReloadTriggered = ref(false) -const mobileResumeSyncInProgress = ref(false) -const visualViewportHeight = ref(typeof window !== 'undefined' ? window.visualViewport?.height ?? window.innerHeight : 0) -const visualViewportOffsetTop = ref(typeof window !== 'undefined' ? window.visualViewport?.offsetTop ?? 0 : 0) -const layoutViewportHeight = ref(typeof window !== 'undefined' ? window.innerHeight : 0) -let accountStatePollTimer: number | null = null -let isAccountStatePollInFlight = false -let existingFolderBrowseRequestId = 0 - -const routeThreadId = computed(() => { - const rawThreadId = route.params.threadId - return typeof rawThreadId === 'string' ? rawThreadId : '' +const { t, uiLanguage, uiLanguageOptions, setUiLanguage } = settings +const { + SETTINGS_HELP, + isSidebarCollapsed, + sidebarSearchQuery, + isSidebarSearchVisible, + sidebarSearchInputRef, + settingsAreaRef, + settingsPanelRef, + settingsButtonRef, + isSettingsOpen, + isAccountsSectionCollapsed, + sendWithEnter, + inProgressSendMode, + darkMode, + chatWidth, + dictationClickToToggle, + dictationAutoSend, + dictationLanguage, + dictationLanguageOptions, + freeModeEnabled, + freeModeLoading, + freeModeCustomKey, + freeModeHasCustomKey, + freeModeCustomKeyMasked, + freeModeCustomKeySaving, + providerError, + selectedProvider, + customEndpointUrl, + customEndpointKey, + customEndpointWireApi, + openRouterWireApi, + opencodeZenKey, + chatWidthLabel, + setSidebarCollapsed, + toggleSidebarSearch, + clearSidebarSearch, + onSidebarSearchKeydown, + toggleSendWithEnter, + cycleInProgressSendMode, + cycleDarkMode, + cycleChatWidth, + toggleDictationClickToToggle, + toggleDictationAutoSend, + onProviderChange, + saveCustomEndpoint, + setOpenRouterWireApi, + saveOpencodeZen, + saveFreeModeCustomKey, + clearFreeModeCustomKey, + loadFreeModeStatus, + onDictationLanguageChange, + toggleAccountsSectionCollapsed, +} = settings +const isAccountSwitchBlocked = computed(() => + isSendingMessage.value || + isInterruptingTurn.value || + (!isHomeRoute.value && selectedThread.value?.inProgress === true) || + selectedThreadServerRequests.value.length > 0, +) +const accountsState = useAppShellAccounts({ + t, + isAccountSwitchBlocked, + startPolling, + stopPolling, + refreshAll: (options) => refreshAll(options), }) +const { + accounts, + isRefreshingAccounts, + isSwitchingAccounts, + removingAccountId, + confirmingRemoveAccountId, + hoveredAccountId, + accountActionError, + shortAccountId, + formatAccountMeta, + isAccountUnavailable, + isAccountActionDisabled, + isRemoveConfirmationActive, + isRemoveVisible, + getAccountSwitchLabel, + getAccountRemoveLabel, + onAccountCardPointerEnter, + onAccountCardPointerLeave, + formatAccountQuota, + buildAccountTitle, + loadAccountsState, + onRefreshAccounts, + onSwitchAccount, + onRemoveAccount, + cleanupAccountPolling, +} = accountsState -const isHomeRoute = computed(() => route.name === 'home') -const isSkillsRoute = computed(() => route.name === 'skills') const contentTitle = computed(() => { if (isSkillsRoute.value) return t('Skills') if (isHomeRoute.value) return t('Start new thread') @@ -1264,37 +1092,179 @@ const selectedThreadPendingRequest = computed(() => { const rows = selectedThreadServerRequests.value return rows.length > 0 ? rows[rows.length - 1] : null }) +const projects = useAppShellProjects({ + t, + projectGroups, + projectDisplayNameById, + selectedThread, + selectedThreadId, + routeName, + isReviewPaneOpen, + isMobile, + isSidebarCollapsed, + pinProjectToTop, + removeProject, + reorderProject, + renameProject, + goHome: () => { + void router.push({ name: 'home' }) + }, + setSidebarCollapsed, + replaceThread: async (threadId: string) => { + await router.replace({ name: 'thread', params: { threadId } }) + }, +}) +const { + newThreadCwd, + newThreadRuntime, + newWorktreeBaseBranch, + worktreeBranchOptions, + isLoadingWorktreeBranches, + workspaceRootOptionsState, + worktreeInitStatus, + defaultNewProjectName, + homeDirectory, + threadBranchOptions, + currentThreadBranch, + isLoadingThreadBranches, + isSwitchingThreadBranch, + createFolderInputRef, + isCreateFolderOpen, + createFolderDraft, + createFolderError, + isCreatingFolder, + isExistingFolderPickerOpen, + existingFolderFilterInputRef, + existingFolderBrowsePath, + existingFolderParentPath, + existingFolderEntries, + existingFolderError, + isExistingFolderLoading, + isOpeningExistingFolder, + showHiddenFolders, + existingFolderFilter, + newThreadFolderOptions, + newWorktreeBranchDropdownOptions, + selectedWorktreeBranchLabel, + contentHeaderBranchDropdownValue, + contentHeaderBranchDropdownOptions, + createFolderParentPath, + isCreateFolderNameValid, + canCreateFolder, + createFolderSubmitLabel, + canBrowseExistingFolderParent, + existingFolderDisplayEntries, + existingFolderFilteredEntries, + isWorktreePath, + resolvePreferredLocalCwd, + onStartNewThread, + onStartNewThreadFromToolbar, + onRenameProject, + onRemoveProject, + onReorderProject, + onSelectNewThreadFolder, + onSelectNewWorktreeBranch, + loadThreadBranches, + loadWorktreeBranches, + submitFirstMessageForNewThread, + onCreateProject, + onOpenExistingFolder, + onCloseExistingFolderPanel, + onBrowseExistingFolder, + onToggleHiddenFolders, + onRetryExistingFolderBrowse, + onConfirmExistingFolder, + onOpenCreateFolderPanel, + onCloseCreateFolderPanel, + onCreateFolder, + applyLaunchProjectPathFromUrl, + refreshDefaultProjectName, + getProjectBaseDirectory, + loadHomeDirectory, + loadWorkspaceRootOptionsState, +} = projects +const routing = useAppShellRouting({ + routeName, + routeThreadId, + selectedThreadId, + isLoadingThreads, + hasInitialized, + routerReady: () => router.isReady(), + shouldPrimeThread: () => true, + primeSelectedThread, + refreshAll, + loadAccountsState, + applyLaunchProjectPathFromUrl, + startPolling, + selectThread, + ensureThreadMessagesLoaded, + replaceHome: async () => { + await router.replace({ name: 'home' }) + }, + replaceThread: async (threadId: string) => { + await router.replace({ name: 'thread', params: { threadId } }) + }, +}) +const { isRouteSyncInProgress, serverMatchedThreadIds, initialize: initializeRouting, syncThreadSelectionWithRoute, bindSidebarSearch, cleanupRouting } = routing const composerCwd = computed(() => { if (isHomeRoute.value) return newThreadCwd.value.trim() return selectedThread.value?.cwd?.trim() ?? '' }) +const integrations = useAppShellIntegrations(t) +const { + showFirstLaunchPluginsCard, + isTelegramConfigOpen, + telegramBotTokenDraft, + telegramAllowedUserIdsDraft, + telegramConfigError, + isTelegramSaving, + telegramStatus, + telegramStatusText, + refreshTelegramStatus, + refreshTelegramConfig, + loadFirstLaunchPluginsCardPreference, + dismissFirstLaunchPluginsCard: dismissFirstLaunchPluginsCardInternal, + saveTelegramConfig, +} = integrations +const viewport = useAppShellViewport({ + isMobile, + isHomeRoute, + composerCwd, + selectedThreadId, + selectedThreadTerminalOpen, + setThreadTerminalOpen, + toggleSelectedThreadTerminal, + refreshAll: (options) => refreshAll(options), + syncThreadSelectionWithRoute, + loadWorkspaceRootOptionsState, + refreshDefaultProjectName, +}) +const { + homeTerminalOpen, + isTerminalInputFocused, + isTerminalKeyboardFocusFallbackActive, + isThreadTerminalAvailable, + visualViewportHeight, + visualViewportOffsetTop, + layoutViewportHeight, + isVirtualKeyboardOpen, + isComposerTerminalOpen, + isTerminalKeyboardLayoutActive, + toggleComposerTerminal, + onTerminalFocusChange, + onHideHomeTerminal, + onHideSelectedThreadTerminal, + resetTerminalKeyboardFocusState, +} = viewport const canShowTerminalToggle = computed(() => ( isThreadTerminalAvailable.value && ( (isHomeRoute.value && composerCwd.value.length > 0) || (route.name === 'thread' && selectedThreadId.value.length > 0) ) )) -const isComposerTerminalOpen = computed(() => ( - isHomeRoute.value ? homeTerminalOpen.value : selectedThreadTerminalOpen.value -)) -const isVirtualKeyboardOpen = computed(() => { - if (!isMobile.value) return false - if (visualViewportHeight.value <= 0 || layoutViewportHeight.value <= 0) return false - return layoutViewportHeight.value - visualViewportHeight.value > 120 -}) -const isTerminalKeyboardLayoutActive = computed(() => ( - isVirtualKeyboardOpen.value || - (isComposerTerminalOpen.value && isTerminalKeyboardFocusFallbackActive.value) -)) const directoryCwd = computed(() => selectedThread.value?.cwd?.trim() ?? newThreadCwd.value.trim()) const isSelectedThreadInProgress = computed(() => !isHomeRoute.value && selectedThread.value?.inProgress === true) const showThreadContextBadge = computed(() => !isHomeRoute.value && !isSkillsRoute.value && selectedThreadId.value.trim().length > 0) -const isAccountSwitchBlocked = computed(() => - isSendingMessage.value || - isInterruptingTurn.value || - isSelectedThreadInProgress.value || - selectedThreadServerRequests.value.length > 0, -) function formatCompactTokenCount(value: number): string { if (!Number.isFinite(value)) return '0' @@ -1325,9 +1295,9 @@ function buildThreadContextTooltip(usage: UiThreadTokenUsage | null): string { } function dismissFirstLaunchPluginsCard(): void { - if (!showFirstLaunchPluginsCard.value) return - showFirstLaunchPluginsCard.value = false - void persistFirstLaunchPluginsCardPreference(true) + dismissFirstLaunchPluginsCardInternal(() => { + return persistFirstLaunchPluginsCardPreference(true) + }) } function onOpenPluginsHomeCard(): void { @@ -1362,126 +1332,6 @@ const threadContextSecondaryText = computed(() => { }) const threadContextTooltip = computed(() => buildThreadContextTooltip(selectedThreadTokenUsage.value)) -const newThreadFolderOptions = computed(() => { - const options: Array<{ value: string; label: string }> = [] - const seenCwds = new Set() - - for (const cwdRaw of workspaceRootOptionsState.value.order) { - const cwd = cwdRaw.trim() - if (!cwd || seenCwds.has(cwd)) continue - seenCwds.add(cwd) - options.push({ - value: cwd, - label: workspaceRootOptionsState.value.labels[cwd] || getPathLeafName(cwd), - }) - } - - for (const group of projectGroups.value) { - const cwd = group.threads[0]?.cwd?.trim() ?? '' - if (!cwd || seenCwds.has(cwd)) continue - seenCwds.add(cwd) - options.push({ - value: cwd, - label: projectDisplayNameById.value[group.projectName] ?? group.projectName, - }) - } - - const selectedCwd = newThreadCwd.value.trim() - if (selectedCwd && !seenCwds.has(selectedCwd)) { - options.unshift({ - value: selectedCwd, - label: getPathLeafName(selectedCwd), - }) - } - - return options -}) -const newWorktreeBranchDropdownOptions = computed>(() => { - const selectedBranch = newWorktreeBaseBranch.value.trim() - const options = [...worktreeBranchOptions.value] - if (selectedBranch && !options.some((option) => option.value === selectedBranch)) { - options.unshift({ value: selectedBranch, label: selectedBranch }) - } - return options -}) -const selectedWorktreeBranchLabel = computed(() => { - const selectedBranch = newWorktreeBaseBranch.value.trim() - if (!selectedBranch) return '' - const selected = newWorktreeBranchDropdownOptions.value.find((option) => option.value === selectedBranch) - return selected?.label ?? selectedBranch -}) -const contentHeaderBranchDropdownValue = computed(() => currentThreadBranch.value ?? '__detached_head__') -const contentHeaderBranchDropdownOptions = computed>(() => { - const options: Array<{ value: string; label: string }> = [ - { - value: '__review__', - label: isReviewPaneOpen.value ? 'Review (Open)' : 'Review', - }, - ] - const seen = new Set() - const currentBranch = currentThreadBranch.value?.trim() ?? '' - if (currentBranch) { - options.push({ value: currentBranch, label: currentBranch }) - seen.add(currentBranch) - } else { - options.push({ value: '__detached_head__', label: 'Detached HEAD' }) - seen.add('__detached_head__') - } - for (const option of threadBranchOptions.value) { - if (!option.value || seen.has(option.value)) continue - seen.add(option.value) - options.push(option) - } - return options -}) -const createFolderParentPath = computed(() => existingFolderBrowsePath.value.trim()) -const isCreateFolderNameValid = computed(() => { - const draft = createFolderDraft.value.trim() - if (!draft) return false - if (draft === '.' || draft === '..') return false - return !/[\\/]/u.test(draft) -}) -const canCreateFolder = computed(() => { - return isCreateFolderNameValid.value && createFolderParentPath.value.trim().length > 0 && !existingFolderError.value -}) -const createFolderSubmitLabel = computed(() => { - if (isCreatingFolder.value) return 'Creating…' - return 'Create' -}) -const canBrowseExistingFolderParent = computed(() => { - const current = existingFolderBrowsePath.value.trim() - const parent = existingFolderParentPath.value.trim() - return Boolean(current && parent && current !== parent) -}) -const existingFolderDisplayEntries = computed(() => { - const entries: Array<{ key: string; name: string; path: string; kind: 'parent' | 'directory' }> = [] - if (canBrowseExistingFolderParent.value) { - entries.push({ - key: `parent:${existingFolderParentPath.value}`, - name: '..', - path: existingFolderParentPath.value, - kind: 'parent', - }) - } - for (const entry of existingFolderEntries.value) { - entries.push({ - key: `directory:${entry.path}`, - name: entry.name, - path: entry.path, - kind: 'directory', - }) - } - return entries -}) -const existingFolderFilteredEntries = computed(() => { - const filter = existingFolderFilter.value.trim().toLowerCase() - if (!filter) return existingFolderDisplayEntries.value - return existingFolderDisplayEntries.value.filter((entry) => - entry.kind === 'parent' || entry.name.toLowerCase().includes(filter), - ) -}) -const darkModeMediaQuery = typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null -const chatWidthLabel = computed(() => t(CHAT_WIDTH_PRESETS[chatWidth.value].label)) const terminalShortcutLabel = computed(() => { if (typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform)) { return '⌘J' @@ -1502,29 +1352,12 @@ const contentStyle = computed(() => { '--virtual-keyboard-inset': `${keyboardInset}px`, } }) -const telegramStatusText = computed(() => { - if (!telegramStatus.value.configured) return t('Not configured') - const base = telegramStatus.value.active ? t('Online') : t('Configured (offline)') - const allowlist = telegramStatus.value.allowAllUsers - ? t('allow all users') - : `${telegramStatus.value.allowedUsers} ${t('allowed user(s)')}` - const mapped = `${telegramStatus.value.mappedChats} ${t('chat(s)')}, ${telegramStatus.value.mappedThreads} ${t('thread(s)')}, ${allowlist}` - const error = telegramStatus.value.lastError ? `, ${t('error')}: ${telegramStatus.value.lastError}` : '' - return `${base}, ${mapped}${error}` -}) + +bindSidebarSearch(sidebarSearchQuery) onMounted(() => { document.addEventListener('pointerdown', onDocumentPointerDown) window.addEventListener('keydown', onWindowKeyDown) - document.addEventListener('visibilitychange', onDocumentVisibilityChange) - window.addEventListener('pageshow', onWindowPageShow) - window.addEventListener('focus', onWindowFocus) - window.addEventListener('resize', updateVisualViewportState) - window.visualViewport?.addEventListener('resize', updateVisualViewportState) - window.visualViewport?.addEventListener('scroll', updateVisualViewportState) - updateVisualViewportState() - applyDarkMode() - darkModeMediaQuery?.addEventListener('change', applyDarkMode) void initialize() void loadHomeDirectory() void loadFirstLaunchPluginsCardPreference() @@ -1539,181 +1372,15 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('pointerdown', onDocumentPointerDown) window.removeEventListener('keydown', onWindowKeyDown) - document.removeEventListener('visibilitychange', onDocumentVisibilityChange) - window.removeEventListener('pageshow', onWindowPageShow) - window.removeEventListener('focus', onWindowFocus) - window.removeEventListener('resize', updateVisualViewportState) - window.visualViewport?.removeEventListener('resize', updateVisualViewportState) - window.visualViewport?.removeEventListener('scroll', updateVisualViewportState) - darkModeMediaQuery?.removeEventListener('change', applyDarkMode) - if (accountStatePollTimer !== null) { - window.clearInterval(accountStatePollTimer) - accountStatePollTimer = null - } - if (threadSearchTimer) { - clearTimeout(threadSearchTimer) - threadSearchTimer = null - } - clearTerminalKeyboardFocusFallbackTimer() + cleanupAccountPolling() + cleanupRouting() stopPolling() }) -function updateVisualViewportState(): void { - if (typeof window === 'undefined') return - layoutViewportHeight.value = Math.max(layoutViewportHeight.value, window.innerHeight) - visualViewportHeight.value = window.visualViewport?.height ?? window.innerHeight - visualViewportOffsetTop.value = window.visualViewport?.offsetTop ?? 0 -} - -watch(sidebarSearchQuery, (value) => { - const query = value.trim() - if (threadSearchTimer) { - clearTimeout(threadSearchTimer) - threadSearchTimer = null - } - if (!query) { - serverMatchedThreadIds.value = null - return - } - - threadSearchTimer = setTimeout(() => { - void searchThreads(query, 1000) - .then((result) => { - if (sidebarSearchQuery.value.trim() !== query) return - serverMatchedThreadIds.value = result.threadIds - }) - .catch(() => { - if (sidebarSearchQuery.value.trim() !== query) return - serverMatchedThreadIds.value = null - }) - }, 220) -}) - -watch(isVirtualKeyboardOpen, (open) => { - if (open) return - isTerminalKeyboardFocusFallbackActive.value = false -}) - -watch(accounts, () => { - if (typeof window === 'undefined') return - const shouldPoll = accounts.value.some((account) => account.quotaStatus === 'loading') - if (!shouldPoll) { - if (accountStatePollTimer !== null) { - window.clearInterval(accountStatePollTimer) - accountStatePollTimer = null - } - return - } - if (accountStatePollTimer !== null) return - accountStatePollTimer = window.setInterval(() => { - if (isAccountStatePollInFlight) return - isAccountStatePollInFlight = true - void loadAccountsState({ silent: true }).finally(() => { - isAccountStatePollInFlight = false - }) - }, 1500) -}, { deep: true }) - function onSkillsChanged(): void { void refreshSkills() } -async function refreshTelegramStatus(): Promise { - try { - telegramStatus.value = await getTelegramStatus() - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to load Telegram status' - telegramStatus.value = { - configured: false, - active: false, - mappedChats: 0, - mappedThreads: 0, - allowedUsers: 0, - allowAllUsers: false, - lastError: message, - } - } -} - -async function refreshTelegramConfig(): Promise { - try { - const config = await getTelegramConfig() - telegramBotTokenDraft.value = config.botToken - telegramAllowedUserIdsDraft.value = config.allowedUserIds.map((value) => String(value)).join('\n') - telegramConfigError.value = '' - } catch (error) { - telegramConfigError.value = error instanceof Error ? error.message : 'Failed to load Telegram configuration' - } -} - -async function loadFirstLaunchPluginsCardPreference(): Promise { - const preference = await getFirstLaunchPluginsCardPreference() - showFirstLaunchPluginsCard.value = preference.dismissed !== true -} - -function parseTelegramAllowedUserIdsInput(value: string): Array { - const rawEntries = value - .split(/[\n,]/) - .map((entry) => entry.trim().replace(/^(telegram|tg):/i, '').trim()) - .filter(Boolean) - const allowAllUsers = rawEntries.includes('*') - const normalizedUserIds = Array.from(new Set(rawEntries - .filter((entry) => /^-?\d+$/.test(entry)) - .map((entry) => Number.parseInt(entry, 10)))) - return allowAllUsers ? ['*', ...normalizedUserIds] : normalizedUserIds -} - -async function saveTelegramConfig(): Promise { - const botToken = telegramBotTokenDraft.value.trim() - const allowedUserIds = parseTelegramAllowedUserIdsInput(telegramAllowedUserIdsDraft.value) - if (!botToken) { - telegramConfigError.value = t('Telegram bot token is required.') - return - } - if (allowedUserIds.length === 0) { - telegramConfigError.value = t('At least one allowed Telegram user ID or * is required.') - return - } - - isTelegramSaving.value = true - telegramConfigError.value = '' - try { - await configureTelegramBot(botToken, allowedUserIds) - telegramAllowedUserIdsDraft.value = allowedUserIds.map((value) => String(value)).join('\n') - await Promise.all([ - refreshTelegramConfig(), - refreshTelegramStatus(), - ]) - window.alert(t('Telegram bot configured. Only allowlisted Telegram users can use the bridge.')) - } catch (error) { - telegramConfigError.value = error instanceof Error ? error.message : t('Failed to connect Telegram bot') - void refreshTelegramStatus() - } finally { - isTelegramSaving.value = false - } -} - -function toggleSidebarSearch(): void { - isSidebarSearchVisible.value = !isSidebarSearchVisible.value - if (isSidebarSearchVisible.value) { - nextTick(() => sidebarSearchInputRef.value?.focus()) - } else { - sidebarSearchQuery.value = '' - } -} - -function clearSidebarSearch(): void { - sidebarSearchQuery.value = '' - sidebarSearchInputRef.value?.focus() -} - -function onSidebarSearchKeydown(event: KeyboardEvent): void { - if (event.key === 'Escape') { - isSidebarSearchVisible.value = false - sidebarSearchQuery.value = '' - } -} - function onSelectThread(threadId: string): void { if (!threadId) return if (route.name === 'thread' && routeThreadId.value === threadId) return @@ -1731,236 +1398,6 @@ async function onExportThread(threadId: string): Promise { onExportChat() } -function shortAccountId(accountId: string): string { - return accountId.length > 8 ? accountId.slice(-8) : accountId -} - -function formatAccountMeta(account: UiAccountEntry): string { - const segments = [account.planType || t('unknown')] - if (account.authMode) { - segments.unshift(account.authMode) - } - return segments.join(' · ') -} - -function isPaymentRequiredErrorMessage(value: string | null): boolean { - if (!value) return false - const normalized = value.toLowerCase() - return normalized.includes('payment required') || /\b402\b/.test(normalized) -} - -function isAccountUnavailable(account: UiAccountEntry): boolean { - return account.unavailableReason === 'payment_required' || isPaymentRequiredErrorMessage(account.quotaError) -} - -function isAccountActionDisabled(account: UiAccountEntry): boolean { - return isRefreshingAccounts.value || isSwitchingAccounts.value || removingAccountId.value.length > 0 - || (account.isActive && removingAccountId.value !== account.accountId && isAccountSwitchBlocked.value) -} - -function isRemoveConfirmationActive(account: UiAccountEntry): boolean { - return confirmingRemoveAccountId.value === account.accountId -} - -function isRemoveVisible(account: UiAccountEntry): boolean { - return hoveredAccountId.value === account.accountId || isRemoveConfirmationActive(account) -} - -function getAccountSwitchLabel(account: UiAccountEntry): string { - if (isAccountUnavailable(account)) return t('Unavailable') - if (account.isActive) return t('Active') - if (isSwitchingAccounts.value) return t('Switching…') - return t('Switch') -} - -function getAccountRemoveLabel(account: UiAccountEntry): string { - if (removingAccountId.value === account.accountId) return t('Removing…') - if (isRemoveConfirmationActive(account)) return t('Click again to remove') - return t('Remove') -} - -function onAccountCardPointerEnter(accountId: string): void { - hoveredAccountId.value = accountId -} - -function onAccountCardPointerLeave(accountId: string): void { - if (hoveredAccountId.value === accountId) { - hoveredAccountId.value = '' - } - if (removingAccountId.value === accountId) return - if (confirmingRemoveAccountId.value === accountId) { - confirmingRemoveAccountId.value = '' - } -} - -function pickWeeklyQuotaWindow(account: UiAccountEntry) { - const quota = account.quotaSnapshot - if (!quota) return null - const windows = [quota?.primary, quota?.secondary].filter((quotaWindow): quotaWindow is UiRateLimitWindow => quotaWindow !== null) - const exactWeekly = windows.find((quotaWindow) => quotaWindow.windowMinutes === 7 * 24 * 60) - if (exactWeekly) { - return exactWeekly - } - const longerWindow = windows - .filter((quotaWindow) => typeof quotaWindow.windowMinutes === 'number' && quotaWindow.windowMinutes >= 7 * 24 * 60) - .sort((first, second) => (first.windowMinutes ?? 0) - (second.windowMinutes ?? 0))[0] ?? null - if (longerWindow) { - return longerWindow - } - return quota.secondary ?? null -} - -function formatResetDateCompact(resetsAt: number | null): string { - if (typeof resetsAt !== 'number' || !Number.isFinite(resetsAt)) return '' - const date = new Date(resetsAt * 1000) - return `${date.getMonth() + 1}月${date.getDate()}日` -} - -function formatAccountQuota(account: UiAccountEntry): string { - if (isAccountUnavailable(account)) { - return account.quotaError || t('402 Payment Required') - } - const quota = account.quotaSnapshot - const window = pickWeeklyQuotaWindow(account) - const fallbackWindow = quota?.primary ?? quota?.secondary ?? null - const displayWindow = window ?? fallbackWindow - if (displayWindow) { - const remainingPercent = Math.max(0, Math.min(100, 100 - Math.round(displayWindow.usedPercent))) - const refreshDate = formatResetDateCompact(displayWindow.resetsAt) - return refreshDate - ? `${remainingPercent}% ${t('weekly remaining')} · ${refreshDate}` - : `${remainingPercent}% ${t('weekly remaining')}` - } - if (quota?.credits?.unlimited) { - return t('Unlimited credits') - } - if (quota?.credits?.hasCredits && quota.credits.balance) { - return `${quota.credits.balance} ${t('credits')}` - } - if (account.quotaStatus === 'loading') { - return t('Loading quota…') - } - if (account.quotaStatus === 'error') { - return account.quotaError || t('Quota unavailable') - } - if (account.quotaStatus === 'ready' || account.quotaStatus === 'idle') { - return t('Quota unavailable') - } - return t('Fetching account details…') -} - -function buildAccountTitle(account: UiAccountEntry): string { - return [ - account.email || t('Account'), - formatAccountMeta(account), - isAccountUnavailable(account) ? t('Unavailable account') : null, - formatAccountQuota(account), - `${t('Workspace')} ${account.accountId}`, - ].filter(Boolean).join('\n') -} - -async function loadAccountsState(options: { silent?: boolean } = {}): Promise { - try { - const result = await getAccounts() - accounts.value = result.accounts - if (!result.accounts.some((account) => account.accountId === hoveredAccountId.value)) { - hoveredAccountId.value = '' - } - if (!result.accounts.some((account) => account.accountId === confirmingRemoveAccountId.value)) { - confirmingRemoveAccountId.value = '' - } - } catch (error) { - if (options.silent === true) return - accountActionError.value = error instanceof Error ? error.message : t('Failed to load accounts') - } -} - -async function onRefreshAccounts(): Promise { - if (isRefreshingAccounts.value || isSwitchingAccounts.value) return - accountActionError.value = '' - hoveredAccountId.value = '' - confirmingRemoveAccountId.value = '' - isRefreshingAccounts.value = true - try { - const result = await refreshAccountsFromAuth() - accounts.value = result.accounts - stopPolling() - startPolling() - void refreshAll({ - includeSelectedThreadMessages: true, - }) - } catch (error) { - accountActionError.value = error instanceof Error ? error.message : t('Failed to refresh accounts') - } finally { - isRefreshingAccounts.value = false - } -} - -async function onSwitchAccount(accountId: string): Promise { - if (isSwitchingAccounts.value || isRefreshingAccounts.value) return - if (isAccountSwitchBlocked.value) { - accountActionError.value = t('Finish the current turn and pending requests before switching accounts.') - return - } - accountActionError.value = '' - hoveredAccountId.value = '' - confirmingRemoveAccountId.value = '' - isSwitchingAccounts.value = true - try { - const nextActiveAccount = await switchAccount(accountId) - accounts.value = accounts.value.map((account) => ( - account.accountId === accountId - ? nextActiveAccount - : { ...account, isActive: false } - )) - stopPolling() - startPolling() - void refreshAll({ - includeSelectedThreadMessages: true, - }) - void loadAccountsState({ silent: true }) - } catch (error) { - accountActionError.value = error instanceof Error ? error.message : t('Failed to switch account') - } finally { - isSwitchingAccounts.value = false - } -} - -async function onRemoveAccount(accountId: string): Promise { - if (isRefreshingAccounts.value || isSwitchingAccounts.value || removingAccountId.value.length > 0) return - const targetAccount = accounts.value.find((account) => account.accountId === accountId) ?? null - if (!targetAccount) return - if (confirmingRemoveAccountId.value !== accountId) { - confirmingRemoveAccountId.value = accountId - return - } - if (targetAccount.isActive && isAccountSwitchBlocked.value) { - accountActionError.value = t('Finish the current turn and pending requests before removing the active account.') - return - } - - const removedWasActive = targetAccount.isActive - accountActionError.value = '' - confirmingRemoveAccountId.value = '' - removingAccountId.value = accountId - try { - const result = await removeAccount(accountId) - accounts.value = result.accounts - stopPolling() - startPolling() - if (removedWasActive) { - void refreshAll({ - includeSelectedThreadMessages: true, - }) - } - void loadAccountsState({ silent: true }) - } catch (error) { - accountActionError.value = error instanceof Error ? error.message : t('Failed to remove account') - } finally { - removingAccountId.value = '' - } -} - function onArchiveThread(threadId: string): void { void archiveThreadById(threadId) } @@ -1976,31 +1413,6 @@ async function onForkThread(threadId: string): Promise { if (isMobile.value) setSidebarCollapsed(true) } -function isWorktreePath(cwdRaw: string): boolean { - const cwd = cwdRaw.trim().replace(/\\/gu, '/') - if (!cwd) return false - return cwd.includes('/.codex/worktrees/') || cwd.includes('/.git/worktrees/') -} - -function resolvePreferredLocalCwd(projectName: string, fallbackCwd = ''): string { - const group = projectGroups.value.find((row) => row.projectName === projectName) - if (!group) return fallbackCwd.trim() - const nonWorktreeThread = group.threads.find((thread) => !isWorktreePath(thread.cwd)) - const candidate = nonWorktreeThread?.cwd?.trim() ?? group.threads[0]?.cwd?.trim() ?? '' - return candidate || fallbackCwd.trim() -} - -function onStartNewThread(projectName: string): void { - const projectGroup = projectGroups.value.find((group) => group.projectName === projectName) - const projectCwd = resolvePreferredLocalCwd(projectName, projectGroup?.threads[0]?.cwd?.trim() ?? '') - if (projectCwd) { - newThreadCwd.value = projectCwd - } - if (isMobile.value) setSidebarCollapsed(true) - if (isHomeRoute.value) return - void router.push({ name: 'home' }) -} - function onBrowseThreadFiles(threadId: string): void { let targetCwd = '' for (const group of projectGroups.value) { @@ -2014,37 +1426,10 @@ function onBrowseThreadFiles(threadId: string): void { window.open(`/codex-local-browse${encodeURI(targetCwd)}`, '_blank', 'noopener,noreferrer') } -function onStartNewThreadFromToolbar(): void { - const selected = selectedThread.value - const cwd = selected - ? resolvePreferredLocalCwd(selected.projectName, selected.cwd?.trim() ?? '') - : '' - if (cwd) { - newThreadCwd.value = cwd - } - if (isMobile.value) setSidebarCollapsed(true) - if (isHomeRoute.value) return - void router.push({ name: 'home' }) -} - -function onRenameProject(payload: { projectName: string; displayName: string }): void { - renameProject(payload.projectName, payload.displayName) -} - function onRenameThread(payload: { threadId: string; title: string }): void { void renameThreadById(payload.threadId, payload.title) } -async function onRemoveProject(projectName: string): Promise { - await removeProject(projectName) - await loadWorkspaceRootOptionsState() - void refreshDefaultProjectName() -} - -function onReorderProject(payload: { projectName: string; toIndex: number }): void { - reorderProject(payload.projectName, payload.toIndex) -} - function onUpdateThreadScrollState(payload: { threadId: string; state: ThreadScrollState }): void { setThreadScrollState(payload.threadId, payload.state) } @@ -2075,12 +1460,6 @@ async function onForkThreadFromMessage(payload: { threadId: string; turnIndex: n if (isMobile.value) setSidebarCollapsed(true) } -function setSidebarCollapsed(nextValue: boolean): void { - if (isSidebarCollapsed.value === nextValue) return - isSidebarCollapsed.value = nextValue - saveSidebarCollapsed(nextValue) -} - function onWindowKeyDown(event: KeyboardEvent): void { if (event.defaultPrevented) return if (event.key === 'Escape' && isSettingsOpen.value) { @@ -2106,72 +1485,15 @@ function onWindowKeyDown(event: KeyboardEvent): void { } } -function toggleComposerTerminal(): void { - if (!isThreadTerminalAvailable.value) return - if (isHomeRoute.value) { - if (!composerCwd.value) return - homeTerminalOpen.value = !homeTerminalOpen.value - if (!homeTerminalOpen.value) { - resetTerminalKeyboardFocusState() - } - return - } - toggleSelectedThreadTerminal() - if (!selectedThreadTerminalOpen.value) { - resetTerminalKeyboardFocusState() - } -} - -function onTerminalFocusChange(focused: boolean): void { - isTerminalInputFocused.value = focused - if (!focused) { - isTerminalKeyboardFocusFallbackActive.value = false - clearTerminalKeyboardFocusFallbackTimer() - return - } - isTerminalKeyboardFocusFallbackActive.value = true - clearTerminalKeyboardFocusFallbackTimer() - terminalKeyboardFocusFallbackTimer = setTimeout(() => { - terminalKeyboardFocusFallbackTimer = null - if (!isVirtualKeyboardOpen.value) { - isTerminalKeyboardFocusFallbackActive.value = false - } - }, 1500) -} - -function onHideHomeTerminal(): void { - homeTerminalOpen.value = false - resetTerminalKeyboardFocusState() -} - -function onHideSelectedThreadTerminal(): void { - if (selectedThreadId.value) { - setThreadTerminalOpen(selectedThreadId.value, false) - } - resetTerminalKeyboardFocusState() -} - -function resetTerminalKeyboardFocusState(): void { - isTerminalInputFocused.value = false - isTerminalKeyboardFocusFallbackActive.value = false - clearTerminalKeyboardFocusFallbackTimer() -} - -function clearTerminalKeyboardFocusFallbackTimer(): void { - if (!terminalKeyboardFocusFallbackTimer) return - clearTimeout(terminalKeyboardFocusFallbackTimer) - terminalKeyboardFocusFallbackTimer = null -} - -async function refreshThreadTerminalStatus(): Promise { - try { - const status = await getThreadTerminalStatus() - isThreadTerminalAvailable.value = status.available - if (!status.available) { - homeTerminalOpen.value = false - if (selectedThreadId.value) { - setThreadTerminalOpen(selectedThreadId.value, false) - } +async function refreshThreadTerminalStatus(): Promise { + try { + const status = await getThreadTerminalStatus() + isThreadTerminalAvailable.value = status.available + if (!status.available) { + homeTerminalOpen.value = false + if (selectedThreadId.value) { + setThreadTerminalOpen(selectedThreadId.value, false) + } } } catch { isThreadTerminalAvailable.value = false @@ -2203,62 +1525,6 @@ function onSettingsAreaClick(event: MouseEvent): void { isSettingsOpen.value = false } -function onDocumentVisibilityChange(): void { - if (typeof document === 'undefined') return - if (!isMobile.value) return - - if (document.visibilityState === 'hidden') { - mobileHiddenAtMs.value = Date.now() - mobileResumeReloadTriggered.value = false - return - } - - maybeSyncAfterMobileResume() -} - -function onWindowPageShow(event: PageTransitionEvent): void { - if (!event.persisted) return - maybeSyncAfterMobileResume() -} - -function onWindowFocus(): void { - if (route.name === 'home') { - void loadWorkspaceRootOptionsState() - void refreshDefaultProjectName() - } - maybeSyncAfterMobileResume() -} - -function maybeSyncAfterMobileResume(): void { - if (typeof window === 'undefined' || typeof document === 'undefined') return - if (!isMobile.value) return - if (document.visibilityState !== 'visible') return - if (mobileResumeReloadTriggered.value) return - if (mobileHiddenAtMs.value === null) return - - const hiddenForMs = Date.now() - mobileHiddenAtMs.value - if (hiddenForMs < MOBILE_RESUME_RELOAD_MIN_HIDDEN_MS) return - - mobileResumeReloadTriggered.value = true - mobileHiddenAtMs.value = null - void syncAfterMobileResume() -} - -async function syncAfterMobileResume(): Promise { - if (mobileResumeSyncInProgress.value) return - mobileResumeSyncInProgress.value = true - - try { - await refreshAll({ - includeSelectedThreadMessages: true, - awaitAncillaryRefreshes: true, - }) - await syncThreadSelectionWithRoute() - } finally { - mobileResumeSyncInProgress.value = false - } -} - function onSubmitThreadMessage(payload: { text: string; imageUrls: string[]; fileAttachments: Array<{ label: string; path: string; fsPath: string }>; skills: Array<{ name: string; path: string }>; mode: 'steer' | 'queue' }): void { const text = payload.text scheduleMobileConversationJumpToLatest() @@ -2271,7 +1537,16 @@ function onSubmitThreadMessage(payload: { text: string; imageUrls: string[]; fil : undefined editingQueuedMessageState.value = null if (isHomeRoute.value) { - void submitFirstMessageForNewThread(text, payload.imageUrls, payload.skills, payload.fileAttachments) + void submitFirstMessageForNewThread( + text, + payload.imageUrls, + payload.skills, + payload.fileAttachments, + sendMessageToNewThread, + () => { + scheduleMobileConversationJumpToLatest() + }, + ) return } void sendMessageToSelectedThread(text, payload.imageUrls, payload.skills, payload.mode, payload.fileAttachments, queueInsertIndex) @@ -2320,35 +1595,6 @@ function scheduleMobileConversationJumpToLatest(): void { }) } -function onSelectNewThreadFolder(cwd: string): void { - newThreadCwd.value = cwd.trim() - createFolderError.value = '' -} - -function onSelectNewWorktreeBranch(branch: string): void { - newWorktreeBaseBranch.value = branch.trim() -} - -async function loadThreadBranches(cwd: string): Promise { - const targetCwd = cwd.trim() - if (!targetCwd || route.name !== 'thread') { - threadBranchOptions.value = [] - currentThreadBranch.value = null - return - } - isLoadingThreadBranches.value = true - try { - const state = await getGitBranchState(targetCwd) - threadBranchOptions.value = state.options - currentThreadBranch.value = state.currentBranch - } catch { - threadBranchOptions.value = [] - currentThreadBranch.value = null - } finally { - isLoadingThreadBranches.value = false - } -} - function onSelectContentHeaderBranch(value: string): void { if (value === '__review__') { isReviewPaneOpen.value = !isReviewPaneOpen.value @@ -2377,342 +1623,6 @@ function onSelectContentHeaderBranch(value: string): void { }) } -async function onCreateProject(): Promise { - const baseDir = await resolveProjectBaseDirectory() - if (!baseDir) return - - await refreshDefaultProjectName() - const suggestedName = defaultNewProjectName.value.trim() || 'New Project (1)' - const projectName = window.prompt(`Create project in ${baseDir}`, suggestedName) - if (projectName === null) return - - const normalizedProjectName = projectName.trim() - if (!normalizedProjectName) return - - const targetPath = normalizeAbsolutePath(joinPath(baseDir, normalizedProjectName)) - if (!targetPath) return - - try { - const normalizedPath = await openProjectRoot(targetPath, { - createIfMissing: true, - label: '', - }) - if (!normalizedPath) return - - newThreadCwd.value = normalizedPath - pinProjectToTop(getPathLeafName(normalizedPath)) - await loadWorkspaceRootOptionsState() - await refreshDefaultProjectName() - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create the project.' - window.alert(message) - } -} - -async function onOpenExistingFolder(): Promise { - const startPath = newThreadCwd.value.trim() || await resolveProjectBaseDirectory() - if (!startPath) return - isCreateFolderOpen.value = false - isExistingFolderPickerOpen.value = true - existingFolderFilter.value = '' - await loadExistingFolderListing(startPath) - if (!existingFolderError.value) { - void nextTick(() => existingFolderFilterInputRef.value?.focus()) - } -} - -function onCloseExistingFolderPanel(): void { - existingFolderBrowseRequestId += 1 - isExistingFolderPickerOpen.value = false - isExistingFolderLoading.value = false - existingFolderError.value = '' - existingFolderFilter.value = '' - onCloseCreateFolderPanel() -} - -async function onBrowseExistingFolder(path: string): Promise { - if (!path || isExistingFolderLoading.value) return - existingFolderFilter.value = '' - await loadExistingFolderListing(path) -} - -function onToggleHiddenFolders(): void { - const currentPath = existingFolderBrowsePath.value.trim() - if (!isExistingFolderPickerOpen.value || !currentPath) return - void loadExistingFolderListing(currentPath) -} - -function onRetryExistingFolderBrowse(): void { - const currentPath = existingFolderBrowsePath.value.trim() - if (!isExistingFolderPickerOpen.value || !currentPath || isExistingFolderLoading.value) return - void loadExistingFolderListing(currentPath) -} - -async function onConfirmExistingFolder(path = existingFolderBrowsePath.value): Promise { - const targetPath = path.trim() - if (!targetPath) return - - existingFolderError.value = '' - isOpeningExistingFolder.value = true - try { - const normalizedPath = await openProjectRoot(targetPath, { - createIfMissing: false, - label: '', - }) - if (!normalizedPath) { - existingFolderError.value = 'Failed to open the selected folder.' - return - } - - newThreadCwd.value = normalizedPath - pinProjectToTop(getPathLeafName(normalizedPath)) - await loadWorkspaceRootOptionsState() - await refreshDefaultProjectName() - onCloseExistingFolderPanel() - } catch (error) { - existingFolderError.value = error instanceof Error ? error.message : 'Failed to open the selected folder.' - } finally { - isOpeningExistingFolder.value = false - } -} - -async function onOpenCreateFolderPanel(): Promise { - createFolderError.value = '' - if (isCreateFolderOpen.value) { - onCloseCreateFolderPanel() - return - } - if (!isExistingFolderPickerOpen.value) { - const startPath = newThreadCwd.value.trim() || await resolveProjectBaseDirectory() - if (!startPath) return - isExistingFolderPickerOpen.value = true - existingFolderFilter.value = '' - await loadExistingFolderListing(startPath) - if (existingFolderError.value) return - } - if (existingFolderError.value) return - createFolderDraft.value = defaultNewProjectName.value - isCreateFolderOpen.value = true - void nextTick(() => createFolderInputRef.value?.focus()) -} - -function onCloseCreateFolderPanel(): void { - createFolderError.value = '' - createFolderDraft.value = '' - isCreateFolderOpen.value = false -} - -async function onCreateFolder(): Promise { - const normalizedInput = createFolderDraft.value.trim() - if (!normalizedInput) return - - createFolderError.value = '' - if (existingFolderError.value) { - createFolderError.value = 'Reload the current folder before creating a new one.' - return - } - isCreatingFolder.value = true - - const baseDir = createFolderParentPath.value.trim() - const targetPath = normalizeAbsolutePath(joinPath(baseDir, normalizedInput)) - - if (!targetPath) { - createFolderError.value = 'Unable to determine where the new folder should be created.' - isCreatingFolder.value = false - return - } - - if (!isCreateFolderNameValid.value) { - createFolderError.value = 'Enter a single folder name.' - isCreatingFolder.value = false - return - } - - try { - const normalizedPath = await createLocalDirectory(targetPath) - if (!normalizedPath) { - createFolderError.value = 'Failed to create the folder.' - return - } - - createFolderError.value = '' - existingFolderFilter.value = '' - await loadExistingFolderListing(normalizedPath) - onCloseCreateFolderPanel() - } catch (error) { - createFolderError.value = error instanceof Error ? error.message : 'Failed to create folder.' - } finally { - isCreatingFolder.value = false - } -} - -async function applyLaunchProjectPathFromUrl(): Promise { - if (typeof window === 'undefined') return false - const launchProjectPath = new URLSearchParams(window.location.search).get('openProjectPath')?.trim() ?? '' - if (!launchProjectPath) return false - try { - const normalizedPath = await openProjectRoot(launchProjectPath, { - createIfMissing: false, - label: '', - }) - if (!normalizedPath) return false - newThreadCwd.value = normalizedPath - pinProjectToTop(getPathLeafName(normalizedPath)) - await router.replace({ name: 'home' }) - await loadWorkspaceRootOptionsState() - const nextUrl = new URL(window.location.href) - nextUrl.searchParams.delete('openProjectPath') - window.history.replaceState({}, '', nextUrl.toString()) - return true - } catch { - // If launch path is invalid, keep normal startup behavior. - return false - } -} - -async function resolveProjectBaseDirectory(): Promise { - const baseDir = getProjectBaseDirectory() - if (baseDir) return baseDir - try { - const loadedHomeDirectory = await getHomeDirectory() - if (loadedHomeDirectory) { - homeDirectory.value = loadedHomeDirectory - return loadedHomeDirectory - } - } catch { - // Fallback handled by empty return. - } - return '' -} - -async function refreshDefaultProjectName(): Promise { - const baseDir = getProjectBaseDirectory() - if (!baseDir) { - defaultNewProjectName.value = 'New Project (1)' - return - } - - try { - const suggestion = await getProjectRootSuggestion(baseDir) - defaultNewProjectName.value = suggestion.name || 'New Project (1)' - } catch { - defaultNewProjectName.value = 'New Project (1)' - } -} - -function getProjectBaseDirectory(): string { - const selected = newThreadCwd.value.trim() - if (selected) return getPathParent(selected) - const first = newThreadFolderOptions.value[0]?.value?.trim() ?? '' - if (first) return getPathParent(first) - return homeDirectory.value.trim() -} - -async function loadHomeDirectory(): Promise { - try { - homeDirectory.value = await getHomeDirectory() - } catch { - homeDirectory.value = '' - } -} - -async function loadWorkspaceRootOptionsState(): Promise { - try { - const state = await getWorkspaceRootsState() - workspaceRootOptionsState.value = { - order: [...state.order], - labels: { ...state.labels }, - } - } catch { - workspaceRootOptionsState.value = { order: [], labels: {} } - } -} - -async function loadExistingFolderListing(path: string): Promise { - const requestId = ++existingFolderBrowseRequestId - existingFolderBrowsePath.value = normalizePathForUi(path).trim() - existingFolderError.value = '' - isExistingFolderLoading.value = true - - try { - const listing = await listLocalDirectories(path, { showHidden: showHiddenFolders.value }) - if (requestId !== existingFolderBrowseRequestId) return - existingFolderBrowsePath.value = listing.path - existingFolderParentPath.value = listing.parentPath - existingFolderEntries.value = listing.entries - } catch (error) { - if (requestId !== existingFolderBrowseRequestId) return - existingFolderError.value = error instanceof Error ? error.message : 'Failed to load local folders.' - existingFolderParentPath.value = getPathParent(existingFolderBrowsePath.value) - existingFolderEntries.value = [] - onCloseCreateFolderPanel() - } finally { - if (requestId === existingFolderBrowseRequestId) { - isExistingFolderLoading.value = false - } - } -} - -function joinPath(parent: string, child: string): string { - const rawParent = normalizePathForUi(parent).trim() - const normalizedChild = normalizePathForUi(child).trim().replace(/^[\\/]+/u, '') - if (!rawParent || !normalizedChild) return '' - const separator = rawParent.includes('\\') && !rawParent.includes('/') ? '\\' : '/' - if (/^[a-zA-Z]:[\\/]?$/u.test(rawParent)) { - return `${rawParent.slice(0, 2)}${separator}${normalizedChild}` - } - if (/^\/+$/u.test(rawParent)) { - return `/${normalizedChild}` - } - const normalizedParent = rawParent.replace(/[\\/]+$/u, '') - if (!normalizedParent) return '' - return `${normalizedParent}${separator}${normalizedChild}` -} - -function normalizeAbsolutePath(value: string): string { - const normalizedValue = normalizePathForUi(value).trim() - if (!normalizedValue) return '' - - const uncMatch = normalizedValue.match(/^\\\\([^\\/]+)[\\/]+([^\\/]+)([\\/].*)?$/u) - if (uncMatch) { - const [, server, share, suffix = ''] = uncMatch - const segments = collapsePathSegments(suffix.split(/[\\/]+/u)) - return segments.length > 0 - ? `\\\\${server}\\${share}\\${segments.join('\\')}` - : `\\\\${server}\\${share}` - } - - const driveMatch = normalizedValue.match(/^([a-zA-Z]:)([\\/].*)?$/u) - if (driveMatch) { - const [, drive, suffix = ''] = driveMatch - const separator = normalizedValue.includes('\\') && !normalizedValue.includes('/') ? '\\' : '/' - const segments = collapsePathSegments(suffix.split(/[\\/]+/u)) - return segments.length > 0 ? `${drive}${separator}${segments.join(separator)}` : `${drive}${separator}` - } - - if (normalizedValue.startsWith('/')) { - const segments = collapsePathSegments(normalizedValue.split('/')) - return segments.length > 0 ? `/${segments.join('/')}` : '/' - } - - return normalizedValue -} - -function collapsePathSegments(rawSegments: readonly string[]): string[] { - const segments: string[] = [] - for (const rawSegment of rawSegments) { - const segment = rawSegment.trim() - if (!segment || segment === '.') continue - if (segment === '..') { - if (segments.length > 0) { - segments.pop() - } - continue - } - segments.push(segment) - } - return segments -} function onSelectModel(modelId: string): void { setSelectedModelIdForThread(composerThreadContextId.value, modelId) @@ -2843,329 +1753,6 @@ function escapeMarkdownText(value: string): string { return value.replace(/([\\`*_{}\[\]()#+\-.!])/g, '\\$1') } -function loadBoolPref(key: string, fallback: boolean): boolean { - if (typeof window === 'undefined') return fallback - const v = window.localStorage.getItem(key) - if (v === null) return fallback - return v === '1' -} - -function loadDarkModePref(): 'system' | 'light' | 'dark' { - if (typeof window === 'undefined') return 'system' - const v = window.localStorage.getItem(DARK_MODE_KEY) - if (v === 'light' || v === 'dark') return v - return 'system' -} - -function loadInProgressSendModePref(): 'steer' | 'queue' { - if (typeof window === 'undefined') return 'steer' - const v = window.localStorage.getItem(IN_PROGRESS_SEND_MODE_KEY) - return v === 'queue' ? 'queue' : 'steer' -} - -function loadChatWidthPref(): ChatWidthMode { - if (typeof window === 'undefined') return 'standard' - const value = window.localStorage.getItem(CHAT_WIDTH_KEY) - return value === 'standard' || value === 'wide' || value === 'extra-wide' ? value : 'standard' -} - -function toggleSendWithEnter(): void { - sendWithEnter.value = !sendWithEnter.value - window.localStorage.setItem(SEND_WITH_ENTER_KEY, sendWithEnter.value ? '1' : '0') -} - -function cycleInProgressSendMode(): void { - inProgressSendMode.value = inProgressSendMode.value === 'steer' ? 'queue' : 'steer' - window.localStorage.setItem(IN_PROGRESS_SEND_MODE_KEY, inProgressSendMode.value) -} - -function cycleDarkMode(): void { - const order: Array<'system' | 'light' | 'dark'> = ['system', 'light', 'dark'] - const idx = order.indexOf(darkMode.value) - darkMode.value = order[(idx + 1) % order.length] - window.localStorage.setItem(DARK_MODE_KEY, darkMode.value) - applyDarkMode() -} - -function cycleChatWidth(): void { - const order: ChatWidthMode[] = ['standard', 'wide', 'extra-wide'] - const idx = order.indexOf(chatWidth.value) - chatWidth.value = order[(idx + 1) % order.length] - window.localStorage.setItem(CHAT_WIDTH_KEY, chatWidth.value) -} - -function toggleDictationClickToToggle(): void { - dictationClickToToggle.value = !dictationClickToToggle.value - window.localStorage.setItem(DICTATION_CLICK_TO_TOGGLE_KEY, dictationClickToToggle.value ? '1' : '0') -} - -function toggleDictationAutoSend(): void { - dictationAutoSend.value = !dictationAutoSend.value - window.localStorage.setItem(DICTATION_AUTO_SEND_KEY, dictationAutoSend.value ? '1' : '0') -} - - -async function onProviderChange(provider: string): Promise { - if (freeModeLoading.value) return - freeModeLoading.value = true - try { - if (provider === 'codex') { - selectedProvider.value = 'codex' - const result = await setFreeMode(false) - freeModeEnabled.value = result.enabled - } else if (provider === 'openrouter') { - selectedProvider.value = 'openrouter' - const result = await setFreeMode(true) - freeModeEnabled.value = result.enabled - await setCustomProvider('', '', { - wireApi: openRouterWireApi.value, - provider: 'openrouter', - }) - } else if (provider === 'opencode-zen') { - selectedProvider.value = 'opencode-zen' - await setCustomProvider('', opencodeZenKey.value.trim(), { - wireApi: 'chat', - provider: 'opencode-zen', - }) - freeModeEnabled.value = true - } else if (provider === 'custom') { - selectedProvider.value = 'custom' - if (customEndpointUrl.value.trim() && customEndpointKey.value.trim()) { - await setCustomProvider(customEndpointUrl.value.trim(), customEndpointKey.value.trim(), { - wireApi: customEndpointWireApi.value, - }) - freeModeEnabled.value = true - } - } - providerError.value = '' - await refreshAll({ includeSelectedThreadMessages: false, providerChanged: true, awaitAncillaryRefreshes: true }) - if (route.name === 'thread') { - void router.push({ name: 'home' }) - } - } catch (err) { - providerError.value = err instanceof Error ? err.message : 'Failed to switch provider' - } finally { - freeModeLoading.value = false - } -} - -async function saveCustomEndpoint(): Promise { - if (freeModeCustomKeySaving.value) return - const url = customEndpointUrl.value.trim() - if (!url) return - freeModeCustomKeySaving.value = true - try { - providerError.value = '' - await setCustomProvider(url, customEndpointKey.value.trim(), { - wireApi: customEndpointWireApi.value, - }) - freeModeEnabled.value = true - await refreshAll({ includeSelectedThreadMessages: false, providerChanged: true, awaitAncillaryRefreshes: true }) - } catch (err) { - providerError.value = err instanceof Error ? err.message : 'Failed to save custom endpoint' - } finally { - freeModeCustomKeySaving.value = false - } -} - -async function setOpenRouterWireApi(nextWireApi: 'responses' | 'chat'): Promise { - if (freeModeCustomKeySaving.value || freeModeLoading.value) return - if (openRouterWireApi.value === nextWireApi) return - const previousWireApi = openRouterWireApi.value - openRouterWireApi.value = nextWireApi - freeModeCustomKeySaving.value = true - try { - providerError.value = '' - await setCustomProvider('', '', { - wireApi: nextWireApi, - provider: 'openrouter', - }) - freeModeEnabled.value = true - await refreshAll({ includeSelectedThreadMessages: false, providerChanged: true, awaitAncillaryRefreshes: true }) - } catch (err) { - openRouterWireApi.value = previousWireApi - providerError.value = err instanceof Error ? err.message : 'Failed to save OpenRouter API format' - } finally { - freeModeCustomKeySaving.value = false - } -} - -async function saveOpencodeZen(): Promise { - if (freeModeCustomKeySaving.value) return - const key = opencodeZenKey.value.trim() - if (!key) return - freeModeCustomKeySaving.value = true - try { - providerError.value = '' - await setCustomProvider('', key, { - wireApi: 'chat', - provider: 'opencode-zen', - }) - freeModeEnabled.value = true - await refreshAll({ includeSelectedThreadMessages: false, providerChanged: true, awaitAncillaryRefreshes: true }) - } catch (err) { - providerError.value = err instanceof Error ? err.message : 'Failed to save OpenCode Zen config' - } finally { - freeModeCustomKeySaving.value = false - } -} - -async function saveFreeModeCustomKey(): Promise { - if (freeModeCustomKeySaving.value) return - freeModeCustomKeySaving.value = true - try { - const key = freeModeCustomKey.value.trim() - await setFreeModeCustomKey(key) - freeModeCustomKey.value = '' - await loadFreeModeStatus() - await refreshAll({ includeSelectedThreadMessages: false }) - } catch { - // Silently fail - } finally { - freeModeCustomKeySaving.value = false - } -} - -async function clearFreeModeCustomKey(): Promise { - if (freeModeCustomKeySaving.value) return - freeModeCustomKeySaving.value = true - try { - await setFreeModeCustomKey('') - freeModeCustomKey.value = '' - await loadFreeModeStatus() - await refreshAll({ includeSelectedThreadMessages: false }) - } catch { - // Silently fail - } finally { - freeModeCustomKeySaving.value = false - } -} - -async function loadFreeModeStatus(): Promise { - try { - const status = await getFreeModeStatus() - freeModeEnabled.value = status.enabled - freeModeHasCustomKey.value = status.customKey ?? false - freeModeCustomKeyMasked.value = status.maskedKey ?? null - if (status.enabled) { - if (status.provider === 'opencode-zen') { - selectedProvider.value = 'opencode-zen' - } else if (status.provider === 'custom') { - selectedProvider.value = 'custom' - customEndpointUrl.value = status.customBaseUrl ?? '' - customEndpointWireApi.value = status.wireApi === 'chat' ? 'chat' : 'responses' - } else { - selectedProvider.value = 'openrouter' - openRouterWireApi.value = status.wireApi === 'chat' ? 'chat' : 'responses' - } - } else { - selectedProvider.value = 'codex' - } - } catch { - // Ignore — free mode status unknown - } -} - -function onDictationLanguageChange(nextValue: string): void { - const normalized = normalizeToWhisperLanguage(nextValue.trim()) - const value = normalized || 'auto' - dictationLanguage.value = value - window.localStorage.setItem(DICTATION_LANGUAGE_KEY, value) -} - -function loadDictationLanguagePref(): string { - if (typeof window === 'undefined') return 'auto' - const value = window.localStorage.getItem(DICTATION_LANGUAGE_KEY)?.trim() || 'auto' - const normalized = normalizeToWhisperLanguage(value) - return normalized || 'auto' -} - -function buildDictationLanguageOptions(): Array<{ value: string; label: string }> { - const options: Array<{ value: string; label: string }> = [{ value: 'auto', label: t('Auto-detect') }] - const seen = new Set(['auto']) - function formatLanguageLabel(value: string): string { - const languageName = WHISPER_LANGUAGES[value] || value - const title = languageName.charAt(0).toUpperCase() + languageName.slice(1) - return `${title} (${value})` - } - - for (const raw of typeof navigator !== 'undefined' ? (navigator.languages ?? []) : []) { - const value = normalizeToWhisperLanguage(raw) - if (!value || seen.has(value)) continue - seen.add(value) - options.push({ - value, - label: `Preferred: ${formatLanguageLabel(value)}`, - }) - } - - for (const value of Object.keys(WHISPER_LANGUAGES)) { - if (seen.has(value)) continue - seen.add(value) - options.push({ - value, - label: formatLanguageLabel(value), - }) - } - - const current = dictationLanguage.value.trim() - if (current && !seen.has(current)) { - options.push({ - value: current, - label: formatLanguageLabel(current), - }) - } - - return options -} - -function normalizeToWhisperLanguage(raw: string): string { - const value = raw.trim().toLowerCase() - if (!value || value === 'auto') return '' - if (value in WHISPER_LANGUAGES) return value - const base = value.split('-')[0] ?? value - if (base in WHISPER_LANGUAGES) return base - return '' -} - -function applyDarkMode(): void { - const root = document.documentElement - if (darkMode.value === 'dark') { - root.classList.add('dark') - } else if (darkMode.value === 'light') { - root.classList.remove('dark') - } else { - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches - root.classList.toggle('dark', prefersDark) - } -} - -function loadSidebarCollapsed(): boolean { - if (typeof window === 'undefined') return false - return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === '1' -} - -function saveSidebarCollapsed(value: boolean): void { - if (typeof window === 'undefined') return - window.localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, value ? '1' : '0') -} - -function loadAccountsSectionCollapsed(): boolean { - if (typeof window === 'undefined') return true - const value = window.localStorage.getItem(ACCOUNTS_SECTION_COLLAPSED_STORAGE_KEY) - if (value === null) return true - return value === '1' -} - -function toggleAccountsSectionCollapsed(): void { - isAccountsSectionCollapsed.value = !isAccountsSectionCollapsed.value - if (typeof window === 'undefined') return - window.localStorage.setItem( - ACCOUNTS_SECTION_COLLAPSED_STORAGE_KEY, - isAccountsSectionCollapsed.value ? '1' : '0', - ) -} - function normalizeMessageType(rawType: string | undefined, role: string): string { const normalized = (rawType ?? '').trim() if (normalized.length > 0) { @@ -3179,174 +1766,9 @@ function onSelectCollaborationMode(mode: 'default' | 'plan'): void { } async function initialize(): Promise { - await router.isReady() - - if (route.name === 'thread' && routeThreadId.value) { - primeSelectedThread(routeThreadId.value) - } - - await refreshAll({ - includeSelectedThreadMessages: route.name === 'thread', - }) - void loadAccountsState({ silent: true }) - await applyLaunchProjectPathFromUrl() - hasInitialized.value = true - await syncThreadSelectionWithRoute() - startPolling() + await initializeRouting() } -async function syncThreadSelectionWithRoute(): Promise { - if (isRouteSyncInProgress.value) { - hasPendingRouteSync = true - return - } - isRouteSyncInProgress.value = true - - try { - do { - hasPendingRouteSync = false - - if (route.name === 'home' || route.name === 'skills') { - if (selectedThreadId.value !== '') { - await selectThread('') - } - continue - } - - if (route.name === 'thread') { - const threadId = routeThreadId.value - if (!threadId) continue - - if (selectedThreadId.value !== threadId) { - await selectThread(threadId) - } else { - void ensureThreadMessagesLoaded(threadId, { silent: true }) - } - } - } while (hasPendingRouteSync) - - } finally { - isRouteSyncInProgress.value = false - } -} - -watch( - () => - [ - route.name, - routeThreadId.value, - isLoadingThreads.value, - selectedThreadId.value, - ] as const, - async () => { - if (!hasInitialized.value) return - await syncThreadSelectionWithRoute() - }, -) - -watch( - () => selectedThreadId.value, - async (threadId) => { - if (!hasInitialized.value) return - if (isRouteSyncInProgress.value) return - if (isHomeRoute.value || isSkillsRoute.value) return - - if (!threadId) { - if (route.name !== 'home') { - await router.replace({ name: 'home' }) - } - return - } - - if (route.name === 'thread' && routeThreadId.value === threadId) return - await router.replace({ name: 'thread', params: { threadId } }) - }, -) - -watch( - () => newThreadFolderOptions.value, - (options) => { - if (options.length === 0) { - newThreadCwd.value = '' - return - } - const hasSelected = options.some((option) => option.value === newThreadCwd.value) - if (!hasSelected) { - newThreadCwd.value = options[0].value - } - void refreshDefaultProjectName() - }, - { immediate: true }, -) - -watch( - () => newThreadCwd.value, - () => { - worktreeInitStatus.value = { phase: 'idle', title: '', message: '' } - void refreshDefaultProjectName() - }, -) - -watch( - () => [newThreadRuntime.value, newThreadCwd.value] as const, - ([runtime, cwd]) => { - if (runtime !== 'worktree') return - void loadWorktreeBranches(cwd) - }, - { immediate: true }, -) - -watch( - () => newThreadRuntime.value, - (runtime) => { - if (runtime === 'local') { - worktreeInitStatus.value = { phase: 'idle', title: '', message: '' } - const current = newThreadCwd.value.trim() - if (current && isWorktreePath(current)) { - const fallbackProjectName = selectedThread.value?.projectName ?? getPathLeafName(current) - const localCwd = resolvePreferredLocalCwd(fallbackProjectName, '') - if (localCwd) { - newThreadCwd.value = localCwd - } - } - return - } - void loadWorktreeBranches(newThreadCwd.value) - }, -) - -watch( - () => route.name, - (name) => { - if (name !== 'home') { - worktreeInitStatus.value = { phase: 'idle', title: '', message: '' } - } - if (name !== 'thread') { - isReviewPaneOpen.value = false - } - }, -) - -watch( - () => selectedThreadId.value, - () => { - worktreeInitStatus.value = { phase: 'idle', title: '', message: '' } - }, -) - -watch( - () => [route.name, composerCwd.value] as const, - ([routeName, cwd]) => { - if (routeName !== 'thread') { - threadBranchOptions.value = [] - currentThreadBranch.value = null - return - } - void loadThreadBranches(cwd) - }, - { immediate: true }, -) - watch( pageTitle, (value) => { @@ -3356,51 +1778,6 @@ watch( { immediate: true }, ) - -watch(isMobile, (mobile) => { - if (mobile && !isSidebarCollapsed.value) { - setSidebarCollapsed(true) - } -}, { immediate: true }) - -async function submitFirstMessageForNewThread( - text: string, - imageUrls: string[] = [], - skills: Array<{ name: string; path: string }> = [], - fileAttachments: Array<{ label: string; path: string; fsPath: string }> = [], -): Promise { - try { - worktreeInitStatus.value = { phase: 'idle', title: '', message: '' } - let targetCwd = newThreadCwd.value - if (newThreadRuntime.value === 'worktree') { - worktreeInitStatus.value = { - phase: 'running', - title: t('Creating worktree'), - message: t('Creating a worktree and running setup.'), - } - try { - const created = await createWorktree(newThreadCwd.value, newWorktreeBaseBranch.value) - targetCwd = created.cwd - newThreadCwd.value = created.cwd - worktreeInitStatus.value = { phase: 'idle', title: '', message: '' } - } catch { - worktreeInitStatus.value = { - phase: 'error', - title: t('Worktree setup failed'), - message: t('Unable to create worktree. Try again or switch to Local project.'), - } - return - } - } - const threadId = await sendMessageToNewThread(text, targetCwd, imageUrls, skills, fileAttachments) - if (!threadId) return - await router.replace({ name: 'thread', params: { threadId } }) - scheduleMobileConversationJumpToLatest() - } catch { - // Error is already reflected in state. - } -} - function buildDirectoryTryPrompt(payload: DirectoryTryItemPayload): string { if (payload.prompt?.trim()) return payload.prompt.trim() const label = payload.displayName.trim() || payload.name.trim() @@ -3440,31 +1817,6 @@ async function onTryDirectoryItem(payload: DirectoryTryItemPayload): Promise { - const normalizedSourceCwd = sourceCwd.trim() - if (!normalizedSourceCwd) { - worktreeBranchOptions.value = [] - newWorktreeBaseBranch.value = '' - return - } - - isLoadingWorktreeBranches.value = true - try { - const options = await getWorktreeBranchOptions(normalizedSourceCwd) - worktreeBranchOptions.value = options - const currentSelection = newWorktreeBaseBranch.value.trim() - const hasCurrentSelection = currentSelection.length > 0 && options.some((option) => option.value === currentSelection) - if (!hasCurrentSelection) { - const preferredMainOption = options.find((option) => option.value.trim() === 'main') - newWorktreeBaseBranch.value = preferredMainOption?.value ?? options[0]?.value ?? '' - } - } catch { - worktreeBranchOptions.value = [] - newWorktreeBaseBranch.value = '' - } finally { - isLoadingWorktreeBranches.value = false - } -}