From 9c9c28a552d86209cc66fc75fd52eaa3d1d3bba0 Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Sat, 8 Aug 2026 19:08:42 +0530 Subject: [PATCH 01/37] feat(git): support nested git repositories in the Git tab When the project root is not itself a git repository, discover nested repositories (depth- and visit-capped readdir walk via a new /api/fs/git-dirs route), auto-select the first one, and show a repository picker next to the branch dropdown to switch. Selections persist per runtime and root; discovery failure is a distinct marker with a retry action, never an empty success. --- packages/ui/src/components/views/GitView.tsx | 489 +++++++++++------- .../ui/src/components/views/git/GitHeader.tsx | 54 +- packages/ui/src/lib/gitApiHttp.ts | 17 + packages/ui/src/lib/i18n/messages/de.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + packages/ui/src/stores/DOCUMENTATION.md | 1 + packages/ui/src/stores/useGitStore.test.ts | 58 +++ packages/ui/src/stores/useGitStore.ts | 165 +++++- packages/web/server/lib/fs/DOCUMENTATION.md | 4 + packages/web/server/lib/fs/routes.js | 129 +++++ packages/web/server/lib/fs/routes.test.js | 219 ++++++++ 20 files changed, 989 insertions(+), 191 deletions(-) diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 28fcf9f1..99e9ac46 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -17,6 +17,9 @@ import { useIsGitRepo, useGitLoadingStatus, useGitLoadingLog, + useEffectiveGitDirectory, + useNestedRepos, + useNestedRepoSelection, } from '@/stores/useGitStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; @@ -249,8 +252,16 @@ export const GitView: React.FC = ({ isActive }) => { loadDefaultGitIdentityId: s.loadDefaultGitIdentityId, }))); - const isGitRepo = useIsGitRepo(currentDirectory ?? null); - const status = useGitStatus(currentDirectory ?? null); + // The root the view is anchored to (session/worktree context stays keyed on + // it). When the root is not itself a repository and the user picked a nested + // one, `gitDirectory` is the effective repository all git data and actions + // operate on. + const rootIsGitRepo = useIsGitRepo(currentDirectory ?? null); + const gitDirectory = useEffectiveGitDirectory(currentDirectory ?? null); + const isGitRepo = useIsGitRepo(gitDirectory ?? null); + const status = useGitStatus(gitDirectory ?? null); + const nestedRepos = useNestedRepos(currentDirectory ?? null); + const nestedRepoSelection = useNestedRepoSelection(currentDirectory ?? null); // Authoritative session↔worktree attachment for repair action display const worktreeAttachment = useSessionWorktreeStore((s) => @@ -265,11 +276,11 @@ export const GitView: React.FC = ({ isActive }) => { : undefined; const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined); - const branches = useGitBranches(currentDirectory ?? null); - const log = useGitLog(currentDirectory ?? null); - const currentIdentity = useGitIdentity(currentDirectory ?? null); - const isLoading = useGitLoadingStatus(currentDirectory ?? null); - const isLogLoading = useGitLoadingLog(currentDirectory ?? null); + const branches = useGitBranches(gitDirectory ?? null); + const log = useGitLog(gitDirectory ?? null); + const currentIdentity = useGitIdentity(gitDirectory ?? null); + const isLoading = useGitLoadingStatus(gitDirectory ?? null); + const isLogLoading = useGitLoadingLog(gitDirectory ?? null); const { setActiveDirectory, fetchAll, @@ -284,6 +295,9 @@ export const GitView: React.FC = ({ isActive }) => { moveStatusPathsOptimistically, restoreStatus, bumpIndexRevision, + ensureNestedRepos, + selectNestedRepo, + clearNestedRepoSelection, } = useGitStore(useShallow((state) => ({ setActiveDirectory: state.setActiveDirectory, fetchAll: state.fetchAll, @@ -298,6 +312,9 @@ export const GitView: React.FC = ({ isActive }) => { moveStatusPathsOptimistically: state.moveStatusPathsOptimistically, restoreStatus: state.restoreStatus, bumpIndexRevision: state.bumpIndexRevision, + ensureNestedRepos: state.ensureNestedRepos, + selectNestedRepo: state.selectNestedRepo, + clearNestedRepoSelection: state.clearNestedRepoSelection, }))); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); @@ -305,10 +322,10 @@ export const GitView: React.FC = ({ isActive }) => { const prStatusBranch = status?.current ?? null; const prChipStatus = useGitHubPrStatusStore((state) => { - if (!currentDirectory || !prStatusBranch) { + if (!gitDirectory || !prStatusBranch) { return null; } - return getFreshestPrStatusForBranch(state.entries, currentDirectory, prStatusBranch); + return getFreshestPrStatusForBranch(state.entries, gitDirectory, prStatusBranch); }); const navigateToDiff = useUIStore((state) => state.navigateToDiff); @@ -332,12 +349,12 @@ export const GitView: React.FC = ({ isActive }) => { clearScheduledGitReconcile(); gitReconcileTimeoutRef.current = window.setTimeout(() => { gitReconcileTimeoutRef.current = null; - if (normalizePath(directory) !== normalizePath(currentDirectory)) { + if (normalizePath(directory) !== normalizePath(gitDirectory)) { return; } void fetchStatus(directory, git, { silent: true }); }, GIT_RECONCILE_DELAY_MS); - }, [clearScheduledGitReconcile, currentDirectory, fetchStatus, git]); + }, [clearScheduledGitReconcile, gitDirectory, fetchStatus, git]); React.useEffect(() => clearScheduledGitReconcile, [clearScheduledGitReconcile]); @@ -491,9 +508,9 @@ export const GitView: React.FC = ({ isActive }) => { const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap; const initialSnapshot = React.useMemo(() => { - if (!currentDirectory) return null; - return gitViewSnapshots.get(currentDirectory) ?? null; - }, [currentDirectory]); + if (!gitDirectory) return null; + return gitViewSnapshots.get(gitDirectory) ?? null; + }, [gitDirectory]); const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled); const [rootBranchHint, setRootBranchHint] = React.useState(null); @@ -663,7 +680,7 @@ export const GitView: React.FC = ({ isActive }) => { // Restore conflict state from localStorage on mount React.useEffect(() => { - if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return; + if (!conflictStorageKey || typeof window === 'undefined' || !gitDirectory) return; const raw = window.localStorage.getItem(conflictStorageKey); if (!raw) return; @@ -675,8 +692,8 @@ export const GitView: React.FC = ({ isActive }) => { operation: 'merge' | 'rebase'; }; - // Validate the stored state matches current directory - if (parsed.directory !== currentDirectory) { + // Validate the stored state matches the effective repository + if (parsed.directory !== gitDirectory) { window.localStorage.removeItem(conflictStorageKey); return; } @@ -688,7 +705,7 @@ export const GitView: React.FC = ({ isActive }) => { } catch { window.localStorage.removeItem(conflictStorageKey); } - }, [conflictStorageKey, currentDirectory]); + }, [conflictStorageKey, gitDirectory]); const [stashDialogOpen, setStashDialogOpen] = React.useState(false); const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge'); const [stashDialogBranch, setStashDialogBranch] = React.useState(''); @@ -724,7 +741,7 @@ export const GitView: React.FC = ({ isActive }) => { }, [loadingCommitHashes]); React.useEffect(() => { - if (!currentDirectory || !git) return; + if (!gitDirectory || !git) return; // Find hashes that are expanded but not yet loaded or loading const hashesToLoad = Array.from(expandedCommitHashes).filter( @@ -747,7 +764,7 @@ export const GitView: React.FC = ({ isActive }) => { void Promise.all( hashesToLoad.map((hash) => git - .getCommitFiles(currentDirectory, hash) + .getCommitFiles(gitDirectory, hash) .then((response) => ({ hash, files: response.files })) .catch((error) => { console.error('Failed to fetch commit files:', error); @@ -791,16 +808,26 @@ export const GitView: React.FC = ({ isActive }) => { return next; }); }; - }, [expandedCommitHashes, currentDirectory, git]); + }, [expandedCommitHashes, gitDirectory, git]); + + // Restore the per-repository draft when the effective repository changes + // (e.g. the user picks a different nested repository from the picker), + // mirroring the fresh-mount behavior of a directory switch. + React.useEffect(() => { + if (!gitDirectory) return; + const snapshot = gitViewSnapshots.get(gitDirectory) ?? null; + setCommitMessage(snapshot?.commitMessage ?? ''); + setGeneratedHighlights(snapshot?.generatedHighlights ?? []); + }, [gitDirectory]); React.useEffect(() => { - if (!currentDirectory) return; - rememberSnapshot(currentDirectory, { - directory: currentDirectory, + if (!gitDirectory) return; + rememberSnapshot(gitDirectory, { + directory: gitDirectory, commitMessage, generatedHighlights, }); - }, [commitMessage, currentDirectory, generatedHighlights]); + }, [commitMessage, gitDirectory, generatedHighlights]); React.useEffect(() => { if (!isActive) return; @@ -811,25 +838,25 @@ export const GitView: React.FC = ({ isActive }) => { React.useEffect(() => { if (!isActive) return; - if (!currentDirectory || !git?.getRemoteUrl) { + if (!gitDirectory || !git?.getRemoteUrl || isGitRepo !== true) { setRemoteUrl(null); return; } let cancelled = false; git - .getRemoteUrl(currentDirectory) + .getRemoteUrl(gitDirectory) .then((url) => { if (!cancelled) setRemoteUrl(url); }) .catch(() => { if (!cancelled) setRemoteUrl(null); }); return () => { cancelled = true; }; - }, [isActive, currentDirectory, git]); + }, [isActive, gitDirectory, git, isGitRepo]); const refreshRemotes = React.useCallback(async () => { - if (!currentDirectory || !git?.getRemotes) { + if (!gitDirectory || !git?.getRemotes || isGitRepo !== true) { setRemotes([]); return; } try { - const remoteList = await git.getRemotes(currentDirectory); + const remoteList = await git.getRemotes(gitDirectory); if (mountedRef.current) { setRemotes(remoteList); } @@ -838,7 +865,7 @@ export const GitView: React.FC = ({ isActive }) => { setRemotes([]); } } - }, [currentDirectory, git]); + }, [gitDirectory, git, isGitRepo]); React.useEffect(() => { if (!isActive) return; @@ -847,37 +874,68 @@ export const GitView: React.FC = ({ isActive }) => { React.useEffect(() => { if (!isActive) return; - if (currentDirectory) { + if (currentDirectory && gitDirectory) { setActiveDirectory(currentDirectory); - void ensureAll(currentDirectory, git); + void ensureAll(gitDirectory, git); } - }, [isActive, currentDirectory, setActiveDirectory, ensureAll, git]); + }, [isActive, currentDirectory, gitDirectory, setActiveDirectory, ensureAll, git]); React.useEffect(() => { if (!isActive) return; - if (!currentDirectory) { + if (!gitDirectory) { return; } return sessionEvents.onGitRefreshHint((hint) => { - if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) { + if (normalizePath(hint.directory) !== normalizePath(gitDirectory)) { return; } if (hint.paths?.length) { - clearDiffCache(currentDirectory, hint.paths); + clearDiffCache(gitDirectory, hint.paths); } - void fetchStatus(currentDirectory, git, { silent: true }); + void fetchStatus(gitDirectory, git, { silent: true }); }); - }, [isActive, clearDiffCache, currentDirectory, fetchStatus, git]); + }, [isActive, clearDiffCache, gitDirectory, fetchStatus, git]); + + // Discover nested repositories once the root probe confirms it is not one. + React.useEffect(() => { + if (!isActive) return; + if (!currentDirectory) return; + if (rootIsGitRepo !== false) return; + void ensureNestedRepos(currentDirectory); + }, [currentDirectory, ensureNestedRepos, isActive, rootIsGitRepo]); + + // Auto-select the first nested repository so the tab opens straight into + // repository data; the header picker switches between repositories. + React.useEffect(() => { + if (!isActive) return; + if (!currentDirectory) return; + if (rootIsGitRepo !== false) return; + if (!nestedRepos || nestedRepos.length === 0) return; + if (nestedRepoSelection) return; + selectNestedRepo(currentDirectory, nestedRepos[0]); + }, [currentDirectory, isActive, nestedRepos, nestedRepoSelection, rootIsGitRepo, selectNestedRepo]); + + // A selected repository that is no longer a git repository is stale: drop + // the selection and re-scan so the picker reflects the current tree. + React.useEffect(() => { + if (!isActive) return; + if (!currentDirectory) return; + if (!nestedRepoSelection) return; + if (gitDirectory === currentDirectory) return; + if (isGitRepo !== false) return; + clearNestedRepoSelection(currentDirectory); + void ensureNestedRepos(currentDirectory, { force: true }); + }, [clearNestedRepoSelection, currentDirectory, ensureNestedRepos, gitDirectory, isActive, isGitRepo, nestedRepoSelection]); const refreshStatusAndBranches = React.useCallback( async (showErrors = true) => { - if (!currentDirectory) return; + if (!gitDirectory) return; try { await Promise.all([ - fetchStatus(currentDirectory, git), - fetchBranches(currentDirectory, git), + fetchStatus(gitDirectory, git), + fetchBranches(gitDirectory, git), ]); } catch (err) { if (showErrors) { @@ -887,42 +945,42 @@ export const GitView: React.FC = ({ isActive }) => { } } }, - [currentDirectory, git, fetchStatus, fetchBranches, t] + [gitDirectory, git, fetchStatus, fetchBranches, t] ); const refreshLog = React.useCallback(async () => { - if (!currentDirectory) return; - await fetchLog(currentDirectory, git, logMaxCountLocal); - }, [currentDirectory, git, fetchLog, logMaxCountLocal]); + if (!gitDirectory) return; + await fetchLog(gitDirectory, git, logMaxCountLocal); + }, [gitDirectory, git, fetchLog, logMaxCountLocal]); const refreshIdentity = React.useCallback(async () => { - if (!currentDirectory) return; - await fetchIdentity(currentDirectory, git); - }, [currentDirectory, git, fetchIdentity]); + if (!gitDirectory) return; + await fetchIdentity(gitDirectory, git); + }, [gitDirectory, git, fetchIdentity]); React.useEffect(() => { if (!isActive) return; - if (!currentDirectory) return; + if (!gitDirectory) return; if (!git?.hasLocalIdentity) return; if (isGitRepo !== true) return; const defaultId = typeof defaultGitIdentityId === 'string' ? defaultGitIdentityId.trim() : ''; if (!defaultId || defaultId === 'global') return; - const previousAttempt = autoAppliedDefaultRef.current.get(currentDirectory); + const previousAttempt = autoAppliedDefaultRef.current.get(gitDirectory); if (previousAttempt === defaultId) return; let cancelled = false; const run = async () => { try { - const hasLocal = await git.hasLocalIdentity?.(currentDirectory); + const hasLocal = await git.hasLocalIdentity?.(gitDirectory); if (cancelled) return; if (hasLocal === true) return; beginIdentityApply(); - await git.setGitIdentity(currentDirectory, defaultId); - autoAppliedDefaultRef.current.set(currentDirectory, defaultId); + await git.setGitIdentity(gitDirectory, defaultId); + autoAppliedDefaultRef.current.set(gitDirectory, defaultId); await refreshIdentity(); } catch (error) { console.warn('Failed to auto-apply default git identity:', error); @@ -938,7 +996,7 @@ export const GitView: React.FC = ({ isActive }) => { return () => { cancelled = true; }; - }, [isActive, beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]); + }, [isActive, beginIdentityApply, gitDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]); const changeEntries = React.useMemo(() => { if (!status) return []; @@ -959,7 +1017,7 @@ export const GitView: React.FC = ({ isActive }) => { ); React.useEffect(() => { - if (!currentDirectory || changeEntries.length === 0) { + if (!gitDirectory || changeEntries.length === 0) { return; } @@ -983,13 +1041,13 @@ export const GitView: React.FC = ({ isActive }) => { } const timeoutId = window.setTimeout(() => { - void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT }); + void prefetchDiffs(gitDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT }); }, 120); return () => { window.clearTimeout(timeoutId); }; - }, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); + }, [changeEntries, gitDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); const getPushedRemoteName = (result?: Awaited>) => { return result?.pushed[0]?.remote @@ -1000,7 +1058,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleSyncAction = async (action: Exclude, remote?: GitRemote) => { - if (!currentDirectory) return; + if (!gitDirectory) return; setSyncAction(action); try { @@ -1020,20 +1078,20 @@ export const GitView: React.FC = ({ isActive }) => { if (!remote) { throw new Error('No remote available for fetch'); } - await git.gitFetch(currentDirectory, { remote: remote.name }); + await git.gitFetch(gitDirectory, { remote: remote.name }); toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name })); } else if (action === 'pull') { if (!remote) { throw new Error('No remote available for pull'); } - const result = await git.gitPull(currentDirectory, getPullOptions(remote)); + const result = await git.gitPull(gitDirectory, getPullOptions(remote)); toast.success( result.files.length === 1 ? t('gitView.toast.pulledFilesSingle', { count: result.files.length, name: remote.name }) : t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name }) ); } else if (action === 'push') { - const result = await git.gitPush(currentDirectory); + const result = await git.gitPush(gitDirectory); toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); } else if (action === 'sync') { if (!remote) { @@ -1041,21 +1099,21 @@ export const GitView: React.FC = ({ isActive }) => { } let pulledFileCount = 0; let pushedChanges = false; - await git.gitFetch(currentDirectory, { remote: remote.name }); - const afterFetch = await git.getGitStatus(currentDirectory); + await git.gitFetch(gitDirectory, { remote: remote.name }); + const afterFetch = await git.getGitStatus(gitDirectory); if ((afterFetch.behind ?? 0) > 0) { if ((afterFetch.files?.length ?? 0) > 0) { toast.error(t('gitView.toast.commitOrStashBeforeSync')); return; } - const pullResult = await git.gitPull(currentDirectory, getPullOptions(remote)); + const pullResult = await git.gitPull(gitDirectory, getPullOptions(remote)); pulledFileCount = pullResult.files.length; } - const afterPull = await git.getGitStatus(currentDirectory); + const afterPull = await git.getGitStatus(gitDirectory); if ((afterPull.ahead ?? 0) > 0) { - await git.gitPush(currentDirectory); + await git.gitPush(gitDirectory); pushedChanges = true; } if (pulledFileCount > 0 && pushedChanges) { @@ -1091,7 +1149,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleRemoveRemote = React.useCallback(async (remote: GitRemote) => { - if (!currentDirectory) return; + if (!gitDirectory) return; const remoteName = remote.name.trim(); if (!remoteName) { @@ -1105,7 +1163,7 @@ export const GitView: React.FC = ({ isActive }) => { setRemovingRemoteName(remoteName); try { - await git.removeRemote(currentDirectory, { remote: remoteName }); + await git.removeRemote(gitDirectory, { remote: remoteName }); toast.success(t('gitView.toast.removedRemote', { name: remoteName })); await Promise.all([ refreshStatusAndBranches(false), @@ -1117,10 +1175,10 @@ export const GitView: React.FC = ({ isActive }) => { } finally { setRemovingRemoteName(null); } - }, [currentDirectory, git, refreshRemotes, refreshStatusAndBranches, t]); + }, [gitDirectory, git, refreshRemotes, refreshStatusAndBranches, t]); const handleCommit = async (options: { pushAfter?: boolean } = {}) => { - if (!currentDirectory) return; + if (!gitDirectory) return; if (!commitMessage.trim()) { toast.error(t('gitView.toast.enterCommitMessage')); return; @@ -1136,11 +1194,11 @@ export const GitView: React.FC = ({ isActive }) => { setCommitAction(action); try { - await git.createGitCommit(currentDirectory, commitMessage.trim(), { + await git.createGitCommit(gitDirectory, commitMessage.trim(), { files: filesToCommit, stageFiles: [], }); - bumpIndexRevision(currentDirectory); + bumpIndexRevision(gitDirectory); toast.success(t('gitView.toast.commitCreated')); setCommitMessage(''); clearGeneratedHighlights(); @@ -1160,21 +1218,21 @@ export const GitView: React.FC = ({ isActive }) => { ? status.tracking.slice(trackingPrefix.length) : undefined; - await git.gitFetch(currentDirectory, { remote: remote.name }); - const afterFetch = await git.getGitStatus(currentDirectory); + await git.gitFetch(gitDirectory, { remote: remote.name }); + const afterFetch = await git.getGitStatus(gitDirectory); if ((afterFetch.behind ?? 0) > 0) { if ((afterFetch.files?.length ?? 0) > 0) { toast.error(t('gitView.toast.commitOrStashBeforeSync')); await refreshStatusAndBranches(false); return; } - await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true }); + await git.gitPull(gitDirectory, { remote: remote.name, branch: trackedBranch, rebase: true }); } - const afterPull = await git.getGitStatus(currentDirectory); + const afterPull = await git.getGitStatus(gitDirectory); let result: Awaited> | undefined; if ((afterPull.ahead ?? 0) > 0) { - result = await git.gitPush(currentDirectory); + result = await git.gitPush(gitDirectory); } toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); triggerFireworks(); @@ -1197,7 +1255,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleGenerateCommitMessage = React.useCallback(async () => { - if (!currentDirectory) return; + if (!gitDirectory) return; const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort(); if (selectedFilePaths.length === 0) { toast.error(t('gitView.toast.stageFileToDescribe')); @@ -1205,13 +1263,13 @@ export const GitView: React.FC = ({ isActive }) => { } console.error('[git-generation][browser] generate button clicked', { - directory: currentDirectory, + directory: gitDirectory, selectedFiles: selectedFilePaths.length, }); setIsGeneratingMessage(true); try { - const { message } = await generateSessionCommitMessage(currentDirectory, selectedFilePaths); + const { message } = await generateSessionCommitMessage(gitDirectory, selectedFilePaths); const subject = message.subject?.trim() ?? ''; const highlights = Array.isArray(message.highlights) ? message.highlights : []; @@ -1242,7 +1300,7 @@ export const GitView: React.FC = ({ isActive }) => { } finally { setIsGeneratingMessage(false); } - }, [currentDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]); + }, [gitDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]); const formatBlockingReason = (reason: ReturnType[number]): string => { if (reason.reason === 'attention') { @@ -1255,7 +1313,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleCreateBranch = async (branchName: string, remote?: GitRemote) => { - if (!currentDirectory || !status) return; + if (!gitDirectory || !status) return; const blockingReasons = getMutationBlockingReasons(worktreeAttachment); if (blockingReasons.length > 0) { @@ -1267,15 +1325,15 @@ export const GitView: React.FC = ({ isActive }) => { const remoteName = remote?.name ?? 'origin'; try { - await git.createBranch(currentDirectory, branchName, checkoutBase ?? 'HEAD'); + await git.createBranch(gitDirectory, branchName, checkoutBase ?? 'HEAD'); toast.success(t('gitView.toast.createdBranch', { name: branchName })); // Checkout the new branch and stay on it - await git.checkoutBranch(currentDirectory, branchName); + await git.checkoutBranch(gitDirectory, branchName); let pushSucceeded = false; try { - await git.gitPush(currentDirectory, { + await git.gitPush(gitDirectory, { remote: remoteName, branch: branchName, options: ['--set-upstream'], @@ -1309,7 +1367,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleRenameBranch = async (oldName: string, newName: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; const blockingReasons = getMutationBlockingReasons(worktreeAttachment); if (blockingReasons.length > 0) { @@ -1318,7 +1376,7 @@ export const GitView: React.FC = ({ isActive }) => { } try { - await git.renameBranch(currentDirectory, oldName, newName); + await git.renameBranch(gitDirectory, oldName, newName); toast.success(t('gitView.toast.renamedBranch', { oldName, newName })); await refreshStatusAndBranches(); await refreshLog(); @@ -1330,7 +1388,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleCheckoutBranch = async (branch: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; // Block mutation if worktree is in an attention-required state const blockingReasons = getMutationBlockingReasons(worktreeAttachment); @@ -1346,7 +1404,7 @@ export const GitView: React.FC = ({ isActive }) => { } try { - await git.checkoutBranch(currentDirectory, normalized); + await git.checkoutBranch(gitDirectory, normalized); toast.success(t('gitView.toast.checkedOut', { name: normalized })); await refreshStatusAndBranches(); await refreshLog(); @@ -1358,11 +1416,11 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleApplyIdentity = async (profile: GitIdentityProfile) => { - if (!currentDirectory) return; + if (!gitDirectory) return; beginIdentityApply(); try { - await git.setGitIdentity(currentDirectory, profile.id); + await git.setGitIdentity(gitDirectory, profile.id); toast.success(t('gitView.toast.appliedIdentity', { name: profile.name })); await refreshIdentity(); } catch (err) { @@ -1545,7 +1603,7 @@ export const GitView: React.FC = ({ isActive }) => { : null; React.useEffect(() => { - if (!currentDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) { + if (!gitDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) { setHistoryBranchDivider(null); return; } @@ -1554,7 +1612,7 @@ export const GitView: React.FC = ({ isActive }) => { const resolveBranchDivider = async () => { try { - const branchOnlyLog = await git.getGitLog(currentDirectory, { + const branchOnlyLog = await git.getGitLog(gitDirectory, { from: baseBranch, to: 'HEAD', maxCount: logMaxCountLocal, @@ -1607,21 +1665,21 @@ export const GitView: React.FC = ({ isActive }) => { return () => { cancelled = true; }; - }, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]); + }, [baseBranch, currentBranch, gitDirectory, git, log, logMaxCountLocal]); // Clear graph log when directory changes React.useEffect(() => { setGraphLog(null); - }, [currentDirectory]); + }, [gitDirectory]); React.useEffect(() => { - if (gitLogDialogMode !== 'graph' || !currentDirectory) { + if (gitLogDialogMode !== 'graph' || !gitDirectory) { if (gitLogDialogMode !== 'graph') setGraphLog(null); return; } let cancelled = false; setGraphLogLoading(true); - git.getGitLog(currentDirectory, { maxCount: graphLogMaxCount, all: true }) + git.getGitLog(gitDirectory, { maxCount: graphLogMaxCount, all: true }) .then((result) => { if (!cancelled) setGraphLog(result); }) @@ -1632,33 +1690,33 @@ export const GitView: React.FC = ({ isActive }) => { if (!cancelled) setGraphLogLoading(false); }); return () => { cancelled = true; }; - }, [gitLogDialogMode, currentDirectory, graphLogMaxCount, graphLogRefreshToken, git]); + }, [gitLogDialogMode, gitDirectory, graphLogMaxCount, graphLogRefreshToken, git]); // Keep these sections stable in layout; individual cards render placeholders when unavailable. const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => { - if (!currentDirectory || paths.length === 0) return; + if (!gitDirectory || paths.length === 0) return; const uniquePaths = Array.from(new Set(paths)); setMovingChangePaths((previous) => { const next = new Set(previous); uniquePaths.forEach((path) => next.add(path)); return next; }); - const previousStatus = moveStatusPathsOptimistically(currentDirectory, uniquePaths, direction); + const previousStatus = moveStatusPathsOptimistically(gitDirectory, uniquePaths, direction); gitIndexMutationQueue.enqueue({ - directory: currentDirectory, + directory: gitDirectory, direction, paths: new Set(uniquePaths), - rollback: () => restoreStatus(currentDirectory, previousStatus), + rollback: () => restoreStatus(gitDirectory, previousStatus), }); scheduleGitMutationFlush(); - }, [currentDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]); + }, [gitDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]); const handleRevertFile = React.useCallback( async (filePath: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; setRevertingPaths((previous) => { const next = new Set(previous); @@ -1667,7 +1725,7 @@ export const GitView: React.FC = ({ isActive }) => { }); try { - await git.revertGitFile(currentDirectory, filePath, { scope: 'working' }); + await git.revertGitFile(gitDirectory, filePath, { scope: 'working' }); toast.success(t('gitView.toast.revertedFile', { path: filePath })); await refreshStatusAndBranches(false); } catch (err) { @@ -1681,12 +1739,12 @@ export const GitView: React.FC = ({ isActive }) => { }); } }, - [currentDirectory, refreshStatusAndBranches, git, t] + [gitDirectory, refreshStatusAndBranches, git, t] ); const handleRevertPaths = React.useCallback( async (paths: string[], setGlobalReverting: boolean, scope: 'all' | 'working' = 'all') => { - if (!currentDirectory || paths.length === 0) { + if (!gitDirectory || paths.length === 0) { return; } @@ -1712,7 +1770,7 @@ export const GitView: React.FC = ({ isActive }) => { try { await Promise.all(uniquePaths.map(async (filePath) => { try { - await git.revertGitFile(currentDirectory, filePath, { scope }); + await git.revertGitFile(gitDirectory, filePath, { scope }); } catch (err) { failed.push({ path: filePath, @@ -1722,7 +1780,7 @@ export const GitView: React.FC = ({ isActive }) => { })); if (touchesStagedIndex && failed.length < uniquePaths.length) { - bumpIndexRevision(currentDirectory); + bumpIndexRevision(gitDirectory); } await refreshStatusAndBranches(false); @@ -1754,7 +1812,7 @@ export const GitView: React.FC = ({ isActive }) => { } } }, - [bumpIndexRevision, currentDirectory, git, isRevertingAll, refreshStatusAndBranches, revertingPaths, stagedChangeEntries, t] + [bumpIndexRevision, gitDirectory, git, isRevertingAll, refreshStatusAndBranches, revertingPaths, stagedChangeEntries, t] ); const handleRevertAll = React.useCallback( @@ -1772,12 +1830,12 @@ export const GitView: React.FC = ({ isActive }) => { ); const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => { - if (currentDirectory && !isMobile) { - openContextDiff(currentDirectory, path, staged); + if (gitDirectory && !isMobile) { + openContextDiff(gitDirectory, path, staged); return; } navigateToDiff(path, staged); - }, [currentDirectory, isMobile, navigateToDiff, openContextDiff]); + }, [gitDirectory, isMobile, navigateToDiff, openContextDiff]); const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []); @@ -1925,7 +1983,7 @@ export const GitView: React.FC = ({ isActive }) => { const handleMerge = React.useCallback( async (branch: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; setBranchOperation('merge'); resetOperationLogs(); @@ -1936,19 +1994,19 @@ export const GitView: React.FC = ({ isActive }) => { try { if (target.remote && target.remoteBranch) { addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running'); - await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch }); + await git.gitFetch(gitDirectory, { remote: target.remote, branch: target.remoteBranch }); updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`); } addOperationLog(`Merging ${target.branch} into ${currentBranch}...`, 'running'); - const result = await git.merge(currentDirectory, { branch: target.branch }); + const result = await git.merge(gitDirectory, { branch: target.branch }); if (result.conflict) { updateLastLog('error', `Merge conflicts detected`); setConflictFiles(result.conflictFiles ?? []); setConflictOperation('merge'); setConflictDialogOpen(true); - persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge'); + persistConflictState(gitDirectory, result.conflictFiles ?? [], 'merge'); } else { updateLastLog('done', `Merged ${target.branch} into ${currentBranch}`); clearConflictState(); @@ -1970,12 +2028,12 @@ export const GitView: React.FC = ({ isActive }) => { } // Note: branchOperation is cleared when dialog closes via handleOperationComplete }, - [currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] + [gitDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] ); const handleRebase = React.useCallback( async (branch: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; setBranchOperation('rebase'); resetOperationLogs(); @@ -1986,19 +2044,19 @@ export const GitView: React.FC = ({ isActive }) => { try { if (target.remote && target.remoteBranch) { addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running'); - await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch }); + await git.gitFetch(gitDirectory, { remote: target.remote, branch: target.remoteBranch }); updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`); } addOperationLog(`Rebasing ${currentBranch} onto ${target.branch}...`, 'running'); - const result = await git.rebase(currentDirectory, { onto: target.branch }); + const result = await git.rebase(gitDirectory, { onto: target.branch }); if (result.conflict) { updateLastLog('error', `Rebase conflicts detected`); setConflictFiles(result.conflictFiles ?? []); setConflictOperation('rebase'); setConflictDialogOpen(true); - persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase'); + persistConflictState(gitDirectory, result.conflictFiles ?? [], 'rebase'); } else { updateLastLog('done', `Rebased ${currentBranch} onto ${target.branch}`); clearConflictState(); @@ -2020,18 +2078,18 @@ export const GitView: React.FC = ({ isActive }) => { } // Note: branchOperation is cleared when dialog closes via handleOperationComplete }, - [currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] + [gitDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] ); const handleAbortConflict = React.useCallback(async () => { - if (!currentDirectory) return; + if (!gitDirectory) return; try { if (conflictOperation === 'merge') { - await git.abortMerge(currentDirectory); + await git.abortMerge(gitDirectory); toast.success(t('gitView.toast.mergeAborted')); } else { - await git.abortRebase(currentDirectory); + await git.abortRebase(gitDirectory); toast.success(t('gitView.toast.rebaseAborted')); } clearConflictState(); @@ -2041,7 +2099,7 @@ export const GitView: React.FC = ({ isActive }) => { const message = err instanceof Error ? err.message : `Failed to abort ${conflictOperation}`; toast.error(message); } - }, [currentDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState, t]); + }, [gitDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState, t]); // Count unresolved conflicts (files with 'U' status) const conflictCount = React.useMemo(() => { @@ -2054,19 +2112,19 @@ export const GitView: React.FC = ({ isActive }) => { }, [status?.files]); const handleContinueOperation = React.useCallback(async () => { - if (!currentDirectory) return; + if (!gitDirectory) return; try { const isMerge = !!status?.mergeInProgress?.head; const isRebase = !!(status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto); if (isMerge) { - const result = await git.continueMerge(currentDirectory); + const result = await git.continueMerge(gitDirectory); if (result.conflict) { setConflictFiles(result.conflictFiles ?? []); setConflictOperation('merge'); setConflictDialogOpen(true); - persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge'); + persistConflictState(gitDirectory, result.conflictFiles ?? [], 'merge'); toast.error(t('gitView.toast.mergeConflictsDetected')); } else { clearConflictState(); @@ -2075,12 +2133,12 @@ export const GitView: React.FC = ({ isActive }) => { await refreshLog(); } } else if (isRebase) { - const result = await git.continueRebase(currentDirectory); + const result = await git.continueRebase(gitDirectory); if (result.conflict) { setConflictFiles(result.conflictFiles ?? []); setConflictOperation('rebase'); setConflictDialogOpen(true); - persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase'); + persistConflictState(gitDirectory, result.conflictFiles ?? [], 'rebase'); toast.error(t('gitView.toast.rebaseConflictsDetected')); } else { clearConflictState(); @@ -2093,18 +2151,18 @@ export const GitView: React.FC = ({ isActive }) => { const message = err instanceof Error ? err.message : t('gitView.toast.continueOperationFailed'); toast.error(message); } - }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState, t]); + }, [gitDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState, t]); const handleAbortOperation = React.useCallback(async () => { - if (!currentDirectory) return; + if (!gitDirectory) return; try { const isMerge = !!status?.mergeInProgress?.head; if (isMerge) { - await git.abortMerge(currentDirectory); + await git.abortMerge(gitDirectory); toast.success(t('gitView.toast.mergeAborted')); } else { - await git.abortRebase(currentDirectory); + await git.abortRebase(gitDirectory); toast.success(t('gitView.toast.rebaseAborted')); } clearConflictState(); @@ -2114,10 +2172,10 @@ export const GitView: React.FC = ({ isActive }) => { const message = err instanceof Error ? err.message : t('gitView.toast.abortOperationFailed'); toast.error(message); } - }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState, t]); + }, [gitDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState, t]); const handleResolveWithAIFromBanner = React.useCallback(() => { - if (!currentDirectory) return; + if (!gitDirectory) return; // Determine operation type from status const isMerge = !!status?.mergeInProgress?.head; @@ -2134,11 +2192,11 @@ export const GitView: React.FC = ({ isActive }) => { } setConflictOperation(operation); setConflictDialogOpen(true); - }, [currentDirectory, status]); + }, [gitDirectory, status]); const handleStashAndRetry = React.useCallback( async (restoreAfter: boolean) => { - if (!currentDirectory) return; + if (!gitDirectory) return; const currentBranch = status?.current; const operation = stashDialogOperation; @@ -2147,12 +2205,12 @@ export const GitView: React.FC = ({ isActive }) => { // Stash changes try { - await git.stash(currentDirectory, { + await git.stash(gitDirectory, { message: `Auto-stash before ${operation} with ${branch}`, includeUntracked: true, }); if (hadStagedChanges) { - bumpIndexRevision(currentDirectory); + bumpIndexRevision(gitDirectory); } } catch (stashErr) { const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes'; @@ -2166,7 +2224,7 @@ export const GitView: React.FC = ({ isActive }) => { try { // Perform the operation if (operation === 'merge') { - const result = await git.merge(currentDirectory, { branch }); + const result = await git.merge(gitDirectory, { branch }); if (result.conflict) { hasConflict = true; setConflictFiles(result.conflictFiles ?? []); @@ -2177,7 +2235,7 @@ export const GitView: React.FC = ({ isActive }) => { toast.success(t('gitView.toast.mergedIntoBranch', { branch, currentBranch: currentBranch || '' })); } } else { - const result = await git.rebase(currentDirectory, { onto: branch }); + const result = await git.rebase(gitDirectory, { onto: branch }); if (result.conflict) { hasConflict = true; setConflictFiles(result.conflictFiles ?? []); @@ -2192,8 +2250,8 @@ export const GitView: React.FC = ({ isActive }) => { // Restore stashed changes if requested and operation succeeded if (restoreAfter && operationSucceeded) { try { - await git.stashPop(currentDirectory); - bumpIndexRevision(currentDirectory); + await git.stashPop(gitDirectory); + bumpIndexRevision(gitDirectory); toast.success(t('gitView.toast.stashedRestored')); } catch (popErr) { const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed'); @@ -2209,8 +2267,8 @@ export const GitView: React.FC = ({ isActive }) => { // If the operation failed (not due to conflicts), try to restore stash if (restoreAfter) { try { - await git.stashPop(currentDirectory); - bumpIndexRevision(currentDirectory); + await git.stashPop(gitDirectory); + bumpIndexRevision(gitDirectory); } catch { // Ignore stash pop errors in this case } @@ -2218,18 +2276,18 @@ export const GitView: React.FC = ({ isActive }) => { throw err; } }, - [bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t] + [bumpIndexRevision, gitDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t] ); const handleLogMaxCountChange = React.useCallback( (count: number) => { setLogMaxCountLocal(count); - if (currentDirectory) { - setLogMaxCount(currentDirectory, count); - fetchLog(currentDirectory, git, count); + if (gitDirectory) { + setLogMaxCount(gitDirectory, count); + fetchLog(gitDirectory, git, count); } }, - [currentDirectory, fetchLog, git, setLogMaxCount] + [gitDirectory, fetchLog, git, setLogMaxCount] ); const handleGraphLogMaxCountChange = React.useCallback((count: number) => { @@ -2238,12 +2296,12 @@ export const GitView: React.FC = ({ isActive }) => { const handleGraphActionSuccess = React.useCallback(() => { setGitLogDialogMode(null); - if (currentDirectory) { - fetchStatus(currentDirectory, git); - fetchBranches(currentDirectory, git); - fetchLog(currentDirectory, git, logMaxCountLocal); + if (gitDirectory) { + fetchStatus(gitDirectory, git); + fetchBranches(gitDirectory, git); + fetchLog(gitDirectory, git, logMaxCountLocal); } - }, [currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); + }, [gitDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); const handleGraphConflict = React.useCallback((result: { conflict: boolean; @@ -2260,10 +2318,10 @@ export const GitView: React.FC = ({ isActive }) => { files: result.conflictFiles?.join(', ') ?? 'unknown files', }), }); - if (currentDirectory) { - fetchStatus(currentDirectory, git); - fetchBranches(currentDirectory, git); - fetchLog(currentDirectory, git, logMaxCountLocal); + if (gitDirectory) { + fetchStatus(gitDirectory, git); + fetchBranches(gitDirectory, git); + fetchLog(gitDirectory, git, logMaxCountLocal); } return; } @@ -2271,10 +2329,10 @@ export const GitView: React.FC = ({ isActive }) => { setConflictFiles(result.conflictFiles ?? []); setConflictOperation(result.operation); setConflictDialogOpen(true); - if (currentDirectory) { - persistConflictState(currentDirectory, result.conflictFiles ?? [], result.operation); + if (gitDirectory) { + persistConflictState(gitDirectory, result.conflictFiles ?? [], result.operation); } - }, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); + }, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, gitDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); if (!currentDirectory) { return ( @@ -2312,20 +2370,65 @@ export const GitView: React.FC = ({ isActive }) => { ); } + // Nested repository discovery: while unknown or failed keep a loading + // state with the failure signal; the picker appears once repositories are + // found (a single repository is auto-selected by an effect above). + if (nestedRepos === undefined || nestedRepos === null) { + return ( +
+ +

+ {nestedRepos === null + ? t('gitView.empty.discoverFailed') + : t('gitView.empty.discoveringRepositories')} +

+ {nestedRepos === null ? ( + + ) : null} +
+ ); + } + + if (nestedRepos.length === 0) { + return ( +
+ +

+ {t('gitView.empty.notGitRepository')} +

+

+ {t('gitView.empty.notGitRepositoryDescription')} +

+ {repairActions.includes('open-without-worktree-features') ? ( +

+ {t('gitView.empty.worktreeFeaturesUnavailable')} +

+ ) : null} +
+ ); + } + + // Repositories were found and are about to be auto-selected (or the + // selected repository is still probing) — hold a brief loading state. return (
- +

- {t('gitView.empty.notGitRepository')} + {t('gitView.loading.checkingRepository')}

-

- {t('gitView.empty.notGitRepositoryDescription')} -

- {repairActions.includes('open-without-worktree-features') ? ( -

- {t('gitView.empty.worktreeFeaturesUnavailable')} -

- ) : null}
); } @@ -2359,8 +2462,16 @@ export const GitView: React.FC = ({ isActive }) => { pullRequest={prChipStatus?.pr ?? null} prChecks={prChipStatus?.checks ?? null} onOpenPullRequest={ - currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined + gitDirectory ? () => openContextSurface(gitDirectory, 'pr') : undefined } + repositoryOptions={gitDirectory !== currentDirectory ? (nestedRepos ?? undefined) : undefined} + selectedRepository={gitDirectory !== currentDirectory ? gitDirectory : null} + onSelectRepository={ + gitDirectory !== currentDirectory && currentDirectory + ? (repository) => selectNestedRepo(currentDirectory, repository) + : undefined + } + repositoryRoot={gitDirectory !== currentDirectory ? currentDirectory : undefined} /> {/* In-progress operation banner */} @@ -2493,10 +2604,10 @@ export const GitView: React.FC = ({ isActive }) => { refreshKey={integrateRefreshKey} showHeader={false} onRefresh={() => { - if (!currentDirectory) return; - fetchStatus(currentDirectory, git); - fetchBranches(currentDirectory, git); - fetchLog(currentDirectory, git, logMaxCountLocal); + if (!gitDirectory) return; + fetchStatus(gitDirectory, git); + fetchBranches(gitDirectory, git); + fetchLog(gitDirectory, git, logMaxCountLocal); }} /> ) : null} @@ -2520,8 +2631,8 @@ export const GitView: React.FC = ({ isActive }) => { setGraphLogRefreshToken((token) => token + 1); return; } - if (!currentDirectory) return; - void fetchLog(currentDirectory, git, logMaxCountLocal); + if (!gitDirectory) return; + void fetchLog(gitDirectory, git, logMaxCountLocal); }} disabled={gitLogDialogMode === 'graph' ? graphLogLoading : isLogLoading} title={t('gitView.history.refresh')} @@ -2553,7 +2664,7 @@ export const GitView: React.FC = ({ isActive }) => { commitFilesMap={commitFilesMap} loadingCommitHashes={loadingCommitHashes} onCopyHash={handleCopyCommitHash} - directory={currentDirectory ?? undefined} + directory={gitDirectory ?? undefined} showHeader={false} contentMaxHeightClassName="h-full max-h-none" branchDivider={gitLogDialogMode === 'graph' ? null : historyBranchDivider} @@ -2567,13 +2678,13 @@ export const GitView: React.FC = ({ isActive }) => { 0} hasStagedChanges={stagedChangeEntries.length > 0} uncommittedFileCount={status?.files?.length ?? 0} onChanged={async (change) => { - if (currentDirectory && change?.affectsIndex) { - bumpIndexRevision(currentDirectory); + if (gitDirectory && change?.affectsIndex) { + bumpIndexRevision(gitDirectory); } await refreshStatusAndBranches(false); await refreshLog(); @@ -2621,12 +2732,12 @@ export const GitView: React.FC = ({ isActive }) => { - {currentDirectory && ( + {gitDirectory && ( void; + // Nested repository picker: shown when the Git tab operates on a repository + // nested inside a non-repository root. Options are absolute repository + // paths; `repositoryRoot` is the root those paths are relative to. + repositoryOptions?: string[]; + selectedRepository?: string | null; + onSelectRepository?: (repository: string) => void; + repositoryRoot?: string; } const IDENTITY_ICON_MAP: Record = { @@ -258,12 +271,23 @@ export const GitHeader: React.FC = ({ pullRequest, prChecks, onOpenPullRequest, + repositoryOptions, + selectedRepository, + onSelectRepository, + repositoryRoot, }) => { const { t } = useI18n(); if (!status) { return null; } + const repositoryOptionsForPicker = (repositoryOptions ?? []).filter(Boolean); + const repositoryRelativePath = (repository: string): string => { + const rootPrefix = `${repositoryRoot ?? ''}/`; + return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository; + }; + const repositoryLabel = selectedRepository ? repositoryRelativePath(selectedRepository) : ''; + const managementButtons = (
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? ( @@ -410,7 +434,7 @@ export const GitHeader: React.FC = ({ return (
-
+
{isWorktreeMode ? ( = ({ remotes={remotes} /> )} + {repositoryOptionsForPicker.length > 0 ? ( + + ) : null}
{identityControl} diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 24317b24..24aeda2a 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -115,6 +115,23 @@ export async function checkIsGitRepository(directory: string): Promise } } +export async function listGitDirectories(root: string): Promise { + const response = await runtimeFetch('/api/fs/git-dirs', { query: { path: root } }); + if (!response.ok) { + throw new Error(`Failed to list git directories: ${response.statusText}`); + } + const data = await response.json(); + if (!data || !Array.isArray(data.repositories)) { + throw new Error('Unexpected git directories response'); + } + return data.repositories + .map((entry: unknown) => { + const path = entry && typeof entry === 'object' && 'path' in entry ? (entry as { path?: unknown }).path : undefined; + return typeof path === 'string' && path.trim() ? path.trim() : null; + }) + .filter((path: string | null): path is string => path !== null); +} + export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { const mode = options?.mode; const runtimeKey = getRuntimeKey(); diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index d26bc6c7..ebf16346 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -859,6 +859,10 @@ export const dict = { 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree-Funktionen sind in diesem Arbeitsbereichsmodus nicht verfügbar.', 'gitView.empty.worktreeSetupDescription': 'Arbeitstruktur-Einrichtung wird abgeschlossen und Repository-Zustand wird vorbereitet.', 'gitView.empty.worktreeSetupInProgress': 'Worktree-Einrichtung läuft', + 'gitView.empty.discoveringRepositories': 'Suche nach Git-Repositories...', + 'gitView.empty.discoverFailed': 'Git-Repositories konnten nicht durchsucht werden', + 'gitView.empty.retryDiscovery': 'Erneut versuchen', + 'gitView.empty.selectRepositoryPlaceholder': 'Repository auswählen...', 'worktree.bootstrap.toast.failed': 'Worktree-Einrichtung fehlgeschlagen', 'worktree.bootstrap.toast.failedDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung wurde nicht abgeschlossen.', 'worktree.bootstrap.toast.timeoutDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung hat ein Timeout.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index fd3b05b1..c77b4e06 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -923,6 +923,10 @@ export const dict = { 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.', 'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.', 'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress', + 'gitView.empty.discoveringRepositories': 'Looking for Git repositories...', + 'gitView.empty.discoverFailed': 'Could not scan for Git repositories', + 'gitView.empty.retryDiscovery': 'Retry', + 'gitView.empty.selectRepositoryPlaceholder': 'Select a repository...', 'worktree.bootstrap.toast.failed': 'Worktree setup failed', 'worktree.bootstrap.toast.failedDescription': 'The worktree was created, but background setup did not finish.', 'worktree.bootstrap.toast.timeoutDescription': 'The worktree was created, but background setup timed out.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 3d362620..4667493a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -924,6 +924,10 @@ export const dict: Record = { "gitView.empty.worktreeFeaturesUnavailable": "Las características de worktree no están disponibles en este modo de espacio de trabajo.", "gitView.empty.worktreeSetupDescription": "Finalizando la configuración de worktree y preparando el estado del repositorio.", "gitView.empty.worktreeSetupInProgress": "Configuración de worktree en progreso", + "gitView.empty.discoveringRepositories": "Buscando repositorios de Git...", + "gitView.empty.discoverFailed": "No se pudo escanear en busca de repositorios de Git", + "gitView.empty.retryDiscovery": "Reintentar", + "gitView.empty.selectRepositoryPlaceholder": "Selecciona un repositorio...", "worktree.bootstrap.toast.failed": "Error al configurar el worktree", "worktree.bootstrap.toast.failedDescription": "El worktree se creó, pero la configuración en segundo plano no terminó.", "worktree.bootstrap.toast.timeoutDescription": "El worktree se creó, pero la configuración en segundo plano agotó el tiempo de espera.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index d85c260c..5eb2d123 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -751,6 +751,10 @@ export const dict = { 'gitView.empty.worktreeFeaturesUnavailable': 'Les fonctionnalités Worktree ne sont pas disponibles dans ce mode d’espace de travail.', 'gitView.empty.worktreeSetupDescription': 'Termine la configuration du worktree et prépare l\'état du dépôt.', 'gitView.empty.worktreeSetupInProgress': 'Configuration de worktree en cours', + 'gitView.empty.discoveringRepositories': 'Recherche des dépôts Git...', + 'gitView.empty.discoverFailed': 'Impossible d’analyser les dépôts Git', + 'gitView.empty.retryDiscovery': 'Réessayer', + 'gitView.empty.selectRepositoryPlaceholder': 'Sélectionnez un dépôt...', 'gitView.gitmoji.empty': 'Aucun gitmoji trouvé', 'gitView.gitmoji.searchPlaceholder': 'Rechercher des gitmoji...', 'gitView.gitmoji.title': 'Insérer un gitmoji', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index e2da8965..ab1df185 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -920,6 +920,10 @@ export const dict: Record = { 'gitView.empty.worktreeFeaturesUnavailable': 'このワークスペースモードではワークツリー機能は利用できません。', 'gitView.empty.worktreeSetupDescription': 'ワークツリーのセットアップを完了し、リポジトリ状態を準備中。', 'gitView.empty.worktreeSetupInProgress': 'ワークツリーのセットアップ進行中', + 'gitView.empty.discoveringRepositories': 'Git リポジトリを検索しています...', + 'gitView.empty.discoverFailed': 'Git リポジトリを検索できませんでした', + 'gitView.empty.retryDiscovery': '再試行', + 'gitView.empty.selectRepositoryPlaceholder': 'リポジトリを選択...', 'worktree.bootstrap.toast.failed': 'ワークツリーのセットアップに失敗しました', 'worktree.bootstrap.toast.failedDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップが完了しませんでした。', 'worktree.bootstrap.toast.timeoutDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index d9d211d9..587cc4e6 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -924,6 +924,10 @@ export const dict: Record = { 'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.', 'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.', 'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중', + 'gitView.empty.discoveringRepositories': 'Git 저장소를 찾는 중...', + 'gitView.empty.discoverFailed': 'Git 저장소를 검색할 수 없습니다', + 'gitView.empty.retryDiscovery': '다시 시도', + 'gitView.empty.selectRepositoryPlaceholder': '저장소 선택...', 'worktree.bootstrap.toast.failed': '워크트리 설정 실패', 'worktree.bootstrap.toast.failedDescription': '워크트리는 생성되었지만 백그라운드 설정이 완료되지 않았습니다.', 'worktree.bootstrap.toast.timeoutDescription': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 26b5b47c..18cad7b2 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1958,6 +1958,10 @@ export const dict: Record = { 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.', 'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.', 'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress', + 'gitView.empty.discoveringRepositories': 'Szukanie repozytoriów Git...', + 'gitView.empty.discoverFailed': 'Nie udało się przeskanować repozytoriów Git', + 'gitView.empty.retryDiscovery': 'Ponów', + 'gitView.empty.selectRepositoryPlaceholder': 'Wybierz repozytorium...', 'worktree.bootstrap.toast.failed': 'Konfiguracja drzewa pracy nie powiodła się', 'worktree.bootstrap.toast.failedDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle nie została ukończona.', 'worktree.bootstrap.toast.timeoutDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle przekroczyła limit czasu.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2ad738e9..212292b9 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -924,6 +924,10 @@ export const dict: Record = { "gitView.empty.worktreeFeaturesUnavailable": "Os recursos de worktree não estão disponíveis neste modo de workspace.", "gitView.empty.worktreeSetupDescription": "Finalizando a configuração de worktree e preparando o status do repositório.", "gitView.empty.worktreeSetupInProgress": "Configuração de worktree em andamento", + "gitView.empty.discoveringRepositories": "Procurando repositórios Git...", + "gitView.empty.discoverFailed": "Não foi possível verificar os repositórios Git", + "gitView.empty.retryDiscovery": "Tentar novamente", + "gitView.empty.selectRepositoryPlaceholder": "Selecione um repositório...", "worktree.bootstrap.toast.failed": "Falha na configuração do worktree", "worktree.bootstrap.toast.failedDescription": "O worktree foi criado, mas a configuração em segundo plano não terminou.", "worktree.bootstrap.toast.timeoutDescription": "O worktree foi criado, mas a configuração em segundo plano atingiu o tempo limite.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 2760c77a..0e016a00 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -924,6 +924,10 @@ export const dict: Record = { "gitView.empty.worktreeFeaturesUnavailable": "У цьому режимі робочої області функції worktree недоступні.", "gitView.empty.worktreeSetupDescription": "Завершення налаштування worktree та підготовка стану сховища.", "gitView.empty.worktreeSetupInProgress": "Виконується налаштування worktree", + "gitView.empty.discoveringRepositories": "Пошук репозиторіїв Git...", + "gitView.empty.discoverFailed": "Не вдалося просканувати репозиторії Git", + "gitView.empty.retryDiscovery": "Повторити", + "gitView.empty.selectRepositoryPlaceholder": "Виберіть репозиторій...", "worktree.bootstrap.toast.failed": "Не вдалося налаштувати worktree", "worktree.bootstrap.toast.failedDescription": "Worktree створено, але фонове налаштування не завершилося.", "worktree.bootstrap.toast.timeoutDescription": "Worktree створено, але час очікування фонового налаштування минув.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 72aa8057..a4134c69 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -924,6 +924,10 @@ export const dict: Record = { 'gitView.empty.worktreeFeaturesUnavailable': '当前工作区模式下,工作树功能不可用。', 'gitView.empty.worktreeSetupDescription': '正在完成工作树设置并准备仓库状态。', 'gitView.empty.worktreeSetupInProgress': '工作树设置进行中', + 'gitView.empty.discoveringRepositories': '正在查找 Git 仓库...', + 'gitView.empty.discoverFailed': '无法扫描 Git 仓库', + 'gitView.empty.retryDiscovery': '重试', + 'gitView.empty.selectRepositoryPlaceholder': '选择仓库...', 'worktree.bootstrap.toast.failed': '工作树设置失败', 'worktree.bootstrap.toast.failedDescription': '工作树已创建,但后台设置未完成。', 'worktree.bootstrap.toast.timeoutDescription': '工作树已创建,但后台设置超时。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index d5106633..e46004cc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -936,6 +936,10 @@ export const dict: Record = { 'gitView.empty.worktreeFeaturesUnavailable': '目前工作區模式下,worktree 功能無法使用。', 'gitView.empty.worktreeSetupDescription': '正在完成 worktree 設定並準備儲存庫狀態。', 'gitView.empty.worktreeSetupInProgress': 'worktree 設定進行中', + 'gitView.empty.discoveringRepositories': '正在尋找 Git 儲存庫...', + 'gitView.empty.discoverFailed': '無法掃描 Git 儲存庫', + 'gitView.empty.retryDiscovery': '重試', + 'gitView.empty.selectRepositoryPlaceholder': '選擇儲存庫...', 'worktree.bootstrap.toast.failed': 'worktree 設定失敗', 'worktree.bootstrap.toast.failedDescription': 'worktree 已建立,但背景設定未完成。', 'worktree.bootstrap.toast.timeoutDescription': 'worktree 已建立,但背景設定逾時。', diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index a578d61f..3a7df025 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -133,6 +133,7 @@ Important properties: - loading state is per-directory, not global - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers - in-flight dedupe exists for status and `ensureAll()` +- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory the Git tab operates on (`root` when the root is a repository, the selected nested repository otherwise) - runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations - status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index 2512989a..a1dd6902 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -335,3 +335,61 @@ describe('useGitStore', () => { expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus); }); }); + +describe('useGitStore nested repository discovery', () => { + beforeEach(() => { + useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); + }); + + test('selects a nested repo per root and persists the selection', () => { + useGitStore.getState().selectNestedRepo('/root-a', '/root-a/repo-one'); + + expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one'); + + // Re-seeding from storage (as a page refresh would) restores the pick. + useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); + expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one'); + }); + + test('keeps selections isolated per root', () => { + useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one'); + useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two'); + + expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/one'); + expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two'); + }); + + test('clears only the given root selection', () => { + useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one'); + useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two'); + + useGitStore.getState().clearNestedRepoSelection('/root-a'); + + expect(useGitStore.getState().nestedRepoSelection.has('/root-a')).toBe(false); + expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two'); + }); + + test('runtime switch does not leak selections or discovery across runtimes', () => { + useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one'); + useGitStore.setState({ nestedReposByRoot: new Map([['/root-a', ['/root-a/one']]]) }); + + useGitStore.getState().resetForRuntimeSwitch('runtime-b'); + + expect(useGitStore.getState().nestedRepoSelection.size).toBe(0); + expect(useGitStore.getState().nestedReposByRoot.size).toBe(0); + }); + + test('marks discovery failure as a failed marker, not an empty success', async () => { + await useGitStore.getState().ensureNestedRepos('/root-a'); + + expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull(); + }); + + test('dedupes concurrent discovery runs for the same root', async () => { + const first = useGitStore.getState().ensureNestedRepos('/root-a'); + const second = useGitStore.getState().ensureNestedRepos('/root-a'); + await Promise.all([first, second]); + + expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull(); + }); +}); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index aec942ba..2ec728df 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -9,6 +9,7 @@ import type { } from '@/lib/api/types'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getRuntimeKey } from '@/lib/runtime-switch'; +import { listGitDirectories } from '@/lib/gitApiHttp'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -77,6 +78,16 @@ interface GitStore { setLogMaxCount: (directory: string, maxCount: number) => void; + // Nested repository discovery: when the root directory is not itself a git + // repository, these hold the discovered repositories and the user's pick. + // `nestedReposByRoot` values are `null` when discovery failed — never a + // valid empty result — and absent when discovery has not run yet. + nestedReposByRoot: Map; + nestedRepoSelection: Map; + ensureNestedRepos: (root: string, options?: { force?: boolean }) => Promise; + selectNestedRepo: (root: string, repository: string) => void; + clearNestedRepoSelection: (root: string) => void; + refresh: (git: GitAPI, options?: { force?: boolean }) => Promise; resetForRuntimeSwitch: (runtimeKey: string) => void; } @@ -101,6 +112,7 @@ const inFlightDiffFetchesByDirectory = new Map>(); const diffFetchGenerationByDirectory = new Map(); const inFlightStatusFetches = new Map>(); const inFlightEnsureAllByDirectory = new Map>(); +const inFlightNestedRepoDiscovery = new Map>(); const requestGenerationByChannel = new Map(); const statusMutationRevisionByDirectory = new Map(); let gitRuntimeGeneration = 0; @@ -276,6 +288,59 @@ const seedDirectoriesFromBranchCache = (runtimeKey: string): Map }>; +}; + +const emptyNestedRepoSelection = (): NestedRepoSelectionEnvelope => ({ version: 1, runtimes: {} }); + +const readNestedRepoSelectionEnvelope = (): NestedRepoSelectionEnvelope => { + try { + const storage = getDeferredSafeStorage(); + const raw = storage.getItem(GIT_NESTED_REPO_SELECTION_KEY); + const parsed = raw ? JSON.parse(raw) as Partial : emptyNestedRepoSelection(); + return parsed?.version === 1 && parsed.runtimes && typeof parsed.runtimes === 'object' + ? { version: 1, runtimes: parsed.runtimes } + : emptyNestedRepoSelection(); + } catch { + return emptyNestedRepoSelection(); + } +}; + +const writeCachedNestedRepoSelection = (runtimeKey: string, roots: Record): void => { + try { + const envelope = readNestedRepoSelectionEnvelope(); + const now = Date.now(); + const boundedRoots = Object.fromEntries( + Object.entries(roots).slice(0, MAX_NESTED_REPO_ROOTS) + ); + envelope.runtimes[runtimeKey] = { updatedAt: now, roots: boundedRoots }; + envelope.runtimes = Object.fromEntries( + Object.entries(envelope.runtimes).sort(([, left], [, right]) => right.updatedAt - left.updatedAt).slice(0, MAX_NESTED_REPO_RUNTIMES), + ); + getDeferredSafeStorage().setItem(GIT_NESTED_REPO_SELECTION_KEY, JSON.stringify(envelope)); + } catch { + // quota / serialization — ignore; the selection still lives in memory + } +}; + +const seedNestedRepoSelection = (runtimeKey: string): Map => { + const roots = readNestedRepoSelectionEnvelope().runtimes[runtimeKey]?.roots ?? {}; + return new Map(Object.entries(roots).filter(([root, repository]) => root && repository)); +}; + // LRU eviction helper for diff cache const evictDiffCacheIfNeeded = ( diffCache: Map, @@ -549,6 +614,8 @@ export const useGitStore = create()( runtimeKey: initialGitRuntimeKey, directories: seedDirectoriesFromBranchCache(initialGitRuntimeKey), activeDirectory: null, + nestedReposByRoot: new Map(), + nestedRepoSelection: seedNestedRepoSelection(initialGitRuntimeKey), resetForRuntimeSwitch: (runtimeKey) => { gitRuntimeGeneration += 1; @@ -557,9 +624,16 @@ export const useGitStore = create()( statusMutationRevisionByDirectory.clear(); inFlightStatusFetches.clear(); inFlightEnsureAllByDirectory.clear(); + inFlightNestedRepoDiscovery.clear(); inFlightDiffFetchesByDirectory.clear(); diffFetchGenerationByDirectory.clear(); - set({ runtimeKey, directories: seedDirectoriesFromBranchCache(runtimeKey), activeDirectory: null }); + set({ + runtimeKey, + directories: seedDirectoriesFromBranchCache(runtimeKey), + activeDirectory: null, + nestedReposByRoot: new Map(), + nestedRepoSelection: seedNestedRepoSelection(runtimeKey), + }); }, setActiveDirectory: (directory) => { @@ -1125,6 +1199,66 @@ export const useGitStore = create()( set({ directories: newDirectories }); }, + ensureNestedRepos: async (root, options = {}) => { + if (!root) return; + const { force = false } = options; + const runtimeKey = getRuntimeKey(); + const key = runtimeDirectoryKey(runtimeKey, root); + const current = get().nestedReposByRoot.get(root); + if (!force && (current !== undefined || inFlightNestedRepoDiscovery.has(key))) { + return; + } + + const existing = inFlightNestedRepoDiscovery.get(key); + if (existing) { + await existing; + return; + } + + const discovery = (async () => { + let repositories: string[] | null = null; + try { + repositories = await listGitDirectories(root); + } catch (error) { + console.error('Failed to discover nested git repositories:', error); + repositories = null; + } + + // A failed retry must not clobber an earlier successful discovery. + const previous = get().nestedReposByRoot.get(root); + const nextValue = repositories ?? previous ?? null; + const next = new Map(get().nestedReposByRoot); + next.set(root, nextValue); + set({ nestedReposByRoot: next }); + })(); + + inFlightNestedRepoDiscovery.set(key, discovery); + try { + await discovery; + } finally { + if (inFlightNestedRepoDiscovery.get(key) === discovery) { + inFlightNestedRepoDiscovery.delete(key); + } + } + }, + + selectNestedRepo: (root, repository) => { + if (!root || !repository) return; + const next = new Map(get().nestedRepoSelection); + next.set(root, repository); + set({ nestedRepoSelection: next }); + writeCachedNestedRepoSelection(getRuntimeKey(), Object.fromEntries(next)); + }, + + clearNestedRepoSelection: (root) => { + if (!root) return; + if (!get().nestedRepoSelection.has(root)) return; + const next = new Map(get().nestedRepoSelection); + next.delete(root); + set({ nestedRepoSelection: next }); + writeCachedNestedRepoSelection(getRuntimeKey(), Object.fromEntries(next)); + }, + ensureStatus: async (directory, git) => { const dirState = get().directories.get(directory); const now = Date.now(); @@ -1221,6 +1355,35 @@ export const useIsGitRepo = (directory: string | null) => { }); }; +// Resolves the directory the Git tab operates on. A root that is itself a git +// repository is always used directly; otherwise a per-root nested-repo +// selection (when present) becomes the effective directory. +export const useEffectiveGitDirectory = (root: string | null) => { + return useGitStore((state) => { + if (!root) return null; + if (state.directories.get(root)?.isGitRepo === true) { + return root; + } + return state.nestedRepoSelection.get(root) ?? root; + }); +}; + +// `undefined` = discovery not run yet, `null` = discovery failed, otherwise +// the discovered nested repository paths (possibly empty). +export const useNestedRepos = (root: string | null) => { + return useGitStore((state) => { + if (!root) return undefined; + return state.nestedReposByRoot.get(root); + }); +}; + +export const useNestedRepoSelection = (root: string | null) => { + return useGitStore((state) => { + if (!root) return null; + return state.nestedRepoSelection.get(root) ?? null; + }); +}; + export const useGitBranchLabel = (directory: string | null) => { return useGitStore((state) => { if (!directory) return null; diff --git a/packages/web/server/lib/fs/DOCUMENTATION.md b/packages/web/server/lib/fs/DOCUMENTATION.md index 4629135b..511b3108 100644 --- a/packages/web/server/lib/fs/DOCUMENTATION.md +++ b/packages/web/server/lib/fs/DOCUMENTATION.md @@ -22,6 +22,10 @@ Own filesystem API behavior for the web server runtime, including workspace-boun - `POST /api/fs/exec` - `GET /api/fs/exec/:jobId` - `GET /api/fs/list` + - `GET /api/fs/git-dirs` — shallow nested git repository discovery for the + Git tab (depth- and visit-capped readdir walk; `.git` directory, file, or + symlink marks a repository boundary; junk directories and symlinks are + never descended into) - Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning. - Enforces workspace boundary checks with active project + worktree fallback support. - `createFsSearchRuntime({ fsPromises, path, spawn, resolveGitBinaryForSpawn })` from `search.js` diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 10842ef3..44294554 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -249,6 +249,79 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject }); }; +// Nested repository discovery bounds: only shallow walks are useful for the +// Git tab's "pick a repository" picker, and deep/monorepo trees can explode +// otherwise. Directories deeper than maxDepth or beyond the visit cap are +// silently not searched. +const GIT_DIRS_MAX_DEPTH = 3; +const GIT_DIRS_MAX_DIRS = 100; +const GIT_DIRS_SKIP_LIST = new Set(['node_modules', 'dist', 'build', '.venv', 'target', '.next']); + +// Walks rootPath and returns every nested git repository path (a directory +// containing a `.git` entry — a directory, a worktree pointer file, or a +// symlink). A repository boundary stops descent: nested repos inside repos +// are not reported. The root itself, when it is a repo, yields no results. +const findGitDirectories = async ({ rootPath, fsPromises, path: pathModule, maxDepth, maxDirs }) => { + const results = []; + let visited = 0; + + const walk = async (dir, depth) => { + if (visited >= maxDirs) { + return; + } + + let dirents; + try { + dirents = await fsPromises.readdir(dir, { withFileTypes: true }); + } catch (error) { + // Unreadable subtree — skip it unless it is the root itself, which the + // route maps to 403/404/500 through the shared error handling. + if (dir === rootPath) { + throw error; + } + return; + } + visited += 1; + + let isRepoBoundary = false; + const subdirectories = []; + for (const dirent of dirents) { + if (dirent.name === '.git') { + isRepoBoundary = true; + continue; + } + if (!dirent.isDirectory() || dirent.isSymbolicLink()) { + continue; + } + if (GIT_DIRS_SKIP_LIST.has(dirent.name)) { + continue; + } + if (depth >= maxDepth) { + continue; + } + subdirectories.push(dirent.name); + } + + if (isRepoBoundary) { + if (dir !== rootPath) { + results.push(dir); + } + return; + } + + subdirectories.sort(); + for (const name of subdirectories) { + if (visited >= maxDirs) { + break; + } + await walk(pathModule.join(dir, name), depth + 1); + } + }; + + await walk(rootPath, 0); + return results; +}; + const deriveCloneDirectoryName = (remoteUrl) => { const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : ''; if (!remote) return ''; @@ -1461,4 +1534,60 @@ export const registerFsRoutes = (app, dependencies) => { return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' }); } }); + + app.get('/api/fs/git-dirs', async (req, res) => { + const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0 + ? req.query.path.trim() + : ''; + if (!rawPath) { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: rawPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + const stats = await fsPromises.stat(resolved.resolved); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' }); + } + + const repositories = await findGitDirectories({ + rootPath: resolved.resolved, + fsPromises, + path, + maxDepth: GIT_DIRS_MAX_DEPTH, + maxDirs: GIT_DIRS_MAX_DIRS, + }); + + return res.json({ + path: resolved.resolved, + repositories: repositories.map((repoPath) => ({ + path: repoPath, + name: path.basename(repoPath), + })), + }); + } catch (error) { + const err = error; + const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined; + if (code === 'ENOENT') { + return res.status(404).json({ error: 'Directory not found', reason: 'not-found' }); + } + if (isOsPermissionError(err)) { + return sendOsPermissionDenied(res, 'Access to directory denied'); + } + console.error('Failed to find git directories:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to find git directories' }); + } + }); }; diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index 1112e333..be8a7cef 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -823,3 +823,222 @@ describe('fs list symlink path space (issue 2627)', () => { }); } }); + +describe('fs git-dirs', () => { + const createDirent = (name, type) => ({ + name, + isDirectory: () => type === 'dir', + isFile: () => type === 'file', + isSymbolicLink: () => type === 'symlink', + }); + + // tree maps directory path -> [[name, type], ...] + const registerGitDirs = (tree, { stat, readdir: readdirOverride } = {}) => { + const { app, getRoute } = createRouteRegistry(); + const readdir = readdirOverride ?? vi.fn(async (dirPath) => (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type))); + registerFsRoutes(app, { + os: { homedir: () => '/home/user' }, + path: path.posix, + fsPromises: { + realpath: async (targetPath) => targetPath, + stat: stat ?? vi.fn(async (targetPath) => ({ isDirectory: () => Boolean(tree[targetPath]) })), + readdir, + }, + spawn: vi.fn(), + crypto: { randomUUID: () => 'job-0' }, + normalizeDirectoryPath: (p) => p, + resolveProjectDirectory: async () => ({ directory: '/workspace' }), + buildAugmentedPath: () => '/usr/bin', + resolveGitBinaryForSpawn: () => 'git', + openchamberUserConfigRoot: '/home/user/.config', + }); + return { handler: getRoute('GET', '/api/fs/git-dirs'), readdir }; + }; + + const callGitDirs = async (handler, query) => { + const res = createMockResponse(); + await handler({ query: query ?? {} }, res); + return res; + }; + + it('returns an empty list when the root itself is a repository', async () => { + const { handler, readdir } = registerGitDirs({ + '/workspace': [['.git', 'dir'], ['proj-a', 'dir']], + '/workspace/proj-a': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ path: '/workspace', repositories: [] }); + expect(readdir).toHaveBeenCalledTimes(1); + }); + + it('finds nested repositories with a .git directory', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['proj-a', 'dir'], ['proj-b', 'dir']], + '/workspace/proj-a': [['.git', 'dir'], ['src', 'dir']], + '/workspace/proj-a/src': [['index.ts', 'file']], + '/workspace/proj-b': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([ + { path: '/workspace/proj-a', name: 'proj-a' }, + { path: '/workspace/proj-b', name: 'proj-b' }, + ]); + }); + + it('treats a .git file (linked worktree) as a repository boundary', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['worktree', 'dir']], + '/workspace/worktree': [['.git', 'file']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/worktree', name: 'worktree' }]); + }); + + it('stops descending at repository boundaries', async () => { + const { handler, readdir } = registerGitDirs({ + '/workspace': [['outer', 'dir']], + '/workspace/outer': [['.git', 'dir'], ['inner', 'dir']], + '/workspace/outer/inner': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/outer', name: 'outer' }]); + expect(readdir).not.toHaveBeenCalledWith('/workspace/outer/inner', { withFileTypes: true }); + }); + + it('does not descend past the depth cap', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['a', 'dir']], + '/workspace/a': [['b', 'dir']], + '/workspace/a/b': [['c', 'dir']], + '/workspace/a/b/c': [['.git', 'dir'], ['d', 'dir']], + '/workspace/a/b/c/d': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/a/b/c', name: 'c' }]); + }); + + it('skips junk directories', async () => { + const { handler, readdir } = registerGitDirs({ + '/workspace': [['node_modules', 'dir'], ['dist', 'dir'], ['real', 'dir']], + '/workspace/node_modules': [['dep', 'dir']], + '/workspace/node_modules/dep': [['.git', 'dir']], + '/workspace/dist': [['.git', 'dir']], + '/workspace/real': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]); + expect(readdir).not.toHaveBeenCalledWith('/workspace/node_modules', { withFileTypes: true }); + }); + + it('never descends into symbolic links', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['link', 'symlink'], ['real', 'dir']], + '/workspace/real': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]); + }); + + it('returns repositories in deterministic order', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['zebra', 'dir'], ['alpha', 'dir']], + '/workspace/zebra': [['.git', 'dir']], + '/workspace/alpha': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.body.repositories.map((repo) => repo.name)).toEqual(['alpha', 'zebra']); + }); + + it('returns 400 when path is missing', async () => { + const { handler } = registerGitDirs({}); + + const res = await callGitDirs(handler, {}); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('Path is required'); + }); + + it('returns 400 when the path is not a directory', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['file.txt', 'file']], + }, { + stat: vi.fn(async (targetPath) => ({ isDirectory: () => targetPath !== '/workspace/file.txt' })), + }); + + const res = await callGitDirs(handler, { path: '/workspace/file.txt' }); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' }); + }); + + it('returns 404 when the directory does not exist', async () => { + const error = Object.assign(new Error('missing'), { code: 'ENOENT' }); + const { handler } = registerGitDirs({}, { + stat: vi.fn(async () => { throw error; }), + }); + + const res = await callGitDirs(handler, { path: '/workspace/missing' }); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'Directory not found', reason: 'not-found' }); + }); + + for (const code of ['EACCES', 'EPERM']) { + it(`maps root ${code} to the os-permission contract`, async () => { + const error = Object.assign(new Error('denied'), { code }); + const { handler } = registerGitDirs({}, { + stat: vi.fn(async () => ({ isDirectory: () => true })), + readdir: vi.fn(async () => { throw error; }), + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' }); + }); + } + + it('skips unreadable subtrees without failing the scan', async () => { + const tree = { + '/workspace': [['blocked', 'dir'], ['open', 'dir']], + '/workspace/open': [['.git', 'dir']], + }; + const blockedError = Object.assign(new Error('denied'), { code: 'EACCES' }); + const { handler } = registerGitDirs(tree, { + readdir: vi.fn(async (dirPath) => { + if (dirPath === '/workspace/blocked') { + throw blockedError; + } + return (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type)); + }), + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/open', name: 'open' }]); + }); +}); From 5e3f9d1ba273985ca68038ca27b889513fb5847e Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Tue, 25 Aug 2026 19:15:35 +0530 Subject: [PATCH 02/37] fix(git): harden nested repository discovery - discard an in-flight discovery when the runtime switches, mirroring the isRequestCurrent pattern, so a late completion cannot repopulate the cleared map and suppress a fresh scan - treat a 501 from /api/fs/git-dirs as an explicit 'unsupported' marker instead of a generic failure; the VS Code webview now answers the route with unsupportedWebRouteResponse so non-repo roots show the honest not-a-repository state without a futile Retry --- packages/ui/src/lib/gitApiHttp.ts | 10 ++++ packages/ui/src/stores/useGitStore.test.ts | 54 +++++++++++++++++++++- packages/ui/src/stores/useGitStore.ts | 38 +++++++++++---- packages/vscode/webview/main.tsx | 4 ++ 4 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 05ff58b2..0ca0cc8d 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -116,8 +116,18 @@ export async function checkIsGitRepository(directory: string): Promise } } +export class GitDirectoriesUnsupportedError extends Error { + constructor() { + super('Nested git repository discovery is not supported by this runtime'); + this.name = 'GitDirectoriesUnsupportedError'; + } +} + export async function listGitDirectories(root: string): Promise { const response = await runtimeFetch('/api/fs/git-dirs', { query: { path: root } }); + if (response.status === 501) { + throw new GitDirectoriesUnsupportedError(); + } if (!response.ok) { throw new Error(`Failed to list git directories: ${response.statusText}`); } diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index a1dd6902..20296dba 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -1,8 +1,22 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; import type { GitStatus } from '@/lib/api/types'; import { useGitStore } from './useGitStore'; import { getRuntimeKey } from '@/lib/runtime-switch'; +// The real transport has no server in tests and fails as a generic error. +// Tests that exercise other failure modes swap this implementation; the +// default keeps every pre-existing expectation (generic failure → null). +const listGitDirectoriesControl: { impl: (root: string) => Promise } = { + impl: async () => { + throw new Error('network unavailable'); + }, +}; +class TestGitDirectoriesUnsupportedError extends Error {} +mock.module('@/lib/gitApiHttp', () => ({ + GitDirectoriesUnsupportedError: TestGitDirectoriesUnsupportedError, + listGitDirectories: (root: string) => listGitDirectoriesControl.impl(root), +})); + type Deferred = { promise: Promise; resolve: (value: T) => void; @@ -338,6 +352,9 @@ describe('useGitStore', () => { describe('useGitStore nested repository discovery', () => { beforeEach(() => { + listGitDirectoriesControl.impl = async () => { + throw new Error('network unavailable'); + }; useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); }); @@ -379,12 +396,47 @@ describe('useGitStore nested repository discovery', () => { expect(useGitStore.getState().nestedReposByRoot.size).toBe(0); }); + test('discards an in-flight discovery result when the runtime switches', async () => { + const stale = useGitStore.getState().ensureNestedRepos('/root-a'); + useGitStore.getState().resetForRuntimeSwitch('runtime-b'); + await stale; + + // The old runtime's late completion must not repopulate the cleared map. + expect(useGitStore.getState().nestedReposByRoot.has('/root-a')).toBe(false); + + // Discovery started under the new runtime still commits normally. + await useGitStore.getState().ensureNestedRepos('/root-a'); + expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull(); + }); + test('marks discovery failure as a failed marker, not an empty success', async () => { await useGitStore.getState().ensureNestedRepos('/root-a'); expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull(); }); + test('marks a 501 runtime as unsupported instead of failed', async () => { + listGitDirectoriesControl.impl = async () => { + throw new TestGitDirectoriesUnsupportedError(); + }; + + await useGitStore.getState().ensureNestedRepos('/root-a'); + + expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBe('unsupported'); + }); + + test('unsupported does not clobber a previous successful discovery', async () => { + listGitDirectoriesControl.impl = async () => ['/root-a/one']; + await useGitStore.getState().ensureNestedRepos('/root-a'); + + listGitDirectoriesControl.impl = async () => { + throw new TestGitDirectoriesUnsupportedError(); + }; + await useGitStore.getState().ensureNestedRepos('/root-a', { force: true }); + + expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toEqual(['/root-a/one']); + }); + test('dedupes concurrent discovery runs for the same root', async () => { const first = useGitStore.getState().ensureNestedRepos('/root-a'); const second = useGitStore.getState().ensureNestedRepos('/root-a'); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 2ec728df..0d1dcf2e 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -9,7 +9,7 @@ import type { } from '@/lib/api/types'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getRuntimeKey } from '@/lib/runtime-switch'; -import { listGitDirectories } from '@/lib/gitApiHttp'; +import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -28,6 +28,11 @@ const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200; type GitStatusFetchMode = 'full' | 'light'; +// Discovery outcome for a root that is not itself a git repository. The three +// states are mutually exclusive: a repository list (possibly empty), a failed +// scan (`null`), or a runtime without the discovery route (`'unsupported'`). +export type NestedRepoDiscovery = string[] | null | 'unsupported'; + interface DirectoryGitState { isGitRepo: boolean | null; status: GitStatus | null; @@ -81,8 +86,9 @@ interface GitStore { // Nested repository discovery: when the root directory is not itself a git // repository, these hold the discovered repositories and the user's pick. // `nestedReposByRoot` values are `null` when discovery failed — never a - // valid empty result — and absent when discovery has not run yet. - nestedReposByRoot: Map; + // valid empty result — `'unsupported'` when the runtime has no discovery + // route, and absent when discovery has not run yet. + nestedReposByRoot: Map; nestedRepoSelection: Map; ensureNestedRepos: (root: string, options?: { force?: boolean }) => Promise; selectNestedRepo: (root: string, repository: string) => void; @@ -1203,6 +1209,7 @@ export const useGitStore = create()( if (!root) return; const { force = false } = options; const runtimeKey = getRuntimeKey(); + const runtimeGeneration = gitRuntimeGeneration; const key = runtimeDirectoryKey(runtimeKey, root); const current = get().nestedReposByRoot.get(root); if (!force && (current !== undefined || inFlightNestedRepoDiscovery.has(key))) { @@ -1217,16 +1224,30 @@ export const useGitStore = create()( const discovery = (async () => { let repositories: string[] | null = null; + let unsupported = false; try { repositories = await listGitDirectories(root); } catch (error) { - console.error('Failed to discover nested git repositories:', error); + if (error instanceof GitDirectoriesUnsupportedError) { + unsupported = true; + } else { + console.error('Failed to discover nested git repositories:', error); + } repositories = null; } - // A failed retry must not clobber an earlier successful discovery. + // A runtime switch invalidates the discovery: resetForRuntimeSwitch + // already cleared the map, and committing old-runtime data here would + // both leak it and suppress a fresh scan for this root. + if (runtimeKey !== getRuntimeKey() || runtimeGeneration !== gitRuntimeGeneration) return; + const previous = get().nestedReposByRoot.get(root); - const nextValue = repositories ?? previous ?? null; + // An authoritative "unsupported" answer replaces only unknown or + // failed state; like a failed retry, it must not clobber an earlier + // successful discovery. + const nextValue: NestedRepoDiscovery = unsupported + ? (previous ?? 'unsupported') + : (repositories ?? previous ?? null); const next = new Map(get().nestedReposByRoot); next.set(root, nextValue); set({ nestedReposByRoot: next }); @@ -1368,8 +1389,9 @@ export const useEffectiveGitDirectory = (root: string | null) => { }); }; -// `undefined` = discovery not run yet, `null` = discovery failed, otherwise -// the discovered nested repository paths (possibly empty). +// `undefined` = discovery not run yet, `null` = discovery failed, +// `'unsupported'` = the runtime has no discovery route, otherwise the +// discovered nested repository paths (possibly empty). export const useNestedRepos = (root: string | null) => { return useGitStore((state) => { if (!root) return undefined; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 3a83a155..a49ba39f 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -388,6 +388,10 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R return unsupportedWebRouteResponse('Scheduled tasks'); } + if (normalizedPathname === '/api/fs/git-dirs') { + return unsupportedWebRouteResponse('Nested git repository discovery'); + } + if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') { const activity = await sendBridgeMessage>('api:session-activity:get') .catch(() => ({})); From cda273d69fdec8dc41abe9cf90e94b41ef582544 Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Tue, 25 Aug 2026 19:15:53 +0530 Subject: [PATCH 03/37] feat(git): resolve nested repositories in the other git surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract GitView's resolution flow into hooks/useNestedGitDirectory (root probe, discovery, auto-select, stale-selection recovery) so the flow no longer depends on SessionSidebar probing the root first, and reuse it in the pull-request view, walkthrough view, and mobile changes surface — all three now operate on the selected nested repository instead of dead-ending on a non-repo root. Shared pending/failed/unsupported/empty states live in git/NestedRepoResolutionStates; desktop changes inherits the behavior through GitView. Selection stays shared per root, so the picker's pick carries across surfaces. --- packages/ui/src/apps/MobileChangesSurface.tsx | 27 +++- packages/ui/src/components/views/GitView.tsx | 126 ++++-------------- .../src/components/views/PullRequestView.tsx | 79 +++++++---- .../views/git/NestedRepoResolutionStates.tsx | 87 ++++++++++++ .../views/walkthrough/WalkthroughView.tsx | 26 +++- .../ui/src/hooks/useNestedGitDirectory.ts | 98 ++++++++++++++ packages/ui/src/stores/DOCUMENTATION.md | 2 +- 7 files changed, 315 insertions(+), 130 deletions(-) create mode 100644 packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx create mode 100644 packages/ui/src/hooks/useNestedGitDirectory.ts diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index b8e98b59..9d12dd69 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -10,6 +10,7 @@ import { SyncActions } from '@/components/views/git/SyncActions'; import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import type { GitStatus } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi'; @@ -21,6 +22,7 @@ import { useIsGitRepo, useGitLoadingStatus, } from '@/stores/useGitStore'; +import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates'; import { getRuntimeKey } from '@/lib/runtime-switch'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; @@ -56,12 +58,17 @@ type MobileChangesSurfaceProps = { export const MobileChangesSurface: React.FC = ({ onClose, initialDiffPath, initialDiffStaged = false }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); - const currentDirectory = normalizePath(useEffectiveDirectory() ?? null); + const rootDirectory = normalizePath(useEffectiveDirectory() ?? null); + // When the root is not itself a repository, changes come from the resolved + // nested repository instead. + const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null); + const currentDirectory = gitDirectory ?? rootDirectory; const status = useGitStatus(currentDirectory || null); const isGitRepo = useIsGitRepo(currentDirectory || null); const isLoadingStatus = useGitLoadingStatus(currentDirectory || null); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const ensureAll = useGitStore((state) => state.ensureAll); + const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos); const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchBranches = useGitStore((state) => state.fetchBranches); const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); @@ -474,12 +481,22 @@ export const MobileChangesSurface: React.FC = ({ onCl return renderListState(); } - if (isLoadingStatus && isGitRepo === null) { - return renderListState(); + // Non-repo root: surface nested-repository resolution (discovering, failed, + // unsupported, none found, or settling on the auto-selected repository). + if (rootIsGitRepo === false || isGitRepo === false) { + return renderListState( + { + if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); + }} + /> + ); } - if (isGitRepo === false) { - return renderListState(); + if (isLoadingStatus && isGitRepo === null) { + return renderListState(); } if (route.type === 'diff') { diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 0603452c..5317c041 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -18,10 +18,9 @@ import { useIsGitRepo, useGitLoadingStatus, useGitLoadingLog, - useEffectiveGitDirectory, - useNestedRepos, - useNestedRepoSelection, } from '@/stores/useGitStore'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; +import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { toast } from '@/components/ui'; @@ -256,13 +255,14 @@ export const GitView: React.FC = ({ isActive }) => { // The root the view is anchored to (session/worktree context stays keyed on // it). When the root is not itself a repository and the user picked a nested // one, `gitDirectory` is the effective repository all git data and actions - // operate on. - const rootIsGitRepo = useIsGitRepo(currentDirectory ?? null); - const gitDirectory = useEffectiveGitDirectory(currentDirectory ?? null); + // operate on. The hook owns probing, discovery, auto-select, and + // stale-selection recovery; data fetching below keys off its result. + const { rootIsGitRepo, gitDirectory, nestedRepos, nestedRepoSelection } = useNestedGitDirectory( + currentDirectory ?? null, + { enabled: isActive }, + ); const isGitRepo = useIsGitRepo(gitDirectory ?? null); const status = useGitStatus(gitDirectory ?? null); - const nestedRepos = useNestedRepos(currentDirectory ?? null); - const nestedRepoSelection = useNestedRepoSelection(currentDirectory ?? null); // Authoritative session↔worktree attachment for repair action display const worktreeAttachment = useSessionWorktreeStore((s) => @@ -298,7 +298,6 @@ export const GitView: React.FC = ({ isActive }) => { bumpIndexRevision, ensureNestedRepos, selectNestedRepo, - clearNestedRepoSelection, } = useGitStore(useShallow((state) => ({ setActiveDirectory: state.setActiveDirectory, fetchAll: state.fetchAll, @@ -315,7 +314,6 @@ export const GitView: React.FC = ({ isActive }) => { bumpIndexRevision: state.bumpIndexRevision, ensureNestedRepos: state.ensureNestedRepos, selectNestedRepo: state.selectNestedRepo, - clearNestedRepoSelection: state.clearNestedRepoSelection, }))); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); @@ -898,37 +896,6 @@ export const GitView: React.FC = ({ isActive }) => { }); }, [isActive, clearDiffCache, gitDirectory, fetchStatus, git]); - // Discover nested repositories once the root probe confirms it is not one. - React.useEffect(() => { - if (!isActive) return; - if (!currentDirectory) return; - if (rootIsGitRepo !== false) return; - void ensureNestedRepos(currentDirectory); - }, [currentDirectory, ensureNestedRepos, isActive, rootIsGitRepo]); - - // Auto-select the first nested repository so the tab opens straight into - // repository data; the header picker switches between repositories. - React.useEffect(() => { - if (!isActive) return; - if (!currentDirectory) return; - if (rootIsGitRepo !== false) return; - if (!nestedRepos || nestedRepos.length === 0) return; - if (nestedRepoSelection) return; - selectNestedRepo(currentDirectory, nestedRepos[0]); - }, [currentDirectory, isActive, nestedRepos, nestedRepoSelection, rootIsGitRepo, selectNestedRepo]); - - // A selected repository that is no longer a git repository is stale: drop - // the selection and re-scan so the picker reflects the current tree. - React.useEffect(() => { - if (!isActive) return; - if (!currentDirectory) return; - if (!nestedRepoSelection) return; - if (gitDirectory === currentDirectory) return; - if (isGitRepo !== false) return; - clearNestedRepoSelection(currentDirectory); - void ensureNestedRepos(currentDirectory, { force: true }); - }, [clearNestedRepoSelection, currentDirectory, ensureNestedRepos, gitDirectory, isActive, isGitRepo, nestedRepoSelection]); - const refreshStatusAndBranches = React.useCallback( async (showErrors = true) => { if (!gitDirectory) return; @@ -2371,66 +2338,25 @@ export const GitView: React.FC = ({ isActive }) => { ); } - // Nested repository discovery: while unknown or failed keep a loading - // state with the failure signal; the picker appears once repositories are - // found (a single repository is auto-selected by an effect above). - if (nestedRepos === undefined || nestedRepos === null) { - return ( -
- -

- {nestedRepos === null - ? t('gitView.empty.discoverFailed') - : t('gitView.empty.discoveringRepositories')} -

- {nestedRepos === null ? ( - - ) : null} -
- ); - } - - if (nestedRepos.length === 0) { - return ( -
- -

- {t('gitView.empty.notGitRepository')} -

-

- {t('gitView.empty.notGitRepositoryDescription')} -

- {repairActions.includes('open-without-worktree-features') ? ( + // Nested repository discovery states (discovering, failed, unsupported, + // none found, or settling on the auto-selected repository). + return ( + { + if (currentDirectory) { + void ensureNestedRepos(currentDirectory, { force: true }); + } + }} + emptyStateFooter={ + repairActions.includes('open-without-worktree-features') ? (

{t('gitView.empty.worktreeFeaturesUnavailable')}

- ) : null} -
- ); - } - - // Repositories were found and are about to be auto-selected (or the - // selected repository is still probing) — hold a brief loading state. - return ( -
- -

- {t('gitView.loading.checkingRepository')} -

-
+ ) : undefined + } + /> ); } @@ -2465,7 +2391,9 @@ export const GitView: React.FC = ({ isActive }) => { onOpenPullRequest={ gitDirectory ? () => openContextSurface(gitDirectory, 'pr') : undefined } - repositoryOptions={gitDirectory !== currentDirectory ? (nestedRepos ?? undefined) : undefined} + repositoryOptions={ + gitDirectory !== currentDirectory && Array.isArray(nestedRepos) ? nestedRepos : undefined + } selectedRepository={gitDirectory !== currentDirectory ? gitDirectory : null} onSelectRepository={ gitDirectory !== currentDirectory && currentDirectory diff --git a/packages/ui/src/components/views/PullRequestView.tsx b/packages/ui/src/components/views/PullRequestView.tsx index baade489..a597b11e 100644 --- a/packages/ui/src/components/views/PullRequestView.tsx +++ b/packages/ui/src/components/views/PullRequestView.tsx @@ -2,10 +2,11 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; -import { useGitStatus, useGitBranches, useGitStore } from '@/stores/useGitStore'; +import { useGitStatus, useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useShallow } from 'zustand/react/shallow'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getRuntimeKey } from '@/lib/runtime-switch'; @@ -14,6 +15,7 @@ import { useI18n } from '@/lib/i18n'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { PullRequestSection } from './git/PullRequestSection'; +import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates'; import { deriveBaseBranch } from './git/baseBranch'; const normalizePath = (value?: string | null): string => @@ -36,9 +38,16 @@ export const PullRequestView: React.FC = () => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory(); - const status = useGitStatus(currentDirectory ?? null); - const branches = useGitBranches(currentDirectory ?? null); - const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll }))); + // When the root is not itself a repository, the pull-request workflow + // operates on the resolved nested repository instead. + const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(currentDirectory ?? null); + const status = useGitStatus(gitDirectory ?? null); + const branches = useGitBranches(gitDirectory ?? null); + const isGitRepo = useIsGitRepo(gitDirectory ?? null); + const { ensureAll, ensureNestedRepos } = useGitStore(useShallow((state) => ({ + ensureAll: state.ensureAll, + ensureNestedRepos: state.ensureNestedRepos, + }))); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); @@ -89,11 +98,11 @@ export const PullRequestView: React.FC = () => { const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined); React.useEffect(() => { - if (!currentDirectory || !git) { + if (!gitDirectory || !git) { return; } - void ensureAll(currentDirectory, git); - }, [currentDirectory, ensureAll, git]); + void ensureAll(gitDirectory, git); + }, [gitDirectory, ensureAll, git]); const [rootBranchHint, setRootBranchHint] = React.useState(null); React.useEffect(() => { @@ -122,52 +131,52 @@ export const PullRequestView: React.FC = () => { }, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]); const [remotes, setRemotes] = React.useState(() => - (currentDirectory ? remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? [] + (gitDirectory ? remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? [] ); const [remoteUrl, setRemoteUrl] = React.useState(() => - (currentDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? null + (gitDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? null ); React.useEffect(() => { - if (!currentDirectory || !git?.getRemotes) { + if (!gitDirectory || !git?.getRemotes) { setRemotes([]); return; } - setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); + setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []); let cancelled = false; - void git.getRemotes(currentDirectory) + void git.getRemotes(gitDirectory) .then((remoteList) => { if (cancelled) return; - remotesCacheByDirectory.set(remoteCacheKey(currentDirectory), remoteList ?? []); + remotesCacheByDirectory.set(remoteCacheKey(gitDirectory), remoteList ?? []); setRemotes(remoteList ?? []); }) - .catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); }); + .catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []); }); return () => { cancelled = true; }; - }, [currentDirectory, git]); + }, [gitDirectory, git]); React.useEffect(() => { - if (!currentDirectory || !git?.getRemoteUrl) { + if (!gitDirectory || !git?.getRemoteUrl) { setRemoteUrl(null); return; } - setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); + setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null); let cancelled = false; - void git.getRemoteUrl(currentDirectory) + void git.getRemoteUrl(gitDirectory) .then((url) => { if (cancelled) return; - remoteUrlCacheByDirectory.set(remoteCacheKey(currentDirectory), url); + remoteUrlCacheByDirectory.set(remoteCacheKey(gitDirectory), url); setRemoteUrl(url); }) - .catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); }); + .catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null); }); return () => { cancelled = true; }; - }, [currentDirectory, git]); + }, [gitDirectory, git]); const localBranches = React.useMemo(() => { if (!branches?.all) return []; @@ -240,7 +249,31 @@ export const PullRequestView: React.FC = () => { worktreeMetadata?.createdFromBranch, ]); - if (!currentDirectory || !currentBranch) { + if (!currentDirectory) { + return ( +
+ +
{t('gitView.pullRequest.title')}
+
{t('gitView.pullRequest.createHint')}
+
+ ); + } + + // Non-repo root: surface nested-repository resolution (discovering, failed, + // unsupported, none found, or settling on the auto-selected repository). + if (rootIsGitRepo === false || isGitRepo === false) { + return ( + { + void ensureNestedRepos(currentDirectory, { force: true }); + }} + /> + ); + } + + if (!currentBranch) { return (
@@ -259,7 +292,7 @@ export const PullRequestView: React.FC = () => { preventOverscroll > void; + /** Optional extra line under the not-a-repository description. */ + emptyStateFooter?: React.ReactNode; +}; + +/** + * Shared empty/loading states for git surfaces while nested-repository + * resolution is pending, failed, or impossible. Renders null once + * repositories are resolved so the consumer can proceed into its own content. + * + * A runtime without the discovery route (VS Code) reports "unsupported": the + * honest state there is the plain not-a-repository empty state, without a + * retry that can never succeed. + */ +export const NestedRepoResolutionStates: React.FC = ({ + rootIsGitRepo, + nestedRepos, + onRetryDiscovery, + emptyStateFooter, +}) => { + const { t } = useI18n(); + + if (rootIsGitRepo !== false) return null; + + if (nestedRepos === undefined || nestedRepos === null) { + return ( +
+ +

+ {nestedRepos === null + ? t('gitView.empty.discoverFailed') + : t('gitView.empty.discoveringRepositories')} +

+ {nestedRepos === null ? ( + + ) : null} +
+ ); + } + + if (nestedRepos === 'unsupported' || nestedRepos.length === 0) { + return ( +
+ +

+ {t('gitView.empty.notGitRepository')} +

+

+ {t('gitView.empty.notGitRepositoryDescription')} +

+ {emptyStateFooter} +
+ ); + } + + // Repositories were found and one is about to be auto-selected (or the + // selected repository is still probing) — hold a brief loading state. + return ( +
+ +

+ {t('gitView.loading.checkingRepository')} +

+
+ ); +}; diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 51b20387..86eada61 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -19,7 +19,7 @@ import { ModelSelector } from '@/components/sections/agents/ModelSelector'; import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useGitBranches, useGitStatus, useGitStore } from '@/stores/useGitStore'; +import { useGitBranches, useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getFreshestPrStatusForBranch, @@ -27,6 +27,7 @@ import { useGitHubPrStatusStore, } from '@/stores/useGitHubPrStatusStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { useUIStore } from '@/stores/useUIStore'; import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; import { cn } from '@/lib/utils'; @@ -36,6 +37,7 @@ import { WalkthroughStages } from './WalkthroughStages'; import { useWalkthroughStageProgress } from './useWalkthroughStageProgress'; import { WalkthroughStream } from './WalkthroughStream'; import { WalkthroughToc } from './WalkthroughToc'; +import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates'; interface WalkthroughViewProps { directory: string; @@ -73,11 +75,17 @@ const TOC_MAX_FRACTION = 0.5; // pickers, 32px action, 36px arrows) read as misalignment, not hierarchy. const HEADER_COMPACT_WIDTH = 680; -export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { +export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewProps) => { const { t, locale, locales, label } = useI18n(); const rootRef = useRef(null); const [panelWidth, setPanelWidth] = useState(0); + // The walkthrough documents one repository. When the root is not itself a + // repository, that is the resolved nested repository; everything below keys + // off `directory`. + const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null); + const directory = gitDirectory ?? rootDirectory; + // Panel width, not viewport width: this surface is resizable independently of // the window. useEffect(() => { @@ -484,6 +492,20 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { [activeLanguage, directory, generate, generateDisabled, source] ); + const isGitRepo = useIsGitRepo(gitDirectory || null); + const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos); + if (rootIsGitRepo === false || isGitRepo === false) { + return ( + { + if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); + }} + /> + ); + } + return (
diff --git a/packages/ui/src/hooks/useNestedGitDirectory.ts b/packages/ui/src/hooks/useNestedGitDirectory.ts new file mode 100644 index 00000000..c0855805 --- /dev/null +++ b/packages/ui/src/hooks/useNestedGitDirectory.ts @@ -0,0 +1,98 @@ +import React from 'react'; +import { useShallow } from 'zustand/react/shallow'; + +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { + useEffectiveGitDirectory, + useGitStore, + useIsGitRepo, + useNestedRepoSelection, + useNestedRepos, +} from '@/stores/useGitStore'; + +type UseNestedGitDirectoryOptions = { + /** False defers all probing/discovery work while the surface is hidden. */ + enabled?: boolean; +}; + +/** + * Resolves the repository a git surface operates on when the project root may + * not itself be a git repository. Owns the full resolution flow: probing the + * root, discovering nested repositories, auto-selecting the first one, and + * dropping a selection whose repository disappeared. + * + * Consumers still fetch their own git data for the returned `gitDirectory`; + * this hook only owns who that directory is. + */ +export const useNestedGitDirectory = ( + root: string | null, + options: UseNestedGitDirectoryOptions = {}, +) => { + const { enabled = true } = options; + const { git } = useRuntimeAPIs(); + + const rootIsGitRepo = useIsGitRepo(root); + const gitDirectory = useEffectiveGitDirectory(root); + const nestedRepos = useNestedRepos(root); + const nestedRepoSelection = useNestedRepoSelection(root); + + // Probe of the resolved repository, used to detect a stale selection. Null + // when there is nothing selected to probe. + const selectedIsGitRepo = useIsGitRepo( + gitDirectory && gitDirectory !== root ? gitDirectory : null, + ); + + const { ensureStatus, ensureNestedRepos, selectNestedRepo, clearNestedRepoSelection } = useGitStore( + useShallow((state) => ({ + ensureStatus: state.ensureStatus, + ensureNestedRepos: state.ensureNestedRepos, + selectNestedRepo: state.selectNestedRepo, + clearNestedRepoSelection: state.clearNestedRepoSelection, + })), + ); + + // Probe the root itself so nested-repo resolution never depends on some + // other surface (e.g. the sidebar badge) having probed it first. + React.useEffect(() => { + if (!enabled || !root) return; + if (rootIsGitRepo !== null) return; + void ensureStatus(root, git); + }, [enabled, ensureStatus, git, root, rootIsGitRepo]); + + // Discover nested repositories once the root probe confirms it is not one. + React.useEffect(() => { + if (!enabled || !root) return; + if (rootIsGitRepo !== false) return; + void ensureNestedRepos(root); + }, [enabled, ensureNestedRepos, root, rootIsGitRepo]); + + // Auto-select the first nested repository so the surface opens straight + // into repository data; a picker (where rendered) switches between them. + React.useEffect(() => { + if (!enabled || !root) return; + if (rootIsGitRepo !== false) return; + if (!nestedRepos || nestedRepos.length === 0) return; + if (nestedRepoSelection) return; + selectNestedRepo(root, nestedRepos[0]); + }, [enabled, nestedRepos, nestedRepoSelection, root, rootIsGitRepo, selectNestedRepo]); + + // A selected repository that is no longer a git repository is stale: drop + // the selection and re-scan so resolution reflects the current tree. + React.useEffect(() => { + if (!enabled || !root || !nestedRepoSelection) return; + if (!gitDirectory || gitDirectory === root) return; + if (selectedIsGitRepo !== false) return; + clearNestedRepoSelection(root); + void ensureNestedRepos(root, { force: true }); + }, [ + clearNestedRepoSelection, + enabled, + ensureNestedRepos, + gitDirectory, + nestedRepoSelection, + root, + selectedIsGitRepo, + ]); + + return { rootIsGitRepo, gitDirectory, nestedRepos, nestedRepoSelection }; +}; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 8dae236a..1897e087 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -148,7 +148,7 @@ Important properties: - loading state is per-directory, not global - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers - in-flight dedupe exists for status and `ensureAll()` -- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory the Git tab operates on (`root` when the root is a repository, the selected nested repository otherwise) +- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface, and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states - runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations - status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes From e26b55e0677f552e2188f77a23863b21c12a21b9 Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Tue, 25 Aug 2026 19:55:00 +0530 Subject: [PATCH 04/37] fix(git): exit nested-repo states once resolution succeeds NestedRepoResolutionStates had no success exit: on a non-repo root rootIsGitRepo stays false forever, so once repositories were found the pull-request and walkthrough tabs kept showing 'Checking repository' even after the selected repository probed as a repository. GitView never hit this because its call site sits inside its own isGitRepo === false branch. The component now takes the operating directory's probe and returns null when it resolves true. DiffView also still keyed its not-a-repository gate and every diff fetch off the raw project root, so opening a change from a nested repository showed 'This directory is not a Git repository'. It now resolves the nested repository for git data and diff operations while session-scoped lookups (session messages, review-flow directory) stay on the root. --- packages/ui/src/apps/MobileChangesSurface.tsx | 1 + packages/ui/src/components/views/DiffView.tsx | 11 ++++++++--- packages/ui/src/components/views/GitView.tsx | 1 + .../ui/src/components/views/PullRequestView.tsx | 1 + .../views/git/NestedRepoResolutionStates.tsx | 13 +++++++++++-- .../views/walkthrough/WalkthroughView.tsx | 1 + 6 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 9d12dd69..7fa06cbb 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -487,6 +487,7 @@ export const MobileChangesSurface: React.FC = ({ onCl return renderListState( { if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 28a15721..823adf72 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore'; import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore'; import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope'; @@ -997,7 +998,11 @@ export const DiffView: React.FC = ({ }) => { const { t } = useI18n(); const { git, files } = useRuntimeAPIs(); - const effectiveDirectory = useEffectiveDirectory(); + const rootDirectory = useEffectiveDirectory(); + // Diffs belong to the repository being diffed: when the root is not + // itself a repository, operate on the resolved nested repository instead. + const { gitDirectory: nestedGitDirectory } = useNestedGitDirectory(rootDirectory ?? null); + const effectiveDirectory = nestedGitDirectory ?? rootDirectory; const openContextSurface = useUIStore((state) => state.openContextSurface); const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource); const { screenWidth, isMobile } = useDeviceInfo(); @@ -1038,7 +1043,7 @@ export const DiffView: React.FC = ({ const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines); const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const sessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory ?? undefined); + const sessionMessages = useSessionMessages(currentSessionId ?? '', rootDirectory ?? undefined); const diffWrapLines = diffWrapLinesStore; const forcedStaged = activeDiffScope === 'staged' ? true : activeDiffScope === 'working' ? false : null; const activeDiffStaged = forcedStaged ?? displayFileStaged; @@ -1645,7 +1650,7 @@ export const DiffView: React.FC = ({ const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => { if (!currentSessionId) return; - const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || effectiveDirectory || ''; + const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || rootDirectory || ''; if (!directory) { toast.error(t('diffView.reviewDialog.toast.noSessionDirectory')); return; diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 5317c041..1d599aef 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -2343,6 +2343,7 @@ export const GitView: React.FC = ({ isActive }) => { return ( { if (currentDirectory) { diff --git a/packages/ui/src/components/views/PullRequestView.tsx b/packages/ui/src/components/views/PullRequestView.tsx index a597b11e..43d6ccbb 100644 --- a/packages/ui/src/components/views/PullRequestView.tsx +++ b/packages/ui/src/components/views/PullRequestView.tsx @@ -265,6 +265,7 @@ export const PullRequestView: React.FC = () => { return ( { void ensureNestedRepos(currentDirectory, { force: true }); diff --git a/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx b/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx index 1798c34e..3e9e1e6d 100644 --- a/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx +++ b/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx @@ -8,6 +8,12 @@ import type { NestedRepoDiscovery } from '@/stores/useGitStore'; type NestedRepoResolutionStatesProps = { /** Probe of the project root: `false` means nested resolution applies. */ rootIsGitRepo: boolean | null; + /** + * Probe of the directory the consumer operates on (root or selected nested + * repository). `true` means resolution succeeded and the consumer should + * render its own content. + */ + resolvedIsGitRepo: boolean | null; /** Discovery outcome for the root (`undefined` = not run yet). */ nestedRepos: NestedRepoDiscovery | undefined; onRetryDiscovery: () => void; @@ -17,8 +23,9 @@ type NestedRepoResolutionStatesProps = { /** * Shared empty/loading states for git surfaces while nested-repository - * resolution is pending, failed, or impossible. Renders null once - * repositories are resolved so the consumer can proceed into its own content. + * resolution is pending, failed, or impossible. Renders null once resolution + * has finished — either the root is a repository or the operating directory + * probed as one — so the consumer can proceed into its own content. * * A runtime without the discovery route (VS Code) reports "unsupported": the * honest state there is the plain not-a-repository empty state, without a @@ -26,6 +33,7 @@ type NestedRepoResolutionStatesProps = { */ export const NestedRepoResolutionStates: React.FC = ({ rootIsGitRepo, + resolvedIsGitRepo, nestedRepos, onRetryDiscovery, emptyStateFooter, @@ -33,6 +41,7 @@ export const NestedRepoResolutionStates: React.FC { if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); From 11a48958e84a59148244b7408df850fabc38377f Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Tue, 25 Aug 2026 20:47:02 +0530 Subject: [PATCH 05/37] fix(git): resolve blank nested-repo surfaces; add repository pickers The resolution gate on the pull-request, walkthrough, and mobile changes surfaces stayed on forever (rootIsGitRepo stays false on a non-repo root) while NestedRepoResolutionStates exits once the selected repository probes as a repository, so those surfaces rendered nothing. The gate now shows resolution states only while the operating directory has not proven to be a repository, matching GitView. Extract GitHeader's repository switcher into git/NestedRepoPicker and mount it in the diff toolbar, a new slim header in the pull-request view, the walkthrough header, and the mobile changes header. The pick is shared per root, so every surface follows. The walkthrough tab mounts keep-alive and hidden; it now receives a visible prop so discovery waits until the tab is actually opened. Add component tests for the shared resolution states. --- packages/ui/src/apps/MobileChangesSurface.tsx | 19 ++++- .../ui/src/components/layout/ContextPanel.tsx | 2 +- packages/ui/src/components/views/DiffView.tsx | 14 +++- .../src/components/views/PullRequestView.tsx | 62 +++++++++++------ .../ui/src/components/views/git/GitHeader.tsx | 46 +++---------- .../components/views/git/NestedRepoPicker.tsx | 67 ++++++++++++++++++ .../git/NestedRepoResolutionStates.test.tsx | 69 +++++++++++++++++++ .../views/walkthrough/WalkthroughView.tsx | 27 +++++++- packages/ui/src/stores/DOCUMENTATION.md | 2 +- 9 files changed, 241 insertions(+), 67 deletions(-) create mode 100644 packages/ui/src/components/views/git/NestedRepoPicker.tsx create mode 100644 packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 7fa06cbb..463886b3 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -23,6 +23,7 @@ import { useGitLoadingStatus, } from '@/stores/useGitStore'; import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates'; +import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker'; import { getRuntimeKey } from '@/lib/runtime-switch'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; @@ -69,6 +70,7 @@ export const MobileChangesSurface: React.FC = ({ onCl const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const ensureAll = useGitStore((state) => state.ensureAll); const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos); + const selectNestedRepo = useGitStore((state) => state.selectNestedRepo); const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchBranches = useGitStore((state) => state.fetchBranches); const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); @@ -472,6 +474,16 @@ export const MobileChangesSurface: React.FC = ({ onCl {status?.current || currentDirectory || ''}

+ {rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null}
{state}
@@ -481,9 +493,10 @@ export const MobileChangesSurface: React.FC = ({ onCl return renderListState(); } - // Non-repo root: surface nested-repository resolution (discovering, failed, - // unsupported, none found, or settling on the auto-selected repository). - if (rootIsGitRepo === false || isGitRepo === false) { + // Non-repo root: surface nested-repository resolution while the operating + // directory has not proven to be a repository (discovering, failed, + // unsupported, none found, or settling on the auto-selected one). + if (rootIsGitRepo === false && isGitRepo !== true) { return renderListState( { {hasWalkthroughTab ? (
- +
) : null} diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 823adf72..8791e460 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; +import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker'; import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore'; import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore'; import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope'; @@ -1001,7 +1002,7 @@ export const DiffView: React.FC = ({ const rootDirectory = useEffectiveDirectory(); // Diffs belong to the repository being diffed: when the root is not // itself a repository, operate on the resolved nested repository instead. - const { gitDirectory: nestedGitDirectory } = useNestedGitDirectory(rootDirectory ?? null); + const { rootIsGitRepo, gitDirectory: nestedGitDirectory, nestedRepos: nestedRepoOptions } = useNestedGitDirectory(rootDirectory ?? null); const effectiveDirectory = nestedGitDirectory ?? rootDirectory; const openContextSurface = useUIStore((state) => state.openContextSurface); const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource); @@ -1012,6 +1013,7 @@ export const DiffView: React.FC = ({ const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const ensureStatus = useGitStore((state) => state.ensureStatus); + const selectNestedRepo = useGitStore((state) => state.selectNestedRepo); const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchBranches = useGitStore((state) => state.fetchBranches); const clearDiffCache = useGitStore((state) => state.clearDiffCache); @@ -2044,6 +2046,16 @@ export const DiffView: React.FC = ({ return (
+ {rootIsGitRepo === false && Array.isArray(nestedRepoOptions) && nestedRepoOptions.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null} {!isMobile && ( activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? ( @@ -44,9 +45,10 @@ export const PullRequestView: React.FC = () => { const status = useGitStatus(gitDirectory ?? null); const branches = useGitBranches(gitDirectory ?? null); const isGitRepo = useIsGitRepo(gitDirectory ?? null); - const { ensureAll, ensureNestedRepos } = useGitStore(useShallow((state) => ({ + const { ensureAll, ensureNestedRepos, selectNestedRepo } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll, ensureNestedRepos: state.ensureNestedRepos, + selectNestedRepo: state.selectNestedRepo, }))); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); @@ -259,9 +261,10 @@ export const PullRequestView: React.FC = () => { ); } - // Non-repo root: surface nested-repository resolution (discovering, failed, - // unsupported, none found, or settling on the auto-selected repository). - if (rootIsGitRepo === false || isGitRepo === false) { + // Non-repo root: surface nested-repository resolution while the operating + // directory has not proven to be a repository (discovering, failed, + // unsupported, none found, or settling on the auto-selected one). + if (rootIsGitRepo === false && isGitRepo !== true) { return ( { ); } + // Repository switcher for non-repo roots with discovered nested + // repositories; the pick is shared per root across git surfaces. + const showRepositoryPicker = + rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0; + return ( - - - +
+ {showRepositoryPicker ? ( +
+ { + if (currentDirectory) selectNestedRepo(currentDirectory, repository); + }} + repositoryRoot={currentDirectory ?? undefined} + /> +
+ ) : null} + + + +
); }; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index bf80a40d..8324f7a3 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -12,12 +12,7 @@ import type { IconName } from "@/components/icon/icons"; import { BranchSelector } from './BranchSelector'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { SyncActions } from './SyncActions'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from '@/components/ui/select'; +import { NestedRepoPicker } from './NestedRepoPicker'; import type { GitStatus, GitIdentityProfile, @@ -282,11 +277,6 @@ export const GitHeader: React.FC = ({ } const repositoryOptionsForPicker = (repositoryOptions ?? []).filter(Boolean); - const repositoryRelativePath = (repository: string): string => { - const rootPrefix = `${repositoryRoot ?? ''}/`; - return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository; - }; - const repositoryLabel = selectedRepository ? repositoryRelativePath(selectedRepository) : ''; const managementButtons = (
@@ -451,33 +441,13 @@ export const GitHeader: React.FC = ({ remotes={remotes} /> )} - {repositoryOptionsForPicker.length > 0 ? ( - + {repositoryOptionsForPicker.length > 0 && onSelectRepository ? ( + ) : null}
diff --git a/packages/ui/src/components/views/git/NestedRepoPicker.tsx b/packages/ui/src/components/views/git/NestedRepoPicker.tsx new file mode 100644 index 00000000..69b009f4 --- /dev/null +++ b/packages/ui/src/components/views/git/NestedRepoPicker.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import { Icon } from '@/components/icon/Icon'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select'; +import { useI18n } from '@/lib/i18n'; + +type NestedRepoPickerProps = { + /** Discovered repository paths under the project root. */ + repositories: string[]; + /** Currently selected repository path (the operating directory). */ + selectedRepository: string | null; + onSelectRepository: (repository: string) => void; + /** Root the repository paths are relative to for display labels. */ + repositoryRoot?: string; +}; + +/** + * Repository switcher shown on git surfaces when a project root is not itself + * a git repository but nested repositories were discovered under it. + */ +export const NestedRepoPicker: React.FC = ({ + repositories, + selectedRepository, + onSelectRepository, + repositoryRoot, +}) => { + const { t } = useI18n(); + + const relativePath = (repository: string): string => { + const rootPrefix = `${repositoryRoot ?? ''}/`; + return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository; + }; + + return ( + + ); +}; diff --git a/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx b/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx new file mode 100644 index 00000000..8e685852 --- /dev/null +++ b/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { I18nProvider } from '@/lib/i18n'; + +import { NestedRepoResolutionStates } from './NestedRepoResolutionStates'; + +const render = (props: React.ComponentProps): string => + renderToStaticMarkup( + + + , + ); + +const baseProps = { + onRetryDiscovery: () => {}, +}; + +describe('NestedRepoResolutionStates', () => { + test('renders nothing while the root has not probed as a non-repository', () => { + for (const rootIsGitRepo of [null, true] as const) { + const markup = render({ ...baseProps, rootIsGitRepo, resolvedIsGitRepo: null, nestedRepos: undefined }); + expect(markup).toBe(''); + } + }); + + test('renders nothing once the operating directory resolved as a repository', () => { + const markup = render({ + ...baseProps, + rootIsGitRepo: false, + resolvedIsGitRepo: true, + nestedRepos: ['/root/one'], + }); + expect(markup).toBe(''); + }); + + test('shows the discovering state before discovery has run', () => { + const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: undefined }); + expect(markup).toContain('Looking for Git repositories...'); + }); + + test('shows the failure state with a retry when discovery failed', () => { + const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: null }); + expect(markup).toContain('Could not scan for Git repositories'); + expect(markup).toContain('Retry'); + }); + + test('shows the plain not-a-repository state with no retry when unsupported', () => { + const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: 'unsupported' }); + expect(markup).toContain('This directory is not a Git repository'); + expect(markup).not.toContain('Retry'); + }); + + test('treats an empty discovery like the not-a-repository state', () => { + const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: [] }); + expect(markup).toContain('This directory is not a Git repository'); + }); + + test('holds a checking state while repositories are found but unresolved', () => { + const markup = render({ + ...baseProps, + rootIsGitRepo: false, + resolvedIsGitRepo: null, + nestedRepos: ['/root/one', '/root/two'], + }); + expect(markup).toContain('Checking repository...'); + }); +}); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index c085fdaa..a504eb34 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -38,9 +38,16 @@ import { useWalkthroughStageProgress } from './useWalkthroughStageProgress'; import { WalkthroughStream } from './WalkthroughStream'; import { WalkthroughToc } from './WalkthroughToc'; import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates'; +import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker'; interface WalkthroughViewProps { directory: string; + /** + * The context panel keeps this view mounted but hidden via CSS, so work + * that should only run for a visible consumer has to be told. Defaults to + * true for mounts that have no visibility signal. + */ + visible?: boolean; } const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working']; @@ -75,7 +82,7 @@ const TOC_MAX_FRACTION = 0.5; // pickers, 32px action, 36px arrows) read as misalignment, not hierarchy. const HEADER_COMPACT_WIDTH = 680; -export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewProps) => { +export const WalkthroughView = ({ directory: rootDirectory, visible = true }: WalkthroughViewProps) => { const { t, locale, locales, label } = useI18n(); const rootRef = useRef(null); const [panelWidth, setPanelWidth] = useState(0); @@ -83,7 +90,7 @@ export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewPro // The walkthrough documents one repository. When the root is not itself a // repository, that is the resolved nested repository; everything below keys // off `directory`. - const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null); + const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null, { enabled: visible }); const directory = gitDirectory ?? rootDirectory; // Panel width, not viewport width: this surface is resizable independently of @@ -494,7 +501,11 @@ export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewPro const isGitRepo = useIsGitRepo(gitDirectory || null); const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos); - if (rootIsGitRepo === false || isGitRepo === false) { + const selectNestedRepo = useGitStore((state) => state.selectNestedRepo); + // Non-repo root: surface nested-repository resolution while the operating + // directory has not proven to be a repository (discovering, failed, + // unsupported, none found, or settling on the auto-selected one). + if (rootIsGitRepo === false && isGitRepo !== true) { return (
+ {rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null} + {showLinearPicker ? ( + + ) : null}
) : null} diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 7f1d4f26..13b24224 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -558,17 +558,29 @@ interface FilePart { const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link'; const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link'; +const LINEAR_ISSUE_LINK_MIME = 'application/vnd.openchamber.linear-issue-link'; -const getGitHubLinkKind = (file: FilePart): 'issue' | 'pr' | null => { +type IssueLinkKind = 'github-issue' | 'github-pr' | 'linear-issue'; + +const getIssueLinkKind = (file: FilePart): IssueLinkKind | null => { if (file.mime === GITHUB_ISSUE_LINK_MIME) { - return 'issue'; + return 'github-issue'; } if (file.mime === GITHUB_PR_LINK_MIME) { - return 'pr'; + return 'github-pr'; + } + if (file.mime === LINEAR_ISSUE_LINK_MIME) { + return 'linear-issue'; } return null; }; +const issueLinkIcon = (kind: IssueLinkKind): 'github' | 'git-pull-request' | 'linear' => { + if (kind === 'github-pr') return 'git-pull-request'; + if (kind === 'linear-issue') return 'linear'; + return 'github'; +}; + interface MessageFilesDisplayProps { files: FilePart[]; onShowPopup?: (content: ToolPopupContent) => void; @@ -591,7 +603,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } }; const resolveDisplayName = React.useCallback((file: FilePart): string => { - const isGitHubLink = getGitHubLinkKind(file) !== null; + const isGitHubLink = getIssueLinkKind(file) !== null; if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) { return file.filename.trim(); } @@ -665,11 +677,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } const fileName = resolveDisplayName(file); const ext = fileName.split('.').pop() || ''; const sizeText = formatFileSize(file.size); - const githubLinkKind = getGitHubLinkKind(file); + const issueLinkKind = getIssueLinkKind(file); return ( - {githubLinkKind && file.url ? ( + {issueLinkKind && file.url ? (
- )} - showSaveStatus={false} + description={t('settings.page.integrations.description')} + showSaveStatus > + {hasLinear ? : null} diff --git a/packages/ui/src/components/sections/integrations/LinearProjectMapping.tsx b/packages/ui/src/components/sections/integrations/LinearProjectMapping.tsx new file mode 100644 index 00000000..138bad49 --- /dev/null +++ b/packages/ui/src/components/sections/integrations/LinearProjectMapping.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + SettingsControlGroup, + SettingsFieldRow, + SETTINGS_FIELDS_STACK_CLASS, + SETTINGS_SELECT_ROW_TRIGGER_CLASS, + SETTINGS_SELECT_SIZE, +} from '@/components/sections/shared/SettingsSection'; +import { reportSettingsSaveState } from '@/lib/persistence'; +import { useI18n } from '@/lib/i18n'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import type { LinearAPI, LinearMappingResult } from '@/lib/api/types'; + +const NONE = '__none__'; +const INHERIT = '__inherit__'; + +export function LinearProjectMapping({ + linear, + connected, + organizationId, +}: { + linear: LinearAPI; + connected: boolean; + organizationId?: string | null; +}) { + const { t } = useI18n(); + const projects = useProjectsStore((state) => state.projects); + const [mapping, setMapping] = React.useState(null); + const [loadFailed, setLoadFailed] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + + const loadMapping = React.useCallback(async () => { + if (!connected) { + setMapping(null); + setLoadFailed(false); + return; + } + try { + const next = await linear.mappingGet(); + if (next.connected === false) { + setMapping(null); + setLoadFailed(false); + return; + } + setMapping(next); + setLoadFailed(false); + } catch (error) { + console.error('Failed to load Linear mapping:', error); + setLoadFailed(true); + } + }, [connected, linear, organizationId]); + + React.useEffect(() => { + void loadMapping(); + }, [loadMapping]); + + const saveMapping = React.useCallback(async (next: LinearMappingResult) => { + const teamProjectPaths: { [teamId: string]: string } = {}; + for (const team of next.teams ?? []) { + if (team.projectPath) { + teamProjectPaths[team.id] = team.projectPath; + } + } + setIsSaving(true); + reportSettingsSaveState('saving'); + try { + const saved = await linear.mappingSet({ + defaultProjectPath: next.defaultProjectPath ?? null, + teamProjectPaths, + }); + if (saved.connected === false) { + setMapping(null); + reportSettingsSaveState('error'); + return; + } + setMapping(saved); + setLoadFailed(false); + reportSettingsSaveState('saved'); + } catch (error) { + console.error('Failed to save Linear mapping:', error); + reportSettingsSaveState('error'); + } finally { + setIsSaving(false); + } + }, [linear]); + + if (!connected) { + return null; + } + + if (loadFailed && !mapping) { + return ( +

+ {t('settings.integrations.linear.mapping.loadFailed')} +

+ ); + } + + if (!mapping) { + return null; + } + + const projectLabel = (path: string) => { + const project = projects.find((entry) => entry.path === path); + return project?.label?.trim() || path; + }; + + const defaultProjectLabel = (value: string | undefined) => { + if (!value || value === NONE) { + return t('settings.integrations.linear.mapping.defaultProject.placeholder'); + } + return projectLabel(value); + }; + + const teamProjectLabel = (value: string | undefined) => { + if (!value || value === INHERIT) { + return t('settings.integrations.linear.mapping.teams.useDefault'); + } + return projectLabel(value); + }; + + return ( +
+ {projects.length === 0 ? ( +

+ {t('settings.integrations.linear.mapping.emptyProjects')} +

+ ) : null} + + + + + + + {(mapping.teams ?? []).length === 0 ? ( +

+ {t('settings.integrations.linear.mapping.emptyTeams')} +

+ ) : ( +
+ {(mapping.teams ?? []).map((team) => ( + + + + ))} +
+ )} +
+
+ ); +} diff --git a/packages/ui/src/components/sections/integrations/LinearSessionComments.tsx b/packages/ui/src/components/sections/integrations/LinearSessionComments.tsx new file mode 100644 index 00000000..5593c70a --- /dev/null +++ b/packages/ui/src/components/sections/integrations/LinearSessionComments.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Switch } from '@/components/ui/switch'; +import { + SettingsFieldRow, + SETTINGS_FIELDS_STACK_CLASS, +} from '@/components/sections/shared/SettingsSection'; +import { reportSettingsSaveState } from '@/lib/persistence'; +import { useI18n } from '@/lib/i18n'; +import type { LinearAPI } from '@/lib/api/types'; + +/** + * Status comments are written into a Linear workspace other people read, so + * they stay off until the user turns them on. The server posts nothing while + * this is off, including the completed and failure comments the event hub + * sends without going through this interface. + */ +export function LinearSessionComments({ + linear, + connected, +}: { + linear: LinearAPI; + connected: boolean; +}) { + const { t } = useI18n(); + const [enabled, setEnabled] = React.useState(null); + const [loadFailed, setLoadFailed] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + + React.useEffect(() => { + if (!connected) { + setEnabled(null); + setLoadFailed(false); + return; + } + let cancelled = false; + void linear.preferencesGet() + .then((preferences) => { + if (cancelled) return; + setEnabled(preferences.sessionComments); + setLoadFailed(false); + }) + .catch(() => { + if (cancelled) return; + setLoadFailed(true); + }); + return () => { + cancelled = true; + }; + }, [connected, linear]); + + const save = React.useCallback(async (next: boolean) => { + const previous = enabled; + setEnabled(next); + setIsSaving(true); + try { + const saved = await linear.preferencesSet({ sessionComments: next }); + setEnabled(saved.sessionComments); + reportSettingsSaveState('saved'); + } catch { + setEnabled(previous); + reportSettingsSaveState('error'); + } finally { + setIsSaving(false); + } + }, [enabled, linear]); + + if (!connected) { + return null; + } + + if (loadFailed) { + return ( +

+ {t('settings.integrations.linear.sessionComments.loadFailed')} +

+ ); + } + + return ( +
+ + { void save(checked); }} + aria-label={t('settings.integrations.linear.sessionComments.aria')} + /> + +
+ ); +} diff --git a/packages/ui/src/components/sections/integrations/LinearSettings.tsx b/packages/ui/src/components/sections/integrations/LinearSettings.tsx new file mode 100644 index 00000000..fc71aadb --- /dev/null +++ b/packages/ui/src/components/sections/integrations/LinearSettings.tsx @@ -0,0 +1,350 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; +import { cn } from '@/lib/utils'; +import { openExternalUrl } from '@/lib/url'; +import { useI18n } from '@/lib/i18n'; +import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop'; +import { Icon } from '@/components/icon/Icon'; +import { SettingsSection } from '@/components/sections/shared/SettingsSection'; +import { LinearProjectMapping } from './LinearProjectMapping'; +import { LinearSessionComments } from './LinearSessionComments'; + +const AUTHORIZATION_WATCH_MS = 3 * 60_000; +const AUTHORIZATION_POLL_MS = 1_500; + +type WorkspaceSnapshot = { + connected: boolean; + ids: string; + currentId: string; + currentAuthorizedAt: number; +}; + +function snapshotWorkspaces(status: { + connected?: boolean; + organization?: { id?: string } | null; + workspaces?: Array<{ id: string; current: boolean; authorizedAt?: number | null }>; +} | null): WorkspaceSnapshot { + const workspaces = status?.workspaces ?? []; + const current = workspaces.find((entry) => entry.current); + return { + connected: Boolean(status?.connected), + ids: workspaces.map((entry) => entry.id).slice().sort().join(','), + currentId: current?.id || status?.organization?.id || '', + currentAuthorizedAt: current?.authorizedAt ?? 0, + }; +} + +function authorizationCompleted(previous: WorkspaceSnapshot, next: WorkspaceSnapshot): boolean { + if (!next.connected) return false; + if (!previous.connected) return true; + return next.ids !== previous.ids + || next.currentId !== previous.currentId + || next.currentAuthorizedAt !== previous.currentAuthorizedAt; +} + +export const LinearSettings: React.FC = () => { + const { t } = useI18n(); + const runtimeLinear = getRegisteredRuntimeAPIs()?.linear; + const status = useLinearAuthStore((state) => state.status); + const isLoading = useLinearAuthStore((state) => state.isLoading); + const hasChecked = useLinearAuthStore((state) => state.hasChecked); + const refreshStatus = useLinearAuthStore((state) => state.refreshStatus); + const setStatus = useLinearAuthStore((state) => state.setStatus); + + const [isBusy, setIsBusy] = React.useState(false); + const [isWaiting, setIsWaiting] = React.useState(false); + const [open, setOpen] = React.useState(false); + const pollTimerRef = React.useRef(null); + + const stopWaiting = React.useCallback(() => { + if (pollTimerRef.current != null) { + window.clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + setIsWaiting(false); + }, []); + + React.useEffect(() => { + if (!runtimeLinear) { + return; + } + if (!hasChecked) { + void refreshStatus(runtimeLinear); + } + return () => { + stopWaiting(); + }; + }, [hasChecked, refreshStatus, runtimeLinear, stopWaiting]); + + const startConnect = React.useCallback(async () => { + if (!runtimeLinear) return; + stopWaiting(); + setIsBusy(true); + const previous = snapshotWorkspaces(useLinearAuthStore.getState().status); + try { + const payload = await runtimeLinear.authStart(isDesktopShell() ? 'desktop' : 'web'); + setIsWaiting(true); + setOpen(true); + void openExternalUrl(payload.authorizationUrl); + + const deadline = Date.now() + AUTHORIZATION_WATCH_MS; + pollTimerRef.current = window.setInterval(() => { + void (async () => { + if (Date.now() > deadline) { + stopWaiting(); + toast.error(t('settings.integrations.linear.toast.authorizationFailed')); + return; + } + const next = await refreshStatus(runtimeLinear, { force: true }); + if (authorizationCompleted(previous, snapshotWorkspaces(next))) { + stopWaiting(); + toast.success(t('settings.integrations.linear.toast.connected')); + void focusDesktopWindow(); + } + })(); + }, AUTHORIZATION_POLL_MS); + } catch (error) { + console.error('Failed to start Linear connect:', error); + toast.error(t('settings.integrations.linear.toast.startConnectFailed')); + stopWaiting(); + } finally { + setIsBusy(false); + } + }, [refreshStatus, runtimeLinear, stopWaiting, t]); + + const activateWorkspace = React.useCallback(async (organizationId: string) => { + if (!runtimeLinear || !organizationId) return; + setIsBusy(true); + try { + const payload = await runtimeLinear.authActivate(organizationId); + setStatus(payload); + toast.success(t('settings.integrations.linear.toast.workspaceSwitched')); + } catch (error) { + console.error('Failed to switch Linear workspace:', error); + toast.error(t('settings.integrations.linear.toast.workspaceSwitchFailed')); + } finally { + setIsBusy(false); + } + }, [runtimeLinear, setStatus, t]); + + const disconnect = React.useCallback(async () => { + if (!runtimeLinear) return; + setIsBusy(true); + try { + stopWaiting(); + await runtimeLinear.authDisconnect(); + toast.success(t('settings.integrations.linear.toast.disconnected')); + await refreshStatus(runtimeLinear, { force: true }); + } catch (error) { + console.error('Failed to disconnect Linear:', error); + toast.error(t('settings.integrations.linear.toast.disconnectFailed')); + } finally { + setIsBusy(false); + } + }, [refreshStatus, runtimeLinear, stopWaiting, t]); + + if (!runtimeLinear) { + return null; + } + + const connected = Boolean(status?.connected); + const user = status?.user; + const organization = status?.organization; + const workspaces = status?.workspaces ?? []; + const otherWorkspaces = workspaces.filter((workspace) => !workspace.current); + const displayName = user?.displayName?.trim() || user?.name?.trim() || t('settings.integrations.linear.label.unknownUser'); + const statusLabel = isWaiting + ? t('settings.integrations.linear.status.waiting') + : isLoading && !hasChecked + ? t('common.loading') + : connected + ? (organization?.name?.trim() || t('settings.integrations.linear.status.connected')) + : t('settings.integrations.linear.status.notConnected'); + const statusClassName = isWaiting + ? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]' + : connected + ? 'bg-[var(--status-success)]/15 text-[var(--status-success)]' + : 'bg-[var(--surface-muted)] text-muted-foreground'; + const expanded = isWaiting || open; + + return ( + + { + if (isWaiting) { + setOpen(true); + return; + } + setOpen(nextOpen); + }} + > +
+ +
+ +
+
+
+ {t('settings.integrations.linear.title')} +
+

+ {t('settings.integrations.linear.description')} +

+
+ + {statusLabel} + + +
+ +
+ {connected ? ( +
+ {user?.avatarUrl ? ( + {t('settings.integrations.linear.avatarAlt.withName', + ) : ( +
+ +
+ )} +
+
{displayName}
+

+ {[organization?.name, user?.email].filter(Boolean).join(' · ')} +

+
+
+ ) : isWaiting ? ( +

+ {t('settings.integrations.linear.flow.description')} +

+ ) : null} + + {connected ? ( + <> + + + {otherWorkspaces.length > 0 ? ( +
+

+ {t('settings.integrations.linear.label.otherWorkspaces')} +

+
+ {otherWorkspaces.map((workspace) => { + const workspaceUser = workspace.user; + const workspaceName = workspace.name?.trim() + || t('settings.integrations.linear.status.connected'); + return ( +
+
+
{workspaceName}
+ {workspaceUser?.email ? ( +

{workspaceUser.email}

+ ) : null} +
+ +
+ ); + })} +
+
+ ) : null} +
+ + +
+ + ) : isWaiting ? ( +
+ + {t('settings.integrations.linear.flow.waiting')} + + +
+ ) : ( + + )} +
+
+
+
+
+ ); +}; diff --git a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx index e9cf51bf..542f765b 100644 --- a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx +++ b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx @@ -414,6 +414,12 @@ export const ThirdPartyIntegrationsSection: React.FC +
+ +

+ {t('settings.integrations.experimentalWarning')} +

+
{THIRD_PARTY_PLUGINS.map(renderPlugin)} diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx index 1b1792c6..0f6b7ec3 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx @@ -61,6 +61,14 @@ const PROMPT_PAGE_MAP: Record = { { id: 'github.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, ], }, + 'linear.issue.review': { + titleKey: 'settings.magicPrompts.page.group.linearIssueReview.title', + descriptionKey: 'settings.magicPrompts.page.group.linearIssueReview.description', + blocks: [ + { id: 'linear.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' }, + { id: 'linear.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, + ], + }, 'github.pr.checks.review': { titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title', descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description', diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx index c25105ef..2ea33ba1 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx @@ -35,6 +35,12 @@ export const MagicPromptsSidebar: React.FC = ({ onItem { id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' }, ], }, + { + groupKey: 'settings.magicPrompts.sidebar.group.linear', + items: [ + { id: 'linear.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.linearIssueReview' }, + ], + }, { groupKey: 'settings.magicPrompts.sidebar.group.planning', items: [ diff --git a/packages/ui/src/components/session/LinearIssuePickerDialog.tsx b/packages/ui/src/components/session/LinearIssuePickerDialog.tsx new file mode 100644 index 00000000..9bf7c0e7 --- /dev/null +++ b/packages/ui/src/components/session/LinearIssuePickerDialog.tsx @@ -0,0 +1,492 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { toast } from '@/components/ui'; +import { Icon } from '@/components/icon/Icon'; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useUIStore } from '@/stores/useUIStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { useDeviceInfo } from '@/lib/device'; +import { buildIssueContextText, startLinearIssueSession } from '@/lib/linearStartSession'; +import type { LinearIssueSummary, LinearMappingResult } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; + +const parseLinearIssueQuery = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + const urlMatch = trimmed.match(/linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i); + if (urlMatch) return urlMatch[1].toUpperCase(); + if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmed)) return trimmed.toUpperCase(); + return null; +}; + +export function LinearIssuePickerDialog({ + open, + onOpenChange, + mode = 'select', + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + mode?: 'createSession' | 'select'; + onSelect?: (issue: { + identifier: string; + title: string; + url: string; + contextText: string; + author?: { login: string; avatarUrl?: string }; + }) => void; +}) { + const { t } = useI18n(); + const { linear } = useRuntimeAPIs(); + const linearAuthStatus = useLinearAuthStore((state) => state.status); + const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); + const refreshStatus = useLinearAuthStore((state) => state.refreshStatus); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const isMobile = useUIStore((state) => state.isMobile); + const { isTablet } = useDeviceInfo(); + const alwaysShowActions = isMobile || isTablet; + + const [query, setQuery] = React.useState(''); + const [issues, setIssues] = React.useState([]); + const [cursor, setCursor] = React.useState(null); + const [hasMore, setHasMore] = React.useState(false); + const [connected, setConnected] = React.useState(true); + const [startingIssueKey, setStartingIssueKey] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [createInWorktree, setCreateInWorktree] = React.useState(false); + const [mapping, setMapping] = React.useState(null); + const [mappingError, setMappingError] = React.useState(null); + const listRequestId = React.useRef(0); + + const directIdentifier = React.useMemo(() => parseLinearIssueQuery(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + + const refresh = React.useCallback(async (search = '') => { + if (linearAuthChecked && linearAuthStatus?.connected === false) { + setConnected(false); + setIssues([]); + setHasMore(false); + setCursor(null); + setError(null); + return; + } + if (!linear?.issuesList) { + setConnected(true); + setError(t('session.linearIssuePicker.error.runtimeUnavailable')); + return; + } + + const requestId = listRequestId.current + 1; + listRequestId.current = requestId; + setIsLoading(true); + setError(null); + try { + const next = await linear.issuesList(search ? { query: search } : undefined); + if (requestId !== listRequestId.current) return; + setConnected(next.connected !== false); + setIssues(next.issues ?? []); + setCursor(next.cursor ?? null); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + if (requestId !== listRequestId.current) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (requestId === listRequestId.current) { + setIsLoading(false); + } + } + }, [linear, linearAuthChecked, linearAuthStatus, t]); + + const refreshMapping = React.useCallback(async () => { + if (mode !== 'createSession') { + setMapping(null); + setMappingError(null); + return; + } + if (!linear?.mappingGet) { + setMapping(null); + setMappingError(t('session.linearIssuePicker.error.runtimeUnavailable')); + return; + } + try { + const next = await linear.mappingGet(); + setMapping(next); + setMappingError(null); + } catch (e) { + setMapping(null); + setMappingError(e instanceof Error ? e.message : String(e)); + } + }, [linear, mode, t]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setStartingIssueKey(null); + setError(null); + setIssues([]); + setCursor(null); + setHasMore(false); + setIsLoading(false); + setConnected(true); + setCreateInWorktree(false); + setMapping(null); + setMappingError(null); + return; + } + if (linear && !linearAuthChecked) { + void refreshStatus(linear); + } + }, [open, linear, linearAuthChecked, refreshStatus]); + + React.useEffect(() => { + if (!open) return; + void refresh(debouncedQuery.trim()); + }, [open, debouncedQuery, refresh]); + + React.useEffect(() => { + if (!open) return; + void refreshMapping(); + }, [open, refreshMapping]); + + const loadMore = React.useCallback(async () => { + if (!linear?.issuesList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore || !cursor) return; + + const requestId = listRequestId.current + 1; + listRequestId.current = requestId; + setIsLoadingMore(true); + try { + const search = debouncedQuery.trim(); + const next = await linear.issuesList({ + query: search || undefined, + cursor, + }); + if (requestId !== listRequestId.current) return; + setConnected(next.connected !== false); + setIssues((prev) => [...prev, ...(next.issues ?? [])]); + setCursor(next.cursor ?? null); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + if (requestId !== listRequestId.current) return; + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadMoreFailed'), { description: message }); + } finally { + if (requestId === listRequestId.current) { + setIsLoadingMore(false); + } + } + }, [cursor, debouncedQuery, hasMore, isLoading, isLoadingMore, linear, t]); + + const openLinearSettings = React.useCallback(() => { + setSettingsPage('integrations'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const selectIssue = React.useCallback(async (issueKey: string) => { + if (!linear?.issueGet) { + toast.error(t('session.linearIssuePicker.error.runtimeUnavailable')); + return; + } + if (startingIssueKey) return; + setStartingIssueKey(issueKey); + try { + const issueRes = await linear.issueGet(issueKey); + if (issueRes.connected === false) { + toast.error(t('session.linearIssuePicker.error.notConnected')); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.linearIssuePicker.error.issueNotFound')); + return; + } + const comments = issue.comments ?? []; + const login = issue.assignee?.displayName || issue.assignee?.name; + onSelect?.({ + identifier: issue.identifier, + title: issue.title, + url: issue.url, + contextText: buildIssueContextText({ issue, comments }), + author: login + ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined } + : undefined, + }); + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); + } finally { + setStartingIssueKey(null); + } + }, [linear, onOpenChange, onSelect, startingIssueKey, t]); + + const startSession = React.useCallback(async (issueKey: string) => { + if (startingIssueKey) return; + setStartingIssueKey(issueKey); + try { + await startLinearIssueSession({ + linear, + issueKey, + createInWorktree, + mapping, + onMappingLoaded: (next) => { + setMapping(next); + setMappingError(null); + }, + onSessionCreated: () => onOpenChange(false), + t, + }); + } finally { + setStartingIssueKey(null); + } + }, [createInWorktree, linear, mapping, onOpenChange, startingIssueKey, t]); + + const handleIssue = React.useCallback((issueKey: string) => { + if (mode === 'select') { + void selectIssue(issueKey); + return; + } + void startSession(issueKey); + }, [mode, selectIssue, startSession]); + + const title = mode === 'select' + ? t('session.linearIssuePicker.title') + : t('session.linearIssuePicker.title.createSession'); + const description = mode === 'select' + ? t('session.linearIssuePicker.description') + : t('session.linearIssuePicker.description.createSession'); + const showDisconnected = linearAuthChecked && connected === false; + const runtimeMissing = !linear; + + const content = ( + <> +
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ {runtimeMissing ? ( +
{t('session.linearIssuePicker.empty.runtimeUnavailable')}
+ ) : null} + + {mode === 'createSession' && mappingError ? ( +
{mappingError}
+ ) : null} + + {isLoading ? ( +
+ + {t('session.linearIssuePicker.loading.issues')} +
+ ) : null} + + {showDisconnected ? ( +
+
{t('session.linearIssuePicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directIdentifier && linear && connected ? ( +
handleIssue(directIdentifier)} + > + + {directIdentifier} + +

+ {t('session.linearIssuePicker.actions.useIssue', { identifier: directIdentifier })} +

+
+ {startingIssueKey === directIdentifier ? ( + + ) : null} +
+
+ ) : null} + + {issues.length === 0 && !isLoading && connected && linear ? ( +
+ {debouncedQuery.trim() + ? t('session.linearIssuePicker.empty.noIssuesFound') + : t('session.linearIssuePicker.empty.noOpenIssuesFound')} +
+ ) : null} + + {issues.map((issue) => ( +
+ ))} + + {hasMore && connected && linear ? ( +
+ +
+ ) : null} +
+ + {mode !== 'select' ? ( +
+

{t('session.linearIssuePicker.actions.sectionTitle')}

+
+
setCreateInWorktree((value) => !value)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setCreateInWorktree((value) => !value); + } + }} + > + + {t('session.linearIssuePicker.actions.createInWorktree')} +
+
+ +
+
+ ) : null} + + ); + + if (isMobile) { + return ( + onOpenChange(false)} + renderHeader={(closeButton) => ( +
+
+

{title}

+ {closeButton} +
+

{description}

+
+ )} + > + {content} +
+ ); + } + + return ( + + + + + + {title} + + + {description} + + + {content} + + + ); +} diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 2912c7d8..c07ec407 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -27,11 +27,12 @@ import { cn } from '@/lib/utils'; import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; -import { buildLinkedIssue } from '@/lib/linkedIssues'; +import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues'; import { useConfigStore } from '@/stores/useConfigStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; @@ -40,6 +41,7 @@ import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/ope import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { postLinearSessionStarted } from '@/lib/linearSessionStatus'; import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch'; import { @@ -50,6 +52,7 @@ import { import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; +import { LinearIssuePickerDialog } from './LinearIssuePickerDialog'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; @@ -59,6 +62,8 @@ import type { GitHubIssuesListResult, GitHubPullRequestContextResult, GitHubPullRequestSummary, + LinearIssue, + LinearIssueComment, } from '@/lib/api/types'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useI18n } from '@/lib/i18n'; @@ -72,6 +77,13 @@ interface ValidationState { touched: boolean; } +type LinkedLinearWorktreeIssue = { + identifier: string; + title: string; + url: string; + author?: { login: string; avatarUrl?: string }; +}; + // State for New Branch mode interface NewBranchState { branchName: string; @@ -80,6 +92,7 @@ interface NewBranchState { sourceBranch: string; linkedIssue: GitHubIssue | null; linkedPr: GitHubPullRequestSummary | null; + linkedLinearIssue: LinkedLinearWorktreeIssue | null; includePrDiff: boolean; } @@ -209,16 +222,29 @@ const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; +const buildLinearIssueContextText = (args: { + issue: LinearIssue; + comments: LinearIssueComment[]; +}) => { + const payload = { + issue: args.issue, + comments: args.comments, + }; + return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + export function NewWorktreeDialog({ open, onOpenChange, onWorktreeCreated, }: NewWorktreeDialogProps) { const { t } = useI18n(); - const { github, git } = useRuntimeAPIs(); + const { github, git, linear } = useRuntimeAPIs(); const isMobile = useUIStore((state) => state.isMobile); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); + const linearAuthStatus = useLinearAuthStore((state) => state.status); + const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); const activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; @@ -240,6 +266,7 @@ export function NewWorktreeDialog({ sourceBranch: '', linkedIssue: null, linkedPr: null, + linkedLinearIssue: null, includePrDiff: false, }); @@ -290,6 +317,7 @@ export function NewWorktreeDialog({ }, [existingWorktreeNames]); const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); + const [linearDialogOpen, setLinearDialogOpen] = React.useState(false); // Desktop branch picker states const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false); @@ -480,12 +508,9 @@ export function NewWorktreeDialog({ directory: string; issue: GitHubIssue | null; pr: GitHubPullRequestSummary | null; + linearIssue: LinkedLinearWorktreeIssue | null; includeDiff: boolean; }) => { - if (!projectDirectory || !github) { - return; - } - const configState = useConfigStore.getState(); const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); @@ -500,6 +525,69 @@ export function NewWorktreeDialog({ const variant = resolveDefaultVariant(providerID, modelID); + if (args.linearIssue) { + if (!linear?.issueGet) { + return; + } + + const issueRes = await linear.issueGet(args.linearIssue.identifier); + if (issueRes.connected === false || !issueRes.issue) { + throw new Error('Failed to load issue context'); + } + + const issue = issueRes.issue; + const comments = issue.comments ?? []; + const login = issue.assignee?.displayName || issue.assignee?.name; + const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', { + identifier: issue.identifier, + }); + const instructionsText = await renderMagicPrompt('linear.issue.review.instructions'); + const contextText = buildLinearIssueContextText({ issue, comments }); + + postLinearSessionStarted(linear, { + sessionId: args.sessionId, + issueIdentifier: issue.identifier, + }); + + await useSessionUIStore.getState().sendMessage( + visiblePromptText, + providerID, + modelID, + agentName, + undefined, + undefined, + [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + variant, + undefined, + { sessionId: args.sessionId, directory: args.directory }, + ); + + void sessionActions.setLinkedIssue( + args.sessionId, + args.directory, + buildLinkedLinearIssue({ + identifier: issue.identifier, + title: issue.title, + url: issue.url, + author: login + ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined } + : args.linearIssue.author, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + toast.success(t('session.newWorktree.toast.sessionFromIssue')); + return; + } + + if (!projectDirectory || !github) { + return; + } + if (args.issue) { if (!github.issueGet || !github.issueComments) { return; @@ -615,6 +703,7 @@ export function NewWorktreeDialog({ } }, [ github, + linear, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, @@ -702,6 +791,7 @@ export function NewWorktreeDialog({ sourceBranch: '', linkedIssue: null, linkedPr: null, + linkedLinearIssue: null, includePrDiff: false, }); }, [open, generateUniqueSlug]); @@ -862,9 +952,10 @@ export function NewWorktreeDialog({ try { const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; + const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null; const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; - const shouldCreateSession = Boolean(linkedIssue || linkedPrState); + const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue); const setupCommands = await getWorktreeSetupCommands(projectRef); const sourceBranch = newBranchState.sourceBranch; @@ -914,7 +1005,9 @@ export function NewWorktreeDialog({ await waitForWorktreeBootstrap(metadata.path); } - const sessionTitle = linkedIssue + const sessionTitle = linkedLinearIssue + ? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim() + : linkedIssue ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() : linkedPrState ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() @@ -966,10 +1059,14 @@ export function NewWorktreeDialog({ directory: metadata.path, issue: linkedIssue, pr: linkedPrState, + linearIssue: linkedLinearIssue, includeDiff: includePrDiff, }).catch((error) => { - const message = error instanceof Error ? error.message : t('session.newWorktree.error.sendGitHubContextFailed'); - toast.error(t('session.newWorktree.error.sendGitHubContextFailed'), { description: message }); + const fallback = linkedLinearIssue + ? t('session.newWorktree.error.sendLinearContextFailed') + : t('session.newWorktree.error.sendGitHubContextFailed'); + const message = error instanceof Error ? error.message : fallback; + toast.error(fallback, { description: message }); }); } else { onWorktreeCreated?.(metadata.path); @@ -999,6 +1096,7 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: null, linkedPr: null, + linkedLinearIssue: null, includePrDiff: false, branchName: '', })); @@ -1012,6 +1110,7 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: issue, linkedPr: null, + linkedLinearIssue: null, includePrDiff: false, branchName: newBranchName, worktreeName: slugifyWorktreeName(newBranchName), @@ -1023,6 +1122,7 @@ export function NewWorktreeDialog({ ...prev, linkedPr: pr, linkedIssue: null, + linkedLinearIssue: null, includePrDiff: result.includeDiff ?? false, branchName: pr.head, worktreeName: slugifyWorktreeName(pr.head), @@ -1031,8 +1131,33 @@ export function NewWorktreeDialog({ } }; + const handleLinearSelect = (issue: { + identifier: string; + title: string; + url: string; + author?: { login: string; avatarUrl?: string }; + }) => { + const newBranchName = `issue-${issue.identifier}-${generateBranchSlug()}`; + setNewBranchState(prev => ({ + ...prev, + linkedLinearIssue: { + identifier: issue.identifier, + title: issue.title, + url: issue.url, + author: issue.author, + }, + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + branchName: newBranchName, + worktreeName: slugifyWorktreeName(newBranchName), + isSyncingWorktreeName: true, + })); + }; + // GitHub connection check const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; + const isLinearConnected = Boolean(linear) && linearAuthChecked && linearAuthStatus?.connected === true; // Check if form is valid for submission const isFormValid = mode === 'existing-branch' @@ -1046,12 +1171,42 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: null, linkedPr: null, + linkedLinearIssue: null, branchName: '', includePrDiff: false, isSyncingWorktreeName: true, })); }; + const startFromIssueButtons = mode === 'new-branch' && (isGitHubConnected || isLinearConnected) ? ( +
+ {isGitHubConnected && ( + + )} + {isLinearConnected && ( + + )} +
+ ) : null; + // Footer content const footerContent = (
@@ -1277,21 +1432,11 @@ export function NewWorktreeDialog({
) : (
-
-
)} + {newBranchState.linkedLinearIssue && ( +
+ + + {t('session.newWorktree.fromLinearIssue', { + identifier: newBranchState.linkedLinearIssue.identifier, + title: newBranchState.linkedLinearIssue.title, + })} + +
+ )}
)} @@ -1527,12 +1684,23 @@ export function NewWorktreeDialog({ )} {/* Linked Item Preview - Two row minimal display */} - {(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && ( + {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
- + + {newBranchState.linkedLinearIssue && ( + + {newBranchState.linkedLinearIssue.identifier} + + )} {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} @@ -1545,11 +1713,11 @@ export function NewWorktreeDialog({ )} - {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} ) : (
-
-
)} + {newBranchState.linkedLinearIssue && ( +
+ + + {t('session.newWorktree.fromLinearIssue', { + identifier: newBranchState.linkedLinearIssue.identifier, + title: newBranchState.linkedLinearIssue.title, + })} + +
+ )}
)} @@ -1968,12 +2138,23 @@ export function NewWorktreeDialog({ )} {/* Linked Item Preview - Two row minimal display */} - {(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && ( + {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
- + + {newBranchState.linkedLinearIssue && ( + + {newBranchState.linkedLinearIssue.identifier} + + )} {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} @@ -1986,11 +2167,11 @@ export function NewWorktreeDialog({ )} - {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + ); } diff --git a/packages/ui/src/components/views/LinearIssuesView.tsx b/packages/ui/src/components/views/LinearIssuesView.tsx new file mode 100644 index 00000000..8454e81c --- /dev/null +++ b/packages/ui/src/components/views/LinearIssuesView.tsx @@ -0,0 +1,1168 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import type { IconName } from '@/components/icon/icons'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { ScrollShadow } from '@/components/ui/ScrollShadow'; +import { toast } from '@/components/ui'; +import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; +import { useUIStore, LINEAR_ISSUE_LIST_ALL_TEAMS } from '@/stores/useUIStore'; +import { useI18n } from '@/lib/i18n'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import { openExternalUrl } from '@/lib/url'; +import { startLinearIssueSession } from '@/lib/linearStartSession'; +import type { + LinearIssue, + LinearIssueLabel, + LinearIssueListPriority, + LinearIssueListStatus, + LinearIssueSummary, + LinearTeamMapping, + LinearWorkflowState, +} from '@/lib/api/types'; + +const LINEAR_MARKDOWN_CLASS = '[&_img]:max-w-full [&_img]:h-auto'; +const FILTER_TRIGGER_CLASS = 'flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 typography-ui-label font-semibold text-foreground outline-none hover:bg-interactive-hover focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'; +const FILTER_COMPACT_TRIGGER_CLASS = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-foreground outline-none hover:bg-interactive-hover focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'; +// The Linear rail is 380–600px. Below this, four or five flex pickers squeeze +// labels to an ellipsis. Status keeps its label (the filter people use most); +// search and the other filters drop to icons that already identify them. +// Walkthrough uses the same icon-only idea at 680px for a wider header. +const FILTER_COMPACT_WIDTH = 520; + +const workspaceLabel = (workspace: { name: string | null; urlKey: string | null; id: string }) => ( + workspace.name?.trim() || workspace.urlKey?.trim() || workspace.id +); + +const LINEAR_PRIORITY_KEYS = { + 0: 'contextPanel.linear.priority.none', + 1: 'contextPanel.linear.priority.urgent', + 2: 'contextPanel.linear.priority.high', + 3: 'contextPanel.linear.priority.medium', + 4: 'contextPanel.linear.priority.low', +} as const; + +const LINEAR_WORKFLOW_TYPE_RANK = { + triage: 0, + backlog: 1, + unstarted: 2, + started: 3, + completed: 4, + canceled: 5, +} as const; + +const linearWorkflowTypeRank = (type: string | null): number => { + if ( + type === 'triage' + || type === 'backlog' + || type === 'unstarted' + || type === 'started' + || type === 'completed' + || type === 'canceled' + ) { + return LINEAR_WORKFLOW_TYPE_RANK[type]; + } + return 99; +}; + +const compareLinearWorkflowStates = (left: LinearWorkflowState, right: LinearWorkflowState): number => { + const typeDelta = linearWorkflowTypeRank(left.type) - linearWorkflowTypeRank(right.type); + if (typeDelta !== 0) return typeDelta; + if (left.position !== right.position) return left.position - right.position; + return left.name.localeCompare(right.name); +}; + +const linearPriorityMessageKey = (priority: number | null | undefined) => { + if (priority !== 0 && priority !== 1 && priority !== 2 && priority !== 3 && priority !== 4) { + return null; + } + return LINEAR_PRIORITY_KEYS[priority]; +}; + +const STATUS_FILTER_ITEMS = [ + { value: 'all', labelKey: 'contextPanel.linear.filter.status.all' }, + { value: 'backlog', labelKey: 'contextPanel.linear.filter.status.backlog' }, + { value: 'todo', labelKey: 'contextPanel.linear.filter.status.todo' }, + { value: 'started', labelKey: 'contextPanel.linear.filter.status.started' }, + { value: 'inReview', labelKey: 'contextPanel.linear.filter.status.inReview' }, + { value: 'completed', labelKey: 'contextPanel.linear.filter.status.completed' }, + { value: 'canceled', labelKey: 'contextPanel.linear.filter.status.canceled' }, + { value: 'duplicate', labelKey: 'contextPanel.linear.filter.status.duplicate' }, +] as const; + +const isLinearIssueListStatus = (value: string): value is LinearIssueListStatus => ( + STATUS_FILTER_ITEMS.some((item) => item.value === value) +); + +const PRIORITY_FILTER_ITEMS = [ + { value: 'all', labelKey: 'contextPanel.linear.filter.priority.all' }, + { value: 'urgent', labelKey: 'contextPanel.linear.priority.urgent' }, + { value: 'high', labelKey: 'contextPanel.linear.priority.high' }, + { value: 'medium', labelKey: 'contextPanel.linear.priority.medium' }, + { value: 'low', labelKey: 'contextPanel.linear.priority.low' }, + { value: 'none', labelKey: 'contextPanel.linear.priority.none' }, +] as const; + +const isLinearIssueListPriority = (value: string): value is LinearIssueListPriority => ( + PRIORITY_FILTER_ITEMS.some((item) => item.value === value) +); + +const labelChipStyle = (color: string | null): React.CSSProperties | undefined => { + if (!color) { + return { backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 12%, transparent)' }; + } + return { + color, + backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)`, + }; +}; + +const LinearIssueLabelChips: React.FC<{ labels: LinearIssueLabel[] }> = ({ labels }) => { + if (labels.length === 0) return null; + return ( +
+ {labels.map((label) => ( + + {label.name} + + ))} +
+ ); +}; + +const LinearFilterMenu: React.FC<{ + icon: IconName; + label: string; + ariaLabel: string; + value: string; + items: Array<{ value: string; label: string }>; + disabled?: boolean; + compact?: boolean; + active?: boolean; + onValueChange: (value: string) => void; +}> = ({ icon, label, ariaLabel, value, items, disabled, compact, active, onValueChange }) => { + const [open, setOpen] = React.useState(false); + + return ( + { + if (!disabled) setOpen(next); + }} + > + + + + + { + onValueChange(next); + setOpen(false); + }} + > + {items.map((item) => ( + + {item.label} + + ))} + + + + ); +}; + +const parseLinearIssueQuery = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + const urlMatch = trimmed.match(/linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i); + if (urlMatch) return urlMatch[1].toUpperCase(); + if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmed)) return trimmed.toUpperCase(); + return null; +}; + +const toIssueSummary = (issue: LinearIssue): LinearIssueSummary => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + state: issue.state, + assignee: issue.assignee, + team: issue.team, + priority: issue.priority, + labels: issue.labels, +}); + +const patchIssueInList = (issues: LinearIssueSummary[], next: LinearIssue): LinearIssueSummary[] => { + const summary = toIssueSummary(next); + return issues.map((issue) => (issue.id === next.id ? summary : issue)); +}; + +export const LinearIssuesView: React.FC = () => { + const { t } = useI18n(); + const { linear } = useRuntimeAPIs(); + const linearAuthStatus = useLinearAuthStore((state) => state.status); + const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); + const refreshStatus = useLinearAuthStore((state) => state.refreshStatus); + const setLinearAuthStatus = useLinearAuthStore((state) => state.setStatus); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const listStatus = useUIStore((state) => state.linearIssueListStatus); + const listAssignee = useUIStore((state) => state.linearIssueListAssignee); + const listTeamId = useUIStore((state) => state.linearIssueListTeamId); + const listPriority = useUIStore((state) => state.linearIssueListPriority); + const linearIssueFocus = useUIStore((state) => state.linearIssueFocus); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); + const setListStatus = useUIStore((state) => state.setLinearIssueListStatus); + const setListAssignee = useUIStore((state) => state.setLinearIssueListAssignee); + const setListTeamId = useUIStore((state) => state.setLinearIssueListTeamId); + const setListPriority = useUIStore((state) => state.setLinearIssueListPriority); + const resetListFilters = useUIStore((state) => state.resetLinearIssueListFilters); + const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus); + + const [query, setQuery] = React.useState(''); + const [searchOpen, setSearchOpen] = React.useState(false); + const [issues, setIssues] = React.useState([]); + const [cursor, setCursor] = React.useState(null); + const [hasMore, setHasMore] = React.useState(false); + const [connected, setConnected] = React.useState(true); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [selectedIssueId, setSelectedIssueId] = React.useState(null); + const [selectedIssue, setSelectedIssue] = React.useState(null); + const [workflowStates, setWorkflowStates] = React.useState([]); + const [isLoadingIssue, setIsLoadingIssue] = React.useState(false); + const [isUpdating, setIsUpdating] = React.useState(false); + const [isStarting, setIsStarting] = React.useState(false); + const [createInWorktree, setCreateInWorktree] = React.useState(false); + const [teams, setTeams] = React.useState([]); + const [isSwitchingWorkspace, setIsSwitchingWorkspace] = React.useState(false); + const listRequestId = React.useRef(0); + const listRootRef = React.useRef(null); + const searchInputRef = React.useRef(null); + const [panelWidth, setPanelWidth] = React.useState(0); + + const directIdentifier = React.useMemo(() => parseLinearIssueQuery(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + + // Same shape the pull request panel uses, so both context surfaces read alike. + const formatCommentTimestamp = React.useCallback((value: string | null) => { + if (!value) return ''; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return ''; + return formatDateTimeForPreference(timestamp, timeFormatPreference, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }, [timeFormatPreference]); + + const openLinearSettings = React.useCallback(() => { + setSettingsPage('integrations'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const listQuery = React.useMemo(() => ({ + query: debouncedQuery.trim() || undefined, + status: listStatus, + assignee: listAssignee, + teamId: listTeamId === LINEAR_ISSUE_LIST_ALL_TEAMS ? undefined : listTeamId, + priority: listPriority === 'all' ? undefined : listPriority, + }), [debouncedQuery, listAssignee, listPriority, listStatus, listTeamId]); + + const workspaces = linearAuthStatus?.workspaces ?? []; + const currentWorkspaceId = workspaces.find((workspace) => workspace.current)?.id + || linearAuthStatus?.organization?.id + || ''; + + const refresh = React.useCallback(async () => { + if (linearAuthChecked && linearAuthStatus?.connected === false) { + setConnected(false); + setIssues([]); + setHasMore(false); + setCursor(null); + setError(null); + return; + } + if (!linear?.issuesList) { + setConnected(true); + setError(t('session.linearIssuePicker.error.runtimeUnavailable')); + return; + } + + const requestId = listRequestId.current + 1; + listRequestId.current = requestId; + setIsLoading(true); + setError(null); + try { + const next = await linear.issuesList(listQuery); + if (requestId !== listRequestId.current) return; + setConnected(next.connected !== false); + if (next.connected === false) { + setIssues([]); + setHasMore(false); + setCursor(null); + return; + } + setIssues(next.issues ?? []); + setCursor(next.cursor ?? null); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + if (requestId !== listRequestId.current) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (requestId === listRequestId.current) { + setIsLoading(false); + } + } + }, [linear, linearAuthChecked, linearAuthStatus, listQuery, t]); + + React.useEffect(() => { + if (linear && !linearAuthChecked) { + void refreshStatus(linear); + } + }, [linear, linearAuthChecked, refreshStatus]); + + React.useEffect(() => { + void refresh(); + }, [refresh]); + + React.useEffect(() => { + if (!linear?.mappingGet || !connected) { + setTeams([]); + return; + } + let cancelled = false; + void linear.mappingGet().then((mapping) => { + if (cancelled) return; + if (mapping.connected === false) { + setTeams([]); + return; + } + setTeams(mapping.teams ?? []); + }).catch(() => { + if (!cancelled) { + setTeams([]); + } + }); + return () => { + cancelled = true; + }; + }, [connected, currentWorkspaceId, linear]); + + React.useEffect(() => { + if (listTeamId === LINEAR_ISSUE_LIST_ALL_TEAMS || teams.length === 0) { + return; + } + if (!teams.some((team) => team.id === listTeamId)) { + setListTeamId(LINEAR_ISSUE_LIST_ALL_TEAMS); + } + }, [listTeamId, setListTeamId, teams]); + + const loadMore = React.useCallback(async () => { + if (!linear?.issuesList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore || !cursor) return; + + const requestId = listRequestId.current + 1; + listRequestId.current = requestId; + setIsLoadingMore(true); + try { + const next = await linear.issuesList({ + ...listQuery, + cursor, + }); + if (requestId !== listRequestId.current) return; + setConnected(next.connected !== false); + if (next.connected === false) { + return; + } + setIssues((prev) => [...prev, ...(next.issues ?? [])]); + setCursor(next.cursor ?? null); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + if (requestId !== listRequestId.current) return; + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadMoreFailed'), { description: message }); + } finally { + if (requestId === listRequestId.current) { + setIsLoadingMore(false); + } + } + }, [cursor, hasMore, isLoading, isLoadingMore, linear, listQuery, t]); + + React.useEffect(() => { + if (!selectedIssueId || !linear?.issueGet) { + return; + } + let cancelled = false; + setIsLoadingIssue(true); + setSelectedIssue(null); + setWorkflowStates([]); + void (async () => { + try { + const issueRes = await linear.issueGet(selectedIssueId); + if (cancelled) return; + if (issueRes.connected === false) { + setConnected(false); + setSelectedIssueId(null); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.linearIssuePicker.error.issueNotFound')); + setSelectedIssueId(null); + return; + } + setSelectedIssue(issue); + const teamId = issue.team?.id; + if (!teamId || !linear.issueStates) { + return; + } + try { + const statesRes = await linear.issueStates(teamId); + if (cancelled) return; + if (statesRes.connected === false) { + setConnected(false); + return; + } + setWorkflowStates(statesRes.states ?? []); + } catch (e) { + if (cancelled) return; + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); + } + } catch (e) { + if (cancelled) return; + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); + setSelectedIssueId(null); + } finally { + if (!cancelled) { + setIsLoadingIssue(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [linear, selectedIssueId, t]); + + React.useEffect(() => { + if (!linearIssueFocus) return; + setSelectedIssueId(linearIssueFocus); + setLinearIssueFocus(null); + }, [linearIssueFocus, setLinearIssueFocus]); + + const applyUpdatedIssue = React.useCallback((issue: LinearIssue) => { + setSelectedIssue(issue); + setIssues((prev) => patchIssueInList(prev, issue)); + }, []); + + const updateIssueState = React.useCallback(async (stateId: string, failedKey: 'contextPanel.linear.toast.statusUpdateFailed' | 'contextPanel.linear.toast.closeFailed') => { + if (!linear?.issueUpdate || !selectedIssue || isUpdating) { + return; + } + if (selectedIssue.state?.id === stateId) { + return; + } + setIsUpdating(true); + try { + const result = await linear.issueUpdate({ id: selectedIssue.id, stateId }); + if (result.connected === false) { + setConnected(false); + toast.error(t(failedKey)); + return; + } + if (!result.issue) { + toast.error(t(failedKey)); + return; + } + applyUpdatedIssue(result.issue); + toast.success(t('contextPanel.linear.toast.statusUpdated')); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t(failedKey), { description: message }); + } finally { + setIsUpdating(false); + } + }, [applyUpdatedIssue, isUpdating, linear, selectedIssue, t]); + + const closeIssue = React.useCallback(() => { + const completed = workflowStates.find((state) => state.type === 'completed'); + if (!completed) { + toast.error(t('contextPanel.linear.error.noCompletedState')); + return; + } + void updateIssueState(completed.id, 'contextPanel.linear.toast.closeFailed'); + }, [t, updateIssueState, workflowStates]); + + const startSession = React.useCallback(async () => { + if (!selectedIssue || isStarting) return; + setIsStarting(true); + try { + await startLinearIssueSession({ + linear, + issueKey: selectedIssue.id, + createInWorktree, + t, + }); + } finally { + setIsStarting(false); + } + }, [createInWorktree, isStarting, linear, selectedIssue, t]); + + const switchWorkspace = React.useCallback(async (organizationId: string) => { + if (!linear?.authActivate || !organizationId || organizationId === currentWorkspaceId || isSwitchingWorkspace) { + return; + } + setIsSwitchingWorkspace(true); + try { + const payload = await linear.authActivate(organizationId); + setLinearAuthStatus(payload); + setSelectedIssueId(null); + setSelectedIssue(null); + setWorkflowStates([]); + setListTeamId(LINEAR_ISSUE_LIST_ALL_TEAMS); + toast.success(t('contextPanel.linear.toast.workspaceSwitched')); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('contextPanel.linear.toast.workspaceSwitchFailed'), { description: message }); + } finally { + setIsSwitchingWorkspace(false); + } + }, [currentWorkspaceId, isSwitchingWorkspace, linear, setLinearAuthStatus, setListTeamId, t]); + + const statusOptions = React.useMemo(() => { + const byId = new Map(workflowStates.map((state) => [state.id, state])); + const currentId = selectedIssue?.state?.id; + const currentName = selectedIssue?.state?.name; + const states = currentId && currentName && !byId.has(currentId) + ? [ + { + id: currentId, + name: currentName, + type: selectedIssue.state?.type ?? null, + position: 0, + }, + ...workflowStates, + ] + : workflowStates; + return [...states].sort(compareLinearWorkflowStates); + }, [selectedIssue, workflowStates]); + + const completedState = workflowStates.find((state) => state.type === 'completed'); + const alreadyCompleted = selectedIssue?.state?.type === 'completed'; + const showDisconnected = linearAuthChecked && connected === false; + const runtimeMissing = !linear; + const showingDetail = Boolean(selectedIssueId); + const usingDefaultFilters = listStatus === 'all' && listAssignee === 'any' && listTeamId === LINEAR_ISSUE_LIST_ALL_TEAMS && listPriority === 'all'; + const canUseListControls = Boolean(linear) && connected && !showDisconnected; + const filtersDisabled = !canUseListControls || isSwitchingWorkspace; + // Zero means the observer has not reported yet; assume there is room rather + // than rendering a compact filter row for one frame on every open. + const compactFilters = panelWidth > 0 && panelWidth < FILTER_COMPACT_WIDTH; + const searchActive = query.trim().length > 0; + const hasActiveFilters = !usingDefaultFilters || searchActive; + const showSearchField = !compactFilters || searchOpen || searchActive; + + const closeCompactSearch = React.useCallback(() => { + setQuery(''); + setSearchOpen(false); + }, []); + + React.useEffect(() => { + const element = listRootRef.current; + if (!element || !globalThis.ResizeObserver) return; + const observer = new ResizeObserver((entries) => { + setPanelWidth(entries[0]?.contentRect.width ?? 0); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [showingDetail]); + + React.useEffect(() => { + if (compactFilters && searchOpen) { + searchInputRef.current?.focus(); + } + }, [compactFilters, searchOpen]); + + const worktreeToggle = ( +
setCreateInWorktree((value) => !value)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setCreateInWorktree((value) => !value); + } + }} + > + + {t('session.linearIssuePicker.actions.createInWorktree')} +
+ ); + + const renderIssueRow = (issue: LinearIssueSummary) => ( +
setSelectedIssueId(issue.id)} + > + + {issue.identifier} + +

+ {issue.title} +

+
+ +
+
+ ); + + if (showingDetail) { + const assigneeName = selectedIssue?.assignee?.displayName || selectedIssue?.assignee?.name; + const comments = selectedIssue?.comments ?? []; + const description = selectedIssue?.description?.trim() || ''; + const statusValue = selectedIssue?.state?.id || ''; + const priorityKey = linearPriorityMessageKey(selectedIssue?.priority); + const labels = selectedIssue?.labels ?? []; + + return ( +
+
+ + {selectedIssue ? ( + + ) : null} +
+ + {isLoadingIssue && !selectedIssue ? ( +
+ + {t('contextPanel.linear.loading.issue')} +
+ ) : null} + + {selectedIssue ? ( + + + {t('contextPanel.linear.loading.issue')} +
+ }> +
+
+
{selectedIssue.identifier}
+

{selectedIssue.title}

+
+ +
+ {statusOptions.length > 0 && statusValue ? ( + + ) : selectedIssue.state?.name ? ( + {selectedIssue.state.name} + ) : null} + + {completedState && !alreadyCompleted ? ( + + ) : null} +
+ +
+ {selectedIssue.team?.name ? ( + <> +
{t('contextPanel.linear.label.team')}
+
{selectedIssue.team.name}
+ + ) : null} +
{t('contextPanel.linear.label.assignee')}
+
+ {assigneeName || t('contextPanel.linear.label.unassigned')} +
+ {priorityKey ? ( + <> +
{t('contextPanel.linear.label.priority')}
+
+ {t(priorityKey)} +
+ + ) : null} + {labels.length > 0 ? ( + <> +
{t('contextPanel.linear.label.labels')}
+
+ +
+ + ) : null} +
+ +
+ {description ? ( + + ) : ( +

{t('contextPanel.linear.empty.noDescription')}

+ )} +
+ +
+

{t('contextPanel.linear.label.comments')}

+ {comments.length === 0 ? ( +

{t('contextPanel.linear.empty.noComments')}

+ ) : ( +
+ {comments.map((comment, index) => { + const author = comment.user?.displayName + || comment.user?.name + || t('contextPanel.linear.label.unassigned'); + const avatarUrl = comment.user?.avatarUrl || null; + const initial = author.charAt(0).toUpperCase(); + const isLast = index === comments.length - 1; + const createdLabel = formatCommentTimestamp(comment.createdAt); + return ( +
+ {!isLast ? ( +
+ ) : null} +
+ {avatarUrl ? ( + {author} + ) : ( + {initial} + )} +
+
+
+ {author} + {createdLabel ? {createdLabel} : null} +
+ {comment.body.trim() ? ( + + ) : null} +
+
+ ); + })} +
+ )} +
+
+ + ) : null} + + {selectedIssue ? ( +
+ {worktreeToggle} + +
+ ) : null} +
+ ); + } + + return ( +
+
+ {showSearchField ? ( +
+ + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape' && compactFilters) { + event.preventDefault(); + closeCompactSearch(); + } + }} + className={cn('pl-9 w-full', compactFilters && 'pr-9')} + /> + {compactFilters ? ( + + ) : null} +
+ ) : null} + + {canUseListControls || (compactFilters && !showSearchField) ? ( +
+ {canUseListControls ? ( + <> + item.value === listStatus) ?? STATUS_FILTER_ITEMS[0]).labelKey)} + ariaLabel={t('contextPanel.linear.filter.statusAria')} + value={listStatus} + active={listStatus !== 'all'} + disabled={filtersDisabled} + items={STATUS_FILTER_ITEMS.map((item) => ({ + value: item.value, + label: t(item.labelKey), + }))} + onValueChange={(value) => { + if (isLinearIssueListStatus(value)) { + setListStatus(value); + } + }} + /> + + item.value === listPriority) ?? PRIORITY_FILTER_ITEMS[0]).labelKey)} + ariaLabel={t('contextPanel.linear.filter.priorityAria')} + value={listPriority} + active={listPriority !== 'all'} + disabled={filtersDisabled} + items={PRIORITY_FILTER_ITEMS.map((item) => ({ + value: item.value, + label: t(item.labelKey), + }))} + onValueChange={(value) => { + if (isLinearIssueListPriority(value)) { + setListPriority(value); + } + }} + /> + + { + if (value === 'any' || value === 'me') { + setListAssignee(value); + } + }} + /> + + {teams.length > 0 ? ( + team.id === listTeamId)?.name ?? listTeamId) + } + ariaLabel={t('contextPanel.linear.filter.teamAria')} + value={listTeamId} + active={listTeamId !== LINEAR_ISSUE_LIST_ALL_TEAMS} + disabled={filtersDisabled} + items={[ + { value: LINEAR_ISSUE_LIST_ALL_TEAMS, label: t('contextPanel.linear.filter.team.all') }, + ...teams.map((team) => ({ value: team.id, label: team.name })), + ]} + onValueChange={setListTeamId} + /> + ) : null} + + {workspaces.length > 1 && currentWorkspaceId ? ( + workspace.id === currentWorkspaceId) ?? { id: currentWorkspaceId, name: null, urlKey: null })} + ariaLabel={t('contextPanel.linear.label.workspaceAria')} + value={currentWorkspaceId} + disabled={isSwitchingWorkspace} + items={workspaces.map((workspace) => ({ + value: workspace.id, + label: workspaceLabel(workspace), + }))} + onValueChange={(value) => { + void switchWorkspace(value); + }} + /> + ) : null} + + {hasActiveFilters ? ( + + ) : null} + + ) : null} + + {compactFilters && !showSearchField ? ( + + ) : null} +
+ ) : null} +
+ + + {runtimeMissing ? ( +
{t('session.linearIssuePicker.empty.runtimeUnavailable')}
+ ) : null} + + {isLoading && issues.length === 0 ? ( +
+ + {t('session.linearIssuePicker.loading.issues')} +
+ ) : null} + + {showDisconnected ? ( +
+
{t('session.linearIssuePicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directIdentifier && linear && connected ? ( +
setSelectedIssueId(directIdentifier)} + > + + {directIdentifier} + +

+ {t('session.linearIssuePicker.actions.useIssue', { identifier: directIdentifier })} +

+
+ ) : null} + + {issues.length === 0 && !isLoading && connected && linear ? ( +
+ {debouncedQuery.trim() + ? t('session.linearIssuePicker.empty.noIssuesFound') + : usingDefaultFilters + ? t('session.linearIssuePicker.empty.noOpenIssuesFound') + : t('contextPanel.linear.empty.noMatchingIssues')} +
+ ) : null} + + {issues.map(renderIssueRow)} + + {hasMore && connected && linear ? ( +
+ +
+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index f8f61a4c..f5995a77 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -964,7 +964,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile : } {getPageTitle(page.slug)} - {(page.slug === 'tunnel' || page.slug === 'integrations') && ( + {page.slug === 'tunnel' && ( {t('settings.view.badge.beta')} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index d29dce96..3eff8676 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -30,6 +30,7 @@ import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstr import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; @@ -505,6 +506,7 @@ export const useKeyboardShortcuts = () => { isVSCode: isVSCodeRuntime(), screenWidth: window.innerWidth, tabs: panel?.tabs ?? [], + linearConnected: useLinearAuthStore.getState().status?.connected === true, }); const target = visibleSurfaces[switchSurfaceDigit - 1]; if (target) { diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts index 2379f9be..6ca3ae8a 100644 --- a/packages/ui/src/hooks/useRouter.ts +++ b/packages/ui/src/hooks/useRouter.ts @@ -2,6 +2,7 @@ import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore'; import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router'; +import { openSessionFromRoute } from '@/lib/router/openSessionFromRoute'; import type { RouteState, AppRouteState } from '@/lib/router'; import { resolveSettingsSlug } from '@/lib/settings/metadata'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; @@ -48,7 +49,6 @@ export function useRouter(): void { const isApplyingRouteRef = React.useRef(false); // Get store actions (stable references) - const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const navigateToDiff = useUIStore((state) => state.navigateToDiff); @@ -67,11 +67,7 @@ export function useRouter(): void { try { // 1. Apply session first (may trigger async operations) if (route.sessionId) { - const currentSessionId = useSessionUIStore.getState().currentSessionId; - if (route.sessionId !== currentSessionId) { - const directoryHint = useSessionUIStore.getState().getDirectoryForSession(route.sessionId); - setCurrentSession(route.sessionId, directoryHint); - } + await openSessionFromRoute(route.sessionId); } // 2. Handle settings first because it is a full-screen overlay. @@ -107,7 +103,7 @@ export function useRouter(): void { isApplyingRouteRef.current = false; } }, - [setCurrentSession, setSettingsDialogOpen, setSettingsPage, navigateToDiff] + [setSettingsDialogOpen, setSettingsPage, navigateToDiff] ); /** diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 51761520..d1b70aa4 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1145,6 +1145,199 @@ export type GitHubDeviceFlowComplete = | { connected: true; user: GitHubUserSummary; scope?: string } | { connected: false; status?: string; error?: string }; +export type LinearUserSummary = { + id: string; + name: string | null; + displayName: string | null; + email: string | null; + avatarUrl: string | null; +}; + +export type LinearOrganizationSummary = { + id: string; + name: string; + urlKey: string | null; +}; + +export type LinearWorkspaceSummary = { + id: string; + name: string | null; + urlKey: string | null; + current: boolean; + user?: LinearUserSummary | null; + authorizedAt?: number | null; +}; + +export type LinearAuthStatus = { + connected: boolean; + user?: LinearUserSummary | null; + organization?: LinearOrganizationSummary | null; + scope?: string; + workspaces?: LinearWorkspaceSummary[]; +}; + +export type LinearAuthStart = { + authorizationUrl: string; + expiresIn: number; + scope: string; +}; + +export type LinearAuthOrigin = 'desktop' | 'web'; + +export type LinearIssueState = { + id: string | null; + name: string | null; + type: string | null; +}; + +export type LinearWorkflowState = { + id: string; + name: string; + type: string | null; + position: number; +}; + +export type LinearIssueAssignee = { + name: string | null; + displayName: string | null; + avatarUrl: string | null; +}; + +export type LinearIssueTeam = { + id: string; + key: string; + name: string; +}; + +export type LinearIssuePriority = 0 | 1 | 2 | 3 | 4; + +export type LinearIssueLabel = { + id: string; + name: string; + color: string | null; +}; + +export type LinearIssueSummary = { + id: string; + identifier: string; + title: string; + url: string; + state?: LinearIssueState | null; + assignee?: LinearIssueAssignee | null; + team?: LinearIssueTeam | null; + priority?: LinearIssuePriority | null; + labels?: LinearIssueLabel[]; +}; + +export type LinearIssueComment = { + id: string; + body: string; + createdAt: string | null; + user?: { name: string | null; displayName: string | null; avatarUrl?: string | null } | null; +}; + +export type LinearIssue = LinearIssueSummary & { + description?: string | null; + comments?: LinearIssueComment[]; +}; + +export type LinearIssueListStatus = 'all' | 'backlog' | 'todo' | 'started' | 'inReview' | 'completed' | 'canceled' | 'duplicate'; +export type LinearIssueListAssignee = 'any' | 'me'; +export type LinearIssueListPriority = 'all' | 'none' | 'urgent' | 'high' | 'medium' | 'low'; + +export type LinearIssuesListOptions = { + query?: string; + cursor?: string; + status?: LinearIssueListStatus; + assignee?: LinearIssueListAssignee; + teamId?: string; + priority?: LinearIssueListPriority; +}; + +export type LinearIssuesListResult = { + connected: boolean; + issues?: LinearIssueSummary[]; + cursor?: string | null; + hasMore?: boolean; +}; + +export type LinearIssueGetResult = { + connected: boolean; + issue?: LinearIssue | null; +}; + +export type LinearIssueStatesResult = { + connected: boolean; + states?: LinearWorkflowState[]; +}; + +export type LinearIssueUpdateInput = { + id: string; + stateId: string; +}; + +export type LinearIssueUpdateResult = { + connected: boolean; + issue?: LinearIssue | null; +}; + +export type LinearTeamMapping = { + id: string; + key: string; + name: string; + projectPath: string | null; +}; + +export type LinearMappingResult = { + connected: boolean; + defaultProjectPath?: string | null; + teams?: LinearTeamMapping[]; +}; + +export type LinearMappingWrite = { + defaultProjectPath: string | null; + teamProjectPaths: { [teamId: string]: string }; +}; + +export type LinearSessionStatusKind = 'started' | 'completed' | 'failure'; + +export type LinearSessionStatusPostInput = { + kind: LinearSessionStatusKind; + sessionId: string; + issueIdentifier?: string; + sessionOrigin?: string; +}; + +export type LinearSessionStatusPostResult = + | { connected: false } + | { connected: true; posted: true; commentId: string | null } + | { + connected: true; + posted: false; + skipped: 'already-posted' | 'issue-not-found' | 'not-started' | 'disabled' | 'origin-not-public'; + }; + +export type LinearPreferences = { + /** Status comments are off until the user opts in. */ + sessionComments: boolean; +}; + +export interface LinearAPI { + authStatus(): Promise; + authStart(origin?: LinearAuthOrigin): Promise; + authDisconnect(): Promise<{ removed: boolean }>; + authActivate(organizationId: string): Promise; + issuesList(options?: LinearIssuesListOptions): Promise; + issueGet(id: string): Promise; + issueStates(teamId: string): Promise; + issueUpdate(input: LinearIssueUpdateInput): Promise; + mappingGet(): Promise; + mappingSet(mapping: LinearMappingWrite): Promise; + sessionStatusPost(input: LinearSessionStatusPostInput): Promise; + preferencesGet(): Promise; + preferencesSet(preferences: LinearPreferences): Promise; +} + export interface GitHubAPI { authStatus(): Promise; authStart(): Promise; @@ -1269,6 +1462,7 @@ export interface RuntimeAPIs { permissions: PermissionsAPI; notifications: NotificationsAPI; github?: GitHubAPI; + linear?: LinearAPI; push?: PushAPI; diagnostics?: DiagnosticsAPI; clientAuth?: ClientAuthAPI; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 1302d87a..75d596f9 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung', @@ -2218,5 +2219,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.themeMode.light.description': 'Immer helles Erscheinungsbild verwenden', 'settings.openchamber.visual.option.themeMode.dark.description': 'Immer dunkles Erscheinungsbild verwenden', 'chat.message.userText.collapseAria': 'Benutzernachricht einklappen', + ...linearIntegrationI18n.de, ...thirdPartyIntegrationI18n.de, }; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index cc6f59a1..f8f2a907 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1,7 +1,11 @@ import { settingsDict } from './de.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { ...settingsDict, + ...linearIssuePickerI18n.de, + ...linearPanelI18n.de, 'common.language.german': 'Deutsch', 'common.loading': 'Wird geladen...', 'common.unavailable': 'Nicht verfügbar', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 29f19526..95620f19 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking', @@ -2217,5 +2218,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n.en, ...thirdPartyIntegrationI18n.en, } as const; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 506a7a2c..5b4e7c5c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1,7 +1,11 @@ import { settingsDict } from './en.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { ...settingsDict, + ...linearIssuePickerI18n.en, + ...linearPanelI18n.en, 'terminalView.actions.attachSelection': 'Attach selected output', 'terminalView.actions.restart': 'Restart terminal', 'chat.message.terminalContext': '{terminal}, lines {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index e5125e1a..fa40f214 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go', @@ -2227,5 +2228,6 @@ export const settingsDict = { "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", + ...linearIntegrationI18n.es, ...thirdPartyIntegrationI18n.es, } as const; diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index c5175e3d..d4af1600 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './es.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.es, + ...linearPanelI18n.es, 'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada', 'terminalView.actions.restart': 'Reiniciar terminal', 'chat.message.terminalContext': '{terminal}, líneas {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 7d374725..432d536d 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Suivi de l’utilisation d’OpenCode Go', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n.fr, ...thirdPartyIntegrationI18n.fr, } as const; diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 4575aa2b..646a3714 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1,7 +1,11 @@ import { settingsDict } from './fr.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { ...settingsDict, + ...linearIssuePickerI18n.fr, + ...linearPanelI18n.fr, 'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée', 'terminalView.actions.restart': 'Redémarrer le terminal', 'chat.message.terminalContext': '{terminal}, lignes {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 1dcf5291..87b35b08 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー', + ...linearIntegrationI18n.ja, ...thirdPartyIntegrationI18n.ja, } as const; diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 9a666ec0..72643f20 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './ja.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.ja, + ...linearPanelI18n.ja, 'terminalView.actions.attachSelection': '選択した出力を添付', 'terminalView.actions.restart': 'ターミナルを再起動', 'chat.message.terminalContext': '{terminal}、{start}〜{end}行', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index ef0d8e6a..08ac89c7 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n.ko, ...thirdPartyIntegrationI18n.ko, } as const; diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 786dd155..8baeb2c3 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './ko.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.ko, + ...linearPanelI18n.ko, 'terminalView.actions.attachSelection': '선택한 출력 첨부', 'terminalView.actions.restart': '터미널 다시 시작', 'chat.message.terminalContext': '{terminal}, {start}-{end}행', diff --git a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts new file mode 100644 index 00000000..0fae64bb --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +import { linearIntegrationI18n } from './linear-integration.i18n'; + +const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const; + +const requiredKeys = [ + 'settings.integrations.firstParty.title', + 'settings.integrations.firstParty.info', + 'settings.integrations.linear.title', + 'settings.integrations.linear.description', + 'settings.integrations.linear.info', + 'settings.integrations.linear.status.notConnected', + 'settings.integrations.linear.status.connected', + 'settings.integrations.linear.status.waiting', + 'settings.integrations.linear.actions.connect', + 'settings.integrations.linear.actions.disconnect', + 'settings.integrations.linear.actions.addWorkspace', + 'settings.integrations.linear.actions.switchTo', + 'settings.integrations.linear.label.otherWorkspaces', + 'settings.integrations.linear.flow.title', + 'settings.integrations.linear.flow.description', + 'settings.integrations.linear.flow.waiting', + 'settings.integrations.linear.toast.connected', + 'settings.integrations.linear.toast.disconnected', + 'settings.integrations.linear.toast.workspaceSwitched', + 'settings.integrations.linear.toast.workspaceSwitchFailed', + 'settings.integrations.linear.toast.startConnectFailed', + 'settings.integrations.linear.toast.disconnectFailed', + 'settings.integrations.linear.toast.authorizationFailed', + 'settings.integrations.linear.avatarAlt.withName', + 'settings.integrations.linear.avatarAlt.fallback', + 'settings.integrations.linear.label.unknownUser', + 'settings.integrations.linear.mapping.defaultProject', + 'settings.integrations.linear.mapping.defaultProject.info', + 'settings.integrations.linear.mapping.defaultProject.placeholder', + 'settings.integrations.linear.mapping.defaultProject.aria', + 'settings.integrations.linear.mapping.teams', + 'settings.integrations.linear.mapping.teams.info', + 'settings.integrations.linear.mapping.teams.useDefault', + 'settings.integrations.linear.mapping.teams.aria', + 'settings.integrations.linear.mapping.emptyProjects', + 'settings.integrations.linear.mapping.emptyTeams', + 'settings.integrations.linear.mapping.loadFailed', + 'settings.integrations.linear.sessionComments.label', + 'settings.integrations.linear.sessionComments.info', + 'settings.integrations.linear.sessionComments.aria', + 'settings.integrations.linear.sessionComments.loadFailed', + 'settings.magicPrompts.sidebar.group.linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview', + 'settings.magicPrompts.page.group.linearIssueReview.title', + 'settings.magicPrompts.page.group.linearIssueReview.description', +] as const; + +describe('linear integration translations', () => { + test('provides every required key in every supported locale', () => { + const english = linearIntegrationI18n.en; + for (const locale of locales) { + for (const key of requiredKeys) { + const value = linearIntegrationI18n[locale][key]; + expect(value).toBeTruthy(); + if ( + locale !== 'en' + && key !== 'settings.integrations.linear.title' + && key !== 'settings.magicPrompts.sidebar.group.linear' + ) { + expect(value).not.toBe(english[key]); + } + } + } + }); +}); diff --git a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts new file mode 100644 index 00000000..529a6aba --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts @@ -0,0 +1,567 @@ +/** Linear first-party integration settings strings — merged into each locale's settings dictionary. */ +export const linearIntegrationI18n = { + en: { + 'settings.integrations.firstParty.title': 'Built-in integrations', + 'settings.integrations.firstParty.info': 'Sign-ins for services that ship with OpenChamber. The login stays on this computer so web, desktop, and a paired phone share it.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Connect Linear workspaces on this OpenChamber server.', + 'settings.integrations.linear.info': 'Connect one or more Linear workspaces. OpenChamber stores the logins on this computer so web, desktop, and a paired phone share them.', + 'settings.integrations.linear.status.notConnected': 'Not connected', + 'settings.integrations.linear.status.connected': 'Connected', + 'settings.integrations.linear.status.waiting': 'Waiting', + 'settings.integrations.linear.actions.connect': 'Connect', + 'settings.integrations.linear.actions.disconnect': 'Disconnect', + 'settings.integrations.linear.actions.addWorkspace': 'Add workspace', + 'settings.integrations.linear.actions.switchTo': 'Switch to', + 'settings.integrations.linear.label.otherWorkspaces': 'Other workspaces', + 'settings.integrations.linear.flow.title': 'Waiting for Linear', + 'settings.integrations.linear.flow.description': 'Finish signing in in the browser tab that just opened.', + 'settings.integrations.linear.flow.waiting': 'Waiting for authorization…', + 'settings.integrations.linear.toast.connected': 'Linear connected', + 'settings.integrations.linear.toast.disconnected': 'Linear disconnected', + 'settings.integrations.linear.toast.workspaceSwitched': 'Switched Linear workspace', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace', + 'settings.integrations.linear.toast.startConnectFailed': 'Could not start Linear sign-in', + 'settings.integrations.linear.toast.disconnectFailed': 'Could not disconnect Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear authorization timed out. Click Connect to try again.', + 'settings.integrations.linear.avatarAlt.withName': 'Linear avatar for {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear avatar', + 'settings.integrations.linear.label.unknownUser': 'Unknown user', + 'settings.integrations.linear.mapping.defaultProject': 'Default project', + 'settings.integrations.linear.mapping.defaultProject.info': 'New sessions from Linear issues use this project unless the issue\'s team has its own mapping.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'None', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Default project for Linear issues', + 'settings.integrations.linear.mapping.teams': 'Team projects', + 'settings.integrations.linear.mapping.teams.info': 'Optional. An issue from a mapped team opens in that project instead of the default.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Use default', + 'settings.integrations.linear.mapping.teams.aria': 'Project for Linear team {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Add a project first, then map Linear teams to it.', + 'settings.integrations.linear.mapping.emptyTeams': 'This Linear workspace has no teams.', + 'settings.integrations.linear.mapping.loadFailed': 'Could not load Linear project mapping.', + 'settings.integrations.linear.sessionComments.label': 'Session comments', + 'settings.integrations.linear.sessionComments.info': 'Adds a comment to the issue when a session starts, finishes, or fails. Comments are only posted when this server has a public address, so the link opens the session for everyone on the issue.', + 'settings.integrations.linear.sessionComments.aria': 'Post session status comments to Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Could not load Linear comment settings.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue Review', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue Review', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts used when starting a session from a Linear issue: visible user message + hidden instructions.', + }, + de: { + 'settings.integrations.firstParty.title': 'Eingebaute Integrationen', + 'settings.integrations.firstParty.info': 'Anmeldungen für Dienste, die mit OpenChamber mitgeliefert werden. Die Anmeldung bleibt auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Verbinde Linear-Workspaces mit diesem OpenChamber-Server.', + 'settings.integrations.linear.info': 'Verbinde einen oder mehrere Linear-Workspaces. OpenChamber speichert die Anmeldungen auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.', + 'settings.integrations.linear.status.notConnected': 'Nicht verbunden', + 'settings.integrations.linear.status.connected': 'Verbunden', + 'settings.integrations.linear.status.waiting': 'Warten', + 'settings.integrations.linear.actions.connect': 'Verbinden', + 'settings.integrations.linear.actions.disconnect': 'Trennen', + 'settings.integrations.linear.actions.addWorkspace': 'Workspace hinzufügen', + 'settings.integrations.linear.actions.switchTo': 'Wechseln zu', + 'settings.integrations.linear.label.otherWorkspaces': 'Andere Workspaces', + 'settings.integrations.linear.flow.title': 'Warte auf Linear', + 'settings.integrations.linear.flow.description': 'Schließe die Anmeldung im gerade geöffneten Browser-Tab ab.', + 'settings.integrations.linear.flow.waiting': 'Warte auf die Autorisierung…', + 'settings.integrations.linear.toast.connected': 'Linear verbunden', + 'settings.integrations.linear.toast.disconnected': 'Linear getrennt', + 'settings.integrations.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden', + 'settings.integrations.linear.toast.startConnectFailed': 'Linear-Anmeldung konnte nicht gestartet werden', + 'settings.integrations.linear.toast.disconnectFailed': 'Linear konnte nicht getrennt werden', + 'settings.integrations.linear.toast.authorizationFailed': 'Die Linear-Autorisierung ist abgelaufen. Klicke auf Verbinden, um es erneut zu versuchen.', + 'settings.integrations.linear.avatarAlt.withName': 'Linear-Avatar für {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear-Avatar', + 'settings.integrations.linear.label.unknownUser': 'Unbekannter Benutzer', + 'settings.integrations.linear.mapping.defaultProject': 'Standardprojekt', + 'settings.integrations.linear.mapping.defaultProject.info': 'Neue Sitzungen aus Linear-Issues nutzen dieses Projekt, sofern das Team des Issues keine eigene Zuordnung hat.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Keines', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Standardprojekt für Linear-Issues', + 'settings.integrations.linear.mapping.teams': 'Team-Projekte', + 'settings.integrations.linear.mapping.teams.info': 'Optional. Ein Issue eines zugeordneten Teams öffnet sich in diesem Projekt statt im Standard.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Standard verwenden', + 'settings.integrations.linear.mapping.teams.aria': 'Projekt für Linear-Team {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Füge zuerst ein Projekt hinzu und ordne dann Linear-Teams zu.', + 'settings.integrations.linear.mapping.emptyTeams': 'Dieser Linear-Workspace hat keine Teams.', + 'settings.integrations.linear.mapping.loadFailed': 'Linear-Projektzuordnung konnte nicht geladen werden.', + 'settings.integrations.linear.sessionComments.label': 'Sitzungskommentare', + 'settings.integrations.linear.sessionComments.info': 'Kommentiert das Issue, wenn eine Sitzung startet, endet oder fehlschlägt. Kommentare werden nur gepostet, wenn dieser Server eine öffentliche Adresse hat, damit der Link die Sitzung für alle Beteiligten öffnet.', + 'settings.integrations.linear.sessionComments.aria': 'Statuskommentare zu Sitzungen in Linear posten', + 'settings.integrations.linear.sessionComments.loadFailed': 'Linear-Kommentareinstellungen konnten nicht geladen werden.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue-Review', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue-Review', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Eingabeaufforderungen beim Start einer Sitzung aus einem Linear-Issue: sichtbare Benutzernachricht + versteckte Anweisungen.', + }, + fr: { + 'settings.integrations.firstParty.title': 'Intégrations natives', + 'settings.integrations.firstParty.info': 'Connexions aux services fournis avec OpenChamber. La connexion reste sur cet ordinateur pour que le web, le bureau et un téléphone apparié la partagent.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Connectez des espaces Linear à ce serveur OpenChamber.', + 'settings.integrations.linear.info': 'Connectez un ou plusieurs espaces Linear. OpenChamber enregistre les connexions sur cet ordinateur pour que le web, le bureau et un téléphone apparié les partagent.', + 'settings.integrations.linear.status.notConnected': 'Non connecté', + 'settings.integrations.linear.status.connected': 'Connecté', + 'settings.integrations.linear.status.waiting': 'En attente', + 'settings.integrations.linear.actions.connect': 'Connecter', + 'settings.integrations.linear.actions.disconnect': 'Déconnecter', + 'settings.integrations.linear.actions.addWorkspace': 'Ajouter un workspace', + 'settings.integrations.linear.actions.switchTo': 'Basculer vers', + 'settings.integrations.linear.label.otherWorkspaces': 'Autres workspaces', + 'settings.integrations.linear.flow.title': 'En attente de Linear', + 'settings.integrations.linear.flow.description': 'Terminez la connexion dans l’onglet du navigateur qui vient de s’ouvrir.', + 'settings.integrations.linear.flow.waiting': 'En attente de l’autorisation…', + 'settings.integrations.linear.toast.connected': 'Linear connecté', + 'settings.integrations.linear.toast.disconnected': 'Linear déconnecté', + 'settings.integrations.linear.toast.workspaceSwitched': 'Workspace Linear modifié', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear', + 'settings.integrations.linear.toast.startConnectFailed': 'Impossible de démarrer la connexion Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'Impossible de déconnecter Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'L’autorisation Linear a expiré. Cliquez sur Connecter pour réessayer.', + 'settings.integrations.linear.avatarAlt.withName': 'Avatar Linear de {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Avatar Linear', + 'settings.integrations.linear.label.unknownUser': 'Utilisateur inconnu', + 'settings.integrations.linear.mapping.defaultProject': 'Projet par défaut', + 'settings.integrations.linear.mapping.defaultProject.info': 'Les nouvelles sessions depuis des tickets Linear utilisent ce projet, sauf si l’équipe du ticket a sa propre association.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Aucun', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Projet par défaut pour les tickets Linear', + 'settings.integrations.linear.mapping.teams': 'Projets par équipe', + 'settings.integrations.linear.mapping.teams.info': 'Facultatif. Un ticket d’une équipe associée s’ouvre dans ce projet plutôt que dans le projet par défaut.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Utiliser le défaut', + 'settings.integrations.linear.mapping.teams.aria': 'Projet pour l’équipe Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Ajoutez d’abord un projet, puis associez les équipes Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'Cet espace Linear n’a aucune équipe.', + 'settings.integrations.linear.mapping.loadFailed': 'Impossible de charger l’association des projets Linear.', + 'settings.integrations.linear.sessionComments.label': 'Commentaires de session', + 'settings.integrations.linear.sessionComments.info': 'Ajoute un commentaire au ticket quand une session démarre, se termine ou échoue. Les commentaires ne sont publiés que si ce serveur a une adresse publique, afin que le lien ouvre la session pour tout le monde.', + 'settings.integrations.linear.sessionComments.aria': 'Publier les commentaires d’état de session dans Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Impossible de charger les réglages de commentaires Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revue d’issue', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Revue d’issue', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts utilisés au démarrage d’une session depuis un ticket Linear : message utilisateur visible + instructions masquées.', + }, + es: { + 'settings.integrations.firstParty.title': 'Integraciones nativas', + 'settings.integrations.firstParty.info': 'Inicios de sesión de los servicios incluidos en OpenChamber. El inicio de sesión se guarda en este ordenador para que la web, el escritorio y un teléfono emparejado lo compartan.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Conecta espacios de Linear a este servidor de OpenChamber.', + 'settings.integrations.linear.info': 'Conecta uno o más espacios de Linear. OpenChamber guarda los inicios de sesión en este ordenador para que la web, el escritorio y un teléfono emparejado los compartan.', + 'settings.integrations.linear.status.notConnected': 'No conectado', + 'settings.integrations.linear.status.connected': 'Conectado', + 'settings.integrations.linear.status.waiting': 'Esperando', + 'settings.integrations.linear.actions.connect': 'Conectar', + 'settings.integrations.linear.actions.disconnect': 'Desconectar', + 'settings.integrations.linear.actions.addWorkspace': 'Añadir workspace', + 'settings.integrations.linear.actions.switchTo': 'Cambiar a', + 'settings.integrations.linear.label.otherWorkspaces': 'Otros workspaces', + 'settings.integrations.linear.flow.title': 'Esperando a Linear', + 'settings.integrations.linear.flow.description': 'Termina de iniciar sesión en la pestaña del navegador que acaba de abrirse.', + 'settings.integrations.linear.flow.waiting': 'Esperando la autorización…', + 'settings.integrations.linear.toast.connected': 'Linear conectado', + 'settings.integrations.linear.toast.disconnected': 'Linear desconectado', + 'settings.integrations.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear', + 'settings.integrations.linear.toast.startConnectFailed': 'No se pudo iniciar la conexión con Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'No se pudo desconectar Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'La autorización de Linear ha caducado. Haz clic en Conectar para intentarlo de nuevo.', + 'settings.integrations.linear.avatarAlt.withName': 'Avatar de Linear de {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Avatar de Linear', + 'settings.integrations.linear.label.unknownUser': 'Usuario desconocido', + 'settings.integrations.linear.mapping.defaultProject': 'Proyecto predeterminado', + 'settings.integrations.linear.mapping.defaultProject.info': 'Las sesiones nuevas desde issues de Linear usan este proyecto, salvo que el equipo del issue tenga su propia asignación.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Ninguno', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Proyecto predeterminado para issues de Linear', + 'settings.integrations.linear.mapping.teams': 'Proyectos por equipo', + 'settings.integrations.linear.mapping.teams.info': 'Opcional. Un issue de un equipo asignado se abre en ese proyecto en lugar del predeterminado.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Usar el predeterminado', + 'settings.integrations.linear.mapping.teams.aria': 'Proyecto para el equipo de Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Añade primero un proyecto y luego asigna equipos de Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'Este espacio de Linear no tiene equipos.', + 'settings.integrations.linear.mapping.loadFailed': 'No se pudo cargar la asignación de proyectos de Linear.', + 'settings.integrations.linear.sessionComments.label': 'Comentarios de sesión', + 'settings.integrations.linear.sessionComments.info': 'Añade un comentario a la incidencia cuando una sesión empieza, termina o falla. Los comentarios solo se publican si este servidor tiene una dirección pública, para que el enlace abra la sesión a todos.', + 'settings.integrations.linear.sessionComments.aria': 'Publicar comentarios de estado de sesión en Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'No se pudieron cargar los ajustes de comentarios de Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisión de issue', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisión de issue', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados al iniciar una sesión desde un issue de Linear: mensaje visible del usuario e instrucciones ocultas.', + }, + ja: { + 'settings.integrations.firstParty.title': '標準連携', + 'settings.integrations.firstParty.info': 'OpenChamber に同梱されているサービスのログインです。このコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'この OpenChamber サーバーに Linear ワークスペースを接続します。複数接続できます。', + 'settings.integrations.linear.info': 'Linear ワークスペースを1つ以上接続します。ログインはこのコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。', + 'settings.integrations.linear.status.notConnected': '未接続', + 'settings.integrations.linear.status.connected': '接続済み', + 'settings.integrations.linear.status.waiting': '待機中', + 'settings.integrations.linear.actions.connect': '接続', + 'settings.integrations.linear.actions.disconnect': '切断', + 'settings.integrations.linear.actions.addWorkspace': 'ワークスペースを追加', + 'settings.integrations.linear.actions.switchTo': '切り替える', + 'settings.integrations.linear.label.otherWorkspaces': '他のワークスペース', + 'settings.integrations.linear.flow.title': 'Linear を待っています', + 'settings.integrations.linear.flow.description': '開いたブラウザタブでサインインを完了してください。', + 'settings.integrations.linear.flow.waiting': '認可を待っています…', + 'settings.integrations.linear.toast.connected': 'Linear に接続しました', + 'settings.integrations.linear.toast.disconnected': 'Linear を切断しました', + 'settings.integrations.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした', + 'settings.integrations.linear.toast.startConnectFailed': 'Linear のサインインを開始できませんでした', + 'settings.integrations.linear.toast.disconnectFailed': 'Linear を切断できませんでした', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear の認可がタイムアウトしました。接続をもう一度押してください。', + 'settings.integrations.linear.avatarAlt.withName': '{name} の Linear アバター', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear アバター', + 'settings.integrations.linear.label.unknownUser': '不明なユーザー', + 'settings.integrations.linear.mapping.defaultProject': 'デフォルトのプロジェクト', + 'settings.integrations.linear.mapping.defaultProject.info': 'Linear Issueから作る新しいセッションはこのプロジェクトを使います。チームに個別の割り当てがある場合はそちらを使います。', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'なし', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issueのデフォルトプロジェクト', + 'settings.integrations.linear.mapping.teams': 'チームのプロジェクト', + 'settings.integrations.linear.mapping.teams.info': '任意。割り当てたチームのIssueは、デフォルトではなくそのプロジェクトで開きます。', + 'settings.integrations.linear.mapping.teams.useDefault': 'デフォルトを使う', + 'settings.integrations.linear.mapping.teams.aria': 'Linearチーム {team} のプロジェクト', + 'settings.integrations.linear.mapping.emptyProjects': '先にプロジェクトを追加してから、Linearチームを割り当ててください。', + 'settings.integrations.linear.mapping.emptyTeams': 'このLinearワークスペースにはチームがありません。', + 'settings.integrations.linear.mapping.loadFailed': 'Linearのプロジェクト割り当てを読み込めませんでした。', + 'settings.integrations.linear.sessionComments.label': 'セッションのコメント', + 'settings.integrations.linear.sessionComments.info': 'セッションの開始・完了・失敗時にイシューへコメントします。リンクを誰でも開けるよう、このサーバーが公開アドレスを持つ場合のみ投稿します。', + 'settings.integrations.linear.sessionComments.aria': 'セッション状態のコメントを Linear に投稿', + 'settings.integrations.linear.sessionComments.loadFailed': 'Linear のコメント設定を読み込めませんでした。', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue レビュー', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue レビュー', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear の Issue からセッションを開始するときに使うプロンプト: 表示ユーザーメッセージ + 非表示の指示。', + }, + ko: { + 'settings.integrations.firstParty.title': '기본 제공 통합', + 'settings.integrations.firstParty.info': 'OpenChamber에 포함된 서비스 로그인입니다. 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': '이 OpenChamber 서버에 Linear 워크스페이스를 연결하세요. 여러 개를 연결할 수 있습니다.', + 'settings.integrations.linear.info': 'Linear 워크스페이스를 하나 이상 연결하세요. 로그인은 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.', + 'settings.integrations.linear.status.notConnected': '연결되지 않음', + 'settings.integrations.linear.status.connected': '연결됨', + 'settings.integrations.linear.status.waiting': '대기 중', + 'settings.integrations.linear.actions.connect': '연결', + 'settings.integrations.linear.actions.disconnect': '연결 해제', + 'settings.integrations.linear.actions.addWorkspace': '워크스페이스 추가', + 'settings.integrations.linear.actions.switchTo': '전환', + 'settings.integrations.linear.label.otherWorkspaces': '다른 워크스페이스', + 'settings.integrations.linear.flow.title': 'Linear 대기 중', + 'settings.integrations.linear.flow.description': '방금 열린 브라우저 탭에서 로그인을 완료하세요.', + 'settings.integrations.linear.flow.waiting': '권한 부여를 기다리는 중…', + 'settings.integrations.linear.toast.connected': 'Linear가 연결됨', + 'settings.integrations.linear.toast.disconnected': 'Linear 연결이 해제됨', + 'settings.integrations.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다', + 'settings.integrations.linear.toast.startConnectFailed': 'Linear 로그인을 시작하지 못했습니다', + 'settings.integrations.linear.toast.disconnectFailed': 'Linear 연결을 해제하지 못했습니다', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear 권한 부여가 시간 초과되었습니다. 연결을 다시 누르세요.', + 'settings.integrations.linear.avatarAlt.withName': '{name}의 Linear 아바타', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear 아바타', + 'settings.integrations.linear.label.unknownUser': '알 수 없는 사용자', + 'settings.integrations.linear.mapping.defaultProject': '기본 프로젝트', + 'settings.integrations.linear.mapping.defaultProject.info': 'Linear 이슈에서 만드는 새 세션은 이 프로젝트를 사용합니다. 해당 팀에 별도 연결이 있으면 그쪽을 씁니다.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': '없음', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear 이슈의 기본 프로젝트', + 'settings.integrations.linear.mapping.teams': '팀 프로젝트', + 'settings.integrations.linear.mapping.teams.info': '선택 사항입니다. 연결한 팀의 이슈는 기본값 대신 그 프로젝트에서 열립니다.', + 'settings.integrations.linear.mapping.teams.useDefault': '기본값 사용', + 'settings.integrations.linear.mapping.teams.aria': 'Linear 팀 {team}의 프로젝트', + 'settings.integrations.linear.mapping.emptyProjects': '먼저 프로젝트를 추가한 다음 Linear 팀을 연결하세요.', + 'settings.integrations.linear.mapping.emptyTeams': '이 Linear 워크스페이스에는 팀이 없습니다.', + 'settings.integrations.linear.mapping.loadFailed': 'Linear 프로젝트 연결을 불러오지 못했습니다.', + 'settings.integrations.linear.sessionComments.label': '세션 댓글', + 'settings.integrations.linear.sessionComments.info': '세션이 시작, 완료, 실패할 때 이슈에 댓글을 남깁니다. 링크를 모두가 열 수 있도록 이 서버에 공개 주소가 있을 때만 게시합니다.', + 'settings.integrations.linear.sessionComments.aria': '세션 상태 댓글을 Linear에 게시', + 'settings.integrations.linear.sessionComments.loadFailed': 'Linear 댓글 설정을 불러오지 못했습니다.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': '이슈 리뷰', + 'settings.magicPrompts.page.group.linearIssueReview.title': '이슈 리뷰', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear 이슈로 세션을 시작할 때 쓰는 프롬프트입니다: 표시 사용자 메시지 + 숨겨진 지침.', + }, + pl: { + 'settings.integrations.firstParty.title': 'Wbudowane integracje', + 'settings.integrations.firstParty.info': 'Logowania do usług dostarczanych z OpenChamber. Zapisujemy je na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Połącz przestrzenie Linear z tym serwerem OpenChamber.', + 'settings.integrations.linear.info': 'Połącz jedną lub kilka przestrzeni Linear. OpenChamber zapisuje logowania na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.', + 'settings.integrations.linear.status.notConnected': 'Nie połączono', + 'settings.integrations.linear.status.connected': 'Połączono', + 'settings.integrations.linear.status.waiting': 'Oczekiwanie', + 'settings.integrations.linear.actions.connect': 'Połącz', + 'settings.integrations.linear.actions.disconnect': 'Rozłącz', + 'settings.integrations.linear.actions.addWorkspace': 'Dodaj workspace', + 'settings.integrations.linear.actions.switchTo': 'Przełącz na', + 'settings.integrations.linear.label.otherWorkspaces': 'Inne przestrzenie', + 'settings.integrations.linear.flow.title': 'Oczekiwanie na Linear', + 'settings.integrations.linear.flow.description': 'Dokończ logowanie w karcie przeglądarki, która właśnie się otworzyła.', + 'settings.integrations.linear.flow.waiting': 'Oczekiwanie na autoryzację…', + 'settings.integrations.linear.toast.connected': 'Połączono z Linear', + 'settings.integrations.linear.toast.disconnected': 'Rozłączono Linear', + 'settings.integrations.linear.toast.workspaceSwitched': 'Przełączono workspace Linear', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear', + 'settings.integrations.linear.toast.startConnectFailed': 'Nie udało się rozpocząć logowania do Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'Nie udało się rozłączyć Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Autoryzacja Linear wygasła. Kliknij Połącz, aby spróbować ponownie.', + 'settings.integrations.linear.avatarAlt.withName': 'Awatar Linear użytkownika {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Awatar Linear', + 'settings.integrations.linear.label.unknownUser': 'Nieznany użytkownik', + 'settings.integrations.linear.mapping.defaultProject': 'Domyślny projekt', + 'settings.integrations.linear.mapping.defaultProject.info': 'Nowe sesje ze zgłoszeń Linear używają tego projektu, chyba że zespół zgłoszenia ma własne przypisanie.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Brak', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Domyślny projekt dla zgłoszeń Linear', + 'settings.integrations.linear.mapping.teams': 'Projekty zespołów', + 'settings.integrations.linear.mapping.teams.info': 'Opcjonalnie. Zgłoszenie z przypisanego zespołu otworzy się w tym projekcie zamiast w domyślnym.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Użyj domyślnego', + 'settings.integrations.linear.mapping.teams.aria': 'Projekt dla zespołu Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Najpierw dodaj projekt, a potem przypisz zespoły Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'Ten obszar Linear nie ma zespołów.', + 'settings.integrations.linear.mapping.loadFailed': 'Nie udało się wczytać przypisania projektów Linear.', + 'settings.integrations.linear.sessionComments.label': 'Komentarze o sesji', + 'settings.integrations.linear.sessionComments.info': 'Dodaje komentarz do zgłoszenia, gdy sesja się zaczyna, kończy lub kończy błędem. Komentarze pojawiają się tylko wtedy, gdy ten serwer ma publiczny adres, żeby link otwierał sesję każdemu.', + 'settings.integrations.linear.sessionComments.aria': 'Publikuj komentarze o stanie sesji w Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Nie udało się wczytać ustawień komentarzy Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Przegląd zgłoszenia', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Przegląd zgłoszenia', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompty używane przy starcie sesji ze zgłoszenia Linear: widoczna wiadomość użytkownika i ukryte instrukcje.', + }, + 'pt-BR': { + 'settings.integrations.firstParty.title': 'Integrações nativas', + 'settings.integrations.firstParty.info': 'Logins dos serviços inclusos no OpenChamber. O login fica neste computador para que a web, o app desktop e um celular emparelhado o compartilhem.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Conecte espaços do Linear a este servidor OpenChamber.', + 'settings.integrations.linear.info': 'Conecte um ou mais espaços do Linear. O OpenChamber guarda os logins neste computador para que a web, o app desktop e um celular emparelhado os compartilhem.', + 'settings.integrations.linear.status.notConnected': 'Não conectado', + 'settings.integrations.linear.status.connected': 'Conectado', + 'settings.integrations.linear.status.waiting': 'Aguardando', + 'settings.integrations.linear.actions.connect': 'Conectar', + 'settings.integrations.linear.actions.disconnect': 'Desconectar', + 'settings.integrations.linear.actions.addWorkspace': 'Adicionar workspace', + 'settings.integrations.linear.actions.switchTo': 'Alternar para', + 'settings.integrations.linear.label.otherWorkspaces': 'Outros workspaces', + 'settings.integrations.linear.flow.title': 'Aguardando o Linear', + 'settings.integrations.linear.flow.description': 'Conclua o login na aba do navegador que acabou de abrir.', + 'settings.integrations.linear.flow.waiting': 'Aguardando autorização…', + 'settings.integrations.linear.toast.connected': 'Linear conectado', + 'settings.integrations.linear.toast.disconnected': 'Linear desconectado', + 'settings.integrations.linear.toast.workspaceSwitched': 'Workspace do Linear alterado', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear', + 'settings.integrations.linear.toast.startConnectFailed': 'Não foi possível iniciar o login no Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'Não foi possível desconectar o Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'A autorização do Linear expirou. Clique em Conectar para tentar de novo.', + 'settings.integrations.linear.avatarAlt.withName': 'Avatar do Linear de {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Avatar do Linear', + 'settings.integrations.linear.label.unknownUser': 'Usuário desconhecido', + 'settings.integrations.linear.mapping.defaultProject': 'Projeto padrão', + 'settings.integrations.linear.mapping.defaultProject.info': 'Novas sessões a partir de issues do Linear usam este projeto, a menos que a equipe da issue tenha o próprio mapeamento.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Nenhum', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Projeto padrão para issues do Linear', + 'settings.integrations.linear.mapping.teams': 'Projetos por equipe', + 'settings.integrations.linear.mapping.teams.info': 'Opcional. Uma issue de uma equipe mapeada abre nesse projeto em vez do padrão.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Usar o padrão', + 'settings.integrations.linear.mapping.teams.aria': 'Projeto para a equipe do Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Adicione um projeto primeiro e depois mapeie as equipes do Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'Este espaço do Linear não tem equipes.', + 'settings.integrations.linear.mapping.loadFailed': 'Não foi possível carregar o mapeamento de projetos do Linear.', + 'settings.integrations.linear.sessionComments.label': 'Comentários de sessão', + 'settings.integrations.linear.sessionComments.info': 'Comenta na issue quando uma sessão começa, termina ou falha. Os comentários só são publicados se este servidor tiver um endereço público, para que o link abra a sessão para todos.', + 'settings.integrations.linear.sessionComments.aria': 'Publicar comentários de status de sessão no Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Não foi possível carregar as configurações de comentários do Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisão de issue', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisão de issue', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados ao iniciar uma sessão a partir de uma issue do Linear: mensagem visível do usuário e instruções ocultas.', + }, + uk: { + 'settings.integrations.firstParty.title': 'Вбудовані інтеграції', + 'settings.integrations.firstParty.info': 'Входи до сервісів, що входять до OpenChamber. Логін лишається на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються одним обліковим записом.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Підключіть Linear workspace до цього сервера OpenChamber. Можна кілька.', + 'settings.integrations.linear.info': 'Підключіть один або кілька Linear workspace. OpenChamber зберігає входи на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються ними.', + 'settings.integrations.linear.status.notConnected': 'Не підключено', + 'settings.integrations.linear.status.connected': 'Підключено', + 'settings.integrations.linear.status.waiting': 'Очікування', + 'settings.integrations.linear.actions.connect': 'Підключити', + 'settings.integrations.linear.actions.disconnect': 'Відключити', + 'settings.integrations.linear.actions.addWorkspace': 'Додати workspace', + 'settings.integrations.linear.actions.switchTo': 'Перемкнути на', + 'settings.integrations.linear.label.otherWorkspaces': 'Інші workspace', + 'settings.integrations.linear.flow.title': 'Очікування Linear', + 'settings.integrations.linear.flow.description': 'Завершіть вхід у вкладці браузера, яка щойно відкрилась.', + 'settings.integrations.linear.flow.waiting': 'Очікування авторизації…', + 'settings.integrations.linear.toast.connected': 'Linear підключено', + 'settings.integrations.linear.toast.disconnected': 'Linear відключено', + 'settings.integrations.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace', + 'settings.integrations.linear.toast.startConnectFailed': 'Не вдалося почати вхід у Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'Не вдалося відключити Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Авторизація Linear завершилась за часом. Натисніть Підключити ще раз.', + 'settings.integrations.linear.avatarAlt.withName': 'Аватар Linear для {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Аватар Linear', + 'settings.integrations.linear.label.unknownUser': 'Невідомий користувач', + 'settings.integrations.linear.mapping.defaultProject': 'Проєкт за замовчуванням', + 'settings.integrations.linear.mapping.defaultProject.info': 'Нові сесії з Linear issue використовують цей проєкт, якщо в команди issue немає власної прив’язки.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Немає', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Проєкт за замовчуванням для Linear issue', + 'settings.integrations.linear.mapping.teams': 'Проєкти команд', + 'settings.integrations.linear.mapping.teams.info': 'Не обов’язково. Issue з прив’язаної команди відкриється в цьому проєкті, а не в типовому.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Використати типовий', + 'settings.integrations.linear.mapping.teams.aria': 'Проєкт для команди Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Спочатку додайте проєкт, потім прив’яжіть команди Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'У цьому робочому просторі Linear немає команд.', + 'settings.integrations.linear.mapping.loadFailed': 'Не вдалося завантажити прив’язку проєктів Linear.', + 'settings.integrations.linear.sessionComments.label': 'Коментарі про сесію', + 'settings.integrations.linear.sessionComments.info': 'Додає коментар до тікета, коли сесія починається, завершується або падає. Коментарі публікуються, лише якщо цей сервер має публічну адресу, щоб посилання відкривало сесію для всіх.', + 'settings.integrations.linear.sessionComments.aria': 'Публікувати коментарі про стан сесії в Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Не вдалося завантажити налаштування коментарів Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Огляд issue', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Огляд issue', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Промпти для старту сесії з Linear issue: видиме повідомлення користувача та приховані інструкції.', + }, + 'zh-CN': { + 'settings.integrations.firstParty.title': '内置集成', + 'settings.integrations.firstParty.info': 'OpenChamber 自带服务的登录。登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它。', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': '将 Linear 工作区连接到此 OpenChamber 服务器。可以连接多个。', + 'settings.integrations.linear.info': '连接一个或多个 Linear 工作区。OpenChamber 把登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它们。', + 'settings.integrations.linear.status.notConnected': '未连接', + 'settings.integrations.linear.status.connected': '已连接', + 'settings.integrations.linear.status.waiting': '等待中', + 'settings.integrations.linear.actions.connect': '连接', + 'settings.integrations.linear.actions.disconnect': '断开', + 'settings.integrations.linear.actions.addWorkspace': '添加工作区', + 'settings.integrations.linear.actions.switchTo': '切换到', + 'settings.integrations.linear.label.otherWorkspaces': '其他工作区', + 'settings.integrations.linear.flow.title': '正在等待 Linear', + 'settings.integrations.linear.flow.description': '请在刚打开的浏览器标签页中完成登录。', + 'settings.integrations.linear.flow.waiting': '正在等待授权…', + 'settings.integrations.linear.toast.connected': '已连接 Linear', + 'settings.integrations.linear.toast.disconnected': '已断开 Linear', + 'settings.integrations.linear.toast.workspaceSwitched': '已切换 Linear 工作区', + 'settings.integrations.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区', + 'settings.integrations.linear.toast.startConnectFailed': '无法开始 Linear 登录', + 'settings.integrations.linear.toast.disconnectFailed': '无法断开 Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear 授权已超时。请再次点击连接。', + 'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 头像', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear 头像', + 'settings.integrations.linear.label.unknownUser': '未知用户', + 'settings.integrations.linear.mapping.defaultProject': '默认项目', + 'settings.integrations.linear.mapping.defaultProject.info': '从 Linear Issue 新建的会话会使用此项目,除非该 Issue 所属团队有单独映射。', + 'settings.integrations.linear.mapping.defaultProject.placeholder': '无', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的默认项目', + 'settings.integrations.linear.mapping.teams': '团队项目', + 'settings.integrations.linear.mapping.teams.info': '可选。来自已映射团队的 Issue 会在该项目中打开,而不是默认项目。', + 'settings.integrations.linear.mapping.teams.useDefault': '使用默认', + 'settings.integrations.linear.mapping.teams.aria': 'Linear 团队 {team} 的项目', + 'settings.integrations.linear.mapping.emptyProjects': '请先添加一个项目,再映射 Linear 团队。', + 'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作区没有团队。', + 'settings.integrations.linear.mapping.loadFailed': '无法加载 Linear 项目映射。', + 'settings.integrations.linear.sessionComments.label': '会话评论', + 'settings.integrations.linear.sessionComments.info': '会话开始、完成或失败时在议题下留言。仅当此服务器拥有公网地址时才发布,这样链接才能让所有人打开该会话。', + 'settings.integrations.linear.sessionComments.aria': '将会话状态评论发布到 Linear', + 'settings.integrations.linear.sessionComments.loadFailed': '无法加载 Linear 评论设置。', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 审查', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 审查', + 'settings.magicPrompts.page.group.linearIssueReview.description': '从 Linear Issue 开始会话时使用的提示词:可见用户消息 + 隐藏指令。', + }, + 'zh-TW': { + 'settings.integrations.firstParty.title': '內建整合', + 'settings.integrations.firstParty.info': 'OpenChamber 內建服務的登入。登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它。', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': '將 Linear 工作區連線到此 OpenChamber 伺服器。可以連線多個。', + 'settings.integrations.linear.info': '連接一個或多個 Linear 工作區。OpenChamber 把登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它們。', + 'settings.integrations.linear.status.notConnected': '未連線', + 'settings.integrations.linear.status.connected': '已連線', + 'settings.integrations.linear.status.waiting': '等待中', + 'settings.integrations.linear.actions.connect': '連線', + 'settings.integrations.linear.actions.disconnect': '中斷連線', + 'settings.integrations.linear.actions.addWorkspace': '新增工作區', + 'settings.integrations.linear.actions.switchTo': '切換到', + 'settings.integrations.linear.label.otherWorkspaces': '其他工作區', + 'settings.integrations.linear.flow.title': '正在等待 Linear', + 'settings.integrations.linear.flow.description': '請在剛開啟的瀏覽器分頁中完成登入。', + 'settings.integrations.linear.flow.waiting': '正在等待授權…', + 'settings.integrations.linear.toast.connected': '已連線 Linear', + 'settings.integrations.linear.toast.disconnected': '已中斷 Linear', + 'settings.integrations.linear.toast.workspaceSwitched': '已切換 Linear 工作區', + 'settings.integrations.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區', + 'settings.integrations.linear.toast.startConnectFailed': '無法開始 Linear 登入', + 'settings.integrations.linear.toast.disconnectFailed': '無法中斷 Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear 授權已逾時。請再次按連線。', + 'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 頭像', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear 頭像', + 'settings.integrations.linear.label.unknownUser': '未知使用者', + 'settings.integrations.linear.mapping.defaultProject': '預設專案', + 'settings.integrations.linear.mapping.defaultProject.info': '從 Linear Issue 新增的會話會使用此專案,除非該 Issue 所屬團隊有單獨對應。', + 'settings.integrations.linear.mapping.defaultProject.placeholder': '無', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的預設專案', + 'settings.integrations.linear.mapping.teams': '團隊專案', + 'settings.integrations.linear.mapping.teams.info': '選用。來自已對應團隊的 Issue 會在該專案中開啟,而不是預設專案。', + 'settings.integrations.linear.mapping.teams.useDefault': '使用預設', + 'settings.integrations.linear.mapping.teams.aria': 'Linear 團隊 {team} 的專案', + 'settings.integrations.linear.mapping.emptyProjects': '請先新增一個專案,再對應 Linear 團隊。', + 'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作區沒有團隊。', + 'settings.integrations.linear.mapping.loadFailed': '無法載入 Linear 專案對應。', + 'settings.integrations.linear.sessionComments.label': '工作階段留言', + 'settings.integrations.linear.sessionComments.info': '工作階段開始、完成或失敗時在議題留言。僅在這台伺服器有公開位址時才發布,這樣連結才能讓所有人開啟該工作階段。', + 'settings.integrations.linear.sessionComments.aria': '將工作階段狀態留言發布到 Linear', + 'settings.integrations.linear.sessionComments.loadFailed': '無法載入 Linear 留言設定。', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 審查', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 審查', + 'settings.magicPrompts.page.group.linearIssueReview.description': '從 Linear Issue 開始會話時使用的提示詞:可見使用者訊息 + 隱藏指令。', + }, + tr: { + 'settings.integrations.firstParty.title': 'Yerleşik entegrasyonlar', + 'settings.integrations.firstParty.info': 'OpenChamber ile gelen hizmetlerin oturumları. Giriş bu bilgisayarda kalır; web, masaüstü ve eşlenen telefon paylaşır.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Linear çalışma alanlarını bu OpenChamber sunucusuna bağla.', + 'settings.integrations.linear.info': 'Bir veya daha fazla Linear çalışma alanı bağla. OpenChamber girişleri bu bilgisayarda tutar; web, masaüstü ve eşlenen telefon paylaşır.', + 'settings.integrations.linear.status.notConnected': 'Bağlı değil', + 'settings.integrations.linear.status.connected': 'Bağlı', + 'settings.integrations.linear.status.waiting': 'Bekleniyor', + 'settings.integrations.linear.actions.connect': 'Bağlan', + 'settings.integrations.linear.actions.disconnect': 'Bağlantıyı kes', + 'settings.integrations.linear.actions.addWorkspace': 'Çalışma alanı ekle', + 'settings.integrations.linear.actions.switchTo': 'Şuna geç', + 'settings.integrations.linear.label.otherWorkspaces': 'Diğer çalışma alanları', + 'settings.integrations.linear.flow.title': 'Linear bekleniyor', + 'settings.integrations.linear.flow.description': 'Az önce açılan tarayıcı sekmesinde girişi bitir.', + 'settings.integrations.linear.flow.waiting': 'Yetkilendirme bekleniyor…', + 'settings.integrations.linear.toast.connected': 'Linear bağlandı', + 'settings.integrations.linear.toast.disconnected': 'Linear bağlantısı kesildi', + 'settings.integrations.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi', + 'settings.integrations.linear.toast.startConnectFailed': 'Linear girişi başlatılamadı', + 'settings.integrations.linear.toast.disconnectFailed': 'Linear bağlantısı kesilemedi', + 'settings.integrations.linear.toast.authorizationFailed': "Linear yetkilendirmesi zaman aşımına uğradı. Yeniden bağlanmak için Bağlan'a bas.", + 'settings.integrations.linear.avatarAlt.withName': '{name} için Linear avatarı', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear avatarı', + 'settings.integrations.linear.label.unknownUser': 'Bilinmeyen kullanıcı', + 'settings.integrations.linear.mapping.defaultProject': 'Varsayılan proje', + 'settings.integrations.linear.mapping.defaultProject.info': "Linear issue'larından yeni session'lar, ekibin kendi eşlemesi yoksa bu projeyi kullanır.", + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Yok', + 'settings.integrations.linear.mapping.defaultProject.aria': "Linear issue'ları için varsayılan proje", + 'settings.integrations.linear.mapping.teams': 'Ekip projeleri', + 'settings.integrations.linear.mapping.teams.info': 'İsteğe bağlı. Eşlenen bir ekipten gelen issue varsayılan yerine o projede açılır.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Varsayılanı kullan', + 'settings.integrations.linear.mapping.teams.aria': 'Linear ekibi {team} için proje', + 'settings.integrations.linear.mapping.emptyProjects': 'Önce bir proje ekle, sonra Linear ekiplerini ona eşle.', + 'settings.integrations.linear.mapping.emptyTeams': 'Bu Linear çalışma alanında ekip yok.', + 'settings.integrations.linear.mapping.loadFailed': 'Linear proje eşlemesi yüklenemedi.', + 'settings.integrations.linear.sessionComments.label': 'Oturum yorumları', + 'settings.integrations.linear.sessionComments.info': 'Bir oturum başladığında, bittiğinde veya başarısız olduğunda göreve yorum ekler. Bağlantının herkeste açılabilmesi için yorumlar yalnızca bu sunucunun genel bir adresi varsa gönderilir.', + 'settings.integrations.linear.sessionComments.aria': 'Oturum durumu yorumlarını Linear’a gönder', + 'settings.integrations.linear.sessionComments.loadFailed': 'Linear yorum ayarları yüklenemedi.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue incelemesi', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue incelemesi', + 'settings.magicPrompts.page.group.linearIssueReview.description': "Linear issue'dan session başlatırken kullanılan prompt'lar: görünen kullanıcı mesajı + gizli talimatlar.", + }, +} as const; diff --git a/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts new file mode 100644 index 00000000..7fc0303e --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; + +const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const; + +const requiredKeys = [ + 'chat.chatInput.actions.linkLinearIssue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria', + 'chat.chatInput.linked.linearIssue.removeAria', + 'session.linearIssuePicker.title', + 'session.linearIssuePicker.description', + 'session.linearIssuePicker.searchPlaceholder', + 'session.linearIssuePicker.empty.notConnected', + 'session.linearIssuePicker.empty.runtimeUnavailable', + 'session.linearIssuePicker.empty.noIssuesFound', + 'session.linearIssuePicker.empty.noOpenIssuesFound', + 'session.linearIssuePicker.loading.issues', + 'session.linearIssuePicker.loading.more', + 'session.linearIssuePicker.actions.openSettings', + 'session.linearIssuePicker.actions.useIssue', + 'session.linearIssuePicker.actions.loadMore', + 'session.linearIssuePicker.actions.openInLinearAria', + 'session.linearIssuePicker.toast.loadMoreFailed', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed', + 'session.linearIssuePicker.error.notConnected', + 'session.linearIssuePicker.error.runtimeUnavailable', + 'session.linearIssuePicker.error.issueNotFound', + 'chat.chatInput.actions.newSessionFromLinearIssue', + 'session.linearIssuePicker.title.createSession', + 'session.linearIssuePicker.description.createSession', + 'session.linearIssuePicker.error.noMappedProject', + 'session.linearIssuePicker.error.noModelSelected', + 'session.linearIssuePicker.toast.sendContextFailed', + 'session.linearIssuePicker.toast.sessionCreated', + 'session.linearIssuePicker.toast.startSessionFailed', + 'session.linearIssuePicker.actions.sectionTitle', + 'session.linearIssuePicker.actions.toggleWorktreeAria', + 'session.linearIssuePicker.actions.createInWorktree', + 'session.linearIssuePicker.actions.refresh', + 'chat.workStatus.linkedIssues.openLinear', + 'session.newWorktree.actions.startFromLinearIssue', + 'session.newWorktree.fromLinearIssue', + 'session.newWorktree.error.sendLinearContextFailed', +] as const; + +describe('linear issue picker translations', () => { + test('provides every required key in every supported locale', () => { + const english = linearIssuePickerI18n.en; + for (const locale of locales) { + for (const key of requiredKeys) { + const value = linearIssuePickerI18n[locale][key]; + expect(value).toBeTruthy(); + if (locale !== 'en') { + expect(value).not.toBe(english[key]); + } + } + } + }); +}); diff --git a/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts new file mode 100644 index 00000000..4a7e70bb --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts @@ -0,0 +1,471 @@ +/** Linear issue picker / composer strings — merged into each locale's main dictionary. */ +export const linearIssuePickerI18n = { + en: { + 'chat.chatInput.actions.linkLinearIssue': 'Link Linear Issue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Open issue in Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Remove linked Linear issue', + 'session.linearIssuePicker.title': 'Link Linear Issue', + 'session.linearIssuePicker.description': 'Select an issue from your connected Linear workspace.', + 'session.linearIssuePicker.searchPlaceholder': 'Search by title, identifier, or Linear URL', + 'session.linearIssuePicker.empty.notConnected': 'Linear is not connected. Connect it in Settings → Integrations.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear is not available in this app.', + 'session.linearIssuePicker.empty.noIssuesFound': 'No issues found', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'No open issues found', + 'session.linearIssuePicker.loading.issues': 'Loading issues...', + 'session.linearIssuePicker.loading.more': 'Loading...', + 'session.linearIssuePicker.actions.openSettings': 'Open settings', + 'session.linearIssuePicker.actions.useIssue': 'Use {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Load more', + 'session.linearIssuePicker.actions.openInLinearAria': 'Open in Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Failed to load more issues', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Failed to load issue details', + 'session.linearIssuePicker.error.notConnected': 'Linear not connected', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear is not available in this app', + 'session.linearIssuePicker.error.issueNotFound': 'Issue not found', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'New Session From Linear Issue', + 'session.linearIssuePicker.title.createSession': 'New Session From Linear Issue', + 'session.linearIssuePicker.description.createSession': 'Creates a session in the project mapped to this Linear team, with the issue as the first prompt.', + 'session.linearIssuePicker.error.noMappedProject': 'Map this Linear team to a project in Settings → Integrations', + 'session.linearIssuePicker.error.noModelSelected': 'No model selected', + 'session.linearIssuePicker.toast.sendContextFailed': 'Failed to send issue context', + 'session.linearIssuePicker.toast.sessionCreated': 'Session created from issue', + 'session.linearIssuePicker.toast.startSessionFailed': 'Failed to start session', + 'session.linearIssuePicker.actions.sectionTitle': 'Actions', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Toggle worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Create in worktree', + 'session.linearIssuePicker.actions.refresh': 'Refresh', + 'chat.workStatus.linkedIssues.openLinear': 'Open {identifier} in Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Start from Linear Issue', + 'session.newWorktree.fromLinearIssue': 'From {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Failed to send Linear context', + }, + de: { + 'chat.chatInput.actions.linkLinearIssue': 'Linear-Issue verknüpfen', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Issue in Linear öffnen', + 'chat.chatInput.linked.linearIssue.removeAria': 'Verknüpftes Linear-Issue entfernen', + 'session.linearIssuePicker.title': 'Linear-Issue verknüpfen', + 'session.linearIssuePicker.description': 'Wähle ein Issue aus deinem verbundenen Linear-Workspace.', + 'session.linearIssuePicker.searchPlaceholder': 'Nach Titel, Kennung oder Linear-URL suchen', + 'session.linearIssuePicker.empty.notConnected': 'Linear ist nicht verbunden. Verbinde es unter Einstellungen → Integrationen.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Keine Issues gefunden', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Keine offenen Issues gefunden', + 'session.linearIssuePicker.loading.issues': 'Issues werden geladen...', + 'session.linearIssuePicker.loading.more': 'Wird geladen...', + 'session.linearIssuePicker.actions.openSettings': 'Einstellungen öffnen', + 'session.linearIssuePicker.actions.useIssue': '{identifier} verwenden', + 'session.linearIssuePicker.actions.loadMore': 'Mehr laden', + 'session.linearIssuePicker.actions.openInLinearAria': 'In Linear öffnen', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Weitere Issues konnten nicht geladen werden', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue-Details konnten nicht geladen werden', + 'session.linearIssuePicker.error.notConnected': 'Linear nicht verbunden', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar', + 'session.linearIssuePicker.error.issueNotFound': 'Issue nicht gefunden', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Neue Sitzung aus Linear-Issue', + 'session.linearIssuePicker.title.createSession': 'Neue Sitzung aus Linear-Issue', + 'session.linearIssuePicker.description.createSession': 'Erstellt eine Sitzung im diesem Linear-Team zugeordneten Projekt, mit dem Issue als erstem Prompt.', + 'session.linearIssuePicker.error.noMappedProject': 'Ordne dieses Linear-Team in Einstellungen → Integrationen einem Projekt zu', + 'session.linearIssuePicker.error.noModelSelected': 'Kein Modell ausgewählt', + 'session.linearIssuePicker.toast.sendContextFailed': 'Issue-Kontext konnte nicht gesendet werden', + 'session.linearIssuePicker.toast.sessionCreated': 'Sitzung aus Issue erstellt', + 'session.linearIssuePicker.toast.startSessionFailed': 'Sitzung konnte nicht gestartet werden', + 'session.linearIssuePicker.actions.sectionTitle': 'Aktionen', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Worktree umschalten', + 'session.linearIssuePicker.actions.createInWorktree': 'In Worktree erstellen', + 'session.linearIssuePicker.actions.refresh': 'Aktualisieren', + 'chat.workStatus.linkedIssues.openLinear': '{identifier} in Linear öffnen', + 'session.newWorktree.actions.startFromLinearIssue': 'Von Linear-Issue starten', + 'session.newWorktree.fromLinearIssue': 'Von {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Linear-Kontext konnte nicht gesendet werden', + }, + fr: { + 'chat.chatInput.actions.linkLinearIssue': 'Lier un ticket Linear', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Ouvrir le ticket dans Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Retirer le ticket Linear lié', + 'session.linearIssuePicker.title': 'Lier un ticket Linear', + 'session.linearIssuePicker.description': 'Choisissez un ticket dans votre espace Linear connecté.', + 'session.linearIssuePicker.searchPlaceholder': 'Rechercher par titre, identifiant ou URL Linear', + 'session.linearIssuePicker.empty.notConnected': 'Linear n’est pas connecté. Connectez-le dans Paramètres → Intégrations.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear n’est pas disponible dans cette application.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Aucun ticket trouvé', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Aucun ticket ouvert trouvé', + 'session.linearIssuePicker.loading.issues': 'Chargement des tickets...', + 'session.linearIssuePicker.loading.more': 'Chargement...', + 'session.linearIssuePicker.actions.openSettings': 'Ouvrir les paramètres', + 'session.linearIssuePicker.actions.useIssue': 'Utiliser {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Charger plus', + 'session.linearIssuePicker.actions.openInLinearAria': 'Ouvrir dans Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Impossible de charger d’autres tickets', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Impossible de charger les détails du ticket', + 'session.linearIssuePicker.error.notConnected': 'Linear non connecté', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear n’est pas disponible dans cette application', + 'session.linearIssuePicker.error.issueNotFound': 'Ticket introuvable', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nouvelle session depuis un ticket Linear', + 'session.linearIssuePicker.title.createSession': 'Nouvelle session depuis un ticket Linear', + 'session.linearIssuePicker.description.createSession': 'Crée une session dans le projet associé à cette équipe Linear, avec le ticket comme premier message.', + 'session.linearIssuePicker.error.noMappedProject': 'Associez cette équipe Linear à un projet dans Paramètres → Intégrations', + 'session.linearIssuePicker.error.noModelSelected': 'Aucun modèle sélectionné', + 'session.linearIssuePicker.toast.sendContextFailed': 'Impossible d’envoyer le contexte du ticket', + 'session.linearIssuePicker.toast.sessionCreated': 'Session créée depuis le ticket', + 'session.linearIssuePicker.toast.startSessionFailed': 'Impossible de démarrer la session', + 'session.linearIssuePicker.actions.sectionTitle': 'Actions disponibles', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activer ou désactiver le worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Créer dans un worktree', + 'session.linearIssuePicker.actions.refresh': 'Actualiser', + 'chat.workStatus.linkedIssues.openLinear': 'Ouvrir {identifier} dans Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Démarrer depuis un ticket Linear', + 'session.newWorktree.fromLinearIssue': 'Depuis {identifier} : {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Impossible d’envoyer le contexte Linear', + }, + es: { + 'chat.chatInput.actions.linkLinearIssue': 'Vincular issue de Linear', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue en Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Quitar issue de Linear vinculado', + 'session.linearIssuePicker.title': 'Vincular issue de Linear', + 'session.linearIssuePicker.description': 'Elige un issue del espacio de Linear conectado.', + 'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador o URL de Linear', + 'session.linearIssuePicker.empty.notConnected': 'Linear no está conectado. Conéctalo en Ajustes → Integraciones.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear no está disponible en esta aplicación.', + 'session.linearIssuePicker.empty.noIssuesFound': 'No se encontraron issues', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'No se encontraron issues abiertos', + 'session.linearIssuePicker.loading.issues': 'Cargando issues...', + 'session.linearIssuePicker.loading.more': 'Cargando...', + 'session.linearIssuePicker.actions.openSettings': 'Abrir ajustes', + 'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Cargar más', + 'session.linearIssuePicker.actions.openInLinearAria': 'Abrir en Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'No se pudieron cargar más issues', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'No se pudieron cargar los detalles del issue', + 'session.linearIssuePicker.error.notConnected': 'Linear no conectado', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear no está disponible en esta aplicación', + 'session.linearIssuePicker.error.issueNotFound': 'Issue no encontrado', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nueva sesión desde un issue de Linear', + 'session.linearIssuePicker.title.createSession': 'Nueva sesión desde un issue de Linear', + 'session.linearIssuePicker.description.createSession': 'Crea una sesión en el proyecto asignado a este equipo de Linear, con el issue como primer mensaje.', + 'session.linearIssuePicker.error.noMappedProject': 'Asigna este equipo de Linear a un proyecto en Ajustes → Integraciones', + 'session.linearIssuePicker.error.noModelSelected': 'Ningún modelo seleccionado', + 'session.linearIssuePicker.toast.sendContextFailed': 'No se pudo enviar el contexto del issue', + 'session.linearIssuePicker.toast.sessionCreated': 'Sesión creada desde el issue', + 'session.linearIssuePicker.toast.startSessionFailed': 'No se pudo iniciar la sesión', + 'session.linearIssuePicker.actions.sectionTitle': 'Acciones', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activar o desactivar worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Crear en worktree', + 'session.linearIssuePicker.actions.refresh': 'Actualizar', + 'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} en Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Empezar desde un issue de Linear', + 'session.newWorktree.fromLinearIssue': 'Desde {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'No se pudo enviar el contexto de Linear', + }, + ja: { + 'chat.chatInput.actions.linkLinearIssue': 'Linear Issueをリンク', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'LinearでIssueを開く', + 'chat.chatInput.linked.linearIssue.removeAria': 'リンクしたLinear Issueを削除', + 'session.linearIssuePicker.title': 'Linear Issueをリンク', + 'session.linearIssuePicker.description': '接続中のLinearワークスペースからIssueを選びます。', + 'session.linearIssuePicker.searchPlaceholder': 'タイトル、識別子、またはLinearのURLで検索', + 'session.linearIssuePicker.empty.notConnected': 'Linearは未接続です。設定 → 連携 で接続してください。', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'このアプリではLinearを利用できません。', + 'session.linearIssuePicker.empty.noIssuesFound': 'Issueが見つかりません', + 'session.linearIssuePicker.empty.noOpenIssuesFound': '未完了のIssueはありません', + 'session.linearIssuePicker.loading.issues': 'Issueを読み込み中...', + 'session.linearIssuePicker.loading.more': '読み込み中...', + 'session.linearIssuePicker.actions.openSettings': '設定を開く', + 'session.linearIssuePicker.actions.useIssue': '{identifier} を使う', + 'session.linearIssuePicker.actions.loadMore': 'さらに読み込む', + 'session.linearIssuePicker.actions.openInLinearAria': 'Linearで開く', + 'session.linearIssuePicker.toast.loadMoreFailed': 'これ以上のIssueを読み込めませんでした', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issueの詳細を読み込めませんでした', + 'session.linearIssuePicker.error.notConnected': 'Linear未接続', + 'session.linearIssuePicker.error.runtimeUnavailable': 'このアプリではLinearを利用できません', + 'session.linearIssuePicker.error.issueNotFound': 'Issueが見つかりません', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear Issueから新しいセッション', + 'session.linearIssuePicker.title.createSession': 'Linear Issueから新しいセッション', + 'session.linearIssuePicker.description.createSession': 'このLinearチームに割り当てたプロジェクトでセッションを作り、Issueを最初のプロンプトにします。', + 'session.linearIssuePicker.error.noMappedProject': '設定 → 連携 でこのLinearチームをプロジェクトに割り当ててください', + 'session.linearIssuePicker.error.noModelSelected': 'モデルが選択されていません', + 'session.linearIssuePicker.toast.sendContextFailed': 'Issueのコンテキストを送信できませんでした', + 'session.linearIssuePicker.toast.sessionCreated': 'Issueからセッションを作成しました', + 'session.linearIssuePicker.toast.startSessionFailed': 'セッションを開始できませんでした', + 'session.linearIssuePicker.actions.sectionTitle': '操作', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'ワークツリーを切り替え', + 'session.linearIssuePicker.actions.createInWorktree': 'ワークツリーで作成', + 'session.linearIssuePicker.actions.refresh': '更新', + 'chat.workStatus.linkedIssues.openLinear': 'Linearで {identifier} を開く', + 'session.newWorktree.actions.startFromLinearIssue': 'Linear Issueから開始', + 'session.newWorktree.fromLinearIssue': '{identifier}: {title}から', + 'session.newWorktree.error.sendLinearContextFailed': 'Linearのコンテキストを送信できませんでした', + }, + 'pt-BR': { + 'chat.chatInput.actions.linkLinearIssue': 'Vincular issue do Linear', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue no Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Remover issue do Linear vinculada', + 'session.linearIssuePicker.title': 'Vincular issue do Linear', + 'session.linearIssuePicker.description': 'Selecione uma issue do espaço Linear conectado.', + 'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador ou URL do Linear', + 'session.linearIssuePicker.empty.notConnected': 'O Linear não está conectado. Conecte em Configurações → Integrações.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'O Linear não está disponível neste app.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Nenhuma issue encontrada', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nenhuma issue aberta encontrada', + 'session.linearIssuePicker.loading.issues': 'Carregando issues...', + 'session.linearIssuePicker.loading.more': 'Carregando...', + 'session.linearIssuePicker.actions.openSettings': 'Abrir configurações', + 'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Carregar mais', + 'session.linearIssuePicker.actions.openInLinearAria': 'Abrir no Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Não foi possível carregar mais issues', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Não foi possível carregar os detalhes da issue', + 'session.linearIssuePicker.error.notConnected': 'Linear não conectado', + 'session.linearIssuePicker.error.runtimeUnavailable': 'O Linear não está disponível neste app', + 'session.linearIssuePicker.error.issueNotFound': 'Issue não encontrada', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nova sessão a partir de uma issue do Linear', + 'session.linearIssuePicker.title.createSession': 'Nova sessão a partir de uma issue do Linear', + 'session.linearIssuePicker.description.createSession': 'Cria uma sessão no projeto associado a esta equipe do Linear, com a issue como o primeiro prompt.', + 'session.linearIssuePicker.error.noMappedProject': 'Associe esta equipe do Linear a um projeto em Configurações → Integrações', + 'session.linearIssuePicker.error.noModelSelected': 'Nenhum modelo selecionado', + 'session.linearIssuePicker.toast.sendContextFailed': 'Não foi possível enviar o contexto da issue', + 'session.linearIssuePicker.toast.sessionCreated': 'Sessão criada a partir da issue', + 'session.linearIssuePicker.toast.startSessionFailed': 'Não foi possível iniciar a sessão', + 'session.linearIssuePicker.actions.sectionTitle': 'Ações', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Ativar ou desativar worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Criar em worktree', + 'session.linearIssuePicker.actions.refresh': 'Atualizar', + 'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} no Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Começar a partir de uma issue do Linear', + 'session.newWorktree.fromLinearIssue': 'De {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Não foi possível enviar o contexto do Linear', + }, + uk: { + 'chat.chatInput.actions.linkLinearIssue': 'Прив’язати Linear issue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Відкрити issue в Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Прибрати прив’язаний Linear issue', + 'session.linearIssuePicker.title': 'Прив’язати Linear issue', + 'session.linearIssuePicker.description': 'Оберіть issue з підключеного робочого простору Linear.', + 'session.linearIssuePicker.searchPlaceholder': 'Пошук за назвою, ідентифікатором або URL Linear', + 'session.linearIssuePicker.empty.notConnected': 'Linear не підключено. Підключіть його в Налаштуваннях → Інтеграції.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear недоступний у цьому застосунку.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Issue не знайдено', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Відкритих issue немає', + 'session.linearIssuePicker.loading.issues': 'Завантаження issue...', + 'session.linearIssuePicker.loading.more': 'Завантаження...', + 'session.linearIssuePicker.actions.openSettings': 'Відкрити налаштування', + 'session.linearIssuePicker.actions.useIssue': 'Використати {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Завантажити ще', + 'session.linearIssuePicker.actions.openInLinearAria': 'Відкрити в Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Не вдалося завантажити більше issue', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Не вдалося завантажити деталі issue', + 'session.linearIssuePicker.error.notConnected': 'Linear не підключено', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear недоступний у цьому застосунку', + 'session.linearIssuePicker.error.issueNotFound': 'Issue не знайдено', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Нова сесія з Linear issue', + 'session.linearIssuePicker.title.createSession': 'Нова сесія з Linear issue', + 'session.linearIssuePicker.description.createSession': 'Створює сесію в проєкті, прив’язаному до цієї команди Linear, з issue як першим запитом.', + 'session.linearIssuePicker.error.noMappedProject': 'Прив’яжіть цю команду Linear до проєкту в Налаштуваннях → Інтеграції', + 'session.linearIssuePicker.error.noModelSelected': 'Модель не вибрано', + 'session.linearIssuePicker.toast.sendContextFailed': 'Не вдалося надіслати контекст issue', + 'session.linearIssuePicker.toast.sessionCreated': 'Сесію створено з issue', + 'session.linearIssuePicker.toast.startSessionFailed': 'Не вдалося почати сесію', + 'session.linearIssuePicker.actions.sectionTitle': 'Дії', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Перемкнути worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Створити у worktree', + 'session.linearIssuePicker.actions.refresh': 'Оновити', + 'chat.workStatus.linkedIssues.openLinear': 'Відкрити {identifier} у Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Почати з Linear issue', + 'session.newWorktree.fromLinearIssue': 'З {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Не вдалося надіслати контекст Linear', + }, + ko: { + 'chat.chatInput.actions.linkLinearIssue': 'Linear 이슈 연결', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Linear에서 이슈 열기', + 'chat.chatInput.linked.linearIssue.removeAria': '연결된 Linear 이슈 제거', + 'session.linearIssuePicker.title': 'Linear 이슈 연결', + 'session.linearIssuePicker.description': '연결된 Linear 워크스페이스에서 이슈를 선택하세요.', + 'session.linearIssuePicker.searchPlaceholder': '제목, 식별자 또는 Linear URL로 검색', + 'session.linearIssuePicker.empty.notConnected': 'Linear가 연결되어 있지 않습니다. 설정 → 연동에서 연결하세요.', + 'session.linearIssuePicker.empty.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다.', + 'session.linearIssuePicker.empty.noIssuesFound': '이슈를 찾을 수 없습니다', + 'session.linearIssuePicker.empty.noOpenIssuesFound': '열린 이슈가 없습니다', + 'session.linearIssuePicker.loading.issues': '이슈를 불러오는 중...', + 'session.linearIssuePicker.loading.more': '불러오는 중...', + 'session.linearIssuePicker.actions.openSettings': '설정 열기', + 'session.linearIssuePicker.actions.useIssue': '{identifier} 사용', + 'session.linearIssuePicker.actions.loadMore': '더 보기', + 'session.linearIssuePicker.actions.openInLinearAria': 'Linear에서 열기', + 'session.linearIssuePicker.toast.loadMoreFailed': '이슈를 더 불러오지 못했습니다', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': '이슈 세부 정보를 불러오지 못했습니다', + 'session.linearIssuePicker.error.notConnected': 'Linear가 연결되지 않음', + 'session.linearIssuePicker.error.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다', + 'session.linearIssuePicker.error.issueNotFound': '이슈를 찾을 수 없습니다', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear 이슈로 새 세션 만들기', + 'session.linearIssuePicker.title.createSession': 'Linear 이슈로 새 세션 만들기', + 'session.linearIssuePicker.description.createSession': '이 Linear 팀에 연결한 프로젝트에서 세션을 만들고, 이슈를 첫 프롬프트로 넣습니다.', + 'session.linearIssuePicker.error.noMappedProject': '설정 → 연동에서 이 Linear 팀을 프로젝트에 연결하세요', + 'session.linearIssuePicker.error.noModelSelected': '모델이 선택되지 않았습니다', + 'session.linearIssuePicker.toast.sendContextFailed': '이슈 컨텍스트를 보내지 못했습니다', + 'session.linearIssuePicker.toast.sessionCreated': '이슈에서 세션을 만들었습니다', + 'session.linearIssuePicker.toast.startSessionFailed': '세션을 시작하지 못했습니다', + 'session.linearIssuePicker.actions.sectionTitle': '작업', + 'session.linearIssuePicker.actions.toggleWorktreeAria': '워크트리 전환', + 'session.linearIssuePicker.actions.createInWorktree': '워크트리에서 만들기', + 'session.linearIssuePicker.actions.refresh': '새로고침', + 'chat.workStatus.linkedIssues.openLinear': 'Linear에서 {identifier} 열기', + 'session.newWorktree.actions.startFromLinearIssue': 'Linear 이슈에서 시작', + 'session.newWorktree.fromLinearIssue': '{identifier}: {title}에서', + 'session.newWorktree.error.sendLinearContextFailed': 'Linear 컨텍스트를 보내지 못했습니다', + }, + pl: { + 'chat.chatInput.actions.linkLinearIssue': 'Powiąż zgłoszenie Linear', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Otwórz zgłoszenie w Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Usuń powiązane zgłoszenie Linear', + 'session.linearIssuePicker.title': 'Powiąż zgłoszenie Linear', + 'session.linearIssuePicker.description': 'Wybierz zgłoszenie z połączonego obszaru Linear.', + 'session.linearIssuePicker.searchPlaceholder': 'Szukaj po tytule, identyfikatorze lub adresie URL Linear', + 'session.linearIssuePicker.empty.notConnected': 'Linear nie jest połączony. Połącz go w Ustawieniach → Integracje.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Nie znaleziono zgłoszeń', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nie znaleziono otwartych zgłoszeń', + 'session.linearIssuePicker.loading.issues': 'Ładowanie zgłoszeń...', + 'session.linearIssuePicker.loading.more': 'Ładowanie...', + 'session.linearIssuePicker.actions.openSettings': 'Otwórz ustawienia', + 'session.linearIssuePicker.actions.useIssue': 'Użyj {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Załaduj więcej', + 'session.linearIssuePicker.actions.openInLinearAria': 'Otwórz w Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Nie udało się załadować kolejnych zgłoszeń', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Nie udało się załadować szczegółów zgłoszenia', + 'session.linearIssuePicker.error.notConnected': 'Linear niepołączony', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji', + 'session.linearIssuePicker.error.issueNotFound': 'Nie znaleziono zgłoszenia', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nowa sesja ze zgłoszenia Linear', + 'session.linearIssuePicker.title.createSession': 'Nowa sesja ze zgłoszenia Linear', + 'session.linearIssuePicker.description.createSession': 'Tworzy sesję w projekcie przypisanym do tego zespołu Linear, ze zgłoszeniem jako pierwszym poleceniem.', + 'session.linearIssuePicker.error.noMappedProject': 'Przypisz ten zespół Linear do projektu w Ustawieniach → Integracje', + 'session.linearIssuePicker.error.noModelSelected': 'Nie wybrano modelu', + 'session.linearIssuePicker.toast.sendContextFailed': 'Nie udało się wysłać kontekstu zgłoszenia', + 'session.linearIssuePicker.toast.sessionCreated': 'Utworzono sesję ze zgłoszenia', + 'session.linearIssuePicker.toast.startSessionFailed': 'Nie udało się rozpocząć sesji', + 'session.linearIssuePicker.actions.sectionTitle': 'Czynności', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Przełącz worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Utwórz w worktree', + 'session.linearIssuePicker.actions.refresh': 'Odśwież', + 'chat.workStatus.linkedIssues.openLinear': 'Otwórz {identifier} w Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Zacznij od zgłoszenia Linear', + 'session.newWorktree.fromLinearIssue': 'Z {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Nie udało się wysłać kontekstu Linear', + }, + 'zh-CN': { + 'chat.chatInput.actions.linkLinearIssue': '关联 Linear Issue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中打开 Issue', + 'chat.chatInput.linked.linearIssue.removeAria': '移除已关联的 Linear Issue', + 'session.linearIssuePicker.title': '关联 Linear Issue', + 'session.linearIssuePicker.description': '从已连接的 Linear 工作区选择一个 Issue。', + 'session.linearIssuePicker.searchPlaceholder': '按标题、标识符或 Linear 链接搜索', + 'session.linearIssuePicker.empty.notConnected': '尚未连接 Linear。请到设置 → 集成 中连接。', + 'session.linearIssuePicker.empty.runtimeUnavailable': '此应用中无法使用 Linear。', + 'session.linearIssuePicker.empty.noIssuesFound': '未找到 Issue', + 'session.linearIssuePicker.empty.noOpenIssuesFound': '没有未完成的 Issue', + 'session.linearIssuePicker.loading.issues': '正在加载 Issue...', + 'session.linearIssuePicker.loading.more': '正在加载...', + 'session.linearIssuePicker.actions.openSettings': '打开设置', + 'session.linearIssuePicker.actions.useIssue': '使用 {identifier}', + 'session.linearIssuePicker.actions.loadMore': '加载更多', + 'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中打开', + 'session.linearIssuePicker.toast.loadMoreFailed': '无法加载更多 Issue', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': '无法加载 Issue 详情', + 'session.linearIssuePicker.error.notConnected': '未连接 Linear', + 'session.linearIssuePicker.error.runtimeUnavailable': '此应用中无法使用 Linear', + 'session.linearIssuePicker.error.issueNotFound': '未找到 Issue', + 'chat.chatInput.actions.newSessionFromLinearIssue': '从 Linear Issue 新建会话', + 'session.linearIssuePicker.title.createSession': '从 Linear Issue 新建会话', + 'session.linearIssuePicker.description.createSession': '在映射到此 Linear 团队的项目中创建会话,并以该 Issue 作为第一条提示。', + 'session.linearIssuePicker.error.noMappedProject': '请在设置 → 集成 中将此 Linear 团队映射到一个项目', + 'session.linearIssuePicker.error.noModelSelected': '未选择模型', + 'session.linearIssuePicker.toast.sendContextFailed': '无法发送 Issue 上下文', + 'session.linearIssuePicker.toast.sessionCreated': '已从 Issue 创建会话', + 'session.linearIssuePicker.toast.startSessionFailed': '无法开始会话', + 'session.linearIssuePicker.actions.sectionTitle': '操作', + 'session.linearIssuePicker.actions.toggleWorktreeAria': '切换 worktree', + 'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中创建', + 'session.linearIssuePicker.actions.refresh': '刷新', + 'chat.workStatus.linkedIssues.openLinear': '在 Linear 中打开 {identifier}', + 'session.newWorktree.actions.startFromLinearIssue': '从 Linear Issue 开始', + 'session.newWorktree.fromLinearIssue': '来自 {identifier}:{title}', + 'session.newWorktree.error.sendLinearContextFailed': '无法发送 Linear 上下文', + }, + 'zh-TW': { + 'chat.chatInput.actions.linkLinearIssue': '關聯 Linear Issue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中開啟 Issue', + 'chat.chatInput.linked.linearIssue.removeAria': '移除已關聯的 Linear Issue', + 'session.linearIssuePicker.title': '關聯 Linear Issue', + 'session.linearIssuePicker.description': '從已連線的 Linear 工作區選擇一個 Issue。', + 'session.linearIssuePicker.searchPlaceholder': '依標題、識別碼或 Linear 網址搜尋', + 'session.linearIssuePicker.empty.notConnected': '尚未連線 Linear。請到設定 → 整合 中連線。', + 'session.linearIssuePicker.empty.runtimeUnavailable': '此應用程式無法使用 Linear。', + 'session.linearIssuePicker.empty.noIssuesFound': '找不到 Issue', + 'session.linearIssuePicker.empty.noOpenIssuesFound': '沒有未完成的 Issue', + 'session.linearIssuePicker.loading.issues': '正在載入 Issue...', + 'session.linearIssuePicker.loading.more': '正在載入...', + 'session.linearIssuePicker.actions.openSettings': '開啟設定', + 'session.linearIssuePicker.actions.useIssue': '使用 {identifier}', + 'session.linearIssuePicker.actions.loadMore': '載入更多', + 'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中開啟', + 'session.linearIssuePicker.toast.loadMoreFailed': '無法載入更多 Issue', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': '無法載入 Issue 詳細資料', + 'session.linearIssuePicker.error.notConnected': '未連線 Linear', + 'session.linearIssuePicker.error.runtimeUnavailable': '此應用程式無法使用 Linear', + 'session.linearIssuePicker.error.issueNotFound': '找不到 Issue', + 'chat.chatInput.actions.newSessionFromLinearIssue': '從 Linear Issue 新增會話', + 'session.linearIssuePicker.title.createSession': '從 Linear Issue 新增會話', + 'session.linearIssuePicker.description.createSession': '在對應到此 Linear 團隊的專案中建立會話,並以該 Issue 作為第一則提示。', + 'session.linearIssuePicker.error.noMappedProject': '請在設定 → 整合 中將此 Linear 團隊對應到一個專案', + 'session.linearIssuePicker.error.noModelSelected': '尚未選擇模型', + 'session.linearIssuePicker.toast.sendContextFailed': '無法傳送 Issue 內容', + 'session.linearIssuePicker.toast.sessionCreated': '已從 Issue 建立會話', + 'session.linearIssuePicker.toast.startSessionFailed': '無法開始會話', + 'session.linearIssuePicker.actions.sectionTitle': '操作', + 'session.linearIssuePicker.actions.toggleWorktreeAria': '切換 worktree', + 'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中建立', + 'session.linearIssuePicker.actions.refresh': '重新整理', + 'chat.workStatus.linkedIssues.openLinear': '在 Linear 中開啟 {identifier}', + 'session.newWorktree.actions.startFromLinearIssue': '從 Linear Issue 開始', + 'session.newWorktree.fromLinearIssue': '來自 {identifier}:{title}', + 'session.newWorktree.error.sendLinearContextFailed': '無法傳送 Linear 內容', + }, + tr: { + 'chat.chatInput.actions.linkLinearIssue': 'Linear Issue bağla', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': "Issue'u Linear'da aç", + 'chat.chatInput.linked.linearIssue.removeAria': "Bağlı Linear issue'u kaldır", + 'session.linearIssuePicker.title': 'Linear Issue bağla', + 'session.linearIssuePicker.description': 'Bağlı Linear çalışma alanından bir issue seç.', + 'session.linearIssuePicker.searchPlaceholder': "Başlığa, tanımlayıcıya veya Linear URL'sine göre ara", + 'session.linearIssuePicker.empty.notConnected': "Linear bağlı değil. Ayarlar → Entegrasyonlar'dan bağla.", + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Issue bulunamadı', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Açık issue bulunamadı', + 'session.linearIssuePicker.loading.issues': "Issue'lar yükleniyor...", + 'session.linearIssuePicker.loading.more': 'Yükleniyor...', + 'session.linearIssuePicker.actions.openSettings': 'Ayarları aç', + 'session.linearIssuePicker.actions.useIssue': '{identifier} kullan', + 'session.linearIssuePicker.actions.loadMore': 'Daha fazla yükle', + 'session.linearIssuePicker.actions.openInLinearAria': "Linear'da aç", + 'session.linearIssuePicker.toast.loadMoreFailed': 'Daha fazla issue yüklenemedi', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue ayrıntıları yüklenemedi', + 'session.linearIssuePicker.error.notConnected': 'Linear bağlı değil', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor', + 'session.linearIssuePicker.error.issueNotFound': 'Issue bulunamadı', + 'chat.chatInput.actions.newSessionFromLinearIssue': "Linear Issue'dan yeni session", + 'session.linearIssuePicker.title.createSession': "Linear Issue'dan yeni session", + 'session.linearIssuePicker.description.createSession': 'Bu Linear ekibine eşlenen projede bir session oluşturur; ilk prompt issue olur.', + 'session.linearIssuePicker.error.noMappedProject': "Bu Linear ekibini Ayarlar → Entegrasyonlar'da bir projeye eşle", + 'session.linearIssuePicker.error.noModelSelected': 'Model seçilmedi', + 'session.linearIssuePicker.toast.sendContextFailed': 'Issue bağlamı gönderilemedi', + 'session.linearIssuePicker.toast.sessionCreated': "Issue'dan session oluşturuldu", + 'session.linearIssuePicker.toast.startSessionFailed': 'Session başlatılamadı', + 'session.linearIssuePicker.actions.sectionTitle': 'İşlemler', + 'session.linearIssuePicker.actions.toggleWorktreeAria': "Worktree'yi aç veya kapat", + 'session.linearIssuePicker.actions.createInWorktree': "Worktree'de oluştur", + 'session.linearIssuePicker.actions.refresh': 'Yenile', + 'chat.workStatus.linkedIssues.openLinear': "{identifier} issue'unu Linear'da aç", + 'session.newWorktree.actions.startFromLinearIssue': "Linear Issue'dan başla", + 'session.newWorktree.fromLinearIssue': '{identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Linear bağlamı gönderilemedi', + }, +} as const; diff --git a/packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts new file mode 100644 index 00000000..ed6424f5 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test'; +import { linearPanelI18n } from './linear-panel.i18n'; + +const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const; + +const requiredKeys = [ + 'contextPanel.mode.linear', + 'contextRail.surface.linear.description', + 'contextPanel.linear.actions.backToList', + 'contextPanel.linear.actions.startSession', + 'contextPanel.linear.actions.closeIssue', + 'contextPanel.linear.actions.closeSearch', + 'contextPanel.linear.label.status', + 'contextPanel.linear.label.team', + 'contextPanel.linear.label.assignee', + 'contextPanel.linear.label.unassigned', + 'contextPanel.linear.label.priority', + 'contextPanel.linear.label.labels', + 'contextPanel.linear.priority.none', + 'contextPanel.linear.priority.urgent', + 'contextPanel.linear.priority.high', + 'contextPanel.linear.priority.medium', + 'contextPanel.linear.priority.low', + 'contextPanel.linear.label.comments', + 'contextPanel.linear.label.statusAria', + 'contextPanel.linear.label.workspace', + 'contextPanel.linear.label.workspaceAria', + 'contextPanel.linear.filter.statusAria', + 'contextPanel.linear.filter.assigneeAria', + 'contextPanel.linear.filter.teamAria', + 'contextPanel.linear.filter.priorityAria', + 'contextPanel.linear.filter.searchAria', + 'contextPanel.linear.filter.clear', + 'contextPanel.linear.filter.clearAria', + 'contextPanel.linear.filter.status.all', + 'contextPanel.linear.filter.status.backlog', + 'contextPanel.linear.filter.status.todo', + 'contextPanel.linear.filter.status.started', + 'contextPanel.linear.filter.status.inReview', + 'contextPanel.linear.filter.status.completed', + 'contextPanel.linear.filter.status.canceled', + 'contextPanel.linear.filter.status.duplicate', + 'contextPanel.linear.filter.assignee.any', + 'contextPanel.linear.filter.assignee.me', + 'contextPanel.linear.filter.team.all', + 'contextPanel.linear.filter.priority.all', + 'contextPanel.linear.empty.noDescription', + 'contextPanel.linear.empty.noComments', + 'contextPanel.linear.empty.noMatchingIssues', + 'contextPanel.linear.loading.issue', + 'contextPanel.linear.toast.statusUpdated', + 'contextPanel.linear.toast.statusUpdateFailed', + 'contextPanel.linear.toast.closeFailed', + 'contextPanel.linear.toast.workspaceSwitched', + 'contextPanel.linear.toast.workspaceSwitchFailed', + 'contextPanel.linear.error.noCompletedState', +] as const; + +const matchingEnglishAllowed = new Set([ + 'contextPanel.mode.linear', + 'contextPanel.linear.label.status', + 'contextPanel.linear.label.team', +]); + +describe('linear panel translations', () => { + test('provides every required key in every supported locale', () => { + const english = linearPanelI18n.en; + for (const locale of locales) { + for (const key of requiredKeys) { + const value = linearPanelI18n[locale][key]; + expect(value).toBeTruthy(); + if (locale !== 'en' && !matchingEnglishAllowed.has(key)) { + expect(value).not.toBe(english[key]); + } + } + } + }); +}); diff --git a/packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts new file mode 100644 index 00000000..1774acaf --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts @@ -0,0 +1,627 @@ +/** Linear context-rail panel strings — merged into each locale's main dictionary. */ +export const linearPanelI18n = { + en: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Browse Linear issues, change status, and start a session', + 'contextPanel.linear.actions.backToList': 'Back to issues', + 'contextPanel.linear.actions.startSession': 'Start session', + 'contextPanel.linear.actions.closeIssue': 'Close issue', + 'contextPanel.linear.actions.closeSearch': 'Close search', + 'contextPanel.linear.label.status': 'Status', + 'contextPanel.linear.label.team': 'Team', + 'contextPanel.linear.label.assignee': 'Assignee', + 'contextPanel.linear.label.unassigned': 'Unassigned', + 'contextPanel.linear.label.priority': 'Priority', + 'contextPanel.linear.label.labels': 'Labels', + 'contextPanel.linear.priority.none': 'No priority', + 'contextPanel.linear.priority.urgent': 'Urgent', + 'contextPanel.linear.priority.high': 'High', + 'contextPanel.linear.priority.medium': 'Medium', + 'contextPanel.linear.priority.low': 'Low', + 'contextPanel.linear.label.comments': 'Comments', + 'contextPanel.linear.label.statusAria': 'Linear issue status', + 'contextPanel.linear.label.workspace': 'Workspace', + 'contextPanel.linear.label.workspaceAria': 'Linear workspace', + 'contextPanel.linear.filter.statusAria': 'Filter issues by status', + 'contextPanel.linear.filter.assigneeAria': 'Filter issues by assignee', + 'contextPanel.linear.filter.teamAria': 'Filter issues by team', + 'contextPanel.linear.filter.priorityAria': 'Filter issues by priority', + 'contextPanel.linear.filter.searchAria': 'Search issues', + 'contextPanel.linear.filter.clear': 'Clear', + 'contextPanel.linear.filter.clearAria': 'Clear issue filters', + 'contextPanel.linear.filter.status.all': 'All', + 'contextPanel.linear.filter.status.backlog': 'Backlog', + 'contextPanel.linear.filter.status.todo': 'To Do', + 'contextPanel.linear.filter.status.started': 'In Progress', + 'contextPanel.linear.filter.status.inReview': 'In Review', + 'contextPanel.linear.filter.status.completed': 'Done', + 'contextPanel.linear.filter.status.canceled': 'Canceled', + 'contextPanel.linear.filter.status.duplicate': 'Duplicate', + 'contextPanel.linear.filter.assignee.any': 'Anyone', + 'contextPanel.linear.filter.assignee.me': 'Assigned to me', + 'contextPanel.linear.filter.team.all': 'All teams', + 'contextPanel.linear.filter.priority.all': 'All priorities', + 'contextPanel.linear.empty.noDescription': 'No description', + 'contextPanel.linear.empty.noComments': 'No comments', + 'contextPanel.linear.empty.noMatchingIssues': 'No issues match these filters', + 'contextPanel.linear.loading.issue': 'Loading issue…', + 'contextPanel.linear.toast.statusUpdated': 'Issue status updated', + 'contextPanel.linear.toast.statusUpdateFailed': 'Could not update issue status', + 'contextPanel.linear.toast.closeFailed': 'Could not close issue', + 'contextPanel.linear.toast.workspaceSwitched': 'Switched Linear workspace', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace', + 'contextPanel.linear.error.noCompletedState': 'This team has no completed status', + }, + de: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Linear-Issues durchsuchen, Status ändern und eine Sitzung starten', + 'contextPanel.linear.actions.backToList': 'Zurück zu den Issues', + 'contextPanel.linear.actions.startSession': 'Sitzung starten', + 'contextPanel.linear.actions.closeIssue': 'Issue schließen', + 'contextPanel.linear.actions.closeSearch': 'Suche schließen', + 'contextPanel.linear.label.status': 'Status', + 'contextPanel.linear.label.team': 'Team', + 'contextPanel.linear.label.assignee': 'Zugewiesen', + 'contextPanel.linear.label.unassigned': 'Nicht zugewiesen', + 'contextPanel.linear.label.priority': 'Priorität', + 'contextPanel.linear.label.labels': 'Kennzeichnungen', + 'contextPanel.linear.priority.none': 'Keine Priorität', + 'contextPanel.linear.priority.urgent': 'Dringend', + 'contextPanel.linear.priority.high': 'Hoch', + 'contextPanel.linear.priority.medium': 'Mittel', + 'contextPanel.linear.priority.low': 'Niedrig', + 'contextPanel.linear.label.comments': 'Kommentare', + 'contextPanel.linear.label.statusAria': 'Status des Linear-Issues', + 'contextPanel.linear.label.workspace': 'Arbeitsbereich', + 'contextPanel.linear.label.workspaceAria': 'Linear-Workspace', + 'contextPanel.linear.filter.statusAria': 'Issues nach Status filtern', + 'contextPanel.linear.filter.assigneeAria': 'Issues nach Zuweisung filtern', + 'contextPanel.linear.filter.teamAria': 'Issues nach Team filtern', + 'contextPanel.linear.filter.priorityAria': 'Issues nach Priorität filtern', + 'contextPanel.linear.filter.searchAria': 'Issues durchsuchen', + 'contextPanel.linear.filter.clear': 'Zurücksetzen', + 'contextPanel.linear.filter.clearAria': 'Issue-Filter zurücksetzen', + 'contextPanel.linear.filter.status.all': 'Alle', + 'contextPanel.linear.filter.status.backlog': 'Warteliste', + 'contextPanel.linear.filter.status.todo': 'Zu tun', + 'contextPanel.linear.filter.status.started': 'In Bearbeitung', + 'contextPanel.linear.filter.status.inReview': 'In Prüfung', + 'contextPanel.linear.filter.status.completed': 'Erledigt', + 'contextPanel.linear.filter.status.canceled': 'Abgebrochen', + 'contextPanel.linear.filter.status.duplicate': 'Duplikat', + 'contextPanel.linear.filter.assignee.any': 'Alle Personen', + 'contextPanel.linear.filter.assignee.me': 'Mir zugewiesen', + 'contextPanel.linear.filter.team.all': 'Alle Teams', + 'contextPanel.linear.filter.priority.all': 'Alle Prioritäten', + 'contextPanel.linear.empty.noDescription': 'Keine Beschreibung', + 'contextPanel.linear.empty.noComments': 'Keine Kommentare', + 'contextPanel.linear.empty.noMatchingIssues': 'Keine Issues passen zu diesen Filtern', + 'contextPanel.linear.loading.issue': 'Issue wird geladen…', + 'contextPanel.linear.toast.statusUpdated': 'Issue-Status aktualisiert', + 'contextPanel.linear.toast.statusUpdateFailed': 'Issue-Status konnte nicht aktualisiert werden', + 'contextPanel.linear.toast.closeFailed': 'Issue konnte nicht geschlossen werden', + 'contextPanel.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden', + 'contextPanel.linear.error.noCompletedState': 'Dieses Team hat keinen erledigten Status', + }, + fr: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Parcourir les tickets Linear, changer le statut et démarrer une session', + 'contextPanel.linear.actions.backToList': 'Retour aux tickets', + 'contextPanel.linear.actions.startSession': 'Démarrer une session', + 'contextPanel.linear.actions.closeIssue': 'Fermer le ticket', + 'contextPanel.linear.actions.closeSearch': 'Fermer la recherche', + 'contextPanel.linear.label.status': 'Statut', + 'contextPanel.linear.label.team': 'Équipe', + 'contextPanel.linear.label.assignee': 'Assigné', + 'contextPanel.linear.label.unassigned': 'Non assigné', + 'contextPanel.linear.label.priority': 'Priorité', + 'contextPanel.linear.label.labels': 'Libellés', + 'contextPanel.linear.priority.none': 'Sans priorité', + 'contextPanel.linear.priority.urgent': 'Urgente', + 'contextPanel.linear.priority.high': 'Haute', + 'contextPanel.linear.priority.medium': 'Moyenne', + 'contextPanel.linear.priority.low': 'Basse', + 'contextPanel.linear.label.comments': 'Commentaires', + 'contextPanel.linear.label.statusAria': 'Statut du ticket Linear', + 'contextPanel.linear.label.workspace': 'Espace de travail', + 'contextPanel.linear.label.workspaceAria': 'Espace de travail Linear', + 'contextPanel.linear.filter.statusAria': 'Filtrer les tickets par statut', + 'contextPanel.linear.filter.assigneeAria': 'Filtrer les tickets par assigné', + 'contextPanel.linear.filter.teamAria': 'Filtrer les tickets par équipe', + 'contextPanel.linear.filter.priorityAria': 'Filtrer les tickets par priorité', + 'contextPanel.linear.filter.searchAria': 'Rechercher des tickets', + 'contextPanel.linear.filter.clear': 'Effacer', + 'contextPanel.linear.filter.clearAria': 'Effacer les filtres des tickets', + 'contextPanel.linear.filter.status.all': 'Tous', + 'contextPanel.linear.filter.status.backlog': 'Liste d’attente', + 'contextPanel.linear.filter.status.todo': 'À faire', + 'contextPanel.linear.filter.status.started': 'En cours', + 'contextPanel.linear.filter.status.inReview': 'En revue', + 'contextPanel.linear.filter.status.completed': 'Terminé', + 'contextPanel.linear.filter.status.canceled': 'Annulé', + 'contextPanel.linear.filter.status.duplicate': 'Doublon', + 'contextPanel.linear.filter.assignee.any': 'Tout le monde', + 'contextPanel.linear.filter.assignee.me': 'Assignés à moi', + 'contextPanel.linear.filter.team.all': 'Toutes les équipes', + 'contextPanel.linear.filter.priority.all': 'Toutes les priorités', + 'contextPanel.linear.empty.noDescription': 'Aucune description', + 'contextPanel.linear.empty.noComments': 'Aucun commentaire', + 'contextPanel.linear.empty.noMatchingIssues': 'Aucun ticket ne correspond à ces filtres', + 'contextPanel.linear.loading.issue': 'Chargement du ticket…', + 'contextPanel.linear.toast.statusUpdated': 'Statut du ticket mis à jour', + 'contextPanel.linear.toast.statusUpdateFailed': 'Impossible de mettre à jour le statut du ticket', + 'contextPanel.linear.toast.closeFailed': 'Impossible de fermer le ticket', + 'contextPanel.linear.toast.workspaceSwitched': 'Workspace Linear modifié', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear', + 'contextPanel.linear.error.noCompletedState': 'Cette équipe n’a pas de statut terminé', + }, + es: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Explora issues de Linear, cambia el estado e inicia una sesión', + 'contextPanel.linear.actions.backToList': 'Volver a los issues', + 'contextPanel.linear.actions.startSession': 'Iniciar sesión', + 'contextPanel.linear.actions.closeIssue': 'Cerrar issue', + 'contextPanel.linear.actions.closeSearch': 'Cerrar búsqueda', + 'contextPanel.linear.label.status': 'Estado', + 'contextPanel.linear.label.team': 'Equipo', + 'contextPanel.linear.label.assignee': 'Asignado', + 'contextPanel.linear.label.unassigned': 'Sin asignar', + 'contextPanel.linear.label.priority': 'Prioridad', + 'contextPanel.linear.label.labels': 'Etiquetas', + 'contextPanel.linear.priority.none': 'Sin prioridad', + 'contextPanel.linear.priority.urgent': 'Urgente', + 'contextPanel.linear.priority.high': 'Alta', + 'contextPanel.linear.priority.medium': 'Media', + 'contextPanel.linear.priority.low': 'Baja', + 'contextPanel.linear.label.comments': 'Comentarios', + 'contextPanel.linear.label.statusAria': 'Estado del issue de Linear', + 'contextPanel.linear.label.workspace': 'Espacio de trabajo', + 'contextPanel.linear.label.workspaceAria': 'Espacio de trabajo de Linear', + 'contextPanel.linear.filter.statusAria': 'Filtrar issues por estado', + 'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por asignado', + 'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipo', + 'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridad', + 'contextPanel.linear.filter.searchAria': 'Buscar issues', + 'contextPanel.linear.filter.clear': 'Borrar', + 'contextPanel.linear.filter.clearAria': 'Borrar filtros de issues', + 'contextPanel.linear.filter.status.all': 'Todos', + 'contextPanel.linear.filter.status.backlog': 'Lista de espera', + 'contextPanel.linear.filter.status.todo': 'Por hacer', + 'contextPanel.linear.filter.status.started': 'En curso', + 'contextPanel.linear.filter.status.inReview': 'En revisión', + 'contextPanel.linear.filter.status.completed': 'Hecho', + 'contextPanel.linear.filter.status.canceled': 'Cancelado', + 'contextPanel.linear.filter.status.duplicate': 'Duplicado', + 'contextPanel.linear.filter.assignee.any': 'Cualquiera', + 'contextPanel.linear.filter.assignee.me': 'Asignados a mí', + 'contextPanel.linear.filter.team.all': 'Todos los equipos', + 'contextPanel.linear.filter.priority.all': 'Todas las prioridades', + 'contextPanel.linear.empty.noDescription': 'Sin descripción', + 'contextPanel.linear.empty.noComments': 'Sin comentarios', + 'contextPanel.linear.empty.noMatchingIssues': 'Ningún issue coincide con estos filtros', + 'contextPanel.linear.loading.issue': 'Cargando issue…', + 'contextPanel.linear.toast.statusUpdated': 'Estado del issue actualizado', + 'contextPanel.linear.toast.statusUpdateFailed': 'No se pudo actualizar el estado del issue', + 'contextPanel.linear.toast.closeFailed': 'No se pudo cerrar el issue', + 'contextPanel.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear', + 'contextPanel.linear.error.noCompletedState': 'Este equipo no tiene un estado completado', + }, + ja: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Linear の Issue を一覧し、状態を変えてセッションを開始します', + 'contextPanel.linear.actions.backToList': 'Issue 一覧に戻る', + 'contextPanel.linear.actions.startSession': 'セッションを開始', + 'contextPanel.linear.actions.closeIssue': 'Issue をクローズ', + 'contextPanel.linear.actions.closeSearch': '検索を閉じる', + 'contextPanel.linear.label.status': '状態', + 'contextPanel.linear.label.team': 'チーム', + 'contextPanel.linear.label.assignee': '担当者', + 'contextPanel.linear.label.unassigned': '未割り当て', + 'contextPanel.linear.label.priority': '優先度', + 'contextPanel.linear.label.labels': 'ラベル', + 'contextPanel.linear.priority.none': '優先度なし', + 'contextPanel.linear.priority.urgent': '緊急', + 'contextPanel.linear.priority.high': '高', + 'contextPanel.linear.priority.medium': '中', + 'contextPanel.linear.priority.low': '低', + 'contextPanel.linear.label.comments': 'コメント', + 'contextPanel.linear.label.statusAria': 'Linear Issue の状態', + 'contextPanel.linear.label.workspace': 'ワークスペース', + 'contextPanel.linear.label.workspaceAria': 'Linear ワークスペース', + 'contextPanel.linear.filter.statusAria': '状態で Issue を絞り込む', + 'contextPanel.linear.filter.assigneeAria': '担当者で Issue を絞り込む', + 'contextPanel.linear.filter.teamAria': 'チームで Issue を絞り込む', + 'contextPanel.linear.filter.priorityAria': '優先度で Issue を絞り込む', + 'contextPanel.linear.filter.searchAria': 'Issue を検索', + 'contextPanel.linear.filter.clear': 'クリア', + 'contextPanel.linear.filter.clearAria': 'Issue フィルターをクリア', + 'contextPanel.linear.filter.status.all': 'すべて', + 'contextPanel.linear.filter.status.backlog': 'バックログ', + 'contextPanel.linear.filter.status.todo': '未着手', + 'contextPanel.linear.filter.status.started': '進行中', + 'contextPanel.linear.filter.status.inReview': 'レビュー中', + 'contextPanel.linear.filter.status.completed': '完了', + 'contextPanel.linear.filter.status.canceled': 'キャンセル', + 'contextPanel.linear.filter.status.duplicate': '重複', + 'contextPanel.linear.filter.assignee.any': '全員', + 'contextPanel.linear.filter.assignee.me': '自分に割り当て', + 'contextPanel.linear.filter.team.all': 'すべてのチーム', + 'contextPanel.linear.filter.priority.all': 'すべての優先度', + 'contextPanel.linear.empty.noDescription': '説明はありません', + 'contextPanel.linear.empty.noComments': 'コメントはありません', + 'contextPanel.linear.empty.noMatchingIssues': 'この条件に合う Issue はありません', + 'contextPanel.linear.loading.issue': 'Issue を読み込み中…', + 'contextPanel.linear.toast.statusUpdated': 'Issue の状態を更新しました', + 'contextPanel.linear.toast.statusUpdateFailed': 'Issue の状態を更新できませんでした', + 'contextPanel.linear.toast.closeFailed': 'Issue をクローズできませんでした', + 'contextPanel.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした', + 'contextPanel.linear.error.noCompletedState': 'このチームには完了ステータスがありません', + }, + ko: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Linear 이슈를 보고 상태를 바꾼 뒤 세션을 시작합니다', + 'contextPanel.linear.actions.backToList': '이슈 목록으로', + 'contextPanel.linear.actions.startSession': '세션 시작', + 'contextPanel.linear.actions.closeIssue': '이슈 닫기', + 'contextPanel.linear.actions.closeSearch': '검색 닫기', + 'contextPanel.linear.label.status': '상태', + 'contextPanel.linear.label.team': '팀', + 'contextPanel.linear.label.assignee': '담당자', + 'contextPanel.linear.label.unassigned': '담당자 없음', + 'contextPanel.linear.label.priority': '우선순위', + 'contextPanel.linear.label.labels': '레이블', + 'contextPanel.linear.priority.none': '우선순위 없음', + 'contextPanel.linear.priority.urgent': '긴급', + 'contextPanel.linear.priority.high': '높음', + 'contextPanel.linear.priority.medium': '보통', + 'contextPanel.linear.priority.low': '낮음', + 'contextPanel.linear.label.comments': '댓글', + 'contextPanel.linear.label.statusAria': 'Linear 이슈 상태', + 'contextPanel.linear.label.workspace': '워크스페이스', + 'contextPanel.linear.label.workspaceAria': 'Linear 워크스페이스', + 'contextPanel.linear.filter.statusAria': '상태로 이슈 필터', + 'contextPanel.linear.filter.assigneeAria': '담당자로 이슈 필터', + 'contextPanel.linear.filter.teamAria': '팀으로 이슈 필터', + 'contextPanel.linear.filter.priorityAria': '우선순위로 이슈 필터', + 'contextPanel.linear.filter.searchAria': '이슈 검색', + 'contextPanel.linear.filter.clear': '지우기', + 'contextPanel.linear.filter.clearAria': '이슈 필터 지우기', + 'contextPanel.linear.filter.status.all': '전체', + 'contextPanel.linear.filter.status.backlog': '백로그', + 'contextPanel.linear.filter.status.todo': '할 일', + 'contextPanel.linear.filter.status.started': '작업 중', + 'contextPanel.linear.filter.status.inReview': '검토 중', + 'contextPanel.linear.filter.status.completed': '완료', + 'contextPanel.linear.filter.status.canceled': '취소됨', + 'contextPanel.linear.filter.status.duplicate': '중복', + 'contextPanel.linear.filter.assignee.any': '누구나', + 'contextPanel.linear.filter.assignee.me': '내게 할당됨', + 'contextPanel.linear.filter.team.all': '모든 팀', + 'contextPanel.linear.filter.priority.all': '모든 우선순위', + 'contextPanel.linear.empty.noDescription': '설명이 없습니다', + 'contextPanel.linear.empty.noComments': '댓글이 없습니다', + 'contextPanel.linear.empty.noMatchingIssues': '이 필터에 맞는 이슈가 없습니다', + 'contextPanel.linear.loading.issue': '이슈를 불러오는 중…', + 'contextPanel.linear.toast.statusUpdated': '이슈 상태를 업데이트했습니다', + 'contextPanel.linear.toast.statusUpdateFailed': '이슈 상태를 업데이트하지 못했습니다', + 'contextPanel.linear.toast.closeFailed': '이슈를 닫지 못했습니다', + 'contextPanel.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다', + 'contextPanel.linear.error.noCompletedState': '이 팀에는 완료 상태가 없습니다', + }, + pl: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Przeglądaj zgłoszenia Linear, zmieniaj status i uruchamiaj sesję', + 'contextPanel.linear.actions.backToList': 'Wróć do zgłoszeń', + 'contextPanel.linear.actions.startSession': 'Uruchom sesję', + 'contextPanel.linear.actions.closeIssue': 'Zamknij zgłoszenie', + 'contextPanel.linear.actions.closeSearch': 'Zamknij wyszukiwanie', + 'contextPanel.linear.label.status': 'Status', + 'contextPanel.linear.label.team': 'Zespół', + 'contextPanel.linear.label.assignee': 'Przypisane', + 'contextPanel.linear.label.unassigned': 'Nieprzypisane', + 'contextPanel.linear.label.priority': 'Priorytet', + 'contextPanel.linear.label.labels': 'Etykiety', + 'contextPanel.linear.priority.none': 'Brak priorytetu', + 'contextPanel.linear.priority.urgent': 'Pilne', + 'contextPanel.linear.priority.high': 'Wysoki', + 'contextPanel.linear.priority.medium': 'Średni', + 'contextPanel.linear.priority.low': 'Niski', + 'contextPanel.linear.label.comments': 'Komentarze', + 'contextPanel.linear.label.statusAria': 'Status zgłoszenia Linear', + 'contextPanel.linear.label.workspace': 'Obszar roboczy', + 'contextPanel.linear.label.workspaceAria': 'Workspace Linear', + 'contextPanel.linear.filter.statusAria': 'Filtruj zgłoszenia według statusu', + 'contextPanel.linear.filter.assigneeAria': 'Filtruj zgłoszenia według osoby', + 'contextPanel.linear.filter.teamAria': 'Filtruj zgłoszenia według zespołu', + 'contextPanel.linear.filter.priorityAria': 'Filtruj zgłoszenia według priorytetu', + 'contextPanel.linear.filter.searchAria': 'Szukaj zgłoszeń', + 'contextPanel.linear.filter.clear': 'Wyczyść', + 'contextPanel.linear.filter.clearAria': 'Wyczyść filtry zgłoszeń', + 'contextPanel.linear.filter.status.all': 'Wszystkie', + 'contextPanel.linear.filter.status.backlog': 'Lista oczekujących', + 'contextPanel.linear.filter.status.todo': 'Do zrobienia', + 'contextPanel.linear.filter.status.started': 'W toku', + 'contextPanel.linear.filter.status.inReview': 'W recenzji', + 'contextPanel.linear.filter.status.completed': 'Ukończone', + 'contextPanel.linear.filter.status.canceled': 'Anulowane', + 'contextPanel.linear.filter.status.duplicate': 'Duplikat', + 'contextPanel.linear.filter.assignee.any': 'Ktokolwiek', + 'contextPanel.linear.filter.assignee.me': 'Przypisane do mnie', + 'contextPanel.linear.filter.team.all': 'Wszystkie zespoły', + 'contextPanel.linear.filter.priority.all': 'Wszystkie priorytety', + 'contextPanel.linear.empty.noDescription': 'Brak opisu', + 'contextPanel.linear.empty.noComments': 'Brak komentarzy', + 'contextPanel.linear.empty.noMatchingIssues': 'Żadne zgłoszenie nie pasuje do tych filtrów', + 'contextPanel.linear.loading.issue': 'Wczytywanie zgłoszenia…', + 'contextPanel.linear.toast.statusUpdated': 'Zaktualizowano status zgłoszenia', + 'contextPanel.linear.toast.statusUpdateFailed': 'Nie udało się zaktualizować statusu zgłoszenia', + 'contextPanel.linear.toast.closeFailed': 'Nie udało się zamknąć zgłoszenia', + 'contextPanel.linear.toast.workspaceSwitched': 'Przełączono workspace Linear', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear', + 'contextPanel.linear.error.noCompletedState': 'Ten zespół nie ma statusu ukończenia', + }, + 'pt-BR': { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Navegue pelas issues do Linear, altere o status e inicie uma sessão', + 'contextPanel.linear.actions.backToList': 'Voltar às issues', + 'contextPanel.linear.actions.startSession': 'Iniciar sessão', + 'contextPanel.linear.actions.closeIssue': 'Fechar issue', + 'contextPanel.linear.actions.closeSearch': 'Fechar pesquisa', + 'contextPanel.linear.label.status': 'Status', + 'contextPanel.linear.label.team': 'Equipe', + 'contextPanel.linear.label.assignee': 'Responsável', + 'contextPanel.linear.label.unassigned': 'Sem responsável', + 'contextPanel.linear.label.priority': 'Prioridade', + 'contextPanel.linear.label.labels': 'Etiquetas', + 'contextPanel.linear.priority.none': 'Sem prioridade', + 'contextPanel.linear.priority.urgent': 'Urgente', + 'contextPanel.linear.priority.high': 'Alta', + 'contextPanel.linear.priority.medium': 'Média', + 'contextPanel.linear.priority.low': 'Baixa', + 'contextPanel.linear.label.comments': 'Comentários', + 'contextPanel.linear.label.statusAria': 'Status da issue do Linear', + 'contextPanel.linear.label.workspace': 'Espaço de trabalho', + 'contextPanel.linear.label.workspaceAria': 'Workspace do Linear', + 'contextPanel.linear.filter.statusAria': 'Filtrar issues por status', + 'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por responsável', + 'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipe', + 'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridade', + 'contextPanel.linear.filter.searchAria': 'Pesquisar issues', + 'contextPanel.linear.filter.clear': 'Limpar', + 'contextPanel.linear.filter.clearAria': 'Limpar filtros de issues', + 'contextPanel.linear.filter.status.all': 'Todas', + 'contextPanel.linear.filter.status.backlog': 'Lista de espera', + 'contextPanel.linear.filter.status.todo': 'A fazer', + 'contextPanel.linear.filter.status.started': 'Em andamento', + 'contextPanel.linear.filter.status.inReview': 'Em revisão', + 'contextPanel.linear.filter.status.completed': 'Concluído', + 'contextPanel.linear.filter.status.canceled': 'Cancelado', + 'contextPanel.linear.filter.status.duplicate': 'Duplicado', + 'contextPanel.linear.filter.assignee.any': 'Qualquer pessoa', + 'contextPanel.linear.filter.assignee.me': 'Atribuídas a mim', + 'contextPanel.linear.filter.team.all': 'Todas as equipes', + 'contextPanel.linear.filter.priority.all': 'Todas as prioridades', + 'contextPanel.linear.empty.noDescription': 'Sem descrição', + 'contextPanel.linear.empty.noComments': 'Sem comentários', + 'contextPanel.linear.empty.noMatchingIssues': 'Nenhuma issue corresponde a estes filtros', + 'contextPanel.linear.loading.issue': 'Carregando issue…', + 'contextPanel.linear.toast.statusUpdated': 'Status da issue atualizado', + 'contextPanel.linear.toast.statusUpdateFailed': 'Não foi possível atualizar o status da issue', + 'contextPanel.linear.toast.closeFailed': 'Não foi possível fechar a issue', + 'contextPanel.linear.toast.workspaceSwitched': 'Workspace do Linear alterado', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear', + 'contextPanel.linear.error.noCompletedState': 'Esta equipe não tem um status de concluído', + }, + uk: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Переглядайте Linear issue, змінюйте статус і запускайте сесію', + 'contextPanel.linear.actions.backToList': 'Назад до issues', + 'contextPanel.linear.actions.startSession': 'Почати сесію', + 'contextPanel.linear.actions.closeIssue': 'Закрити issue', + 'contextPanel.linear.actions.closeSearch': 'Закрити пошук', + 'contextPanel.linear.label.status': 'Статус', + 'contextPanel.linear.label.team': 'Команда', + 'contextPanel.linear.label.assignee': 'Виконавець', + 'contextPanel.linear.label.unassigned': 'Не призначено', + 'contextPanel.linear.label.priority': 'Пріоритет', + 'contextPanel.linear.label.labels': 'Мітки', + 'contextPanel.linear.priority.none': 'Без пріоритету', + 'contextPanel.linear.priority.urgent': 'Терміновий', + 'contextPanel.linear.priority.high': 'Високий', + 'contextPanel.linear.priority.medium': 'Середній', + 'contextPanel.linear.priority.low': 'Низький', + 'contextPanel.linear.label.comments': 'Коментарі', + 'contextPanel.linear.label.statusAria': 'Статус Linear issue', + 'contextPanel.linear.label.workspace': 'Робочий простір', + 'contextPanel.linear.label.workspaceAria': 'Робочий простір Linear', + 'contextPanel.linear.filter.statusAria': 'Фільтрувати issues за статусом', + 'contextPanel.linear.filter.assigneeAria': 'Фільтрувати issues за виконавцем', + 'contextPanel.linear.filter.teamAria': 'Фільтрувати issues за командою', + 'contextPanel.linear.filter.priorityAria': 'Фільтрувати issues за пріоритетом', + 'contextPanel.linear.filter.searchAria': 'Шукати issues', + 'contextPanel.linear.filter.clear': 'Скинути', + 'contextPanel.linear.filter.clearAria': 'Скинути фільтри issues', + 'contextPanel.linear.filter.status.all': 'Усі', + 'contextPanel.linear.filter.status.backlog': 'Беклог', + 'contextPanel.linear.filter.status.todo': 'До виконання', + 'contextPanel.linear.filter.status.started': 'У роботі', + 'contextPanel.linear.filter.status.inReview': 'На перегляді', + 'contextPanel.linear.filter.status.completed': 'Готово', + 'contextPanel.linear.filter.status.canceled': 'Скасовано', + 'contextPanel.linear.filter.status.duplicate': 'Дублікат', + 'contextPanel.linear.filter.assignee.any': 'Будь-хто', + 'contextPanel.linear.filter.assignee.me': 'Призначені мені', + 'contextPanel.linear.filter.team.all': 'Усі команди', + 'contextPanel.linear.filter.priority.all': 'Усі пріоритети', + 'contextPanel.linear.empty.noDescription': 'Немає опису', + 'contextPanel.linear.empty.noComments': 'Немає коментарів', + 'contextPanel.linear.empty.noMatchingIssues': 'Немає issues за цими фільтрами', + 'contextPanel.linear.loading.issue': 'Завантаження issue…', + 'contextPanel.linear.toast.statusUpdated': 'Статус issue оновлено', + 'contextPanel.linear.toast.statusUpdateFailed': 'Не вдалося оновити статус issue', + 'contextPanel.linear.toast.closeFailed': 'Не вдалося закрити issue', + 'contextPanel.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace', + 'contextPanel.linear.error.noCompletedState': 'У цієї команди немає статусу completed', + }, + 'zh-CN': { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': '浏览 Linear Issue、更改状态并开始会话', + 'contextPanel.linear.actions.backToList': '返回 Issue 列表', + 'contextPanel.linear.actions.startSession': '开始会话', + 'contextPanel.linear.actions.closeIssue': '关闭 Issue', + 'contextPanel.linear.actions.closeSearch': '关闭搜索', + 'contextPanel.linear.label.status': '状态', + 'contextPanel.linear.label.team': '团队', + 'contextPanel.linear.label.assignee': '负责人', + 'contextPanel.linear.label.unassigned': '未指派', + 'contextPanel.linear.label.priority': '优先级', + 'contextPanel.linear.label.labels': '标签', + 'contextPanel.linear.priority.none': '无优先级', + 'contextPanel.linear.priority.urgent': '紧急', + 'contextPanel.linear.priority.high': '高', + 'contextPanel.linear.priority.medium': '中', + 'contextPanel.linear.priority.low': '低', + 'contextPanel.linear.label.comments': '评论', + 'contextPanel.linear.label.statusAria': 'Linear Issue 状态', + 'contextPanel.linear.label.workspace': '工作区', + 'contextPanel.linear.label.workspaceAria': 'Linear 工作区', + 'contextPanel.linear.filter.statusAria': '按状态筛选 Issue', + 'contextPanel.linear.filter.assigneeAria': '按负责人筛选 Issue', + 'contextPanel.linear.filter.teamAria': '按团队筛选 Issue', + 'contextPanel.linear.filter.priorityAria': '按优先级筛选 Issue', + 'contextPanel.linear.filter.searchAria': '搜索 Issue', + 'contextPanel.linear.filter.clear': '清除', + 'contextPanel.linear.filter.clearAria': '清除 Issue 筛选', + 'contextPanel.linear.filter.status.all': '全部', + 'contextPanel.linear.filter.status.backlog': '待办池', + 'contextPanel.linear.filter.status.todo': '待办', + 'contextPanel.linear.filter.status.started': '进行中', + 'contextPanel.linear.filter.status.inReview': '审核中', + 'contextPanel.linear.filter.status.completed': '已完成', + 'contextPanel.linear.filter.status.canceled': '已取消', + 'contextPanel.linear.filter.status.duplicate': '重复', + 'contextPanel.linear.filter.assignee.any': '任何人', + 'contextPanel.linear.filter.assignee.me': '指派给我', + 'contextPanel.linear.filter.team.all': '所有团队', + 'contextPanel.linear.filter.priority.all': '所有优先级', + 'contextPanel.linear.empty.noDescription': '没有描述', + 'contextPanel.linear.empty.noComments': '没有评论', + 'contextPanel.linear.empty.noMatchingIssues': '没有符合这些筛选条件的 Issue', + 'contextPanel.linear.loading.issue': '正在加载 Issue…', + 'contextPanel.linear.toast.statusUpdated': '已更新 Issue 状态', + 'contextPanel.linear.toast.statusUpdateFailed': '无法更新 Issue 状态', + 'contextPanel.linear.toast.closeFailed': '无法关闭 Issue', + 'contextPanel.linear.toast.workspaceSwitched': '已切换 Linear 工作区', + 'contextPanel.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区', + 'contextPanel.linear.error.noCompletedState': '此团队没有已完成状态', + }, + 'zh-TW': { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': '瀏覽 Linear Issue、變更狀態並開始會話', + 'contextPanel.linear.actions.backToList': '返回 Issue 列表', + 'contextPanel.linear.actions.startSession': '開始會話', + 'contextPanel.linear.actions.closeIssue': '關閉 Issue', + 'contextPanel.linear.actions.closeSearch': '關閉搜尋', + 'contextPanel.linear.label.status': '狀態', + 'contextPanel.linear.label.team': '團隊', + 'contextPanel.linear.label.assignee': '負責人', + 'contextPanel.linear.label.unassigned': '未指派', + 'contextPanel.linear.label.priority': '優先級', + 'contextPanel.linear.label.labels': '標籤', + 'contextPanel.linear.priority.none': '無優先級', + 'contextPanel.linear.priority.urgent': '緊急', + 'contextPanel.linear.priority.high': '高', + 'contextPanel.linear.priority.medium': '中', + 'contextPanel.linear.priority.low': '低', + 'contextPanel.linear.label.comments': '留言', + 'contextPanel.linear.label.statusAria': 'Linear Issue 狀態', + 'contextPanel.linear.label.workspace': '工作區', + 'contextPanel.linear.label.workspaceAria': 'Linear 工作區', + 'contextPanel.linear.filter.statusAria': '依狀態篩選 Issue', + 'contextPanel.linear.filter.assigneeAria': '依負責人篩選 Issue', + 'contextPanel.linear.filter.teamAria': '依團隊篩選 Issue', + 'contextPanel.linear.filter.priorityAria': '依優先級篩選 Issue', + 'contextPanel.linear.filter.searchAria': '搜尋 Issue', + 'contextPanel.linear.filter.clear': '清除', + 'contextPanel.linear.filter.clearAria': '清除 Issue 篩選', + 'contextPanel.linear.filter.status.all': '全部', + 'contextPanel.linear.filter.status.backlog': '待辦池', + 'contextPanel.linear.filter.status.todo': '待辦', + 'contextPanel.linear.filter.status.started': '進行中', + 'contextPanel.linear.filter.status.inReview': '審核中', + 'contextPanel.linear.filter.status.completed': '已完成', + 'contextPanel.linear.filter.status.canceled': '已取消', + 'contextPanel.linear.filter.status.duplicate': '重複', + 'contextPanel.linear.filter.assignee.any': '任何人', + 'contextPanel.linear.filter.assignee.me': '指派給我', + 'contextPanel.linear.filter.team.all': '所有團隊', + 'contextPanel.linear.filter.priority.all': '所有優先級', + 'contextPanel.linear.empty.noDescription': '沒有描述', + 'contextPanel.linear.empty.noComments': '沒有留言', + 'contextPanel.linear.empty.noMatchingIssues': '沒有符合這些篩選條件的 Issue', + 'contextPanel.linear.loading.issue': '正在載入 Issue…', + 'contextPanel.linear.toast.statusUpdated': '已更新 Issue 狀態', + 'contextPanel.linear.toast.statusUpdateFailed': '無法更新 Issue 狀態', + 'contextPanel.linear.toast.closeFailed': '無法關閉 Issue', + 'contextPanel.linear.toast.workspaceSwitched': '已切換 Linear 工作區', + 'contextPanel.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區', + 'contextPanel.linear.error.noCompletedState': '此團隊沒有已完成狀態', + }, + tr: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': "Linear issue'larını incele, durumu değiştir ve session başlat", + 'contextPanel.linear.actions.backToList': 'Issue listesine dön', + 'contextPanel.linear.actions.startSession': 'Session başlat', + 'contextPanel.linear.actions.closeIssue': "Issue'u kapat", + 'contextPanel.linear.actions.closeSearch': 'Aramayı kapat', + 'contextPanel.linear.label.status': 'Durum', + 'contextPanel.linear.label.team': 'Ekip', + 'contextPanel.linear.label.assignee': 'Atanan', + 'contextPanel.linear.label.unassigned': 'Atanmamış', + 'contextPanel.linear.label.priority': 'Öncelik', + 'contextPanel.linear.label.labels': 'Etiketler', + 'contextPanel.linear.priority.none': 'Öncelik yok', + 'contextPanel.linear.priority.urgent': 'Acil', + 'contextPanel.linear.priority.high': 'Yüksek', + 'contextPanel.linear.priority.medium': 'Orta', + 'contextPanel.linear.priority.low': 'Düşük', + 'contextPanel.linear.label.comments': 'Yorumlar', + 'contextPanel.linear.label.statusAria': 'Linear issue durumu', + 'contextPanel.linear.label.workspace': 'Çalışma alanı', + 'contextPanel.linear.label.workspaceAria': 'Linear çalışma alanı', + 'contextPanel.linear.filter.statusAria': "Issue'ları duruma göre süz", + 'contextPanel.linear.filter.assigneeAria': "Issue'ları atanan kişiye göre süz", + 'contextPanel.linear.filter.teamAria': "Issue'ları ekibe göre süz", + 'contextPanel.linear.filter.priorityAria': "Issue'ları önceliğe göre süz", + 'contextPanel.linear.filter.searchAria': "Issue'larda ara", + 'contextPanel.linear.filter.clear': 'Temizle', + 'contextPanel.linear.filter.clearAria': "Issue filtrelerini temizle", + 'contextPanel.linear.filter.status.all': 'Tümü', + 'contextPanel.linear.filter.status.backlog': 'Bekleme listesi', + 'contextPanel.linear.filter.status.todo': 'Yapılacak', + 'contextPanel.linear.filter.status.started': 'Devam ediyor', + 'contextPanel.linear.filter.status.inReview': 'İncelemede', + 'contextPanel.linear.filter.status.completed': 'Bitti', + 'contextPanel.linear.filter.status.canceled': 'İptal', + 'contextPanel.linear.filter.status.duplicate': 'Yinelenen', + 'contextPanel.linear.filter.assignee.any': 'Herkes', + 'contextPanel.linear.filter.assignee.me': 'Bana atananlar', + 'contextPanel.linear.filter.team.all': 'Tüm ekipler', + 'contextPanel.linear.filter.priority.all': 'Tüm öncelikler', + 'contextPanel.linear.empty.noDescription': 'Açıklama yok', + 'contextPanel.linear.empty.noComments': 'Yorum yok', + 'contextPanel.linear.empty.noMatchingIssues': 'Bu süzgeçlere uyan issue yok', + 'contextPanel.linear.loading.issue': 'Issue yükleniyor…', + 'contextPanel.linear.toast.statusUpdated': 'Issue durumu güncellendi', + 'contextPanel.linear.toast.statusUpdateFailed': 'Issue durumu güncellenemedi', + 'contextPanel.linear.toast.closeFailed': 'Issue kapatılamadı', + 'contextPanel.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi', + 'contextPanel.linear.error.noCompletedState': 'Bu ekibin tamamlandı durumu yok', + }, +} as const; diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index b6953d2b..ac8d5357 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go', @@ -2220,5 +2221,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n.pl, ...thirdPartyIntegrationI18n.pl, }; diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 5e6ee9f6..abe5287a 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './pl.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.pl, + ...linearPanelI18n.pl, 'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe', 'terminalView.actions.restart': 'Uruchom terminal ponownie', 'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 276df6df..fb5fa2c1 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go', @@ -2227,5 +2228,6 @@ export const settingsDict = { "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", + ...linearIntegrationI18n['pt-BR'], ...thirdPartyIntegrationI18n['pt-BR'], } as const; diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 581f7450..b2719031 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './pt-BR.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n['pt-BR'], + ...linearPanelI18n['pt-BR'], 'terminalView.actions.attachSelection': 'Anexar saída selecionada', 'terminalView.actions.restart': 'Reiniciar terminal', 'chat.message.terminalContext': '{terminal}, linhas {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index 34125588..c4ce2859 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go kullanım takibi', @@ -2218,4 +2219,5 @@ export const settingsDict = { 'settings.openchamber.visual.field.sessionTabsAria': 'Başlıktaki session sekmelerini aç/kapat', 'settings.openchamber.visual.field.sessionTabsInfo': 'Açtığınız session\'lar başlıkta sekmeler olarak dizilir. Kapatırsanız düz session başlığına döner.', ...thirdPartyIntegrationI18n.tr, + ...linearIntegrationI18n.tr, }; diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 6812353d..593255c9 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -1,7 +1,11 @@ import { settingsDict } from './tr.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { ...settingsDict, + ...linearIssuePickerI18n.tr, + ...linearPanelI18n.tr, 'terminalView.actions.attachSelection': 'Seçili çıktıyı ekle', 'terminalView.actions.restart': 'Terminali yeniden başlat', 'chat.message.terminalContext': '{terminal}, {start}-{end}. satırlar', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 34cdc172..d65facf2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go', @@ -2227,5 +2228,6 @@ export const settingsDict = { "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", + ...linearIntegrationI18n.uk, ...thirdPartyIntegrationI18n.uk, } as const; diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index d11b878b..adf207ff 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './uk.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.uk, + ...linearPanelI18n.uk, 'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід', 'terminalView.actions.restart': 'Перезапустити термінал', 'chat.message.terminalContext': '{terminal}, рядки {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index f910b3d2..c7f1f622 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n['zh-CN'], ...thirdPartyIntegrationI18n['zh-CN'], } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 184213b0..7dcf3db8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './zh-CN.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n['zh-CN'], + ...linearPanelI18n['zh-CN'], 'terminalView.actions.attachSelection': '附加所选输出', 'terminalView.actions.restart': '重启终端', 'chat.message.terminalContext': '{terminal},第 {start}-{end} 行', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index fb6e7243..e24521ae 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n['zh-TW'], ...thirdPartyIntegrationI18n['zh-TW'], } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 2f6e5d7d..d537d9f1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './zh-TW.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n['zh-TW'], + ...linearPanelI18n['zh-TW'], 'terminalView.actions.attachSelection': '附加所選輸出', 'terminalView.actions.restart': '重新啟動終端', 'chat.message.terminalContext': '{terminal},第 {start}-{end} 行', diff --git a/packages/ui/src/lib/linearProjectMapping.test.ts b/packages/ui/src/lib/linearProjectMapping.test.ts new file mode 100644 index 00000000..8f5b64d9 --- /dev/null +++ b/packages/ui/src/lib/linearProjectMapping.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test'; +import { resolveLinearMappedProjectPath } from './linearProjectMapping'; +import type { LinearMappingResult } from './api/types'; + +const mapping = (): LinearMappingResult => ({ + connected: true, + defaultProjectPath: '/default', + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/eng' }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], +}); + +describe('resolveLinearMappedProjectPath', () => { + test('prefers the team path over the default', () => { + expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-eng', key: 'ENG', name: 'Engineering' })) + .toBe('/eng'); + }); + + test('falls back to the default when the team has no path', () => { + expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-des', key: 'DES', name: 'Design' })) + .toBe('/default'); + }); + + test('matches a team by key when the id is missing', () => { + expect(resolveLinearMappedProjectPath(mapping(), { id: '', key: 'ENG', name: 'Engineering' })) + .toBe('/eng'); + }); + + test('returns null when Linear is disconnected or unmapped', () => { + expect(resolveLinearMappedProjectPath({ connected: false }, { id: 'team-eng', key: 'ENG', name: 'Engineering' })) + .toBeNull(); + expect(resolveLinearMappedProjectPath({ + connected: true, + defaultProjectPath: null, + teams: [{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null }], + }, { id: 'team-des', key: 'DES', name: 'Design' })).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/linearProjectMapping.ts b/packages/ui/src/lib/linearProjectMapping.ts new file mode 100644 index 00000000..c8fb2ae5 --- /dev/null +++ b/packages/ui/src/lib/linearProjectMapping.ts @@ -0,0 +1,24 @@ +import type { LinearIssueTeam, LinearMappingResult } from '@/lib/api/types'; + +export function resolveLinearMappedProjectPath( + mapping: LinearMappingResult | null | undefined, + team: LinearIssueTeam | null | undefined, +): string | null { + if (!mapping || mapping.connected === false) { + return null; + } + const teams = mapping.teams ?? []; + if (team?.id) { + const byId = teams.find((entry) => entry.id === team.id); + if (byId?.projectPath) { + return byId.projectPath; + } + } + if (team?.key) { + const byKey = teams.find((entry) => entry.key === team.key); + if (byKey?.projectPath) { + return byKey.projectPath; + } + } + return mapping.defaultProjectPath?.trim() || null; +} diff --git a/packages/ui/src/lib/linearSessionStatus.test.ts b/packages/ui/src/lib/linearSessionStatus.test.ts new file mode 100644 index 00000000..672fb3f5 --- /dev/null +++ b/packages/ui/src/lib/linearSessionStatus.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { resolveLinearSessionOrigin } from './linearSessionStatus'; + +describe('resolveLinearSessionOrigin', () => { + const originalWindow = globalThis.window; + + afterEach(() => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }); + }); + + test('uses the page origin on web', () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { origin: 'https://app.example.com' }, + }, + }); + expect(resolveLinearSessionOrigin()).toBe('https://app.example.com'); + }); + + test('uses the desktop loopback origin instead of the packaged UI scheme', () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { origin: 'openchamber-ui://app' }, + __OPENCHAMBER_ELECTRON__: { runtime: 'electron' }, + __OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:3001', + }, + }); + expect(resolveLinearSessionOrigin()).toBe('http://127.0.0.1:3001'); + }); + + test('reports no origin when the desktop shell has no http loopback', () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { origin: 'openchamber-ui://app' }, + __OPENCHAMBER_ELECTRON__: { runtime: 'electron' }, + __OPENCHAMBER_LOCAL_ORIGIN__: 'openchamber-ui://app', + }, + }); + // A deep link is unopenable for everyone but this machine, so the server + // gets no origin and posts no comment. + expect(resolveLinearSessionOrigin()).toBe(undefined); + }); +}); diff --git a/packages/ui/src/lib/linearSessionStatus.ts b/packages/ui/src/lib/linearSessionStatus.ts new file mode 100644 index 00000000..c7e0cee1 --- /dev/null +++ b/packages/ui/src/lib/linearSessionStatus.ts @@ -0,0 +1,45 @@ +import type { LinearAPI } from '@/lib/api/types'; +import { isElectronShell } from '@/lib/desktop'; +import { getLocalDesktopOrigin } from '@/lib/desktopCurrentHost'; + +function isHttpOrigin(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Origin Linear comments should open. Packaged desktop UI lives on + * `openchamber-ui://`, which is not a URL a browser can load from Linear, so + * report the http origin the local server actually listens on instead. The + * server decides whether that origin is reachable by anyone else; a comment is + * only posted when it is. + */ +export function resolveLinearSessionOrigin(): string | undefined { + if (typeof window === 'undefined') return undefined; + if (isElectronShell()) { + const localOrigin = getLocalDesktopOrigin().trim(); + if (localOrigin && isHttpOrigin(localOrigin)) { + return new URL(localOrigin).origin; + } + return undefined; + } + const origin = window.location.origin.trim(); + return origin || undefined; +} + +export function postLinearSessionStarted( + linear: LinearAPI | undefined, + args: { sessionId: string; issueIdentifier: string }, +): void { + if (!linear?.sessionStatusPost) return; + void linear.sessionStatusPost({ + kind: 'started', + sessionId: args.sessionId, + issueIdentifier: args.issueIdentifier, + sessionOrigin: resolveLinearSessionOrigin(), + }).catch(() => undefined); +} diff --git a/packages/ui/src/lib/linearStartSession.test.ts b/packages/ui/src/lib/linearStartSession.test.ts new file mode 100644 index 00000000..d19ebf39 --- /dev/null +++ b/packages/ui/src/lib/linearStartSession.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { buildIssueContextText } from './linearStartSession'; +import type { LinearIssue } from '@/lib/api/types'; + +const issue: LinearIssue = { + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + description: 'Users cannot sign in.', + comments: [], +}; + +describe('buildIssueContextText', () => { + test('serializes the issue and comments as JSON context', () => { + const text = buildIssueContextText({ + issue, + comments: [{ + id: 'comment-1', + body: 'Still broken', + createdAt: '2026-08-24T10:00:00.000Z', + user: { name: 'Ada', displayName: 'Ada Lovelace' }, + }], + }); + expect(text.startsWith('Linear issue context (JSON)\n')).toBe(true); + expect(text).toContain('"identifier": "ENG-12"'); + expect(text).toContain('Still broken'); + }); +}); diff --git a/packages/ui/src/lib/linearStartSession.ts b/packages/ui/src/lib/linearStartSession.ts new file mode 100644 index 00000000..532db084 --- /dev/null +++ b/packages/ui/src/lib/linearStartSession.ts @@ -0,0 +1,235 @@ +import { toast } from '@/components/ui'; +import type { LinearAPI, LinearIssue, LinearIssueComment, LinearMappingResult } from '@/lib/api/types'; +import type { I18nKey, I18nParams } from '@/lib/i18n'; +import { parseModelIdentifier } from '@/lib/modelIdentifier'; +import { modelVariantNames } from '@/lib/modelVariants'; +import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; +import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; +import { buildLinkedLinearIssue } from '@/lib/linkedIssues'; +import { resolveLinearMappedProjectPath } from '@/lib/linearProjectMapping'; +import { postLinearSessionStarted } from '@/lib/linearSessionStatus'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; + +type TranslateFn = (key: I18nKey, params?: I18nParams) => string; + +export function buildIssueContextText(args: { + issue: LinearIssue; + comments: LinearIssueComment[]; +}): string { + const payload = { + issue: args.issue, + comments: args.comments, + }; + return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; +} + +function resolveDefaultAgentName(): string | undefined { + const configState = useConfigStore.getState(); + const settingsDefaultAgent = configState.settingsDefaultAgent; + if (settingsDefaultAgent) { + return settingsDefaultAgent; + } + const visibleAgents = configState.agents.filter((agent) => !agent.hidden); + return ( + configState.currentAgentName + || visibleAgents.find((agent) => agent.mode === 'primary' || !agent.mode)?.name + || visibleAgents[0]?.name + ); +} + +function resolveDefaultModelSelection(): { providerID: string; modelID: string } | null { + const configState = useConfigStore.getState(); + const settingsDefaultModel = configState.settingsDefaultModel; + if (!settingsDefaultModel) { + return null; + } + + const parsed = parseModelIdentifier(settingsDefaultModel); + if (!parsed) { + return null; + } + const { providerId: providerID, modelId: modelID } = parsed; + + const modelMetadata = configState.getModelMetadata(providerID, modelID); + if (!modelMetadata) { + return null; + } + + return { providerID, modelID }; +} + +function resolveDefaultVariant(providerID: string, modelID: string): string | undefined { + const configState = useConfigStore.getState(); + const settingsDefaultVariant = configState.settingsDefaultVariant; + const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID + ? configState.currentVariant + : undefined; + + const provider = configState.providers.find((entry) => entry.id === providerID); + const model = provider?.models.find((entry) => entry.id === modelID); + const variantNames = modelVariantNames(model); + if (variantNames.length === 0) { + return settingsDefaultVariant || currentVariant || undefined; + } + if (settingsDefaultVariant && variantNames.includes(settingsDefaultVariant)) { + return settingsDefaultVariant; + } + if (currentVariant && variantNames.includes(currentVariant)) { + return currentVariant; + } + return undefined; +} + +export async function startLinearIssueSession(args: { + linear: LinearAPI | undefined; + issueKey: string; + createInWorktree: boolean; + mapping?: LinearMappingResult | null; + onMappingLoaded?: (mapping: LinearMappingResult) => void; + onSessionCreated?: () => void; + t: TranslateFn; +}): Promise { + const { linear, issueKey, createInWorktree, t } = args; + if (!linear?.issueGet || !linear.mappingGet) { + toast.error(t('session.linearIssuePicker.error.runtimeUnavailable')); + return false; + } + + try { + let mappingView = args.mapping; + if (!mappingView) { + mappingView = await linear.mappingGet(); + args.onMappingLoaded?.(mappingView); + } + if (mappingView.connected === false) { + toast.error(t('session.linearIssuePicker.error.notConnected')); + return false; + } + + const issueRes = await linear.issueGet(issueKey); + if (issueRes.connected === false) { + toast.error(t('session.linearIssuePicker.error.notConnected')); + return false; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.linearIssuePicker.error.issueNotFound')); + return false; + } + + const projectDirectory = resolveLinearMappedProjectPath(mappingView, issue.team); + if (!projectDirectory) { + toast.error(t('session.linearIssuePicker.error.noMappedProject')); + return false; + } + + const comments = issue.comments ?? []; + const sessionTitle = `${issue.identifier} ${issue.title}`.trim(); + const login = issue.assignee?.displayName || issue.assignee?.name; + + const { sessionId, sessionDirectory } = await (async () => { + if (createInWorktree) { + const preferred = `issue-${issue.identifier}-${generateBranchSlug()}`; + const created = await createWorktreeSessionForNewBranch( + projectDirectory, + preferred, + undefined, + { returnAfterDirectoryCreated: true }, + ); + if (!created?.id) { + throw new Error('Failed to create worktree session'); + } + return { sessionId: created.id, sessionDirectory: created.path }; + } + + const session = await sessionActions.createSession(sessionTitle, projectDirectory, null); + if (!session?.id) { + throw new Error('Failed to create session'); + } + return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory }; + })(); + + void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); + + try { + useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); + } catch { + // ignore + } + + args.onSessionCreated?.(); + useUIStore.getState().closeMainSurfaces(); + useUIStore.getState().setSessionSwitcherOpen(false); + + postLinearSessionStarted(linear, { + sessionId, + issueIdentifier: issue.identifier, + }); + + const configState = useConfigStore.getState(); + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; + const defaultModel = resolveDefaultModelSelection(); + const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; + const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; + const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined; + if (!providerID || !modelID) { + toast.error(t('session.linearIssuePicker.error.noModelSelected')); + return true; + } + + const variant = resolveDefaultVariant(providerID, modelID); + const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', { + identifier: issue.identifier, + }); + const instructionsText = await renderMagicPrompt('linear.issue.review.instructions'); + const contextText = buildIssueContextText({ issue, comments }); + + void sessionActions.setLinkedIssue( + sessionId, + sessionDirectory, + buildLinkedLinearIssue({ + identifier: issue.identifier, + title: issue.title, + url: issue.url, + author: login + ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined } + : undefined, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + void useSessionUIStore.getState().sendMessage( + visiblePromptText, + providerID, + modelID, + agentName, + undefined, + undefined, + [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + variant, + undefined, + { sessionId, directory: sessionDirectory }, + ).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + toast.error(t('session.linearIssuePicker.toast.sendContextFailed'), { + description: message, + }); + }); + + toast.success(t('session.linearIssuePicker.toast.sessionCreated')); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(t('session.linearIssuePicker.toast.startSessionFailed'), { description: message }); + return false; + } +} diff --git a/packages/ui/src/lib/linkedIssues.test.ts b/packages/ui/src/lib/linkedIssues.test.ts index 34281475..7669649c 100644 --- a/packages/ui/src/lib/linkedIssues.test.ts +++ b/packages/ui/src/lib/linkedIssues.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from 'bun:test'; import type { Session } from '@opencode-ai/sdk/v2'; -import { buildLinkedIssue, buildLinkedIssueId, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues'; +import { buildLinkedIssue, buildLinkedIssueId, buildLinkedLinearIssue, canOpenLinearIssueInContextPanel, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues'; -const issue = (overrides: Partial = {}): LinkedIssue => ({ +type LinkedGitHubIssue = Exclude; + +const issue = (overrides: Partial = {}): LinkedGitHubIssue => ({ id: 'owner/repo#12', number: 12, title: 'Rail badge count', @@ -76,6 +78,28 @@ describe('buildLinkedIssue', () => { }); }); +describe('buildLinkedLinearIssue', () => { + test('stores the Linear identifier without inventing a GitHub number', () => { + const built = buildLinkedLinearIssue({ + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + author: { login: 'Ada', avatarUrl: 'https://avatars/1' }, + linkedAt: 5, + }); + expect(built).toEqual({ + id: 'linear:ENG-12', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + kind: 'linear', + author: 'Ada', + authorAvatarUrl: 'https://avatars/1', + linkedAt: 5, + }); + }); +}); + describe('getLinkedIssues', () => { test('returns an empty list for a session with no metadata', () => { expect(getLinkedIssues(undefined)).toEqual([]); @@ -95,6 +119,17 @@ describe('getLinkedIssues', () => { expect(getLinkedIssues(session)).toEqual([good]); }); + test('keeps Linear entries next to GitHub ones', () => { + const github = issue(); + const linear = buildLinkedLinearIssue({ + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + linkedAt: 2, + }); + expect(getLinkedIssues(sessionWith([github, linear]))).toEqual([github, linear]); + }); + test('survives a non-array payload', () => { expect(getLinkedIssues(sessionWith({ nope: true }))).toEqual([]); }); @@ -143,3 +178,41 @@ describe('withLinkedIssue', () => { expect((next.openchamber as { linked_issues: LinkedIssue[] }).linked_issues).toEqual([issue()]); }); }); + +describe('canOpenLinearIssueInContextPanel', () => { + test('opens the rail when Linear is connected, the shell has a context panel, and a directory is known', () => { + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: true, + linearConnected: true, + inDedicatedMobileShell: false, + directory: '/repo', + })).toBe(true); + }); + + test('falls back when Linear is missing, disconnected, the mobile shell is open, or the directory is blank', () => { + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: false, + linearConnected: true, + inDedicatedMobileShell: false, + directory: '/repo', + })).toBe(false); + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: true, + linearConnected: false, + inDedicatedMobileShell: false, + directory: '/repo', + })).toBe(false); + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: true, + linearConnected: true, + inDedicatedMobileShell: true, + directory: '/repo', + })).toBe(false); + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: true, + linearConnected: true, + inDedicatedMobileShell: false, + directory: ' ', + })).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/linkedIssues.ts b/packages/ui/src/lib/linkedIssues.ts index da61ba31..5ed1d010 100644 --- a/packages/ui/src/lib/linkedIssues.ts +++ b/packages/ui/src/lib/linkedIssues.ts @@ -2,20 +2,17 @@ import type { Session } from '@opencode-ai/sdk/v2'; import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewMetadata'; /** - * GitHub issues and pull requests a user has linked to a session. + * Issues and pull requests a user has linked to a session. * - * Stored as a **snapshot**, not a reference: number, title, author and avatar - * only. Enough to render a row and open the thing, and nothing more — the body, - * comments and state of an issue belong to GitHub, and mirroring them here - * would mean owning their staleness. The stored title can drift from the real - * one; that is the accepted cost of a storage that never needs refreshing. + * Stored as a **snapshot**, not a reference: identifier or number, title, author + * and avatar only. Enough to render a row and open the thing, and nothing more. * * Rides the same session-metadata channel as pinned messages * (`contextObligatoryMessages`), so it inherits their persistence and sync for * free. */ -export type LinkedIssue = { +export type LinkedGitHubIssue = { /** `owner/repo#number`, unique per session and stable across renames. */ id: string; number: number; @@ -27,10 +24,24 @@ export type LinkedIssue = { linkedAt: number; }; +export type LinkedLinearIssue = { + /** `linear:{identifier}`, unique per session. */ + id: string; + identifier: string; + title: string; + url: string; + kind: 'linear'; + author?: string; + authorAvatarUrl?: string; + linkedAt: number; +}; + +export type LinkedIssue = LinkedGitHubIssue | LinkedLinearIssue; + const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === 'object' && !Array.isArray(value)); -const isLinkedIssue = (value: unknown): value is LinkedIssue => ( +const isLinkedGitHubIssue = (value: unknown): value is LinkedGitHubIssue => ( isRecord(value) && typeof value.id === 'string' && value.id.length > 0 @@ -43,9 +54,29 @@ const isLinkedIssue = (value: unknown): value is LinkedIssue => ( && Number.isFinite(value.linkedAt) ); +const isLinkedLinearIssue = (value: unknown): value is LinkedLinearIssue => ( + isRecord(value) + && typeof value.id === 'string' + && value.id.length > 0 + && typeof value.identifier === 'string' + && value.identifier.length > 0 + && typeof value.title === 'string' + && typeof value.url === 'string' + && value.kind === 'linear' + && typeof value.linkedAt === 'number' + && Number.isFinite(value.linkedAt) +); + +const isLinkedIssue = (value: unknown): value is LinkedIssue => ( + isLinkedGitHubIssue(value) || isLinkedLinearIssue(value) +); + export const buildLinkedIssueId = (owner: string, repo: string, number: number): string => `${owner}/${repo}#${number}`; +const buildLinkedLinearIssueId = (identifier: string): string => + `linear:${identifier}`; + /** * Builds the stored snapshot from what an attach flow already has. * @@ -61,7 +92,7 @@ export const buildLinkedIssue = (input: { kind: 'issue' | 'pull'; author?: { login?: string; avatarUrl?: string } | null; linkedAt: number; -}): LinkedIssue => { +}): LinkedGitHubIssue => { const match = /github\.com\/([^/]+)\/([^/]+)\//.exec(input.url); const id = match ? buildLinkedIssueId(match[1], match[2], input.number) @@ -79,6 +110,35 @@ export const buildLinkedIssue = (input: { }; }; +export const buildLinkedLinearIssue = (input: { + identifier: string; + title: string; + url: string; + author?: { login?: string; avatarUrl?: string } | null; + linkedAt: number; +}): LinkedLinearIssue => ({ + id: buildLinkedLinearIssueId(input.identifier), + identifier: input.identifier, + title: input.title, + url: input.url, + kind: 'linear', + author: input.author?.login ?? undefined, + authorAvatarUrl: input.author?.avatarUrl ?? undefined, + linkedAt: input.linkedAt, +}); + +export const canOpenLinearIssueInContextPanel = (options: { + linearAvailable: boolean; + linearConnected: boolean; + inDedicatedMobileShell: boolean; + directory: string | null | undefined; +}): boolean => ( + options.linearAvailable + && options.linearConnected + && !options.inDedicatedMobileShell + && Boolean(options.directory?.trim()) +); + export const getLinkedIssues = (session: Session | null | undefined): LinkedIssue[] => { const openchamber = getSessionMetadata(session).openchamber; if (!isRecord(openchamber) || !Array.isArray(openchamber.linked_issues)) return []; diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index c4201f6c..b0599716 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -13,6 +13,8 @@ export type MagicPromptId = | 'github.pr.review.instructions' | 'github.issue.review.visible' | 'github.issue.review.instructions' + | 'linear.issue.review.visible' + | 'linear.issue.review.instructions' | 'github.pr.checks.review.visible' | 'github.pr.checks.review.instructions' | 'github.pr.comments.review.visible' @@ -56,7 +58,7 @@ export interface MagicPromptDefinition { id: MagicPromptId; title: string; description: string; - group: 'Git' | 'GitHub' | 'Planning' | 'Session'; + group: 'Git' | 'GitHub' | 'Linear' | 'Planning' | 'Session'; template: string; placeholders?: Array<{ key: string; description: string }>; } @@ -261,6 +263,61 @@ Question/Support: - Answer/guidance (max 6 lines) - Missing info (max 4) +Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`, + }, + { + id: 'linear.issue.review.visible', + title: 'Linear Issue Review Visible Prompt', + group: 'Linear', + description: 'Visible user message when creating a session from a Linear issue.', + placeholders: [ + { key: 'identifier', description: 'Linear issue identifier, such as ENG-12.' }, + ], + template: 'Review this Linear issue {{identifier}} using the provided issue context', + }, + { + id: 'linear.issue.review.instructions', + title: 'Linear Issue Review Instructions', + group: 'Linear', + description: 'Hidden instructions attached when generating a Linear issue review response.', + template: `Review this Linear issue using the provided issue context. + +Process: +- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: . +- Gather any needed repository context (code, config, docs) to validate assumptions. +- After gathering, if anything is still unclear or cannot be verified, do not speculate — state what's missing and ask targeted questions. + +Mode selection by type: +- Bug / Question/Support / Ops: deliver the response directly using the matching template below. Do not bombard me with questions for straightforward diagnosis; use "Missing info" / "Repro/diagnostics needed" fields instead. +- Feature request / Refactor with substantive unknowns: this is effectively a planning session. Do not emit the Feature template on the first turn. Instead, ask me focused clarifying questions in batches of at most 3, one topic at a time (scope, constraints, tradeoffs, UX, etc.), wait for answers, drop questions that became irrelevant, and repeat until you have no more substantive questions. Only then emit the Feature template. + +Output rules: +- Compact output; pick ONE template below and omit the others. +- No emojis. No code snippets. No fenced blocks. +- Short inline code identifiers allowed. +- Reference evidence with file paths and line ranges when applicable; if exact lines are not available, cite the file and say "approx" + why. +- Keep the entire response under ~300 words (applies to the final template output, not to clarifying-question turns). + +Templates (choose one): +Bug: +- Summary (1-2 sentences) +- Likely cause (max 2) +- Repro/diagnostics needed (max 3) +- Fix approach (max 4 steps) +- Verification (max 3) + +Feature: +- Summary (1-2 sentences) +- Requirements (max 4) +- Unknowns/questions (max 4) +- Proposed plan (max 5 steps) +- Verification (max 3) + +Question/Support: +- Summary (1-2 sentences) +- Answer/guidance (max 6 lines) +- Missing info (max 4) + Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`, }, { diff --git a/packages/ui/src/lib/messages/contextParts.test.ts b/packages/ui/src/lib/messages/contextParts.test.ts index 52caca5f..a04b8658 100644 --- a/packages/ui/src/lib/messages/contextParts.test.ts +++ b/packages/ui/src/lib/messages/contextParts.test.ts @@ -114,6 +114,13 @@ describe('round-trip through part metadata', () => { expect(readContextPart(part)).toEqual(payload); }); + test('linear references carry picker-built text and the identifier', () => { + const payload: ContextPartPayload = { kind: 'linear-issue', identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12' }; + const part = asPart(payload, 'Linear issue context (JSON)\n{}'); + expect(part.text).toBe('Linear issue context (JSON)\n{}'); + expect(readContextPart(part)).toEqual(payload); + }); + test('non-text parts, missing metadata, and malformed payloads read as null', () => { expect(readContextPart({ type: 'file', metadata: {} })).toBeNull(); expect(readContextPart({ type: 'text' })).toBeNull(); diff --git a/packages/ui/src/lib/messages/contextParts.ts b/packages/ui/src/lib/messages/contextParts.ts index f875f8a7..8ec46a2c 100644 --- a/packages/ui/src/lib/messages/contextParts.ts +++ b/packages/ui/src/lib/messages/contextParts.ts @@ -96,6 +96,13 @@ type GitHubPrContext = { url: string; }; +type LinearIssueContext = { + kind: 'linear-issue'; + identifier: string; + title: string; + url: string; +}; + export type ContextPartPayload = | CodeCommentContext | TerminalContextPayload @@ -105,7 +112,8 @@ export type ContextPartPayload = | FileQuoteContext | ChatQuoteContext | GitHubIssueContext - | GitHubPrContext; + | GitHubPrContext + | LinearIssueContext; export type ContextPartMetadata = { [K in typeof CONTEXT_METADATA_KEY]: ContextPartPayload }; @@ -154,6 +162,7 @@ export function formatContextText(payload: ContextPartPayload): string { return `Attached failed GitHub PR check (${payload.label}):\n\`\`\`\n${payload.output}\n\`\`\`${payload.text ? `\n\n${payload.text}` : ''}`; case 'github-issue': case 'github-pr': + case 'linear-issue': // Linked issues/PRs carry server-fetched context text built by // their pickers; there is no default text to derive here. return ''; @@ -162,8 +171,9 @@ export function formatContextText(payload: ContextPartPayload): string { /** * Build the synthetic part for one context payload. `text` overrides the - * derived text; github-issue/github-pr payloads require it because their - * model-facing context is fetched by the picker, not derived from metadata. + * derived text; github-issue/github-pr/linear-issue payloads require it + * because their model-facing context is fetched by the picker, not derived + * from metadata. */ export function createContextPart(payload: ContextPartPayload, text?: string): ContextPart { const resolvedText = text ?? formatContextText(payload); @@ -297,6 +307,12 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [ title: z.string(), url: z.string(), }), + z.object({ + kind: z.literal('linear-issue'), + identifier: z.string().min(1), + title: z.string(), + url: z.string(), + }), ]); /** The subset of a message part that context read-back inspects. */ diff --git a/packages/ui/src/lib/router/openSessionFromRoute.test.ts b/packages/ui/src/lib/router/openSessionFromRoute.test.ts new file mode 100644 index 00000000..d6db8198 --- /dev/null +++ b/packages/ui/src/lib/router/openSessionFromRoute.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; + +import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +import { openSessionFromRoute } from './openSessionFromRoute'; + +const SESSION_ID = 'ses_linear_open'; +const PROJECT_DIR = '/projects/linear-from-url'; +const OTHER_DIR = '/projects/linear-from-url-other'; + +const buildSession = (id: string, directory: string): Session => ({ + id, + title: id, + directory, + time: { created: 1, updated: 2 }, +} as Session); + +describe('openSessionFromRoute', () => { + beforeEach(() => { + useSessionUIStore.getState().setCurrentSession(null); + useGlobalSessionsStore.setState({ + activeSessions: [], + archivedSessions: [], + sessionsByDirectory: new Map(), + hasLoaded: true, + status: 'ready', + }); + }); + + test('selects the routed session once the global list knows its directory', async () => { + useGlobalSessionsStore.setState({ + activeSessions: [buildSession(SESSION_ID, PROJECT_DIR)], + archivedSessions: [], + hasLoaded: true, + status: 'ready', + }); + + await openSessionFromRoute(SESSION_ID); + + expect(useSessionUIStore.getState().currentSessionId).toBe(SESSION_ID); + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(PROJECT_DIR); + }); + + test('replaces a guessed directory once the global list knows the owner', async () => { + const id = 'ses_linear_guessed'; + useSessionUIStore.getState().setCurrentSession(id); + const guessed = useSessionUIStore.getState().currentSessionDirectory; + + useGlobalSessionsStore.setState({ + activeSessions: [buildSession(id, OTHER_DIR)], + archivedSessions: [], + hasLoaded: true, + status: 'ready', + }); + + await openSessionFromRoute(id); + + expect(useSessionUIStore.getState().currentSessionId).toBe(id); + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(OTHER_DIR); + expect(guessed).not.toBe(OTHER_DIR); + }); +}); diff --git a/packages/ui/src/lib/router/openSessionFromRoute.ts b/packages/ui/src/lib/router/openSessionFromRoute.ts new file mode 100644 index 00000000..c59aa9b4 --- /dev/null +++ b/packages/ui/src/lib/router/openSessionFromRoute.ts @@ -0,0 +1,33 @@ +import { ensureGlobalSessionsLoaded, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +/** + * Select a session named by `/?session=`. Cold loads often do not know the + * owning directory yet, so a first selection may guess the active project. + * After the global session list is available, re-select with that directory + * unless the user already moved to a different session. + */ +export async function openSessionFromRoute(sessionId: string): Promise { + const id = sessionId.trim(); + if (!id) return; + + const initial = useSessionUIStore.getState(); + if (initial.currentSessionId !== id) { + initial.setCurrentSession(id, initial.getDirectoryForSession(id)); + } + + const snapshot = await ensureGlobalSessionsLoaded().catch(() => null); + if (!snapshot) return; + + const latest = useSessionUIStore.getState(); + if (latest.currentSessionId !== id) return; + + const session = [...snapshot.activeSessions, ...snapshot.archivedSessions] + .find((entry) => entry.id === id); + if (!session) return; + + const directory = resolveGlobalSessionDirectory(session); + if (!directory || directory === latest.currentSessionDirectory) return; + + latest.setCurrentSession(id, directory); +} diff --git a/packages/ui/src/lib/router/parseRoute.test.ts b/packages/ui/src/lib/router/parseRoute.test.ts new file mode 100644 index 00000000..610fb68e --- /dev/null +++ b/packages/ui/src/lib/router/parseRoute.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseRoute } from './parseRoute'; + +describe('parseRoute session', () => { + test('reads a session id including OpenCode underscores', () => { + const route = parseRoute(new URLSearchParams('session=ses_abc123')); + expect(route.sessionId).toBe('ses_abc123'); + }); + + test('decodes a percent-encoded session id', () => { + const route = parseRoute(new URLSearchParams('session=ses%5Fabc123')); + expect(route.sessionId).toBe('ses_abc123'); + }); + + test('ignores a blank session param', () => { + const route = parseRoute(new URLSearchParams('session=')); + expect(route.sessionId).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index eea1ba80..4fc3c8d6 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -202,7 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ { slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode }, { slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode }, { slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode }, - { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger'] }, + { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'linear'] }, ] as const; const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = { diff --git a/packages/ui/src/lib/settings/search.test.ts b/packages/ui/src/lib/settings/search.test.ts index 96274aa4..e3cf0fa8 100644 --- a/packages/ui/src/lib/settings/search.test.ts +++ b/packages/ui/src/lib/settings/search.test.ts @@ -38,4 +38,30 @@ describe('settings search', () => { expect(results.some((result) => result.id === 'integrations.third-party.opencode-cursor-oauth')).toBe(true); }); + + test('finds Linear connect on the integrations page', () => { + const results = buildSettingsSearchResults({ + query: 'linear', + runtimeCtx, + t, + getPageTitle: (page) => page, + }); + + expect(results.some((result) => result.id === 'integrations.linear')).toBe(true); + expect(results.some((result) => result.id === 'integrations.linear.add-workspace')).toBe(true); + expect(results.some((result) => result.id === 'integrations.linear.mapping')).toBe(true); + }); + + test('hides Linear connect in VS Code', () => { + const results = buildSettingsSearchResults({ + query: 'linear', + runtimeCtx: { ...runtimeCtx, isVSCode: true }, + t, + getPageTitle: (page) => page, + }); + + expect(results.some((result) => result.id === 'integrations.linear')).toBe(false); + expect(results.some((result) => result.id === 'integrations.linear.add-workspace')).toBe(false); + expect(results.some((result) => result.id === 'integrations.linear.mapping')).toBe(false); + }); }); diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index cf8fe7b9..d428ec62 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -984,6 +984,38 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode, }, + { + id: 'integrations.first-party', + page: 'integrations', + titleKey: 'settings.integrations.firstParty.title', + descriptionKey: 'settings.integrations.firstParty.info', + keywords: ['built-in', 'first-party', 'native', 'linear'], + isAvailable: (ctx) => !ctx.isVSCode, + }, + { + id: 'integrations.linear', + page: 'integrations', + titleKey: 'settings.integrations.linear.title', + descriptionKey: 'settings.integrations.linear.description', + keywords: ['linear', 'issues', 'oauth', 'connect', 'workspace'], + isAvailable: (ctx) => !ctx.isVSCode, + }, + { + id: 'integrations.linear.add-workspace', + page: 'integrations', + titleKey: 'settings.integrations.linear.actions.addWorkspace', + descriptionKey: 'settings.integrations.linear.description', + keywords: ['linear', 'workspace', 'add', 'connect', 'oauth'], + isAvailable: (ctx) => !ctx.isVSCode, + }, + { + id: 'integrations.linear.mapping', + page: 'integrations', + titleKey: 'settings.integrations.linear.mapping.defaultProject', + descriptionKey: 'settings.integrations.linear.mapping.defaultProject.info', + keywords: ['linear', 'project', 'team', 'map', 'workspace', 'directory'], + isAvailable: (ctx) => !ctx.isVSCode, + }, { id: 'integrations.third-party', page: 'integrations', diff --git a/packages/ui/src/lib/surfaces/DOCUMENTATION.md b/packages/ui/src/lib/surfaces/DOCUMENTATION.md index fd750557..dbdf65f3 100644 --- a/packages/ui/src/lib/surfaces/DOCUMENTATION.md +++ b/packages/ui/src/lib/surfaces/DOCUMENTATION.md @@ -26,10 +26,10 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by (`useUIStore.contextRailHiddenSurfaces`, edited from the rail's trailing configure button — `ContextRailSurfacesDialog`), drops the plan surface unless plan mode is enabled, - drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, and hides - `has-content` surfaces until a tab of their mode exists. Both consumers use - it so the digit shown on a rail badge always maps to the same surface the - shortcut opens. + drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, hides + Linear unless a workspace is connected, and hides `has-content` surfaces + until a tab of their mode exists. Both consumers use it so the digit shown + on a rail badge always maps to the same surface the shortcut opens. ## Adding a surface @@ -53,7 +53,22 @@ the `openContext*` actions in `useUIStore`. positions). Chat tab records stay open, but only the active chat iframe is mounted while the panel is open. A selected chat restores its state from the session stores. A closed panel mounts no chat iframe. - Singleton surfaces (git, pr, notes, plan, context) remount on switch. These + Singleton surfaces (git, pr, linear, notes, plan, context) remount on switch. These surfaces must restore their state from stores or snapshots. - Runtime scope: desktop/web `MainLayout` only. VS Code and the dedicated mobile shell have their own layouts and do not consume this registry. + Linear is a desktop/web singleton on this rail. VS Code and mobile omit it + (no this registry, and VS Code has no `RuntimeAPIs.linear`). The Linear + rail icon is hidden until a Linear workspace is connected. A persisted Linear + tab stays open across reload until auth has resolved; only a confirmed + disconnect closes the panel. The surface lists + issues with status (All, Backlog, To Do, In Progress, In Review, Done, Canceled, Duplicate), assignee, team, and priority filters, can switch + the current workspace, and keeps Start session in a footer on the issue card. + Those filters restore from `useUIStore` when the surface remounts. Non-default + status, assignee, team, priority, and search tint the filter icon `text-primary`, + same as the context rail; one control clears them. Workspace switch is not a + filter. Work-status Context sources + can open a specific issue here through `linearIssueFocus`. Below 520px + search and the filters other than status drop to icons; status keeps its label. The card + shows priority and labels. Changing filters keeps the previous list + until the next page arrives. diff --git a/packages/ui/src/lib/surfaces/registry.test.ts b/packages/ui/src/lib/surfaces/registry.test.ts index 73f93acf..e60a1af3 100644 --- a/packages/ui/src/lib/surfaces/registry.test.ts +++ b/packages/ui/src/lib/surfaces/registry.test.ts @@ -12,6 +12,7 @@ const baseOptions = { isVSCode: false, screenWidth: 1200, tabs: [], + linearConnected: true, } as const; describe('getVisibleContextRailSurfaces', () => { @@ -63,4 +64,17 @@ describe('getVisibleContextRailSurfaces', () => { const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, railOrder: ['git', 'context'] }); expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']); }); + + test('places Linear after Pull Request in the default order', () => { + const ids = getVisibleContextRailSurfaces(baseOptions).map((surface) => surface.id); + const pr = ids.indexOf('pr'); + const linear = ids.indexOf('linear'); + expect(pr).toBeGreaterThanOrEqual(0); + expect(linear).toBe(pr + 1); + }); + + test('hides Linear until a workspace is connected', () => { + expect(getVisibleContextRailSurfaces({ ...baseOptions, linearConnected: false }).some((s) => s.id === 'linear')).toBe(false); + expect(getVisibleContextRailSurfaces({ ...baseOptions, linearConnected: true }).some((s) => s.id === 'linear')).toBe(true); + }); }); diff --git a/packages/ui/src/lib/surfaces/registry.ts b/packages/ui/src/lib/surfaces/registry.ts index 21d2992f..9a0a8e10 100644 --- a/packages/ui/src/lib/surfaces/registry.ts +++ b/packages/ui/src/lib/surfaces/registry.ts @@ -6,6 +6,7 @@ export type ContextSurfaceId = | 'editor' | 'git' | 'pr' + | 'linear' | 'diff' | 'walkthrough' | 'terminal' @@ -65,6 +66,15 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [ labelKey: 'contextPanel.mode.pr', availability: 'always', }, + { + id: 'linear', + descriptionKey: 'contextRail.surface.linear.description', + defaultWidthFraction: 0.45, + mode: 'linear', + icon: 'linear', + labelKey: 'contextPanel.mode.linear', + availability: 'always', + }, { id: 'diff', descriptionKey: 'contextRail.surface.diff.description', @@ -194,6 +204,8 @@ type VisibleRailSurfacesOptions = { isVSCode: boolean; screenWidth: number; tabs: readonly { mode: ContextPanelMode }[]; + /** Linear's rail icon stays off until a workspace is connected. */ + linearConnected: boolean; }; /** @@ -225,6 +237,9 @@ export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOption if (surface.id === 'browser' && options.isVSCode) { return false; } + if (surface.id === 'linear' && !options.linearConnected) { + return false; + } if (surface.availability === 'has-content') { return options.tabs.some((tab) => tab.mode === surface.mode); } diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 1817654a..5f1d7d44 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -38,7 +38,7 @@ Examples: - `useFeatureFlagsStore.ts` - `useUpdateStore.ts` -These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. +These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted. Context-panel session chats mount only the active chat iframe. After installing its message listener, the iframe requests its authoritative visibility from the diff --git a/packages/ui/src/stores/useLinearAuthStore.ts b/packages/ui/src/stores/useLinearAuthStore.ts new file mode 100644 index 00000000..95560a2f --- /dev/null +++ b/packages/ui/src/stores/useLinearAuthStore.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; +import type { LinearAuthStatus, RuntimeAPIs } from '@/lib/api/types'; + +type LinearAuthStatusWithError = LinearAuthStatus & { error?: string }; + +type LinearAuthStore = { + status: LinearAuthStatusWithError | null; + isLoading: boolean; + hasChecked: boolean; + setStatus: (status: LinearAuthStatusWithError | null) => void; + refreshStatus: ( + runtimeLinear?: RuntimeAPIs['linear'], + options?: { force?: boolean } + ) => Promise; +}; + +const fetchStatus = async ( + runtimeLinear?: RuntimeAPIs['linear'] +): Promise => { + if (!runtimeLinear) { + return { connected: false }; + } + return runtimeLinear.authStatus(); +}; + +let inFlightAuthRefresh: Promise | null = null; + +export const useLinearAuthStore = create((set, get) => ({ + status: null, + isLoading: false, + hasChecked: false, + setStatus: (status) => set({ status, hasChecked: true }), + refreshStatus: async (runtimeLinear, options) => { + if (!runtimeLinear) { + return get().status; + } + const { hasChecked, status } = get(); + if (hasChecked && !options?.force) { + return status; + } + + if (inFlightAuthRefresh) return inFlightAuthRefresh; + + set({ isLoading: true }); + inFlightAuthRefresh = (async () => { + try { + const payload = await fetchStatus(runtimeLinear); + set({ status: payload, isLoading: false, hasChecked: true }); + return payload; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // A failed request is not an authoritative disconnect. Keep the last + // known status and leave `hasChecked` false so the next caller retries + // instead of hiding Linear for the rest of the session. + set((state) => ({ + status: state.status + ? { ...state.status, error: message } + : { connected: false, error: message }, + isLoading: false, + })); + return null; + } + })().finally(() => { inFlightAuthRefresh = null; }); + + return inFlightAuthRefresh; + }, +})); diff --git a/packages/ui/src/stores/useUIStore.linearFilters.test.ts b/packages/ui/src/stores/useUIStore.linearFilters.test.ts new file mode 100644 index 00000000..44789aa4 --- /dev/null +++ b/packages/ui/src/stores/useUIStore.linearFilters.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } from './useUIStore'; + +describe('linear issue list filters', () => { + beforeEach(() => { + useUIStore.setState({ + linearIssueListStatus: 'all', + linearIssueListAssignee: 'any', + linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListPriority: 'all', + linearIssueFocus: null, + }); + }); + + test('stores status, assignee, team, and priority across setter calls', () => { + useUIStore.getState().setLinearIssueListStatus('todo'); + expect(useUIStore.getState().linearIssueListStatus).toBe('todo'); + useUIStore.getState().setLinearIssueListStatus('started'); + expect(useUIStore.getState().linearIssueListStatus).toBe('started'); + useUIStore.getState().setLinearIssueListStatus('inReview'); + expect(useUIStore.getState().linearIssueListStatus).toBe('inReview'); + useUIStore.getState().setLinearIssueListStatus('completed'); + expect(useUIStore.getState().linearIssueListStatus).toBe('completed'); + useUIStore.getState().setLinearIssueListStatus('canceled'); + expect(useUIStore.getState().linearIssueListStatus).toBe('canceled'); + useUIStore.getState().setLinearIssueListStatus('duplicate'); + expect(useUIStore.getState().linearIssueListStatus).toBe('duplicate'); + useUIStore.getState().setLinearIssueListStatus('backlog'); + expect(useUIStore.getState().linearIssueListStatus).toBe('backlog'); + useUIStore.getState().setLinearIssueListStatus('all'); + useUIStore.getState().setLinearIssueListAssignee('me'); + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + useUIStore.getState().setLinearIssueListPriority('urgent'); + + expect(useUIStore.getState().linearIssueListStatus).toBe('all'); + expect(useUIStore.getState().linearIssueListAssignee).toBe('me'); + expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng'); + expect(useUIStore.getState().linearIssueListPriority).toBe('urgent'); + }); + + test('resets status, assignee, team, and priority together', () => { + useUIStore.getState().setLinearIssueListStatus('todo'); + useUIStore.getState().setLinearIssueListAssignee('me'); + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + useUIStore.getState().setLinearIssueListPriority('urgent'); + + useUIStore.getState().resetLinearIssueListFilters(); + + expect(useUIStore.getState().linearIssueListStatus).toBe('all'); + expect(useUIStore.getState().linearIssueListAssignee).toBe('any'); + expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS); + expect(useUIStore.getState().linearIssueListPriority).toBe('all'); + }); + + test('treats a blank team id as all teams', () => { + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + useUIStore.getState().setLinearIssueListTeamId(' '); + expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS); + }); + + test('stores a one-shot Linear issue identifier for the rail panel', () => { + useUIStore.getState().setLinearIssueFocus(' ENG-12 '); + expect(useUIStore.getState().linearIssueFocus).toBe('ENG-12'); + useUIStore.getState().setLinearIssueFocus(' '); + expect(useUIStore.getState().linearIssueFocus).toBeNull(); + useUIStore.getState().setLinearIssueFocus('ENG-12'); + useUIStore.getState().setLinearIssueFocus(null); + expect(useUIStore.getState().linearIssueFocus).toBeNull(); + }); +}); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 3049ccd8..0066881c 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -7,14 +7,14 @@ import type { ShortcutCombo } from '@/lib/shortcuts'; import type { DraftStarterRef } from '@/lib/draftStarters'; import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions'; import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; -import type { TerminalShell } from '@/lib/api/types'; +import type { LinearIssueListAssignee, LinearIssueListPriority, LinearIssueListStatus, TerminalShell } from '@/lib/api/types'; import type { ProjectRef } from '@/lib/projectContextApi'; import { useFilesViewTabsStore } from './useFilesViewTabsStore'; import { isWindowsArm64 } from '@/lib/platform'; import { isVSCodeRuntime } from '@/lib/desktop'; export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch'; -export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal'; +export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal'; export type MermaidRenderingMode = 'svg' | 'ascii'; export type UserMessageRenderingMode = 'markdown' | 'plain'; export type ChatRenderMode = 'sorted' | 'live'; @@ -40,6 +40,37 @@ function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap { return value === 'vim' ? 'vim' : 'default'; } +export const LINEAR_ISSUE_LIST_ALL_TEAMS = 'all'; + +function sanitizeLinearIssueListStatus(value: unknown): LinearIssueListStatus { + return value === 'all' + || value === 'backlog' + || value === 'todo' + || value === 'started' + || value === 'inReview' + || value === 'completed' + || value === 'canceled' + || value === 'duplicate' + ? value + : 'all'; +} + +function sanitizeLinearIssueListAssignee(value: unknown): LinearIssueListAssignee { + return value === 'me' || value === 'any' ? value : 'any'; +} + +function sanitizeLinearIssueListTeamId(value: unknown): string { + if (typeof value !== 'string') return LINEAR_ISSUE_LIST_ALL_TEAMS; + const teamId = value.trim(); + return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS; +} + +function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority { + return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all' + ? value + : 'all'; +} + type ContextPanelTab = { id: string; mode: ContextPanelMode; @@ -342,7 +373,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => { // Legacy 'preview' tabs are converted to 'browser' by the v14 migration; // anything still carrying an unknown mode here is discarded rather than // resurrected into a tab the panel cannot render. - if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') { + if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'linear' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') { continue; } @@ -618,7 +649,7 @@ const sanitizeContextPanelByDirectory = ( if (candidate.widthByMode && typeof candidate.widthByMode === 'object') { for (const [mode, value] of Object.entries(candidate.widthByMode as Record)) { if ( - (mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'notes' || mode === 'terminal') + (mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal') && typeof value === 'number' && Number.isFinite(value) ) { @@ -787,6 +818,12 @@ interface UIStore { /** Width of the walkthrough table of contents, in pixels. */ walkthroughTocWidth: number; gitChangesViewMode: 'flat' | 'tree'; + linearIssueListStatus: LinearIssueListStatus; + linearIssueListAssignee: LinearIssueListAssignee; + linearIssueListTeamId: string; + linearIssueListPriority: LinearIssueListPriority; + /** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */ + linearIssueFocus: string | null; isTimelineDialogOpen: boolean; isPromptNavigatorPanelOpen: boolean; isImagePreviewOpen: boolean; @@ -983,6 +1020,12 @@ interface UIStore { setDiffWrapLines: (wrap: boolean) => void; setWalkthroughTocWidth: (width: number) => void; setGitChangesViewMode: (mode: 'flat' | 'tree') => void; + setLinearIssueListStatus: (status: LinearIssueListStatus) => void; + setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void; + setLinearIssueListTeamId: (teamId: string) => void; + setLinearIssueListPriority: (priority: LinearIssueListPriority) => void; + resetLinearIssueListFilters: () => void; + setLinearIssueFocus: (identifier: string | null) => void; setMultiRunLauncherOpen: (open: boolean) => void; setTimelineDialogOpen: (open: boolean) => void; setPromptNavigatorPanelOpen: (open: boolean) => void; @@ -1140,6 +1183,11 @@ export const useUIStore = create()( diffWrapLines: false, walkthroughTocWidth: 224, gitChangesViewMode: 'flat', + linearIssueListStatus: 'all', + linearIssueListAssignee: 'any', + linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListPriority: 'all', + linearIssueFocus: null, isTimelineDialogOpen: false, isPromptNavigatorPanelOpen: false, isImagePreviewOpen: false, @@ -2055,7 +2103,37 @@ export const useUIStore = create()( setGitChangesViewMode: (mode) => { set({ gitChangesViewMode: mode }); }, - + + setLinearIssueListStatus: (status) => { + set({ linearIssueListStatus: sanitizeLinearIssueListStatus(status) }); + }, + + setLinearIssueListAssignee: (assignee) => { + set({ linearIssueListAssignee: sanitizeLinearIssueListAssignee(assignee) }); + }, + + setLinearIssueListTeamId: (teamId) => { + set({ linearIssueListTeamId: sanitizeLinearIssueListTeamId(teamId) }); + }, + + setLinearIssueListPriority: (priority) => { + set({ linearIssueListPriority: sanitizeLinearIssueListPriority(priority) }); + }, + + resetLinearIssueListFilters: () => { + set({ + linearIssueListStatus: 'all', + linearIssueListAssignee: 'any', + linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListPriority: 'all', + }); + }, + + setLinearIssueFocus: (identifier) => { + const trimmed = identifier?.trim() ?? ''; + set({ linearIssueFocus: trimmed || null }); + }, + setInputBarOffset: (offset) => { set({ inputBarOffset: offset }); }, @@ -2712,6 +2790,11 @@ export const useUIStore = create()( } } + state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus); + state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee); + state.linearIssueListTeamId = sanitizeLinearIssueListTeamId(state.linearIssueListTeamId); + state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority); + state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap); state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior); @@ -2789,6 +2872,10 @@ export const useUIStore = create()( diffWrapLines: state.diffWrapLines, walkthroughTocWidth: state.walkthroughTocWidth, gitChangesViewMode: state.gitChangesViewMode, + linearIssueListStatus: state.linearIssueListStatus, + linearIssueListAssignee: state.linearIssueListAssignee, + linearIssueListTeamId: state.linearIssueListTeamId, + linearIssueListPriority: state.linearIssueListPriority, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 7c174659..911b1844 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -77,6 +77,7 @@ import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; import { createSessionAssistRuntime } from './lib/session-assist/runtime.js'; import { createSessionGoalRuntime } from './lib/session-goal/runtime.js'; import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js'; +import { createLinearSessionStatusRuntime } from './lib/linear/status-runtime.js'; import { createSessionKnowledgeRuntime } from './lib/session-knowledge/runtime.js'; import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js'; import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js'; @@ -856,6 +857,8 @@ const contextObligatoryRuntime = createContextObligatoryRuntime({ sessionKnowledgeRuntime, }); +const linearSessionStatusRuntime = createLinearSessionStatusRuntime(); + const globalMessageStreamHub = createGlobalMessageStreamHub({ buildOpenCodeUrl, getOpenCodeAuthHeaders, @@ -901,6 +904,7 @@ globalMessageStreamHub.subscribeEvent((event) => { sessionAssistRuntime.processPayload(payload, directory); sessionGoalRuntime.processPayload(payload, directory); contextObligatoryRuntime.processPayload(payload, directory); + linearSessionStatusRuntime.processPayload(payload); }); const processForwardedEventPayload = (payload, emitSyntheticEvent) => { diff --git a/packages/web/server/lib/linear/DOCUMENTATION.md b/packages/web/server/lib/linear/DOCUMENTATION.md new file mode 100644 index 00000000..3b158738 --- /dev/null +++ b/packages/web/server/lib/linear/DOCUMENTATION.md @@ -0,0 +1,97 @@ +# Linear Module Documentation + +## Purpose + +This module owns Linear OAuth, issue lookup, Linear-team-to-project mapping, issue status updates, and session status comments on Linear issues. Credentials live on the OpenChamber server, so web, desktop, and a phone paired to that host share them. You can store more than one Linear workspace; exactly one is current. Issue list, mapping, and new OAuth default to the current workspace. Session status comments use the workspace that started the session. The right-hand context panel lists issues for the current workspace, can switch workspace, filters the list, shows a read-only card, changes status or closes the issue, and starts a session or worktree. Start session stays visible in a footer while the issue card scrolls. The chat picker lists issues and attaches them to a message. New Worktree can also start from a Linear issue in the currently active project. A session started from a Linear issue can post started/completed/failure comments, each with an OpenChamber session link. Those comments are opt-in and only appear when this server has a publicly reachable address. + +VS Code omits Linear (`RuntimeAPIs.linear` is optional). Hide Linear UI when the API is missing. + +## Entrypoints and structure + +- `packages/web/server/lib/linear/index.js`: public server entrypoint. `routes.js` loads it lazily with `await import('./index.js')`. +- `packages/web/server/lib/linear/routes.js`: Express registration for the public callback, `/api/linear/auth/*`, `/api/linear/issues/*`, `/api/linear/mapping`, and `/api/linear/session-status`. +- `packages/web/server/lib/linear/auth.js`: auth file, client id, scopes, redirect URI. +- `packages/web/server/lib/linear/oauth.js`: authorization-code + PKCE S256, public callback broker handoff, refresh, revoke. +- `packages/web/server/lib/linear/client.js`: GraphQL helper, viewer/organization lookup, and access-token refresh. GraphQL errors prefer `extensions.userPresentableMessage` / validation constraints over the generic `Argument Validation Error` label. User-facing Linear errors set `LinearApiError.userError`. Requests send `public-file-urls-expire-in: 3600` so file URLs in issue descriptions and comments are temporarily readable in the panel. +- `packages/web/server/lib/linear/issues.js`: list/search/get issues, team workflow states, `issueUpdate`, and `commentCreate`. Parses identifiers and Linear URLs. `issueUpdate` resolves identifiers to UUIDs first because Linear's mutation does not accept `ENG-12`. List/get include `state.id`, `priority` (0–4), and labels (`id`, `name`, sanitized hex `color`) so the panel can show them and update status. +- `packages/web/server/lib/linear/teams.js`: list Linear teams for mapping UI. +- `packages/web/server/lib/linear/mapping.js`: persist default and per-team OpenChamber project paths. Separate from the auth file so disconnect does not wipe maps. +- `packages/web/server/lib/linear/status.js`: persist per-session started/completed/failure flags and post the matching Linear comment with an open-session URL. Posts nothing unless the user opted in and the session origin is public; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The dedupe file keeps the newest 500 sessions. +- `packages/web/server/lib/linear/status-runtime.js`: on the OpenCode event hub, first `session.status` idle after started posts completed once; `session.error` (except abort) posts failure once. +- `packages/web/src/api/linear.ts`: web client wrapper. Electron and hosted/Capacitor mobile reuse it. VS Code omits `linear`. + +## Public routes + +- `GET /linear/oauth/callback`: public fallback for an explicitly configured direct redirect URI. The built-in flow uses the stable callback broker instead, because desktop and self-hosted instances may have private or dynamic addresses. +- `GET /api/linear/auth/status`: connected flag, current user/organization/scope, and `workspaces` (id, name, current, user, authorizedAt). Never returns tokens. A 401 on the current workspace drops that workspace only; if another remains, status returns that one instead of disconnected. Identity refresh does not bump `authorizedAt`. +- `POST /api/linear/auth/start`: returns `{ authorizationUrl, expiresIn, scope }`. Body may include `origin: "desktop"` so the callback page can raise the desktop window. The authorize URL uses `prompt=consent` so Add workspace can pick a different Linear org. Completing OAuth stores or replaces that org and makes it current. +- `POST /api/linear/auth/activate`: body `{ organizationId }`. Makes that stored workspace current. 400 if the id is missing, 404 if it is not stored. +- `DELETE /api/linear/auth`: revokes the current workspace refresh token when present, then drops that workspace only. Other stored workspaces stay. Mapping is kept. +- `GET /api/linear/issues/list?query=&cursor=&status=&assignee=&teamId=&priority=`: issues from the current workspace. Omitted `status` is incomplete states (same as the chat picker). The panel sends `all`, `backlog`, `todo` (Linear `unstarted`), `started` (In Progress, excluding the In Review name), `inReview` (state name In Review), `completed` (Done), `canceled` (excluding the Duplicate name), or `duplicate` (state type or name Duplicate). `assignee` is `any` (default) or `me`. `teamId` limits the list to that Linear team. `priority` is `all` (default), `none`, `urgent`, `high`, `medium`, or `low`. An identifier or Linear URL returns that issue even if it is completed and ignores the other filters. Each issue includes `state.id` when Linear sends it, plus `priority` (0 none through 4 low) and `labels`. Never returns tokens. +- `GET /api/linear/issues/get?id=`: one issue by UUID or identifier, including description, comments, team, `state.id`, priority, and labels. +- `GET /api/linear/issues/states?teamId=`: workflow states for that Linear team (`id`, `name`, `type`, `position`), ordered like Linear's workflow: type (backlog, unstarted, started, completed, canceled) then position. Missing `teamId` is 400. Linear not-found or validation errors are 400 with Linear's presentable message. Disconnected is `{ connected: false }` with HTTP 200. +- `POST /api/linear/issues/update`: body `{ id, stateId }`. `id` may be a UUID, identifier, or Linear URL; identifiers are resolved before `issueUpdate` because Linear's mutation requires a UUID. Returns the updated issue. Closing an issue is this same call with the team's first `type: completed` state. Missing `id` or `stateId` is 400. Linear validation (for example a non-UUID `stateId`) is 400 with Linear's presentable message. A GraphQL 401 clears that workspace only. Disconnected is `{ connected: false }` with HTTP 200. +- `GET /api/linear/mapping`: stored default project plus live Linear teams with their mapped paths. Missing file is empty mapping. Malformed file is 500, not empty success. Disconnected is `{ connected: false }` with HTTP 200. +- `PUT /api/linear/mapping`: replace default project and per-team paths. Body `{ defaultProjectPath, teamProjectPaths }`. Failed write does not touch tokens. Disconnected is `{ connected: false }` and does not save. +- `GET /api/linear/preferences`: `{ sessionComments }`. `PUT /api/linear/preferences` with body `{ sessionComments: boolean }` replaces it and returns the stored value. A non-boolean body is 400. The preference is server-side because the event hub posts completed/failure without going through the interface. +- `POST /api/linear/session-status`: post a started/completed/failure comment on the linked Linear issue. Body `{ kind, sessionId, issueIdentifier?, sessionOrigin? }`. `started` requires `issueIdentifier`. `completed` and `failure` reuse the stored issue and open URL from `started`. Each kind posts at most once per session. Answers in this order: disconnected is `{ connected: false }` with HTTP 200; comments turned off is `skipped: 'disabled'`; a `sessionOrigin` nobody else can reach is `skipped: 'origin-not-public'`. `sessionOrigin` must be `http` or `https` with no path, and must resolve to a public host — loopback, private LAN and desktop deep links post no comment at all rather than a link only its author can open. Comment bodies are one markdown link: `[OpenChamber session started](url)` so Linear keeps the `?session=` query. The comment carries no issue or session title: it already sits on the issue, and titles routinely contain brackets that would break the link. Invalid body is 400. + +`POST /api/linear/auth/start`, `PUT /api/linear/mapping`, `POST /api/linear/issues/update`, and `POST /api/linear/session-status` parse JSON on the route (`16kb`). They are not on the `/api` 50mb allowlist. + +Disconnected list/get/states/update/mapping/session-status return `{ connected: false }` with HTTP 200 so the picker and panel can show an empty state. Missing `id` on get is 400. Missing `teamId` on states is 400. + +## Auth storage and config + +- Auth storage: `~/.config/openchamber/linear-auth.json` (or `$OPENCHAMBER_DATA_DIR/linear-auth.json`). Shape is `{ workspaces: [ { accessToken, refreshToken, user, organization, workspaceId, current, authorizedAt, ... } ] }`. `workspaceId` is the Linear organization id, or `user:` when there is no org, or `legacy` for a migrated token with neither. A legacy single-object file is rewritten to this list on read. Reconnecting the same org replaces that slot. +- Mapping storage: `~/.config/openchamber/linear-mapping.json` (same data dir). Shape is `{ workspaces: { [workspaceId]: { defaultProjectPath, teamProjectPaths } } }`. Reads and writes use the current workspace slice. A legacy flat file is wrapped under the current workspace id on read. Disconnect does not wipe maps. Writes are atomic and file mode is `0o600`. +- Session status storage: `~/.config/openchamber/linear-session-status.json` (same data dir). Writes are atomic and file mode is `0o600`. Dedupes started/completed/failure per OpenChamber session id. +- Writes are atomic and file mode is `0o600`. +- Client ID: `OPENCHAMBER_LINEAR_CLIENT_ID` -> `settings.json` `linearClientId` -> baked-in public default. +- Client secret: `OPENCHAMBER_LINEAR_CLIENT_SECRET` -> `settings.json` `linearClientSecret`. Optional with PKCE. Do not commit a secret. +- Scopes: `OPENCHAMBER_LINEAR_SCOPES` -> `settings.json` `linearScopes` -> `read,write,comments:create`. +- Session comments: `settings.json` `linearSessionComments`, boolean, absent means off. Written only through `PUT /api/linear/preferences`. +- Broker URL: `OPENCHAMBER_LINEAR_BROKER_URL` -> `settings.json` `linearBrokerUrl` -> `https://api.openchamber.dev/v1/oauth/linear`. +- Redirect URI: `OPENCHAMBER_LINEAR_REDIRECT_URI` -> `settings.json` `linearRedirectUri` -> `/callback`. Setting an explicit redirect URI bypasses the broker for custom/self-hosted OAuth applications. + +Linear requires an exact callback match. The built-in application registers `https://api.openchamber.dev/v1/oauth/linear/callback`; the broker holds only the short-lived authorization code. The local OpenChamber server keeps the claim secret and PKCE verifier, exchanges the code for tokens locally, then acknowledges the handoff. Custom brokers must expose `/start`, `/callback`, `/poll`, and `/complete` with the same contract. + +## OAuth contract + +- Authorization code + PKCE S256. Linear has no device flow. +- The broker stores hashes of OAuth state and a separate claim secret for ten minutes. It never receives the PKCE verifier or Linear tokens. The local status polling path claims a completed broker result and persists tokens on the OpenChamber server. +- Access tokens expire in 24 hours. Refresh tokens rotate; persist the new refresh token from every successful refresh. Concurrent refreshes share one in-flight promise per workspace. +- `invalid_grant` / 401 on refresh clears that workspace only so a dead token cannot loop. If it was the last workspace, status becomes disconnected. +- A GraphQL 401 after a valid-looking token also clears that workspace. A network failure while a token is stored does not: status stays connected with the last known user. + +## Project mapping + +OpenChamber has projects (directories), not accounts or organizations. Mapping is how create-session (picker and the right-hand panel) picks a directory: + +1. If the issue's Linear team has a project path, use that. +2. Otherwise use the default project path. +3. If neither is set, the UI tells the user to map the team in Settings → Integrations. It does not fall back to the currently active project. + +A worktree started from the panel or picker is created in that mapped project. New Worktree from Git is different: it stays in the currently active project. + +## Shared UI + +- `RuntimeAPIs.linear` is optional. Hide Linear settings, the chat picker, and the panel when it is missing (VS Code). +- Store: `packages/ui/src/stores/useLinearAuthStore.ts`. App start refreshes it from `App.tsx` and `MobileApp.tsx`, not `VSCodeApp`. +- Settings: first-party section on the Integrations page. Connect opens the authorization URL and polls status until the workspace list or current `authorizedAt` changes, so Add workspace is not treated as done just because a workspace was already connected. When connected, map a default project and optional per-team projects for the current workspace. Other stored workspaces appear in a list with Switch to. Disconnect removes the current workspace only. The panel can also switch the current workspace when more than one is stored. +- Context panel: desktop/web right-hand rail surface `linear` (`packages/ui/src/components/views/LinearIssuesView.tsx`). Singleton like git/pr. The rail icon is hidden until a Linear workspace is connected; disconnecting while the panel is open closes it. List/search defaults to all issues; the status filter is All, Backlog, To Do, In Progress, In Review, Done, Canceled, and Duplicate, matching the card status order. Identifier/URL still finds completed. Status, assignee, team, and priority filters persist in `useUIStore` so they survive rail switches. Non-default list filters and search tint the filter icon `text-primary`, same as the context rail; one control clears them, not the workspace switch. Changing those filters keeps the previous list until the next page arrives and does not disable the filter row. On a narrow panel search and the filters other than status drop to icons; status keeps its label. The card shows priority and labels. Comments render as an avatar timeline matching the pull request panel, so both context surfaces read alike; comment authors carry `avatarUrl`. The card is read-only except status (`issueUpdate`) and Close (first completed workflow state). Start session stays in a footer while the description and comments scroll. Start session / worktree share `startLinearIssueSession` with the picker. No create-issue, no writing comments, no polling. VS Code and the mobile workspace drawer omit this rail. +- Chat: composer attach menu "Link Linear Issue" attaches body and comments as `linear-issue` context on the next send. Exclusive with a linked GitHub issue or PR. The attached issue is stored on session metadata (`kind: 'linear'`) so work status can show it. Clicking that work-status row opens the Linear rail when Linear is connected on desktop/web; otherwise the Linear URL. Managed Chats do not offer start-from-issue; those sessions have no project directory. +- Worktree: New Worktree can start from a Linear issue. It uses the currently active project and does not consult team-to-project mapping. GitHub issue/PR and Linear issue are exclusive on that form. +- Status comments: off until the user turns them on in Settings -> Integrations -> Linear (`LinearSessionComments.tsx`). When on, create-session and worktree-from-Linear post `started` after the session exists. The event hub posts `completed` on the first idle after that, and `failure` on `session.error` except `MessageAbortedError`. Failed comments must not fail session create. Comment bodies are English (they live on Linear) and are one markdown link named `OpenChamber session started` (or completed/failed). Web uses `/?session=` on the current origin; desktop reports the loopback origin its own server listens on, not `openchamber-ui://`. A Linear comment is read by the whole team, so the server posts nothing when that origin is not publicly reachable rather than publishing a link only its author could open. Opening `/?session=` selects that session after the global session list can resolve its directory. +- Magic prompts: `linear.issue.review.visible` / `.instructions`. Do not reuse the GitHub issue-review templates for Linear. + +## Notes for contributors + +The implementation and deployment hand-off for the stable callback broker is +in [`OAUTH-BROKER-HANDOFF.md`](./OAUTH-BROKER-HANDOFF.md). It records the exact +Linear redirect URI that must be registered and why the original loopback +callback could not support packaged desktop or arbitrary self-hosted servers. + +- Do not log tokens, codes, verifiers, or the client secret. +- Do not add Linear under Git or as a third-party plugin row. +- Actor is `user`. Do not enable Linear client-credentials tokens for this flow. +- One OAuth grant is still one Linear organization. The server stores many grants and keeps one current. Webhooks and inbound Linear issue actions are out of scope until a later change. diff --git a/packages/web/server/lib/linear/OAUTH-BROKER-HANDOFF.md b/packages/web/server/lib/linear/OAUTH-BROKER-HANDOFF.md new file mode 100644 index 00000000..12d99711 --- /dev/null +++ b/packages/web/server/lib/linear/OAUTH-BROKER-HANDOFF.md @@ -0,0 +1,91 @@ +# Linear OAuth broker hand-off + +## Required Linear application change + +Register this exact redirect URI in the Linear OAuth application used by the +baked-in client ID: + +`https://api.openchamber.dev/v1/oauth/linear/callback` + +Linear compares the full redirect URI, including scheme, host, path, and port. +Deploy the API broker and apply its D1 migration before testing this branch. + +## Why the original callback failed + +The first implementation redirected Linear back to the OpenChamber server: + +`http://127.0.0.1:/linear/oauth/callback` + +That address is not stable across OpenChamber runtimes: + +- packaged desktop prefers its stored local port and can select another free + port when needed; +- local development and the CLI use different ports; +- self-hosted servers may sit behind a reverse proxy or have no public inbound + address at all. + +Linear requires an exact pre-registered callback. Registering every possible +desktop or self-hosted address is impossible, and forcing desktop onto one port +would make startup fail whenever another process owns that port. + +## New flow + +The built-in Linear client now uses the stable callback broker in +`openchamber-website/apps/api`: + +1. The OpenChamber server generates OAuth state, a PKCE verifier, and a separate + claim secret. +2. The broker stores only hashes of state and the claim secret for ten minutes. +3. Linear sends its authorization code to the stable public callback. +4. The OpenChamber server polls the broker with state and the claim secret. +5. The OpenChamber server exchanges the code using the PKCE verifier and stores + the Linear tokens locally. +6. After persistence succeeds, OpenChamber acknowledges the hand-off and the + broker marks it consumed. + +The broker never receives the PKCE verifier, access token, or refresh token. +Private Relay is not involved; the local server only needs outbound HTTPS. + +## Compatibility and configuration + +- `OPENCHAMBER_LINEAR_BROKER_URL` or `settings.json` `linearBrokerUrl` selects a + self-hosted broker. The default is + `https://api.openchamber.dev/v1/oauth/linear`. +- `OPENCHAMBER_LINEAR_REDIRECT_URI` or `settings.json` `linearRedirectUri` + bypasses the broker and preserves the direct callback flow for a custom + Linear OAuth application. + +## Owning files + +OpenChamber: + +- `auth.js`: broker and redirect configuration. +- `oauth.js`: PKCE, broker registration/poll/acknowledgement, token exchange. +- `routes.js`: starts authorization and completes broker results during status + polling. + +Hosted API, in the `openchamber-website` repository: + +- `apps/api/src/routes/linear-oauth.ts` +- `apps/api/migrations/0010_linear_oauth_transactions.sql` +- `apps/api/LINEAR-OAUTH.md` + +## Validation + +OpenChamber focused tests: + +```sh +bunx vitest run \ + packages/web/server/lib/linear/oauth.test.js \ + packages/web/server/lib/linear/auth.test.js \ + packages/web/server/lib/linear/routes.test.js +``` + +Hosted API checks: + +```sh +cd apps/api +bun test src/routes/linear-oauth.test.ts +bun run check +bun run build +``` diff --git a/packages/web/server/lib/linear/auth.js b/packages/web/server/lib/linear/auth.js new file mode 100644 index 00000000..67ef53c8 --- /dev/null +++ b/packages/web/server/lib/linear/auth.js @@ -0,0 +1,436 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { isPlainObject, readEnv, readFiniteNumber, readTrimmedString } from './parse.js'; + +const DEFAULT_LINEAR_CLIENT_ID = '91bbe26a69a2c8568d3683f1e01e776c'; +const DEFAULT_LINEAR_SCOPES = 'read,write,comments:create'; +const DEFAULT_LINEAR_BROKER_URL = 'https://api.openchamber.dev/v1/oauth/linear'; +const ACCESS_TOKEN_REFRESH_SKEW_MS = 2 * 60_000; +const LEGACY_WORKSPACE_ID = 'legacy'; +const SESSION_COMMENTS_SETTING_KEY = 'linearSessionComments'; + +function resolveDataDir() { + const fromEnv = readEnv('OPENCHAMBER_DATA_DIR'); + if (fromEnv) { + return path.resolve(fromEnv); + } + return path.join(os.homedir(), '.config', 'openchamber'); +} + +function storageFile() { + return path.join(resolveDataDir(), 'linear-auth.json'); +} + +function settingsFile() { + return path.join(resolveDataDir(), 'settings.json'); +} + +function ensureStorageDir() { + const dir = resolveDataDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function readJsonFile(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const parsed = JSON.parse(trimmed); + if (!isPlainObject(parsed)) { + return null; + } + return parsed; + } catch (error) { + console.error('Failed to read Linear auth file:', error); + return null; + } +} + +function writeJsonFile(filePath, payload) { + ensureStorageDir(); + const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8'); + try { + fs.chmodSync(tmpFile, 0o600); + } catch { + // best-effort + } + fs.renameSync(tmpFile, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } +} + +function normalizeUser(user) { + if (!isPlainObject(user)) { + return null; + } + const id = readTrimmedString(user.id); + if (!id) { + return null; + } + return { + id, + name: readTrimmedString(user.name) || null, + displayName: readTrimmedString(user.displayName) || null, + email: readTrimmedString(user.email) || null, + avatarUrl: readTrimmedString(user.avatarUrl) || null, + }; +} + +function normalizeOrganization(organization) { + if (!isPlainObject(organization)) { + return null; + } + const id = readTrimmedString(organization.id); + const name = readTrimmedString(organization.name); + if (!id || !name) { + return null; + } + return { + id, + name, + urlKey: readTrimmedString(organization.urlKey) || null, + }; +} + +function resolveLinearWorkspaceId({ organization, user, workspaceId } = {}) { + const explicit = readTrimmedString(workspaceId); + if (explicit) return explicit; + const organizationId = organization ? readTrimmedString(organization.id) : ''; + if (organizationId) return organizationId; + const userId = user ? readTrimmedString(user.id) : ''; + if (userId) return `user:${userId}`; + return LEGACY_WORKSPACE_ID; +} + +function normalizeAuthEntry(raw) { + if (!isPlainObject(raw)) { + return null; + } + const accessToken = readTrimmedString(raw.accessToken); + if (!accessToken) { + return null; + } + const user = normalizeUser(raw.user); + const organization = normalizeOrganization(raw.organization); + return { + accessToken, + refreshToken: readTrimmedString(raw.refreshToken) || null, + tokenType: readTrimmedString(raw.tokenType) || 'bearer', + expiresAt: readFiniteNumber(raw.expiresAt), + scope: readTrimmedString(raw.scope), + createdAt: readFiniteNumber(raw.createdAt), + authorizedAt: readFiniteNumber(raw.authorizedAt) || readFiniteNumber(raw.createdAt), + user, + organization, + current: Boolean(raw.current), + workspaceId: resolveLinearWorkspaceId({ + organization, + user, + workspaceId: raw.workspaceId, + }), + }; +} + +function normalizeAuthList(raw) { + const source = Array.isArray(raw?.workspaces) + ? raw.workspaces + : (raw?.accessToken ? [raw] : []); + const list = source.map((entry) => normalizeAuthEntry(entry)).filter(Boolean); + + if (!list.length) { + return { list: [], changed: Boolean(raw && (raw.accessToken || Array.isArray(raw.workspaces))) }; + } + + let changed = Array.isArray(raw?.workspaces) === false && Boolean(raw?.accessToken); + const seen = new Set(); + const deduped = []; + for (const entry of list) { + if (seen.has(entry.workspaceId)) { + changed = true; + continue; + } + seen.add(entry.workspaceId); + deduped.push(entry); + } + + let currentFound = false; + deduped.forEach((entry) => { + if (entry.current && !currentFound) { + currentFound = true; + } else if (entry.current && currentFound) { + entry.current = false; + changed = true; + } + }); + + if (!currentFound && deduped[0]) { + deduped[0].current = true; + changed = true; + } + + return { list: deduped, changed }; +} + +function readAuthList() { + const data = readJsonFile(storageFile()); + if (!data) { + return []; + } + const { list, changed } = normalizeAuthList(data); + if (changed) { + writeAuthList(list); + } + return list; +} + +function writeAuthList(list) { + if (!list.length) { + const filePath = storageFile(); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + return; + } + writeJsonFile(storageFile(), { workspaces: list }); +} + +function readSettings() { + return readJsonFile(settingsFile()) || {}; +} + +function writeSettings(settings) { + writeJsonFile(settingsFile(), settings); +} + +function readSettingString(key) { + const stored = readSettings()[key]; + return readTrimmedString(stored); +} + +export function getLinearAuth() { + const list = readAuthList(); + if (!list.length) { + return null; + } + return list.find((entry) => entry.current) || list[0]; +} + +export function getLinearAuthByWorkspaceId(workspaceId) { + const id = readTrimmedString(workspaceId); + if (!id) { + return getLinearAuth(); + } + return readAuthList().find((entry) => entry.workspaceId === id) || null; +} + +export function getLinearAuthWorkspaces() { + return readAuthList().map((entry) => ({ + id: entry.workspaceId, + name: entry.organization?.name || null, + urlKey: entry.organization?.urlKey || null, + current: Boolean(entry.current), + user: entry.user || null, + authorizedAt: entry.authorizedAt || entry.createdAt || null, + })); +} + +export function setLinearAuth(input, options = {}) { + const accessToken = readTrimmedString(input?.accessToken); + if (!accessToken) { + throw new Error('accessToken is required'); + } + const activate = options.activate !== false; + const list = readAuthList(); + const current = list.find((entry) => entry.current) || list[0] || null; + + const nextUser = Object.prototype.hasOwnProperty.call(input, 'user') + ? normalizeUser(input.user) + : current?.user || null; + const nextOrganization = Object.prototype.hasOwnProperty.call(input, 'organization') + ? normalizeOrganization(input.organization) + : current?.organization || null; + const workspaceId = resolveLinearWorkspaceId({ + organization: nextOrganization, + user: nextUser, + workspaceId: input?.workspaceId || (nextOrganization || nextUser ? '' : current?.workspaceId), + }); + + const existingIndex = list.findIndex((entry) => entry.workspaceId === workspaceId); + const previous = existingIndex >= 0 ? list[existingIndex] : ( + nextOrganization || nextUser ? null : current + ); + const targetIndex = existingIndex >= 0 + ? existingIndex + : (previous && !nextOrganization && !nextUser ? list.indexOf(previous) : -1); + const wasCurrent = previous?.current === true; + + const next = { + accessToken, + refreshToken: Object.prototype.hasOwnProperty.call(input, 'refreshToken') + ? (readTrimmedString(input.refreshToken) || null) + : previous?.refreshToken || null, + tokenType: readTrimmedString(input?.tokenType) || previous?.tokenType || 'bearer', + expiresAt: readFiniteNumber(input?.expiresAt) ?? previous?.expiresAt ?? null, + scope: readTrimmedString(input?.scope) || previous?.scope || '', + createdAt: previous?.createdAt || Date.now(), + authorizedAt: Object.prototype.hasOwnProperty.call(input, 'authorizedAt') + ? (readFiniteNumber(input.authorizedAt) || Date.now()) + : (activate ? Date.now() : (previous?.authorizedAt || previous?.createdAt || Date.now())), + user: nextUser, + organization: nextOrganization, + current: false, + workspaceId, + }; + + if (targetIndex >= 0) { + list[targetIndex] = next; + } else { + list.push(next); + } + + const writtenIndex = targetIndex >= 0 ? targetIndex : list.length - 1; + if (activate || !list.some((entry) => entry.current)) { + list.forEach((entry, index) => { + entry.current = index === writtenIndex; + }); + } else { + list[writtenIndex].current = wasCurrent; + } + + writeAuthList(list); + return list[writtenIndex]; +} + +export function activateLinearAuth(workspaceId) { + const id = readTrimmedString(workspaceId); + if (!id) { + return false; + } + const list = readAuthList(); + const index = list.findIndex((entry) => entry.workspaceId === id); + if (index === -1) { + return false; + } + list.forEach((entry, idx) => { + entry.current = idx === index; + }); + writeAuthList(list); + return true; +} + +export function clearLinearAuth(workspaceId) { + try { + const list = readAuthList(); + if (!list.length) { + return true; + } + const id = readTrimmedString(workspaceId); + const remaining = id + ? list.filter((entry) => entry.workspaceId !== id) + : list.filter((entry) => !entry.current); + if (!remaining.length) { + writeAuthList([]); + return true; + } + if (!remaining.some((entry) => entry.current)) { + remaining[0].current = true; + } + writeAuthList(remaining); + return true; + } catch (error) { + console.error('Failed to clear Linear auth file:', error); + return false; + } +} + +export function isLinearAccessTokenStale(expiresAt, now = Date.now()) { + const expiry = readFiniteNumber(expiresAt); + if (expiry == null) { + return true; + } + return expiry - ACCESS_TOKEN_REFRESH_SKEW_MS <= now; +} + +export function toLinearPublicStatus(auth, workspaces = getLinearAuthWorkspaces()) { + if (!auth?.accessToken) { + return { connected: false }; + } + return { + connected: true, + user: auth.user || null, + organization: auth.organization || null, + scope: auth.scope || undefined, + workspaces, + }; +} + +export function getLinearClientId() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_ID'); + if (fromEnv) return fromEnv; + const stored = readSettingString('linearClientId'); + if (stored) return stored; + return DEFAULT_LINEAR_CLIENT_ID; +} + +export function getLinearClientSecret() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_SECRET'); + if (fromEnv) return fromEnv; + return readSettingString('linearClientSecret'); +} + +export function getLinearScopes() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_SCOPES'); + if (fromEnv) return fromEnv; + const stored = readSettingString('linearScopes'); + if (stored) return stored; + return DEFAULT_LINEAR_SCOPES; +} + +export function getLinearBrokerUrl() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_BROKER_URL'); + if (fromEnv) return fromEnv.replace(/\/+$/, ''); + const stored = readSettingString('linearBrokerUrl'); + if (stored) return stored.replace(/\/+$/, ''); + return DEFAULT_LINEAR_BROKER_URL; +} + +export function getLinearRedirectUri() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_REDIRECT_URI'); + if (fromEnv) return fromEnv; + const stored = readSettingString('linearRedirectUri'); + if (stored) return stored; + return `${getLinearBrokerUrl()}/callback`; +} + +/** + * Status comments are opt-in: they are written into a Linear workspace other + * people read, so nothing is posted until the user turns them on. + */ +export function getLinearSessionCommentsEnabled() { + return readSettings()[SESSION_COMMENTS_SETTING_KEY] === true; +} + +export function setLinearSessionCommentsEnabled(enabled) { + const next = enabled === true; + const settings = readSettings(); + settings[SESSION_COMMENTS_SETTING_KEY] = next; + writeSettings(settings); + return next; +} + +export function getLinearAuthFilePath() { + return storageFile(); +} +export const DEFAULT_LINEAR_CLIENT_ID_VALUE = DEFAULT_LINEAR_CLIENT_ID; diff --git a/packages/web/server/lib/linear/auth.test.js b/packages/web/server/lib/linear/auth.test.js new file mode 100644 index 00000000..a45c7149 --- /dev/null +++ b/packages/web/server/lib/linear/auth.test.js @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + getLinearAuth, + getLinearAuthWorkspaces, + setLinearAuth, + activateLinearAuth, + clearLinearAuth, + toLinearPublicStatus, + getLinearClientId, + getLinearRedirectUri, + isLinearAccessTokenStale, + getLinearAuthFilePath, + DEFAULT_LINEAR_CLIENT_ID_VALUE, +} from './auth.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-auth-')); + +describe('Linear auth storage', () => { + let dataDir; + let previousDataDir; + let previousPort; + let previousClientId; + let previousRedirect; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + previousClientId = process.env.OPENCHAMBER_LINEAR_CLIENT_ID; + previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID; + delete process.env.OPENCHAMBER_LINEAR_SCOPES; + delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + delete process.env.OPENCHAMBER_PORT; + }); + + afterEach(() => { + restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir); + restoreEnv('OPENCHAMBER_PORT', previousPort); + restoreEnv('OPENCHAMBER_LINEAR_CLIENT_ID', previousClientId); + restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns disconnected when no auth file exists', () => { + expect(getLinearAuth()).toBeNull(); + expect(toLinearPublicStatus(null)).toEqual({ connected: false }); + }); + + it('persists tokens without exposing them on the public status', () => { + setLinearAuth({ + accessToken: 'lin_oauth_access', + refreshToken: 'lin_oauth_refresh', + expiresAt: Date.now() + 60_000, + scope: 'read,write', + user: { id: 'user-1', name: 'Ada', displayName: 'Ada Lovelace', email: 'ada@example.com', avatarUrl: 'https://example.com/a.png' }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + }); + + const stored = getLinearAuth(); + expect(stored.accessToken).toBe('lin_oauth_access'); + expect(stored.refreshToken).toBe('lin_oauth_refresh'); + expect(stored.workspaceId).toBe('org-1'); + const publicStatus = toLinearPublicStatus(stored); + expect(publicStatus).toEqual({ + connected: true, + user: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + scope: 'read,write', + workspaces: [{ + id: 'org-1', + name: 'OpenChamber', + urlKey: 'openchamber', + current: true, + user: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + authorizedAt: stored.authorizedAt, + }], + }); + expect(JSON.stringify(publicStatus)).not.toContain('lin_oauth'); + const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8')); + expect(file.accessToken).toBeUndefined(); + expect(file.workspaces).toHaveLength(1); + expect(file.workspaces[0].accessToken).toBe('lin_oauth_access'); + }); + + it('keeps the previous refresh token when a later write omits it', () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + expiresAt: 1, + }); + setLinearAuth({ + accessToken: 'access-2', + expiresAt: 2, + }); + expect(getLinearAuth().refreshToken).toBe('refresh-1'); + expect(getLinearAuth().accessToken).toBe('access-2'); + }); + + it('rotates the refresh token when a new one is provided', () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + }); + setLinearAuth({ + accessToken: 'access-2', + refreshToken: 'refresh-2', + }); + expect(getLinearAuth().refreshToken).toBe('refresh-2'); + }); + + it('rejects a write without an access token', () => { + expect(() => setLinearAuth({ refreshToken: 'refresh-1' })).toThrow('accessToken is required'); + }); + + it('treats a missing or past expiry as stale', () => { + expect(isLinearAccessTokenStale(null)).toBe(true); + expect(isLinearAccessTokenStale(Date.now() - 1)).toBe(true); + expect(isLinearAccessTokenStale(Date.now() + 10 * 60_000)).toBe(false); + }); + + it('uses the baked-in client id unless env or settings override it', () => { + expect(getLinearClientId()).toBe(DEFAULT_LINEAR_CLIENT_ID_VALUE); + process.env.OPENCHAMBER_LINEAR_CLIENT_ID = 'env-client'; + expect(getLinearClientId()).toBe('env-client'); + }); + + it('uses the stable public broker callback by default', () => { + process.env.OPENCHAMBER_PORT = '3001'; + expect(getLinearRedirectUri()).toBe('https://api.openchamber.dev/v1/oauth/linear/callback'); + process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://localhost:3000/linear/oauth/callback'; + expect(getLinearRedirectUri()).toBe('http://localhost:3000/linear/oauth/callback'); + }); + + it('deletes the auth file on clear', () => { + setLinearAuth({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + expect(fs.existsSync(getLinearAuthFilePath())).toBe(true); + expect(clearLinearAuth()).toBe(true); + expect(fs.existsSync(getLinearAuthFilePath())).toBe(false); + expect(getLinearAuth()).toBeNull(); + }); + + it('migrates a legacy single-workspace file', () => { + fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify({ + accessToken: 'legacy-access', + refreshToken: 'legacy-refresh', + user: { id: 'user-1', name: 'Ada' }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + }), 'utf8'); + + const stored = getLinearAuth(); + expect(stored.accessToken).toBe('legacy-access'); + expect(stored.workspaceId).toBe('org-1'); + expect(stored.current).toBe(true); + const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8')); + expect(file.workspaces).toHaveLength(1); + expect(file.accessToken).toBeUndefined(); + }); + + it('stores a second workspace and activates it without dropping the first', () => { + setLinearAuth({ + accessToken: 'access-a', + refreshToken: 'refresh-a', + user: { id: 'user-a', name: 'Ada' }, + organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' }, + }); + setLinearAuth({ + accessToken: 'access-b', + refreshToken: 'refresh-b', + user: { id: 'user-b', name: 'Ben' }, + organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' }, + }); + + expect(getLinearAuth().workspaceId).toBe('org-b'); + expect(getLinearAuthWorkspaces().map((entry) => entry.id).sort()).toEqual(['org-a', 'org-b']); + expect(activateLinearAuth('org-a')).toBe(true); + expect(getLinearAuth().workspaceId).toBe('org-a'); + expect(getLinearAuth().accessToken).toBe('access-a'); + expect(getLinearAuthWorkspaces().find((entry) => entry.id === 'org-b').current).toBe(false); + }); + + it('drops only the current workspace on unscoped clear', () => { + setLinearAuth({ + accessToken: 'access-a', + organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' }, + user: { id: 'user-a', name: 'Ada' }, + }); + setLinearAuth({ + accessToken: 'access-b', + organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' }, + user: { id: 'user-b', name: 'Ben' }, + }); + expect(clearLinearAuth()).toBe(true); + expect(getLinearAuth().workspaceId).toBe('org-a'); + expect(getLinearAuth().accessToken).toBe('access-a'); + expect(getLinearAuthWorkspaces()).toHaveLength(1); + }); + + it('does not bump authorizedAt when a later write opts out of activate', () => { + setLinearAuth({ + accessToken: 'access-1', + user: { id: 'user-1', name: 'Ada' }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + }); + const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8')); + file.workspaces[0].authorizedAt = 111; + fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify(file, null, 2), 'utf8'); + + setLinearAuth({ + accessToken: 'access-1', + user: { id: 'user-1', name: 'Ada' }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + workspaceId: 'org-1', + }, { activate: false }); + expect(getLinearAuth().authorizedAt).toBe(111); + expect(getLinearAuth().current).toBe(true); + }); +}); + +function restoreEnv(name, previous) { + if (previous === undefined) { + delete process.env[name]; + return; + } + process.env[name] = previous; +} diff --git a/packages/web/server/lib/linear/client.js b/packages/web/server/lib/linear/client.js new file mode 100644 index 00000000..7eee2d60 --- /dev/null +++ b/packages/web/server/lib/linear/client.js @@ -0,0 +1,191 @@ +import { + getLinearAuth, + getLinearAuthByWorkspaceId, + setLinearAuth, + clearLinearAuth, + isLinearAccessTokenStale, +} from './auth.js'; +import { refreshAccessToken } from './oauth.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'; +const VIEWER_QUERY = '{ viewer { id name displayName email avatarUrl } organization { id name urlKey } }'; +// Linear file URLs in GraphQL need this header or the browser cannot load +// uploads.linear.app images (comment screenshots, description images). +const LINEAR_PUBLIC_FILE_URL_TTL_SECONDS = '3600'; + +export class LinearApiError extends Error { + constructor(message, status, options = {}) { + super(message); + this.name = 'LinearApiError'; + this.status = status; + this.userError = options.userError === true; + } +} + +function readGraphqlError(payload) { + const errors = Array.isArray(payload.errors) ? payload.errors : []; + const first = errors.length > 0 && isPlainObject(errors[0]) ? errors[0] : null; + if (!first) { + return { message: '', userError: false, status: 502 }; + } + const extensions = isPlainObject(first.extensions) ? first.extensions : null; + const presentable = extensions ? readTrimmedString(extensions.userPresentableMessage) : ''; + let constraint = ''; + const validationErrors = extensions && Array.isArray(extensions.validationErrors) + ? extensions.validationErrors + : []; + for (const entry of validationErrors) { + if (!isPlainObject(entry) || !isPlainObject(entry.constraints)) continue; + for (const value of Object.values(entry.constraints)) { + const text = readTrimmedString(value); + if (text) { + constraint = text; + break; + } + } + if (constraint) break; + } + const message = presentable || constraint || readTrimmedString(first.message); + const code = extensions ? readTrimmedString(extensions.code) : ''; + const userError = extensions?.userError === true + || code === 'INVALID_INPUT' + || code === 'INPUT_ERROR' + || /^entity not found/i.test(message) + || /^argument validation/i.test(message); + return { + message, + userError, + status: userError ? 400 : 502, + }; +} + +function readIdentity(payload) { + const data = isPlainObject(payload) ? payload.data : null; + const viewer = isPlainObject(data) ? data.viewer : null; + if (!isPlainObject(viewer) || !readTrimmedString(viewer.id)) { + return null; + } + const organization = isPlainObject(data) ? data.organization : null; + const organizationId = isPlainObject(organization) ? readTrimmedString(organization.id) : ''; + const organizationName = isPlainObject(organization) ? readTrimmedString(organization.name) : ''; + return { + user: { + id: viewer.id.trim(), + name: readTrimmedString(viewer.name) || null, + displayName: readTrimmedString(viewer.displayName) || null, + email: readTrimmedString(viewer.email) || null, + avatarUrl: readTrimmedString(viewer.avatarUrl) || null, + }, + organization: organizationId && organizationName + ? { + id: organizationId, + name: organizationName, + urlKey: readTrimmedString(organization.urlKey) || null, + } + : null, + }; +} + +export async function fetchLinearGraphql(accessToken, query, variables) { + const token = readTrimmedString(accessToken); + if (!token) { + throw new LinearApiError('Linear is not connected', 401); + } + + const body = { query }; + if (isPlainObject(variables)) { + body.variables = variables; + } + + const response = await fetch(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'public-file-urls-expire-in': LINEAR_PUBLIC_FILE_URL_TTL_SECONDS, + }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => null); + if (response.status === 401) { + throw new LinearApiError('Linear token expired or revoked', 401); + } + if (!response.ok) { + throw new LinearApiError(`Linear GraphQL request failed (${response.status})`, response.status); + } + if (!isPlainObject(payload)) { + throw new LinearApiError('Linear GraphQL response was not JSON', 502); + } + const data = isPlainObject(payload.data) ? payload.data : null; + if (!data) { + const graphqlError = readGraphqlError(payload); + throw new LinearApiError( + graphqlError.message || 'Linear GraphQL response did not include data', + graphqlError.status, + { userError: graphqlError.userError }, + ); + } + return data; +} + +export async function fetchLinearIdentity(accessToken) { + const data = await fetchLinearGraphql(accessToken, VIEWER_QUERY); + const identity = readIdentity({ data }); + if (!identity) { + throw new LinearApiError('Linear GraphQL response did not include a viewer', 502); + } + return identity; +} + +const inFlightRefreshByWorkspace = new Map(); + +async function refreshWorkspaceAuth(auth) { + const tokens = await refreshAccessToken(auth.refreshToken); + const next = setLinearAuth({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken || auth.refreshToken, + tokenType: tokens.tokenType, + expiresAt: tokens.expiresAt, + scope: tokens.scope || auth.scope, + user: auth.user, + organization: auth.organization, + workspaceId: auth.workspaceId, + }, { activate: false }); + return next.accessToken; +} + +export async function getValidLinearAccessToken(workspaceId) { + const auth = workspaceId + ? getLinearAuthByWorkspaceId(workspaceId) + : getLinearAuth(); + if (!auth?.accessToken) { + return null; + } + if (!isLinearAccessTokenStale(auth.expiresAt)) { + return auth.accessToken; + } + if (!auth.refreshToken) { + clearLinearAuth(auth.workspaceId); + return null; + } + const key = auth.workspaceId; + const pending = inFlightRefreshByWorkspace.get(key); + if (pending) { + return pending; + } + const promise = refreshWorkspaceAuth(auth) + .catch((error) => { + if (error?.code === 'INVALID_GRANT' || error?.status === 400 || error?.status === 401) { + clearLinearAuth(auth.workspaceId); + return null; + } + throw error; + }) + .finally(() => { + inFlightRefreshByWorkspace.delete(key); + }); + inFlightRefreshByWorkspace.set(key, promise); + return promise; +} diff --git a/packages/web/server/lib/linear/index.js b/packages/web/server/lib/linear/index.js new file mode 100644 index 00000000..24300d32 --- /dev/null +++ b/packages/web/server/lib/linear/index.js @@ -0,0 +1,61 @@ +export { + getLinearAuth, + getLinearAuthByWorkspaceId, + getLinearAuthWorkspaces, + setLinearAuth, + activateLinearAuth, + clearLinearAuth, + toLinearPublicStatus, + getLinearClientId, + getLinearClientSecret, + getLinearScopes, + getLinearBrokerUrl, + getLinearRedirectUri, + isLinearAccessTokenStale, + getLinearAuthFilePath, + getLinearSessionCommentsEnabled, + setLinearSessionCommentsEnabled, + DEFAULT_LINEAR_CLIENT_ID_VALUE, +} from './auth.js'; + +export { + startAuthorization, + consumeAuthorizationCallback, + pollAuthorizationBroker, + completeAuthorizationBroker, + refreshAccessToken, + revokeToken, + LinearOAuthError, +} from './oauth.js'; + +export { + fetchLinearIdentity, + getValidLinearAccessToken, + LinearApiError, +} from './client.js'; + +export { + listLinearIssues, + getLinearIssue, + listLinearIssueStates, + updateLinearIssue, +} from './issues.js'; + +export { + listLinearTeams, +} from './teams.js'; + +export { + LinearMappingError, + getLinearMappingFilePath, + mergeLinearMappingView, + readStoredLinearMapping, + resolveMappedProjectPath, + setStoredLinearMapping, +} from './mapping.js'; + +export { + LinearSessionStatusError, + isPublicSessionOrigin, + postLinearSessionStatus, +} from './status.js'; diff --git a/packages/web/server/lib/linear/issues.js b/packages/web/server/lib/linear/issues.js new file mode 100644 index 00000000..01b17f74 --- /dev/null +++ b/packages/web/server/lib/linear/issues.js @@ -0,0 +1,499 @@ +import { clearLinearAuth, getLinearAuth, getLinearAuthByWorkspaceId } from './auth.js'; +import { fetchLinearGraphql, getValidLinearAccessToken } from './client.js'; +import { isPlainObject, isString, readFiniteNumber, readTrimmedString } from './parse.js'; + +const PAGE_SIZE = 50; + +const LIST_STATUS_STATE = { + open: { type: { nin: ['completed', 'canceled', 'duplicate'] } }, + backlog: { type: { eq: 'backlog' } }, + todo: { type: { eq: 'unstarted' } }, + started: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } }, + inReview: { name: { eqIgnoreCase: 'In Review' } }, + completed: { type: { eq: 'completed' } }, + canceled: { type: { eq: 'canceled' }, name: { neqIgnoreCase: 'Duplicate' } }, + duplicate: { or: [{ type: { eq: 'duplicate' } }, { name: { eqIgnoreCase: 'Duplicate' } }] }, +}; + +function readListStatus(value) { + const status = readTrimmedString(value); + if (status === 'all' || Object.hasOwn(LIST_STATUS_STATE, status)) { + return status; + } + return 'open'; +} + +function readListAssignee(value) { + const assignee = readTrimmedString(value); + if (assignee === 'me' || assignee === 'any') { + return assignee; + } + return 'any'; +} + +const LIST_PRIORITY_EQ = { + none: 0, + urgent: 1, + high: 2, + medium: 3, + low: 4, +}; + +function readListPriority(value) { + const priority = readTrimmedString(value); + if (priority === 'none' || priority === 'urgent' || priority === 'high' || priority === 'medium' || priority === 'low') { + return priority; + } + return 'all'; +} + +function buildIssueListFilter({ status, assignee, teamId, priority } = {}) { + const filter = {}; + const resolvedStatus = readListStatus(status); + const resolvedAssignee = readListAssignee(assignee); + const resolvedPriority = readListPriority(priority); + const team = readTrimmedString(teamId); + if (resolvedStatus !== 'all') { + filter.state = LIST_STATUS_STATE[resolvedStatus]; + } + if (resolvedAssignee === 'me') { + filter.assignee = { isMe: { eq: true } }; + } + if (team) { + filter.team = { id: { eq: team } }; + } + if (resolvedPriority !== 'all') { + filter.priority = { eq: LIST_PRIORITY_EQ[resolvedPriority] }; + } + return Object.keys(filter).length > 0 ? filter : undefined; +} + +const ISSUE_SUMMARY_FIELDS = ` + id + identifier + title + url + priority + state { id name type } + assignee { name displayName avatarUrl } + team { id key name } + labels { nodes { id name color } } +`; +const LIST_QUERY = ` + query ListLinearIssues($first: Int!, $after: String, $filter: IssueFilter) { + issues(first: $first, after: $after, filter: $filter, orderBy: updatedAt) { + nodes { ${ISSUE_SUMMARY_FIELDS} } + pageInfo { hasNextPage endCursor } + } + } +`; +const SEARCH_QUERY = ` + query SearchLinearIssues($term: String!, $first: Int!, $after: String, $filter: IssueFilter) { + searchIssues(term: $term, first: $first, after: $after, filter: $filter) { + nodes { ${ISSUE_SUMMARY_FIELDS} } + pageInfo { hasNextPage endCursor } + } + } +`; +const GET_QUERY = ` + query GetLinearIssue($id: String!) { + issue(id: $id) { + ${ISSUE_SUMMARY_FIELDS} + description + comments(first: 50) { + nodes { + id + body + createdAt + user { name displayName avatarUrl } + } + } + } + } +`; +const COMMENT_CREATE = ` + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { id } + } + } +`; +const STATES_QUERY = ` + query TeamWorkflowStates($id: String!) { + team(id: $id) { + states(first: 50) { + nodes { id name type position } + } + } + } +`; +const ISSUE_UPDATE = ` + mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { + ${ISSUE_SUMMARY_FIELDS} + description + comments(first: 50) { + nodes { + id + body + createdAt + user { name displayName avatarUrl } + } + } + } + } + } +`; +const IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const URL_IDENTIFIER_RE = /linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i; + +export function parseLinearIssueRef(value) { + const trimmed = readTrimmedString(value); + if (!trimmed) return null; + const urlMatch = trimmed.match(URL_IDENTIFIER_RE); + if (urlMatch) { + return { kind: 'identifier', value: urlMatch[1].toUpperCase() }; + } + if (IDENTIFIER_RE.test(trimmed)) { + return { kind: 'identifier', value: trimmed.toUpperCase() }; + } + if (UUID_RE.test(trimmed)) { + return { kind: 'id', value: trimmed.toLowerCase() }; + } + return null; +} + +function readState(value) { + if (!isPlainObject(value)) return null; + const id = readTrimmedString(value.id) || null; + const name = readTrimmedString(value.name) || null; + const type = readTrimmedString(value.type) || null; + if (!id && !name && !type) return null; + return { id, name, type }; +} + +const WORKFLOW_TYPE_ORDER = { + triage: 0, + backlog: 1, + unstarted: 2, + started: 3, + completed: 4, + canceled: 5, +}; + +function workflowTypeRank(type) { + if (type === 'triage' || type === 'backlog' || type === 'unstarted' || type === 'started' || type === 'completed' || type === 'canceled') { + return WORKFLOW_TYPE_ORDER[type]; + } + return 99; +} + +function compareWorkflowStates(left, right) { + const typeDelta = workflowTypeRank(left.type) - workflowTypeRank(right.type); + if (typeDelta !== 0) return typeDelta; + if (left.position !== right.position) return left.position - right.position; + return left.name.localeCompare(right.name); +} + +function readWorkflowState(value) { + if (!isPlainObject(value)) return null; + const id = readTrimmedString(value.id); + const name = readTrimmedString(value.name); + if (!id || !name) return null; + const position = readFiniteNumber(value.position); + return { + id, + name, + type: readTrimmedString(value.type) || null, + position: position ?? 0, + }; +} + +function readAssignee(value) { + if (!isPlainObject(value)) return null; + const name = readTrimmedString(value.name) || null; + const displayName = readTrimmedString(value.displayName) || null; + const avatarUrl = readTrimmedString(value.avatarUrl) || null; + if (!name && !displayName && !avatarUrl) return null; + return { name, displayName, avatarUrl }; +} + +function readTeam(value) { + if (!isPlainObject(value)) return null; + const id = readTrimmedString(value.id); + const key = readTrimmedString(value.key); + const name = readTrimmedString(value.name); + if (!id || !key || !name) return null; + return { id, key, name }; +} + +function readPriority(value) { + if (!Number.isInteger(value) || value < 0 || value > 4) return null; + return value; +} + +function readLabelColor(value) { + const raw = readTrimmedString(value); + if (!raw) return null; + const hex = raw.startsWith('#') ? raw.slice(1) : raw; + if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return null; + return `#${hex.toLowerCase()}`; +} + +function readLabel(value) { + if (!isPlainObject(value)) return null; + const id = readTrimmedString(value.id); + const name = readTrimmedString(value.name); + if (!id || !name) return null; + return { + id, + name, + color: readLabelColor(value.color), + }; +} + +function readLabels(value) { + const nodes = isPlainObject(value) && Array.isArray(value.nodes) + ? value.nodes + : Array.isArray(value) + ? value + : []; + return nodes.map(readLabel).filter(Boolean); +} + +function readIssueSummary(node) { + if (!isPlainObject(node)) return null; + const id = readTrimmedString(node.id); + const identifier = readTrimmedString(node.identifier); + const title = readTrimmedString(node.title); + const url = readTrimmedString(node.url); + if (!id || !identifier || !title || !url) return null; + return { + id, + identifier, + title, + url, + state: readState(node.state), + assignee: readAssignee(node.assignee), + team: readTeam(node.team), + priority: readPriority(node.priority), + labels: readLabels(node.labels), + }; +} + +function readComment(node) { + if (!isPlainObject(node)) return null; + const id = readTrimmedString(node.id); + if (!id) return null; + const body = isString(node.body) ? node.body : ''; + const user = isPlainObject(node.user) + ? { + name: readTrimmedString(node.user.name) || null, + displayName: readTrimmedString(node.user.displayName) || null, + avatarUrl: readTrimmedString(node.user.avatarUrl) || null, + } + : null; + return { + id, + body, + createdAt: readTrimmedString(node.createdAt) || null, + user: user && (user.name || user.displayName) ? user : null, + }; +} + +function readIssue(node) { + const summary = readIssueSummary(node); + if (!summary) return null; + const commentsPayload = isPlainObject(node.comments) ? node.comments.nodes : null; + const comments = Array.isArray(commentsPayload) + ? commentsPayload.map(readComment).filter(Boolean) + : []; + return { + ...summary, + description: isString(node.description) ? node.description : null, + comments, + }; +} + +function readPageInfo(connection) { + const pageInfo = isPlainObject(connection) ? connection.pageInfo : null; + if (!isPlainObject(pageInfo)) { + return { hasMore: false, cursor: null }; + } + return { + hasMore: pageInfo.hasNextPage === true, + cursor: readTrimmedString(pageInfo.endCursor) || null, + }; +} + +function readIssueNodes(connection) { + const nodes = isPlainObject(connection) ? connection.nodes : null; + if (!Array.isArray(nodes)) return []; + return nodes.map(readIssueSummary).filter(Boolean); +} + +async function withLinearToken(run, workspaceId) { + try { + const token = await getValidLinearAccessToken(workspaceId); + if (!token) { + return { connected: false }; + } + return await run(token); + } catch (error) { + if (error?.status === 401) { + const failed = workspaceId + ? getLinearAuthByWorkspaceId(workspaceId) + : getLinearAuth(); + clearLinearAuth(failed?.workspaceId || workspaceId); + return { connected: false }; + } + throw error; + } +} + +async function fetchIssueByRef(token, ref) { + const data = await fetchLinearGraphql(token, GET_QUERY, { id: ref.value }); + return readIssue(data.issue); +} + +export async function listLinearIssues({ query, cursor, status, assignee, teamId, priority } = {}) { + return withLinearToken(async (token) => { + const ref = parseLinearIssueRef(query); + if (ref) { + const issue = await fetchIssueByRef(token, ref); + return { + connected: true, + issues: issue ? [issue] : [], + cursor: null, + hasMore: false, + }; + } + + const after = readTrimmedString(cursor) || null; + const term = readTrimmedString(query); + const filter = buildIssueListFilter({ status, assignee, teamId, priority }); + const variables = { + first: PAGE_SIZE, + }; + if (filter) { + variables.filter = filter; + } + if (after) { + variables.after = after; + } + + if (term) { + variables.term = term; + const data = await fetchLinearGraphql(token, SEARCH_QUERY, variables); + const connection = isPlainObject(data.searchIssues) ? data.searchIssues : null; + const page = readPageInfo(connection); + return { + connected: true, + issues: readIssueNodes(connection), + cursor: page.cursor, + hasMore: page.hasMore, + }; + } + + const data = await fetchLinearGraphql(token, LIST_QUERY, variables); + const connection = isPlainObject(data.issues) ? data.issues : null; + const page = readPageInfo(connection); + return { + connected: true, + issues: readIssueNodes(connection), + cursor: page.cursor, + hasMore: page.hasMore, + }; + }); +} + +export async function getLinearIssue(id) { + const ref = parseLinearIssueRef(id) || (readTrimmedString(id) ? { kind: 'id', value: readTrimmedString(id) } : null); + if (!ref) { + return { connected: true, issue: null }; + } + return withLinearToken(async (token) => { + const issue = await fetchIssueByRef(token, ref); + return { connected: true, issue }; + }); +} + +export async function listLinearIssueStates(teamId) { + const id = readTrimmedString(teamId); + if (!id) { + const error = new Error('teamId is required'); + error.code = 'INVALID'; + throw error; + } + return withLinearToken(async (token) => { + const data = await fetchLinearGraphql(token, STATES_QUERY, { id }); + const team = isPlainObject(data.team) ? data.team : null; + const connection = isPlainObject(team) ? team.states : null; + const nodes = isPlainObject(connection) && Array.isArray(connection.nodes) + ? connection.nodes + : []; + const states = nodes + .map(readWorkflowState) + .filter(Boolean) + .sort(compareWorkflowStates); + return { connected: true, states }; + }); +} + +export async function updateLinearIssue({ id, stateId } = {}) { + const issueId = readTrimmedString(id); + const nextStateId = readTrimmedString(stateId); + if (!issueId || !nextStateId) { + const error = new Error('id and stateId are required'); + error.code = 'INVALID'; + throw error; + } + const ref = parseLinearIssueRef(issueId) || { kind: 'id', value: issueId }; + return withLinearToken(async (token) => { + const resolved = ref.kind === 'identifier' + ? await fetchIssueByRef(token, ref) + : null; + const resolvedId = resolved?.id || (ref.kind === 'id' ? ref.value : ''); + if (!resolvedId) { + return { connected: true, issue: null }; + } + const data = await fetchLinearGraphql(token, ISSUE_UPDATE, { + id: resolvedId, + input: { stateId: nextStateId }, + }); + const payload = isPlainObject(data.issueUpdate) ? data.issueUpdate : null; + return { + connected: true, + issue: payload ? readIssue(payload.issue) : null, + }; + }); +} + +export async function createLinearIssueComment({ issueId, body, organizationId } = {}) { + const text = isString(body) ? body : ''; + const ref = parseLinearIssueRef(issueId) + || (readTrimmedString(issueId) ? { kind: 'id', value: readTrimmedString(issueId) } : null); + if (!ref || !text.trim()) { + return { connected: true, comment: null }; + } + return withLinearToken(async (token) => { + const issue = await fetchIssueByRef(token, ref); + if (!issue) { + return { connected: true, comment: null }; + } + const data = await fetchLinearGraphql(token, COMMENT_CREATE, { + input: { issueId: issue.id, body: text }, + }); + const payload = isPlainObject(data.commentCreate) ? data.commentCreate : null; + const comment = isPlainObject(payload?.comment) ? payload.comment : null; + const id = comment ? readTrimmedString(comment.id) : ''; + return { + connected: true, + comment: id ? { id } : null, + }; + }, organizationId); +} diff --git a/packages/web/server/lib/linear/issues.test.js b/packages/web/server/lib/linear/issues.test.js new file mode 100644 index 00000000..bc0a0685 --- /dev/null +++ b/packages/web/server/lib/linear/issues.test.js @@ -0,0 +1,512 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setLinearAuth, clearLinearAuth } from './auth.js'; +import { getLinearIssue, listLinearIssues, listLinearIssueStates, parseLinearIssueRef, createLinearIssueComment, updateLinearIssue } from './issues.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-issues-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const issueNode = { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + priority: 1, + state: { id: 'state-started', name: 'In Progress', type: 'started' }, + assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' }, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + labels: { nodes: [{ id: 'label-bug', name: 'Bug', color: 'EB5757' }] }, +}; + +describe('parseLinearIssueRef', () => { + it('reads identifiers, URLs, and UUIDs', () => { + expect(parseLinearIssueRef('eng-12')).toEqual({ kind: 'identifier', value: 'ENG-12' }); + expect(parseLinearIssueRef('https://linear.app/openchamber/issue/ENG-12/broken-login')) + .toEqual({ kind: 'identifier', value: 'ENG-12' }); + expect(parseLinearIssueRef('11111111-2222-3333-4444-555555555555')) + .toEqual({ kind: 'id', value: '11111111-2222-3333-4444-555555555555' }); + expect(parseLinearIssueRef('login redirect')).toBeNull(); + }); +}); + +describe('Linear issue list/get', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns disconnected without calling Linear when there is no auth', async () => { + clearLinearAuth(); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(listLinearIssues()).resolves.toEqual({ connected: false }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('lists incomplete issues and never returns the token', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query ListLinearIssues'); + expect(body.variables.filter.state.type.nin).toEqual(['completed', 'canceled', 'duplicate']); + expect(options.headers.Authorization).toBe('Bearer access-1'); + expect(options.headers['public-file-urls-expire-in']).toBe('3600'); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }, + }, + }); + })); + + const result = await listLinearIssues(); + expect(result).toEqual({ + connected: true, + issues: [{ + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { id: 'state-started', name: 'In Progress', type: 'started' }, + assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' }, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + priority: 1, + labels: [{ id: 'label-bug', name: 'Bug', color: '#eb5757' }], + }], + cursor: 'cursor-2', + hasMore: true, + }); + expect(JSON.stringify(result)).not.toContain('access-1'); + }); + + it('includes priority and labels and drops invalid values', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ + data: { + issues: { + nodes: [{ + ...issueNode, + priority: 9, + labels: { + nodes: [ + { id: 'label-ok', name: 'Bug', color: '#EB5757' }, + { id: 'label-bad-color', name: 'Nope', color: 'red' }, + { id: '', name: 'Missing id' }, + ], + }, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }))); + + const result = await listLinearIssues(); + expect(result.issues?.[0]?.priority).toBeNull(); + expect(result.issues?.[0]?.labels).toEqual([ + { id: 'label-ok', name: 'Bug', color: '#eb5757' }, + { id: 'label-bad-color', name: 'Nope', color: null }, + ]); + }); + + it('searches by text and looks up an identifier directly', async () => { + const graphql = vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('SearchLinearIssues')) { + expect(body.variables.term).toBe('login'); + return jsonResponse({ + data: { + searchIssues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + } + expect(body.variables.id).toBe('ENG-12'); + return jsonResponse({ + data: { + issue: { + ...issueNode, + description: 'Users cannot sign in.', + comments: { + nodes: [{ + id: 'comment-1', + body: 'Still broken', + createdAt: '2026-08-24T10:00:00.000Z', + user: { name: 'Ada', displayName: 'Ada Lovelace' }, + }], + }, + }, + }, + }); + }); + vi.stubGlobal('fetch', graphql); + + const search = await listLinearIssues({ query: 'login' }); + expect(search.issues).toHaveLength(1); + expect(search.hasMore).toBe(false); + + const byId = await listLinearIssues({ query: 'https://linear.app/openchamber/issue/ENG-12' }); + expect(byId.issues?.[0]?.identifier).toBe('ENG-12'); + expect(byId.hasMore).toBe(false); + }); + + it('applies status, assignee, team, and priority list filters', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.variables.filter).toEqual({ + state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } }, + assignee: { isMe: { eq: true } }, + team: { id: { eq: 'team-eng' } }, + priority: { eq: 1 }, + }); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const result = await listLinearIssues({ + status: 'started', + assignee: 'me', + teamId: 'team-eng', + priority: 'urgent', + }); + expect(result.issues).toHaveLength(1); + }); + + it('filters each panel status to a Linear state type or name', async () => { + const filters = []; + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + filters.push(JSON.parse(options.body).variables.filter); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + await listLinearIssues({ status: 'todo' }); + await listLinearIssues({ status: 'backlog' }); + await listLinearIssues({ status: 'started' }); + await listLinearIssues({ status: 'inReview' }); + await listLinearIssues({ status: 'completed' }); + await listLinearIssues({ status: 'canceled' }); + await listLinearIssues({ status: 'duplicate' }); + expect(filters).toEqual([ + { state: { type: { eq: 'unstarted' } } }, + { state: { type: { eq: 'backlog' } } }, + { state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } } }, + { state: { name: { eqIgnoreCase: 'In Review' } } }, + { state: { type: { eq: 'completed' } } }, + { state: { type: { eq: 'canceled' }, name: { neqIgnoreCase: 'Duplicate' } } }, + { state: { or: [{ type: { eq: 'duplicate' } }, { name: { eqIgnoreCase: 'Duplicate' } }] } }, + ]); + }); + + it('omits the state filter when listing all issues', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.variables.filter).toBeUndefined(); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + await listLinearIssues({ status: 'all' }); + }); + + it('filters no-priority issues as Linear priority 0', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.variables.filter).toEqual({ + priority: { eq: 0 }, + }); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + await listLinearIssues({ status: 'all', priority: 'none' }); + }); + + it('looks up an identifier without applying list filters', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('GetLinearIssue'); + expect(body.variables.id).toBe('ENG-12'); + expect(body.variables.filter).toBeUndefined(); + return jsonResponse({ data: { issue: issueNode } }); + })); + + const result = await listLinearIssues({ + query: 'ENG-12', + status: 'completed', + assignee: 'me', + teamId: 'team-eng', + priority: 'urgent', + }); + expect(result.issues?.[0]?.identifier).toBe('ENG-12'); + }); + + it('loads one issue with comments', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ + data: { + issue: { + ...issueNode, + description: 'Users cannot sign in.', + comments: { nodes: [{ id: 'comment-1', body: 'Still broken', createdAt: '2026-08-24T10:00:00.000Z', user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' } }] }, + }, + }, + }))); + + const result = await getLinearIssue('ENG-12'); + expect(result.connected).toBe(true); + expect(result.issue?.description).toBe('Users cannot sign in.'); + expect(result.issue?.priority).toBe(1); + expect(result.issue?.labels).toEqual([{ id: 'label-bug', name: 'Bug', color: '#eb5757' }]); + expect(result.issue?.comments).toEqual([{ + id: 'comment-1', + body: 'Still broken', + createdAt: '2026-08-24T10:00:00.000Z', + user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' }, + }]); + }); + + it('creates a comment on the resolved issue UUID', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + expect(body.variables.id).toBe('ENG-12'); + return jsonResponse({ + data: { + issue: { + ...issueNode, + description: null, + comments: { nodes: [] }, + }, + }, + }); + } + expect(body.query).toContain('mutation CommentCreate'); + expect(body.variables.input).toEqual({ + issueId: 'issue-uuid-1', + body: 'OpenChamber session started.', + }); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: 'comment-9' }, + }, + }, + }); + })); + + const result = await createLinearIssueComment({ + issueId: 'ENG-12', + body: 'OpenChamber session started.', + }); + expect(result).toEqual({ connected: true, comment: { id: 'comment-9' } }); + expect(JSON.stringify(result)).not.toContain('access-1'); + }); + + it('clears auth and reports disconnected after a GraphQL 401', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401))); + await expect(listLinearIssues()).resolves.toEqual({ connected: false }); + await expect(listLinearIssues()).resolves.toEqual({ connected: false }); + }); + + it('lists team workflow states in Linear workflow order', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query TeamWorkflowStates'); + expect(body.variables.id).toBe('team-eng'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + team: { + states: { + nodes: [ + { id: 'state-done', name: 'Done', type: 'completed', position: 0 }, + { id: 'state-review', name: 'In Review', type: 'started', position: 1 }, + { id: 'state-todo', name: 'Todo', type: 'unstarted', position: 0 }, + { id: 'state-dup', name: 'Duplicate', type: 'canceled', position: 1 }, + { id: 'state-progress', name: 'In Progress', type: 'started', position: 0 }, + { id: 'state-backlog', name: 'Backlog', type: 'backlog', position: 0 }, + { id: 'state-canceled', name: 'Canceled', type: 'canceled', position: 0 }, + ], + }, + }, + }, + }); + })); + + const result = await listLinearIssueStates('team-eng'); + expect(result.states?.map((state) => state.name)).toEqual([ + 'Backlog', + 'Todo', + 'In Progress', + 'In Review', + 'Done', + 'Canceled', + 'Duplicate', + ]); + }); + + it('rejects workflow states without a team id', async () => { + await expect(listLinearIssueStates('')).rejects.toMatchObject({ + message: 'teamId is required', + code: 'INVALID', + }); + }); + + it('updates an issue state and returns the issue', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('mutation IssueUpdate'); + expect(body.variables).toEqual({ + id: 'issue-uuid-1', + input: { stateId: 'state-done' }, + }); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + issueUpdate: { + success: true, + issue: { + ...issueNode, + state: { id: 'state-done', name: 'Done', type: 'completed' }, + description: null, + comments: { nodes: [] }, + }, + }, + }, + }); + })); + + const result = await updateLinearIssue({ id: 'issue-uuid-1', stateId: 'state-done' }); + expect(result.connected).toBe(true); + expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' }); + expect(JSON.stringify(result)).not.toContain('access-1'); + }); + + it('rejects an issue update without id or stateId', async () => { + await expect(updateLinearIssue({ id: 'issue-uuid-1' })).rejects.toMatchObject({ + message: 'id and stateId are required', + code: 'INVALID', + }); + }); + + it('resolves an issue identifier before issueUpdate', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + expect(body.variables.id).toBe('ENG-12'); + return jsonResponse({ + data: { + issue: { + ...issueNode, + description: null, + comments: { nodes: [] }, + }, + }, + }); + } + expect(body.query).toContain('mutation IssueUpdate'); + expect(body.variables).toEqual({ + id: 'issue-uuid-1', + input: { stateId: 'state-done' }, + }); + return jsonResponse({ + data: { + issueUpdate: { + success: true, + issue: { + ...issueNode, + state: { id: 'state-done', name: 'Done', type: 'completed' }, + description: null, + comments: { nodes: [] }, + }, + }, + }, + }); + })); + + const result = await updateLinearIssue({ id: 'ENG-12', stateId: 'state-done' }); + expect(result.connected).toBe(true); + expect(result.issue?.id).toBe('issue-uuid-1'); + expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' }); + }); + + it('surfaces Linear validation constraints from GraphQL errors', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ + data: null, + errors: [{ + message: 'Argument Validation Error', + extensions: { + code: 'INVALID_INPUT', + userError: true, + userPresentableMessage: 'stateId must be a UUID.', + validationErrors: [{ + property: 'stateId', + constraints: { isUuid: 'stateId must be a UUID.' }, + }], + }, + }], + }))); + + await expect(updateLinearIssue({ id: 'issue-uuid-1', stateId: 'not-a-uuid' })).rejects.toMatchObject({ + name: 'LinearApiError', + message: 'stateId must be a UUID.', + status: 400, + userError: true, + }); + }); +}); diff --git a/packages/web/server/lib/linear/mapping.js b/packages/web/server/lib/linear/mapping.js new file mode 100644 index 00000000..0182cc15 --- /dev/null +++ b/packages/web/server/lib/linear/mapping.js @@ -0,0 +1,188 @@ +import fs from 'fs'; +import path from 'path'; +import { getLinearAuth, getLinearAuthFilePath } from './auth.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +export class LinearMappingError extends Error { + constructor(message, code) { + super(message); + this.name = 'LinearMappingError'; + this.code = code; + } +} + +function mappingFile() { + return path.join(path.dirname(getLinearAuthFilePath()), 'linear-mapping.json'); +} + +const UNSCOPED_MAPPING_KEY = '__unscoped__'; + +function mappingOrgKey() { + const auth = getLinearAuth(); + return readTrimmedString(auth?.workspaceId) || UNSCOPED_MAPPING_KEY; +} + +function emptyMapping() { + return { + defaultProjectPath: null, + teamProjectPaths: {}, + }; +} + +function readTeamProjectPaths(value) { + if (!isPlainObject(value)) { + return {}; + } + const next = {}; + for (const key of Object.keys(value)) { + const teamId = readTrimmedString(key); + const projectPath = readTrimmedString(value[key]); + if (teamId && projectPath) { + next[teamId] = projectPath; + } + } + return next; +} + +function normalizeMappingSlice(raw) { + if (!isPlainObject(raw)) { + return emptyMapping(); + } + return { + defaultProjectPath: readTrimmedString(raw.defaultProjectPath) || null, + teamProjectPaths: readTeamProjectPaths(raw.teamProjectPaths), + }; +} + +function readMappingDocument(raw) { + if (!isPlainObject(raw)) { + return { workspaces: {} }; + } + if (isPlainObject(raw.workspaces)) { + const workspaces = {}; + for (const key of Object.keys(raw.workspaces)) { + const orgKey = readTrimmedString(key); + if (!orgKey) continue; + workspaces[orgKey] = normalizeMappingSlice(raw.workspaces[key]); + } + return { workspaces }; + } + return { + workspaces: { + [mappingOrgKey()]: normalizeMappingSlice(raw), + }, + }; +} + +function writeJsonFile(filePath, payload) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8'); + try { + fs.chmodSync(tmpFile, 0o600); + } catch { + // best-effort + } + fs.renameSync(tmpFile, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } +} + +export function getLinearMappingFilePath() { + return mappingFile(); +} + +export function readStoredLinearMapping() { + const filePath = mappingFile(); + if (!fs.existsSync(filePath)) { + return emptyMapping(); + } + let parsed; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (!trimmed) { + return emptyMapping(); + } + parsed = JSON.parse(trimmed); + } catch { + throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED'); + } + if (!isPlainObject(parsed)) { + throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED'); + } + const document = readMappingDocument(parsed); + return document.workspaces[mappingOrgKey()] || emptyMapping(); +} + +export function setStoredLinearMapping(input) { + if (!isPlainObject(input)) { + throw new LinearMappingError('Mapping body must be an object', 'INVALID'); + } + const filePath = mappingFile(); + let document = { workspaces: {} }; + if (fs.existsSync(filePath)) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (trimmed) { + const parsed = JSON.parse(trimmed); + if (!isPlainObject(parsed)) { + throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED'); + } + document = readMappingDocument(parsed); + } + } catch (error) { + if (error instanceof LinearMappingError) { + throw error; + } + throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED'); + } + } + const next = { + defaultProjectPath: readTrimmedString(input.defaultProjectPath) || null, + teamProjectPaths: readTeamProjectPaths(input.teamProjectPaths), + }; + document.workspaces[mappingOrgKey()] = next; + writeJsonFile(filePath, document); + return next; +} + +export function mergeLinearMappingView(stored, teams) { + const mapping = stored || emptyMapping(); + const nodes = Array.isArray(teams) ? teams : []; + return { + defaultProjectPath: mapping.defaultProjectPath, + teams: nodes.map((team) => ({ + id: team.id, + key: team.key, + name: team.name, + projectPath: mapping.teamProjectPaths[team.id] || null, + })), + }; +} + +export function resolveMappedProjectPath(view, team) { + const teams = Array.isArray(view?.teams) ? view.teams : []; + const teamId = team ? readTrimmedString(team.id) : ''; + if (teamId) { + const row = teams.find((entry) => entry.id === teamId); + if (row?.projectPath) { + return row.projectPath; + } + } + const teamKey = team ? readTrimmedString(team.key) : ''; + if (teamKey) { + const row = teams.find((entry) => entry.key === teamKey); + if (row?.projectPath) { + return row.projectPath; + } + } + return view?.defaultProjectPath || null; +} diff --git a/packages/web/server/lib/linear/mapping.test.js b/packages/web/server/lib/linear/mapping.test.js new file mode 100644 index 00000000..b0b56a4a --- /dev/null +++ b/packages/web/server/lib/linear/mapping.test.js @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { activateLinearAuth, getLinearAuth, setLinearAuth } from './auth.js'; +import { + getLinearMappingFilePath, + mergeLinearMappingView, + readStoredLinearMapping, + resolveMappedProjectPath, + setStoredLinearMapping, +} from './mapping.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-mapping-')); + +describe('Linear project mapping storage', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + }); + + afterEach(() => { + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('treats a missing file as empty mapping, not a failure', () => { + expect(fs.existsSync(getLinearMappingFilePath())).toBe(false); + expect(readStoredLinearMapping()).toEqual({ + defaultProjectPath: null, + teamProjectPaths: {}, + }); + }); + + it('round-trips a default project and per-team paths', () => { + const written = setStoredLinearMapping({ + defaultProjectPath: '/Users/ada/openchamber', + teamProjectPaths: { + 'team-eng': '/Users/ada/eng', + 'team-empty': ' ', + }, + }); + expect(written).toEqual({ + defaultProjectPath: '/Users/ada/openchamber', + teamProjectPaths: { 'team-eng': '/Users/ada/eng' }, + }); + expect(readStoredLinearMapping()).toEqual(written); + expect(fs.statSync(getLinearMappingFilePath()).mode & 0o777).toBe(0o600); + }); + + it('replaces the previous mapping on write', () => { + setStoredLinearMapping({ + defaultProjectPath: '/old', + teamProjectPaths: { 'team-eng': '/eng' }, + }); + const next = setStoredLinearMapping({ + defaultProjectPath: null, + teamProjectPaths: {}, + }); + expect(next).toEqual({ defaultProjectPath: null, teamProjectPaths: {} }); + expect(readStoredLinearMapping()).toEqual(next); + }); + + it('keeps tokens when a mapping write is rejected', () => { + setLinearAuth({ + accessToken: 'access-keep', + refreshToken: 'refresh-keep', + expiresAt: Date.now() + 60_000, + }); + setStoredLinearMapping({ + defaultProjectPath: '/keep', + teamProjectPaths: { 'team-eng': '/eng' }, + }); + expect(() => setStoredLinearMapping(null)).toThrow(/object/); + expect(readStoredLinearMapping()).toEqual({ + defaultProjectPath: '/keep', + teamProjectPaths: { 'team-eng': '/eng' }, + }); + expect(getLinearAuth().accessToken).toBe('access-keep'); + }); + + it('rejects a malformed mapping file instead of treating it as empty', () => { + fs.writeFileSync(getLinearMappingFilePath(), '{not-json', 'utf8'); + expect(() => readStoredLinearMapping()).toThrow(/malformed/); + }); + + it('merges live teams onto stored paths and resolves team then default', () => { + const stored = { + defaultProjectPath: '/default', + teamProjectPaths: { 'team-eng': '/eng' }, + }; + const view = mergeLinearMappingView(stored, [ + { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + { id: 'team-des', key: 'DES', name: 'Design' }, + ]); + expect(view).toEqual({ + defaultProjectPath: '/default', + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/eng' }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], + }); + expect(resolveMappedProjectPath(view, { id: 'team-eng', key: 'ENG' })).toBe('/eng'); + expect(resolveMappedProjectPath(view, { id: 'team-des', key: 'DES' })).toBe('/default'); + expect(resolveMappedProjectPath(view, null)).toBe('/default'); + }); + + it('keeps mapping slices isolated per workspace', () => { + setLinearAuth({ + accessToken: 'access-a', + user: { id: 'user-a', name: 'Ada' }, + organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' }, + }); + setStoredLinearMapping({ + defaultProjectPath: '/alpha', + teamProjectPaths: { 'team-a': '/alpha-eng' }, + }); + + setLinearAuth({ + accessToken: 'access-b', + user: { id: 'user-b', name: 'Ben' }, + organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' }, + }); + setStoredLinearMapping({ + defaultProjectPath: '/beta', + teamProjectPaths: {}, + }); + expect(readStoredLinearMapping()).toEqual({ + defaultProjectPath: '/beta', + teamProjectPaths: {}, + }); + + expect(activateLinearAuth('org-a')).toBe(true); + expect(readStoredLinearMapping()).toEqual({ + defaultProjectPath: '/alpha', + teamProjectPaths: { 'team-a': '/alpha-eng' }, + }); + }); +}); diff --git a/packages/web/server/lib/linear/oauth.js b/packages/web/server/lib/linear/oauth.js new file mode 100644 index 00000000..c25a21de --- /dev/null +++ b/packages/web/server/lib/linear/oauth.js @@ -0,0 +1,345 @@ +import crypto from 'crypto'; +import { + getLinearClientId, + getLinearClientSecret, + getLinearBrokerUrl, + getLinearRedirectUri, + getLinearScopes, +} from './auth.js'; +import { isPlainObject, isString, readFiniteNumber, readTrimmedString } from './parse.js'; + +export const LINEAR_AUTHORIZE_URL = 'https://linear.app/oauth/authorize'; +export const LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token'; +export const LINEAR_REVOKE_URL = 'https://api.linear.app/oauth/revoke'; +export const PENDING_AUTHORIZATION_TTL_MS = 10 * 60_000; + +const pendingByState = new Map(); +const brokerPollsByState = new Map(); + +export class LinearOAuthError extends Error { + constructor(message, code = 'LINEAR_OAUTH_FAILED') { + super(message); + this.name = 'LinearOAuthError'; + this.code = code; + } +} + +export function createPkcePair() { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +function pruneExpiredPending(now = Date.now()) { + for (const [state, entry] of pendingByState.entries()) { + if (!entry || entry.expiresAt <= now) { + pendingByState.delete(state); + } + } +} + +function normalizeScope(scope) { + if (isString(scope)) { + return scope.trim(); + } + if (Array.isArray(scope)) { + return scope.filter((item) => isString(item) && item.trim()).join(','); + } + return ''; +} + +function readExpiresAt(expiresIn, now = Date.now()) { + const seconds = readFiniteNumber(expiresIn); + if (seconds == null || seconds <= 0) { + return now + 24 * 60 * 60 * 1000; + } + return now + Math.floor(seconds) * 1000; +} + +function parseTokenPayload(payload) { + if (!isPlainObject(payload)) { + throw new LinearOAuthError('Linear token response was empty'); + } + if (readTrimmedString(payload.error)) { + throw new LinearOAuthError( + readTrimmedString(payload.error_description) || readTrimmedString(payload.error), + readTrimmedString(payload.error).toUpperCase(), + ); + } + const accessToken = readTrimmedString(payload.access_token); + if (!accessToken) { + throw new LinearOAuthError('Linear token response was missing access_token'); + } + return { + accessToken, + refreshToken: readTrimmedString(payload.refresh_token) || null, + tokenType: readTrimmedString(payload.token_type) || 'bearer', + expiresAt: readExpiresAt(payload.expires_in), + scope: normalizeScope(payload.scope), + }; +} + +async function postForm(url, body) { + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(body).toString(), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + const description = isPlainObject(payload) + ? (readTrimmedString(payload.error_description) || readTrimmedString(payload.error)) + : ''; + const error = new LinearOAuthError( + description || `Linear token request failed (${response.status})`, + readTrimmedString(payload?.error).toUpperCase() || 'LINEAR_OAUTH_FAILED', + ); + error.status = response.status; + throw error; + } + return parseTokenPayload(payload); +} + +async function readJsonResponse(response, fallbackMessage) { + const payload = await response.json().catch(() => null); + if (!response.ok) { + const message = isPlainObject(payload) && readTrimmedString(payload.error) + ? readTrimmedString(payload.error) + : `${fallbackMessage} (${response.status})`; + const error = new LinearOAuthError(message, 'LINEAR_BROKER_FAILED'); + error.status = response.status; + throw error; + } + if (!isPlainObject(payload)) { + throw new LinearOAuthError(`${fallbackMessage}: invalid response`, 'LINEAR_BROKER_FAILED'); + } + return payload; +} + +function brokerCallbackUrl(brokerUrl) { + return `${brokerUrl.replace(/\/+$/, '')}/callback`; +} + +async function registerBrokerTransaction({ brokerUrl, state, claimSecret }) { + const response = await fetch(`${brokerUrl}/start`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ state, claimSecret }), + }); + const payload = await readJsonResponse(response, 'Could not start Linear authorization broker'); + const redirectUri = readTrimmedString(payload.redirectUri); + if (!redirectUri || redirectUri !== brokerCallbackUrl(brokerUrl)) { + throw new LinearOAuthError('Linear authorization broker returned an unexpected callback URL', 'LINEAR_BROKER_FAILED'); + } + return redirectUri; +} + +export async function startAuthorization({ origin } = {}) { + const clientId = getLinearClientId(); + if (!clientId) { + throw new LinearOAuthError( + 'Linear OAuth client not configured. Set OPENCHAMBER_LINEAR_CLIENT_ID.', + 'LINEAR_CLIENT_ID_MISSING', + ); + } + + pruneExpiredPending(); + const { verifier, challenge } = createPkcePair(); + const state = crypto.randomBytes(32).toString('base64url'); + const brokerUrl = getLinearBrokerUrl(); + const configuredRedirectUri = getLinearRedirectUri(); + const usesBroker = configuredRedirectUri === brokerCallbackUrl(brokerUrl); + const claimSecret = usesBroker ? crypto.randomBytes(32).toString('base64url') : null; + const redirectUri = usesBroker + ? await registerBrokerTransaction({ brokerUrl, state, claimSecret }) + : configuredRedirectUri; + const scope = getLinearScopes(); + pendingByState.set(state, { + codeVerifier: verifier, + redirectUri, + origin: origin === 'desktop' ? 'desktop' : 'web', + broker: usesBroker ? { url: brokerUrl, claimSecret } : null, + expiresAt: Date.now() + PENDING_AUTHORIZATION_TTL_MS, + }); + + const url = new URL(LINEAR_AUTHORIZE_URL); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('scope', scope); + url.searchParams.set('state', state); + url.searchParams.set('code_challenge', challenge); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('actor', 'user'); + url.searchParams.set('prompt', 'consent'); + + return { + authorizationUrl: url.toString(), + expiresIn: Math.floor(PENDING_AUTHORIZATION_TTL_MS / 1000), + scope, + }; +} + +async function pollBrokerState(state, pending) { + const response = await fetch(`${pending.broker.url}/poll`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ state, claimSecret: pending.broker.claimSecret }), + }); + if (response.status === 202) { + return null; + } + const payload = await readJsonResponse(response, 'Could not read Linear authorization result'); + const status = readTrimmedString(payload.status); + if (status === 'complete') { + const result = await consumeAuthorizationCallback({ code: payload.code, state }); + return { + ...result, + brokerReceipt: { state, ...pending.broker }, + }; + } + if (status === 'failed') { + return consumeAuthorizationCallback({ + state, + error: payload.error, + errorDescription: payload.errorDescription, + }); + } + throw new LinearOAuthError('Linear authorization broker returned an unexpected result', 'LINEAR_BROKER_FAILED'); +} + +export async function completeAuthorizationBroker(receipt) { + if (!receipt?.url || !receipt?.state || !receipt?.claimSecret) return false; + const response = await fetch(`${receipt.url}/complete`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: receipt.state, claimSecret: receipt.claimSecret }), + }); + if (!response.ok) { + throw new LinearOAuthError(`Could not acknowledge Linear authorization result (${response.status})`, 'LINEAR_BROKER_FAILED'); + } + return true; +} + +export async function pollAuthorizationBroker() { + pruneExpiredPending(); + for (const [state, pending] of pendingByState.entries()) { + if (!pending?.broker) continue; + let poll = brokerPollsByState.get(state); + if (!poll) { + poll = pollBrokerState(state, pending).finally(() => brokerPollsByState.delete(state)); + brokerPollsByState.set(state, poll); + } + const result = await poll; + if (result) return result; + } + return null; +} + +function failAuthorization(message, code, origin) { + const error = new LinearOAuthError(message, code); + if (origin) { + error.origin = origin; + } + return error; +} + +export async function consumeAuthorizationCallback({ code, state, error, errorDescription }) { + pruneExpiredPending(); + const pending = readTrimmedString(state) ? pendingByState.get(state) : null; + + if (readTrimmedString(error)) { + if (readTrimmedString(state)) pendingByState.delete(state); + throw failAuthorization( + readTrimmedString(errorDescription) || readTrimmedString(error), + readTrimmedString(error).toUpperCase(), + pending?.origin, + ); + } + if (!readTrimmedString(code)) { + if (readTrimmedString(state)) pendingByState.delete(state); + throw failAuthorization( + 'Linear did not return an authorization code.', + 'MISSING_CODE', + pending?.origin, + ); + } + if (!pending?.codeVerifier) { + throw failAuthorization( + 'This authorization session has expired or is unknown to the running app. Return to OpenChamber and click Connect again.', + 'UNKNOWN_STATE', + ); + } + + const body = { + grant_type: 'authorization_code', + code: code.trim(), + redirect_uri: pending.redirectUri, + client_id: getLinearClientId(), + code_verifier: pending.codeVerifier, + }; + const clientSecret = getLinearClientSecret(); + if (clientSecret) { + body.client_secret = clientSecret; + } + + try { + const tokens = await postForm(LINEAR_TOKEN_URL, body); + pendingByState.delete(state); + return { + ...tokens, + origin: pending.origin, + }; + } catch (caught) { + if (caught instanceof Error) { + caught.origin = pending.origin; + } + throw caught; + } +} + +export async function refreshAccessToken(refreshToken) { + const token = readTrimmedString(refreshToken); + if (!token) { + throw new LinearOAuthError('refresh_token is required', 'MISSING_REFRESH_TOKEN'); + } + const body = { + grant_type: 'refresh_token', + refresh_token: token, + client_id: getLinearClientId(), + }; + const clientSecret = getLinearClientSecret(); + if (clientSecret) { + body.client_secret = clientSecret; + } + return postForm(LINEAR_TOKEN_URL, body); +} + +export async function revokeToken(token, tokenTypeHint) { + const value = readTrimmedString(token); + if (!value) { + return false; + } + const body = { token: value }; + if (tokenTypeHint === 'access_token' || tokenTypeHint === 'refresh_token') { + body.token_type_hint = tokenTypeHint; + } + try { + const response = await fetch(LINEAR_REVOKE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body).toString(), + }); + return response.status === 200; + } catch { + return false; + } +} + +export function clearPendingAuthorizationsForTests() { + pendingByState.clear(); + brokerPollsByState.clear(); +} diff --git a/packages/web/server/lib/linear/oauth.test.js b/packages/web/server/lib/linear/oauth.test.js new file mode 100644 index 00000000..3d3e18ef --- /dev/null +++ b/packages/web/server/lib/linear/oauth.test.js @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + startAuthorization, + consumeAuthorizationCallback, + pollAuthorizationBroker, + completeAuthorizationBroker, + refreshAccessToken, + clearPendingAuthorizationsForTests, +} from './oauth.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-oauth-')); + +describe('Linear OAuth PKCE', () => { + let dataDir; + let previousDataDir; + let previousPort; + let previousRedirect; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID; + process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback'; + clearPendingAuthorizationsForTests(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearPendingAuthorizationsForTests(); + restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir); + restoreEnv('OPENCHAMBER_PORT', previousPort); + restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('creates an S256 authorize URL and stores a pending verifier', async () => { + const started = await startAuthorization({ origin: 'desktop' }); + const url = new URL(started.authorizationUrl); + expect(url.origin + url.pathname).toBe('https://linear.app/oauth/authorize'); + expect(url.searchParams.get('client_id')).toBe('91bbe26a69a2c8568d3683f1e01e776c'); + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:3001/linear/oauth/callback'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(url.searchParams.get('actor')).toBe('user'); + expect(url.searchParams.get('prompt')).toBe('consent'); + expect(started.scope).toBe('read,write,comments:create'); + expect(started.expiresIn).toBe(600); + }); + + it('refuses a callback whose state was never started', async () => { + const tokenFetch = vi.fn(); + vi.stubGlobal('fetch', tokenFetch); + await expect(consumeAuthorizationCallback({ + code: 'attacker-code', + state: 'forged', + })).rejects.toMatchObject({ code: 'UNKNOWN_STATE' }); + expect(tokenFetch).not.toHaveBeenCalled(); + }); + + it('exchanges a matching code with the original PKCE verifier', async () => { + const started = await startAuthorization({ origin: 'web' }); + const state = new URL(started.authorizationUrl).searchParams.get('state'); + const tokenFetch = vi.fn(async () => new Response(JSON.stringify({ + access_token: 'access-1', + refresh_token: 'refresh-1', + token_type: 'Bearer', + expires_in: 86399, + scope: 'read,write,comments:create', + }), { status: 200 })); + vi.stubGlobal('fetch', tokenFetch); + + const result = await consumeAuthorizationCallback({ code: 'auth-code', state }); + expect(result.accessToken).toBe('access-1'); + expect(result.refreshToken).toBe('refresh-1'); + expect(result.origin).toBe('web'); + + expect(tokenFetch).toHaveBeenCalledTimes(1); + const [url, init] = tokenFetch.mock.calls[0]; + expect(String(url)).toBe('https://api.linear.app/oauth/token'); + expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + const body = new URLSearchParams(init.body); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('auth-code'); + expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(body.get('client_secret')).toBeNull(); + + await expect(consumeAuthorizationCallback({ code: 'auth-code', state })).rejects.toMatchObject({ + code: 'UNKNOWN_STATE', + }); + }); + + it('persists a rotated refresh token from Linear', async () => { + const tokenFetch = vi.fn(async () => new Response(JSON.stringify({ + access_token: 'access-2', + refresh_token: 'refresh-2', + token_type: 'Bearer', + expires_in: 86399, + }), { status: 200 })); + vi.stubGlobal('fetch', tokenFetch); + const tokens = await refreshAccessToken('refresh-1'); + expect(tokens.accessToken).toBe('access-2'); + expect(tokens.refreshToken).toBe('refresh-2'); + const body = new URLSearchParams(tokenFetch.mock.calls[0][1].body); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh-1'); + }); + + it('claims a broker callback and exchanges it locally with PKCE', async () => { + delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + const brokerAndTokenFetch = vi.fn(async (url, init) => { + const target = String(url); + if (target.endsWith('/start')) { + const body = JSON.parse(init.body); + expect(body.state).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(body.claimSecret).toMatch(/^[A-Za-z0-9_-]{43}$/); + return new Response(JSON.stringify({ + redirectUri: 'https://api.openchamber.dev/v1/oauth/linear/callback', + expiresIn: 600, + }), { status: 200 }); + } + if (target.endsWith('/poll')) { + return new Response(JSON.stringify({ status: 'complete', code: 'broker-code' }), { status: 200 }); + } + if (target.endsWith('/complete')) { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (target === 'https://api.linear.app/oauth/token') { + const body = new URLSearchParams(init.body); + expect(body.get('code')).toBe('broker-code'); + expect(body.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback'); + expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/); + return new Response(JSON.stringify({ + access_token: 'broker-access', + refresh_token: 'broker-refresh', + expires_in: 86399, + }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + }); + vi.stubGlobal('fetch', brokerAndTokenFetch); + + const started = await startAuthorization({ origin: 'desktop' }); + const authorizationUrl = new URL(started.authorizationUrl); + expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback'); + + const result = await pollAuthorizationBroker(); + expect(result).toMatchObject({ accessToken: 'broker-access', origin: 'desktop' }); + await expect(completeAuthorizationBroker(result.brokerReceipt)).resolves.toBe(true); + expect(brokerAndTokenFetch).toHaveBeenCalledTimes(4); + }); +}); + +function restoreEnv(name, previous) { + if (previous === undefined) { + delete process.env[name]; + return; + } + process.env[name] = previous; +} diff --git a/packages/web/server/lib/linear/parse.js b/packages/web/server/lib/linear/parse.js new file mode 100644 index 00000000..4182b2ef --- /dev/null +++ b/packages/web/server/lib/linear/parse.js @@ -0,0 +1,23 @@ +export function isString(value) { + return Object.prototype.toString.call(value) === '[object String]'; +} + +export function isPlainObject(value) { + if (value == null || Array.isArray(value)) { + return false; + } + return Object.getPrototypeOf(value) === Object.prototype; +} + +export function readTrimmedString(value) { + return isString(value) && value.trim() ? value.trim() : ''; +} + +export function readFiniteNumber(value) { + return Number.isFinite(value) ? value : null; +} + +export function readEnv(name) { + const raw = process.env[name]; + return raw ? raw.trim() : ''; +} diff --git a/packages/web/server/lib/linear/routes.js b/packages/web/server/lib/linear/routes.js new file mode 100644 index 00000000..ada59486 --- /dev/null +++ b/packages/web/server/lib/linear/routes.js @@ -0,0 +1,432 @@ +import express from 'express'; +import { readTrimmedString } from './parse.js'; + +const PENDING_JSON_LIMIT = '16kb'; +const parseJsonBody = express.json({ limit: PENDING_JSON_LIMIT }); + +function queryValue(req, key) { + const raw = req.query?.[key]; + const value = Array.isArray(raw) ? raw[0] : raw; + return readTrimmedString(value); +} + +function isLinearUserError(error) { + return error?.code === 'INVALID' || error?.userError === true; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderLinearOAuthCallbackPage({ title, message, desktopReturn }) { + return ` + + + + +${escapeHtml(title)} — OpenChamber + + + +
+

${escapeHtml(title)}

+

${escapeHtml(message)}

+${desktopReturn ? `
Return to OpenChamber +` : ''} +
+ +`; +} + +async function storeAuthorizationResult(libraries, result) { + const { setLinearAuth, fetchLinearIdentity } = libraries; + let user = null; + let organization = null; + try { + const identity = await fetchLinearIdentity(result.accessToken); + user = identity.user; + organization = identity.organization; + } catch (error) { + console.error('Failed to load Linear identity after OAuth:', error); + } + return setLinearAuth({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + tokenType: result.tokenType, + expiresAt: result.expiresAt, + scope: result.scope, + user, + organization, + }); +} + +export function registerLinearRoutes(app) { + let linearLibraries = null; + const getLinearLibraries = async () => { + if (!linearLibraries) { + linearLibraries = await import('./index.js'); + } + return linearLibraries; + }; + + app.get('/linear/oauth/callback', async (req, res) => { + const finish = (status, { title, message, desktopReturn = false }) => { + res.status(status).type('html').send(renderLinearOAuthCallbackPage({ title, message, desktopReturn })); + }; + + try { + const libraries = await getLinearLibraries(); + const { consumeAuthorizationCallback } = libraries; + const result = await consumeAuthorizationCallback({ + code: queryValue(req, 'code'), + state: queryValue(req, 'state'), + error: queryValue(req, 'error'), + errorDescription: queryValue(req, 'error_description'), + }); + + await storeAuthorizationResult(libraries, result); + + return finish(200, { + title: 'Authorization Complete', + message: 'You can close this tab and return to OpenChamber.', + desktopReturn: result.origin === 'desktop', + }); + } catch (error) { + const code = error instanceof Error ? error.code : ''; + const status = code === 'UNKNOWN_STATE' || code === 'MISSING_CODE' || code === 'ACCESS_DENIED' + ? 400 + : 502; + return finish(status, { + title: 'Authorization Failed', + message: error instanceof Error ? error.message : 'Linear authorization failed. Return to OpenChamber and click Connect again.', + desktopReturn: error?.origin === 'desktop', + }); + } + }); + + app.get('/api/linear/auth/status', async (_req, res) => { + try { + const libraries = await getLinearLibraries(); + const { + getLinearAuth, + getLinearAuthWorkspaces, + getValidLinearAccessToken, + fetchLinearIdentity, + setLinearAuth, + clearLinearAuth, + toLinearPublicStatus, + pollAuthorizationBroker, + completeAuthorizationBroker, + } = libraries; + + try { + const result = await pollAuthorizationBroker(); + if (result) { + await storeAuthorizationResult(libraries, result); + await completeAuthorizationBroker(result.brokerReceipt).catch((error) => { + console.warn('Failed to acknowledge Linear authorization broker result:', error); + }); + } + } catch (error) { + console.error('Failed to complete Linear authorization through broker:', error); + } + + const accessToken = await getValidLinearAccessToken(); + if (!accessToken) { + return res.json({ connected: false }); + } + + const auth = getLinearAuth(); + try { + const identity = await fetchLinearIdentity(accessToken); + const next = setLinearAuth({ + accessToken, + refreshToken: auth?.refreshToken, + tokenType: auth?.tokenType, + expiresAt: auth?.expiresAt, + scope: auth?.scope, + user: identity.user, + organization: identity.organization, + workspaceId: auth?.workspaceId, + }, { activate: false }); + return res.json(toLinearPublicStatus(next, getLinearAuthWorkspaces())); + } catch (error) { + if (error?.status === 401) { + clearLinearAuth(auth?.workspaceId); + const remaining = getLinearAuth(); + if (!remaining) { + return res.json({ connected: false }); + } + return res.json(toLinearPublicStatus(remaining, getLinearAuthWorkspaces())); + } + if (auth) { + return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces())); + } + throw error; + } + } catch (error) { + console.error('Failed to get Linear auth status:', error); + return res.status(500).json({ error: error.message || 'Failed to get Linear auth status' }); + } + }); + + app.post('/api/linear/auth/start', parseJsonBody, async (req, res) => { + try { + const { startAuthorization } = await getLinearLibraries(); + const origin = req.body?.origin === 'desktop' ? 'desktop' : 'web'; + const payload = await startAuthorization({ origin }); + return res.json(payload); + } catch (error) { + const status = error?.code === 'LINEAR_CLIENT_ID_MISSING' ? 400 : 500; + console.error('Failed to start Linear authorization:', error); + return res.status(status).json({ error: error.message || 'Failed to start Linear authorization' }); + } + }); + + app.get('/api/linear/issues/list', async (req, res) => { + try { + const { listLinearIssues } = await getLinearLibraries(); + const result = await listLinearIssues({ + query: queryValue(req, 'query'), + cursor: queryValue(req, 'cursor'), + status: queryValue(req, 'status'), + assignee: queryValue(req, 'assignee'), + teamId: queryValue(req, 'teamId'), + priority: queryValue(req, 'priority'), + }); + return res.json(result); + } catch (error) { + console.error('Failed to list Linear issues:', error); + return res.status(500).json({ error: error.message || 'Failed to list Linear issues' }); + } + }); + + app.get('/api/linear/issues/get', async (req, res) => { + try { + const id = queryValue(req, 'id'); + if (!id) { + return res.status(400).json({ error: 'id is required' }); + } + const { getLinearIssue } = await getLinearLibraries(); + const result = await getLinearIssue(id); + return res.json(result); + } catch (error) { + console.error('Failed to load Linear issue:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear issue' }); + } + }); + + app.get('/api/linear/issues/states', async (req, res) => { + try { + const teamId = queryValue(req, 'teamId'); + if (!teamId) { + return res.status(400).json({ error: 'teamId is required' }); + } + const { listLinearIssueStates } = await getLinearLibraries(); + const result = await listLinearIssueStates(teamId); + return res.json(result); + } catch (error) { + if (isLinearUserError(error)) { + return res.status(400).json({ error: error.message }); + } + console.error('Failed to load Linear workflow states:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear workflow states' }); + } + }); + + app.post('/api/linear/issues/update', parseJsonBody, async (req, res) => { + try { + const { updateLinearIssue } = await getLinearLibraries(); + const result = await updateLinearIssue({ + id: req.body?.id, + stateId: req.body?.stateId, + }); + return res.json(result); + } catch (error) { + if (isLinearUserError(error)) { + return res.status(400).json({ error: error.message }); + } + console.error('Failed to update Linear issue:', error); + return res.status(500).json({ error: error.message || 'Failed to update Linear issue' }); + } + }); + + app.get('/api/linear/mapping', async (_req, res) => { + try { + const { + listLinearTeams, + readStoredLinearMapping, + mergeLinearMappingView, + LinearMappingError, + } = await getLinearLibraries(); + const teamsResult = await listLinearTeams(); + if (teamsResult.connected === false) { + return res.json({ connected: false }); + } + let stored; + try { + stored = readStoredLinearMapping(); + } catch (error) { + if (error instanceof LinearMappingError && error.code === 'MALFORMED') { + return res.status(500).json({ error: error.message }); + } + throw error; + } + return res.json({ + connected: true, + ...mergeLinearMappingView(stored, teamsResult.teams), + }); + } catch (error) { + console.error('Failed to load Linear mapping:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear mapping' }); + } + }); + + app.put('/api/linear/mapping', parseJsonBody, async (req, res) => { + try { + const { + getValidLinearAccessToken, + listLinearTeams, + setStoredLinearMapping, + mergeLinearMappingView, + LinearMappingError, + } = await getLinearLibraries(); + const accessToken = await getValidLinearAccessToken(); + if (!accessToken) { + return res.json({ connected: false }); + } + let stored; + try { + stored = setStoredLinearMapping(req.body); + } catch (error) { + if (error instanceof LinearMappingError && error.code === 'INVALID') { + return res.status(400).json({ error: error.message }); + } + throw error; + } + const teamsResult = await listLinearTeams(); + if (teamsResult.connected === false) { + return res.json({ + connected: true, + ...mergeLinearMappingView(stored, []), + }); + } + return res.json({ + connected: true, + ...mergeLinearMappingView(stored, teamsResult.teams), + }); + } catch (error) { + console.error('Failed to save Linear mapping:', error); + return res.status(500).json({ error: error.message || 'Failed to save Linear mapping' }); + } + }); + + app.post('/api/linear/session-status', parseJsonBody, async (req, res) => { + try { + const { postLinearSessionStatus, LinearSessionStatusError } = await getLinearLibraries(); + try { + const result = await postLinearSessionStatus({ + kind: req.body?.kind, + sessionId: req.body?.sessionId, + issueIdentifier: req.body?.issueIdentifier, + sessionOrigin: req.body?.sessionOrigin, + }); + return res.json(result); + } catch (error) { + if (error instanceof LinearSessionStatusError && error.code === 'INVALID') { + return res.status(400).json({ error: error.message }); + } + if (error instanceof LinearSessionStatusError && error.code === 'MALFORMED') { + return res.status(500).json({ error: error.message }); + } + throw error; + } + } catch (error) { + console.error('Failed to post Linear session status:', error); + return res.status(500).json({ error: error.message || 'Failed to post Linear session status' }); + } + }); + + app.get('/api/linear/preferences', async (_req, res) => { + try { + const { getLinearSessionCommentsEnabled } = await getLinearLibraries(); + return res.json({ sessionComments: getLinearSessionCommentsEnabled() }); + } catch (error) { + console.error('Failed to load Linear preferences:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear preferences' }); + } + }); + + app.put('/api/linear/preferences', parseJsonBody, async (req, res) => { + try { + const sessionComments = req.body?.sessionComments; + if (sessionComments !== true && sessionComments !== false) { + return res.status(400).json({ error: 'sessionComments must be a boolean' }); + } + const { setLinearSessionCommentsEnabled } = await getLinearLibraries(); + return res.json({ sessionComments: setLinearSessionCommentsEnabled(sessionComments) }); + } catch (error) { + console.error('Failed to save Linear preferences:', error); + return res.status(500).json({ error: error.message || 'Failed to save Linear preferences' }); + } + }); + + app.post('/api/linear/auth/activate', parseJsonBody, async (req, res) => { + try { + const { + activateLinearAuth, + getLinearAuth, + getLinearAuthWorkspaces, + toLinearPublicStatus, + } = await getLinearLibraries(); + const organizationId = readTrimmedString(req.body?.organizationId); + if (!organizationId) { + return res.status(400).json({ error: 'organizationId is required' }); + } + const activated = activateLinearAuth(organizationId); + if (!activated) { + return res.status(404).json({ error: 'Linear workspace not found' }); + } + const auth = getLinearAuth(); + if (!auth) { + return res.json({ connected: false }); + } + return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces())); + } catch (error) { + console.error('Failed to switch Linear workspace:', error); + return res.status(500).json({ error: error.message || 'Failed to switch Linear workspace' }); + } + }); + + app.delete('/api/linear/auth', async (_req, res) => { + try { + const { getLinearAuth, clearLinearAuth, revokeToken } = await getLinearLibraries(); + const auth = getLinearAuth(); + if (auth?.refreshToken) { + await revokeToken(auth.refreshToken, 'refresh_token'); + } else if (auth?.accessToken) { + await revokeToken(auth.accessToken, 'access_token'); + } + const removed = clearLinearAuth(auth?.workspaceId); + return res.json({ success: true, removed }); + } catch (error) { + console.error('Failed to disconnect Linear:', error); + return res.status(500).json({ error: error.message || 'Failed to disconnect Linear' }); + } + }); +} diff --git a/packages/web/server/lib/linear/routes.test.js b/packages/web/server/lib/linear/routes.test.js new file mode 100644 index 00000000..a6d87112 --- /dev/null +++ b/packages/web/server/lib/linear/routes.test.js @@ -0,0 +1,661 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { registerLinearRoutes } from './routes.js'; +import { setLinearAuth, setLinearSessionCommentsEnabled } from './auth.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-routes-')); + +const createApp = () => { + const app = express(); + registerLinearRoutes(app); + return app; +}; + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +describe('Linear auth routes', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback'; + delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + delete process.env.OPENCHAMBER_PORT; + delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('starts authorization and completes it from the public callback', async () => { + const app = createApp(); + const start = await request(app) + .post('/api/linear/auth/start') + .send({ origin: 'desktop' }) + .expect(200); + + expect(start.body.authorizationUrl).toContain('https://linear.app/oauth/authorize'); + const state = new URL(start.body.authorizationUrl).searchParams.get('state'); + + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target === 'https://api.linear.app/oauth/token') { + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + token_type: 'Bearer', + expires_in: 86399, + scope: 'read,write,comments:create', + }); + } + if (target === 'https://api.linear.app/graphql') { + return jsonResponse({ + data: { + viewer: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + }, + }); + } + throw new Error(`unexpected fetch: ${target}`); + })); + + const callback = await request(app) + .get('/linear/oauth/callback') + .query({ state, code: 'auth-code' }) + .expect(200); + + expect(callback.text).toContain('Authorization Complete'); + expect(callback.text).toContain('openchamber://focus/linear-auth'); + + const status = await request(app).get('/api/linear/auth/status').expect(200); + expect(status.body.connected).toBe(true); + expect(status.body.user).toEqual({ + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }); + expect(status.body.organization).toEqual({ id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }); + expect(status.body.scope).toBe('read,write,comments:create'); + expect(status.body.workspaces).toEqual([{ + id: 'org-1', + name: 'OpenChamber', + urlKey: 'openchamber', + current: true, + user: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + authorizedAt: expect.any(Number), + }]); + expect(JSON.stringify(status.body)).not.toContain('access-1'); + expect(JSON.stringify(status.body)).not.toContain('refresh-1'); + + const again = await request(app).get('/api/linear/auth/status').expect(200); + expect(again.body.workspaces[0].authorizedAt).toBe(status.body.workspaces[0].authorizedAt); + }); + + it('never exchanges a code whose state is unknown', async () => { + const tokenFetch = vi.fn(); + vi.stubGlobal('fetch', tokenFetch); + const app = createApp(); + const response = await request(app) + .get('/linear/oauth/callback') + .query({ state: 'forged', code: 'attacker-code' }) + .expect(400); + expect(tokenFetch).not.toHaveBeenCalled(); + expect(response.text).toContain('Authorization Failed'); + expect(response.text).not.toContain('openchamber://'); + }); + + it('omits the desktop deep link for flows started outside the desktop shell', async () => { + const app = createApp(); + const start = await request(app) + .post('/api/linear/auth/start') + .send({ origin: 'web' }) + .expect(200); + const state = new URL(start.body.authorizationUrl).searchParams.get('state'); + + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + expires_in: 86399, + }); + } + return jsonResponse({ + data: { viewer: { id: 'user-1', name: 'Ada' }, organization: null }, + }); + })); + + const response = await request(app) + .get('/linear/oauth/callback') + .query({ state, code: 'auth-code' }) + .expect(200); + expect(response.text).not.toContain('openchamber://'); + }); + + it('disconnects and revokes the refresh token', async () => { + const app = createApp(); + const start = await request(app).post('/api/linear/auth/start').send({}).expect(200); + const state = new URL(start.body.authorizationUrl).searchParams.get('state'); + const fetchMock = vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + expires_in: 86399, + }); + } + if (target.includes('/graphql')) { + return jsonResponse({ data: { viewer: { id: 'user-1', name: 'Ada' } } }); + } + if (target.includes('/oauth/revoke')) { + return new Response('', { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await request(app).get('/linear/oauth/callback').query({ state, code: 'auth-code' }).expect(200); + await request(app).delete('/api/linear/auth').expect(200); + + const revokeCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/oauth/revoke')); + expect(revokeCall).toBeTruthy(); + const body = new URLSearchParams(revokeCall[1].body); + expect(body.get('token')).toBe('refresh-1'); + expect(body.get('token_type_hint')).toBe('refresh_token'); + + const status = await request(app).get('/api/linear/auth/status').expect(200); + expect(status.body).toEqual({ connected: false }); + }); + + it('stores a second workspace, switches current, and disconnects only that one', async () => { + const app = createApp(); + + const startA = await request(app).post('/api/linear/auth/start').send({}).expect(200); + const stateA = new URL(startA.body.authorizationUrl).searchParams.get('state'); + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-a', + refresh_token: 'refresh-a', + expires_in: 86399, + scope: 'read,write,comments:create', + }); + } + if (target.includes('/graphql')) { + return jsonResponse({ + data: { + viewer: { id: 'user-a', name: 'Ada' }, + organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' }, + }, + }); + } + throw new Error(`unexpected fetch: ${target}`); + })); + await request(app).get('/linear/oauth/callback').query({ state: stateA, code: 'code-a' }).expect(200); + + const startB = await request(app).post('/api/linear/auth/start').send({}).expect(200); + const stateB = new URL(startB.body.authorizationUrl).searchParams.get('state'); + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-b', + refresh_token: 'refresh-b', + expires_in: 86399, + scope: 'read,write,comments:create', + }); + } + if (target.includes('/graphql')) { + return jsonResponse({ + data: { + viewer: { id: 'user-b', name: 'Ben' }, + organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' }, + }, + }); + } + if (target.includes('/oauth/revoke')) { + return new Response('', { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + })); + await request(app).get('/linear/oauth/callback').query({ state: stateB, code: 'code-b' }).expect(200); + + const both = await request(app).get('/api/linear/auth/status').expect(200); + expect(both.body.organization.id).toBe('org-b'); + expect(both.body.workspaces).toHaveLength(2); + + await request(app).post('/api/linear/auth/activate').send({}).expect(400); + await request(app).post('/api/linear/auth/activate').send({ organizationId: 'missing' }).expect(404); + + const activated = await request(app) + .post('/api/linear/auth/activate') + .send({ organizationId: 'org-a' }) + .expect(200); + expect(activated.body.organization.id).toBe('org-a'); + expect(activated.body.workspaces.find((entry) => entry.id === 'org-a').current).toBe(true); + expect(activated.body.workspaces.find((entry) => entry.id === 'org-b').current).toBe(false); + + await request(app).delete('/api/linear/auth').expect(200); + const remaining = await request(app).get('/api/linear/auth/status').expect(200); + expect(remaining.body.connected).toBe(true); + expect(remaining.body.organization.id).toBe('org-b'); + expect(remaining.body.workspaces).toHaveLength(1); + expect(remaining.body.workspaces[0].id).toBe('org-b'); + }); + + it('lists and gets issues through authenticated routes without leaking tokens', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('GetLinearIssue')) { + return jsonResponse({ + data: { + issue: { + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'Todo', type: 'unstarted' }, + assignee: null, + description: 'Users cannot sign in.', + comments: { nodes: [] }, + }, + }, + }); + } + return jsonResponse({ + data: { + issues: { + nodes: [{ + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'Todo', type: 'unstarted' }, + assignee: null, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const app = createApp(); + const list = await request(app).get('/api/linear/issues/list').expect(200); + expect(list.body.connected).toBe(true); + expect(list.body.issues).toHaveLength(1); + expect(JSON.stringify(list.body)).not.toContain('access-1'); + + const missing = await request(app).get('/api/linear/issues/get').expect(400); + expect(missing.body.error).toBe('id is required'); + + const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200); + expect(got.body.issue.identifier).toBe('ENG-12'); + expect(got.body.issue.description).toBe('Users cannot sign in.'); + expect(got.body.issue.state).toEqual({ id: null, name: 'Todo', type: 'unstarted' }); + }); + + it('passes list filters from query params to Linear', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.variables.filter).toEqual({ + state: { type: { eq: 'completed' } }, + assignee: { isMe: { eq: true } }, + team: { id: { eq: 'team-eng' } }, + priority: { eq: 1 }, + }); + return jsonResponse({ + data: { + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const app = createApp(); + const list = await request(app).get('/api/linear/issues/list').query({ + status: 'completed', + assignee: 'me', + teamId: 'team-eng', + priority: 'urgent', + }).expect(200); + expect(list.body.connected).toBe(true); + expect(list.body.issues).toEqual([]); + }); + + it('lists workflow states and updates issue status without leaking tokens', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'write', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('TeamWorkflowStates')) { + expect(body.variables.id).toBe('team-eng'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + team: { + states: { + nodes: [ + { id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 }, + { id: 'state-done', name: 'Done', type: 'completed', position: 2 }, + ], + }, + }, + }, + }); + } + expect(body.query).toContain('mutation IssueUpdate'); + expect(body.variables).toEqual({ + id: 'issue-uuid-1', + input: { stateId: 'state-done' }, + }); + return jsonResponse({ + data: { + issueUpdate: { + success: true, + issue: { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { id: 'state-done', name: 'Done', type: 'completed' }, + assignee: null, + description: null, + comments: { nodes: [] }, + }, + }, + }, + }); + })); + + const app = createApp(); + const missingTeam = await request(app).get('/api/linear/issues/states').expect(400); + expect(missingTeam.body.error).toBe('teamId is required'); + + const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200); + expect(states.body.connected).toBe(true); + expect(states.body.states).toEqual([ + { id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 }, + { id: 'state-done', name: 'Done', type: 'completed', position: 2 }, + ]); + expect(JSON.stringify(states.body)).not.toContain('access-1'); + + const missingBody = await request(app).post('/api/linear/issues/update').send({}).expect(400); + expect(missingBody.body.error).toBe('id and stateId are required'); + + const updated = await request(app).post('/api/linear/issues/update').send({ + id: 'issue-uuid-1', + stateId: 'state-done', + }).expect(200); + expect(updated.body.connected).toBe(true); + expect(updated.body.issue.identifier).toBe('ENG-12'); + expect(updated.body.issue.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' }); + expect(JSON.stringify(updated.body)).not.toContain('access-1'); + }); + + it('returns 400 for Linear validation and not-found GraphQL errors', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'write', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('TeamWorkflowStates')) { + return jsonResponse({ + data: null, + errors: [{ + message: 'Entity not found: Team', + extensions: { + code: 'INPUT_ERROR', + userError: true, + userPresentableMessage: 'Could not find referenced Team.', + }, + }], + }); + } + return jsonResponse({ + data: null, + errors: [{ + message: 'Argument Validation Error', + extensions: { + code: 'INVALID_INPUT', + userError: true, + userPresentableMessage: 'stateId must be a UUID.', + }, + }], + }); + })); + + const app = createApp(); + const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'missing-team' }).expect(400); + expect(states.body.error).toBe('Could not find referenced Team.'); + + const updated = await request(app).post('/api/linear/issues/update').send({ + id: 'issue-uuid-1', + stateId: 'not-a-uuid', + }).expect(400); + expect(updated.body.error).toBe('stateId must be a UUID.'); + }); + + it('returns disconnected for issue routes when Linear is not connected', async () => { + const app = createApp(); + const list = await request(app).get('/api/linear/issues/list').expect(200); + expect(list.body).toEqual({ connected: false }); + const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200); + expect(got.body).toEqual({ connected: false }); + const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200); + expect(states.body).toEqual({ connected: false }); + const updated = await request(app).post('/api/linear/issues/update').send({ + id: 'issue-1', + stateId: 'state-done', + }).expect(200); + expect(updated.body).toEqual({ connected: false }); + }); + + it('returns disconnected mapping when Linear is not connected', async () => { + const app = createApp(); + const mapping = await request(app).get('/api/linear/mapping').expect(200); + expect(mapping.body).toEqual({ connected: false }); + const saved = await request(app).put('/api/linear/mapping').send({ + defaultProjectPath: '/tmp/project', + teamProjectPaths: {}, + }).expect(200); + expect(saved.body).toEqual({ connected: false }); + }); + + it('saves and reads Linear team-to-project mapping without leaking tokens', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query ListLinearTeams'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + teams: { + nodes: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + { id: 'team-des', key: 'DES', name: 'Design' }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const app = createApp(); + const empty = await request(app).get('/api/linear/mapping').expect(200); + expect(empty.body).toEqual({ + connected: true, + defaultProjectPath: null, + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: null }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], + }); + expect(JSON.stringify(empty.body)).not.toContain('access-1'); + + const saved = await request(app).put('/api/linear/mapping').send({ + defaultProjectPath: '/Users/ada/openchamber', + teamProjectPaths: { 'team-eng': '/Users/ada/eng' }, + }).expect(200); + expect(saved.body).toEqual({ + connected: true, + defaultProjectPath: '/Users/ada/openchamber', + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/Users/ada/eng' }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], + }); + expect(JSON.stringify(saved.body)).not.toContain('access-1'); + + const reread = await request(app).get('/api/linear/mapping').expect(200); + expect(reread.body.defaultProjectPath).toBe('/Users/ada/openchamber'); + expect(reread.body.teams[0].projectPath).toBe('/Users/ada/eng'); + }); + + it('posts a session status comment and never leaks the token', async () => { + setLinearSessionCommentsEnabled(true); + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + return jsonResponse({ + data: { + issue: { + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'Todo', type: 'unstarted' }, + assignee: null, + description: null, + comments: { nodes: [] }, + }, + }, + }); + } + expect(body.query).toContain('mutation CommentCreate'); + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: 'comment-1' }, + }, + }, + }); + })); + + const app = createApp(); + const missing = await request(app).post('/api/linear/session-status').send({ + kind: 'started', + }).expect(400); + expect(missing.body.error).toBe('kind and sessionId are required'); + + const posted = await request(app).post('/api/linear/session-status').send({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }).expect(200); + expect(posted.body).toEqual({ + connected: true, + posted: true, + commentId: 'comment-1', + }); + expect(JSON.stringify(posted.body)).not.toContain('access-1'); + }); + + it('reads and writes the session-comment preference', async () => { + const app = createApp(); + const initial = await request(app).get('/api/linear/preferences').expect(200); + expect(initial.body).toEqual({ sessionComments: false }); + + const invalid = await request(app).put('/api/linear/preferences').send({ sessionComments: 'yes' }).expect(400); + expect(invalid.body.error).toBe('sessionComments must be a boolean'); + + const enabled = await request(app).put('/api/linear/preferences').send({ sessionComments: true }).expect(200); + expect(enabled.body).toEqual({ sessionComments: true }); + const reread = await request(app).get('/api/linear/preferences').expect(200); + expect(reread.body).toEqual({ sessionComments: true }); + }); + + it('returns disconnected session-status when Linear is not connected', async () => { + const app = createApp(); + const response = await request(app).post('/api/linear/session-status').send({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + }).expect(200); + expect(response.body).toEqual({ connected: false }); + }); +}); diff --git a/packages/web/server/lib/linear/status-runtime.js b/packages/web/server/lib/linear/status-runtime.js new file mode 100644 index 00000000..6b2a1e67 --- /dev/null +++ b/packages/web/server/lib/linear/status-runtime.js @@ -0,0 +1,64 @@ +import { isPlainObject, readTrimmedString } from './parse.js'; +import { postLinearSessionStatus } from './status.js'; + +function readProperties(payload) { + if (!isPlainObject(payload)) return {}; + return isPlainObject(payload.properties) ? payload.properties : {}; +} + +function readNested(properties, key) { + return isPlainObject(properties[key]) ? properties[key] : {}; +} + +function extractSessionId(payload) { + const properties = readProperties(payload); + const info = readNested(properties, 'info'); + return readTrimmedString(info.sessionID) + || readTrimmedString(info.sessionId) + || readTrimmedString(properties.sessionID) + || readTrimmedString(properties.sessionId) + || readTrimmedString(properties.session); +} + +function extractStatusType(payload) { + if (!isPlainObject(payload) || payload.type !== 'session.status') return ''; + const properties = readProperties(payload); + const status = readNested(properties, 'status'); + const info = readNested(properties, 'info'); + return readTrimmedString(status.type) || readTrimmedString(info.type); +} + +function extractErrorName(payload) { + if (!isPlainObject(payload) || payload.type !== 'session.error') return ''; + const properties = readProperties(payload); + return readTrimmedString(readNested(properties, 'error').name); +} + +export function createLinearSessionStatusRuntime() { + let stopped = false; + + const processPayload = (payload) => { + if (stopped) return; + const sessionId = extractSessionId(payload); + if (!sessionId) return; + + if (isPlainObject(payload) && payload.type === 'session.error') { + if (extractErrorName(payload) === 'MessageAbortedError') return; + void postLinearSessionStatus({ kind: 'failure', sessionId }).catch((error) => { + console.warn('[linear] failed to post session failure comment:', error?.message || error); + }); + return; + } + + if (extractStatusType(payload) !== 'idle') return; + void postLinearSessionStatus({ kind: 'completed', sessionId }).catch((error) => { + console.warn('[linear] failed to post session completed comment:', error?.message || error); + }); + }; + + const stop = () => { + stopped = true; + }; + + return { processPayload, stop }; +} diff --git a/packages/web/server/lib/linear/status-runtime.test.js b/packages/web/server/lib/linear/status-runtime.test.js new file mode 100644 index 00000000..36bc6856 --- /dev/null +++ b/packages/web/server/lib/linear/status-runtime.test.js @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js'; +import { createLinearSessionStatusRuntime } from './status-runtime.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-runtime-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const issueNode = { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'In Progress', type: 'started' }, + assignee: null, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + description: null, + comments: { nodes: [] }, +}; + +function stubLinearGraphql({ commentId = 'comment-1' } = {}) { + return vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + return jsonResponse({ data: { issue: issueNode } }); + } + if (body.query.includes('mutation CommentCreate')) { + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: commentId }, + }, + }, + }); + } + throw new Error(`unexpected query: ${body.query}`); + }); +} + +describe('Linear session status runtime', () => { + let dataDir; + let previousDataDir; + let previousPort; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + setLinearSessionCommentsEnabled(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + if (previousPort === undefined) { + delete process.env.OPENCHAMBER_PORT; + } else { + process.env.OPENCHAMBER_PORT = previousPort; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('posts completed on the first idle after started, then ignores later idles', async () => { + const { postLinearSessionStatus } = await import('./status.js'); + vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' })); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + + const graphql = stubLinearGraphql({ commentId: 'done' }); + vi.stubGlobal('fetch', graphql); + const runtime = createLinearSessionStatusRuntime(); + runtime.processPayload({ + type: 'session.status', + properties: { sessionID: 'ses_1', status: { type: 'idle' } }, + }); + runtime.processPayload({ + type: 'session.status', + properties: { sessionID: 'ses_1', status: { type: 'idle' } }, + }); + await vi.waitFor(() => { + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + }); + runtime.stop(); + }); + + it('posts failure on session.error and skips user abort', async () => { + const { postLinearSessionStatus } = await import('./status.js'); + vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' })); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + + const graphql = stubLinearGraphql({ commentId: 'fail' }); + vi.stubGlobal('fetch', graphql); + const runtime = createLinearSessionStatusRuntime(); + runtime.processPayload({ + type: 'session.error', + properties: { + sessionID: 'ses_1', + error: { name: 'MessageAbortedError', message: 'stopped' }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(graphql).not.toHaveBeenCalled(); + + runtime.processPayload({ + type: 'session.error', + properties: { + sessionID: 'ses_1', + error: { name: 'ProviderError', message: 'boom' }, + }, + }); + await vi.waitFor(() => { + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + expect(body).toContain('OpenChamber session failed'); + }); + runtime.stop(); + }); + + it('does not treat busy as completed', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + const runtime = createLinearSessionStatusRuntime(); + runtime.processPayload({ + type: 'session.status', + properties: { sessionID: 'ses_1', status: { type: 'busy' } }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(graphql).not.toHaveBeenCalled(); + runtime.stop(); + }); +}); diff --git a/packages/web/server/lib/linear/status.js b/packages/web/server/lib/linear/status.js new file mode 100644 index 00000000..9ba19457 --- /dev/null +++ b/packages/web/server/lib/linear/status.js @@ -0,0 +1,280 @@ +import fs from 'fs'; +import path from 'path'; +import { getLinearAuth, getLinearAuthFilePath, getLinearSessionCommentsEnabled } from './auth.js'; +import { createLinearIssueComment } from './issues.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +const LINEAR_SESSION_STATUS_KINDS = ['started', 'completed', 'failure']; +const MAX_SESSION_STATUS_RECORDS = 500; + +export class LinearSessionStatusError extends Error { + constructor(message, code) { + super(message); + this.name = 'LinearSessionStatusError'; + this.code = code; + } +} + +const inflight = new Map(); + +function statusFile() { + return path.join(path.dirname(getLinearAuthFilePath()), 'linear-session-status.json'); +} + +function writeJsonFile(filePath, payload) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8'); + try { + fs.chmodSync(tmpFile, 0o600); + } catch { + // best-effort + } + fs.renameSync(tmpFile, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } +} + +const PRIVATE_HOST_SUFFIXES = ['.local', '.localhost', '.internal', '.lan', '.home.arpa']; + +function isPrivateIpv4(hostname) { + const parts = hostname.split('.'); + if (parts.length !== 4) return false; + const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : -1)); + if (octets.some((octet) => octet < 0 || octet > 255)) return false; + const [a, b] = octets; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + // 100.64.0.0/10 is carrier-grade NAT, which Tailscale and similar overlays use. + if (a === 100 && b >= 64 && b <= 127) return true; + return false; +} + +function isPrivateIpv6(hostname) { + const address = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase(); + if (address === '::1' || address === '::') return true; + // fc00::/7 (unique local) and fe80::/10 (link local). + return /^f[cd]/.test(address) || /^fe[89ab]/.test(address); +} + +/** + * A session link is only worth writing into Linear when somebody other than the + * person who started the session can open it. Loopback, private LAN and + * overlay-network addresses reach nobody else, so they do not qualify. + */ +export function isPublicSessionOrigin(value) { + const origin = readSessionOrigin(value); + if (!origin) return false; + let hostname; + try { + hostname = new URL(origin).hostname.toLowerCase(); + } catch { + return false; + } + if (!hostname || hostname === 'localhost') return false; + if (PRIVATE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false; + if (hostname.includes(':') || hostname.startsWith('[')) return !isPrivateIpv6(hostname); + if (/^[\d.]+$/.test(hostname)) return !isPrivateIpv4(hostname); + // A bare single-label host is a LAN machine name, not a routable address. + return hostname.includes('.'); +} + +export function readSessionOrigin(value) { + const trimmed = readTrimmedString(value); + if (!trimmed) return ''; + try { + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''; + if (url.username || url.password) return ''; + if (url.search || url.hash) return ''; + if (url.pathname && url.pathname !== '/') return ''; + return url.origin; + } catch { + return ''; + } +} + +export function buildLinearSessionOpenUrl(sessionId, sessionOrigin) { + const id = readTrimmedString(sessionId); + const origin = readSessionOrigin(sessionOrigin); + if (!origin) return ''; + return `${origin}/?session=${encodeURIComponent(id)}`; +} + +function statusWord(kind) { + if (kind === 'started') return 'started'; + if (kind === 'completed') return 'completed'; + return 'failed'; +} + +export function buildLinearSessionStatusComment({ kind, sessionUrl }) { + const url = readTrimmedString(sessionUrl); + const label = `OpenChamber session ${statusWord(kind)}`; + if (!url) return label; + // The comment already lives on the issue, so it says only what happened and + // links to the session. Issue titles routinely contain brackets ("[Bug] …"), + // which would break this markdown link if they were repeated in the label. + return `[${label}](${url})`; +} + +function readBooleanFlag(value) { + return value === true; +} + +function readRecord(value) { + if (!isPlainObject(value)) return null; + const issueIdentifier = readTrimmedString(value.issueIdentifier); + if (!issueIdentifier) return null; + return { + issueIdentifier, + sessionOrigin: readSessionOrigin(value.sessionOrigin) || null, + organizationId: readTrimmedString(value.organizationId) || null, + started: readBooleanFlag(value.started), + completed: readBooleanFlag(value.completed), + failure: readBooleanFlag(value.failure), + }; +} + +function readRecords() { + const filePath = statusFile(); + if (!fs.existsSync(filePath)) { + return {}; + } + let parsed; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (!trimmed) { + return {}; + } + parsed = JSON.parse(trimmed); + } catch { + throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED'); + } + if (!isPlainObject(parsed)) { + throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED'); + } + const next = {}; + for (const key of Object.keys(parsed)) { + const sessionId = readTrimmedString(key); + const record = readRecord(parsed[key]); + if (sessionId && record) { + next[sessionId] = record; + } + } + return next; +} + +/** + * The file only exists to dedupe comments, so it does not need to remember + * every session ever started. Keep the newest entries and drop the tail. + */ +export function pruneSessionStatusRecords(records, limit = MAX_SESSION_STATUS_RECORDS) { + const keys = Object.keys(records); + if (keys.length <= limit) { + return records; + } + const kept = {}; + for (const key of keys.slice(keys.length - limit)) { + kept[key] = records[key]; + } + return kept; +} + +function writeRecords(records) { + writeJsonFile(statusFile(), pruneSessionStatusRecords(records)); +} + +async function postOnce(input) { + const kind = readTrimmedString(input?.kind); + const sessionId = readTrimmedString(input?.sessionId); + if (!LINEAR_SESSION_STATUS_KINDS.includes(kind) || !sessionId) { + throw new LinearSessionStatusError('kind and sessionId are required', 'INVALID'); + } + + // Disconnected answers first so the picker and panel keep showing their + // "connect Linear" state whatever the comment preference says. + if (!getLinearAuth()) { + return { connected: false }; + } + if (!getLinearSessionCommentsEnabled()) { + return { connected: true, posted: false, skipped: 'disabled' }; + } + + const records = readRecords(); + const existing = records[sessionId] || null; + if (existing?.[kind] === true) { + return { connected: true, posted: false, skipped: 'already-posted' }; + } + if (kind !== 'started' && existing?.started !== true) { + return { connected: true, posted: false, skipped: 'not-started' }; + } + + const issueIdentifier = readTrimmedString(input?.issueIdentifier) + || readTrimmedString(existing?.issueIdentifier); + if (!issueIdentifier) { + throw new LinearSessionStatusError('issueIdentifier is required', 'INVALID'); + } + + const sessionOrigin = readSessionOrigin(input?.sessionOrigin) + || readTrimmedString(existing?.sessionOrigin); + // Without an origin other people can reach, the comment would carry a link + // only its author could open. Say nothing rather than publish a dead link. + if (!isPublicSessionOrigin(sessionOrigin)) { + return { connected: true, posted: false, skipped: 'origin-not-public' }; + } + const sessionUrl = buildLinearSessionOpenUrl(sessionId, sessionOrigin); + const organizationId = readTrimmedString(input?.organizationId) + || readTrimmedString(existing?.organizationId) + || readTrimmedString(getLinearAuth()?.workspaceId); + const body = buildLinearSessionStatusComment({ kind, sessionUrl }); + const commentResult = await createLinearIssueComment({ + issueId: issueIdentifier, + body, + organizationId, + }); + if (commentResult.connected === false) { + return { connected: false }; + } + if (!commentResult.comment) { + return { connected: true, posted: false, skipped: 'issue-not-found' }; + } + + records[sessionId] = { + issueIdentifier, + sessionOrigin: sessionOrigin || null, + organizationId: organizationId || null, + started: existing?.started === true || kind === 'started', + completed: existing?.completed === true || kind === 'completed', + failure: existing?.failure === true || kind === 'failure', + }; + writeRecords(records); + return { + connected: true, + posted: true, + commentId: commentResult.comment.id, + }; +} + +export async function postLinearSessionStatus(input) { + const kind = readTrimmedString(input?.kind); + const sessionId = readTrimmedString(input?.sessionId); + const key = `${sessionId}:${kind}`; + const pending = inflight.get(key); + if (pending) { + return pending; + } + const promise = postOnce(input).finally(() => { + inflight.delete(key); + }); + inflight.set(key, promise); + return promise; +} diff --git a/packages/web/server/lib/linear/status.test.js b/packages/web/server/lib/linear/status.test.js new file mode 100644 index 00000000..d2c9fb8a --- /dev/null +++ b/packages/web/server/lib/linear/status.test.js @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js'; +import { + buildLinearSessionOpenUrl, + buildLinearSessionStatusComment, + isPublicSessionOrigin, + postLinearSessionStatus, + pruneSessionStatusRecords, + readSessionOrigin, +} from './status.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const issueNode = { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'In Progress', type: 'started' }, + assignee: null, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + description: null, + comments: { nodes: [] }, +}; + +function stubLinearGraphql({ commentId = 'comment-1' } = {}) { + return vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + return jsonResponse({ data: { issue: issueNode } }); + } + if (body.query.includes('mutation CommentCreate')) { + expect(body.variables.input.issueId).toBe('issue-uuid-1'); + expect(body.variables.input.body).toContain('/?session=ses_1'); + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: commentId }, + }, + }, + }); + } + throw new Error(`unexpected query: ${body.query}`); + }); +} + +describe('Linear session status comments', () => { + let dataDir; + let previousDataDir; + let previousPort; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + setLinearSessionCommentsEnabled(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + if (previousPort === undefined) { + delete process.env.OPENCHAMBER_PORT; + } else { + process.env.OPENCHAMBER_PORT = previousPort; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('reads http(s) origins and rejects other URLs', () => { + expect(readSessionOrigin('https://app.example.com')).toBe('https://app.example.com'); + expect(readSessionOrigin('http://127.0.0.1:3001/')).toBe('http://127.0.0.1:3001'); + expect(readSessionOrigin('javascript:alert(1)')).toBe(''); + expect(readSessionOrigin('https://app.example.com/secret')).toBe(''); + expect(readSessionOrigin('openchamber:')).toBe(''); + expect(buildLinearSessionOpenUrl('ses_1', 'https://app.example.com')) + .toBe('https://app.example.com/?session=ses_1'); + expect(buildLinearSessionOpenUrl('ses_1', '')).toBe(''); + }); + + it('treats only externally reachable origins as public', () => { + expect(isPublicSessionOrigin('https://chamber.example.com')).toBe(true); + expect(isPublicSessionOrigin('http://chamber.example.com:8080')).toBe(true); + expect(isPublicSessionOrigin('https://203.0.113.10')).toBe(true); + + expect(isPublicSessionOrigin('http://localhost:3001')).toBe(false); + expect(isPublicSessionOrigin('http://127.0.0.1:3001')).toBe(false); + expect(isPublicSessionOrigin('http://[::1]:3001')).toBe(false); + expect(isPublicSessionOrigin('http://192.168.1.20:3001')).toBe(false); + expect(isPublicSessionOrigin('http://10.0.0.5:3001')).toBe(false); + expect(isPublicSessionOrigin('http://172.20.1.4:3001')).toBe(false); + expect(isPublicSessionOrigin('http://169.254.10.1:3001')).toBe(false); + expect(isPublicSessionOrigin('http://100.101.102.103:3001')).toBe(false); + expect(isPublicSessionOrigin('http://macbook.local:3001')).toBe(false); + expect(isPublicSessionOrigin('http://macbook:3001')).toBe(false); + expect(isPublicSessionOrigin('http://[fd00::1]:3001')).toBe(false); + expect(isPublicSessionOrigin('openchamber:')).toBe(false); + expect(isPublicSessionOrigin('')).toBe(false); + }); + + it('posts nothing while session comments are turned off', async () => { + setLinearSessionCommentsEnabled(false); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'disabled' }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('posts nothing when the session origin only the author can reach', async () => { + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'http://127.0.0.1:3001', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' }); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_2', + issueIdentifier: 'ENG-12', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('keeps the newest dedupe records and drops the oldest', () => { + const records = {}; + for (let index = 0; index < 5; index += 1) { + records[`ses_${index}`] = { issueIdentifier: 'ENG-12', started: true }; + } + expect(Object.keys(pruneSessionStatusRecords(records, 3))).toEqual(['ses_2', 'ses_3', 'ses_4']); + expect(Object.keys(pruneSessionStatusRecords(records, 10))).toHaveLength(5); + }); + + it('makes the whole status line one link and carries no title', () => { + expect(buildLinearSessionStatusComment({ + kind: 'started', + sessionUrl: 'https://app.example.com/?session=ses_1', + })).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)'); + expect(buildLinearSessionStatusComment({ + kind: 'completed', + sessionUrl: 'https://app.example.com/?session=ses_1', + })).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)'); + expect(buildLinearSessionStatusComment({ + kind: 'failure', + sessionUrl: 'https://app.example.com/?session=ses_1', + })).toBe('[OpenChamber session failed](https://app.example.com/?session=ses_1)'); + }); + + it('cannot be broken by brackets in the issue title', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + // One balanced pair of brackets, so a title like "[Bug] …" can never leak in + // and split the link across the renderer. + expect(body.match(/\[/g)).toHaveLength(1); + expect(body.match(/\]/g)).toHaveLength(1); + }); + + it('returns disconnected without calling Linear when there is no auth', async () => { + clearLinearAuth(); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + })).resolves.toEqual({ connected: false }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('posts a started comment once and skips repeats', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + + const first = await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-1' }); + + const second = await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' }); + + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + expect(body).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)'); + expect(JSON.stringify(first)).not.toContain('access-1'); + }); + + it('skips completed until started has been posted', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'completed', + sessionId: 'ses_1', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'not-started' }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('posts completed once after started, reusing the stored open URL', async () => { + vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'comment-started' })); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + + const graphql = stubLinearGraphql({ commentId: 'comment-done' }); + vi.stubGlobal('fetch', graphql); + const first = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' }); + expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-done' }); + const second = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' }); + expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' }); + + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + expect(body).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)'); + }); +}); diff --git a/packages/web/server/lib/linear/teams.js b/packages/web/server/lib/linear/teams.js new file mode 100644 index 00000000..2cdcf93a --- /dev/null +++ b/packages/web/server/lib/linear/teams.js @@ -0,0 +1,72 @@ +import { clearLinearAuth, getLinearAuth } from './auth.js'; +import { fetchLinearGraphql, getValidLinearAccessToken } from './client.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +const TEAMS_QUERY = ` + query ListLinearTeams($first: Int!, $after: String) { + teams(first: $first, after: $after) { + nodes { id key name } + pageInfo { hasNextPage endCursor } + } + } +`; +const PAGE_SIZE = 50; +const MAX_PAGES = 20; + +function readTeam(node) { + if (!isPlainObject(node)) { + return null; + } + const id = readTrimmedString(node.id); + const key = readTrimmedString(node.key); + const name = readTrimmedString(node.name); + if (!id || !key || !name) { + return null; + } + return { id, key, name }; +} + +export async function listLinearTeams() { + try { + const token = await getValidLinearAccessToken(); + if (!token) { + return { connected: false }; + } + + const teams = []; + let after = null; + for (let page = 0; page < MAX_PAGES; page += 1) { + const variables = { first: PAGE_SIZE }; + if (after) { + variables.after = after; + } + const data = await fetchLinearGraphql(token, TEAMS_QUERY, variables); + const connection = isPlainObject(data.teams) ? data.teams : null; + const nodes = isPlainObject(connection) && Array.isArray(connection.nodes) + ? connection.nodes + : []; + for (const node of nodes) { + const team = readTeam(node); + if (team) { + teams.push(team); + } + } + const pageInfo = isPlainObject(connection) ? connection.pageInfo : null; + if (!isPlainObject(pageInfo) || pageInfo.hasNextPage !== true) { + break; + } + after = readTrimmedString(pageInfo.endCursor); + if (!after) { + break; + } + } + + return { connected: true, teams }; + } catch (error) { + if (error?.status === 401) { + clearLinearAuth(getLinearAuth()?.workspaceId); + return { connected: false }; + } + throw error; + } +} diff --git a/packages/web/server/lib/linear/teams.test.js b/packages/web/server/lib/linear/teams.test.js new file mode 100644 index 00000000..806b259e --- /dev/null +++ b/packages/web/server/lib/linear/teams.test.js @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { clearLinearAuth, setLinearAuth } from './auth.js'; +import { listLinearTeams } from './teams.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-teams-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +describe('Linear teams list', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns disconnected without calling Linear when there is no auth', async () => { + clearLinearAuth(); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(listLinearTeams()).resolves.toEqual({ connected: false }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('lists teams across pages and never returns the token', async () => { + const graphql = vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query ListLinearTeams'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + if (!body.variables.after) { + return jsonResponse({ + data: { + teams: { + nodes: [{ id: 'team-eng', key: 'ENG', name: 'Engineering' }], + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }, + }, + }); + } + expect(body.variables.after).toBe('cursor-2'); + return jsonResponse({ + data: { + teams: { + nodes: [{ id: 'team-des', key: 'DES', name: 'Design' }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + }); + vi.stubGlobal('fetch', graphql); + + const result = await listLinearTeams(); + expect(result).toEqual({ + connected: true, + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + { id: 'team-des', key: 'DES', name: 'Design' }, + ], + }); + expect(JSON.stringify(result)).not.toContain('access-1'); + expect(graphql).toHaveBeenCalledTimes(2); + }); + + it('clears auth and reports disconnected after a GraphQL 401', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401))); + await expect(listLinearTeams()).resolves.toEqual({ connected: false }); + }); +}); diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index f36f0c45..4a39043f 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -4,6 +4,7 @@ import { registerSmallModelRoutes } from '../small-model/routes.js'; import { registerWalkthroughRoutes } from '../walkthrough/routes.js'; import { registerSessionGoalRoutes } from '../session-goal/routes.js'; import { registerGitHubRoutes } from '../github/routes.js'; +import { registerLinearRoutes } from '../linear/routes.js'; import { registerGitRoutes } from '../git/routes.js'; import { registerDevServerRoutes } from '../dev-servers/routes.js'; import { registerMagicPromptRoutes } from '../magic-prompts/routes.js'; @@ -300,6 +301,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { registerWalkthroughRoutes(app, { getWalkthroughService }); registerSessionGoalRoutes(app); registerGitHubRoutes(app); + registerLinearRoutes(app); registerGitRoutes(app); registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts }); registerMagicPromptRoutes(app, { diff --git a/packages/web/server/lib/opencode/static-routes-runtime.js b/packages/web/server/lib/opencode/static-routes-runtime.js index de935be5..2feaa4ee 100644 --- a/packages/web/server/lib/opencode/static-routes-runtime.js +++ b/packages/web/server/lib/opencode/static-routes-runtime.js @@ -47,20 +47,20 @@ export const createStaticRoutesRuntime = (dependencies) => { normalizePwaOrientation, }); - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { res.sendFile(path.join(distPath, 'index.html')); }); return; } console.warn(`Warning: ${distPath} not found, static files will not be served`); - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { res.status(404).send('Static files not found. Please build the application first.'); }); }; const registerApiOnlyFallbackRoutes = (app) => { - app.get(/^(?!\/api|\/auth|\/health|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { + app.get(/^(?!\/api|\/auth|\/health|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { const command = 'openchamber connect-url --help'; res.status(200).format({ html: () => { diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index 12831517..b286108c 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -15,6 +15,7 @@ import { createWebNotificationsAPI } from './notifications'; import { createWebToolsAPI } from './tools'; import { createWebPushAPI } from './push'; import { createWebGitHubAPI } from './github'; +import { createWebLinearAPI } from './linear'; import { createWebClientAuthAPI } from './clientAuth'; export interface WebAPIsOptions { @@ -45,6 +46,7 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => { permissions: createWebPermissionsAPI(), notifications: createWebNotificationsAPI(), github: createWebGitHubAPI({ urls: activeUrls }), + linear: createWebLinearAPI(), push: createWebPushAPI(), clientAuth: createWebClientAuthAPI(), tools: createWebToolsAPI(), diff --git a/packages/web/src/api/linear.ts b/packages/web/src/api/linear.ts new file mode 100644 index 00000000..216c72f8 --- /dev/null +++ b/packages/web/src/api/linear.ts @@ -0,0 +1,609 @@ +import type { + LinearAPI, + LinearAuthOrigin, + LinearAuthStart, + LinearAuthStatus, + LinearIssue, + LinearIssueAssignee, + LinearIssueComment, + LinearIssueLabel, + LinearIssuePriority, + LinearIssueGetResult, + LinearIssueState, + LinearIssueStatesResult, + LinearIssueUpdateInput, + LinearIssueUpdateResult, + LinearIssueSummary, + LinearIssueTeam, + LinearIssuesListOptions, + LinearIssuesListResult, + LinearMappingResult, + LinearMappingWrite, + LinearOrganizationSummary, + LinearPreferences, + LinearSessionStatusPostInput, + LinearSessionStatusPostResult, + LinearTeamMapping, + LinearWorkflowState, + LinearUserSummary, + LinearWorkspaceSummary, +} from '@openchamber/ui/lib/api/types'; +import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; + +type LinearJson = { + connected?: boolean; + user?: LinearUserSummary | null; + organization?: LinearOrganizationSummary | null; + scope?: string; + workspaces?: LinearWorkspaceSummary[]; + authorizationUrl?: string; + expiresIn?: number; + removed?: boolean; + error?: string; + issues?: LinearIssueSummary[]; + cursor?: string | null; + hasMore?: boolean; + issue?: LinearIssue | null; + states?: LinearWorkflowState[]; + defaultProjectPath?: string | null; + teams?: LinearTeamMapping[]; + posted?: boolean; + skipped?: string; + commentId?: string | null; + sessionComments?: boolean; +}; + +async function readLinearJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function readErrorMessage(payload: LinearJson | null, fallback: string): string { + const error = payload?.error?.trim(); + return error || fallback; +} + +function readFiniteNumber(value: number | null | undefined): number | null { + return Number.isFinite(value) ? (value ?? null) : null; +} + +function readRawString(value: string | null | undefined): string | null { + return Object.prototype.toString.call(value) === '[object String]' ? `${value}` : null; +} + +function parseUser(payload: LinearUserSummary | null | undefined): LinearUserSummary | null { + const id = payload?.id?.trim(); + if (!id) return null; + return { + id, + name: payload?.name?.trim() || null, + displayName: payload?.displayName?.trim() || null, + email: payload?.email?.trim() || null, + avatarUrl: payload?.avatarUrl?.trim() || null, + }; +} + +function parseOrganization(payload: LinearOrganizationSummary | null | undefined): LinearOrganizationSummary | null { + const id = payload?.id?.trim(); + const name = payload?.name?.trim(); + if (!id || !name) return null; + return { + id, + name, + urlKey: payload?.urlKey?.trim() || null, + }; +} + +function parseWorkspace(payload: LinearWorkspaceSummary | null | undefined): LinearWorkspaceSummary | null { + const id = payload?.id?.trim(); + if (!id) return null; + const authorizedAt = payload?.authorizedAt; + return { + id, + name: payload?.name?.trim() || null, + urlKey: payload?.urlKey?.trim() || null, + current: payload?.current === true, + user: parseUser(payload?.user), + authorizedAt: readFiniteNumber(authorizedAt), + }; +} + +function toAuthStatus(payload: LinearJson | null): LinearAuthStatus | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + const workspaces = Array.isArray(payload.workspaces) + ? payload.workspaces.map(parseWorkspace).filter((entry): entry is LinearWorkspaceSummary => entry != null) + : []; + return { + connected: payload.connected, + user: parseUser(payload.user), + organization: parseOrganization(payload.organization), + scope: payload.scope?.trim() || undefined, + workspaces: payload.connected ? workspaces : undefined, + }; +} + +function toAuthStart(payload: LinearJson | null): LinearAuthStart | null { + const authorizationUrl = payload?.authorizationUrl?.trim(); + const expiresIn = payload?.expiresIn; + const scope = payload?.scope?.trim(); + if (!authorizationUrl || !Number.isFinite(expiresIn) || expiresIn == null || !scope) { + return null; + } + return { authorizationUrl, expiresIn, scope }; +} + +function parseState(payload: LinearIssueState | null | undefined): LinearIssueState | null { + const id = payload?.id?.trim() || null; + const name = payload?.name?.trim() || null; + const type = payload?.type?.trim() || null; + if (!id && !name && !type) return null; + return { id, name, type }; +} + +function parseWorkflowState(payload: LinearWorkflowState | null | undefined): LinearWorkflowState | null { + const id = payload?.id?.trim(); + const name = payload?.name?.trim(); + if (!id || !name) return null; + const position = payload?.position; + return { + id, + name, + type: payload?.type?.trim() || null, + position: readFiniteNumber(position) ?? 0, + }; +} + +function parseAssignee(payload: LinearIssueAssignee | null | undefined): LinearIssueAssignee | null { + const name = payload?.name?.trim() || null; + const displayName = payload?.displayName?.trim() || null; + const avatarUrl = payload?.avatarUrl?.trim() || null; + if (!name && !displayName && !avatarUrl) return null; + return { name, displayName, avatarUrl }; +} + +function parseTeam(payload: LinearIssueTeam | null | undefined): LinearIssueTeam | null { + const id = payload?.id?.trim(); + const key = payload?.key?.trim(); + const name = payload?.name?.trim(); + if (!id || !key || !name) return null; + return { id, key, name }; +} + +function parsePriority(value: LinearIssueSummary['priority']): LinearIssuePriority | null { + if (value !== 0 && value !== 1 && value !== 2 && value !== 3 && value !== 4) { + return null; + } + return value; +} + +function parseLabelColor(value: string | null | undefined): string | null { + const raw = value?.trim(); + if (!raw) return null; + const hex = raw.startsWith('#') ? raw.slice(1) : raw; + if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return null; + return `#${hex.toLowerCase()}`; +} + +function parseLabel(payload: LinearIssueLabel | null | undefined): LinearIssueLabel | null { + if (!payload) return null; + const id = payload?.id?.trim(); + const name = payload?.name?.trim(); + if (!id || !name) return null; + return { + id, + name, + color: parseLabelColor(payload.color), + }; +} + +function parseLabels(payload: LinearIssueSummary['labels']): LinearIssueLabel[] { + if (!Array.isArray(payload)) return []; + return payload.map(parseLabel).filter((label): label is LinearIssueLabel => label != null); +} + +function parseIssueSummary(payload: LinearIssueSummary | null | undefined): LinearIssueSummary | null { + if (!payload) return null; + const id = payload?.id?.trim(); + const identifier = payload?.identifier?.trim(); + const title = payload?.title?.trim(); + const url = payload?.url?.trim(); + if (!id || !identifier || !title || !url) return null; + return { + id, + identifier, + title, + url, + state: parseState(payload.state), + assignee: parseAssignee(payload.assignee), + team: parseTeam(payload.team), + priority: parsePriority(payload.priority), + labels: parseLabels(payload.labels), + }; +} + +function parseComment(payload: LinearIssueComment | null | undefined): LinearIssueComment | null { + const id = payload?.id?.trim(); + if (!id) return null; + const body = payload?.body; + return { + id, + body: readRawString(body) ?? '', + createdAt: payload?.createdAt?.trim() || null, + user: payload?.user + ? { + name: payload.user.name?.trim() || null, + displayName: payload.user.displayName?.trim() || null, + avatarUrl: payload.user.avatarUrl?.trim() || null, + } + : null, + }; +} + +function parseIssue(payload: LinearIssue | null | undefined): LinearIssue | null { + const summary = parseIssueSummary(payload); + if (!summary) return null; + const comments = Array.isArray(payload?.comments) + ? payload.comments.map(parseComment).filter((comment): comment is LinearIssueComment => comment != null) + : []; + const description = payload?.description; + return { + ...summary, + description: readRawString(description), + comments, + }; +} + +function toIssuesList(payload: LinearJson | null): LinearIssuesListResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + const issues = Array.isArray(payload.issues) + ? payload.issues.map(parseIssueSummary).filter((issue): issue is LinearIssueSummary => issue != null) + : []; + return { + connected: true, + issues, + cursor: payload.cursor?.trim() || null, + hasMore: payload.hasMore === true, + }; +} + +function toIssueGet(payload: LinearJson | null): LinearIssueGetResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + return { + connected: true, + issue: parseIssue(payload.issue), + }; +} + +function toIssueStates(payload: LinearJson | null): LinearIssueStatesResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + const states = Array.isArray(payload.states) + ? payload.states.map(parseWorkflowState).filter((state): state is LinearWorkflowState => state != null) + : []; + return { connected: true, states }; +} + +function toIssueUpdate(payload: LinearJson | null): LinearIssueUpdateResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + return { + connected: true, + issue: parseIssue(payload.issue), + }; +} + +function parseTeamMapping(payload: LinearTeamMapping | null | undefined): LinearTeamMapping | null { + const id = payload?.id?.trim(); + const key = payload?.key?.trim(); + const name = payload?.name?.trim(); + if (!id || !key || !name) return null; + const projectPath = payload?.projectPath?.trim() || null; + return { id, key, name, projectPath }; +} + +function toMapping(payload: LinearJson | null): LinearMappingResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + const teams = Array.isArray(payload.teams) + ? payload.teams.map(parseTeamMapping).filter((team): team is LinearTeamMapping => team != null) + : []; + return { + connected: true, + defaultProjectPath: payload.defaultProjectPath?.trim() || null, + teams, + }; +} + +type LinearSessionStatusSkipped = Extract< + LinearSessionStatusPostResult, + { posted: false } +>['skipped']; + +const SESSION_STATUS_SKIPPED: readonly LinearSessionStatusSkipped[] = [ + 'already-posted', + 'issue-not-found', + 'not-started', + 'disabled', + 'origin-not-public', +]; + +function parseSkipped(value: string | undefined): LinearSessionStatusSkipped | null { + return SESSION_STATUS_SKIPPED.find((entry) => entry === value) ?? null; +} + +function toPreferences(payload: LinearJson | null): LinearPreferences | null { + if (payload?.sessionComments !== true && payload?.sessionComments !== false) { + return null; + } + return { sessionComments: payload.sessionComments }; +} + +function toSessionStatusPost(payload: LinearJson | null): LinearSessionStatusPostResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + if (payload.posted === true) { + return { + connected: true, + posted: true, + commentId: payload.commentId?.trim() || null, + }; + } + const skipped = parseSkipped(payload.skipped); + if (payload.posted === false && skipped) { + return { connected: true, posted: false, skipped }; + } + return null; +} + +export const createWebLinearAPI = (): LinearAPI => ({ + async authStatus(): Promise { + const response = await runtimeFetch('/api/linear/auth/status', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const status = toAuthStatus(payload); + if (!response.ok || !status) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear status')); + } + return status; + }, + + async authStart(origin?: LinearAuthOrigin): Promise { + const response = await runtimeFetch('/api/linear/auth/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(origin ? { origin } : {}), + }); + const payload = await readLinearJson(response); + const started = toAuthStart(payload); + if (!response.ok || !started) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to start Linear auth')); + } + return started; + }, + + async authDisconnect(): Promise<{ removed: boolean }> { + const response = await runtimeFetch('/api/linear/auth', { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + if (!response.ok) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to disconnect Linear')); + } + return { removed: payload?.removed === true }; + }, + + async authActivate(organizationId: string): Promise { + const response = await runtimeFetch('/api/linear/auth/activate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ organizationId }), + }); + const payload = await readLinearJson(response); + const status = toAuthStatus(payload); + if (!response.ok || !status) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to switch Linear workspace')); + } + return status; + }, + + async issuesList(options?: LinearIssuesListOptions): Promise { + const params = new URLSearchParams(); + const query = options?.query?.trim(); + const cursor = options?.cursor?.trim(); + const status = options?.status?.trim(); + const assignee = options?.assignee?.trim(); + const teamId = options?.teamId?.trim(); + const priority = options?.priority?.trim(); + if (query) params.set('query', query); + if (cursor) params.set('cursor', cursor); + if (status) params.set('status', status); + if (assignee) params.set('assignee', assignee); + if (teamId) params.set('teamId', teamId); + if (priority) params.set('priority', priority); + const queryString = params.toString(); + const suffix = queryString ? `?${queryString}` : ''; + const response = await runtimeFetch(`/api/linear/issues/list${suffix}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toIssuesList(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear issues')); + } + return result; + }, + + async issueGet(id: string): Promise { + const params = new URLSearchParams({ id }); + const response = await runtimeFetch(`/api/linear/issues/get?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toIssueGet(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear issue')); + } + return result; + }, + + async issueStates(teamId: string): Promise { + const params = new URLSearchParams({ teamId }); + const response = await runtimeFetch(`/api/linear/issues/states?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toIssueStates(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear workflow states')); + } + return result; + }, + + async issueUpdate(input: LinearIssueUpdateInput): Promise { + const response = await runtimeFetch('/api/linear/issues/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + id: input.id, + stateId: input.stateId, + }), + }); + const payload = await readLinearJson(response); + const result = toIssueUpdate(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to update Linear issue')); + } + return result; + }, + + async mappingGet(): Promise { + const response = await runtimeFetch('/api/linear/mapping', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toMapping(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear mapping')); + } + return result; + }, + + async mappingSet(mapping: LinearMappingWrite): Promise { + const response = await runtimeFetch('/api/linear/mapping', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + defaultProjectPath: mapping.defaultProjectPath, + teamProjectPaths: mapping.teamProjectPaths, + }), + }); + const payload = await readLinearJson(response); + const result = toMapping(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to save Linear mapping')); + } + return result; + }, + + async sessionStatusPost(input: LinearSessionStatusPostInput): Promise { + const response = await runtimeFetch('/api/linear/session-status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + kind: input.kind, + sessionId: input.sessionId, + issueIdentifier: input.issueIdentifier, + sessionOrigin: input.sessionOrigin, + }), + }); + const payload = await readLinearJson(response); + const result = toSessionStatusPost(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to post Linear session status')); + } + return result; + }, + + async preferencesGet(): Promise { + const response = await runtimeFetch('/api/linear/preferences', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toPreferences(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear preferences')); + } + return result; + }, + + async preferencesSet(preferences: LinearPreferences): Promise { + const response = await runtimeFetch('/api/linear/preferences', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ sessionComments: preferences.sessionComments }), + }); + const payload = await readLinearJson(response); + const result = toPreferences(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to save Linear preferences')); + } + return result; + }, +}); diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 60877c12..5fbae619 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -115,6 +115,10 @@ export default defineConfig({ target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`, changeOrigin: true, }, + '/linear': { + target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`, + changeOrigin: true, + }, '/api': { target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`, changeOrigin: true, From 391f9383345d065717228966fba0e7d9b50cd735 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 02:24:11 +0300 Subject: [PATCH 20/37] feat(voice): match local and macOS voices to the language of the text Text-to-speech picked one voice regardless of what language a reply was in. A dependency-free language detector (script, marker letters, function words) now decides the language of the whole message once; with the new "Match the voice to the language of the text" setting the local provider switches to a catalog model for that language (Kokoro zh/en and Piper models for 12 languages, downloaded on first use like the existing model) and macOS say switches to an installed voice whose locale matches. The local voice picker lists voices of every installed model, and the settings show which language models are on disk. The Ukrainian Piper medium build is a character-level model that sherpa-onnx turns into noise, so the espeak-based Lada build is used instead. Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98 --- .../sections/openchamber/VoiceSettings.tsx | 221 ++++++++++++------ packages/ui/src/hooks/useLocalTTS.ts | 16 +- packages/ui/src/hooks/useMessageTTS.ts | 7 + packages/ui/src/hooks/useSayTTS.ts | 3 + .../ui/src/lib/i18n/messages/de.settings.ts | 5 +- .../ui/src/lib/i18n/messages/en.settings.ts | 5 +- .../ui/src/lib/i18n/messages/es.settings.ts | 5 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 5 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 5 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 5 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 5 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 5 +- .../ui/src/lib/i18n/messages/tr.settings.ts | 5 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 5 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 5 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 5 +- packages/ui/src/stores/useConfigStore.ts | 35 +++ .../web/server/lib/dictation/DOCUMENTATION.md | 25 +- .../lib/dictation/local/model-catalog.js | 220 +++++++++++++++++ .../lib/dictation/local/model-catalog.test.js | 42 ++++ .../server/lib/dictation/local/sherpa-tts.js | 61 +++-- .../lib/dictation/local/worker-process.js | 2 + packages/web/server/lib/dictation/runtime.js | 4 + packages/web/server/lib/dictation/service.js | 31 ++- packages/web/server/lib/tts/DOCUMENTATION.md | 1 + .../web/server/lib/tts/language-detect.js | 210 +++++++++++++++++ .../server/lib/tts/language-detect.test.js | 76 ++++++ packages/web/server/lib/tts/routes.js | 24 +- packages/web/server/lib/tts/routes.test.js | 26 +++ 29 files changed, 949 insertions(+), 115 deletions(-) create mode 100644 packages/web/server/lib/dictation/local/model-catalog.test.js create mode 100644 packages/web/server/lib/tts/language-detect.js create mode 100644 packages/web/server/lib/tts/language-detect.test.js diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index 8d3a1ac3..9abfeffe 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -71,6 +71,7 @@ const LOCAL_STT_MODELS = [ interface DictationModelState { id: string; + description?: string; installed: boolean; downloading: boolean; downloadProgress: number | null; @@ -288,10 +289,32 @@ const KOKORO_VOICE_OPTIONS = [ const LOCAL_TTS_MODEL_ID = 'kokoro-en-v0_19'; -const LocalTtsModelStatus = () => { - const { t } = useI18n(); - const [model, setModel] = useState(null); - const [requesting, setRequesting] = useState(false); +const KOKORO_MULTI_LANG_MODEL_ID = 'kokoro-multi-lang-v1_1'; +// A few named speakers out of the 103 in the Chinese/English Kokoro build. +const KOKORO_MULTI_LANG_VOICE_OPTIONS = [ + { id: 0, label: 'Maple (af)' }, + { id: 1, label: 'Sol (af)' }, + { id: 2, label: 'Vale (bf)' }, + { id: 3, label: 'Xiaoxiao (zf)' }, + { id: 58, label: 'Yunxi (zm)' }, +]; + +interface LocalTtsVoiceOption { + modelId: string; + speakerId: number; + label: string; +} + +const localTtsVoiceKey = (modelId: string, speakerId: number): string => `${modelId}:${speakerId}`; + +/** + * Local TTS models as the server reports them, plus the actions Settings + * offers on them. Shared by the model list and the voice picker so both see + * the same install state. + */ +const useLocalTtsModels = () => { + const [models, setModels] = useState([]); + const [requestingId, setRequestingId] = useState(null); const refresh = useCallback(async () => { try { @@ -300,11 +323,8 @@ const LocalTtsModelStatus = () => { return; } const data = await response.json(); - const entry = Array.isArray(data?.ttsModels) - ? data.ttsModels.find((m: DictationModelState) => m.id === LOCAL_TTS_MODEL_ID) - : null; - if (entry) { - setModel(entry); + if (Array.isArray(data?.ttsModels)) { + setModels(data.ttsModels); } } catch { // Display-only status; keep the previous state on fetch failure. @@ -315,81 +335,118 @@ const LocalTtsModelStatus = () => { void refresh(); }, [refresh]); + const anyDownloading = models.some((model) => model.downloading); useEffect(() => { - if (!model?.downloading) { + if (!anyDownloading) { return; } const interval = setInterval(() => { void refresh(); }, 2000); return () => clearInterval(interval); - }, [model?.downloading, refresh]); + }, [anyDownloading, refresh]); - const request = async (method: 'POST' | 'DELETE') => { - setRequesting(true); + const request = useCallback(async (modelId: string, method: 'POST' | 'DELETE') => { + setRequestingId(modelId); try { const path = method === 'POST' - ? `/api/dictation/models/${LOCAL_TTS_MODEL_ID}/download` - : `/api/dictation/models/${LOCAL_TTS_MODEL_ID}`; + ? `/api/dictation/models/${modelId}/download` + : `/api/dictation/models/${modelId}`; await runtimeFetch(path, { method }); await refresh(); } catch { // Status refresh reports errors. } finally { - setRequesting(false); + setRequestingId(null); } - }; + }, [refresh]); - if (!model) { + return { models, requestingId, request, refresh }; +}; + +// Voices the picker offers: Kokoro speakers for the Kokoro models, one voice +// per installed Piper model. Only installed models (plus the default) appear, +// so a language model the server fetched on its own becomes selectable once +// it is on disk. +const buildLocalTtsVoiceOptions = (models: DictationModelState[]): LocalTtsVoiceOption[] => { + const options: LocalTtsVoiceOption[] = KOKORO_VOICE_OPTIONS.map((voice) => ({ + modelId: LOCAL_TTS_MODEL_ID, + speakerId: voice.id, + label: voice.label, + })); + for (const model of models) { + if (model.id === LOCAL_TTS_MODEL_ID || !model.installed) continue; + if (model.id === KOKORO_MULTI_LANG_MODEL_ID) { + for (const voice of KOKORO_MULTI_LANG_VOICE_OPTIONS) { + options.push({ modelId: model.id, speakerId: voice.id, label: `${voice.label} · Kokoro zh/en` }); + } + continue; + } + options.push({ modelId: model.id, speakerId: 0, label: model.description ?? model.id }); + } + return options; +}; + +const LocalTtsModelStatus = ({ models, requestingId, request }: ReturnType) => { + const { t } = useI18n(); + + // The default English model is always listed; language models the server + // fetched on its own appear once they are installed or downloading, so + // the list shows what is on disk rather than the whole catalog. + const visible = models.filter((model) => model.id === LOCAL_TTS_MODEL_ID || model.installed || model.downloading); + if (visible.length === 0) { return null; } return ( -
- Kokoro - 305 MB - {model.installed ? ( - <> - - - - ) : model.downloading ? ( - - - - {typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''} - - - ) : ( - - )} - {model.downloadError ? ( - {model.downloadError} - ) : null} +
+ {visible.map((model) => ( +
+ {model.description ?? model.id} + {model.installed ? ( + <> + + + + ) : model.downloading ? ( + + + + {typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''} + + + ) : ( + + )} + {model.downloadError ? ( + {model.downloadError} + ) : null} +
+ ))}
); }; @@ -424,6 +481,12 @@ export const VoiceSettings: React.FC = () => { const sayVoice = useConfigStore((state) => state.sayVoice); const setSayVoice = useConfigStore((state) => state.setSayVoice); const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId); + const localTtsModelId = useConfigStore((state) => state.localTtsModelId); + const setLocalTtsModelId = useConfigStore((state) => state.setLocalTtsModelId); + const localTtsModels = useLocalTtsModels(); + const localTtsVoiceOptions = useMemo(() => buildLocalTtsVoiceOptions(localTtsModels.models), [localTtsModels.models]); + const ttsFollowTextLanguage = useConfigStore((state) => state.ttsFollowTextLanguage); + const setTtsFollowTextLanguage = useConfigStore((state) => state.setTtsFollowTextLanguage); const setLocalTtsVoiceId = useConfigStore((state) => state.setLocalTtsVoiceId); const { speak: speakLocalTts, stop: stopLocalTts, isPlaying: isLocalTtsPlaying, error: localTtsError } = useLocalTTS(); @@ -432,13 +495,14 @@ export const VoiceSettings: React.FC = () => { stopLocalTts(); return; } - const voiceLabel = KOKORO_VOICE_OPTIONS.find((v) => v.id === localTtsVoiceId)?.label + const voiceLabel = localTtsVoiceOptions.find((v) => v.modelId === localTtsModelId && v.speakerId === localTtsVoiceId)?.label ?? String(localTtsVoiceId); void speakLocalTts(t('settings.voice.page.preview.voiceLine', { voiceName: voiceLabel }), { + model: localTtsModelId, speakerId: localTtsVoiceId, speed: useConfigStore.getState().speechRate, }); - }, [isLocalTtsPlaying, localTtsVoiceId, speakLocalTts, stopLocalTts, t]); + }, [isLocalTtsPlaying, localTtsModelId, localTtsVoiceId, localTtsVoiceOptions, speakLocalTts, stopLocalTts, t]); const browserVoice = useConfigStore((state) => state.browserVoice); const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice); const openaiVoice = useConfigStore((state) => state.openaiVoice); @@ -959,24 +1023,39 @@ export const VoiceSettings: React.FC = () => { )} {/* Local (Kokoro) TTS model status */} - {voiceProvider === 'local' && } + {voiceProvider === 'local' && } + + {(voiceProvider === 'local' || voiceProvider === 'say') && ( + + )} {/* Voice Selection */} {voiceProvider === 'local' && ( <> diff --git a/packages/ui/src/hooks/useLocalTTS.ts b/packages/ui/src/hooks/useLocalTTS.ts index 307356a2..0efc1a46 100644 --- a/packages/ui/src/hooks/useLocalTTS.ts +++ b/packages/ui/src/hooks/useLocalTTS.ts @@ -14,10 +14,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { runtimeFetch } from '@/lib/runtime-fetch'; export interface LocalTTSSpeakOptions { - /** Kokoro speaker id (0-10) */ + /** Catalog id of the local model to use; defaults to the server's default model. */ + model?: string; + /** Speaker id within the model (Kokoro voices; Piper models have one) */ speakerId?: number; /** Playback speed multiplier (1.0 = normal) */ speed?: number; + /** + * `'auto'`: the server picks a model and voice for the text's language. + * The language is judged on the whole message, not on each chunk sent for + * synthesis, so a short chunk cannot flip the voice mid-reply. + */ + language?: 'auto'; onStart?: () => void; onEnd?: () => void; onError?: (error: string) => void; @@ -35,6 +43,8 @@ export interface UseLocalTTSReturn { /** Target chunk size: big enough to amortize requests, small enough for low latency. */ const MIN_CHUNK_CHARS = 60; const MAX_CHUNK_CHARS = 400; +// Enough of the message for language detection to see whole sentences. +const LANGUAGE_SAMPLE_CHARS = 2000; /** * Split text into sentence-aligned chunks for pipelined synthesis. @@ -170,6 +180,7 @@ export function useLocalTTS(): UseLocalTTSReturn { const session: PlaybackSession = { cancelled: false, abort: new AbortController() }; sessionRef.current = session; + const languageSample = options?.language === 'auto' ? text.slice(0, LANGUAGE_SAMPLE_CHARS) : undefined; const fetchChunk = async (chunk: string): Promise => { const response = await runtimeFetch('/api/dictation/tts/speak', { @@ -177,8 +188,11 @@ export function useLocalTTS(): UseLocalTTSReturn { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: chunk, + model: options?.model, ...(typeof options?.speakerId === 'number' ? { speakerId: options.speakerId } : {}), ...(typeof options?.speed === 'number' ? { speed: options.speed } : {}), + language: options?.language, + languageSample, }), signal: session.abort.signal, }); diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts index 88e10615..4950e320 100644 --- a/packages/ui/src/hooks/useMessageTTS.ts +++ b/packages/ui/src/hooks/useMessageTTS.ts @@ -61,6 +61,8 @@ export function useMessageTTS(): UseMessageTTSReturn { const speechVolume = useConfigStore((state) => state.speechVolume); const sayVoice = useConfigStore((state) => state.sayVoice); const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId); + const localTtsModelId = useConfigStore((state) => state.localTtsModelId); + const ttsFollowTextLanguage = useConfigStore((state) => state.ttsFollowTextLanguage); const browserVoice = useConfigStore((state) => state.browserVoice); const openaiVoice = useConfigStore((state) => state.openaiVoice); const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice); @@ -135,8 +137,10 @@ export function useMessageTTS(): UseMessageTTSReturn { }); } else if (voiceProvider === 'local') { await speakLocalTTS(sanitizedText, { + model: localTtsModelId, speakerId: localTtsVoiceId, speed: speechRate, + language: ttsFollowTextLanguage ? 'auto' : undefined, onEnd: () => setIsPlaying(false), onError: () => setIsPlaying(false), }); @@ -145,6 +149,7 @@ export function useMessageTTS(): UseMessageTTSReturn { await speakSayTTS(sanitizedText, { voice: sayVoice, rate: wordsPerMinute, + language: ttsFollowTextLanguage ? 'auto' : undefined, onEnd: () => setIsPlaying(false), onError: () => setIsPlaying(false), }); @@ -187,6 +192,8 @@ export function useMessageTTS(): UseMessageTTSReturn { speakSayTTS, speakLocalTTS, localTtsVoiceId, + localTtsModelId, + ttsFollowTextLanguage, stop, ]); diff --git a/packages/ui/src/hooks/useSayTTS.ts b/packages/ui/src/hooks/useSayTTS.ts index c00353b1..f9108e6d 100644 --- a/packages/ui/src/hooks/useSayTTS.ts +++ b/packages/ui/src/hooks/useSayTTS.ts @@ -105,6 +105,8 @@ interface SpeakOptions { voice?: string; /** Speech rate in words per minute (defaults to 200) */ rate?: number; + /** `'auto'`: the server switches to a voice that speaks the text's language. */ + language?: 'auto'; /** Callback when playback starts */ onStart?: () => void; /** Callback when playback ends */ @@ -229,6 +231,7 @@ export function useSayTTS(options: UseSayTTSOptions = {}): UseSayTTSReturn { text: text.trim(), voice: options?.voice || 'Samantha', rate: options?.rate || 200, + language: options?.language, }), signal: abortControllerRef.current.signal, }); diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 75d596f9..86dc0985 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1795,7 +1795,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Server', 'settings.voice.page.provider.local': 'Lokal', 'settings.voice.page.tooltip.sttLocal': 'On-device Transkription auf dem OpenChamber-Server. Modelle werden automatisch heruntergeladen; kein API-Schlüssel erforderlich.', - 'settings.voice.page.tooltip.localTts': 'On-device Synthese auf dem OpenChamber-Server (Kokoro, Englisch). Das Modell wird automatisch heruntergeladen; kein API-Schlüssel erforderlich.', + 'settings.voice.page.tooltip.localTts': 'On-Device-Synthese auf dem OpenChamber-Server (Kokoro für Englisch; Modelle für andere Sprachen werden beim ersten Einsatz geladen). Kein API-Schlüssel nötig.', + 'settings.voice.page.field.followTextLanguage': 'Stimme an die Sprache des Textes anpassen', + 'settings.voice.page.field.followTextLanguageAria': 'Stimme an die Sprache des Textes anpassen', + 'settings.voice.page.field.followTextLanguageInfo': 'Ist eine Antwort in einer anderen Sprache, wird eine Stimme für diese Sprache verwendet: eine passende macOS-Stimme oder ein lokales Modell, das beim ersten Einsatz geladen wird.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (Englisch)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 europäische Sprachen)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (mehrsprachig)', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 95620f19..e0c6468f 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1862,7 +1862,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Server', 'settings.voice.page.provider.local': 'Local', 'settings.voice.page.tooltip.sttLocal': 'On-device transcription on the OpenChamber server. Models download automatically; no API key needed.', - 'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro, English). The model downloads automatically; no API key needed.', + 'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro for English; models for other languages download on first use). No API key needed.', + 'settings.voice.page.field.followTextLanguage': 'Match the voice to the language of the text', + 'settings.voice.page.field.followTextLanguageAria': 'Match the voice to the language of the text', + 'settings.voice.page.field.followTextLanguageInfo': 'When a reply is in another language, a voice for that language is used: a matching macOS voice, or a local model that downloads on first use.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (English)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 European languages)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingual)', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index fa40f214..b72f9e26 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { "settings.voice.page.provider.server": "Servidor", "settings.voice.page.provider.local": "Local", "settings.voice.page.tooltip.sttLocal": "Transcripción local en el servidor de OpenChamber. Los modelos se descargan automáticamente; no se necesita clave de API.", - "settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro, inglés). El modelo se descarga automáticamente; no se necesita clave de API.", + "settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro para inglés; los modelos de otros idiomas se descargan en el primer uso). No requiere clave de API.", + "settings.voice.page.field.followTextLanguage": "Ajustar la voz al idioma del texto", + "settings.voice.page.field.followTextLanguageAria": "Ajustar la voz al idioma del texto", + "settings.voice.page.field.followTextLanguageInfo": "Si una respuesta está en otro idioma, se usa una voz para ese idioma: una voz de macOS adecuada o un modelo local que se descarga en el primer uso.", "settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglés)", "settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeos)", "settings.voice.page.stt.model.whisperBase": "Whisper base (multilingüe)", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 432d536d..ae7d5d4e 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1757,7 +1757,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Serveur', 'settings.voice.page.provider.local': 'Local', 'settings.voice.page.tooltip.sttLocal': 'Transcription locale sur le serveur OpenChamber. Les modèles se téléchargent automatiquement ; aucune clé d\'API requise.', - 'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro, anglais). Le modèle se télécharge automatiquement ; aucune clé d’API requise.', + 'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro pour l’anglais ; les modèles des autres langues sont téléchargés à la première utilisation). Aucune clé API requise.', + 'settings.voice.page.field.followTextLanguage': 'Adapter la voix à la langue du texte', + 'settings.voice.page.field.followTextLanguageAria': 'Adapter la voix à la langue du texte', + 'settings.voice.page.field.followTextLanguageInfo': 'Si une réponse est dans une autre langue, une voix pour cette langue est utilisée : une voix macOS adaptée ou un modèle local téléchargé à la première utilisation.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (anglais)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 langues européennes)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingue)', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 87b35b08..a68f0fc7 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1872,7 +1872,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'サーバー', 'settings.voice.page.provider.local': 'ローカル', 'settings.voice.page.tooltip.sttLocal': 'OpenChamber サーバー上でローカルに文字起こしします。モデルは自動でダウンロードされ、API キーは不要です。', - 'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(Kokoro、英語)。モデルは自動でダウンロードされ、API キーは不要です。', + 'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(英語は Kokoro、他の言語のモデルは初回使用時にダウンロード)。API キーは不要です。', + 'settings.voice.page.field.followTextLanguage': 'テキストの言語に合わせて音声を選ぶ', + 'settings.voice.page.field.followTextLanguageAria': 'テキストの言語に合わせて音声を選ぶ', + 'settings.voice.page.field.followTextLanguageInfo': '返答が別の言語の場合、その言語の音声を使います。対応する macOS の音声、または初回使用時にダウンロードされるローカルモデルです。', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英語)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(ヨーロッパ25言語)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base(多言語)', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 08ac89c7..4fdb93af 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': '서버', 'settings.voice.page.provider.local': '로컬', 'settings.voice.page.tooltip.sttLocal': 'OpenChamber 서버에서 로컬로 변환합니다. 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.', - 'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(Kokoro, 영어). 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.', + 'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(영어는 Kokoro, 다른 언어 모델은 처음 사용할 때 다운로드). API 키가 필요 없습니다.', + 'settings.voice.page.field.followTextLanguage': '텍스트 언어에 맞는 음성 사용', + 'settings.voice.page.field.followTextLanguageAria': '텍스트 언어에 맞는 음성 사용', + 'settings.voice.page.field.followTextLanguageInfo': '응답이 다른 언어이면 해당 언어의 음성을 사용합니다. 일치하는 macOS 음성 또는 처음 사용할 때 다운로드되는 로컬 모델입니다.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (영어)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (유럽 25개 언어)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (다국어)', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index ac8d5357..f8272cc1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -2176,7 +2176,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Serwer', 'settings.voice.page.provider.local': 'Lokalny', 'settings.voice.page.tooltip.sttLocal': 'Transkrypcja lokalna na serwerze OpenChamber. Modele pobierają się automatycznie; klucz API nie jest potrzebny.', - 'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro, angielski). Model pobiera się automatycznie; klucz API nie jest potrzebny.', + 'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro dla angielskiego; modele innych języków pobierane przy pierwszym użyciu). Klucz API nie jest potrzebny.', + 'settings.voice.page.field.followTextLanguage': 'Dopasuj głos do języka tekstu', + 'settings.voice.page.field.followTextLanguageAria': 'Dopasuj głos do języka tekstu', + 'settings.voice.page.field.followTextLanguageInfo': 'Gdy odpowiedź jest w innym języku, używany jest głos dla tego języka: pasujący głos macOS albo lokalny model pobierany przy pierwszym użyciu.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (angielski)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 języków europejskich)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (wielojęzyczny)', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index fb5fa2c1..3f2827c5 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { "settings.voice.page.provider.server": "Servidor", "settings.voice.page.provider.local": "Local", "settings.voice.page.tooltip.sttLocal": "Transcrição local no servidor do OpenChamber. Os modelos são baixados automaticamente; não é necessária chave de API.", - "settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro, inglês). O modelo é baixado automaticamente; não é necessária chave de API.", + "settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro para inglês; modelos de outros idiomas são baixados no primeiro uso). Não requer chave de API.", + "settings.voice.page.field.followTextLanguage": "Ajustar a voz ao idioma do texto", + "settings.voice.page.field.followTextLanguageAria": "Ajustar a voz ao idioma do texto", + "settings.voice.page.field.followTextLanguageInfo": "Se uma resposta estiver em outro idioma, uma voz desse idioma é usada: uma voz do macOS correspondente ou um modelo local baixado no primeiro uso.", "settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglês)", "settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeus)", "settings.voice.page.stt.model.whisperBase": "Whisper base (multilíngue)", diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index c4ce2859..ead5621c 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -1787,7 +1787,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Sunucu', 'settings.voice.page.provider.local': 'Yerel', 'settings.voice.page.tooltip.sttLocal': 'OpenChamber sunucusunda cihaz üstü transkripsiyon. Modeller otomatik indirilir; API anahtarı gerekmez.', - 'settings.voice.page.tooltip.localTts': 'OpenChamber sunucusunda cihaz üstü sentez (Kokoro, İngilizce). Model otomatik indirilir; API anahtarı gerekmez.', + 'settings.voice.page.tooltip.localTts': 'OpenChamber sunucusunda yerel sentez (İngilizce için Kokoro; diğer dillerin modelleri ilk kullanımda indirilir). API anahtarı gerekmez.', + 'settings.voice.page.field.followTextLanguage': 'Sesi metnin diline göre seç', + 'settings.voice.page.field.followTextLanguageAria': 'Sesi metnin diline göre seç', + 'settings.voice.page.field.followTextLanguageInfo': 'Yanıt başka bir dildeyse o dil için bir ses kullanılır: uygun bir macOS sesi veya ilk kullanımda indirilen yerel bir model.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (İngilizce)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 Avrupa dili)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (çok dilli)', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index d65facf2..f3d88411 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { "settings.voice.page.provider.server": "Сервер", "settings.voice.page.provider.local": "Локальний", "settings.voice.page.tooltip.sttLocal": "Локальна розшифровка на сервері OpenChamber. Моделі завантажуються автоматично; ключ API не потрібен.", - "settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro, англійська). Модель завантажується автоматично; ключ API не потрібен.", + "settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro для англійської; моделі для інших мов завантажуються при першому використанні). Ключ API не потрібен.", + "settings.voice.page.field.followTextLanguage": "Підбирати голос під мову тексту", + "settings.voice.page.field.followTextLanguageAria": "Підбирати голос під мову тексту", + "settings.voice.page.field.followTextLanguageInfo": "Якщо відповідь іншою мовою, використовується голос цієї мови: відповідний голос macOS або локальна модель, яка завантажується при першому використанні.", "settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (англійська)", "settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 європейських мов)", "settings.voice.page.stt.model.whisperBase": "Whisper base (мультимовна)", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index c7f1f622..c0eef58a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': '服务器', 'settings.voice.page.provider.local': '本地', 'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 服务器上本地转写。模型自动下载,无需 API 密钥。', - 'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(Kokoro,英语)。模型自动下载,无需 API 密钥。', + 'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(英语使用 Kokoro;其他语言的模型在首次使用时下载)。无需 API 密钥。', + 'settings.voice.page.field.followTextLanguage': '根据文本语言匹配语音', + 'settings.voice.page.field.followTextLanguageAria': '根据文本语言匹配语音', + 'settings.voice.page.field.followTextLanguageInfo': '当回复使用其他语言时,将使用该语言的语音:匹配的 macOS 语音,或首次使用时下载的本地模型。', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英语)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(25 种欧洲语言)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base(多语言)', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index e24521ae..c0639769 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1746,7 +1746,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': '伺服器', 'settings.voice.page.provider.local': '本機', 'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 伺服器上本機轉寫。模型會自動下載,無需 API 金鑰。', - 'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(Kokoro,英文)。模型會自動下載,無需 API 金鑰。', + 'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(英文使用 Kokoro;其他語言的模型在首次使用時下載)。不需要 API 金鑰。', + 'settings.voice.page.field.followTextLanguage': '依文字語言選擇語音', + 'settings.voice.page.field.followTextLanguageAria': '依文字語言選擇語音', + 'settings.voice.page.field.followTextLanguageInfo': '當回覆使用其他語言時,會使用該語言的語音:相符的 macOS 語音,或首次使用時下載的本機模型。', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英文)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(25 種歐洲語言)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base(多語言)', diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index b4a370ca..66f1487e 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1067,6 +1067,10 @@ interface ConfigStore { sayVoice: string; browserVoice: string; localTtsVoiceId: number; + /** Local TTS model the chosen voice belongs to (catalog id). */ + localTtsModelId: string; + /** Local and macOS voices follow the language of the text being read. */ + ttsFollowTextLanguage: boolean; openaiVoice: string; openaiApiKey: string; openaiCompatibleUrl: string; @@ -1094,6 +1098,8 @@ interface ConfigStore { setSayVoice: (voice: string) => void; setBrowserVoice: (voice: string) => void; setLocalTtsVoiceId: (voiceId: number) => void; + setLocalTtsModelId: (modelId: string) => void; + setTtsFollowTextLanguage: (enabled: boolean) => void; setOpenaiVoice: (voice: string) => void; setOpenaiApiKey: (apiKey: string) => void; setOpenaiCompatibleUrl: (url: string) => void; @@ -1277,6 +1283,21 @@ export const useConfigStore = create()( } return 0; })(), + localTtsModelId: (() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('localTtsModelId'); + if (saved) return saved; + } + return 'kokoro-en-v0_19'; + })(), + + ttsFollowTextLanguage: (() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('ttsFollowTextLanguage'); + if (saved !== null) return saved === 'true'; + } + return true; + })(), // Browser voice - load from localStorage or default to empty (auto-select) browserVoice: (() => { if (typeof window !== 'undefined') { @@ -2962,6 +2983,20 @@ export const useConfigStore = create()( } }, + setLocalTtsModelId: (modelId: string) => { + set({ localTtsModelId: modelId }); + if (typeof window !== 'undefined') { + localStorage.setItem('localTtsModelId', modelId); + } + }, + + setTtsFollowTextLanguage: (enabled: boolean) => { + set({ ttsFollowTextLanguage: enabled }); + if (typeof window !== 'undefined') { + localStorage.setItem('ttsFollowTextLanguage', String(enabled)); + } + }, + setBrowserVoice: (voice: string) => { set({ browserVoice: voice }); if (typeof window !== 'undefined') { diff --git a/packages/web/server/lib/dictation/DOCUMENTATION.md b/packages/web/server/lib/dictation/DOCUMENTATION.md index e66af116..49e38aae 100644 --- a/packages/web/server/lib/dictation/DOCUMENTATION.md +++ b/packages/web/server/lib/dictation/DOCUMENTATION.md @@ -11,12 +11,25 @@ live transcript costs O(n^2) work for a result the final decode replaces. The composer shows no text while recording and inserts the full transcript on stop. -Local TTS (Kokoro via sherpa-onnx OfflineTts) runs in the same worker process -and is exposed as `POST /api/dictation/tts/speak` (JSON `{text, speakerId?, -speed?, model?}` → WAV bytes; 503 with `reasonCode` while the model is -downloading). TTS models live in the same catalog/downloader as STT models -(`local/model-catalog.js` `LOCAL_TTS_MODEL_CATALOG`) and are managed by the -same status/download/delete routes. +Local TTS (Kokoro and Piper/VITS via sherpa-onnx OfflineTts) runs in the same +worker process and is exposed as `POST /api/dictation/tts/speak` (JSON +`{text, speakerId?, speed?, model?, language?, languageSample?}` → WAV bytes; 503 with +`reasonCode` while the model is downloading). TTS models live in the same +catalog/downloader as STT models (`local/model-catalog.js` +`LOCAL_TTS_MODEL_CATALOG`) and are managed by the same status/download/delete +routes. + +Each TTS catalog entry declares the `languages` it speaks. With +`language: 'auto'` the service detects the language of `languageSample` — the +whole message the chunk belongs to, sent by the client with every chunk — or +of `text` when no sample is given +(`../tts/language-detect.js`, script plus function-word scoring, no +dependencies) and keeps the caller's model when it speaks that language; +otherwise it switches to the catalog model for the language, downloading it on +first use like any other model, and starts from that model's default speaker +(`defaultSpeakerByLanguage`) instead of the caller's speaker id. A language no +catalog model covers keeps the caller's model, so text is always spoken. The +response carries `X-Speech-Model` and `X-Speech-Language`. ## Ownership diff --git a/packages/web/server/lib/dictation/local/model-catalog.js b/packages/web/server/lib/dictation/local/model-catalog.js index 29ea524b..16af62c9 100644 --- a/packages/web/server/lib/dictation/local/model-catalog.js +++ b/packages/web/server/lib/dictation/local/model-catalog.js @@ -68,9 +68,19 @@ export const LOCAL_STT_MODEL_CATALOG = { * Local text-to-speech models (sherpa-onnx OfflineTts). Downloaded and * managed through the same pipeline as the STT models. */ +/** + * Local text-to-speech models (sherpa-onnx OfflineTts). Downloaded and + * managed through the same pipeline as the STT models. + * + * `languages` lists the languages a model speaks well; the speech service + * uses it to pick a model for the language a text is written in. Kokoro + * models carry speaker ids (`voices`); a Piper model is one voice for one + * language. `lexicon` entries are joined with commas for sherpa-onnx. + */ export const LOCAL_TTS_MODEL_CATALOG = { 'kokoro-en-v0_19': { type: 'kokoro', + languages: ['en'], archiveUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2', extractedDir: 'kokoro-en-v0_19', @@ -82,6 +92,188 @@ export const LOCAL_TTS_MODEL_CATALOG = { }, description: 'Kokoro TTS (English, natural voices)', }, + 'kokoro-multi-lang-v1_1': { + type: 'kokoro', + languages: ['zh', 'en'], + // sherpa-onnx wires this Kokoro build for Chinese and English only; + // speakers 0-2 are English, 3-102 Chinese. + defaultSpeakerByLanguage: { en: 0, zh: 3 }, + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-multi-lang-v1_1.tar.bz2', + extractedDir: 'kokoro-multi-lang-v1_1', + files: { + model: 'model.onnx', + voices: 'voices.bin', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + lexiconEnglish: 'lexicon-us-en.txt', + lexiconChinese: 'lexicon-zh.txt', + }, + lexicon: ['lexiconEnglish', 'lexiconChinese'], + description: 'Kokoro TTS (Chinese and English, 103 voices)', + }, + // The larger `ukrainian_tts-medium` build is a character-level model + // (`phoneme_type: text`); sherpa-onnx phonemizes every Piper model through + // espeak-ng, which turns that one into noise. `vits-coqui-uk-mai` sounds + // better but reads Cyrillic only and drops every Latin word (file names, + // product names), which is unusable in a coding chat. Lada is an espeak + // model: small, but it reads mixed text. + 'piper-uk_UA-lada-x_low': { + type: 'vits', + languages: ['uk'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-uk_UA-lada-x_low.tar.bz2', + extractedDir: 'vits-piper-uk_UA-lada-x_low', + files: { + model: 'uk_UA-lada-x_low.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Ukrainian)', + }, + 'piper-de_DE-thorsten-medium': { + type: 'vits', + languages: ['de'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-de_DE-thorsten-medium.tar.bz2', + extractedDir: 'vits-piper-de_DE-thorsten-medium', + files: { + model: 'de_DE-thorsten-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (German)', + }, + 'piper-fr_FR-siwis-medium': { + type: 'vits', + languages: ['fr'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-fr_FR-siwis-medium.tar.bz2', + extractedDir: 'vits-piper-fr_FR-siwis-medium', + files: { + model: 'fr_FR-siwis-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (French)', + }, + 'piper-es_ES-davefx-medium': { + type: 'vits', + languages: ['es'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-es_ES-davefx-medium.tar.bz2', + extractedDir: 'vits-piper-es_ES-davefx-medium', + files: { + model: 'es_ES-davefx-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Spanish)', + }, + 'piper-it_IT-paola-medium': { + type: 'vits', + languages: ['it'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-it_IT-paola-medium.tar.bz2', + extractedDir: 'vits-piper-it_IT-paola-medium', + files: { + model: 'it_IT-paola-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Italian)', + }, + 'piper-pt_BR-faber-medium': { + type: 'vits', + languages: ['pt'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-pt_BR-faber-medium.tar.bz2', + extractedDir: 'vits-piper-pt_BR-faber-medium', + files: { + model: 'pt_BR-faber-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Portuguese (Brazil))', + }, + 'piper-pl_PL-gosia-medium': { + type: 'vits', + languages: ['pl'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-pl_PL-gosia-medium.tar.bz2', + extractedDir: 'vits-piper-pl_PL-gosia-medium', + files: { + model: 'pl_PL-gosia-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Polish)', + }, + 'piper-ru_RU-irina-medium': { + type: 'vits', + languages: ['ru'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-ru_RU-irina-medium.tar.bz2', + extractedDir: 'vits-piper-ru_RU-irina-medium', + files: { + model: 'ru_RU-irina-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Russian)', + }, + 'piper-nl_NL-pim-medium': { + type: 'vits', + languages: ['nl'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-nl_NL-pim-medium.tar.bz2', + extractedDir: 'vits-piper-nl_NL-pim-medium', + files: { + model: 'nl_NL-pim-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Dutch)', + }, + 'piper-cs_CZ-jirka-medium': { + type: 'vits', + languages: ['cs'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-cs_CZ-jirka-medium.tar.bz2', + extractedDir: 'vits-piper-cs_CZ-jirka-medium', + files: { + model: 'cs_CZ-jirka-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Czech)', + }, + 'piper-tr_TR-dfki-medium': { + type: 'vits', + languages: ['tr'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-tr_TR-dfki-medium.tar.bz2', + extractedDir: 'vits-piper-tr_TR-dfki-medium', + files: { + model: 'tr_TR-dfki-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Turkish)', + }, + 'piper-sv_SE-nst-medium': { + type: 'vits', + languages: ['sv'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-sv_SE-nst-medium.tar.bz2', + extractedDir: 'vits-piper-sv_SE-nst-medium', + files: { + model: 'sv_SE-nst-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Swedish)', + }, }; export const DEFAULT_LOCAL_STT_MODEL = 'parakeet-tdt-0.6b-v2-int8'; @@ -131,6 +323,34 @@ export function getLocalSttModelSpec(modelId) { }; } +/** + * The local TTS model to use for a language, preferring the model the user + * selected when it speaks that language. Returns null when no catalog model + * covers the language, in which case callers keep the selected model. + * @param {string} language BCP-47 primary subtag (`uk`, `zh`...) + * @param {string} [preferredModelId] + * @returns {string | null} + */ +export function resolveLocalTtsModelForLanguage(language, preferredModelId) { + const speaks = (modelId) => LOCAL_TTS_MODEL_CATALOG[modelId]?.languages?.includes(language) === true; + if (preferredModelId && speaks(preferredModelId)) return preferredModelId; + const candidate = LOCAL_TTS_MODEL_IDS.find(speaks); + return candidate ?? null; +} + +/** + * The speaker id a model should use for a language when the caller's + * speaker was chosen for another language. `undefined` keeps the caller's + * speaker. + * @param {string} modelId + * @param {string} language + * @returns {number | undefined} + */ +export function getLocalTtsDefaultSpeaker(modelId, language) { + const speaker = LOCAL_TTS_MODEL_CATALOG[modelId]?.defaultSpeakerByLanguage?.[language]; + return Number.isInteger(speaker) ? speaker : undefined; +} + /** * @param {string} modelsDir * @param {string} modelId diff --git a/packages/web/server/lib/dictation/local/model-catalog.test.js b/packages/web/server/lib/dictation/local/model-catalog.test.js new file mode 100644 index 00000000..e14a66aa --- /dev/null +++ b/packages/web/server/lib/dictation/local/model-catalog.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_LOCAL_TTS_MODEL, + LOCAL_TTS_MODEL_CATALOG, + getLocalSttModelSpec, + getLocalTtsDefaultSpeaker, + resolveLocalTtsModelForLanguage, +} from './model-catalog.js'; + +describe('local TTS catalog', () => { + it('keeps the selected model when it speaks the language', () => { + expect(resolveLocalTtsModelForLanguage('en', DEFAULT_LOCAL_TTS_MODEL)).toBe(DEFAULT_LOCAL_TTS_MODEL); + expect(resolveLocalTtsModelForLanguage('zh', 'kokoro-multi-lang-v1_1')).toBe('kokoro-multi-lang-v1_1'); + }); + + it('picks a catalog model for a language the selected model lacks', () => { + expect(resolveLocalTtsModelForLanguage('uk', DEFAULT_LOCAL_TTS_MODEL)).toBe('piper-uk_UA-lada-x_low'); + expect(resolveLocalTtsModelForLanguage('zh', DEFAULT_LOCAL_TTS_MODEL)).toBe('kokoro-multi-lang-v1_1'); + }); + + it('returns null for a language no model covers', () => { + expect(resolveLocalTtsModelForLanguage('xx', DEFAULT_LOCAL_TTS_MODEL)).toBeNull(); + }); + + it('gives Chinese a Chinese speaker on the multi-language Kokoro', () => { + expect(getLocalTtsDefaultSpeaker('kokoro-multi-lang-v1_1', 'zh')).toBe(3); + expect(getLocalTtsDefaultSpeaker('kokoro-multi-lang-v1_1', 'en')).toBe(0); + expect(getLocalTtsDefaultSpeaker('piper-uk_UA-lada-x_low', 'uk')).toBeUndefined(); + }); + + it('every TTS entry declares its languages and installable files', () => { + for (const [id, spec] of Object.entries(LOCAL_TTS_MODEL_CATALOG)) { + expect(spec.languages.length, id).toBeGreaterThan(0); + expect(spec.archiveUrl, id).toMatch(/^https:\/\/github\.com\/k2-fsa\/sherpa-onnx\/releases\/download\/tts-models\//); + const resolved = getLocalSttModelSpec(id); + expect(resolved.requiredFiles, id).toContain(spec.files.model); + for (const key of spec.lexicon ?? []) { + expect(spec.files[key], `${id} lexicon ${key}`).toBeTruthy(); + } + } + }); +}); diff --git a/packages/web/server/lib/dictation/local/sherpa-tts.js b/packages/web/server/lib/dictation/local/sherpa-tts.js index f4fa7972..589bae69 100644 --- a/packages/web/server/lib/dictation/local/sherpa-tts.js +++ b/packages/web/server/lib/dictation/local/sherpa-tts.js @@ -1,5 +1,5 @@ /** - * Sherpa-onnx offline TTS (Kokoro). Runs inside the dictation worker process + * Sherpa-onnx offline TTS (Kokoro and Piper/VITS). Runs inside the dictation worker process * only — never load the native addon in the main server process. */ @@ -23,20 +23,49 @@ function float32ToPcm16le(samples) { return Buffer.from(out.buffer, out.byteOffset, out.byteLength); } +/** + * sherpa-onnx model config for one catalog entry. Kokoro carries a voices + * bank (speaker ids) and optional lexicons; a Piper/VITS model is a single + * voice with espeak-ng phonemization. + * @param {{ modelDir: string, type?: string, files: Record, lexicon?: string[] }} config + */ +function buildModelConfig(config) { + const file = (key, label) => { + const filePath = path.join(config.modelDir, config.files[key]); + assertFileExists(filePath, label); + return filePath; + }; + const modelPath = file('model', 'TTS model'); + const tokensPath = file('tokens', 'TTS tokens'); + + if (config.type === 'vits') { + // Piper models phonemize through espeak-ng (`espeakData`); character + // models (Coqui) read the text directly and carry no espeak data. + const dataDir = config.files.espeakData ? file('espeakData', 'TTS espeak-ng dataDir') : ''; + return { vits: { model: modelPath, tokens: tokensPath, ...(dataDir ? { dataDir } : {}), lengthScale: 1.0 } }; + } + + const dataDir = file('espeakData', 'TTS espeak-ng dataDir'); + const voicesPath = file('voices', 'TTS voices'); + const lexicon = (config.lexicon ?? []).map((key) => file(key, 'TTS lexicon')).join(','); + return { + kokoro: { + model: modelPath, + voices: voicesPath, + tokens: tokensPath, + dataDir, + lengthScale: 1.0, + ...(lexicon ? { lexicon } : {}), + }, + }; +} + export class SherpaTtsEngine { /** - * @param {{ modelDir: string, files: { model: string, voices: string, tokens: string, espeakData: string }, numThreads?: number }} config + * @param {{ modelDir: string, type?: string, files: Record, lexicon?: string[], numThreads?: number }} config */ constructor(config) { - const modelPath = path.join(config.modelDir, config.files.model); - const voicesPath = path.join(config.modelDir, config.files.voices); - const tokensPath = path.join(config.modelDir, config.files.tokens); - const dataDir = path.join(config.modelDir, config.files.espeakData); - - assertFileExists(modelPath, 'TTS model'); - assertFileExists(voicesPath, 'TTS voices'); - assertFileExists(tokensPath, 'TTS tokens'); - assertFileExists(dataDir, 'TTS espeak-ng dataDir'); + const model = buildModelConfig(config); const sherpa = loadSherpaOnnxNode(); if (typeof sherpa.OfflineTts !== 'function') { @@ -44,15 +73,7 @@ export class SherpaTtsEngine { } this.tts = new sherpa.OfflineTts({ - model: { - kokoro: { - model: modelPath, - voices: voicesPath, - tokens: tokensPath, - dataDir, - lengthScale: 1.0, - }, - }, + model, numThreads: config.numThreads ?? 2, provider: 'cpu', maxNumSentences: 1, diff --git a/packages/web/server/lib/dictation/local/worker-process.js b/packages/web/server/lib/dictation/local/worker-process.js index 3a5c34b8..06a7ac8b 100644 --- a/packages/web/server/lib/dictation/local/worker-process.js +++ b/packages/web/server/lib/dictation/local/worker-process.js @@ -102,7 +102,9 @@ function getTtsEngine(modelsDir, modelId) { const spec = getLocalSttModelSpec(modelId); const created = new SherpaTtsEngine({ modelDir: getLocalSttModelDir(modelsDir, modelId), + type: spec.type, files: spec.files, + lexicon: spec.lexicon, numThreads: 2, }); ttsEngines.set(key, created); diff --git a/packages/web/server/lib/dictation/runtime.js b/packages/web/server/lib/dictation/runtime.js index 8fae1fcf..59437a12 100644 --- a/packages/web/server/lib/dictation/runtime.js +++ b/packages/web/server/lib/dictation/runtime.js @@ -63,6 +63,8 @@ export function createDictationRuntime({ model: typeof req.body?.model === 'string' ? req.body.model : undefined, speakerId: Number.isInteger(req.body?.speakerId) ? req.body.speakerId : undefined, speed: typeof req.body?.speed === 'number' ? req.body.speed : undefined, + language: req.body?.language === 'auto' ? 'auto' : undefined, + languageSample: typeof req.body?.languageSample === 'string' ? req.body.languageSample.slice(0, 4000) : undefined, }); if (result.error) { res.status(503).json({ @@ -73,6 +75,8 @@ export function createDictationRuntime({ return; } res.setHeader('Content-Type', result.format || 'audio/wav'); + res.setHeader('X-Speech-Model', result.modelId); + if (result.language) res.setHeader('X-Speech-Language', result.language); res.send(result.audio); } catch (error) { res.status(500).json({ error: error?.message || 'Failed to synthesize speech' }); diff --git a/packages/web/server/lib/dictation/service.js b/packages/web/server/lib/dictation/service.js index 7dc555d2..e40bc253 100644 --- a/packages/web/server/lib/dictation/service.js +++ b/packages/web/server/lib/dictation/service.js @@ -1,3 +1,4 @@ +import { detectTextLanguage } from '../tts/language-detect.js'; /** * Dictation service: resolves STT providers, tracks local model download * state, and exposes a readiness snapshot for the status route. @@ -16,6 +17,8 @@ import { OpenAICompatibleTranscriptionSession } from './openai-compatible-sessio import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + getLocalTtsDefaultSpeaker, + resolveLocalTtsModelForLanguage, LOCAL_STT_MODEL_CATALOG, LOCAL_STT_MODEL_IDS, LOCAL_TTS_MODEL_CATALOG, @@ -220,10 +223,30 @@ export function createDictationService({ modelsDir }) { /** * Synthesize speech with the local TTS model. Returns WAV bytes, or a * readiness error while the model is missing/downloading. - * @param {{ text: string, model?: string, speakerId?: number, speed?: number }} options + * + * With `language: 'auto'` the text's language decides the model: the + * caller's model when it speaks that language, otherwise the catalog + * model for it (downloaded on first use, reported as in-progress until it + * lands). The caller's speaker id is kept only on the caller's model; a + * substitute model starts from its own default speaker for the language. + * A language no catalog model covers keeps the caller's model, so text is + * never silently dropped. + * `languageSample` is the whole message the chunk belongs to (or a prefix + * of it): the language is judged on that, never on a short chunk alone. + * @param {{ text: string, model?: string, speakerId?: number, speed?: number, language?: string, languageSample?: string }} options */ - const synthesizeSpeech = async ({ text, model, speakerId, speed }) => { - const modelId = isLocalTtsModelId(model) ? model : DEFAULT_LOCAL_TTS_MODEL; + const synthesizeSpeech = async ({ text, model, speakerId, speed, language, languageSample }) => { + const requestedModelId = isLocalTtsModelId(model) ? model : DEFAULT_LOCAL_TTS_MODEL; + let modelId = requestedModelId; + let resolvedLanguage = null; + if (language === 'auto') { + resolvedLanguage = detectTextLanguage(languageSample || text).language; + const forLanguage = resolveLocalTtsModelForLanguage(resolvedLanguage, requestedModelId); + if (forLanguage && forLanguage !== requestedModelId) { + modelId = forLanguage; + speakerId = getLocalTtsDefaultSpeaker(modelId, resolvedLanguage); + } + } const installed = await isLocalSttModelInstalled(modelsDir, modelId); if (!installed) { const state = downloadStates.get(modelId); @@ -251,7 +274,7 @@ export function createDictationService({ modelsDir }) { speakerId, speed, }); - return { audio: result.audio, format: result.format }; + return { audio: result.audio, format: result.format, modelId, language: resolvedLanguage }; }; /** diff --git a/packages/web/server/lib/tts/DOCUMENTATION.md b/packages/web/server/lib/tts/DOCUMENTATION.md index 81a46760..2e0c4352 100644 --- a/packages/web/server/lib/tts/DOCUMENTATION.md +++ b/packages/web/server/lib/tts/DOCUMENTATION.md @@ -11,6 +11,7 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API. - `packages/web/server/lib/text/summarization.js`: Shared text summarization stub and sanitization utilities. It performs no external Zen calls. - `packages/web/server/lib/tts/stt.js`: STT proxy for OpenAI-compatible transcription endpoints. - `packages/web/server/lib/tts/base-url.js`: shared base URL validation and normalization for custom OpenAI-compatible endpoints. +- `packages/web/server/lib/tts/language-detect.js`: dependency-free language detection for voice selection (`detectTextLanguage`, `pickVoiceForLanguage`, `languageOfLocale`). Used by the macOS `say` route (`language: 'auto'` switches to an installed voice whose locale matches the text; the response carries `X-Speech-Voice` and `X-Speech-Language`) and by the dictation module's local TTS model choice. ## Public exports diff --git a/packages/web/server/lib/tts/language-detect.js b/packages/web/server/lib/tts/language-detect.js new file mode 100644 index 00000000..d87968e6 --- /dev/null +++ b/packages/web/server/lib/tts/language-detect.js @@ -0,0 +1,210 @@ +/** + * Language detection for text-to-speech voice selection. + * + * Picks the language a piece of chat text is written in so a TTS provider + * can choose a matching voice or model. Deliberately small and dependency + * free: the writing system decides most cases outright, and Latin-script + * languages are told apart by function words and characteristic letters. + * The answer is a best effort for voice selection, not a linguistic claim — + * an unknown language falls back to English rather than failing. + */ + +const SCRIPT_RANGES = [ + ['hangul', /[가-힯ᄀ-ᇿ㄰-㆏]/g], + ['kana', /[぀-ヿ]/g], + ['han', /[一-鿿㐀-䶿]/g], + ['cyrillic', /[Ѐ-ӿ]/g], + ['greek', /[Ͱ-Ͽ]/g], + ['arabic', /[؀-ۿ]/g], + ['hebrew', /[֐-׿]/g], + ['thai', /[฀-๿]/g], + ['devanagari', /[ऀ-ॿ]/g], + ['latin', /[A-Za-zÀ-ɏ]/g], +]; + +const SCRIPT_LANGUAGE = { + hangul: 'ko', + greek: 'el', + arabic: 'ar', + hebrew: 'he', + thai: 'th', + devanagari: 'hi', +}; + +// Letters that only (or overwhelmingly) occur in one language of a script. +const LATIN_MARKERS = { + pl: /[łęąńśźż]/i, + cs: /[řěůťďň]/i, + tr: /[ğışİ]/, + pt: /[ãõ]/i, + es: /[ñ¿¡]/, + de: /[ß]/, + fr: /[œ]/i, + sv: /[å]/i, +}; + +// Frequent function words per language. Scored by whole-word hits; every +// list has the same length so scores stay comparable. +const STOPWORDS = { + en: ['the', 'and', 'is', 'to', 'of', 'that', 'you', 'with', 'for', 'this', 'are', 'it', 'not', 'have', 'can', 'will', 'your', 'from', 'which', 'when'], + de: ['und', 'der', 'die', 'das', 'ist', 'nicht', 'mit', 'ein', 'eine', 'auch', 'sich', 'auf', 'für', 'wird', 'werden', 'oder', 'aber', 'wenn', 'sind', 'kann'], + fr: ['le', 'la', 'les', 'et', 'est', 'une', 'des', 'pour', 'que', 'qui', 'dans', 'pas', 'vous', 'sur', 'avec', 'sont', 'nous', 'cette', 'mais', 'plus'], + es: ['el', 'la', 'los', 'las', 'que', 'es', 'una', 'por', 'para', 'con', 'del', 'como', 'pero', 'más', 'este', 'esta', 'son', 'tiene', 'puede', 'también'], + it: ['il', 'la', 'che', 'di', 'è', 'una', 'per', 'non', 'con', 'del', 'della', 'come', 'sono', 'anche', 'questo', 'questa', 'gli', 'nel', 'più', 'essere'], + pt: ['o', 'a', 'os', 'as', 'que', 'é', 'uma', 'para', 'com', 'não', 'do', 'da', 'como', 'mas', 'também', 'este', 'esta', 'são', 'você', 'pode'], + pl: ['i', 'nie', 'jest', 'się', 'na', 'to', 'że', 'jak', 'ale', 'dla', 'oraz', 'przez', 'czy', 'tym', 'jego', 'można', 'jeśli', 'tego', 'które', 'także'], + nl: ['de', 'het', 'een', 'en', 'van', 'is', 'niet', 'dat', 'met', 'voor', 'ook', 'zijn', 'maar', 'als', 'wordt', 'deze', 'kan', 'naar', 'bij', 'dan'], + cs: ['a', 'je', 'se', 'na', 'to', 'že', 'jak', 'ale', 'pro', 'nebo', 'jsou', 'může', 'také', 'tento', 'když', 'jeho', 'které', 'být', 'aby', 'ještě'], + tr: ['ve', 'bir', 'bu', 'için', 'ile', 'de', 'da', 'ama', 'gibi', 'daha', 'var', 'olarak', 'çok', 'ne', 'her', 'kadar', 'sonra', 'değil', 'olan', 'ise'], + sv: ['och', 'att', 'det', 'är', 'en', 'som', 'för', 'inte', 'med', 'till', 'den', 'kan', 'har', 'ett', 'men', 'också', 'eller', 'från', 'när', 'vara'], + uk: ['і', 'та', 'що', 'це', 'не', 'як', 'для', 'він', 'вона', 'але', 'або', 'також', 'тільки', 'вже', 'якщо', 'його', 'цей', 'ця', 'бути', 'коли'], + ru: ['и', 'что', 'это', 'не', 'как', 'для', 'он', 'она', 'но', 'или', 'также', 'только', 'уже', 'если', 'его', 'этот', 'эта', 'быть', 'когда', 'чтобы'], +}; + +const LATIN_LANGUAGES = ['en', 'de', 'fr', 'es', 'it', 'pt', 'pl', 'nl', 'cs', 'tr', 'sv']; +const CYRILLIC_LANGUAGES = ['uk', 'ru']; + +const countMatches = (text, pattern) => { + const matches = text.match(pattern); + return matches ? matches.length : 0; +}; + +const scoreStopwords = (words, languages) => { + const scores = {}; + for (const language of languages) { + const list = new Set(STOPWORDS[language]); + let hits = 0; + for (const word of words) { + if (list.has(word)) hits += 1; + } + scores[language] = hits; + } + return scores; +}; + +const bestOf = (scores, fallback) => { + let best = fallback; + let bestScore = 0; + for (const [language, score] of Object.entries(scores)) { + if (score > bestScore) { + best = language; + bestScore = score; + } + } + return best; +}; + +const pickByMarkers = (text, markers) => { + for (const [language, pattern] of Object.entries(markers)) { + if (pattern.test(text)) return language; + } + return null; +}; + +/** + * @param {string} text + * @returns {{ language: string, script: string }} BCP-47 primary language subtag and the dominant script. + */ +export function detectTextLanguage(text) { + const source = typeof text === 'string' ? text : ''; + const counts = SCRIPT_RANGES.map(([script, pattern]) => [script, countMatches(source, pattern)]); + const letters = counts.reduce((sum, [, count]) => sum + count, 0); + if (letters === 0) return { language: 'en', script: 'latin' }; + + // Kana settles Japanese even when Han dominates the character count. + const kana = counts.find(([script]) => script === 'kana')?.[1] ?? 0; + const han = counts.find(([script]) => script === 'han')?.[1] ?? 0; + if (kana > 0 && kana + han >= letters * 0.3) return { language: 'ja', script: 'kana' }; + if (han > 0 && han >= letters * 0.3) return { language: 'zh', script: 'han' }; + + const [script] = counts.reduce((best, entry) => (entry[1] > best[1] ? entry : best)); + + if (script in SCRIPT_LANGUAGE) return { language: SCRIPT_LANGUAGE[script], script }; + + const words = source.toLowerCase().split(/[^\p{L}\p{M}']+/u).filter(Boolean); + + if (script === 'cyrillic') { + const scores = scoreStopwords(words, CYRILLIC_LANGUAGES); + const ukMarkers = countMatches(source, /[іїєґ]/gi); + const ruMarkers = countMatches(source, /[ыэъё]/gi); + // Letters decide: the two alphabets differ in letters that occur in + // nearly every sentence. Function words only settle a text that shows + // neither set, and a text with no Russian-only letters is far more + // likely Ukrainian than the reverse, so that tie goes to Ukrainian. + if (ukMarkers !== ruMarkers) return { language: ukMarkers > ruMarkers ? 'uk' : 'ru', script }; + if (scores.uk !== scores.ru) return { language: scores.uk > scores.ru ? 'uk' : 'ru', script }; + return { language: ruMarkers > 0 ? 'ru' : 'uk', script }; + } + + const scores = scoreStopwords(words, LATIN_LANGUAGES); + const marked = pickByMarkers(source, LATIN_MARKERS); + // A characteristic letter outranks stopword counts unless another language + // clearly dominates the function words (a German text quoting "façade"). + if (marked && scores[marked] * 2 >= scores[bestOf(scores, marked)]) { + return { language: marked, script }; + } + return { language: bestOf(scores, 'en'), script }; +} + +/** + * Map a detected language onto the locales a voice list uses (`uk_UA`, + * `en_US`...). Returns the preferred locale prefixes in order. + * @param {string} language + * @returns {string[]} + */ +function localePrefixesForLanguage(language) { + const table = { + en: ['en_US', 'en_GB', 'en'], + uk: ['uk_UA', 'uk'], + ru: ['ru_RU', 'ru'], + de: ['de_DE', 'de'], + fr: ['fr_FR', 'fr_CA', 'fr'], + es: ['es_ES', 'es_MX', 'es'], + it: ['it_IT', 'it'], + pt: ['pt_BR', 'pt_PT', 'pt'], + pl: ['pl_PL', 'pl'], + nl: ['nl_NL', 'nl_BE', 'nl'], + cs: ['cs_CZ', 'cs'], + tr: ['tr_TR', 'tr'], + sv: ['sv_SE', 'sv'], + zh: ['zh_CN', 'zh_TW', 'zh_HK', 'zh'], + ja: ['ja_JP', 'ja'], + ko: ['ko_KR', 'ko'], + el: ['el_GR', 'el'], + ar: ['ar_001', 'ar_SA', 'ar'], + he: ['he_IL', 'he'], + th: ['th_TH', 'th'], + hi: ['hi_IN', 'hi'], + }; + return table[language] ?? [language]; +} + +/** + * Choose a voice for a language from a `say`-style voice list. + * Prefers an enhanced/premium variant of a matching voice, then any voice of + * the exact locale, then any voice of the language. Returns null when the + * list has no voice for that language. + * @param {string} language + * @param {ReadonlyArray<{ name: string, locale: string }>} voices + * @returns {string | null} + */ +export function pickVoiceForLanguage(language, voices) { + const prefixes = localePrefixesForLanguage(language); + for (const prefix of prefixes) { + const matching = voices.filter((voice) => voice.locale === prefix || voice.locale.startsWith(`${prefix}_`) || (prefix === language && voice.locale.startsWith(`${language}_`))); + if (matching.length === 0) continue; + const enhanced = matching.find((voice) => /\((Enhanced|Premium)\)/i.test(voice.name)); + return (enhanced ?? matching[0]).name; + } + return null; +} + +/** + * Language of a voice, from its locale (`uk_UA` → `uk`). + * @param {string | null | undefined} locale + * @returns {string | null} + */ +export function languageOfLocale(locale) { + if (typeof locale !== 'string' || !locale) return null; + return locale.split(/[_-]/)[0].toLowerCase(); +} diff --git a/packages/web/server/lib/tts/language-detect.test.js b/packages/web/server/lib/tts/language-detect.test.js new file mode 100644 index 00000000..a8c0f2c3 --- /dev/null +++ b/packages/web/server/lib/tts/language-detect.test.js @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { detectTextLanguage, languageOfLocale, pickVoiceForLanguage } from './language-detect.js'; + +describe('detectTextLanguage', () => { + it.each([ + ['en', 'The build is green and the tests pass, so you can merge this now.'], + ['uk', 'Привіт! Це тестове повідомлення, і воно написане українською мовою.'], + ['ru', 'Привет! Это тестовое сообщение, и оно написано на русском языке.'], + ['de', 'Die Änderung ist fertig und die Tests laufen ohne Fehler durch.'], + ['fr', 'La modification est prête et les tests passent sans erreur.'], + ['es', 'El cambio está listo y las pruebas pasan sin errores.'], + ['it', 'La modifica è pronta e i test passano senza errori.'], + ['pt', 'A alteração está pronta e os testes passam sem erros, você pode continuar.'], + ['pl', 'Zmiana jest gotowa i testy przechodzą bez błędów.'], + ['nl', 'De wijziging is klaar en de tests slagen zonder fouten.'], + ['cs', 'Změna je hotová a testy procházejí bez chyb.'], + ['tr', 'Değişiklik hazır ve testler hatasız geçiyor.'], + ['sv', 'Ändringen är klar och testerna går igenom utan fel.'], + ['zh', '修改已经完成,所有测试都通过了。'], + ['ja', '変更が完了し、すべてのテストに合格しました。'], + ['ko', '변경이 완료되었고 모든 테스트를 통과했습니다.'], + ])('detects %s', (language, text) => { + expect(detectTextLanguage(text).language).toBe(language); + }); + + it.each([ + ['uk', 'Готово. Запушено.'], + ['uk', 'Все ок'], + ['uk', 'Добре, давай так зробимо'], + ['ru', 'Хорошо, давай так и сделаем'], + ['ru', 'Готово, всё запушено.'], + ])('tells short %s phrases apart by letters', (language, text) => { + expect(detectTextLanguage(text).language).toBe(language); + }); + + it('falls back to English for text without letters', () => { + expect(detectTextLanguage('1234 ... !!!').language).toBe('en'); + expect(detectTextLanguage('').language).toBe('en'); + }); + + it('does not let a single quoted foreign word flip an English paragraph', () => { + const text = 'The façade of the building is the part that you see from the street, and it is not the same as the interior.'; + expect(detectTextLanguage(text).language).toBe('en'); + }); +}); + +describe('pickVoiceForLanguage', () => { + const voices = [ + { name: 'Samantha', locale: 'en_US' }, + { name: 'Daniel', locale: 'en_GB' }, + { name: 'Lesya', locale: 'uk_UA' }, + { name: 'Lesya (Enhanced)', locale: 'uk_UA' }, + { name: 'Milena', locale: 'ru_RU' }, + { name: 'Anna', locale: 'de_DE' }, + ]; + + it('prefers the enhanced variant of a matching voice', () => { + expect(pickVoiceForLanguage('uk', voices)).toBe('Lesya (Enhanced)'); + }); + + it('prefers the primary locale of a language', () => { + expect(pickVoiceForLanguage('en', voices)).toBe('Samantha'); + }); + + it('returns null when no voice speaks the language', () => { + expect(pickVoiceForLanguage('ja', voices)).toBeNull(); + }); +}); + +describe('languageOfLocale', () => { + it('reads the language subtag', () => { + expect(languageOfLocale('uk_UA')).toBe('uk'); + expect(languageOfLocale('en-GB')).toBe('en'); + expect(languageOfLocale(null)).toBeNull(); + }); +}); diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js index 5d2c4618..2f2074ff 100644 --- a/packages/web/server/lib/tts/routes.js +++ b/packages/web/server/lib/tts/routes.js @@ -2,6 +2,8 @@ import express from 'express'; import { normalizeCustomOpenAIBaseURL } from './base-url.js'; import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js'; +import { detectTextLanguage, languageOfLocale, pickVoiceForLanguage } from './language-detect.js'; + export function registerTtsRoutes(app, { sayTTSCapability }) { let ttsModulePromise = null; const getTtsModule = async () => { @@ -154,7 +156,8 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { // macOS 'say' command TTS speak endpoint app.post('/api/tts/say/speak', async (req, res) => { try { - const { text, voice = 'Samantha', rate = 200 } = req.body || {}; + const { text, rate = 200, language, languageSample } = req.body || {}; + let voice = typeof req.body?.voice === 'string' && req.body.voice.trim() ? req.body.voice.trim() : 'Samantha'; if (!text || typeof text !== 'string' || !text.trim()) { return res.status(400).json({ error: 'Text is required' }); @@ -164,6 +167,23 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { if (process.platform !== 'darwin') { return res.status(503).json({ error: 'macOS say command not available on this platform' }); } + + // `language: 'auto'`: keep the chosen voice while it speaks the text's + // language, otherwise switch to an installed voice that does. A + // language with no installed voice keeps the chosen voice — say still + // reads the text, just with an accent — rather than failing. + let resolvedLanguage = null; + if (language === 'auto') { + const capability = await sayTTSCapability; + const voices = Array.isArray(capability?.voices) ? capability.voices : []; + const sample = typeof languageSample === 'string' && languageSample.trim() ? languageSample.slice(0, 4000) : text; + resolvedLanguage = detectTextLanguage(sample).language; + const chosen = voices.find((entry) => entry.name === voice); + if (languageOfLocale(chosen?.locale) !== resolvedLanguage) { + const match = pickVoiceForLanguage(resolvedLanguage, voices); + if (match) voice = match; + } + } const { exec } = await import('child_process'); const { promisify } = await import('util'); @@ -195,6 +215,8 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { // Send audio response res.setHeader('Content-Type', 'audio/mp4'); + res.setHeader('X-Speech-Voice', voice); + if (resolvedLanguage) res.setHeader('X-Speech-Language', resolvedLanguage); res.setHeader('Content-Length', audioBuffer.length); res.send(audioBuffer); diff --git a/packages/web/server/lib/tts/routes.test.js b/packages/web/server/lib/tts/routes.test.js index f4940265..fce960a4 100644 --- a/packages/web/server/lib/tts/routes.test.js +++ b/packages/web/server/lib/tts/routes.test.js @@ -33,6 +33,32 @@ describe('tts routes', () => { }); }); + it('switches the say voice to the language of the text when asked to', async () => { + const capability = Promise.resolve({ + available: true, + voices: [ + { name: 'Samantha', locale: 'en_US' }, + { name: 'Lesya', locale: 'uk_UA' }, + { name: 'Lesya (Enhanced)', locale: 'uk_UA' }, + ], + }); + const app = createApp(capability); + const response = await request(app) + .post('/api/tts/say/speak') + .send({ text: 'Привіт! Це відповідь українською мовою, і вона досить довга.', voice: 'Samantha', language: 'auto' }); + + // On macOS the route synthesizes; elsewhere it refuses before running say. + // Either way the chosen voice must be the Ukrainian one when the platform + // allows the request to proceed. + if (process.platform === 'darwin') { + expect(response.status).toBe(200); + expect(response.headers['x-speech-voice']).toBe('Lesya (Enhanced)'); + expect(response.headers['x-speech-language']).toBe('uk'); + } else { + expect(response.status).toBe(503); + } + }); + it('returns local note fallback while model summarization is retired', async () => { const response = await request(createApp()) .post('/api/text/summarize') From 7fb246d5302e432d8a3649be9699ae4d187c509c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 02:25:17 +0300 Subject: [PATCH 21/37] chore: changelog for Linear, language-matched voices, failed-turn diagnostics, and session landing Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98 --- CHANGELOG.md | 8 ++++++++ packages/vscode/CHANGELOG.md | 2 ++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e64970..be0d64f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,15 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Linear integration:** connect a Linear workspace in Settings → Integrations, browse its issues in the context rail with status, priority, assignee, and team filters, and start a session or worktree straight from an issue. Sessions started that way post started, completed, and failed comments on the issue, each linking back to the session; chat can also attach an issue to the next send (thanks to @AlexKutas). +- **Voice: the voice follows the language of the text.** With "Match the voice to the language of the text" (Settings → Voice, on by default) the local provider switches to a model for the reply's language — Kokoro for Chinese/English and Piper models for Ukrainian, German, French, Spanish, Italian, Portuguese, Polish, Russian, Dutch, Czech, Turkish, and Swedish, downloaded on first use — and macOS say switches to an installed voice of that language. The local voice picker lists every installed model's voices. - **Chat:** switching sessions is now near-instant. The clicked session highlights at once, and its conversation appears as one finished view — text, tool cards, and the recap together — instead of arriving in pieces with a moment of unstyled code blocks and links. Header session tabs switch without a crossfade, and the tab title no longer jumps when a tab becomes active. - Chat: command and skill autocomplete in a Chat (a session that belongs to no project) lists that chat's own commands and skills instead of the project last selected in the sidebar, and file mentions in a new chat draft no longer search the previous project. - Files: Ctrl/Cmd+F opens the find bar in the Markdown preview even when nothing inside the preview has focus. +- Chat: a turn that OpenCode stopped no longer ends with nothing on screen — what OpenCode reported shows under the last message, and a message an idle session has left unanswered is named as such. The status report (Ctrl/Cmd+Shift+L) now lists the last session errors, rejected sends, the managed OpenCode process's last error, and where the log files are. +- Chat: a session opened from the sidebar lands at its end and stays there, instead of landing above the bottom or snapping up a moment later while the recap and subagent cards finish measuring. +- Git: the commit graph no longer leaves a gap in a lane when the same branch is merged twice (thanks to @Naputt1). +- Desktop: on Windows and Linux the close button sits flush against the window edge, so the exact top-right corner closes the window, and its hover color follows the theme (thanks to @kydorn). ## [1.21.1] - 2026-08-29 @@ -35,6 +41,8 @@ All notable changes to this project will be documented in this file. - Small model: requests send the provider's configured headers, such as an API-gateway subscription key (thanks to @dmitrii-galantsev); a configured Anthropic endpoint is used without a doubled `/v1`, and Google models without reasoning no longer receive a thinking option (thanks to @mpeter and @IngTian). - Projects: the folder picker can select several directories at once and add them together (thanks to @herjarsa). - Files: files reached through a symlink inside the workspace, or under a project root that is itself a symlink, open again instead of failing with an access error (thanks to @herjarsa). +- Sidebar: searching sessions now also finds Chats — sessions that belong to no project — which used to vanish from the list as soon as anything was typed (thanks to @yulia-ivashko). +- Chat: a message made only of quoted context fragments now appears in the prompt navigator; opening or closing the context panel no longer leaves a blank tail under the last message. - Settings/Providers: after saving an API key or signing in, the provider no longer shows "Credentials missing" with its models hidden until you switch away and back (thanks to @herjarsa). - Projects: the folder picker can enter a directory that is already a project to browse from there (thanks to @weixiang1862), and sending, forking, and image attachments work in projects whose path has non-ASCII characters, such as `Masaüstü` (thanks to @fitzgpt). - Git: the status panel refreshes from real repository state after checkout, branch, stash, merge, rebase, or reset, and remote branches that were never fetched appear in branch lists (thanks to @makeittech); the Branch diff scope no longer compares against the wrong base for branches created from the current branch (thanks to @gaojunran); picking `origin/main` in the branch selector checks out the local branch instead of a detached `HEAD` (thanks to @yulia-ivashko); branch search hides non-matching branches (thanks to @bashrusakh). diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 2bacf37a..85dc52a5 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,6 +1,8 @@ ## [Unreleased] - Switching sessions is faster: the clicked session highlights at once, and its conversation appears as one finished view — text, tool cards, and the recap together — instead of arriving in pieces with a moment of unstyled code blocks. +- Chat: a turn that OpenCode stopped no longer ends with nothing on screen — what OpenCode reported shows under the last message, and a message an idle session has left unanswered is named as such. The status report (Ctrl/Cmd+Shift+L) now lists the last session errors and rejected sends. +- Chat: a session opened from the sidebar lands at its end and stays there, instead of landing above the bottom or snapping up a moment later. ## [1.21.1] - 2026-08-29 From 93fdfa50d5623bca2fd1676632a4d45da45da5fd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 10:23:26 +0300 Subject: [PATCH 22/37] fix(git): normalize discovered nested repository paths on the client The server joins discovered repository paths with the platform separator while every other git directory key in the UI is normalized, so on Windows a discovered repository never matched its own selection or the root prefix the picker strips. Parse the route's response at the boundary and normalize each path. Note in the store docs that worktree bootstrap and session machinery stay keyed on the project root while a nested repository is selected. --- packages/ui/src/lib/gitApiHttp.ts | 17 ++++++++++------- packages/ui/src/stores/DOCUMENTATION.md | 1 + 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index b771feb0..d9908dfc 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -35,6 +35,7 @@ import type { RevertCommitResponse, ResetToCommitResponse, } from './api/types'; +import { normalizePath } from './pathNormalization'; import { runtimeFetch } from './runtime-fetch'; import { getRuntimeUrlResolver } from './runtime-url'; import { getRuntimeKey } from './runtime-switch'; @@ -146,16 +147,18 @@ export async function listGitDirectories(root: string): Promise { if (!response.ok) { throw new Error(`Failed to list git directories: ${response.statusText}`); } - const data = await response.json(); - if (!data || !Array.isArray(data.repositories)) { + // SAFETY: the route is ours (`GET /api/fs/git-dirs`) and answers this exact + // shape on every 2xx; a malformed body fails the array check below. + const data = await response.json() as { repositories?: Array<{ path?: string | null }> }; + if (!Array.isArray(data?.repositories)) { throw new Error('Unexpected git directories response'); } + // The server joins paths with the platform separator; every other git + // directory key in the UI is normalized, so match that here or a Windows + // repository never equals its own selection or root prefix. return data.repositories - .map((entry: unknown) => { - const path = entry && typeof entry === 'object' && 'path' in entry ? (entry as { path?: unknown }).path : undefined; - return typeof path === 'string' && path.trim() ? path.trim() : null; - }) - .filter((path: string | null): path is string => path !== null); + .map((entry) => normalizePath(entry?.path ?? null)) + .filter((path): path is string => path !== null); } export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index fdc00ab4..2fbce401 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -149,6 +149,7 @@ Important properties: - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers - in-flight dedupe exists for status and `ensureAll()`; status dedupe is scoped to the per-directory status mutation revision, so a refresh requested after a mutation never joins a pre-mutation in-flight request - nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). A selection whose repository fails its probe is dropped and remembered session-only (`staleClearedSelections`) so auto-select does not re-pick it and loop walk+probe; manual picker picks bypass the memory. `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface (Git tab, diff view, pull-request view, walkthrough view, mobile changes), and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states +- worktree bootstrap polling and session/worktree machinery stay keyed on the project root even while a nested repository is selected; only git data and actions follow the selection - runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations - status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes From e0f298b957c588b1c2a27cceeea3d4ea8d36091c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 10:26:04 +0300 Subject: [PATCH 23/37] chore: changelog entry for nested git repositories Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be0d64f6..ce20a8a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. - Files: Ctrl/Cmd+F opens the find bar in the Markdown preview even when nothing inside the preview has focus. - Chat: a turn that OpenCode stopped no longer ends with nothing on screen — what OpenCode reported shows under the last message, and a message an idle session has left unanswered is named as such. The status report (Ctrl/Cmd+Shift+L) now lists the last session errors, rejected sends, the managed OpenCode process's last error, and where the log files are. - Chat: a session opened from the sidebar lands at its end and stays there, instead of landing above the bottom or snapping up a moment later while the recap and subagent cards finish measuring. +- Git: a project whose root is not a Git repository now works with the repositories nested inside it. The Git tab opens the first one it finds, a picker next to the branch dropdown switches between them, and the diff, pull request, walkthrough, and mobile Changes views follow the same choice (thanks to @jaygupta17). - Git: the commit graph no longer leaves a gap in a lane when the same branch is merged twice (thanks to @Naputt1). - Desktop: on Windows and Linux the close button sits flush against the window edge, so the exact top-right corner closes the window, and its hover color follows the theme (thanks to @kydorn). From e338a9c561a82a5fef0be1c6e2d1edb2a4581f4b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 11:17:38 +0300 Subject: [PATCH 24/37] fix(ui): nested repositories open diffs and report status from the selected repo Opening a file from the Git panel while a nested repository was selected created the diff tab under the repository path, a key the context panel never displays. Tabs are keyed by the project root; the diff surface resolves the selected nested repository itself. The work-status Project section now reads branch, changes, and PR from the same resolved repository as the Git tab and names the nested folder under the branch so the reader knows which repository the readouts describe. --- .../work-status/WorkStatusPrimaryGroup.tsx | 50 +++++++++++++++---- packages/ui/src/components/views/GitView.tsx | 10 ++-- packages/ui/src/stores/DOCUMENTATION.md | 2 +- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index bfe046cf..fc0cada6 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useI18n } from '@/lib/i18n'; import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { runBackgroundNetworkTask } from '@/lib/background-network'; import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore'; import { useSessionMessages } from '@/sync/sync-context'; @@ -51,37 +52,54 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, const fetchStatus = useGitStore((state) => state.fetchStatus); const clearDiffCache = useGitStore((state) => state.clearDiffCache); + // The repository the readouts describe. Same resolution as the Git tab: the + // session directory itself when it is a repository, otherwise the nested + // repository selected (or auto-selected) for it, so a session in a plain + // folder of repositories still reports the branch and changes the Git tab + // shows. Navigation below stays keyed on `directory` — context-panel tabs + // are per project root. + const { gitDirectory } = useNestedGitDirectory(directory, { enabled: showRepository }); + const gitStatus = useGitStore( React.useCallback( - (state) => (directory ? state.directories.get(directory)?.status ?? null : null), - [directory], + (state) => (gitDirectory ? state.directories.get(gitDirectory)?.status ?? null : null), + [gitDirectory], ), ); // Warm the shared git cache through the background-network gate so the panel // never competes with the chat's own bootstrap traffic for sockets. React.useEffect(() => { - if (!showRepository || !directory || !git) return; - void runBackgroundNetworkTask(() => ensureStatus(directory, git)); - }, [directory, git, ensureStatus, showRepository]); + if (!showRepository || !gitDirectory || !git) return; + void runBackgroundNetworkTask(() => ensureStatus(gitDirectory, git)); + }, [gitDirectory, git, ensureStatus, showRepository]); // Own the live invalidation for the repository readout. The desktop // composer's changed-files row no longer renders, so this panel must not // depend on ChatInput (or an opened Git surface) to refresh the shared cache // on its behalf. React.useEffect(() => { - if (!showRepository || !directory || !git) return; + if (!showRepository || !gitDirectory || !git) return; return sessionEvents.onGitRefreshHint((hint) => { - if (normalizePath(hint.directory) !== normalizePath(directory)) return; + if (normalizePath(hint.directory) !== normalizePath(gitDirectory)) return; if (hint.paths?.length) { - clearDiffCache(directory, hint.paths); + clearDiffCache(gitDirectory, hint.paths); } - void fetchStatus(directory, git, { silent: true }); + void fetchStatus(gitDirectory, git, { silent: true }); }); - }, [clearDiffCache, directory, fetchStatus, git, showRepository]); + }, [clearDiffCache, gitDirectory, fetchStatus, git, showRepository]); const branch = gitStatus?.current?.trim() || null; + // Which repository under the project the branch belongs to. Only meaningful + // when the readouts come from a nested repository; for a project that is a + // repository itself the section header already names it. + const nestedRepoLabel = React.useMemo(() => { + if (!directory || !gitDirectory || gitDirectory === directory) return null; + const rootPrefix = `${directory}/`; + return gitDirectory.startsWith(rootPrefix) ? gitDirectory.slice(rootPrefix.length) : gitDirectory; + }, [directory, gitDirectory]); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); // Worktrees normally sit beside rather than beneath their project directory, // so a prefix match alone cannot find their owning project. Reuse the shared @@ -103,7 +121,7 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, // Read-only: PR watching is owned by the background tracker. Starting a watch // here would multiply GitHub requests per open session, which is exactly the // fan-out the PR-status concurrency gate exists to prevent. - const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch); + const prSummary = useFreshestPrVisualSummaryForBranch(gitDirectory, branch); // `getCurrentModel` is an imperative getter: its reference never changes, so // calling it in render subscribes to nothing. Subscribe to the selected model @@ -274,6 +292,16 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, /> ) : null} + {nestedRepoLabel ? ( + openSurface('git') : undefined} + ariaLabel={t('chat.workStatus.action.openGit')} + label={nestedRepoLabel} + muted + /> + ) : null} + {changed ? ( = ({ isActive }) => { [handleRevertPaths] ); + // Context-panel tabs are keyed by the project root, not by the repository + // being diffed: the diff surface resolves the selected nested repository on + // its own, so opening the tab under `gitDirectory` would park it under a key + // the panel never displays. const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => { - if (gitDirectory && !isMobile) { - openContextDiff(gitDirectory, path, staged); + if (currentDirectory && !isMobile) { + openContextDiff(currentDirectory, path, staged); return; } navigateToDiff(path, staged); - }, [gitDirectory, isMobile, navigateToDiff, openContextDiff]); + }, [currentDirectory, isMobile, navigateToDiff, openContextDiff]); const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []); diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 2fbce401..ac82998a 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -148,7 +148,7 @@ Important properties: - loading state is per-directory, not global - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers - in-flight dedupe exists for status and `ensureAll()`; status dedupe is scoped to the per-directory status mutation revision, so a refresh requested after a mutation never joins a pre-mutation in-flight request -- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). A selection whose repository fails its probe is dropped and remembered session-only (`staleClearedSelections`) so auto-select does not re-pick it and loop walk+probe; manual picker picks bypass the memory. `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface (Git tab, diff view, pull-request view, walkthrough view, mobile changes), and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states +- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). A selection whose repository fails its probe is dropped and remembered session-only (`staleClearedSelections`) so auto-select does not re-pick it and loop walk+probe; manual picker picks bypass the memory. `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface (Git tab, diff view, pull-request view, walkthrough view, mobile changes, work-status project readout), and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states - worktree bootstrap polling and session/worktree machinery stay keyed on the project root even while a nested repository is selected; only git data and actions follow the selection - runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations From 1423fc57b8a63789bc837b052154e327dcdbfbd8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 11:30:07 +0300 Subject: [PATCH 25/37] fix: use delete icon for context preview remove action Updates the context preview remove button icon Makes the control better match its delete behavior --- .../ui/src/components/chat/composer/ui/ComposerContextChips.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx index f39bb5fd..0641f1cb 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx @@ -136,7 +136,7 @@ const DraftPreviewEntry: React.FC<{ aria-label={t('chat.chatInput.contextPreview.remove')} title={t('chat.chatInput.contextPreview.remove')} > - +
From 5fabeccd2d6c26d80415f09dd2ae75d3ab1ccd5a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 11:34:29 +0300 Subject: [PATCH 26/37] chore: highlight multi-repository projects in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce20a8a6..1e488719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,12 @@ All notable changes to this project will be documented in this file. - **Linear integration:** connect a Linear workspace in Settings → Integrations, browse its issues in the context rail with status, priority, assignee, and team filters, and start a session or worktree straight from an issue. Sessions started that way post started, completed, and failed comments on the issue, each linking back to the session; chat can also attach an issue to the next send (thanks to @AlexKutas). - **Voice: the voice follows the language of the text.** With "Match the voice to the language of the text" (Settings → Voice, on by default) the local provider switches to a model for the reply's language — Kokoro for Chinese/English and Piper models for Ukrainian, German, French, Spanish, Italian, Portuguese, Polish, Russian, Dutch, Czech, Turkish, and Swedish, downloaded on first use — and macOS say switches to an installed voice of that language. The local voice picker lists every installed model's voices. +- **Git: projects made of several repositories.** A project whose root is not itself a Git repository — a folder of plugins, a workspace of services — now works with the repositories inside it. The Git tab opens the first one it finds, a picker next to the branch dropdown switches between them, and the diff, pull request, walkthrough, and mobile Changes views follow the same choice; the work status card shows the chosen repository's branch and changes with the folder named under the branch (thanks to @jaygupta17). - **Chat:** switching sessions is now near-instant. The clicked session highlights at once, and its conversation appears as one finished view — text, tool cards, and the recap together — instead of arriving in pieces with a moment of unstyled code blocks and links. Header session tabs switch without a crossfade, and the tab title no longer jumps when a tab becomes active. - Chat: command and skill autocomplete in a Chat (a session that belongs to no project) lists that chat's own commands and skills instead of the project last selected in the sidebar, and file mentions in a new chat draft no longer search the previous project. - Files: Ctrl/Cmd+F opens the find bar in the Markdown preview even when nothing inside the preview has focus. - Chat: a turn that OpenCode stopped no longer ends with nothing on screen — what OpenCode reported shows under the last message, and a message an idle session has left unanswered is named as such. The status report (Ctrl/Cmd+Shift+L) now lists the last session errors, rejected sends, the managed OpenCode process's last error, and where the log files are. - Chat: a session opened from the sidebar lands at its end and stays there, instead of landing above the bottom or snapping up a moment later while the recap and subagent cards finish measuring. -- Git: a project whose root is not a Git repository now works with the repositories nested inside it. The Git tab opens the first one it finds, a picker next to the branch dropdown switches between them, and the diff, pull request, walkthrough, and mobile Changes views follow the same choice (thanks to @jaygupta17). - Git: the commit graph no longer leaves a gap in a lane when the same branch is merged twice (thanks to @Naputt1). - Desktop: on Windows and Linux the close button sits flush against the window edge, so the exact top-right corner closes the window, and its hover color follows the theme (thanks to @kydorn). From db0bf115ad5fff3363a5c3910e54780219757e98 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 13:19:45 +0300 Subject: [PATCH 27/37] fix(scheduled-tasks): keep task fields a server build does not know Every project-config write re-serialized normalized tasks, so a server that shares the config file but predates a field (goal, auto-accept) stripped it the first time any task ran. Untouched tasks now go back to disk verbatim, a state update swaps only `state`, and only a deliberately replaced task is serialized from the normalized shape. --- .../web/server/lib/projects/project-config.js | 38 +++++++-- .../lib/projects/project-config.test.js | 85 +++++++++++++++++++ .../lib/scheduled-tasks/DOCUMENTATION.md | 7 ++ 3 files changed, 125 insertions(+), 5 deletions(-) diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index 1b946b34..798da289 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -499,11 +499,20 @@ export const createProjectConfigRuntime = (deps) => { } }; + // Normalized tasks for reading, plus the raw on-disk record of each one for + // writing back. Normalization only keeps the fields THIS build knows, so a + // write that re-serialized normalized tasks would strip every field added + // by a newer build (or a newer UI) the moment an older server touched the + // file — a goal or auto-accept setting silently lost after a task ran. + // Writers therefore persist untouched tasks from `rawTasksByID` verbatim and + // only serialize a normalized task where the task itself was deliberately + // replaced. const readProjectConfigFromDisk = async (projectID) => { const parsed = await readRawProjectConfigFromDisk(projectID); const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : []; const now = Date.now(); const scheduledTasks = []; + const rawTasksByID = new Map(); for (const task of tasksRaw) { try { const normalized = normalizeTaskForStorage(task, { @@ -514,15 +523,31 @@ export const createProjectConfigRuntime = (deps) => { refreshUpdatedAt: false, }); scheduledTasks.push(normalized); + rawTasksByID.set(normalized.id, task); } catch { } } return { version: PROJECT_CONFIG_VERSION, scheduledTasks, + rawTasksByID, }; }; + // The list to write: tasks this write replaced go out normalized; every + // other task goes out exactly as stored, fields unknown to this build + // included. A state-only update counts as untouched — only its `state` is + // swapped onto the stored record. Callers keep working with (and returning) + // the normalized tasks; only the bytes on disk differ. + const toStoredTasks = (config, tasks, { replacedIDs = new Set(), stateUpdatedID = null } = {}) => ( + tasks.map((task) => { + if (replacedIDs.has(task.id)) return task; + const stored = config.rawTasksByID.get(task.id); + if (!stored) return task; + return task.id === stateUpdatedID ? { ...stored, state: task.state } : stored; + }) + ); + const writeProjectConfigToDisk = async (projectID, config) => { const filePath = resolveProjectConfigPath(projectID); const parentDirectory = path.dirname(filePath); @@ -607,7 +632,7 @@ export const createProjectConfigRuntime = (deps) => { const nextConfig = { version: PROJECT_CONFIG_VERSION, - scheduledTasks: nextTasks, + scheduledTasks: toStoredTasks(current, nextTasks, { replacedIDs: new Set([normalizedTask.id]) }), }; await writeProjectConfigToDisk(projectID, nextConfig); @@ -633,7 +658,7 @@ export const createProjectConfigRuntime = (deps) => { if (deleted) { await writeProjectConfigToDisk(projectID, { version: PROJECT_CONFIG_VERSION, - scheduledTasks: nextTasks, + scheduledTasks: toStoredTasks(current, nextTasks), }); } @@ -676,7 +701,7 @@ export const createProjectConfigRuntime = (deps) => { await writeProjectConfigToDisk(projectID, { version: PROJECT_CONFIG_VERSION, - scheduledTasks: nextTasks, + scheduledTasks: toStoredTasks(current, nextTasks, { stateUpdatedID: nextTask.id }), }); return { @@ -737,7 +762,7 @@ export const createProjectConfigRuntime = (deps) => { await writeProjectConfigToDisk(projectID, { version: PROJECT_CONFIG_VERSION, - scheduledTasks: nextTasks, + scheduledTasks: toStoredTasks(current, nextTasks, { stateUpdatedID: nextTask.id }), }); return { @@ -797,6 +822,7 @@ export const createProjectConfigRuntime = (deps) => { const consumedLoopPaths = new Set(); const nextTasks = []; + const replacedIDs = new Set(); for (const task of tasks) { if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) { // The driving loop file was removed (or renamed) — unschedule. @@ -828,6 +854,7 @@ export const createProjectConfigRuntime = (deps) => { }, ); nextTasks.push(adopted); + replacedIDs.add(adopted.id); pendingLoops.delete(loop.definition.name); if (task.loopFile) { consumedLoopPaths.add(task.loopFile); @@ -863,6 +890,7 @@ export const createProjectConfigRuntime = (deps) => { }, ); nextTasks.push(created); + replacedIDs.add(created.id); } catch (error) { console.warn(`[scheduled-tasks] skipped loop ${loop.filePath}:`, error?.message ?? error); } @@ -870,7 +898,7 @@ export const createProjectConfigRuntime = (deps) => { await writeProjectConfigToDisk(projectID, { version: PROJECT_CONFIG_VERSION, - scheduledTasks: nextTasks, + scheduledTasks: toStoredTasks(current, nextTasks, { replacedIDs }), }); return nextTasks; diff --git a/packages/web/server/lib/projects/project-config.test.js b/packages/web/server/lib/projects/project-config.test.js index f4184cd6..867a0119 100644 --- a/packages/web/server/lib/projects/project-config.test.js +++ b/packages/web/server/lib/projects/project-config.test.js @@ -14,6 +14,7 @@ const createRuntime = async () => { }); return { runtime, + tempRoot, cleanup: async () => { await rm(tempRoot, { recursive: true, force: true }); }, @@ -472,6 +473,90 @@ describe('project-config loop reconciliation', () => { } }); + describe('fields this build does not know', () => { + // Simulates a config written by a newer build (or a newer UI): the task + // carries execution and state fields normalization here has never heard of. + const seedForeignTask = async (runtime, tempRoot) => { + const created = await runtime.upsertScheduledTask('project-test', { + name: 'Nightly digest', + enabled: true, + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { prompt: 'Summarize', providerID: 'openai', modelID: 'gpt-4.1', goalEnabled: true }, + }); + const filePath = path.join(tempRoot, 'project-test.json'); + const stored = JSON.parse(await readFile(filePath, 'utf8')); + stored.scheduledTasks[0].execution.futureExecutionField = 'keep me'; + stored.scheduledTasks[0].state.futureStateField = 42; + stored.scheduledTasks[0].futureTopLevelField = true; + await writeFile(filePath, JSON.stringify(stored, null, 2), 'utf8'); + return { id: created.task.id, filePath }; + }; + + const readStoredTask = async (filePath, id) => { + const stored = JSON.parse(await readFile(filePath, 'utf8')); + return stored.scheduledTasks.find((task) => task.id === id); + }; + + it('survive a state update after a run, and the claim update', async () => { + const { runtime, tempRoot, cleanup } = await createRuntime(); + try { + const { id, filePath } = await seedForeignTask(runtime, tempRoot); + + await runtime.updateScheduledTaskState('project-test', id, { lastStatus: 'success', lastRunAt: 1000 }); + let stored = await readStoredTask(filePath, id); + expect(stored.execution.futureExecutionField).toBe('keep me'); + expect(stored.execution.goalEnabled).toBe(true); + expect(stored.futureTopLevelField).toBe(true); + expect(stored.state.lastStatus).toBe('success'); + + await runtime.updateScheduledTaskStateIf('project-test', id, () => true, { lastScheduledFor: 5000 }); + stored = await readStoredTask(filePath, id); + expect(stored.execution.futureExecutionField).toBe('keep me'); + expect(stored.state.lastScheduledFor).toBe(5000); + } finally { + await cleanup(); + } + }); + + it('survive writes that replace or delete a different task, and a loop sync', async () => { + const { runtime, tempRoot, cleanup } = await createRuntime(); + try { + const { id, filePath } = await seedForeignTask(runtime, tempRoot); + + const other = await runtime.upsertScheduledTask('project-test', { + id: 'other-task', + name: 'Other', + enabled: true, + schedule: { kind: 'daily', time: '10:00', timezone: 'UTC' }, + execution: { prompt: 'Other', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me'); + + await runtime.deleteScheduledTask('project-test', other.task.id); + expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me'); + + await runtime.reconcileLoopTasks('project-test', []); + expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me'); + } finally { + await cleanup(); + } + }); + + it('are dropped only when the task itself is deliberately saved', async () => { + const { runtime, tempRoot, cleanup } = await createRuntime(); + try { + const { id, filePath } = await seedForeignTask(runtime, tempRoot); + const [task] = await runtime.listScheduledTasks('project-test'); + await runtime.upsertScheduledTask('project-test', { ...task, name: 'Renamed' }); + const stored = await readStoredTask(filePath, id); + expect(stored.name).toBe('Renamed'); + expect(stored.execution.futureExecutionField).toBeUndefined(); + } finally { + await cleanup(); + } + }); + }); + it('conditionally updates state only when the predicate passes (occurrence claim)', async () => { const { runtime, cleanup } = await createRuntime(); try { diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index f15639ae..d1ed383d 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -25,6 +25,13 @@ in shared project config under the project write lock: from the winner's persisted `nextRunAt`. - Project config writes also take a cross-process `.json.lock` file so the read-modify-write is serialized across processes, not only within one process. +- The sharing processes may run different OpenChamber versions. Normalization + keeps only the fields a build knows, so every writer persists tasks it did + not change verbatim from disk and swaps only `state` onto a task whose state + it updated; a task goes out normalized only when it was deliberately + replaced (upsert, loop adoption). An older server touching the file after a + run therefore cannot strip fields a newer build added, such as a task's goal + or auto-accept settings. - Lock timeout / filesystem errors on claim, manual-start, or completion state writes always release the in-process running slot (via `finally`) and best-effort re-arm the **next future** occurrence; they must not leave the task permanently From 73fd2e9d9d3887344238d3a550d9e383baf6542c Mon Sep 17 00:00:00 2001 From: Pablo Gonzalez Date: Sun, 30 Aug 2026 12:26:57 +0200 Subject: [PATCH 28/37] fix(ui): scope theme settings per runtime instance (#2897) Closes #2958 --- packages/ui/src/apps/mobileNativeChrome.ts | 21 ++ .../ui/src/contexts/ThemeSystemContext.tsx | 93 ++---- .../ui/src/contexts/theme-storage.test.ts | 290 ++++++++++++++++++ packages/ui/src/contexts/theme-storage.ts | 184 +++++++++++ packages/ui/src/lib/persistence.ts | 5 - 5 files changed, 526 insertions(+), 67 deletions(-) create mode 100644 packages/ui/src/contexts/theme-storage.test.ts create mode 100644 packages/ui/src/contexts/theme-storage.ts diff --git a/packages/ui/src/apps/mobileNativeChrome.ts b/packages/ui/src/apps/mobileNativeChrome.ts index a26c6eb4..07d1c7e6 100644 --- a/packages/ui/src/apps/mobileNativeChrome.ts +++ b/packages/ui/src/apps/mobileNativeChrome.ts @@ -70,6 +70,27 @@ export const useNativeMobileChrome = (): void => { const retry = window.setTimeout(() => void applyStatusBar(), 400); cleanup.push(() => window.clearTimeout(retry)); + // Theme toggles must reach the status bar without an app restart: re-run + // whenever the root dark/light class flips — the one signal every theme + // path converges on (settings toggle, synced settings, storage events, + // system-preference changes while in system mode). splashBg* colors are + // per-variant values, so they are stable across mode toggles. + if (platform === 'android') { + let wasDark = root.classList.contains('dark'); + const themeClassObserver = new MutationObserver(() => { + const isDark = root.classList.contains('dark'); + if (isDark === wasDark) return; + wasDark = isDark; + void applyStatusBar(); + }); + themeClassObserver.observe(root, { attributes: true, attributeFilter: ['class'] }); + if (disposed) { + themeClassObserver.disconnect(); + return; + } + cleanup.push(() => themeClassObserver.disconnect()); + } + const { App } = await import('@capacitor/app'); const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { if (isActive) void applyStatusBar(); diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index 681e2caf..edc7929b 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -31,6 +31,12 @@ import { import { isValidTheme } from './theme-validation'; import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { + adoptThemePreferencesForRuntime, + resolveThemePreferencesForRuntime, + resolveThemePreferencesFromStorageEvent, + writeThemePreferencesForRuntime, +} from './theme-storage'; type ThemePreferences = { themeMode: ThemeMode; @@ -87,46 +93,27 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => { const embeddedMode = embeddedParams?.get('themeMode'); const embeddedLightId = embeddedParams?.get('lightThemeId'); const embeddedDarkId = embeddedParams?.get('darkThemeId'); - const storedMode = localStorage.getItem('themeMode'); - const storedLightId = localStorage.getItem('lightThemeId'); - const storedDarkId = localStorage.getItem('darkThemeId'); - const legacyUseSystem = localStorage.getItem('useSystemTheme'); - const legacyThemeId = localStorage.getItem('selectedThemeId'); - const legacyVariant = localStorage.getItem('selectedThemeVariant'); + // Scoped entry when present; otherwise a one-time seed from the superseded + // global keys (see resolveThemePreferencesForRuntime), so the first scoped + // write carries the last-known theme instead of defaults. + const resolvedPreferences = resolveThemePreferencesForRuntime(getRuntimeKey()); if (embeddedMode === 'light' || embeddedMode === 'dark' || embeddedMode === 'system') { themeMode = embeddedMode; - } else if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') { - themeMode = storedMode; - } else if (legacyUseSystem !== null) { - const useSystem = legacyUseSystem === 'true'; - if (useSystem) { - themeMode = 'system'; - } else if (legacyThemeId) { - const legacyTheme = getThemeById(legacyThemeId); - if (legacyTheme) { - themeMode = legacyTheme.metadata.variant === 'dark' ? 'dark' : 'light'; - if (legacyTheme.metadata.variant === 'dark') { - darkThemeId = legacyTheme.metadata.id; - } else { - lightThemeId = legacyTheme.metadata.id; - } - } - } - } else if (legacyVariant === 'light' || legacyVariant === 'dark') { - themeMode = legacyVariant; + } else { + themeMode = resolvedPreferences.themeMode; } if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) { lightThemeId = embeddedLightId.trim(); - } else if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) { - lightThemeId = storedLightId.trim(); + } else { + lightThemeId = resolvedPreferences.lightThemeId; } if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) { darkThemeId = embeddedDarkId.trim(); - } else if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) { - darkThemeId = storedDarkId.trim(); + } else { + darkThemeId = resolvedPreferences.darkThemeId; } } @@ -314,6 +301,9 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro customThemesRequestRef.current += 1; setCustomThemes([]); setCustomThemesLoading(false); + // Adopt the new instance's last-known theme immediately; the incoming + // settings sync refines it with the server's authoritative value. + setPreferences((prev) => adoptThemePreferencesForRuntime(detail.runtimeKey, prev)); void reloadCustomThemes(); }), [isVSCode, reloadCustomThemes]); @@ -424,6 +414,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro return; } + writeThemePreferencesForRuntime(getRuntimeKey(), { + themeMode: preferences.themeMode, + lightThemeId: preferences.lightThemeId, + darkThemeId: preferences.darkThemeId, + }); + + // Cosmetic last-writer-wins hints for the pre-React splash shells + // (packages/web/index.html, mobile.html, mini-chat.html) and the Android + // status bar, which run before the scoped key can be read. Not part of the + // app's theme authority — the scoped entry and the per-instance server + // settings own that. localStorage.setItem('themeMode', preferences.themeMode); localStorage.setItem('lightThemeId', preferences.lightThemeId); localStorage.setItem('darkThemeId', preferences.darkThemeId); @@ -434,8 +435,6 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro currentTheme.metadata.variant === 'light' ? 'light' : 'dark', ); - // Splash screen (packages/web/index.html) runs before the theme CSS vars load. - // Persist just enough to theme it on next boot. const lightTheme = ensureThemeById(preferences.lightThemeId, 'light'); const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark'); @@ -459,37 +458,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro return; } - if (event.key !== 'themeMode' && event.key !== 'lightThemeId' && event.key !== 'darkThemeId') { - return; - } - - setPreferences((prev) => { - const nextModeRaw = localStorage.getItem('themeMode'); - const nextMode: ThemeMode = - nextModeRaw === 'light' || nextModeRaw === 'dark' || nextModeRaw === 'system' - ? nextModeRaw - : prev.themeMode; - - const nextLightRaw = localStorage.getItem('lightThemeId'); - const nextLight = typeof nextLightRaw === 'string' && nextLightRaw.trim().length > 0 - ? nextLightRaw.trim() - : prev.lightThemeId; - - const nextDarkRaw = localStorage.getItem('darkThemeId'); - const nextDark = typeof nextDarkRaw === 'string' && nextDarkRaw.trim().length > 0 - ? nextDarkRaw.trim() - : prev.darkThemeId; - - if (nextMode === prev.themeMode && nextLight === prev.lightThemeId && nextDark === prev.darkThemeId) { - return prev; - } - - return { - themeMode: nextMode, - lightThemeId: nextLight, - darkThemeId: nextDark, - }; - }); + setPreferences((prev) => resolveThemePreferencesFromStorageEvent(event.key, getRuntimeKey(), prev) ?? prev); }; window.addEventListener('storage', handleStorage); diff --git a/packages/ui/src/contexts/theme-storage.test.ts b/packages/ui/src/contexts/theme-storage.test.ts new file mode 100644 index 00000000..92acc2ba --- /dev/null +++ b/packages/ui/src/contexts/theme-storage.test.ts @@ -0,0 +1,290 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; + +import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes'; + +import { + adoptThemePreferencesForRuntime, + getThemePreferencesStorageKey, + isTransientRuntimeKey, + readThemePreferencesForRuntime, + resolveThemePreferencesForRuntime, + resolveThemePreferencesFromStorageEvent, + writeThemePreferencesForRuntime, +} from './theme-storage'; + +let createdWindow = false; +let createdLocalStorage = false; + +const ensureLocalStorage = (): void => { + if (typeof localStorage !== 'undefined') { + return; + } + const values = new Map(); + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + clear: () => { + values.clear(); + }, + }, + configurable: true, + writable: true, + }); + createdLocalStorage = true; +}; + +beforeEach(() => { + if (typeof window === 'undefined') { + Object.defineProperty(globalThis, 'window', { + value: {}, + configurable: true, + writable: true, + }); + createdWindow = true; + } + ensureLocalStorage(); + localStorage.clear(); +}); + +afterAll(() => { + if (createdWindow) { + delete (globalThis as { window?: unknown }).window; + } + if (createdLocalStorage) { + delete (globalThis as { localStorage?: unknown }).localStorage; + } +}); + +const preferences = { + themeMode: 'dark' as const, + lightThemeId: 'light-theme', + darkThemeId: 'dark-theme', +}; + +describe('theme preference runtime scoping', () => { + test('keys differ per runtime', () => { + expect(getThemePreferencesStorageKey('runtime-a')).not.toBe(getThemePreferencesStorageKey('runtime-b')); + }); + + test('round-trips preferences for the same runtime', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences); + }); + + test('a window on one instance never reads another instance theme', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(readThemePreferencesForRuntime('runtime-b')).toBeNull(); + }); + + test('latest write wins per runtime without cross-instance effects', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + writeThemePreferencesForRuntime('runtime-b', { themeMode: 'light', lightThemeId: 'other-light', darkThemeId: 'other-dark' }); + + expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences); + expect(readThemePreferencesForRuntime('runtime-b')).toEqual({ + themeMode: 'light', + lightThemeId: 'other-light', + darkThemeId: 'other-dark', + }); + }); + + test('malformed or invalid payloads are failure, not empty authority', () => { + localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json'); + expect(readThemePreferencesForRuntime('runtime-a')).toBeNull(); + + localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), JSON.stringify({ themeMode: 'neon' })); + expect(readThemePreferencesForRuntime('runtime-a')).toBeNull(); + + localStorage.setItem( + getThemePreferencesStorageKey('runtime-a'), + JSON.stringify({ themeMode: 'dark', lightThemeId: '', darkThemeId: 'dark-theme' }), + ); + expect(readThemePreferencesForRuntime('runtime-a')).toBeNull(); + }); + + test('leaves the splash-hint and migration-seed globals untouched', () => { + localStorage.setItem('themeMode', 'dark'); + localStorage.setItem('lightThemeId', 'light-theme'); + localStorage.setItem('darkThemeId', 'dark-theme'); + localStorage.setItem('useSystemTheme', 'false'); + localStorage.setItem('selectedThemeId', 'dark-theme'); + localStorage.setItem('selectedThemeVariant', 'dark'); + localStorage.setItem('splashBgDark', '#0c0a09'); + localStorage.setItem('splashFgDark', '#fafaf9'); + + writeThemePreferencesForRuntime('runtime-a', preferences); + + // The scoped key owns the app theme; the global keys stay as cosmetic + // last-writer-wins hints for the pre-React splash shells and the Android + // status bar, and as the one-time migration seed for new runtimes. + expect(localStorage.getItem('themeMode')).toBe('dark'); + expect(localStorage.getItem('lightThemeId')).toBe('light-theme'); + expect(localStorage.getItem('darkThemeId')).toBe('dark-theme'); + expect(localStorage.getItem('useSystemTheme')).toBe('false'); + expect(localStorage.getItem('selectedThemeId')).toBe('dark-theme'); + expect(localStorage.getItem('selectedThemeVariant')).toBe('dark'); + expect(localStorage.getItem('splashBgDark')).toBe('#0c0a09'); + expect(localStorage.getItem('splashFgDark')).toBe('#fafaf9'); + expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences); + }); +}); + +describe('theme preference resolution chain', () => { + test('uses the scoped entry when present', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual(preferences); + }); + + test('seeds from the legacy mode and theme ids when no scoped entry exists', () => { + localStorage.setItem('themeMode', 'dark'); + localStorage.setItem('lightThemeId', 'legacy-light'); + localStorage.setItem('darkThemeId', 'legacy-dark'); + + expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({ + themeMode: 'dark', + lightThemeId: 'legacy-light', + darkThemeId: 'legacy-dark', + }); + }); + + test('seeds from the useSystemTheme/selectedThemeId legacy chain', () => { + localStorage.setItem('useSystemTheme', 'false'); + localStorage.setItem('selectedThemeId', DEFAULT_DARK_THEME_ID); + + expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({ + themeMode: 'dark', + lightThemeId: DEFAULT_LIGHT_THEME_ID, + darkThemeId: DEFAULT_DARK_THEME_ID, + }); + }); + + test('falls back to defaults when nothing is stored', () => { + expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({ + themeMode: 'system', + lightThemeId: DEFAULT_LIGHT_THEME_ID, + darkThemeId: DEFAULT_DARK_THEME_ID, + }); + }); + + test('the migrated seed survives into the scoped key while the seed globals stay', () => { + localStorage.setItem('themeMode', 'dark'); + localStorage.setItem('lightThemeId', 'legacy-light'); + localStorage.setItem('darkThemeId', 'legacy-dark'); + + writeThemePreferencesForRuntime('runtime-a', resolveThemePreferencesForRuntime('runtime-a')); + + expect(readThemePreferencesForRuntime('runtime-a')).toEqual({ + themeMode: 'dark', + lightThemeId: 'legacy-light', + darkThemeId: 'legacy-dark', + }); + expect(localStorage.getItem('themeMode')).toBe('dark'); + expect(localStorage.getItem('lightThemeId')).toBe('legacy-light'); + expect(localStorage.getItem('darkThemeId')).toBe('legacy-dark'); + }); +}); + +describe('runtime-switch adoption', () => { + const current = { themeMode: 'dark' as const, lightThemeId: 'current-light', darkThemeId: 'current-dark' }; + + test('adopts the target runtime stored theme when one exists', () => { + writeThemePreferencesForRuntime('runtime-b', preferences); + + expect(adoptThemePreferencesForRuntime('runtime-b', current)).toEqual(preferences); + }); + + test('keeps the current preferences — same reference — when the target runtime has no entry', () => { + expect(adoptThemePreferencesForRuntime('runtime-empty', current)).toBe(current); + }); +}); + +describe('transient runtime keys', () => { + test('uninitialized and disconnected runtime keys are transient', () => { + expect(isTransientRuntimeKey('url:default')).toBe(true); + expect(isTransientRuntimeKey('mobile-disconnected')).toBe(true); + expect(isTransientRuntimeKey('')).toBe(true); + expect(isTransientRuntimeKey('local')).toBe(false); + expect(isTransientRuntimeKey('url:https://host.example')).toBe(false); + }); + + test('writes are skipped for transient runtimes — no stale cold-boot theme gets pinned', () => { + writeThemePreferencesForRuntime('url:default', preferences); + writeThemePreferencesForRuntime('mobile-disconnected', preferences); + + expect(readThemePreferencesForRuntime('url:default')).toBeNull(); + expect(readThemePreferencesForRuntime('mobile-disconnected')).toBeNull(); + expect(localStorage.getItem(getThemePreferencesStorageKey('url:default'))).toBeNull(); + }); + + test('reads never surface an entry under a transient key', () => { + localStorage.setItem(getThemePreferencesStorageKey('url:default'), JSON.stringify(preferences)); + + expect(readThemePreferencesForRuntime('url:default')).toBeNull(); + }); + + test('boot resolution falls back to the global splash hints for transient runtimes', () => { + localStorage.setItem('themeMode', 'light'); + localStorage.setItem('lightThemeId', 'legacy-light'); + localStorage.setItem('darkThemeId', 'legacy-dark'); + + expect(resolveThemePreferencesForRuntime('url:default')).toEqual({ + themeMode: 'light', + lightThemeId: 'legacy-light', + darkThemeId: 'legacy-dark', + }); + }); + + test('endpoint-switch adoption keeps current preferences for transient runtimes', () => { + const current = { themeMode: 'light' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' }; + + expect(adoptThemePreferencesForRuntime('mobile-disconnected', current)).toBe(current); + }); +}); + +describe('theme storage event resolution', () => { + const current = { themeMode: 'system' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' }; + + test('adopts a storage event for the current runtime', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toEqual(preferences); + }); + + test('ignores a storage event from another runtime', () => { + writeThemePreferencesForRuntime('runtime-b', preferences); + + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-b'), 'runtime-a', current)).toBeNull(); + }); + + test('ignores legacy global theme keys (revert-to-globals regression guard)', () => { + localStorage.setItem('themeMode', 'dark'); + localStorage.setItem('lightThemeId', 'light-theme'); + localStorage.setItem('darkThemeId', 'dark-theme'); + + expect(resolveThemePreferencesFromStorageEvent('themeMode', 'runtime-a', current)).toBeNull(); + expect(resolveThemePreferencesFromStorageEvent('lightThemeId', 'runtime-a', current)).toBeNull(); + expect(resolveThemePreferencesFromStorageEvent('darkThemeId', 'runtime-a', current)).toBeNull(); + }); + + test('resolves to no change when stored preferences already match', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', preferences)).toBeNull(); + }); + + test('resolves to no change when nothing valid is stored', () => { + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull(); + + localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json'); + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull(); + }); +}); diff --git a/packages/ui/src/contexts/theme-storage.ts b/packages/ui/src/contexts/theme-storage.ts new file mode 100644 index 00000000..8fc94ef6 --- /dev/null +++ b/packages/ui/src/contexts/theme-storage.ts @@ -0,0 +1,184 @@ +import type { ThemeMode } from '@/types/theme'; +import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes'; + +type StoredThemePreferences = { + themeMode: ThemeMode; + lightThemeId: string; + darkThemeId: string; +}; + +// Theme preferences are scoped per runtime endpoint, like the settings mirror +// (lib/persistence.ts), so windows pointing at different instances never +// overwrite or adopt each other's theme through shared localStorage. +// +// Retention is intentionally unbounded, unlike the mirror's capped 5-runtime +// index: each entry is ~150 bytes, the count is bounded by the distinct +// instances ever visited from this origin, and evicting old entries would only +// discard the last-known theme for rarely visited instances while saving +// trivial space. +const THEME_PREFERENCES_KEY_PREFIX = 'openchamber.theme.v2:'; + +export const getThemePreferencesStorageKey = (runtimeKey: string): string => + `${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`; + +// Runtime keys that mean "no instance connected" — the uninitialized default +// and the mobile disconnect state. They carry no instance theme, so scoped +// storage must not read or write them: a write would pin whatever theme was +// current at that moment (e.g. cold-boot defaults) to a key every future +// launch resolves before connecting, and a read would surface that stale +// entry on the mobile connect splash. The global splash hints are the right +// fallback for those phases. +const TRANSIENT_RUNTIME_KEYS = new Set(['', 'url:default', 'mobile-disconnected']); + +export const isTransientRuntimeKey = (runtimeKey: string): boolean => + TRANSIENT_RUNTIME_KEYS.has(runtimeKey); + +export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => { + if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { + return null; + } + let raw: string | null = null; + try { + raw = localStorage.getItem(getThemePreferencesStorageKey(runtimeKey)); + } catch { + return null; + } + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') { + return null; + } + const candidate = parsed as Record; + if (candidate.themeMode !== 'light' && candidate.themeMode !== 'dark' && candidate.themeMode !== 'system') { + return null; + } + if (typeof candidate.lightThemeId !== 'string' || typeof candidate.darkThemeId !== 'string') { + return null; + } + const lightThemeId = candidate.lightThemeId.trim(); + const darkThemeId = candidate.darkThemeId.trim(); + if (!lightThemeId || !darkThemeId) { + return null; + } + return { themeMode: candidate.themeMode, lightThemeId, darkThemeId }; + } catch { + return null; + } +}; + +export const writeThemePreferencesForRuntime = (runtimeKey: string, preferences: StoredThemePreferences): void => { + if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { + return; + } + try { + localStorage.setItem(getThemePreferencesStorageKey(runtimeKey), JSON.stringify(preferences)); + } catch { + // localStorage unavailable (e.g. read-only contextBridge) — the server + // settings sync remains authoritative and the app still works. + } +}; + +/** + * Resolve the preferences a cross-window storage event should apply for the + * current runtime. Returns null — meaning "keep current preferences" — when + * the event targets another runtime's key, when no valid stored preferences + * exist, or when the stored preferences already match the current ones (the + * identity check breaks cross-window adoption loops). + */ +export const resolveThemePreferencesFromStorageEvent = ( + eventKey: string | null, + runtimeKey: string, + current: StoredThemePreferences, +): StoredThemePreferences | null => { + if (eventKey !== getThemePreferencesStorageKey(runtimeKey)) { + return null; + } + const stored = readThemePreferencesForRuntime(runtimeKey); + if (!stored) { + return null; + } + if (stored.themeMode === current.themeMode && stored.lightThemeId === current.lightThemeId && stored.darkThemeId === current.darkThemeId) { + return null; + } + return stored; +}; + +// One-time migration seed: pre-scoped builds persisted theme state in these +// global keys. They are resolved only while no scoped entry exists — the +// persist effect then seeds the scoped key from the returned preferences — so +// no client-only theme state is discarded before the authoritative server sync +// lands. The keys themselves stay (see ThemeSystemContext's persist effect): +// the pre-React splash shells and the Android status bar read them as +// cosmetic last-writer-wins hints. +const readLegacyThemePreferences = (): StoredThemePreferences => { + let themeMode: ThemeMode = 'system'; + let lightThemeId: string = DEFAULT_LIGHT_THEME_ID; + let darkThemeId: string = DEFAULT_DARK_THEME_ID; + + if (typeof window === 'undefined') { + return { themeMode, lightThemeId, darkThemeId }; + } + + const legacyMode = localStorage.getItem('themeMode'); + const legacyUseSystem = localStorage.getItem('useSystemTheme'); + const legacyThemeId = localStorage.getItem('selectedThemeId'); + const legacyVariant = localStorage.getItem('selectedThemeVariant'); + + if (legacyMode === 'light' || legacyMode === 'dark' || legacyMode === 'system') { + themeMode = legacyMode; + } else if (legacyUseSystem !== null) { + const useSystem = legacyUseSystem === 'true'; + if (useSystem) { + themeMode = 'system'; + } else if (legacyThemeId) { + const legacyTheme = getThemeById(legacyThemeId); + if (legacyTheme) { + themeMode = legacyTheme.metadata.variant === 'dark' ? 'dark' : 'light'; + if (legacyTheme.metadata.variant === 'dark') { + darkThemeId = legacyTheme.metadata.id; + } else { + lightThemeId = legacyTheme.metadata.id; + } + } + } + } else if (legacyVariant === 'light' || legacyVariant === 'dark') { + themeMode = legacyVariant; + } + + const legacyLightId = localStorage.getItem('lightThemeId'); + const legacyDarkId = localStorage.getItem('darkThemeId'); + if (typeof legacyLightId === 'string' && legacyLightId.trim().length > 0) { + lightThemeId = legacyLightId.trim(); + } + if (typeof legacyDarkId === 'string' && legacyDarkId.trim().length > 0) { + darkThemeId = legacyDarkId.trim(); + } + + return { themeMode, lightThemeId, darkThemeId }; +}; + +/** + * Resolve the preferences for a runtime at boot: the scoped entry when one + * exists, otherwise a one-time seed from the superseded global keys, otherwise + * defaults. The seed guarantees the first scoped write carries the last-known + * theme instead of defaults. + */ +export const resolveThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences => { + const stored = readThemePreferencesForRuntime(runtimeKey); + return stored ?? readLegacyThemePreferences(); +}; + +/** + * Adopt another runtime's stored preferences when the endpoint switches: the + * new runtime's scoped entry when one exists, otherwise the current + * preferences unchanged (the same reference — no re-render, no write-through) + * until the incoming settings sync refines with the server's authoritative + * value. + */ +export const adoptThemePreferencesForRuntime = ( + runtimeKey: string, + current: StoredThemePreferences, +): StoredThemePreferences => readThemePreferencesForRuntime(runtimeKey) ?? current; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 934162d1..91870b90 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -106,11 +106,6 @@ const persistToLocalStorage = (settings: DesktopSettings) => { } persistRuntimeSettingsMirror(settings, getRuntimeKey()); - setOrRemoveLocalStorage('selectedThemeId', settings.themeId || null); - setOrRemoveLocalStorage('selectedThemeVariant', settings.themeVariant || null); - setOrRemoveLocalStorage('lightThemeId', settings.lightThemeId || null); - setOrRemoveLocalStorage('darkThemeId', settings.darkThemeId || null); - setOrRemoveLocalStorage('useSystemTheme', typeof settings.useSystemTheme === 'boolean' ? String(settings.useSystemTheme) : null); setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null); if (settings.homeDirectory) { localStorage.setItem('homeDirectory', settings.homeDirectory); From 6eec839fbfb4e0e93ac18e18b10f03c7bf32b0ef Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 13:29:52 +0300 Subject: [PATCH 29/37] refactor(ui): parse the scoped theme entry at the boundary; runtime-switch owns transient keys The scoped theme entry is now parsed by one boundary parser with a stated invariant instead of ad hoc typeof narrowing, and the runtime keys that mean "no instance connected" live next to the code that produces them so a new sentinel cannot miss the theme-storage guard. --- packages/ui/src/apps/MobileApp.tsx | 10 +- .../ui/src/contexts/theme-storage.test.ts | 2 +- packages/ui/src/contexts/theme-storage.ts | 95 +++++++++---------- packages/ui/src/lib/runtime-switch.ts | 11 +++ 4 files changed, 63 insertions(+), 55 deletions(-) diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 234df4b7..0d1dbab9 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -27,7 +27,7 @@ import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device' import { useHardwareKeyboard } from '@/lib/hardwareKeyboard'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; +import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint, MOBILE_DISCONNECTED_RUNTIME_KEY } from '@/lib/runtime-switch'; import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache'; import { cn } from '@/lib/utils'; @@ -686,7 +686,7 @@ export function MobileApp({ apis }: MobileAppProps) { }; const disconnect = (reason: string) => { logMobileConnectEvent('resume:disconnect', { reason }); - switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY }); setConnectionEpoch((value) => value + 1); }; @@ -896,7 +896,7 @@ export function MobileApp({ apis }: MobileAppProps) { const dropToConnectScreen = (notice: MobileConnectionNotice | null) => { logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' }); if (notice) setAutoConnectNotice(notice); - switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY }); setConnectionEpoch((value) => value + 1); }; void reprobeActiveConnection().then(async (outcome) => { @@ -1196,7 +1196,7 @@ export function MobileApp({ apis }: MobileAppProps) { type="button" variant="outline" onClick={() => { - switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY }); setConnectionEpoch((value) => value + 1); }} > @@ -1279,7 +1279,7 @@ export function MobileApp({ apis }: MobileAppProps) { { - switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY }); setConnectionEpoch((value) => value + 1); }} /> diff --git a/packages/ui/src/contexts/theme-storage.test.ts b/packages/ui/src/contexts/theme-storage.test.ts index 92acc2ba..ecba5ad0 100644 --- a/packages/ui/src/contexts/theme-storage.test.ts +++ b/packages/ui/src/contexts/theme-storage.test.ts @@ -5,12 +5,12 @@ import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/theme import { adoptThemePreferencesForRuntime, getThemePreferencesStorageKey, - isTransientRuntimeKey, readThemePreferencesForRuntime, resolveThemePreferencesForRuntime, resolveThemePreferencesFromStorageEvent, writeThemePreferencesForRuntime, } from './theme-storage'; +import { isTransientRuntimeKey } from '@/lib/runtime-switch'; let createdWindow = false; let createdLocalStorage = false; diff --git a/packages/ui/src/contexts/theme-storage.ts b/packages/ui/src/contexts/theme-storage.ts index 8fc94ef6..863b9efc 100644 --- a/packages/ui/src/contexts/theme-storage.ts +++ b/packages/ui/src/contexts/theme-storage.ts @@ -1,5 +1,6 @@ import type { ThemeMode } from '@/types/theme'; import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes'; +import { isTransientRuntimeKey } from '@/lib/runtime-switch'; type StoredThemePreferences = { themeMode: ThemeMode; @@ -21,56 +22,56 @@ const THEME_PREFERENCES_KEY_PREFIX = 'openchamber.theme.v2:'; export const getThemePreferencesStorageKey = (runtimeKey: string): string => `${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`; -// Runtime keys that mean "no instance connected" — the uninitialized default -// and the mobile disconnect state. They carry no instance theme, so scoped -// storage must not read or write them: a write would pin whatever theme was -// current at that moment (e.g. cold-boot defaults) to a key every future -// launch resolves before connecting, and a read would surface that stale -// entry on the mobile connect splash. The global splash hints are the right -// fallback for those phases. -const TRANSIENT_RUNTIME_KEYS = new Set(['', 'url:default', 'mobile-disconnected']); +const THEME_MODES: readonly ThemeMode[] = ['light', 'dark', 'system']; -export const isTransientRuntimeKey = (runtimeKey: string): boolean => - TRANSIENT_RUNTIME_KEYS.has(runtimeKey); +const isThemeMode = (value: string): value is ThemeMode => + THEME_MODES.some((mode) => mode === value); -export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => { - if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { - return null; - } - let raw: string | null = null; +// Boundary parser for the scoped entry. A malformed or partial payload is a +// failure (`null`), never a valid default: the caller then falls back to the +// legacy seed or keeps its current preferences. +const parseStoredThemePreferences = (raw: string): StoredThemePreferences | null => { try { - raw = localStorage.getItem(getThemePreferencesStorageKey(runtimeKey)); - } catch { - return null; - } - if (!raw) { - return null; - } - try { - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== 'object') { + // SAFETY: this key is written only by `writeThemePreferencesForRuntime` + // with exactly this shape. Every field is still re-checked below, and a + // field of the wrong type throws on `.trim()` into the catch. + const candidate = JSON.parse(raw) as Partial | null; + if (candidate === null) { return null; } - const candidate = parsed as Record; - if (candidate.themeMode !== 'light' && candidate.themeMode !== 'dark' && candidate.themeMode !== 'system') { + const themeMode = candidate.themeMode ?? ''; + if (!isThemeMode(themeMode)) { return null; } - if (typeof candidate.lightThemeId !== 'string' || typeof candidate.darkThemeId !== 'string') { - return null; - } - const lightThemeId = candidate.lightThemeId.trim(); - const darkThemeId = candidate.darkThemeId.trim(); + const lightThemeId = (candidate.lightThemeId ?? '').trim(); + const darkThemeId = (candidate.darkThemeId ?? '').trim(); if (!lightThemeId || !darkThemeId) { return null; } - return { themeMode: candidate.themeMode, lightThemeId, darkThemeId }; + return { themeMode, lightThemeId, darkThemeId }; } catch { return null; } }; +const readLocalStorageItem = (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; + +export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => { + if (isTransientRuntimeKey(runtimeKey)) { + return null; + } + const raw = readLocalStorageItem(getThemePreferencesStorageKey(runtimeKey)); + return raw ? parseStoredThemePreferences(raw) : null; +}; + export const writeThemePreferencesForRuntime = (runtimeKey: string, preferences: StoredThemePreferences): void => { - if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { + if (isTransientRuntimeKey(runtimeKey)) { return; } try { @@ -118,16 +119,12 @@ const readLegacyThemePreferences = (): StoredThemePreferences => { let lightThemeId: string = DEFAULT_LIGHT_THEME_ID; let darkThemeId: string = DEFAULT_DARK_THEME_ID; - if (typeof window === 'undefined') { - return { themeMode, lightThemeId, darkThemeId }; - } + const legacyMode = readLocalStorageItem('themeMode'); + const legacyUseSystem = readLocalStorageItem('useSystemTheme'); + const legacyThemeId = readLocalStorageItem('selectedThemeId'); + const legacyVariant = readLocalStorageItem('selectedThemeVariant'); - const legacyMode = localStorage.getItem('themeMode'); - const legacyUseSystem = localStorage.getItem('useSystemTheme'); - const legacyThemeId = localStorage.getItem('selectedThemeId'); - const legacyVariant = localStorage.getItem('selectedThemeVariant'); - - if (legacyMode === 'light' || legacyMode === 'dark' || legacyMode === 'system') { + if (legacyMode !== null && isThemeMode(legacyMode)) { themeMode = legacyMode; } else if (legacyUseSystem !== null) { const useSystem = legacyUseSystem === 'true'; @@ -148,13 +145,13 @@ const readLegacyThemePreferences = (): StoredThemePreferences => { themeMode = legacyVariant; } - const legacyLightId = localStorage.getItem('lightThemeId'); - const legacyDarkId = localStorage.getItem('darkThemeId'); - if (typeof legacyLightId === 'string' && legacyLightId.trim().length > 0) { - lightThemeId = legacyLightId.trim(); + const legacyLightId = readLocalStorageItem('lightThemeId')?.trim(); + const legacyDarkId = readLocalStorageItem('darkThemeId')?.trim(); + if (legacyLightId) { + lightThemeId = legacyLightId; } - if (typeof legacyDarkId === 'string' && legacyDarkId.trim().length > 0) { - darkThemeId = legacyDarkId.trim(); + if (legacyDarkId) { + darkThemeId = legacyDarkId; } return { themeMode, lightThemeId, darkThemeId }; diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 8b11ef90..a6a6eecc 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -51,6 +51,17 @@ const normalizeRuntimeUrlKey = (value: string): string => { } }; +// Runtime keys that mean "no instance connected": the uninitialized default +// (`normalizeRuntimeUrlKey` of an empty/unparseable base URL) and the mobile +// disconnect state (`MobileApp` switches to it when the connection drops). +// Per-instance client state (e.g. the scoped theme entry) must not be read +// from or written under them. +export const MOBILE_DISCONNECTED_RUNTIME_KEY = 'mobile-disconnected'; +const UNINITIALIZED_RUNTIME_KEY = 'url:default'; + +export const isTransientRuntimeKey = (runtimeKey: string): boolean => + runtimeKey === '' || runtimeKey === UNINITIALIZED_RUNTIME_KEY || runtimeKey === MOBILE_DISCONNECTED_RUNTIME_KEY; + const readInjectedApiBaseUrl = (): string => { if (typeof window === 'undefined') return ''; const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__; From 9847ef179fd6f3c8455404983c502a6088a807a0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 13:33:36 +0300 Subject: [PATCH 30/37] chore: changelog for per-instance themes and scheduled-task settings retention --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e488719..c903d197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file. - Chat: a turn that OpenCode stopped no longer ends with nothing on screen — what OpenCode reported shows under the last message, and a message an idle session has left unanswered is named as such. The status report (Ctrl/Cmd+Shift+L) now lists the last session errors, rejected sends, the managed OpenCode process's last error, and where the log files are. - Chat: a session opened from the sidebar lands at its end and stays there, instead of landing above the bottom or snapping up a moment later while the recap and subagent cards finish measuring. - Git: the commit graph no longer leaves a gap in a lane when the same branch is merged twice (thanks to @Naputt1). +- Settings: the theme is now remembered per OpenChamber instance. Two windows connected to different instances no longer swap themes with each other or overwrite each other's choice on every settings sync; each window boots with the theme of the instance it points at (thanks to @kydorn). +- Scheduled tasks: a task's Goal and Auto-accept settings no longer disappear after a run when another OpenChamber process — an older desktop, CLI, or VS Code build — shares the same project config. Every process now rewrites only what it changed and leaves the rest of each task exactly as stored. - Desktop: on Windows and Linux the close button sits flush against the window edge, so the exact top-right corner closes the window, and its hover color follows the theme (thanks to @kydorn). ## [1.21.1] - 2026-08-29 From 65054a5f4aeb2badae57cf425d13a142a85afc2d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 14:17:12 +0300 Subject: [PATCH 31/37] feat(ui): gate the pull-request surface on GitHub, move the account into it, and GitHub sign-in into Integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pull-request rail icon now appears only while GitHub is connected (OAuth or gh CLI), like Linear; Linear sits after the walkthrough in the default rail order. The GitHub account avatar and switcher leave the header for the pull-request panel, where the walkthrough, refresh, and account controls share one row and one height, and the account stays visible on the panel's empty state. A manual refresh keeps its spinner on screen long enough to read as work done. GitHub sign-in moves from Settings → Git to Settings → Integrations → Built-in integrations as a card before Linear; search and the connect buttons follow it. --- .../github/GitHubAccountControl.tsx | 161 ++++++++++++++++ .../components/layout/ContextPanelRail.tsx | 15 +- packages/ui/src/components/layout/Header.tsx | 176 ------------------ .../sections/git-identities/GitPage.tsx | 4 +- .../integrations/GitHubIntegration.tsx | 72 +++++++ .../integrations/IntegrationsPage.tsx | 22 ++- .../sections/integrations/LinearSettings.tsx | 9 - .../sections/openchamber/GitHubSettings.tsx | 57 ++++-- .../sections/openchamber/OpenChamberPage.tsx | 11 -- .../session/GitHubIntegrationDialog.tsx | 2 +- .../session/GitHubIssuePickerDialog.tsx | 2 +- .../session/GitHubPrPickerDialog.tsx | 2 +- .../views/git/PullRequestSection.tsx | 96 +++++++--- packages/ui/src/hooks/useKeyboardShortcuts.ts | 2 + .../i18n/messages/linear-integration.i18n.ts | 38 +++- packages/ui/src/lib/settings/metadata.ts | 4 +- packages/ui/src/lib/settings/search.ts | 16 +- packages/ui/src/lib/surfaces/DOCUMENTATION.md | 4 +- packages/ui/src/lib/surfaces/registry.test.ts | 15 +- packages/ui/src/lib/surfaces/registry.ts | 25 ++- 20 files changed, 461 insertions(+), 272 deletions(-) create mode 100644 packages/ui/src/components/github/GitHubAccountControl.tsx create mode 100644 packages/ui/src/components/sections/integrations/GitHubIntegration.tsx diff --git a/packages/ui/src/components/github/GitHubAccountControl.tsx b/packages/ui/src/components/github/GitHubAccountControl.tsx new file mode 100644 index 00000000..2f8a6ca9 --- /dev/null +++ b/packages/ui/src/components/github/GitHubAccountControl.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import type { GitHubAuthStatus } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { cn } from '@/lib/utils'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; + +type GitHubAccount = NonNullable[number]; + +const AVATAR_CLASS = 'flex h-6 w-6 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80'; + +const activateAccount = async ( + github: ReturnType['github'], + accountId: string, +): Promise => { + if (github) { + return github.authActivate(accountId); + } + const response = await runtimeFetch('/api/github/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ accountId }), + }); + // SAFETY: the route is ours and answers the auth status shape (plus an + // `error` string on failure) on every response; a non-ok status throws below. + const body = (await response.json().catch(() => null)) as (GitHubAuthStatus & { error?: string }) | null; + if (!response.ok || !body) { + throw new Error(body?.error || response.statusText); + } + return body; +}; + +/** + * The connected GitHub account: an avatar, and a switcher when more than one + * account is signed in (OAuth and `gh` CLI logins). Renders nothing while + * GitHub is disconnected — connecting happens in Settings → Integrations. + */ +export const GitHubAccountControl: React.FC<{ className?: string }> = ({ className }) => { + const { t } = useI18n(); + const { github } = useRuntimeAPIs(); + const status = useGitHubAuthStore((state) => state.status); + const setStatus = useGitHubAuthStore((state) => state.setStatus); + const [isSwitching, setIsSwitching] = React.useState(false); + + const switchAccount = React.useCallback(async (accountId: string) => { + if (!accountId || isSwitching) return; + setIsSwitching(true); + try { + setStatus(await activateAccount(github, accountId)); + } catch (error) { + console.error('Failed to switch GitHub account:', error); + } finally { + setIsSwitching(false); + } + }, [github, isSwitching, setStatus]); + + if (!status?.connected) { + return null; + } + + const login = status.user?.login ?? null; + const avatarUrl = status.user?.avatarUrl ?? null; + const accounts: GitHubAccount[] = status.accounts ?? []; + const title = login ? t('header.github.connectedWithLogin', { login }) : t('header.github.connected'); + const avatar = avatarUrl ? ( + {login + ) : ( + + ); + + if (accounts.length <= 1) { + return ( +
+ {avatar} +
+ ); + } + + return ( + + + + + + + {t('header.github.accountsTitle')} + + + {accounts.map((account) => { + const accountUser = account.user; + const isCurrent = Boolean(account.current); + const sourceLabel = account.source === 'gh-cli' + ? t('header.github.accountSource.cli') + : t('header.github.accountSource.oauth'); + return ( + { + if (!isCurrent) { + void switchAccount(account.id); + } + }} + > + {accountUser?.avatarUrl ? ( + {accountUser.login + ) : ( +
+ +
+ )} + + + {accountUser?.name?.trim() || accountUser?.login || 'GitHub'} + + {accountUser?.login ? ( + + {accountUser.login} + · + {sourceLabel} + + ) : null} + + {isCurrent ? : null} +
+ ); + })} +
+
+ ); +}; diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index f1504a45..deb92627 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -35,6 +35,7 @@ import { import { cn } from '@/lib/utils'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitStatus } from '@/stores/useGitStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog'; @@ -171,6 +172,8 @@ export const ContextPanelRail: React.FC = () => { const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); const linearConnected = useLinearAuthStore((state) => state.status?.connected === true); + const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); + const githubConnected = useGitHubAuthStore((state) => state.status?.connected === true); const { screenWidth } = useDeviceInfo(); const gitStatus = useGitStatus(directoryKey || null); @@ -268,9 +271,12 @@ export const ContextPanelRail: React.FC = () => { screenWidth, tabs, linearConnected, + githubConnected, }); - }, [contextRailHiddenSurfaces, contextRailOrder, linearConnected, planModeEnabled, screenWidth, tabs]); + }, [contextRailHiddenSurfaces, contextRailOrder, githubConnected, linearConnected, planModeEnabled, screenWidth, tabs]); + // A surface whose integration disconnected closes rather than lingering as + // an active panel with no rail icon. React.useEffect(() => { if (!directoryKey || !linearAuthChecked || linearConnected || activeMode !== 'linear') { return; @@ -278,6 +284,13 @@ export const ContextPanelRail: React.FC = () => { closeContextPanel(directoryKey); }, [activeMode, closeContextPanel, directoryKey, linearAuthChecked, linearConnected]); + React.useEffect(() => { + if (!directoryKey || !githubAuthChecked || githubConnected || activeMode !== 'pr') { + return; + } + closeContextPanel(directoryKey); + }, [activeMode, closeContextPanel, directoryKey, githubAuthChecked, githubConnected]); + const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false); const handleDragEnd = React.useCallback((event: DragEndEvent) => { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 7c1f3d3e..43126229 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -9,7 +9,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; @@ -30,7 +29,6 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; -import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; @@ -45,7 +43,6 @@ import { import { } from '@/components/ui/collapsible'; -import type { GitHubAuthStatus } from '@/lib/api/types'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; @@ -123,132 +120,6 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({ ); }); -type DesktopGitHubControlProps = { - isMobile: boolean; - githubAuthStatus: GitHubAuthStatus | null; - githubAccounts: Array[number]>; - githubAvatarUrl: string | null; - githubLogin: string | null; - isSwitchingGitHubAccount: boolean; - handleGitHubAccountSwitch: (accountId: string) => Promise; -}; - -const DesktopGitHubControl = React.memo(function DesktopGitHubControl({ - isMobile, - githubAuthStatus, - githubAccounts, - githubAvatarUrl, - githubLogin, - isSwitchingGitHubAccount, - handleGitHubAccountSwitch, -}: DesktopGitHubControlProps) { - const { t } = useI18n(); - if (!githubAuthStatus?.connected || isMobile) { - return null; - } - - if (githubAccounts.length > 1) { - return ( - - - - - - - {t('header.github.accountsTitle')} - - - {githubAccounts.map((account) => { - const accountUser = account.user; - const isCurrent = Boolean(account.current); - const sourceLabel = account.source === 'gh-cli' - ? t('header.github.accountSource.cli') - : t('header.github.accountSource.oauth'); - return ( - { - if (!isCurrent) { - void handleGitHubAccountSwitch(account.id); - } - }} - > - {accountUser?.avatarUrl ? ( - {accountUser.login - ) : ( -
- -
- )} - - - {accountUser?.name?.trim() || accountUser?.login || 'GitHub'} - - {accountUser?.login ? ( - - {accountUser.login} - · - {sourceLabel} - - ) : null} - - {isCurrent ? : null} -
- ); - })} -
-
- ); - } - - return ( -
- {githubAvatarUrl ? ( - {githubLogin - ) : ( - - )} -
- ); -}); - type DesktopServicesMenuProps = { isDesktopApp: boolean; currentInstanceLabel: string; @@ -439,7 +310,6 @@ export const Header: React.FC = () => { const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled); const getCurrentModel = useConfigStore((state) => state.getCurrentModel); - const runtimeApis = useRuntimeAPIs(); const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); @@ -488,8 +358,6 @@ export const Header: React.FC = () => { const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const { isMobile } = useDeviceInfo(); - const githubAuthStatus = useGitHubAuthStore((state) => state.status); - const setGitHubAuthStatus = useGitHubAuthStore((state) => state.setStatus); const headerRef = React.useRef(null); @@ -571,10 +439,6 @@ export const Header: React.FC = () => { } }, [contextUsage, currentSessionId, isContextUsageResolvedForSession]); - const githubAvatarUrl = githubAuthStatus?.connected ? (githubAuthStatus.user?.avatarUrl ?? null) : null; - const githubLogin = githubAuthStatus?.connected ? (githubAuthStatus.user?.login ?? null) : null; - const githubAccounts = githubAuthStatus?.accounts ?? []; - const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false); const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false); const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local'); const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true); @@ -1183,37 +1047,6 @@ export const Header: React.FC = () => { sessionDirectory, ]); - const handleGitHubAccountSwitch = React.useCallback(async (accountId: string) => { - if (!accountId || isSwitchingGitHubAccount) return; - setIsSwitchingGitHubAccount(true); - try { - const payload = runtimeApis.github - ? await runtimeApis.github.authActivate(accountId) - : await (async () => { - const response = await runtimeFetch('/api/github/auth/activate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ accountId }), - }); - const body = (await response.json().catch(() => null)) as - | (GitHubAuthStatus & { error?: string }) - | null; - if (!response.ok || !body) { - throw new Error(body?.error || response.statusText); - } - return body; - })(); - - setGitHubAuthStatus(payload); - } catch (error) { - console.error('Failed to switch GitHub account:', error); - } finally { - setIsSwitchingGitHubAccount(false); - } - }, [isSwitchingGitHubAccount, runtimeApis.github, setGitHubAuthStatus]); @@ -1482,15 +1315,6 @@ export const Header: React.FC = () => { onOpenRemoteUpdate={openRemoteInstanceUpdate} /> ) : null} - ); diff --git a/packages/ui/src/components/sections/git-identities/GitPage.tsx b/packages/ui/src/components/sections/git-identities/GitPage.tsx index d90d1a8f..31060e83 100644 --- a/packages/ui/src/components/sections/git-identities/GitPage.tsx +++ b/packages/ui/src/components/sections/git-identities/GitPage.tsx @@ -19,7 +19,6 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore'; import { useShallow } from 'zustand/react/shallow'; import { GitSettings } from '@/components/sections/openchamber/GitSettings'; -import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings'; import { GitIdentityEditorDialog } from './GitIdentityEditorDialog'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; @@ -121,10 +120,9 @@ export const GitPage: React.FC = () => { title={t('settings.page.git.title')} showSaveStatus > - - openEditor('new')}> {t('settings.common.badge.new')} diff --git a/packages/ui/src/components/sections/integrations/GitHubIntegration.tsx b/packages/ui/src/components/sections/integrations/GitHubIntegration.tsx new file mode 100644 index 00000000..466de0f7 --- /dev/null +++ b/packages/ui/src/components/sections/integrations/GitHubIntegration.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Icon } from '@/components/icon/Icon'; +import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; + +/** + * The GitHub row of Settings → Integrations → Built-in integrations: a + * collapsible card whose body is the account/device-flow UI. Sign-in status + * shows on the collapsed row so the page answers "am I connected?" at a + * glance, like the Linear card beside it. + */ +export const GitHubIntegration: React.FC = () => { + const { t } = useI18n(); + const status = useGitHubAuthStore((state) => state.status); + const isLoading = useGitHubAuthStore((state) => state.isLoading); + const hasChecked = useGitHubAuthStore((state) => state.hasChecked); + const [open, setOpen] = React.useState(false); + + const connected = status?.connected === true; + const statusLabel = isLoading && !hasChecked + ? t('common.loading') + : connected + ? (status?.user?.login?.trim() || t('settings.github.page.status.active')) + : t('settings.integrations.github.status.notConnected'); + const statusClassName = connected + ? 'bg-[var(--status-success)]/15 text-[var(--status-success)]' + : 'bg-[var(--surface-muted)] text-muted-foreground'; + + return ( + +
+ +
+ +
+
+
+ {t('settings.integrations.github.title')} +
+

+ {t('settings.integrations.github.description')} +

+
+ + {statusLabel} + + +
+ + + +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx index 8bbf2cab..a3b5956d 100644 --- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx +++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx @@ -1,7 +1,10 @@ import React from 'react'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; +import { SettingsSection } from '@/components/sections/shared/SettingsSection'; import { useI18n } from '@/lib/i18n'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { GitHubIntegration } from './GitHubIntegration'; import { LinearSettings } from './LinearSettings'; import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection'; @@ -15,7 +18,11 @@ export const IntegrationsPage: React.FC = ({ onOpenPluginManager, }) => { const { t } = useI18n(); + // GitHub sign-in is an OpenChamber server feature; the VS Code extension + // uses the editor's own GitHub session instead. + const hasGitHub = !isVSCodeRuntime(); const hasLinear = Boolean(getRegisteredRuntimeAPIs()?.linear); + const hasBuiltIn = hasGitHub || hasLinear; return ( = ({ description={t('settings.page.integrations.description')} showSaveStatus > - {hasLinear ? : null} + {hasBuiltIn ? ( + + {hasGitHub ? : null} + {hasLinear ? : null} + + ) : null} diff --git a/packages/ui/src/components/sections/integrations/LinearSettings.tsx b/packages/ui/src/components/sections/integrations/LinearSettings.tsx index fc71aadb..98bda06c 100644 --- a/packages/ui/src/components/sections/integrations/LinearSettings.tsx +++ b/packages/ui/src/components/sections/integrations/LinearSettings.tsx @@ -9,7 +9,6 @@ import { openExternalUrl } from '@/lib/url'; import { useI18n } from '@/lib/i18n'; import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop'; import { Icon } from '@/components/icon/Icon'; -import { SettingsSection } from '@/components/sections/shared/SettingsSection'; import { LinearProjectMapping } from './LinearProjectMapping'; import { LinearSessionComments } from './LinearSessionComments'; @@ -172,13 +171,6 @@ export const LinearSettings: React.FC = () => { const expanded = isWaiting || open; return ( - { @@ -345,6 +337,5 @@ export const LinearSettings: React.FC = () => {
- ); }; diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index e2a06bda..c72dc48b 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -34,7 +34,12 @@ type DeviceFlowCompleteResponse = | { connected: true; user: GitHubUser; scope?: string } | { connected: false; status?: string; error?: string }; -export const GitHubSettings: React.FC = () => { +type GitHubSettingsProps = { + /** Rendered inside the Integrations card: no section chrome of its own. */ + embedded?: boolean; +}; + +export const GitHubSettings: React.FC = ({ embedded = false }) => { const { t } = useI18n(); const { isMobile } = useDeviceInfo(); const runtimeGitHub = getRegisteredRuntimeAPIs()?.github; @@ -269,14 +274,8 @@ export const GitHubSettings: React.FC = () => { ? t('settings.github.page.accountSource.cli') : t('settings.github.page.accountSource.oauth'); - return ( + const accountSection = ( <> -
{connected ? (
@@ -445,10 +444,12 @@ export const GitHubSettings: React.FC = () => {
)} - + + ); - {ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) && ( - + const ghCliSection = ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) + ? ( + <>
@@ -499,8 +500,40 @@ export const GitHubSettings: React.FC = () => {
+ + ) + : null; + + if (embedded) { + return ( +
+ {accountSection} + {ghCliSection ? ( +
+ {t('settings.github.page.ghCli.title')} + {ghCliSection} +
+ ) : null} +
+ ); + } + + return ( + <> + + {accountSection} + + + {ghCliSection ? ( + + {ghCliSection} - )} + ) : null} ); }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index d1959d72..d9d7ee93 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -7,7 +7,6 @@ import { AppLinkSecuritySettings } from './AppLinkSecuritySettings'; import { DefaultsSettings } from './DefaultsSettings'; import { GitSettings } from './GitSettings'; import { NotificationSettings } from './NotificationSettings'; -import { GitHubSettings } from './GitHubSettings'; import { VoiceSettings } from './VoiceSettings'; import { TunnelSettings } from './TunnelSettings'; import { OpenCodeCliSettings } from './OpenCodeCliSettings'; @@ -78,8 +77,6 @@ export const OpenChamberPage: React.FC = ({ section }) => return ; case 'git': return ; - case 'github': - return ; case 'notifications': return ; case 'voice': @@ -233,14 +230,6 @@ const GitSectionContent: React.FC = () => { return ; }; -// GitHub section: Connect account for PR/issue workflows -const GitHubSectionContent: React.FC = () => { - if (isVSCodeRuntime()) { - return null; - } - return ; -}; - // Notifications section: Native browser notifications const NotificationSectionContent: React.FC = () => { return ; diff --git a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx index 409348ef..966ff6ea 100644 --- a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx +++ b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx @@ -284,7 +284,7 @@ export function GitHubIntegrationDialog({ const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; const openGitHubSettings = () => { - setSettingsPage('github'); + setSettingsPage('integrations'); setSettingsDialogOpen(true); }; diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx index 2db1e456..a861ac7b 100644 --- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -230,7 +230,7 @@ export function GitHubIssuePickerDialog({ const repoUrl = result?.repo?.url ?? null; const openGitHubSettings = React.useCallback(() => { - setSettingsPage('github'); + setSettingsPage('integrations'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage]); diff --git a/packages/ui/src/components/session/GitHubPrPickerDialog.tsx b/packages/ui/src/components/session/GitHubPrPickerDialog.tsx index c423503a..fcd0b716 100644 --- a/packages/ui/src/components/session/GitHubPrPickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubPrPickerDialog.tsx @@ -217,7 +217,7 @@ export function GitHubPrPickerDialog({ const connected = githubAuthChecked ? result?.connected !== false : true; const openGitHubSettings = React.useCallback(() => { - setSettingsPage('github'); + setSettingsPage('integrations'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage]); diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 1a767916..20c9d87d 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -20,6 +20,7 @@ import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { Icon } from "@/components/icon/Icon"; +import { GitHubAccountControl } from '@/components/github/GitHubAccountControl'; import { useUIStore } from '@/stores/useUIStore'; import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction'; @@ -102,6 +103,10 @@ const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'op }; const PR_ACTION_REFRESH_DELAYS_MS = [2_000, 5_000] as const; +// A manual refresh keeps its spinner visible at least this long: the request +// often answers from the server cache within a few milliseconds, and a +// spinner that never reaches the screen reads as "the button did nothing". +const PR_MANUAL_REFRESH_MIN_SPIN_MS = 600; const branchToTitle = (branch: string): string => { return branch @@ -337,7 +342,7 @@ export const PullRequestSection: React.FC<{ const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime(); const openGitHubSettings = React.useCallback(() => { - setSettingsPage('github'); + setSettingsPage('integrations'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage]); @@ -1040,6 +1045,31 @@ export const PullRequestSection: React.FC<{ await refreshPrStatus(prStatusKey, options); }, [prStatusKey, refreshPrStatus]); + const [isManualRefreshing, setIsManualRefreshing] = React.useState(false); + const manualRefreshMountedRef = React.useRef(true); + React.useEffect(() => { + manualRefreshMountedRef.current = true; + return () => { + manualRefreshMountedRef.current = false; + }; + }, []); + const refreshManually = React.useCallback(async () => { + if (isManualRefreshing) return; + setIsManualRefreshing(true); + const startedAt = Date.now(); + try { + await refresh({ force: true }); + } finally { + const remaining = PR_MANUAL_REFRESH_MIN_SPIN_MS - (Date.now() - startedAt); + if (remaining > 0) { + await new Promise((resolve) => window.setTimeout(resolve, remaining)); + } + if (manualRefreshMountedRef.current) { + setIsManualRefreshing(false); + } + } + }, [isManualRefreshing, refresh]); + const scheduleActionRefresh = React.useCallback(() => { pendingActionRefreshTimersRef.current.forEach((timerId) => { window.clearTimeout(timerId); @@ -1406,7 +1436,10 @@ export const PullRequestSection: React.FC<{ return (
-
{t('gitView.pullRequest.title')}
+
+
{t('gitView.pullRequest.title')}
+ +
{t('gitView.pullRequest.availableOnFeatureBranches')}
@@ -1450,7 +1483,7 @@ export const PullRequestSection: React.FC<{ return (
-
+
{pr ? (
-
- {isLoading ? : null} +
+ {pr && showWalkthroughAction ? ( + + ) : null} - + {isLoading || isManualRefreshing + ? + : } +

{t('gitView.pr.actions.refresh')}

+
{pr ? ( -
+
{prStatusText} {checks ? ( @@ -1508,23 +1561,6 @@ export const PullRequestSection: React.FC<{ ) : null}
- {showWalkthroughAction ? ( - - ) : null} {canMerge && pr.draft && pr.state === 'open' ? ( diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 3eff8676..bb069914 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -30,6 +30,7 @@ import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstr import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; @@ -507,6 +508,7 @@ export const useKeyboardShortcuts = () => { screenWidth: window.innerWidth, tabs: panel?.tabs ?? [], linearConnected: useLinearAuthStore.getState().status?.connected === true, + githubConnected: useGitHubAuthStore.getState().status?.connected === true, }); const target = visibleSurfaces[switchSurfaceDigit - 1]; if (target) { diff --git a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts index 529a6aba..44deca07 100644 --- a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts +++ b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts @@ -1,6 +1,9 @@ -/** Linear first-party integration settings strings — merged into each locale's settings dictionary. */ +/** Built-in integration (GitHub, Linear) settings strings — merged into each locale's settings dictionary. */ export const linearIntegrationI18n = { en: { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': 'Connect a GitHub account for pull requests and issues.', + 'settings.integrations.github.status.notConnected': 'Not connected', 'settings.integrations.firstParty.title': 'Built-in integrations', 'settings.integrations.firstParty.info': 'Sign-ins for services that ship with OpenChamber. The login stays on this computer so web, desktop, and a paired phone share it.', 'settings.integrations.linear.title': 'Linear', @@ -48,6 +51,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts used when starting a session from a Linear issue: visible user message + hidden instructions.', }, de: { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': 'GitHub-Konto für Pull Requests und Issues verbinden.', + 'settings.integrations.github.status.notConnected': 'Nicht verbunden', 'settings.integrations.firstParty.title': 'Eingebaute Integrationen', 'settings.integrations.firstParty.info': 'Anmeldungen für Dienste, die mit OpenChamber mitgeliefert werden. Die Anmeldung bleibt auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.', 'settings.integrations.linear.title': 'Linear', @@ -95,6 +101,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': 'Eingabeaufforderungen beim Start einer Sitzung aus einem Linear-Issue: sichtbare Benutzernachricht + versteckte Anweisungen.', }, fr: { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': 'Connecter un compte GitHub pour les pull requests et les issues.', + 'settings.integrations.github.status.notConnected': 'Non connecté', 'settings.integrations.firstParty.title': 'Intégrations natives', 'settings.integrations.firstParty.info': 'Connexions aux services fournis avec OpenChamber. La connexion reste sur cet ordinateur pour que le web, le bureau et un téléphone apparié la partagent.', 'settings.integrations.linear.title': 'Linear', @@ -142,6 +151,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts utilisés au démarrage d’une session depuis un ticket Linear : message utilisateur visible + instructions masquées.', }, es: { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': 'Conecta una cuenta de GitHub para pull requests e issues.', + 'settings.integrations.github.status.notConnected': 'No conectado', 'settings.integrations.firstParty.title': 'Integraciones nativas', 'settings.integrations.firstParty.info': 'Inicios de sesión de los servicios incluidos en OpenChamber. El inicio de sesión se guarda en este ordenador para que la web, el escritorio y un teléfono emparejado lo compartan.', 'settings.integrations.linear.title': 'Linear', @@ -189,6 +201,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados al iniciar una sesión desde un issue de Linear: mensaje visible del usuario e instrucciones ocultas.', }, ja: { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': 'プルリクエストと Issue のために GitHub アカウントを接続します。', + 'settings.integrations.github.status.notConnected': '未接続', 'settings.integrations.firstParty.title': '標準連携', 'settings.integrations.firstParty.info': 'OpenChamber に同梱されているサービスのログインです。このコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。', 'settings.integrations.linear.title': 'Linear', @@ -236,6 +251,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear の Issue からセッションを開始するときに使うプロンプト: 表示ユーザーメッセージ + 非表示の指示。', }, ko: { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': '풀 리퀘스트와 이슈를 위해 GitHub 계정을 연결합니다.', + 'settings.integrations.github.status.notConnected': '연결되지 않음', 'settings.integrations.firstParty.title': '기본 제공 통합', 'settings.integrations.firstParty.info': 'OpenChamber에 포함된 서비스 로그인입니다. 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.', 'settings.integrations.linear.title': 'Linear', @@ -283,6 +301,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear 이슈로 세션을 시작할 때 쓰는 프롬프트입니다: 표시 사용자 메시지 + 숨겨진 지침.', }, pl: { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': 'Połącz konto GitHub dla pull requestów i issues.', + 'settings.integrations.github.status.notConnected': 'Nie połączono', 'settings.integrations.firstParty.title': 'Wbudowane integracje', 'settings.integrations.firstParty.info': 'Logowania do usług dostarczanych z OpenChamber. Zapisujemy je na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.', 'settings.integrations.linear.title': 'Linear', @@ -330,6 +351,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompty używane przy starcie sesji ze zgłoszenia Linear: widoczna wiadomość użytkownika i ukryte instrukcje.', }, 'pt-BR': { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': 'Conecte uma conta do GitHub para pull requests e issues.', + 'settings.integrations.github.status.notConnected': 'Não conectado', 'settings.integrations.firstParty.title': 'Integrações nativas', 'settings.integrations.firstParty.info': 'Logins dos serviços inclusos no OpenChamber. O login fica neste computador para que a web, o app desktop e um celular emparelhado o compartilhem.', 'settings.integrations.linear.title': 'Linear', @@ -377,6 +401,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados ao iniciar uma sessão a partir de uma issue do Linear: mensagem visível do usuário e instruções ocultas.', }, uk: { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': 'Підключіть акаунт GitHub для pull request-ів та issues.', + 'settings.integrations.github.status.notConnected': 'Не підключено', 'settings.integrations.firstParty.title': 'Вбудовані інтеграції', 'settings.integrations.firstParty.info': 'Входи до сервісів, що входять до OpenChamber. Логін лишається на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються одним обліковим записом.', 'settings.integrations.linear.title': 'Linear', @@ -424,6 +451,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': 'Промпти для старту сесії з Linear issue: видиме повідомлення користувача та приховані інструкції.', }, 'zh-CN': { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': '连接 GitHub 账号以处理拉取请求和议题。', + 'settings.integrations.github.status.notConnected': '未连接', 'settings.integrations.firstParty.title': '内置集成', 'settings.integrations.firstParty.info': 'OpenChamber 自带服务的登录。登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它。', 'settings.integrations.linear.title': 'Linear', @@ -471,6 +501,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': '从 Linear Issue 开始会话时使用的提示词:可见用户消息 + 隐藏指令。', }, 'zh-TW': { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': '連接 GitHub 帳號以處理拉取請求與議題。', + 'settings.integrations.github.status.notConnected': '未連接', 'settings.integrations.firstParty.title': '內建整合', 'settings.integrations.firstParty.info': 'OpenChamber 內建服務的登入。登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它。', 'settings.integrations.linear.title': 'Linear', @@ -518,6 +551,9 @@ export const linearIntegrationI18n = { 'settings.magicPrompts.page.group.linearIssueReview.description': '從 Linear Issue 開始會話時使用的提示詞:可見使用者訊息 + 隱藏指令。', }, tr: { + 'settings.integrations.github.title': 'GitHub', + 'settings.integrations.github.description': "Pull request ve issue'lar için bir GitHub hesabı bağlayın.", + 'settings.integrations.github.status.notConnected': 'Bağlı değil', 'settings.integrations.firstParty.title': 'Yerleşik entegrasyonlar', 'settings.integrations.firstParty.info': 'OpenChamber ile gelen hizmetlerin oturumları. Giriş bu bilgisayarda kalır; web, masaüstü ve eşlenen telefon paylaşır.', 'settings.integrations.linear.title': 'Linear', diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index 4fc3c8d6..8603434d 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -150,7 +150,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ title: 'Git', group: 'projects', kind: 'single', - keywords: ['git', 'github', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji', 'oauth', 'prs', 'issues'], + keywords: ['git', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji'], isAvailable: (ctx) => !ctx.isVSCode, }, { @@ -202,7 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ { slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode }, { slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode }, { slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode }, - { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'linear'] }, + { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'github', 'linear'] }, ] as const; const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = { diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index d428ec62..14d0caf2 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -527,12 +527,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ // user to an empty spot on the page. isAvailable: (ctx) => !ctx.isVSCode && useUIStore.getState().agentMemoryFeatureAvailable, }, - { - id: 'git.github-account', - page: 'git', - titleKey: 'settings.github.page.actions.connect', - keywords: ['github', 'account', 'oauth', 'prs', 'issues'], - }, { id: 'git.identities', page: 'git', @@ -989,7 +983,15 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ page: 'integrations', titleKey: 'settings.integrations.firstParty.title', descriptionKey: 'settings.integrations.firstParty.info', - keywords: ['built-in', 'first-party', 'native', 'linear'], + keywords: ['built-in', 'first-party', 'native', 'github', 'linear'], + isAvailable: (ctx) => !ctx.isVSCode, + }, + { + id: 'integrations.github', + page: 'integrations', + titleKey: 'settings.integrations.github.title', + descriptionKey: 'settings.integrations.github.description', + keywords: ['github', 'account', 'oauth', 'gh', 'cli', 'prs', 'pull request', 'issues', 'connect'], isAvailable: (ctx) => !ctx.isVSCode, }, { diff --git a/packages/ui/src/lib/surfaces/DOCUMENTATION.md b/packages/ui/src/lib/surfaces/DOCUMENTATION.md index dbdf65f3..8ae0254e 100644 --- a/packages/ui/src/lib/surfaces/DOCUMENTATION.md +++ b/packages/ui/src/lib/surfaces/DOCUMENTATION.md @@ -27,7 +27,9 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by configure button — `ContextRailSurfacesDialog`), drops the plan surface unless plan mode is enabled, drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, hides - Linear unless a workspace is connected, and hides `has-content` surfaces + Linear unless a workspace is connected, hides the pull-request surface + unless GitHub is connected (OAuth or `gh` CLI — signed in from Settings → + Integrations), and hides `has-content` surfaces until a tab of their mode exists. Both consumers use it so the digit shown on a rail badge always maps to the same surface the shortcut opens. diff --git a/packages/ui/src/lib/surfaces/registry.test.ts b/packages/ui/src/lib/surfaces/registry.test.ts index e60a1af3..db4876b1 100644 --- a/packages/ui/src/lib/surfaces/registry.test.ts +++ b/packages/ui/src/lib/surfaces/registry.test.ts @@ -13,6 +13,7 @@ const baseOptions = { screenWidth: 1200, tabs: [], linearConnected: true, + githubConnected: true, } as const; describe('getVisibleContextRailSurfaces', () => { @@ -65,12 +66,16 @@ describe('getVisibleContextRailSurfaces', () => { expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']); }); - test('places Linear after Pull Request in the default order', () => { + test('places Linear right after the walkthrough in the default order', () => { const ids = getVisibleContextRailSurfaces(baseOptions).map((surface) => surface.id); - const pr = ids.indexOf('pr'); - const linear = ids.indexOf('linear'); - expect(pr).toBeGreaterThanOrEqual(0); - expect(linear).toBe(pr + 1); + const walkthrough = ids.indexOf('walkthrough'); + expect(walkthrough).toBeGreaterThanOrEqual(0); + expect(ids.indexOf('linear')).toBe(walkthrough + 1); + }); + + test('hides the pull request surface until GitHub is connected', () => { + expect(getVisibleContextRailSurfaces({ ...baseOptions, githubConnected: false }).some((s) => s.id === 'pr')).toBe(false); + expect(getVisibleContextRailSurfaces({ ...baseOptions, githubConnected: true }).some((s) => s.id === 'pr')).toBe(true); }); test('hides Linear until a workspace is connected', () => { diff --git a/packages/ui/src/lib/surfaces/registry.ts b/packages/ui/src/lib/surfaces/registry.ts index 9a0a8e10..f4dc0ee9 100644 --- a/packages/ui/src/lib/surfaces/registry.ts +++ b/packages/ui/src/lib/surfaces/registry.ts @@ -66,15 +66,6 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [ labelKey: 'contextPanel.mode.pr', availability: 'always', }, - { - id: 'linear', - descriptionKey: 'contextRail.surface.linear.description', - defaultWidthFraction: 0.45, - mode: 'linear', - icon: 'linear', - labelKey: 'contextPanel.mode.linear', - availability: 'always', - }, { id: 'diff', descriptionKey: 'contextRail.surface.diff.description', @@ -93,6 +84,15 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [ labelKey: 'contextPanel.mode.walkthrough', availability: 'always', }, + { + id: 'linear', + descriptionKey: 'contextRail.surface.linear.description', + defaultWidthFraction: 0.45, + mode: 'linear', + icon: 'linear', + labelKey: 'contextPanel.mode.linear', + availability: 'always', + }, { id: 'editor', descriptionKey: 'contextRail.surface.editor.description', @@ -206,6 +206,10 @@ type VisibleRailSurfacesOptions = { tabs: readonly { mode: ContextPanelMode }[]; /** Linear's rail icon stays off until a workspace is connected. */ linearConnected: boolean; + /** The pull-request rail icon stays off until GitHub is connected (OAuth + or a detected `gh` CLI login). GitHub is connected from Settings, so + hiding the surface removes no entry point. */ + githubConnected: boolean; }; /** @@ -240,6 +244,9 @@ export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOption if (surface.id === 'linear' && !options.linearConnected) { return false; } + if (surface.id === 'pr' && !options.githubConnected) { + return false; + } if (surface.availability === 'has-content') { return options.tabs.some((tab) => tab.mode === surface.mode); } From 02b1ee637d297104c9c17bfa6748735c94e06a0b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 14:28:22 +0300 Subject: [PATCH 32/37] feat(ui): move project actions from the titlebar overlay into the header before Open in --- packages/ui/src/components/layout/Header.tsx | 33 +++++++------------ .../src/components/layout/SidebarTopBar.tsx | 4 +-- .../layout/TitlebarLeftControls.tsx | 12 +------ 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 43126229..3fce3d60 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -46,6 +46,8 @@ import { import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; +import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; +import { useProjectActionsContext } from '@/hooks/useProjectActionsContext'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; import { SessionTabsStrip, type SessionTabMenuArgs } from './SessionTabsStrip'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop'; @@ -998,27 +1000,9 @@ export const Header: React.FC = () => { return normalize(openDirectory || activeProject?.path || ''); }, [activeProject?.path, openDirectory]); - const activeProjectRef = React.useMemo(() => { - if (!activeProject) { - return null; - } - return { id: activeProject.id, path: activeProject.path }; - }, [activeProject]); - - const lastProjectActionsContextRef = React.useRef<{ - projectRef: { id: string; path: string }; - directory: string; - } | null>(null); - - React.useEffect(() => { - if (!activeProjectRef || !actionDirectory) { - return; - } - lastProjectActionsContextRef.current = { - projectRef: activeProjectRef, - directory: actionDirectory, - }; - }, [actionDirectory, activeProjectRef]); + // Same resolution the titlebar overlay used to own: worktree → session → + // draft → project path, sticky across session switches. + const projectActionsContext = useProjectActionsContext(); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); @@ -1295,6 +1279,13 @@ export const Header: React.FC = () => { const desktopSidebarActions = ( <> + {projectActionsContext ? ( + + ) : null} {/* Instances only exist in the desktop app. On web the menu was left holding a single dev-only shutdown action, which is not a reason to diff --git a/packages/ui/src/components/layout/SidebarTopBar.tsx b/packages/ui/src/components/layout/SidebarTopBar.tsx index 564e051a..2e332521 100644 --- a/packages/ui/src/components/layout/SidebarTopBar.tsx +++ b/packages/ui/src/components/layout/SidebarTopBar.tsx @@ -2,8 +2,8 @@ import React from 'react'; /** * Strip at the top of the desktop left sidebar that reserves room for the - * persistent {@link TitlebarLeftControls} overlay (sidebar toggle + project - * actions), so the session list starts below them. Its height tracks the + * persistent {@link TitlebarLeftControls} overlay (sidebar toggle), so the + * session list starts below it. Its height tracks the * header via `--oc-header-height`. * * Split into two regions so the strip stays a window drag area while the diff --git a/packages/ui/src/components/layout/TitlebarLeftControls.tsx b/packages/ui/src/components/layout/TitlebarLeftControls.tsx index c8484eb7..41b92b3f 100644 --- a/packages/ui/src/components/layout/TitlebarLeftControls.tsx +++ b/packages/ui/src/components/layout/TitlebarLeftControls.tsx @@ -4,8 +4,6 @@ import { Icon } from '@/components/icon/Icon'; import { cn } from '@/lib/utils'; import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; -import { useProjectActionsContext } from '@/hooks/useProjectActionsContext'; -import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { invokeDesktop } from '@/lib/desktop'; @@ -15,7 +13,7 @@ const ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary hover:bg-interactive-hover transition-colors'; /** - * Persistent top-left titlebar controls (sidebar toggle + project actions). + * Persistent top-left titlebar controls (app menu on frameless chrome + sidebar toggle). * * Rendered exactly once as an absolutely-positioned overlay above both the * sidebar and the header, so the buttons never migrate / re-mount between the @@ -29,7 +27,6 @@ export const TitlebarLeftControls: React.FC = () => { const { t } = useI18n(); const toggleSidebar = useUIStore((state) => state.toggleSidebar); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const projectActionsContext = useProjectActionsContext(); const clusterRef = React.useRef(null); const toggleShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_sidebar', shortcutOverrides)); @@ -123,13 +120,6 @@ export const TitlebarLeftControls: React.FC = () => {

{t('header.actions.openSessionsWithShortcut', { shortcut: toggleShortcut })}

- - {projectActionsContext ? ( - - ) : null}
); From 654b3d2441567e9ac73fa58c8add4ce8037e09e4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 16:54:41 +0300 Subject: [PATCH 33/37] fix(chat): keep the outgoing conversation still until the next one replaces it Switching sessions moved the conversation on screen before the swap: the composer and the status chip followed the live selection and re-shaped a commit ahead of the timeline, so the pinned outgoing chat jumped; and the reveal effect re-ran for the outgoing session when its waited flag flipped, hiding it a few frames before the next one mounted. The chat column now reads one deferred session, and the reveal runs once per opened session. --- .../ui/src/components/chat/ChatContainer.tsx | 18 ++++++++++++++++-- packages/ui/src/components/chat/ChatInput.tsx | 12 ++++++++++-- .../src/components/chat/chatColumnSession.ts | 18 ++++++++++++++++++ packages/ui/src/hooks/useAssistantStatus.ts | 11 +++++++++-- packages/ui/src/sync/DOCUMENTATION.md | 8 ++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/components/chat/chatColumnSession.ts diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 5c70ee79..295a5411 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -4,6 +4,7 @@ import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import { ChatInput } from './ChatInput'; +import { ChatColumnSessionContext, type ChatColumnSession } from './chatColumnSession'; import { DraftPresetChips } from './DraftPresetChips'; import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; @@ -391,6 +392,13 @@ const ChatViewport = React.memo(({ const timelineRootRef = React.useRef(null); const endPinningReleasedRef = React.useRef(endPinningReleased); endPinningReleasedRef.current = endPinningReleased; + // Read through a ref: the effect runs once per gate (per opened session). + // `revealWaited` flips for the session still on screen the moment another + // one is selected — before the deferred swap mounts it — and re-running + // the effect then would hide the outgoing timeline for the frames until + // the new one arrives. + const revealWaitedRef = React.useRef(revealWaited); + revealWaitedRef.current = revealWaited; React.useLayoutEffect(() => { const root = timelineRootRef.current; if (!root) return; @@ -440,7 +448,7 @@ const ChatViewport = React.memo(({ if (finished) return; revealGate.close(); if (revealGate.holds === 0) { - reveal(revealWaited); + reveal(revealWaitedRef.current); return; } revealGate.onEmpty = () => reveal(true); @@ -452,7 +460,7 @@ const ChatViewport = React.memo(({ if (frame !== null) window.cancelAnimationFrame(frame); revealGate.onEmpty = null; }; - }, [revealGate, revealWaited, scrollRef]); + }, [revealGate, scrollRef]); const scrollContainerProps = React.useMemo(() => ({ className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target', @@ -741,6 +749,10 @@ export const ChatContainer: React.FC = ({ // viewport is pinned to the end so the first visible frame is already // at the bottom. const revealGate = React.useMemo(() => createTimelineRevealGate(), [currentSessionKey]); + const chatColumnSession = React.useMemo( + () => ({ sessionId: currentSessionId ?? null, directory: currentSessionId ? effectiveSessionDirectory ?? null : null }), + [currentSessionId, effectiveSessionDirectory], + ); const ensureSessionRenderable = React.useCallback( (sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory), [effectiveSessionDirectory, sync], @@ -1567,6 +1579,7 @@ export const ChatContainer: React.FC = ({ return (
+
{returnToParentButton} {sessionSurface} @@ -1651,6 +1664,7 @@ export const ChatContainer: React.FC = ({ onLoadEarlier={handleLoadOlderClick} />
+
{/* Kept mounted while it could ever show, so it can animate its own collapse; `visible` drives that. Unmounting on the spot is what made the chat jump wide before easing narrow again. */} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 4220e54e..371861f0 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -51,6 +51,7 @@ import { ModelControls } from './ModelControls'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { ComposerStatusBar } from './ComposerStatusBar'; import { PendingChangesBar } from './PendingChangesBar'; +import { useChatColumnSession } from './chatColumnSession'; import { useChatSurfaceMode } from './useChatSurfaceMode'; import { MobileAgentButton } from './MobileAgentButton'; import { MobileModelButton } from './MobileModelButton'; @@ -335,9 +336,16 @@ const ChatInputComponent: React.FC = ({ const sendMessage = React.useRef((...args: any[]) => Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)), ).current; - const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + // Inside the chat column the composer follows the session the timeline is + // showing (see chatColumnSession.ts); elsewhere it follows the live one. + const liveSessionId = useSessionUIStore((s) => s.currentSessionId); + const chatColumnSession = useChatColumnSession(); + const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId; const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); - const currentDirectory = useEffectiveDirectory() ?? fallbackDirectory; + const liveEffectiveDirectory = useEffectiveDirectory(); + const currentDirectory = (chatColumnSession?.sessionId ? chatColumnSession.directory : null) + ?? liveEffectiveDirectory + ?? fallbackDirectory; const currentSessionDirectoryForSync = useSessionUIStore( React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]), ); diff --git a/packages/ui/src/components/chat/chatColumnSession.ts b/packages/ui/src/components/chat/chatColumnSession.ts new file mode 100644 index 00000000..53519d34 --- /dev/null +++ b/packages/ui/src/components/chat/chatColumnSession.ts @@ -0,0 +1,18 @@ +import React from 'react'; + +/** + * The session the chat column is showing — the deferred selection the + * timeline renders, not the live store value. The composer and everything + * stacked with the timeline read it so the column changes as one: a session + * click publishes the live selection first, and a composer that followed it + * would change height (changed-files row, todos, queued chips) while the + * outgoing timeline is still on screen, shoving that timeline before the swap. + */ +export type ChatColumnSession = { + sessionId: string | null; + directory: string | null; +}; + +export const ChatColumnSessionContext = React.createContext(null); + +export const useChatColumnSession = (): ChatColumnSession | null => React.useContext(ChatColumnSessionContext); diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index 414e1981..3a3c7edc 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -1,4 +1,5 @@ import React from 'react'; +import { useChatColumnSession } from '@/components/chat/chatColumnSession'; import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2'; import type { MessageStreamPhase } from '@/stores/types/sessionTypes'; @@ -301,8 +302,14 @@ export const getActiveAssistantContext = (messages: Message[]): ActiveAssistantC }; export function useAssistantStatus(): AssistantStatusSnapshot { - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); + // Inside the chat column, follow the session the timeline shows rather + // than the live selection, so the status chip changes together with the + // conversation instead of a commit ahead of it. + const chatColumnSession = useChatColumnSession(); + const liveSessionId = useSessionUIStore((state) => state.currentSessionId); + const liveSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); + const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId; + const currentSessionDirectory = chatColumnSession ? chatColumnSession.directory : liveSessionDirectory; const rawSessionMessages = useSessionMessages( currentSessionId ?? '', diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index fd9ffefe..753ae66e 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -464,6 +464,14 @@ the user waited for fades in (100ms); one that was ready appears in the same frame. The sidebar prefetches the two rows on either side of the open session shortly after it settles, so most neighbouring switches are warm. +The column changes as one. The composer and the status chip above it read +the session the timeline shows (`components/chat/chatColumnSession.ts`), not +the live selection: read live, they re-shaped a commit ahead of the swap +(a taller draft, chips, a working chip) and the outgoing timeline, pinned to +its end, jumped before it was replaced. The reveal effect below runs once per +gate for the same reason — `revealWaited` flips for the outgoing session at +the click, and re-running on it hid that timeline before the next one mounted. + The timeline's first paint for a session is atomic. `ChatContainer` owns a `TimelineRevealGate` per session key (`components/chat/timelineRevealGate.ts`): a markdown renderer whose first paint is provisional (blocks not yet in the From d0a5538bfff9c68463904e0603d482095381472e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 18:03:29 +0300 Subject: [PATCH 34/37] feat: tune session sidebar tooltip timing Adds a shared sidebar tooltip provider with delayed open and instant close behavior Removes per-button tooltip delays so sidebar actions feel more consistent Aligns session sidebar hover interactions with the opencode-style tooltip experience --- .../ui/src/components/session/SessionSidebar.tsx | 13 ++++++------- .../sidebar/projects/SessionGroupSection.tsx | 6 +++--- .../session/sidebar/projects/sortableItems.tsx | 4 ++-- .../session/sidebar/sessions/SessionNodeItem.tsx | 2 +- .../session/sidebar/shell/SidebarHeader.tsx | 14 +++++++------- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 0e5e11e1..48521e5f 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -592,13 +592,12 @@ const SessionSidebarComponent: React.FC = ({ }, [mobileVariant, openNewSessionDraft, setSessionSwitcherOpen]); return ( - // One shared tooltip provider for the whole sidebar: session tooltips open - // instantly, and moving between rows hands the tooltip over (grouping) - // instead of replaying the exit/enter animation for each row. - // closeDelay bridges the small gap between rows: the tooltip survives the - // pointer crossing row margins, and the grouping timeout hands it over to - // the next row without an exit/enter cycle. - + // One shared tooltip provider for the whole sidebar, matching the opencode + // sidebar feel: 400ms before the first tooltip opens, instant close on + // leave, and grouping — moving between rows within 600ms hands the tooltip + // over to the next row without replaying the open delay or exit/enter + // animation. +
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
- +