diff --git a/.agents/skills/performance-engineering/SKILL.md b/.agents/skills/performance-engineering/SKILL.md index 8194aaab..e060e6de 100644 --- a/.agents/skills/performance-engineering/SKILL.md +++ b/.agents/skills/performance-engineering/SKILL.md @@ -210,7 +210,7 @@ command, how to stand up a production build to measure against, how to read the artifacts, and the validity guarantees these scripts enforce. Read it before measuring. -Four unattended capture commands exist; prefer them over ad-hoc timing code, +Five unattended capture commands exist; prefer them over ad-hoc timing code, and extend them when a scenario is missing rather than measuring by hand. | Command | Answers | @@ -218,6 +218,8 @@ and extend them when a scenario is missing rather than measuring by hand. | `bun run profile:idle` | What the app does while nobody interacts with it. Supports `--session`, `--tab`, `--then-tab`, `--panel`, `--expand-projects` to reach a specific mounted state, plus `--baseline` and `--budget-*` for regression gating. | | `bun run profile:session` | What a streaming assistant response costs. Creates a session, dispatches a prompt through the `openchamber session` CLI, and records until the session reports idle. Reports the long-task distribution, a timeline-trace breakdown, running animations, and output-normalised metrics. | | `bun run profile:animation` | What a CSS animation costs, isolated from the app. Animate only `transform` and `opacity`; everything else recalculates style every frame. | +| `bun run profile:switch` | How long switching sessions from the sidebar takes: `ack` (the clicked row highlights) and `content` (the target session's messages are on screen), cold and warm, plus the requests each switch fires. Use it as the regression gate for any change in the sidebar, header, chat container, or markdown first paint. | +| `bun run profile:switch` | How long switching sessions from the sidebar takes: `ack` (the clicked row highlights) and `content` (the target session's messages are on screen), cold and warm, plus the requests each switch fires. Use it as the regression gate for any change in the sidebar, header, chat container, or markdown first paint. | | `bun run profile:browser` | A manually driven capture when the interaction cannot be scripted. | Both automated commands fail loudly rather than reporting a clean result when diff --git a/.agents/skills/triage-prs/SKILL.md b/.agents/skills/triage-prs/SKILL.md index eb95a315..75f885fa 100644 --- a/.agents/skills/triage-prs/SKILL.md +++ b/.agents/skills/triage-prs/SKILL.md @@ -42,7 +42,7 @@ Execute the approved closes/comments with retries and ~1–2s spacing between ca The review bot's `review:*` labels are a pre-sort, not a verdict: `review:ready` PRs go first (the bot found no code defects — likely MERGE/MERGE-THEN-FIX), `review:blocked` ones carry a bot comment whose findings the verdict review verifies rather than rediscovers. Bot labels never replace the pr-review pass — the bot cannot judge product fit or maintainability scope. The reverse holds too: when the bot's BLOCKED findings are the whole story and the author has not answered, the maintainer never re-posts them in their own voice — the PR is *waiting on author* and the report says so in one line. -Split the clean pool smallest-first (tiny diffs are fast wins and most likely mergeable). Fan out the `pr-reviewer` subagent (`.opencode/agent/pr-reviewer.md`, which loads the `pr-review` skill and carries the hard rules) — one PR per call, or ~10 PRs per general subagent that receives the full `pr-review` skill text when `pr-reviewer` is unavailable. The subagent inherits the chat's model; never hand verdicts to a smaller model to save quota — a verdict from a small model is a pre-sort, not a decision. Each returns per-PR verdict blocks in the skill's output format. +Split the clean pool smallest-first (tiny diffs are fast wins and most likely mergeable). Fan out the `pr-reviewer` subagent (`.opencode/agent/pr-reviewer.md`, which loads the `pr-review` skill and carries the hard rules). It takes one PR or several per call — group related PRs together when one context can serve them, give a large or contentious PR its own call; fall back to a general subagent that receives the full `pr-review` skill text when `pr-reviewer` is unavailable. The subagent inherits the chat's model; never hand verdicts to a smaller model to save quota — a verdict from a small model is a pre-sort, not a decision. Each returns per-PR verdict blocks in the skill's output format. **Report format.** The consolidated report is what the maintainer decides from — calibrate each entry so no follow-up question is needed, without ballooning: diff --git a/.opencode/agent/pr-reviewer.md b/.opencode/agent/pr-reviewer.md index 91d9a8c9..2fcfee37 100644 --- a/.opencode/agent/pr-reviewer.md +++ b/.opencode/agent/pr-reviewer.md @@ -1,10 +1,10 @@ --- mode: subagent -description: Reviews one pull request as the maintainer's proxy and returns a single verdict (DECLINE / PUSH-BACK / MERGE-THEN-FIX / MERGE) with its ready action. Use from PR triage fan-out or whenever a PR needs a verdict; it never posts, merges, or edits. +description: Reviews one or several pull requests as the maintainer's proxy and returns one verdict block per PR (DECLINE / PUSH-BACK / MERGE-THEN-FIX / MERGE) with its ready action. Hand it a single PR or a list; it never posts, merges, or edits. color: "#d08770" --- -You review exactly one pull request in the OpenChamber repository and return one verdict the maintainer can act on. +You review the pull requests you were handed — one or several — in the OpenChamber repository, and return one verdict block per PR that the maintainer can act on. Work through them one at a time, fully, before starting the next; count your output blocks against the numbers you received and never drop one. Load `.agents/skills/pr-review/SKILL.md` first and follow it exactly: it owns the verdict ladder, the "symptom's path" bar for MERGE, the verified-vs-unverifiable distinction, the residue-owner rule between PUSH-BACK and MERGE-THEN-FIX, product-fit escalation, ache salvage, pickup mode, the output format, and the voice. Then follow `AGENTS.md` instruction order for the change's character: load every matching project skill and the owning `DOCUMENTATION.md` / `README.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac6419d..ccdabf6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.22.0] - 2026-08-30 + +- **Linear integration:** connect a workspace in Settings → Integrations, browse and filter issues, and start a session or worktree from an issue. OpenChamber reports session progress back to Linear and can attach an issue to the next chat message (thanks to @AlexKutas). +- **Voice:** local text-to-speech and macOS say now choose a voice that matches the reply's language. Additional local models download on first use, and the voice picker lists voices from every installed model. +- **Git:** projects containing several repositories can now switch between them from the Git tab. Diff, pull request, walkthrough, mobile Changes, and work status follow the selected repository (thanks to @jaygupta17). +- **Chat:** sessions opened from the sidebar stay at the latest message, and switching sessions no longer causes jumps, partial rendering, crossfades, or tab-title shifts. +- Chat: command, skill, and file autocomplete in projectless chats no longer uses the previously selected project. +- Chat: reverting to a message, or forking from one, now brings its attached context back to the composer — review comments, chat and file quotes, terminal selections, and browser annotations are no longer lost. +- Chat: stopped and unanswered turns now explain what happened. The status report includes recent session, send, and managed OpenCode errors, plus log locations. +- Files: Ctrl/Cmd+F opens search in the Markdown preview even when the preview is not focused. +- GitHub: account connection has moved to Settings → Integrations. The pull-request panel includes account controls, and its context-rail icon appears only when connected. +- Git: the commit graph no longer leaves a lane gap when the same branch is merged twice (thanks to @Naputt1). +- Settings: themes are now remembered per OpenChamber instance, so windows connected to different instances keep their own theme (thanks to @kydorn). +- Scheduled tasks: Goal, Auto-accept, and other task settings are preserved when older OpenChamber builds share the same project config. +- Desktop: on Windows and Linux, the close button reaches the top-right corner and follows the theme on hover (thanks to @kydorn). + ## [1.21.1] - 2026-08-29 - **Turkish interface:** OpenChamber can now be used in Turkish (thanks to @fitzgpt). @@ -31,6 +47,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/bun.lock b/bun.lock index 9353a0f7..15e74422 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.21.1", + "version": "1.22.0", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -134,7 +134,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.21.1", + "version": "1.22.0", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -241,7 +241,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.21.1", + "version": "1.22.0", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.25", @@ -264,7 +264,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.21.1", + "version": "1.22.0", "bin": { "openchamber": "./bin/cli.js", }, diff --git a/package.json b/package.json index c9ed193c..0c430683 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.21.1", + "version": "1.22.0", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", @@ -88,7 +88,8 @@ "release:test:arm": "./scripts/test-release-build.sh aarch64", "profile:idle": "node scripts/profile-idle.mjs", "profile:session": "node scripts/profile-session.mjs", - "profile:animation": "node scripts/profile-animation.mjs" + "profile:animation": "node scripts/profile-animation.mjs", + "profile:switch": "node scripts/profile-switch.mjs" }, "dependencies": { "@base-ui/react": "^1.4.0", diff --git a/packages/electron/package.json b/packages/electron/package.json index 5e169440..2281926a 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.21.1", + "version": "1.22.0", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index 4cfe929e..31181a9e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.21.1", + "version": "1.22.0", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index daad92af..e52bcb1e 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -49,6 +49,7 @@ import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import type { RuntimeAPIs } from '@/lib/api/types'; import { TooltipProvider } from '@/components/ui/tooltip'; @@ -247,6 +248,7 @@ function App({ apis }: AppProps) { const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory); const [showMemoryDebug, setShowMemoryDebug] = React.useState(false); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); + const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus); const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode); // Embedded chats start inactive until the parent panel identifies the active // tab. Otherwise a newly loaded background tab can focus its composer first @@ -345,7 +347,8 @@ function App({ apis }: AppProps) { } void refreshGitHubAuthStatus(apis.github, { force: true }); - }, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]); + void refreshLinearAuthStatus(apis.linear, { force: true }); + }, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]); useAppFontEffects(); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 9178e36b..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'; @@ -35,6 +35,7 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useGitStore } from '@/stores/useGitStore'; import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -630,6 +631,7 @@ export function MobileApp({ apis }: MobileAppProps) { const clearError = useSessionUIStore((state) => state.clearError); const setIsMobile = useUIStore((state) => state.setIsMobile); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); + const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const projects = useProjectsStore((state) => state.projects); const [connectionEpoch, setConnectionEpoch] = React.useState(0); @@ -678,12 +680,13 @@ export function MobileApp({ apis }: MobileAppProps) { const refreshInPlace = () => { void initializeApp(); void refreshGitHubAuthStatus(apis.github, { force: true }); + void refreshLinearAuthStatus(apis.linear, { force: true }); if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); }; 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); }; @@ -746,7 +749,7 @@ export function MobileApp({ apis }: MobileAppProps) { lastNativeResumeSyncEventAtRef.current = now; window.dispatchEvent(new Event('openchamber:system-resume')); } - }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); + }, [agentsCount, apis.github, apis.linear, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus, refreshLinearAuthStatus]); useNativeMobileChrome(); useNativeMobileLifecycle(handleNativeResume); @@ -893,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) => { @@ -1031,7 +1034,8 @@ export function MobileApp({ apis }: MobileAppProps) { React.useEffect(() => { if (!isConnected) return; void refreshGitHubAuthStatus(apis.github, { force: true }); - }, [apis.github, isConnected, refreshGitHubAuthStatus]); + void refreshLinearAuthStatus(apis.linear, { force: true }); + }, [apis.github, apis.linear, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus]); // Discover all worktrees for every known project so the draft session's // worktree/branch dropdown can list every available branch — not only the @@ -1192,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); }} > @@ -1275,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/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index b8e98b59..463886b3 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,8 @@ import { useIsGitRepo, 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; @@ -56,12 +59,18 @@ 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 selectNestedRepo = useGitStore((state) => state.selectNestedRepo); const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchBranches = useGitStore((state) => state.fetchBranches); const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); @@ -465,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}
@@ -474,12 +493,24 @@ export const MobileChangesSurface: React.FC = ({ onCl return renderListState(); } - if (isLoadingStatus && isGitRepo === null) { - return renderListState(); + // 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( + { + 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/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/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 43aa97dd..ef0ab2b3 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'; @@ -11,11 +12,24 @@ import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; import { useGlobalSyncStore } from '@/sync/global-sync-store'; import MessageList, { type MessageListHandle } from './MessageList'; +import { createTimelineRevealGate, TIMELINE_REVEAL_CAP_MS, TimelineRevealGateContext, type TimelineRevealGate } from './timelineRevealGate'; + +// How long the previous timeline stays on screen while a session that is not +// in memory loads, before the skeleton takes over. +const SESSION_SWITCH_HOLD_MS = 400; +// End inset reserved for the status row that floats over the timeline's +// bottom edge (its tallest resting height plus the mb-2 gap). +const STATUS_OVERLAY_RESERVED_HEIGHT = 40; +// A freshly opened timeline is shown once its content height has held still +// for this many consecutive frames, or after the cap. +const TIMELINE_SETTLE_STABLE_FRAMES = 2; +const TIMELINE_SETTLE_CAP_MS = 300; import { PermissionCard } from './PermissionCard'; import { QuestionCard } from './QuestionCard'; import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery'; import { StatusRowContainer } from './StatusRowContainer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; +import { SessionErrorNotice } from '@/components/chat/SessionErrorNotice'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; @@ -175,9 +189,9 @@ type ChatViewportProps = { } | null; scrollToBottom: () => void; endPinningReleased: boolean; - // One-shot fade for content that replaced the hydration skeleton; - // cached sessions render instantly without it. - revealContent: boolean; + /** The user waited for this session (held or fetched); reveal it with a fade. */ + revealWaited: boolean; + revealGate: TimelineRevealGate; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; isProgrammaticFollowActive: boolean; @@ -214,7 +228,8 @@ const ChatViewport = React.memo(({ retryOverlay, scrollToBottom, endPinningReleased, - revealContent, + revealWaited, + revealGate, sessionQuestions, sessionPermissions, isProgrammaticFollowActive, @@ -362,12 +377,91 @@ const ChatViewport = React.memo(({ )} + ); diff --git a/packages/ui/src/components/layout/__tests__/linear-panel-review-guards.test.ts b/packages/ui/src/components/layout/__tests__/linear-panel-review-guards.test.ts new file mode 100644 index 00000000..df45aec3 --- /dev/null +++ b/packages/ui/src/components/layout/__tests__/linear-panel-review-guards.test.ts @@ -0,0 +1,42 @@ +/** + * Guards from the OPE-296 review: stale Linear list pages must not land, and a + * persisted Linear tab must survive reload until auth has actually resolved. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const railSource = readFileSync(join(__dirname, '..', 'ContextPanelRail.tsx'), 'utf-8'); +const issuesViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'LinearIssuesView.tsx'), 'utf-8'); +const pickerSource = readFileSync(join(__dirname, '..', '..', 'session', 'LinearIssuePickerDialog.tsx'), 'utf-8'); + +const sliceFn = (source: string, marker: string, length: number) => { + const start = source.indexOf(marker); + expect(start).toBeGreaterThan(-1); + return source.slice(start, start + length); +}; + +describe('Linear panel review guards', () => { + test('disconnect-close waits for Linear auth to resolve', () => { + const effect = sliceFn(railSource, 'if (!directoryKey || !linearAuthChecked || linearConnected || activeMode !== \'linear\')', 240); + expect(effect).toContain('closeContextPanel(directoryKey)'); + expect(railSource).toContain('state.hasChecked'); + }); + + test('rail loadMore shares listRequestId with refresh', () => { + const loadMore = sliceFn(issuesViewSource, 'const loadMore = React.useCallback(async () => {', 900); + expect(loadMore).toContain('const requestId = listRequestId.current + 1'); + expect(loadMore).toContain('if (requestId !== listRequestId.current) return'); + }); + + test('picker refresh and loadMore reject stale pages', () => { + const refresh = sliceFn(pickerSource, 'const refresh = React.useCallback(async (search = \'\') => {', 1400); + const loadMore = sliceFn(pickerSource, 'const loadMore = React.useCallback(async () => {', 900); + expect(refresh).toContain('const requestId = listRequestId.current + 1'); + expect(refresh).toContain('if (requestId !== listRequestId.current) return'); + expect(loadMore).toContain('const requestId = listRequestId.current + 1'); + expect(loadMore).toContain('if (requestId !== listRequestId.current) return'); + }); +}); diff --git a/packages/ui/src/components/sections/git-identities/GitPage.tsx b/packages/ui/src/components/sections/git-identities/GitPage.tsx index 147af05e..ba3bb31e 100644 --- a/packages/ui/src/components/sections/git-identities/GitPage.tsx +++ b/packages/ui/src/components/sections/git-identities/GitPage.tsx @@ -194,6 +194,7 @@ export const GitPage: React.FC = (props) => { 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 666c0801..a3b5956d 100644 --- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx +++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx @@ -1,8 +1,11 @@ import React from 'react'; -import { Icon } from '@/components/icon/Icon'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; -import { SETTINGS_DESCRIPTION_CLASS } from '@/components/sections/shared/SettingsSection'; +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'; interface IntegrationsPageProps { @@ -15,25 +18,32 @@ 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 ( -

{t('settings.page.integrations.description')}

-
- -

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

-
- - )} - showSaveStatus={false} + description={t('settings.page.integrations.description')} + showSaveStatus > + {hasBuiltIn ? ( + + {hasGitHub ? : null} + {hasLinear ? : null} + + ) : 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..773b687b --- /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]); + + React.useEffect(() => { + void loadMapping(); + }, [loadMapping, organizationId]); + + 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..98bda06c --- /dev/null +++ b/packages/ui/src/components/sections/integrations/LinearSettings.tsx @@ -0,0 +1,341 @@ +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 { 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 d7615788..3cd318a1 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 ef8389ff..68893b29 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.gitlab', items: [ 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/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/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/session/GitLabIntegrationDialog.tsx b/packages/ui/src/components/session/GitLabIntegrationDialog.tsx index ef729a95..026229a8 100644 --- a/packages/ui/src/components/session/GitLabIntegrationDialog.tsx +++ b/packages/ui/src/components/session/GitLabIntegrationDialog.tsx @@ -64,7 +64,7 @@ export function GitLabIntegrationDialog({ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const activeProject = useProjectsStore((state) => state.getActiveProject()); - + const projectDirectory = activeProject?.path ?? null; const projectRef: ProjectRef | null = React.useMemo(() => { if (projectDirectory && activeProject) { @@ -93,12 +93,12 @@ export function GitLabIntegrationDialog({ const loadData = React.useCallback(async (query?: string) => { if (!projectDirectory || !gitlab) return; if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return; - + setLoading(true); setError(null); setPage(1); setHasMore(false); - + try { if (activeTab === 'issues' && gitlab.issuesList) { const result = await gitlab.issuesList(projectDirectory, { page: 1, query }); @@ -192,12 +192,12 @@ export function GitLabIntegrationDialog({ if (!projectDirectory || !gitlab) return; if (loading || loadingMore) return; if (!hasMore) return; - + setLoadingMore(true); - + try { const nextPage = page + 1; - + if (activeTab === 'issues' && gitlab.issuesList) { const result = debouncedSearchQuery.trim() ? await gitlab.issuesList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() }) @@ -240,26 +240,26 @@ export function GitLabIntegrationDialog({ setHasMore(false); return; } - + void loadData(); }, [open, loadData]); // Validate branches for worktree creation const validateBranch = React.useCallback(async (branchName: string) => { if (!projectRef || !branchName) return; - + // Check cache first if (validations.has(branchName)) return; - + try { const result = await validateWorktreeCreate(projectRef, { mode: 'new', branchName, worktreeName: branchName, }); - + const blockingError = result.errors.find((entry) => entry.code === 'branch_in_use'); - + setValidations(prev => new Map(prev).set(branchName, { isValid: !blockingError, error: blockingError @@ -279,7 +279,7 @@ export function GitLabIntegrationDialog({ // Validate MR branches when loaded React.useEffect(() => { if (!open || activeTab !== 'mrs') return; - + mrs.forEach(mr => { if (mr.sourceBranch) { void validateBranch(mr.sourceBranch); @@ -420,7 +420,7 @@ export function GitLabIntegrationDialog({ {t('session.gitlabIntegration.empty.noIssuesFound')}
)} - + {hasMore && !loadingMore && (
)} - + {/* Include Diff Checkbox - only show when MR tab is active and MR is selected */} {activeTab === 'mrs' && selectedMr && ( )} - + {/* Right side: Buttons */}
- + {/* Selected Item Inline Display */} {(selectedIssue || selectedMr) && (
@@ -650,7 +650,7 @@ export function GitLabIntegrationDialog({ {t('session.gitlabIntegration.title')} - + {/* Tabs - using SortableTabsStrip */}
{ + 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 4b43e716..4601eff1 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -29,11 +29,12 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; +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 { useGitProvider } from '@/lib/gitProvider'; import { useConfigStore } from '@/stores/useConfigStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; @@ -43,6 +44,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 { @@ -55,6 +57,7 @@ import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/use import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; import { GitLabIntegrationDialog } from './GitLabIntegrationDialog'; import { GiteaIntegrationDialog } from './GiteaIntegrationDialog'; +import { LinearIssuePickerDialog } from './LinearIssuePickerDialog'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; @@ -72,6 +75,8 @@ import type { GiteaIssue, GiteaIssuesListResult, GiteaPullRequestContextResult, + LinearIssue, + LinearIssueComment, } from '@/lib/api/types'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useI18n } from '@/lib/i18n'; @@ -85,6 +90,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; @@ -94,6 +106,7 @@ interface NewBranchState { linkedIssue: GitHubIssue | null; linkedPr: GitHubPullRequestSummary | null; includePrDiff: boolean; + linkedLinearIssue: LinkedLinearWorktreeIssue | null; linkedGitLabIssue: { number: number; title: string; url: string } | null; linkedGitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null; includeGitLabMrDiff: boolean; @@ -262,13 +275,24 @@ const buildGiteaPrContextText = (payload: GiteaPullRequestContextResult) => { return `Gitea 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, gitlab, gitea } = useRuntimeAPIs(); + const { github, git, gitlab, gitea, linear } = useRuntimeAPIs(); const isMobile = useUIStore((state) => state.isMobile); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); @@ -278,8 +302,10 @@ export function NewWorktreeDialog({ const giteaAuthStatus = useGiteaAuthStore((state) => state.status); const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked); const refreshGiteaAuth = useGiteaAuthStore((state) => state.refreshStatus); + const linearAuthStatus = useLinearAuthStore((state) => state.status); + const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); const activeProject = useProjectsStore((state) => state.getActiveProject()); - + const projectDirectory = activeProject?.path ?? null; const projectRef: ProjectRef | null = React.useMemo(() => { if (projectDirectory && activeProject) { @@ -290,7 +316,7 @@ export function NewWorktreeDialog({ // Mode state const [mode, setMode] = React.useState('new-branch'); - + // Separate state for each mode (persisted when switching tabs) const [newBranchState, setNewBranchState] = React.useState({ branchName: '', @@ -300,6 +326,7 @@ export function NewWorktreeDialog({ linkedIssue: null, linkedPr: null, includePrDiff: false, + linkedLinearIssue: null, linkedGitLabIssue: null, linkedGitLabMr: null, includeGitLabMrDiff: false, @@ -307,12 +334,12 @@ export function NewWorktreeDialog({ linkedGiteaPr: null, includeGiteaPrDiff: false, }); - + const [existingBranchState, setExistingBranchState] = React.useState({ selectedBranch: '', worktreeName: '', }); - + // Use cached branches from Git store (instant if already fetched) const branches = useGitBranches(projectDirectory); const isLoadingBranches = useGitLoadingBranches(projectDirectory); @@ -325,7 +352,7 @@ export function NewWorktreeDialog({ .filter((branchName: string) => !branchName.startsWith('remotes/')) .sort(); }, [branches]); - + const remoteBranches = React.useMemo(() => { if (!branches?.all) return []; return branches.all @@ -333,7 +360,7 @@ export function NewWorktreeDialog({ .map((branchName: string) => branchName.replace(/^remotes\//, '')) .sort(); }, [branches]); - + // Get existing worktrees for the current project to avoid conflicts const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const existingWorktreeNames = React.useMemo(() => { @@ -341,7 +368,7 @@ export function NewWorktreeDialog({ const worktrees = availableWorktreesByProject.get(projectDirectory) ?? []; return new Set(worktrees.map(wt => wt.name)); }, [availableWorktreesByProject, projectDirectory]); - + // Generate a unique slug that doesn't conflict with existing worktrees const generateUniqueSlug = React.useCallback((maxAttempts = 10): string => { for (let attempt = 0; attempt < maxAttempts; attempt++) { @@ -353,10 +380,11 @@ export function NewWorktreeDialog({ // Fallback: add timestamp if all attempts failed return `${generateBranchSlug()}-${Date.now().toString(36).slice(-4)}`; }, [existingWorktreeNames]); - + const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); const [gitlabDialogOpen, setGitlabDialogOpen] = React.useState(false); const [giteaDialogOpen, setGiteaDialogOpen] = React.useState(false); + const [linearDialogOpen, setLinearDialogOpen] = React.useState(false); // Populate the GitLab auth status on mount so the "Start from GitLab issue/MR" // action is available without first visiting Settings. refreshStatus dedupes @@ -373,7 +401,7 @@ export function NewWorktreeDialog({ void refreshGiteaAuth(gitea); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - + // Desktop branch picker states const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false); const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false); @@ -507,7 +535,7 @@ export function NewWorktreeDialog({ worktreeError: null, touched: false, }); - + // Creation state const [isCreating, setIsCreating] = React.useState(false); const [validationAbortController, setValidationAbortController] = React.useState(null); @@ -564,6 +592,7 @@ export function NewWorktreeDialog({ issue: GitHubIssue | null; pr: GitHubPullRequestSummary | null; includeDiff: boolean; + linearIssue: LinkedLinearWorktreeIssue | null; gitLabIssue: { number: number; title: string; url: string } | null; gitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null; includeGitLabMrDiff: boolean; @@ -589,6 +618,65 @@ 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 (args.issue) { if (!github || !github.issueGet || !github.issueComments) { return; @@ -938,6 +1026,7 @@ export function NewWorktreeDialog({ github, gitlab, gitea, + linear, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, @@ -1026,6 +1115,7 @@ export function NewWorktreeDialog({ linkedIssue: null, linkedPr: null, includePrDiff: false, + linkedLinearIssue: null, linkedGitLabIssue: null, linkedGitLabMr: null, includeGitLabMrDiff: false, @@ -1038,7 +1128,7 @@ export function NewWorktreeDialog({ // Sync worktree name with branch name for new-branch mode React.useEffect(() => { if (mode !== 'new-branch' || !newBranchState.isSyncingWorktreeName) return; - + const normalizedBranch = normalizeBranchName(newBranchState.branchName); const newWorktreeName = slugifyWorktreeName(normalizedBranch); setNewBranchState(prev => ({ ...prev, worktreeName: newWorktreeName })); @@ -1047,26 +1137,26 @@ export function NewWorktreeDialog({ // Validation - only runs after fields are touched const validateInputs = React.useCallback(async () => { if (!projectRef || !validation.touched || isCreating) return; - + // Cancel previous validation if (validationAbortController) { validationAbortController.abort(); } - + const abortController = new AbortController(); setValidationAbortController(abortController); - + setValidation(prev => ({ ...prev, isValidating: true })); - + try { const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch; const worktreeName = currentState.worktreeName; const normalizedBranch = normalizeBranchName(branchName); const normalizedWorktree = slugifyWorktreeName(worktreeName); - + let branchError: string | null = null; let worktreeError: string | null = null; - + if (!normalizedBranch) { branchError = t('session.newWorktree.error.branchNameRequired'); } @@ -1074,7 +1164,7 @@ export function NewWorktreeDialog({ if (!normalizedWorktree) { worktreeError = t('session.newWorktree.error.worktreeDirectoryRequired'); } - + // Only run server validation if we have values if (normalizedBranch && normalizedWorktree) { const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; @@ -1091,9 +1181,9 @@ export function NewWorktreeDialog({ ...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}), ...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}), }); - + if (abortController.signal.aborted) return; - + if (!result.ok) { result.errors.forEach((error) => { if (error.code === 'worktree_exists') { @@ -1107,7 +1197,7 @@ export function NewWorktreeDialog({ }); } } - + if (!abortController.signal.aborted) { setValidation(prev => ({ ...prev, @@ -1147,11 +1237,11 @@ export function NewWorktreeDialog({ // Trigger validation on input changes (only after touched) React.useEffect(() => { if (!open || !projectRef || !validation.touched || isCreating) return; - + const timer = setTimeout(() => { void validateInputs(); }, 300); - + return () => clearTimeout(timer); }, [currentState.worktreeName, currentBranchName, open, projectRef, validateInputs, validation.touched, isCreating]); @@ -1161,20 +1251,20 @@ export function NewWorktreeDialog({ toast.error(t('session.newWorktree.error.noActiveProject')); return; } - + // Mark as touched and validate immediately setValidation(prev => ({ ...prev, touched: true })); - + const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch; const worktreeName = currentState.worktreeName; const normalizedBranch = normalizeBranchName(branchName); const normalizedWorktree = slugifyWorktreeName(worktreeName); - + if (!normalizedBranch) { toast.error(t('session.newWorktree.error.branchNameRequired')); return; } - + if (!normalizedWorktree) { toast.error(t('session.newWorktree.error.worktreeDirectoryRequired')); return; @@ -1191,21 +1281,22 @@ export function NewWorktreeDialog({ branchError: null, worktreeError: null, })); - + setIsCreating(true); - + try { const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; + const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null; const linkedGitLabIssue = mode === 'new-branch' ? newBranchState.linkedGitLabIssue : null; const linkedGitLabMr = mode === 'new-branch' ? newBranchState.linkedGitLabMr : null; const includeGitLabMrDiff = mode === 'new-branch' ? newBranchState.includeGitLabMrDiff : false; const linkedGiteaIssue = mode === 'new-branch' ? newBranchState.linkedGiteaIssue : null; const linkedGiteaPr = mode === 'new-branch' ? newBranchState.linkedGiteaPr : null; const includeGiteaPrDiff = mode === 'new-branch' ? newBranchState.includeGiteaPrDiff : false; - const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedGitLabIssue || linkedGitLabMr || linkedGiteaIssue || linkedGiteaPr); + const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue || linkedGitLabIssue || linkedGitLabMr || linkedGiteaIssue || linkedGiteaPr); const setupCommands = await getWorktreeSetupCommands(projectRef); const sourceBranch = newBranchState.sourceBranch; @@ -1289,19 +1380,21 @@ export function NewWorktreeDialog({ await waitForWorktreeBootstrap(metadata.path); } - const sessionTitle = linkedIssue - ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() - : linkedPrState - ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() - : linkedGitLabIssue - ? `#${linkedGitLabIssue.number} ${linkedGitLabIssue.title}`.trim() - : linkedGitLabMr - ? `!${linkedGitLabMr.number} ${linkedGitLabMr.title}`.trim() - : linkedGiteaIssue - ? `#${linkedGiteaIssue.number} ${linkedGiteaIssue.title}`.trim() - : linkedGiteaPr - ? `#${linkedGiteaPr.number} ${linkedGiteaPr.title}`.trim() - : t('session.newWorktree.newSessionTitle'); + const sessionTitle = linkedLinearIssue + ? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim() + : linkedIssue + ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() + : linkedPrState + ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() + : linkedGitLabIssue + ? `#${linkedGitLabIssue.number} ${linkedGitLabIssue.title}`.trim() + : linkedGitLabMr + ? `!${linkedGitLabMr.number} ${linkedGitLabMr.title}`.trim() + : linkedGiteaIssue + ? `#${linkedGiteaIssue.number} ${linkedGiteaIssue.title}`.trim() + : linkedGiteaPr + ? `#${linkedGiteaPr.number} ${linkedGiteaPr.title}`.trim() + : t('session.newWorktree.newSessionTitle'); const session = await sessionActions.createSession(sessionTitle, metadata.path, null); if (!session?.id) { @@ -1324,7 +1417,7 @@ export function NewWorktreeDialog({ onOpenChange(false); setIsCreating(false); } - + // Save the last source-branch choice for the next open. const lastSourceBranch = resolveWorktreeSourceBranchToPersist({ mode, @@ -1336,7 +1429,7 @@ export function NewWorktreeDialog({ if (lastSourceBranch) { localStorage.setItem(LAST_WORKTREE_SOURCE_BRANCH_KEY, lastSourceBranch); } - + toast.success(t('session.newWorktree.toast.worktreeCreated'), { description: t('session.newWorktree.toast.worktreeCreatedDescription', { target: `${metadata.branch || metadata.name}${sourceLabel ? ` ${t('session.newWorktree.fromSource', { source: sourceLabel })}` : ''}`, @@ -1350,6 +1443,7 @@ export function NewWorktreeDialog({ issue: linkedIssue, pr: linkedPrState, includeDiff: includePrDiff, + linearIssue: linkedLinearIssue, gitLabIssue: linkedGitLabIssue, gitLabMr: linkedGitLabMr, includeGitLabMrDiff: includeGitLabMrDiff, @@ -1360,9 +1454,12 @@ export function NewWorktreeDialog({ // There is no Gitea-branded send-context error key in the frozen // catalogs; the gitea path reuses the generic GitHub wording. const isGitLabLink = Boolean(linkedGitLabIssue || linkedGitLabMr); - const errorKey = isGitLabLink - ? 'session.newWorktree.error.sendGitLabContextFailed' - : 'session.newWorktree.error.sendGitHubContextFailed'; + const isLinearLink = Boolean(linkedLinearIssue); + const errorKey = isLinearLink + ? 'session.newWorktree.error.sendLinearContextFailed' + : isGitLabLink + ? 'session.newWorktree.error.sendGitLabContextFailed' + : 'session.newWorktree.error.sendGitHubContextFailed'; const message = error instanceof Error ? error.message : t(errorKey); toast.error(t(errorKey), { description: message }); }); @@ -1639,7 +1736,7 @@ export function NewWorktreeDialog({ const footerContent = (
{/* Validation error */} -
+
{validation.touched && (validation.branchError || validation.worktreeError) && ( <> @@ -1649,7 +1746,7 @@ export function NewWorktreeDialog({ )}
- + {/* Buttons */}
- + {/* Mobile Branch Picker Overlay */} )} - {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( + {existingBranchRankedGroups.otherLocal.length > 0 && (
- {t('session.newWorktree.localBranches')} + {hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
{existingBranchRankedGroups.otherLocal.map((branch) => ( @@ -1822,10 +1919,10 @@ export function NewWorktreeDialog({
)} - {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( + {existingBranchRankedGroups.otherRemote.length > 0 && (
- {t('session.newWorktree.remoteBranches')} + {hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
{existingBranchRankedGroups.otherRemote.map((branch) => ( @@ -2054,7 +2151,7 @@ export function NewWorktreeDialog({ {t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
)} - + {/* Mobile Source Branch Picker Overlay */} )} - {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( + {sourceBranchRankedGroups.otherLocal.length > 0 && (
- {t('session.newWorktree.localBranches')} + {hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
{sourceBranchRankedGroups.otherLocal.map((branch) => ( @@ -2138,10 +2235,10 @@ export function NewWorktreeDialog({
)} - {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( + {sourceBranchRankedGroups.otherRemote.length > 0 && (
- {t('session.newWorktree.remoteBranches')} + {hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
{sourceBranchRankedGroups.otherRemote.map((branch) => ( @@ -2183,7 +2280,7 @@ export function NewWorktreeDialog({ ) : ( )} - + {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} @@ -2214,11 +2311,11 @@ export function NewWorktreeDialog({ {t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })} )} - + {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title} - + - +
- + {/* Row 2: PR/MR branch info + diff indicator */} {newBranchState.linkedPr && (
@@ -2287,7 +2384,7 @@ export function NewWorktreeDialog({ {t('session.newWorktree.title')} - + {/* Mode Selection - using SortableTabsStrip */}
)} - {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( + {existingBranchRankedGroups.otherLocal.length > 0 && ( <> - + {hasExistingBranchQuery && } + {existingBranchRankedGroups.otherLocal.map((branch) => ( )} - {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( + {existingBranchRankedGroups.otherRemote.length > 0 && ( <> - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( )} - + {existingBranchRankedGroups.otherRemote.map((branch) => ( )} - {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( + {sourceBranchRankedGroups.otherLocal.length > 0 && ( <> - + {hasSourceBranchQuery && } + {sourceBranchRankedGroups.otherLocal.map((branch) => ( )} - {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( + {sourceBranchRankedGroups.otherRemote.length > 0 && ( <> - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && ( )} - + {sourceBranchRankedGroups.otherRemote.map((branch) => ( )} - + {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} @@ -2767,11 +2866,11 @@ export function NewWorktreeDialog({ {t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })} )} - + {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title} - + - +
- + {/* Row 2: PR/MR branch info + diff indicator */} {newBranchState.linkedPr && (
@@ -2844,7 +2943,7 @@ export function NewWorktreeDialog({ )}
- +
+ + + { + 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/PullRequestView.tsx b/packages/ui/src/components/views/PullRequestView.tsx index 9fc3facd..eb71c8fb 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'; @@ -15,6 +16,8 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { PullRequestSection } from './git/PullRequestSection'; +import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates'; +import { NestedRepoPicker } from './git/NestedRepoPicker'; import { GitHubIssuesSection } from './git/GitHubIssuesSection'; import { deriveBaseBranch } from './git/baseBranch'; @@ -38,9 +41,17 @@ 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, selectNestedRepo } = useGitStore(useShallow((state) => ({ + ensureAll: state.ensureAll, + ensureNestedRepos: state.ensureNestedRepos, + selectNestedRepo: state.selectNestedRepo, + }))); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); @@ -91,11 +102,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(() => { @@ -124,52 +135,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 []; @@ -261,6 +272,27 @@ export const PullRequestView: React.FC = () => { return prEmptyState; } + // 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 ( + { + void ensureNestedRepos(currentDirectory, { force: true }); + }} + /> + ); + } + + // 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 ( { preventOverscroll >
+ {showRepositoryPicker ? ( +
+ { + if (currentDirectory) selectNestedRepo(currentDirectory, repository); + }} + repositoryRoot={currentDirectory ?? undefined} + /> +
+ ) : null}
{ {activeTab === 'pr' ? ( currentBranch ? ( = ({ 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/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 3cece850..2f71f554 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -12,6 +12,7 @@ import type { IconName } from "@/components/icon/icons"; import { BranchSelector } from './BranchSelector'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { SyncActions } from './SyncActions'; +import { NestedRepoPicker } from './NestedRepoPicker'; import type { GitStatus, GitIdentityProfile, @@ -53,6 +54,13 @@ interface GitHeaderProps { pullRequest?: GitHubPullRequest | null; prChecks?: GitHubChecksSummary | null; onOpenPullRequest?: () => 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; gitLabMr?: GitLabMergeRequestSummary | null; onOpenGitLabMr?: () => void; giteaPr?: GiteaPullRequestSummary | null; @@ -264,6 +272,10 @@ export const GitHeader: React.FC = ({ pullRequest, prChecks, onOpenPullRequest, + repositoryOptions, + selectedRepository, + onSelectRepository, + repositoryRoot, gitLabMr, onOpenGitLabMr, giteaPr, @@ -274,6 +286,8 @@ export const GitHeader: React.FC = ({ return null; } + const repositoryOptionsForPicker = (repositoryOptions ?? []).filter(Boolean); + const managementButtons = (
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? ( @@ -488,7 +502,7 @@ export const GitHeader: React.FC = ({ return (
-
+
{isWorktreeMode ? ( = ({ remotes={remotes} /> )} + {repositoryOptionsForPicker.length > 0 && onSelectRepository ? ( + + ) : null}
{identityControl} 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/git/NestedRepoResolutionStates.tsx b/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx new file mode 100644 index 00000000..3e9e1e6d --- /dev/null +++ b/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +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; + /** 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 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 + * retry that can never succeed. + */ +export const NestedRepoResolutionStates: React.FC = ({ + rootIsGitRepo, + resolvedIsGitRepo, + nestedRepos, + onRetryDiscovery, + emptyStateFooter, +}) => { + const { t } = useI18n(); + + if (rootIsGitRepo !== false) return null; + if (resolvedIsGitRepo === true) 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/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 6b6d6a38..cfe62633 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'; @@ -111,6 +112,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 @@ -346,7 +351,7 @@ export const PullRequestSection: React.FC<{ const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime(); const openGitHubSettings = React.useCallback(() => { - setSettingsPage('github'); + setSettingsPage('integrations'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage]); @@ -1175,6 +1180,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); @@ -1555,7 +1585,10 @@ export const PullRequestSection: React.FC<{ return (
-
{t('gitView.pullRequest.title')}
+
+
{t('gitView.pullRequest.title')}
+ +
{t('gitView.pullRequest.availableOnFeatureBranches')}
@@ -1599,7 +1632,7 @@ export const PullRequestSection: React.FC<{ return (
-
+
{pr ? (
-
- {isLoading ? : null} +
+ {pr && showWalkthroughAction ? ( + + ) : null} - + {isLoading || isManualRefreshing + ? + : } +

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

+
{pr ? ( -
+
{prStatusText} {checks ? ( @@ -1657,23 +1710,6 @@ export const PullRequestSection: React.FC<{ ) : null}
- {showWalkthroughAction ? ( - - ) : null} {canMerge && pr.draft && pr.state === 'open' ? ( diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 287a1b43..02cad5be 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -22,7 +22,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, @@ -30,6 +30,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'; @@ -39,9 +40,17 @@ import { WalkthroughStages } from './WalkthroughStages'; 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']; @@ -76,11 +85,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, visible = true }: 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, { enabled: visible }); + const directory = gitDirectory ?? rootDirectory; + // Panel width, not viewport width: this surface is resizable independently of // the window. useEffect(() => { @@ -502,9 +517,38 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { [activeLanguage, directory, generate, generateDisabled, source] ); + const isGitRepo = useIsGitRepo(gitDirectory || null); + const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos); + 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 ( + { + if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); + }} + /> + ); + } + return (
+ {rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null}