Merge remote-tracking branch 'origin/release/v1.22.0' into custom

# Conflicts:
#	bun.lock
#	packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts
#	packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx
#	packages/ui/src/components/layout/ContextPanelRail.tsx
#	packages/ui/src/hooks/useKeyboardShortcuts.ts
#	packages/ui/src/lib/i18n/messages/de.ts
#	packages/ui/src/lib/surfaces/DOCUMENTATION.md
#	packages/web/server/lib/fs/routes.test.js
This commit is contained in:
2026-09-03 06:10:05 -04:00
237 changed files with 17813 additions and 1117 deletions
@@ -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 artifacts, and the validity guarantees these scripts enforce. Read it before
measuring. 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. and extend them when a scenario is missing rather than measuring by hand.
| Command | Answers | | 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: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: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: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. | | `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 Both automated commands fail loudly rather than reporting a clean result when
+1 -1
View File
@@ -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. 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: **Report format.** The consolidated report is what the maintainer decides from — calibrate each entry so no follow-up question is needed, without ballooning:
+2 -2
View File
@@ -1,10 +1,10 @@
--- ---
mode: subagent 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" 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`. 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`.
+18
View File
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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 ## [1.21.1] - 2026-08-29
- **Turkish interface:** OpenChamber can now be used in Turkish (thanks to @fitzgpt). - **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). - 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). - 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). - 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). - 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). - 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). - 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).
+4 -4
View File
@@ -97,7 +97,7 @@
}, },
"packages/electron": { "packages/electron": {
"name": "@openchamber/electron", "name": "@openchamber/electron",
"version": "1.21.1", "version": "1.22.0",
"dependencies": { "dependencies": {
"@openchamber/web": "workspace:*", "@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2", "electron-context-menu": "^4.1.2",
@@ -134,7 +134,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.21.1", "version": "1.22.0",
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0", "@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
@@ -241,7 +241,7 @@
}, },
"packages/vscode": { "packages/vscode": {
"name": "openchamber", "name": "openchamber",
"version": "1.21.1", "version": "1.22.0",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.18.25", "@opencode-ai/sdk": "1.18.25",
@@ -264,7 +264,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@openchamber/web", "name": "@openchamber/web",
"version": "1.21.1", "version": "1.22.0",
"bin": { "bin": {
"openchamber": "./bin/cli.js", "openchamber": "./bin/cli.js",
}, },
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "openchamber-monorepo", "name": "openchamber-monorepo",
"version": "1.21.1", "version": "1.22.0",
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -88,7 +88,8 @@
"release:test:arm": "./scripts/test-release-build.sh aarch64", "release:test:arm": "./scripts/test-release-build.sh aarch64",
"profile:idle": "node scripts/profile-idle.mjs", "profile:idle": "node scripts/profile-idle.mjs",
"profile:session": "node scripts/profile-session.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": { "dependencies": {
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@openchamber/electron", "name": "@openchamber/electron",
"version": "1.21.1", "version": "1.22.0",
"private": true, "private": true,
"description": "Electron desktop runtime for OpenChamber", "description": "Electron desktop runtime for OpenChamber",
"author": "OpenChamber", "author": "OpenChamber",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.21.1", "version": "1.22.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/main.tsx", "main": "src/main.tsx",
+4 -1
View File
@@ -49,6 +49,7 @@ import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import type { RuntimeAPIs } from '@/lib/api/types'; import type { RuntimeAPIs } from '@/lib/api/types';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
@@ -247,6 +248,7 @@ function App({ apis }: AppProps) {
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory); const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false); const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode); const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
// Embedded chats start inactive until the parent panel identifies the active // Embedded chats start inactive until the parent panel identifies the active
// tab. Otherwise a newly loaded background tab can focus its composer first // 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 }); void refreshGitHubAuthStatus(apis.github, { force: true });
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]); void refreshLinearAuthStatus(apis.linear, { force: true });
}, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
useAppFontEffects(); useAppFontEffects();
+11 -7
View File
@@ -27,7 +27,7 @@ import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device'
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard'; import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; 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 { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache'; import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -35,6 +35,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useGitStore } from '@/stores/useGitStore'; import { useGitStore } from '@/stores/useGitStore';
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore'; import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -630,6 +631,7 @@ export function MobileApp({ apis }: MobileAppProps) {
const clearError = useSessionUIStore((state) => state.clearError); const clearError = useSessionUIStore((state) => state.clearError);
const setIsMobile = useUIStore((state) => state.setIsMobile); const setIsMobile = useUIStore((state) => state.setIsMobile);
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus);
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
const projects = useProjectsStore((state) => state.projects); const projects = useProjectsStore((state) => state.projects);
const [connectionEpoch, setConnectionEpoch] = React.useState(0); const [connectionEpoch, setConnectionEpoch] = React.useState(0);
@@ -678,12 +680,13 @@ export function MobileApp({ apis }: MobileAppProps) {
const refreshInPlace = () => { const refreshInPlace = () => {
void initializeApp(); void initializeApp();
void refreshGitHubAuthStatus(apis.github, { force: true }); void refreshGitHubAuthStatus(apis.github, { force: true });
void refreshLinearAuthStatus(apis.linear, { force: true });
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
}; };
const disconnect = (reason: string) => { const disconnect = (reason: string) => {
logMobileConnectEvent('resume:disconnect', { reason }); logMobileConnectEvent('resume:disconnect', { reason });
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1); setConnectionEpoch((value) => value + 1);
}; };
@@ -746,7 +749,7 @@ export function MobileApp({ apis }: MobileAppProps) {
lastNativeResumeSyncEventAtRef.current = now; lastNativeResumeSyncEventAtRef.current = now;
window.dispatchEvent(new Event('openchamber:system-resume')); 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(); useNativeMobileChrome();
useNativeMobileLifecycle(handleNativeResume); useNativeMobileLifecycle(handleNativeResume);
@@ -893,7 +896,7 @@ export function MobileApp({ apis }: MobileAppProps) {
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => { const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' }); logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' });
if (notice) setAutoConnectNotice(notice); if (notice) setAutoConnectNotice(notice);
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1); setConnectionEpoch((value) => value + 1);
}; };
void reprobeActiveConnection().then(async (outcome) => { void reprobeActiveConnection().then(async (outcome) => {
@@ -1031,7 +1034,8 @@ export function MobileApp({ apis }: MobileAppProps) {
React.useEffect(() => { React.useEffect(() => {
if (!isConnected) return; if (!isConnected) return;
void refreshGitHubAuthStatus(apis.github, { force: true }); 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 // Discover all worktrees for every known project so the draft session's
// worktree/branch dropdown can list every available branch — not only the // worktree/branch dropdown can list every available branch — not only the
@@ -1192,7 +1196,7 @@ export function MobileApp({ apis }: MobileAppProps) {
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1); setConnectionEpoch((value) => value + 1);
}} }}
> >
@@ -1275,7 +1279,7 @@ export function MobileApp({ apis }: MobileAppProps) {
<OpenCodeUpdateToast /> <OpenCodeUpdateToast />
<MobileAppUpdateToast /> <MobileAppUpdateToast />
<MobileShell onActiveConnectionDeleted={() => { <MobileShell onActiveConnectionDeleted={() => {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1); setConnectionEpoch((value) => value + 1);
}} /> }} />
<AppLinkConfirmDialog /> <AppLinkConfirmDialog />
+36 -5
View File
@@ -10,6 +10,7 @@ import { SyncActions } from '@/components/views/git/SyncActions';
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import type { GitStatus } from '@/lib/api/types'; import type { GitStatus } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi'; import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi';
@@ -21,6 +22,8 @@ import {
useIsGitRepo, useIsGitRepo,
useGitLoadingStatus, useGitLoadingStatus,
} from '@/stores/useGitStore'; } from '@/stores/useGitStore';
import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
import { getRuntimeKey } from '@/lib/runtime-switch'; import { getRuntimeKey } from '@/lib/runtime-switch';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
@@ -56,12 +59,18 @@ type MobileChangesSurfaceProps = {
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => { export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => {
const { t } = useI18n(); const { t } = useI18n();
const { git } = useRuntimeAPIs(); 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 status = useGitStatus(currentDirectory || null);
const isGitRepo = useIsGitRepo(currentDirectory || null); const isGitRepo = useIsGitRepo(currentDirectory || null);
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null); const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureAll = useGitStore((state) => state.ensureAll); 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 fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches); const fetchBranches = useGitStore((state) => state.fetchBranches);
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
@@ -465,6 +474,16 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
{status?.current || currentDirectory || ''} {status?.current || currentDirectory || ''}
</p> </p>
</div> </div>
{rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? (
<NestedRepoPicker
repositories={nestedRepos}
selectedRepository={gitDirectory ?? null}
onSelectRepository={(repository) => {
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
}}
repositoryRoot={rootDirectory ?? undefined}
/>
) : null}
</header> </header>
<div className="min-h-0 flex-1">{state}</div> <div className="min-h-0 flex-1">{state}</div>
</div> </div>
@@ -474,12 +493,24 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
return renderListState(<MobileChangesState message={t('gitView.empty.selectSessionOrDirectory')} />); return renderListState(<MobileChangesState message={t('gitView.empty.selectSessionOrDirectory')} />);
} }
if (isLoadingStatus && isGitRepo === null) { // Non-repo root: surface nested-repository resolution while the operating
return renderListState(<MobileChangesState loading message={t('gitView.loading.checkingRepository')} />); // 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(
<NestedRepoResolutionStates
rootIsGitRepo={rootIsGitRepo}
resolvedIsGitRepo={isGitRepo}
nestedRepos={nestedRepos}
onRetryDiscovery={() => {
if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true });
}}
/>
);
} }
if (isGitRepo === false) { if (isLoadingStatus && isGitRepo === null) {
return renderListState(<MobileChangesState icon message={t('gitView.empty.notGitRepository')} description={t('gitView.empty.notGitRepositoryDescription')} />); return renderListState(<MobileChangesState loading message={t('gitView.loading.checkingRepository')} />);
} }
if (route.type === 'diff') { if (route.type === 'diff') {
@@ -70,6 +70,27 @@ export const useNativeMobileChrome = (): void => {
const retry = window.setTimeout(() => void applyStatusBar(), 400); const retry = window.setTimeout(() => void applyStatusBar(), 400);
cleanup.push(() => window.clearTimeout(retry)); 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 { App } = await import('@capacitor/app');
const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { const stateHandle = await App.addListener('appStateChange', ({ isActive }) => {
if (isActive) void applyStatusBar(); if (isActive) void applyStatusBar();
+179 -28
View File
@@ -4,6 +4,7 @@ import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question'; import type { QuestionRequest } from '@/types/question';
import { ChatInput } from './ChatInput'; import { ChatInput } from './ChatInput';
import { ChatColumnSessionContext, type ChatColumnSession } from './chatColumnSession';
import { DraftPresetChips } from './DraftPresetChips'; import { DraftPresetChips } from './DraftPresetChips';
import { useInputStore } from '@/sync/input-store'; import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -11,11 +12,24 @@ import { Skeleton } from '@/components/ui/skeleton';
import ChatEmptyState from './ChatEmptyState'; import ChatEmptyState from './ChatEmptyState';
import { useGlobalSyncStore } from '@/sync/global-sync-store'; import { useGlobalSyncStore } from '@/sync/global-sync-store';
import MessageList, { type MessageListHandle } from './MessageList'; 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 { PermissionCard } from './PermissionCard';
import { QuestionCard } from './QuestionCard'; import { QuestionCard } from './QuestionCard';
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery'; import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
import { StatusRowContainer } from './StatusRowContainer'; import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import { SessionErrorNotice } from '@/components/chat/SessionErrorNotice';
import ScrollToBottomButton from './components/ScrollToBottomButton'; import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail'; import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
@@ -175,9 +189,9 @@ type ChatViewportProps = {
} | null; } | null;
scrollToBottom: () => void; scrollToBottom: () => void;
endPinningReleased: boolean; endPinningReleased: boolean;
// One-shot fade for content that replaced the hydration skeleton; /** The user waited for this session (held or fetched); reveal it with a fade. */
// cached sessions render instantly without it. revealWaited: boolean;
revealContent: boolean; revealGate: TimelineRevealGate;
sessionQuestions: QuestionRequest[]; sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[]; sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean; isProgrammaticFollowActive: boolean;
@@ -214,7 +228,8 @@ const ChatViewport = React.memo(({
retryOverlay, retryOverlay,
scrollToBottom, scrollToBottom,
endPinningReleased, endPinningReleased,
revealContent, revealWaited,
revealGate,
sessionQuestions, sessionQuestions,
sessionPermissions, sessionPermissions,
isProgrammaticFollowActive, isProgrammaticFollowActive,
@@ -362,12 +377,91 @@ const ChatViewport = React.memo(({
</div> </div>
)} )}
<SessionErrorNotice sessionId={currentSessionId} directory={directory} />
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} /> <SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" /> <div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
</> </>
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]); ), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
// Opening a session paints the timeline as one finished picture: the root
// stays invisible while any renderer holds a provisional first paint, then
// everything appears together. A session the user waited for fades in
// once as a whole; one that was ready at the click shows in the same
// frame.
const timelineRootRef = React.useRef<HTMLDivElement | null>(null);
const endPinningReleasedRef = React.useRef(endPinningReleased);
endPinningReleasedRef.current = endPinningReleased;
// Read through a ref: the effect runs once per gate (per opened session).
// `revealWaited` flips for the session still on screen the moment another
// one is selected — before the deferred swap mounts it — and re-running
// the effect then would hide the outgoing timeline for the frames until
// the new one arrives.
const revealWaitedRef = React.useRef(revealWaited);
revealWaitedRef.current = revealWaited;
React.useLayoutEffect(() => {
const root = timelineRootRef.current;
if (!root) return;
root.setAttribute('data-timeline-reveal', 'pending');
let finished = false;
let timer: number | null = null;
let frame: number | null = null;
// Revealed once the geometry has settled: after the last hold the
// list still lays rows out from its own measurements over a few
// frames, so the timeline stays hidden — pinned to the end on every
// frame — until the content height has held still for two frames,
// then shows already sitting on the end. The settle is bounded so a
// list that keeps growing (images, late tool output) still appears.
const reveal = (fade: boolean) => {
if (finished) return;
finished = true;
if (timer !== null) window.clearTimeout(timer);
const startedAt = performance.now();
let lastHeight = -1;
let stableFrames = 0;
const settle = () => {
frame = null;
const node = scrollRef.current;
let height = -1;
if (node) {
height = node.scrollHeight;
if (!endPinningReleasedRef.current) {
const end = height - node.clientHeight;
if (end - node.scrollTop > 1) node.scrollTop = end;
}
}
stableFrames = height === lastHeight ? stableFrames + 1 : 0;
lastHeight = height;
if (stableFrames < TIMELINE_SETTLE_STABLE_FRAMES && performance.now() - startedAt < TIMELINE_SETTLE_CAP_MS) {
frame = window.requestAnimationFrame(settle);
return;
}
if (fade) root.setAttribute('data-timeline-reveal', 'fading');
else root.removeAttribute('data-timeline-reveal');
};
frame = window.requestAnimationFrame(settle);
};
// Holds are taken in layout effects, including those of rows the list
// mounts in a nested synchronous pass; a microtask runs after all of
// them and still before the browser paints this commit.
queueMicrotask(() => {
if (finished) return;
revealGate.close();
if (revealGate.holds === 0) {
reveal(revealWaitedRef.current);
return;
}
revealGate.onEmpty = () => reveal(true);
timer = window.setTimeout(() => reveal(true), TIMELINE_REVEAL_CAP_MS);
});
return () => {
finished = true;
if (timer !== null) window.clearTimeout(timer);
if (frame !== null) window.cancelAnimationFrame(frame);
revealGate.onEmpty = null;
};
}, [revealGate, scrollRef]);
const scrollContainerProps = React.useMemo(() => ({ const scrollContainerProps = React.useMemo(() => ({
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target', className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
style: CHAT_SCROLL_STYLE, style: CHAT_SCROLL_STYLE,
@@ -385,11 +479,12 @@ const ChatViewport = React.memo(({
isDesktopExpandedInput isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none' ? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1', : 'flex-1',
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
)} )}
ref={timelineRootRef}
aria-hidden={isDesktopExpandedInput} aria-hidden={isDesktopExpandedInput}
> >
<div className="absolute inset-0"> <div className="absolute inset-0">
<TimelineRevealGateContext.Provider value={revealGate}>
<MessageList <MessageList
key={currentSessionKey} key={currentSessionKey}
ref={messageListRef} ref={messageListRef}
@@ -417,6 +512,7 @@ const ChatViewport = React.memo(({
listFooter={listFooter} listFooter={listFooter}
scrollContainerProps={scrollContainerProps} scrollContainerProps={scrollContainerProps}
/> />
</TimelineRevealGateContext.Provider>
<OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} /> <OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
{showPromptNavigator && promptTurnIds.length >= 2 ? ( {showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail <PromptNavigatorRail
@@ -448,7 +544,8 @@ const ChatViewport = React.memo(({
&& prev.retryOverlay === next.retryOverlay && prev.retryOverlay === next.retryOverlay
&& prev.scrollToBottom === next.scrollToBottom && prev.scrollToBottom === next.scrollToBottom
&& prev.endPinningReleased === next.endPinningReleased && prev.endPinningReleased === next.endPinningReleased
&& prev.revealContent === next.revealContent && prev.revealWaited === next.revealWaited
&& prev.revealGate === next.revealGate
&& prev.sessionQuestions === next.sessionQuestions && prev.sessionQuestions === next.sessionQuestions
&& prev.sessionPermissions === next.sessionPermissions && prev.sessionPermissions === next.sessionPermissions
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
@@ -588,10 +685,54 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
}) => { }) => {
const messagesEnabled = messagesEnabledProp ?? active; const messagesEnabled = messagesEnabledProp ?? active;
const { t } = useI18n(); const { t } = useI18n();
// Session UI state // Session UI state. The selection is published synchronously by the
const currentSessionId = useSessionUIStore((s) => s.currentSessionId); // sidebar click, but the chat swaps its content on a deferred copy: the
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory); // first commit paints the cheap reactions (active row, URL, tab) while the
// timeline for the new session renders in an interruptible transition
// behind it. Both fields travel as one value so the key, the message
// subscription, and the loader target never mix an old directory with a
// new session id.
const liveSessionId = useSessionUIStore((s) => s.currentSessionId);
const liveSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId); const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
const liveSelection = React.useMemo(
() => ({ sessionId: liveSessionId, directory: liveSessionDirectory }),
[liveSessionId, liveSessionDirectory],
);
// A session whose messages are not in memory yet keeps the previous
// timeline on screen while they load, instead of flashing a skeleton
// between two conversations. The hold ends when the session becomes
// renderable or after SESSION_SWITCH_HOLD_MS, whichever comes first, and
// never applies when nothing was shown before or when the session was just
// created from a draft.
const liveSessionRenderable = useSessionRenderable(liveSessionId ?? '', liveSessionDirectory ?? undefined);
const shownSelectionRef = React.useRef(liveSelection);
const [expiredHoldSessionId, setExpiredHoldSessionId] = React.useState<string | null>(null);
const holdPreviousTimeline = Boolean(liveSessionId)
&& !liveSessionRenderable
&& liveSessionId !== materializedDraftSessionId
&& shownSelectionRef.current.sessionId !== null
&& shownSelectionRef.current.sessionId !== liveSessionId
&& expiredHoldSessionId !== liveSessionId;
React.useEffect(() => {
if (!holdPreviousTimeline || !liveSessionId) return;
const timer = window.setTimeout(() => setExpiredHoldSessionId(liveSessionId), SESSION_SWITCH_HOLD_MS);
return () => window.clearTimeout(timer);
}, [holdPreviousTimeline, liveSessionId]);
// A session the user waited for (not in memory at the click) fades in; one
// that was ready appears in the same frame. Decided once per selection so
// a later, warm visit to the same session is instant again.
const lastLiveSessionIdRef = React.useRef<string | null | undefined>(undefined);
const waitedSessionIdRef = React.useRef<string | null>(null);
if (liveSessionId !== lastLiveSessionIdRef.current) {
lastLiveSessionIdRef.current = liveSessionId;
waitedSessionIdRef.current = liveSessionId && !liveSessionRenderable ? liveSessionId : null;
}
const targetSelection = holdPreviousTimeline ? shownSelectionRef.current : liveSelection;
const { sessionId: currentSessionId, directory: currentSessionDirectory } = React.useDeferredValue(targetSelection);
shownSelectionRef.current = { sessionId: currentSessionId, directory: currentSessionDirectory };
const revealWaited = Boolean(currentSessionId) && currentSessionId === waitedSessionIdRef.current;
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession); const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
@@ -604,6 +745,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const currentSessionKey = currentSessionId const currentSessionKey = currentSessionId
? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId]) ? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId])
: null; : null;
// One gate per opened session; the scroll hook holds it until the
// viewport is pinned to the end so the first visible frame is already
// at the bottom.
const revealGateRef = React.useRef<{ key: string | null; gate: TimelineRevealGate } | null>(null);
if (revealGateRef.current?.key !== currentSessionKey) {
revealGateRef.current = { key: currentSessionKey, gate: createTimelineRevealGate() };
}
const revealGate = revealGateRef.current.gate;
const chatColumnSession = React.useMemo<ChatColumnSession>(
() => ({ sessionId: currentSessionId ?? null, directory: currentSessionId ? effectiveSessionDirectory ?? null : null }),
[currentSessionId, effectiveSessionDirectory],
);
const ensureSessionRenderable = React.useCallback( const ensureSessionRenderable = React.useCallback(
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory), (sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
[effectiveSessionDirectory, sync], [effectiveSessionDirectory, sync],
@@ -824,9 +977,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return () => setWorkStatusPanelVisible(false); return () => setWorkStatusPanelVisible(false);
}, [setWorkStatusPanelVisible, showWorkStatusPanel]); }, [setWorkStatusPanelVisible, showWorkStatusPanel]);
const messageListRef = React.useRef<MessageListHandle | null>(null); const messageListRef = React.useRef<MessageListHandle | null>(null);
// Session keys that showed the hydration skeleton this app run; their
// content gets a one-shot reveal fade once it replaces the skeleton.
const hydrationRevealKeyRef = React.useRef<string | null>(null);
const currentSession = useSession(currentSessionId, effectiveSessionDirectory); const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
@@ -907,13 +1057,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
}; };
}, []); }, []);
// Selection policy reads the live selection, not the deferred one: right
// after a click the deferred id still names the previous session (or
// nothing) for one commit, and acting on that would open a draft over the
// session the user just chose.
React.useEffect(() => { React.useEffect(() => {
if (autoOpenDraft && !currentSessionId && !draftOpen) { if (autoOpenDraft && !liveSessionId && !draftOpen) {
// Programmatic fallback, not user navigation — must not clear the // Programmatic fallback, not user navigation — must not clear the
// persisted last-session pointer the cold-launch restore reads. // persisted last-session pointer the cold-launch restore reads.
openNewSessionDraft({ automatic: true }); openNewSessionDraft({ automatic: true });
} }
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]); }, [autoOpenDraft, liveSessionId, draftOpen, openNewSessionDraft]);
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {}); const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
const handleActiveTurnChange = React.useCallback((turnId: string | null) => { const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
@@ -924,7 +1078,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
// OVER the timeline's bottom edge; its measured height keeps the live // OVER the timeline's bottom edge; its measured height keeps the live
// streaming line above it and reserves matching end inset in the list. // streaming line above it and reserves matching end inset in the list.
const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0); const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0);
const composerOverlayHeight = statusOverlayHeight; // The reserve is fixed so the timeline's end does not move when the row
// appears a commit after the session opened: a viewport pinned to the end
// would otherwise be left sitting the row's height above it. Measurement
// only extends the reserve for a taller row.
const composerOverlayHeight = Math.max(STATUS_OVERLAY_RESERVED_HEIGHT, statusOverlayHeight);
const statusOverlayObserverRef = React.useRef<ResizeObserver | null>(null); const statusOverlayObserverRef = React.useRef<ResizeObserver | null>(null);
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => { const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
statusOverlayObserverRef.current?.disconnect(); statusOverlayObserverRef.current?.disconnect();
@@ -981,6 +1139,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
sessionMessageCount, sessionMessageCount,
composerOverlayHeight, composerOverlayHeight,
lastUserMessageId, lastUserMessageId,
sessionIsWorking,
revealGate,
onActiveTurnChange: handleActiveTurnChange, onActiveTurnChange: handleActiveTurnChange,
}); });
@@ -1163,15 +1323,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const isSessionHydrating = const isSessionHydrating =
Boolean(currentSessionId) Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot; && !hasRenderableSessionSnapshot;
React.useEffect(() => {
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
// One-shot: forget the key after the reveal animation has played so a
// later (now cached) visit to the same session opens instantly.
const timer = setTimeout(() => {
hydrationRevealKeyRef.current = null;
}, 400);
return () => clearTimeout(timer);
}, [isSessionHydrating, currentSessionKey]);
const retrySessionLoad = React.useCallback(() => { const retrySessionLoad = React.useCallback(() => {
if (!messagesEnabled || !currentSessionId) return; if (!messagesEnabled || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
@@ -1310,9 +1461,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
} }
const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking; const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
if (showHydrationSkeleton) {
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
}
if (showHydrationSkeleton) { if (showHydrationSkeleton) {
if (sessionMessageLoadState.status === 'error') { if (sessionMessageLoadState.status === 'error') {
return ( return (
@@ -1415,7 +1563,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
retryOverlay={retryOverlay} retryOverlay={retryOverlay}
scrollToBottom={resumeToLatestInstant} scrollToBottom={resumeToLatestInstant}
endPinningReleased={userOwnsScroll} endPinningReleased={userOwnsScroll}
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)} revealWaited={revealWaited}
revealGate={revealGate}
sessionQuestions={sessionQuestions} sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions} sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isFollowingProgrammatically} isProgrammaticFollowActive={isFollowingProgrammatically}
@@ -1434,6 +1583,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return ( return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background"> <div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<ChatColumnSessionContext.Provider value={chatColumnSession}>
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background"> <div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton} {returnToParentButton}
{sessionSurface} {sessionSurface}
@@ -1518,6 +1668,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
onLoadEarlier={handleLoadOlderClick} onLoadEarlier={handleLoadOlderClick}
/> />
</div> </div>
</ChatColumnSessionContext.Provider>
{/* Kept mounted while it could ever show, so it can animate its own {/* Kept mounted while it could ever show, so it can animate its own
collapse; `visible` drives that. Unmounting on the spot is what made collapse; `visible` drives that. Unmounting on the spot is what made
the chat jump wide before easing narrow again. */} the chat jump wide before easing narrow again. */}
+102 -13
View File
@@ -17,7 +17,7 @@ import {
} from '@/sync/attachment-files'; } from '@/sync/attachment-files';
import type { AttachedFile } from '@/stores/types/sessionTypes'; import type { AttachedFile } from '@/stores/types/sessionTypes';
import * as sessionActions from '@/sync/session-actions'; import * as sessionActions from '@/sync/session-actions';
import { buildLinkedIssue } from '@/lib/linkedIssues'; import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
import { useUserMessageHistory } from "@/sync/sync-context"; import { useUserMessageHistory } from "@/sync/sync-context";
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore';
@@ -51,6 +51,7 @@ import { ModelControls } from './ModelControls';
import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { ComposerStatusBar } from './ComposerStatusBar'; import { ComposerStatusBar } from './ComposerStatusBar';
import { PendingChangesBar } from './PendingChangesBar'; import { PendingChangesBar } from './PendingChangesBar';
import { useChatColumnSession } from './chatColumnSession';
import { useChatSurfaceMode } from './useChatSurfaceMode'; import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton'; import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton'; import { MobileModelButton } from './MobileModelButton';
@@ -66,6 +67,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog'; import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog'; import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
import { LinearIssuePickerDialog } from '@/components/session/LinearIssuePickerDialog';
import { GitLabIssuePickerDialog } from '@/components/session/GitLabIssuePickerDialog'; import { GitLabIssuePickerDialog } from '@/components/session/GitLabIssuePickerDialog';
import { GitLabMrPickerDialog } from '@/components/session/GitLabMrPickerDialog'; import { GitLabMrPickerDialog } from '@/components/session/GitLabMrPickerDialog';
import { GiteaIssuePickerDialog } from '@/components/session/GiteaIssuePickerDialog'; import { GiteaIssuePickerDialog } from '@/components/session/GiteaIssuePickerDialog';
@@ -77,8 +79,8 @@ import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSkillsStore } from '@/stores/useSkillsStore'; import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
import { useCommandsStore } from '@/stores/useCommandsStore'; import { selectCommandsForDirectory, useCommandsStore } from '@/stores/useCommandsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { usePermissionStore } from '@/stores/permissionStore'; import { usePermissionStore } from '@/stores/permissionStore';
@@ -339,9 +341,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const sendMessage = React.useRef((...args: any[]) => const sendMessage = React.useRef((...args: any[]) =>
Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)), Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)),
).current; ).current;
const currentSessionId = useSessionUIStore((s) => s.currentSessionId); // Inside the chat column the composer follows the session the timeline is
// showing (see chatColumnSession.ts); elsewhere it follows the live one.
const liveSessionId = useSessionUIStore((s) => s.currentSessionId);
const chatColumnSession = useChatColumnSession();
const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId;
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
const currentDirectory = useEffectiveDirectory() ?? fallbackDirectory; const liveEffectiveDirectory = useEffectiveDirectory();
const currentDirectory = (chatColumnSession?.sessionId ? chatColumnSession.directory : null)
?? liveEffectiveDirectory
?? fallbackDirectory;
const currentSessionDirectoryForSync = useSessionUIStore( const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]), React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
); );
@@ -431,7 +440,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const isExpandedInput = useUIStore((state) => state.isExpandedInput); const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const setExpandedInput = useUIStore((state) => state.setExpandedInput); const setExpandedInput = useUIStore((state) => state.setExpandedInput);
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen); const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
const { git: runtimeGit, vscode: vscodeApi } = useRuntimeAPIs(); const { git: runtimeGit, vscode: vscodeApi, linear: runtimeLinear } = useRuntimeAPIs();
const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent); const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent);
const cycleAgentShortcut = React.useMemo(() => ( const cycleAgentShortcut = React.useMemo(() => (
getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined) getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined)
@@ -608,8 +617,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
// Known slash-invocations (commands + skills + built-ins) used to highlight // Known slash-invocations (commands + skills + built-ins) used to highlight
// matching /tokens in the composer, the same way confirmed @files are. // matching /tokens in the composer, the same way confirmed @files are.
const availableCommands = useCommandsStore((s) => s.commands); const availableCommands = useCommandsStore((s) => selectCommandsForDirectory(s, currentDirectory));
const availableSkills = useSkillsStore((s) => s.skills); const availableSkills = useSkillsStore((s) => selectSkillsForDirectory(s, currentDirectory));
const knownSlashNames = React.useMemo(() => { const knownSlashNames = React.useMemo(() => {
const names = new Set<string>([ const names = new Set<string>([
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore', 'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
@@ -728,6 +737,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
// Issue linking state // Issue linking state
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [prPickerOpen, setPrPickerOpen] = React.useState(false); const [prPickerOpen, setPrPickerOpen] = React.useState(false);
const [linearPickerOpen, setLinearPickerOpen] = React.useState(false);
const [gitlabIssuePickerOpen, setGitlabIssuePickerOpen] = React.useState(false); const [gitlabIssuePickerOpen, setGitlabIssuePickerOpen] = React.useState(false);
const [gitlabMrPickerOpen, setGitlabMrPickerOpen] = React.useState(false); const [gitlabMrPickerOpen, setGitlabMrPickerOpen] = React.useState(false);
const [giteaIssuePickerOpen, setGiteaIssuePickerOpen] = React.useState(false); const [giteaIssuePickerOpen, setGiteaIssuePickerOpen] = React.useState(false);
@@ -751,6 +761,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
author?: { login: string; avatarUrl?: string }; author?: { login: string; avatarUrl?: string };
provider?: 'github' | 'gitlab' | 'gitea'; provider?: 'github' | 'gitlab' | 'gitea';
} | null>(null); } | null>(null);
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{
identifier: string;
title: string;
url: string;
contextText: string;
author?: { login: string; avatarUrl?: string };
} | null>(null);
// Message queue // Message queue
const messageQueueTarget = currentSessionId const messageQueueTarget = currentSessionId
@@ -995,6 +1012,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
} }
}, [gitProvider]); }, [gitProvider]);
const openLinearPicker = React.useCallback(() => {
setLinearPickerOpen(true);
}, []);
const getSubmitErrorMessage = (error: unknown, fallback: string) => { const getSubmitErrorMessage = (error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : ''; const message = error instanceof Error ? error.message : '';
return message.toLowerCase().includes('runtime changed') return message.toLowerCase().includes('runtime changed')
@@ -1163,7 +1184,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
: []; : [];
const availableSkillNames = new Set( const availableSkillNames = new Set(
useSkillsStore.getState().skills.map((skill) => skill.name), selectSkillsForDirectory(useSkillsStore.getState(), currentDirectory).map((skill) => skill.name),
); );
const outgoing = buildOutgoingMessage({ const outgoing = buildOutgoingMessage({
@@ -1181,6 +1202,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
linkedPr: linkedPr linkedPr: linkedPr
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText } ? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
: null, : null,
linkedLinearIssue: linkedLinearIssue
? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText }
: null,
}, { }, {
parseAgentMention: (text) => { parseAgentMention: (text) => {
const { sanitizedText, mention } = parseAgentMentions(text, agents); const { sanitizedText, mention } = parseAgentMentions(text, agents);
@@ -1418,6 +1442,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
true, true,
).catch(() => undefined); ).catch(() => undefined);
} }
if (linkedLinearIssue && linkTargetSessionId) {
void sessionActions.setLinkedIssue(
linkTargetSessionId,
linkTargetDirectory,
buildLinkedLinearIssue({
identifier: linkedLinearIssue.identifier,
title: linkedLinearIssue.title,
url: linkedLinearIssue.url,
author: linkedLinearIssue.author,
linkedAt: Date.now(),
}),
true,
).catch(() => undefined);
}
// Clear linked issue after successful message send // Clear linked issue after successful message send
if (linkedIssue) { if (linkedIssue) {
@@ -1426,6 +1464,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
if (linkedPr) { if (linkedPr) {
setLinkedPr(null); setLinkedPr(null);
} }
if (linkedLinearIssue) {
setLinkedLinearIssue(null);
}
}).catch((error: unknown) => { }).catch((error: unknown) => {
const rawMessage = const rawMessage =
error instanceof Error error instanceof Error
@@ -2285,10 +2326,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}; };
React.useEffect(() => { React.useEffect(() => {
if (!active || !currentSessionId || isMobile) return;
if (active && currentSessionId && composerRef.current && !isMobile) { // Focusing forces layout. Right after a session switch the layout is
composerRef.current.focus(); // dirty from the whole timeline mounting, so the focus call would pay
} // for that layout inside the commit; a frame later it is nearly free.
const frame = window.requestAnimationFrame(() => {
composerRef.current?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [active, currentSessionId, isMobile]); }, [active, currentSessionId, isMobile]);
React.useEffect(() => { React.useEffect(() => {
@@ -2547,6 +2592,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const footerGapClass = 'gap-x-1.5 gap-y-0'; const footerGapClass = 'gap-x-1.5 gap-y-0';
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
const showLinearPicker = Boolean(runtimeLinear) && !isVSCode;
// The work-status panel carries the agent's todos and the changed-file // The work-status panel carries the agent's todos and the changed-file
// count, but only on the desktop/web layout — VS Code and mobile have no // count, but only on the desktop/web layout — VS Code and mobile have no
// panel, so these keep their place above the composer there. // panel, so these keep their place above the composer there.
@@ -2627,6 +2673,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
draftPickerOpen: mobileDraftPicker !== null, draftPickerOpen: mobileDraftPicker !== null,
issuePickerOpen, issuePickerOpen,
prPickerOpen, prPickerOpen,
linearPickerOpen,
isDragging, isDragging,
}, },
}); });
@@ -2801,6 +2848,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
onRemove={() => setLinkedPr(null)} onRemove={() => setLinkedPr(null)}
/> />
) : null} ) : null}
{linkedLinearIssue && !isVSCode ? (
<LinkedReferenceRow
numberLabel={linkedLinearIssue.identifier}
title={linkedLinearIssue.title}
url={linkedLinearIssue.url}
author={linkedLinearIssue.author}
openInBrowserLabel={t('chat.chatInput.linked.linearIssue.openInBrowserAria')}
removeLabel={t('chat.chatInput.linked.linearIssue.removeAria')}
onReopenPicker={() => setLinearPickerOpen(true)}
onRemove={() => setLinkedLinearIssue(null)}
/>
) : null}
<RevertedMessageDock <RevertedMessageDock
sessionId={currentSessionId} sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory} directory={currentSessionDirectoryForSync ?? currentDirectory}
@@ -2866,6 +2925,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
onPickLocalFiles={handlePickLocalFiles} onPickLocalFiles={handlePickLocalFiles}
onOpenIssuePicker={openIssuePicker} onOpenIssuePicker={openIssuePicker}
onOpenPrPicker={openPrPicker} onOpenPrPicker={openPrPicker}
showLinearPicker={showLinearPicker}
onOpenLinearPicker={openLinearPicker}
onOpenAttachSheet={openMobileAttachSheet} onOpenAttachSheet={openMobileAttachSheet}
onStartDictation={toggleDictation} onStartDictation={toggleDictation}
onAbort={handleAbort} onAbort={handleAbort}
@@ -3046,6 +3107,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
onPickLocalFiles={handlePickLocalFiles} onPickLocalFiles={handlePickLocalFiles}
onOpenIssuePicker={openIssuePicker} onOpenIssuePicker={openIssuePicker}
onOpenPrPicker={openPrPicker} onOpenPrPicker={openPrPicker}
showLinearPicker={showLinearPicker}
onOpenLinearPicker={openLinearPicker}
onOpenAttachSheet={openMobileAttachSheet} onOpenAttachSheet={openMobileAttachSheet}
onToggleExpandedInput={handleToggleExpandedInput} onToggleExpandedInput={handleToggleExpandedInput}
onTogglePermissionAutoAccept={handlePermissionAutoAcceptToggle} onTogglePermissionAutoAccept={handlePermissionAutoAcceptToggle}
@@ -3110,6 +3173,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
onSelect={(issue) => { onSelect={(issue) => {
setLinkedIssue(issue); setLinkedIssue(issue);
setLinkedPr(null); setLinkedPr(null);
setLinkedLinearIssue(null);
}} }}
/> />
<GitHubPrPickerDialog <GitHubPrPickerDialog
@@ -3118,6 +3182,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
onSelect={(pr) => { onSelect={(pr) => {
setLinkedPr(pr); setLinkedPr(pr);
setLinkedIssue(null); setLinkedIssue(null);
setLinkedLinearIssue(null);
}}
/>
<LinearIssuePickerDialog
open={linearPickerOpen}
onOpenChange={setLinearPickerOpen}
mode="select"
onSelect={(issue) => {
setLinkedLinearIssue(issue);
setLinkedIssue(null);
setLinkedPr(null);
}} }}
/> />
<GitLabIssuePickerDialog <GitLabIssuePickerDialog
@@ -3235,6 +3310,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
<Icon name={gitProvider === 'gitlab' ? 'gitlab' : 'git-pull-request'} className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" /> <Icon name={gitProvider === 'gitlab' ? 'gitlab' : 'git-pull-request'} className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabMr') : gitProvider === 'gitea' ? t('chat.chatInput.actions.linkGiteaPr') : t('chat.chatInput.actions.linkGithubPr')} {gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabMr') : gitProvider === 'gitea' ? t('chat.chatInput.actions.linkGiteaPr') : t('chat.chatInput.actions.linkGithubPr')}
</button> </button>
{showLinearPicker ? (
<button
type="button"
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
onClick={() => {
mobileShell.skipNextOverlayCloseRestore();
setMobileAttachMenuOpen(false);
requestAnimationFrame(openLinearPicker);
}}
>
<Icon name="linear" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{t('chat.chatInput.actions.linkLinearIssue')}
</button>
) : null}
</div> </div>
</MobileOverlayPanel> </MobileOverlayPanel>
) : null} ) : null}
@@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils'; import { cn, fuzzyMatch } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useCommandsStore } from '@/stores/useCommandsStore'; import { selectCommandsForDirectory, useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore'; import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -73,10 +74,16 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const [commands, setCommands] = React.useState<CommandInfo[]>([]); const [commands, setCommands] = React.useState<CommandInfo[]>([]);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const commandsWithMetadata = useCommandsStore((s) => s.commands); // Commands and skills belong to the directory the composer sends to — the
const refreshCommands = useCommandsStore((s) => s.loadCommands); // session's own directory, or the Chats root for a chat draft — not to the
const skills = useSkillsStore((s) => s.skills); // project the app was on last.
const refreshSkills = useSkillsStore((s) => s.loadSkills); const effectiveDirectory = useEffectiveDirectory();
const commandsWithMetadata = useCommandsStore((s) => selectCommandsForDirectory(s, effectiveDirectory));
const loadCommandsForDirectory = useCommandsStore((s) => s.loadCommands);
const skills = useSkillsStore((s) => selectSkillsForDirectory(s, effectiveDirectory));
const loadSkillsForDirectory = useSkillsStore((s) => s.loadSkills);
const refreshCommands = React.useCallback(() => loadCommandsForDirectory(effectiveDirectory), [effectiveDirectory, loadCommandsForDirectory]);
const refreshSkills = React.useCallback(() => loadSkillsForDirectory(effectiveDirectory), [effectiveDirectory, loadSkillsForDirectory]);
const [selectedIndex, setSelectedIndex] = React.useState(0); const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0); const selectedIndexRef = React.useRef(0);
const keyboardNavigationRef = React.useRef(false); const keyboardNavigationRef = React.useRef(false);
@@ -577,6 +577,8 @@ const PR_LINK_MIMES = new Set([
'application/vnd.gitea.pull-request-link', 'application/vnd.gitea.pull-request-link',
]); ]);
const LINEAR_ISSUE_LINK_MIME = 'application/vnd.openchamber.linear-issue-link';
type ForgeLinkInfo = { kind: 'issue' | 'pr'; provider: 'github' | 'gitlab' | 'gitea' } | null; type ForgeLinkInfo = { kind: 'issue' | 'pr'; provider: 'github' | 'gitlab' | 'gitea' } | null;
const getForgeLinkInfo = (file: FilePart): ForgeLinkInfo => { const getForgeLinkInfo = (file: FilePart): ForgeLinkInfo => {
@@ -598,9 +600,21 @@ const getForgeLinkInfo = (file: FilePart): ForgeLinkInfo => {
return null; return null;
}; };
const forgeLinkIconName = (info: ForgeLinkInfo): 'github' | 'gitlab' | 'git-branch' | 'git-pull-request' => { const isLinearLink = (file: FilePart): boolean => file.mime === LINEAR_ISSUE_LINK_MIME;
type LinkInfo = ForgeLinkInfo | { kind: 'linear-issue' } | null;
const getLinkInfo = (file: FilePart): LinkInfo => {
const forge = getForgeLinkInfo(file);
if (forge) return forge;
if (isLinearLink(file)) return { kind: 'linear-issue' };
return null;
};
const linkIconName = (info: LinkInfo): 'github' | 'gitlab' | 'git-branch' | 'git-pull-request' | 'linear' => {
if (!info) return 'github'; if (!info) return 'github';
if (info.kind === 'pr') return 'git-pull-request'; if (info.kind === 'pr') return 'git-pull-request';
if (info.kind === 'linear-issue') return 'linear';
if (info.provider === 'gitlab') return 'gitlab'; if (info.provider === 'gitlab') return 'gitlab';
if (info.provider === 'gitea') return 'git-branch'; if (info.provider === 'gitea') return 'git-branch';
return 'github'; return 'github';
@@ -628,8 +642,8 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
}; };
const resolveDisplayName = React.useCallback((file: FilePart): string => { const resolveDisplayName = React.useCallback((file: FilePart): string => {
const isForgeLink = getForgeLinkInfo(file) !== null; const isLink = getLinkInfo(file) !== null;
if (isForgeLink && typeof file.filename === 'string' && file.filename.trim().length > 0) { if (isLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
return file.filename.trim(); return file.filename.trim();
} }
return extractFilename(file.filename || file.url); return extractFilename(file.filename || file.url);
@@ -702,11 +716,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
const fileName = resolveDisplayName(file); const fileName = resolveDisplayName(file);
const ext = fileName.split('.').pop() || ''; const ext = fileName.split('.').pop() || '';
const sizeText = formatFileSize(file.size); const sizeText = formatFileSize(file.size);
const forgeLink = getForgeLinkInfo(file); const linkInfo = getLinkInfo(file);
return ( return (
<Tooltip key={`file-${file.url || file.filename || index}`}> <Tooltip key={`file-${file.url || file.filename || index}`}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
{forgeLink && file.url ? ( {linkInfo && file.url ? (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -714,7 +728,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
}} }}
className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors" className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors"
> >
<Icon name={forgeLinkIconName(forgeLink)} className="text-muted-foreground h-3.5 w-3.5" /> <Icon name={linkIconName(linkInfo)} className="text-muted-foreground h-3.5 w-3.5" />
<div className="overflow-hidden max-w-[220px]"> <div className="overflow-hidden max-w-[220px]">
<span className="truncate block" title={fileName}>{fileName}</span> <span className="truncate block" title={fileName}>{fileName}</span>
</div> </div>
@@ -797,7 +811,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
const fileName = resolveDisplayName(file); const fileName = resolveDisplayName(file);
const isImage = file.mime?.startsWith('image/'); const isImage = file.mime?.startsWith('image/');
const sizeText = formatFileSize(file.size); const sizeText = formatFileSize(file.size);
const forgeLink = getForgeLinkInfo(file); const linkInfo = getLinkInfo(file);
if (isImage && file.url) { if (isImage && file.url) {
return ( return (
@@ -820,7 +834,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
); );
} }
if (forgeLink && file.url) { if (linkInfo && file.url) {
return ( return (
<Tooltip key={file.url || `${fileName}-${index}`}> <Tooltip key={file.url || `${fileName}-${index}`}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -835,7 +849,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
)} )}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Icon name={forgeLinkIconName(forgeLink)} className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} /> <Icon name={linkIconName(linkInfo)} className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{fileName}</p> <p className="font-medium truncate">{fileName}</p>
@@ -2,7 +2,7 @@ import React from 'react';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { loadMarkdownRendererModule } from './markdownRendererLoader'; import { getLoadedMarkdownRendererModule, loadMarkdownRendererModule } from './markdownRendererLoader';
// Thin lazy wrapper around the MarkdownRenderer implementation. // Thin lazy wrapper around the MarkdownRenderer implementation.
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom // The full implementation (marked + Shiki highlighting + KaTeX + morphdom
@@ -41,21 +41,29 @@ const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown;
); );
}; };
export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => ( export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => {
const loaded = getLoadedMarkdownRendererModule();
if (loaded) return <loaded.MarkdownRenderer {...props} />;
return (
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}> <React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
<MarkdownRendererLazy {...props} /> <MarkdownRendererLazy {...props} />
</React.Suspense> </React.Suspense>
); );
};
type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy> & { type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy> & {
fallbackContent?: React.ReactNode; fallbackContent?: React.ReactNode;
}; };
export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => ( export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => {
const loaded = getLoadedMarkdownRendererModule();
if (loaded) return <loaded.SimpleMarkdownRenderer {...props} />;
return (
<React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}> <React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}>
<SimpleMarkdownRendererLazy {...props} /> <SimpleMarkdownRendererLazy {...props} />
</React.Suspense> </React.Suspense>
); );
};
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => ( export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
@@ -193,6 +193,8 @@ const fakeReact = {
return hookStates[index] as { current: T }; return hookStates[index] as { current: T };
}, },
memo: <T>(component: T): T => component, memo: <T>(component: T): T => component,
createContext: <T>(defaultValue: T) => ({ Provider: 'provider', defaultValue }),
useContext: <T>(context: { defaultValue: T }): T => context.defaultValue,
}; };
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => { const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
@@ -51,6 +51,7 @@ import {
import { fileReferenceExists } from './fileReferenceStat'; import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug'; import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache'; import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
import { TimelineRevealGateContext } from './timelineRevealGate';
import { getRuntimeKey } from '@/lib/runtime-switch'; import { getRuntimeKey } from '@/lib/runtime-switch';
const useCurrentMermaidTheme = () => { const useCurrentMermaidTheme = () => {
@@ -692,6 +693,30 @@ const useMermaidInlineInteractions = ({
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>(); const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
const MERMAID_RENDER_CACHE_MAX = 100; const MERMAID_RENDER_CACHE_MAX = 100;
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id'; const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
// True when the container already holds exactly these settled blocks with the
// current decoration. The first paint of a remounted message is served from
// the block cache; when that paint is already final, the async render would
// only parse, highlight, sanitize, and morph the same HTML into place again.
const domMatchesRenderedBlocks = (
target: HTMLElement,
blocks: ReadonlyArray<{ id: string }>,
decorationId: string,
): boolean => {
const children = target.children;
if (children.length !== blocks.length) return false;
for (let index = 0; index < blocks.length; index += 1) {
const child = children[index];
if (
!child
|| child.getAttribute('data-md-id') !== blocks[index]?.id
|| child.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId
) {
return false;
}
}
return true;
};
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>(); const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
let nextMarkdownDecorationId = 0; let nextMarkdownDecorationId = 0;
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000; const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
@@ -804,6 +829,16 @@ const useMorphdomMarkdown = ({
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null); const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
const renderRevisionRef = React.useRef(0); const renderRevisionRef = React.useRef(0);
// A provisional first paint (blocks not in the settled cache) holds the
// timeline reveal until the async render lands, so the session opens with
// final code highlighting instead of a visible restyle.
const revealGate = React.useContext(TimelineRevealGateContext);
const releaseRevealHoldRef = React.useRef<(() => void) | null>(null);
const releaseRevealHold = React.useCallback(() => {
releaseRevealHoldRef.current?.();
releaseRevealHoldRef.current = null;
}, []);
React.useEffect(() => releaseRevealHold, [releaseRevealHold]);
// Only DOM that was actually restored or completed by the async pipeline is // Only DOM that was actually restored or completed by the async pipeline is
// eligible for capture. A fallback from an earlier content revision is not. // eligible for capture. A fallback from an earlier content revision is not.
const mountedDomRef = React.useRef<{ const mountedDomRef = React.useRef<{
@@ -909,6 +944,9 @@ const useMorphdomMarkdown = ({
} }
if (hasMermaidBlock) refreshMermaidViewers(); if (hasMermaidBlock) refreshMermaidViewers();
} else { } else {
if (!streaming && !releaseRevealHoldRef.current) {
releaseRevealHoldRef.current = revealGate?.hold() ?? null;
}
const block = document.createElement('div'); const block = document.createElement('div');
block.setAttribute('data-md-block', ''); block.setAttribute('data-md-block', '');
block.style.display = 'contents'; block.style.display = 'contents';
@@ -924,7 +962,7 @@ const useMorphdomMarkdown = ({
// or re-decorating ordinary blocks. // or re-decorating ordinary blocks.
refreshMermaidViewers(); refreshMermaidViewers();
} }
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]); }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers, revealGate]);
React.useEffect(() => () => { React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup(); mermaidViewerRef.current?.cleanup();
@@ -939,6 +977,18 @@ const useMorphdomMarkdown = ({
const renderRevision = renderRevisionRef.current; const renderRevision = renderRevisionRef.current;
const decorationId = getMarkdownDecorationId(ctx); const decorationId = getMarkdownDecorationId(ctx);
if (!streaming) {
const cachedBlocks = getCachedMarkdownBlocks(text, imageMode);
if (cachedBlocks && domMatchesRenderedBlocks(target, cachedBlocks, decorationId)) {
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
streamPerfCount('ui.markdown_renderer.settled_paint.reused');
releaseRevealHold();
return;
}
}
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => { void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active || renderRevisionRef.current !== renderRevision) return; if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[]; const existing = Array.from(target.children) as HTMLElement[];
@@ -1028,12 +1078,13 @@ const useMorphdomMarkdown = ({
mountedDomRef.current = domCacheKey mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied } ? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null; : null;
releaseRevealHold();
}); });
return () => { return () => {
active = false; active = false;
}; };
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]); }, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, releaseRevealHold, streaming, text]);
React.useEffect(() => { React.useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -0,0 +1,112 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { useLatestSessionError } from '@/sync/notification-store';
import { useDirectoryStore, useSessionStatus } from '@/sync/sync-context';
interface SessionErrorNoticeProps {
sessionId: string;
directory?: string;
}
// How long a user message may sit unanswered on an idle session before the
// notice calls it a reply that never began.
const UNANSWERED_AFTER_MS = 5_000;
type LastMessageState = {
role: string;
timestamp: number;
hasError: boolean;
} | null;
// The last message of a session, with whether it already carries an error of
// its own: an assistant message that OpenCode marked failed renders its error
// inline, so the session-level notice must not repeat it.
const useLastMessageState = (sessionId: string, directory?: string): LastMessageState => {
const store = useDirectoryStore(directory);
const cacheRef = React.useRef<LastMessageState>(null);
const getSnapshot = React.useCallback((): LastMessageState => {
if (!sessionId) return null;
const messages = store.getState().message[sessionId];
const last = messages && messages.length > 0 ? messages[messages.length - 1] : null;
// SAFETY: store messages are SDK `Message` records; `error` is the optional
// assistant-message error the SDK types carry, read here only for presence.
const info = last as { role?: string; time?: { completed?: number; created?: number }; error?: unknown } | null;
if (!info) {
cacheRef.current = null;
return null;
}
const next: LastMessageState = {
role: typeof info.role === 'string' ? info.role : '',
timestamp: info.time?.completed ?? info.time?.created ?? 0,
hasError: Boolean(info.error),
};
const cached = cacheRef.current;
if (cached && cached.role === next.role && cached.timestamp === next.timestamp && cached.hasError === next.hasError) {
return cached;
}
cacheRef.current = next;
return next;
}, [sessionId, store]);
const subscribe = React.useCallback((notify: () => void) => {
if (!sessionId) return () => undefined;
return store.subscribe(notify);
}, [sessionId, store]);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/**
* Shows what OpenCode reported when it stopped a turn without producing a
* reply. Rendered under the last message, only while that turn is the latest
* one: sending again moves the last message past the error and hides it.
*/
export const SessionErrorNotice: React.FC<SessionErrorNoticeProps> = ({ sessionId, directory }) => {
const { t } = useI18n();
const latestError = useLatestSessionError(sessionId);
const status = useSessionStatus(sessionId, directory);
const lastMessage = useLastMessageState(sessionId, directory);
const isIdle = !status || status.type === 'idle';
const reportedError = latestError && isIdle
&& (!lastMessage || latestError.time >= lastMessage.timestamp)
&& !(lastMessage?.role === 'assistant' && lastMessage.hasError)
? latestError
: null;
// A user message that the session is idle on, with nothing after it for a
// while, is a reply that never began: the send was accepted but OpenCode
// produced neither a message nor an error for it.
const unansweredSince = !reportedError && isIdle && lastMessage?.role === 'user' ? lastMessage.timestamp : null;
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (unansweredSince === null) return undefined;
const remaining = UNANSWERED_AFTER_MS - (Date.now() - unansweredSince);
if (remaining <= 0) return undefined;
const timer = window.setTimeout(() => setNow(Date.now()), remaining + 50);
return () => window.clearTimeout(timer);
}, [unansweredSince]);
const unanswered = unansweredSince !== null && Math.max(now, Date.now()) - unansweredSince >= UNANSWERED_AFTER_MS;
if (!reportedError && !unanswered) return null;
const detail = reportedError
? (reportedError.error?.message ?? t('chat.sessionError.noDetails'))
: t('chat.sessionError.noDetails');
const name = reportedError?.error?.name;
return (
<div className="chat-message-column">
<div
role="status"
className="mt-3 max-w-full break-words rounded-2xl border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3 text-base leading-relaxed"
>
<div className="flex items-start gap-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-error)]" />
<div className="min-w-0 flex-1 break-words">
<div className="font-medium text-foreground">{reportedError ? t('chat.sessionError.title') : t('chat.sessionError.noReply')}</div>
<div className="mt-1 text-foreground/80">{name ? `${name}: ${detail}` : detail}</div>
</div>
</div>
</div>
</div>
);
};
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { useSessionAssistState } from '@/hooks/useSessionAssist'; import { useSessionAssistState } from '@/hooks/useSessionAssist';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { TimelineRevealGateContext } from '@/components/chat/timelineRevealGate';
interface SessionRecapNoteProps { interface SessionRecapNoteProps {
sessionId: string; sessionId: string;
@@ -12,8 +13,17 @@ interface SessionRecapNoteProps {
// the last message (above the reserved bottom gap). Appears only after the // the last message (above the reserved bottom gap). Appears only after the
// 1-minute quiet window, so the layout shift happens off-screen in practice. // 1-minute quiet window, so the layout shift happens off-screen in practice.
export const SessionRecapNote: React.FC<SessionRecapNoteProps> = React.memo(({ sessionId, directory, isMobile }) => { export const SessionRecapNote: React.FC<SessionRecapNoteProps> = React.memo(({ sessionId, directory, isMobile }) => {
const { visibleRecap } = useSessionAssistState(sessionId, directory); const { visibleRecap, sessionKnown } = useSessionAssistState(sessionId, directory);
const { t } = useI18n(); const { t } = useI18n();
// The recap is part of the opened session's finished picture: until the
// session record is in memory it cannot be decided, and appearing a commit
// later would grow the footer under a viewport already pinned to the end.
const revealGate = React.useContext(TimelineRevealGateContext);
React.useLayoutEffect(() => {
if (sessionKnown) return undefined;
const release = revealGate?.hold();
return release ?? undefined;
}, [revealGate, sessionKnown]);
if (!visibleRecap) { if (!visibleRecap) {
return null; return null;
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils'; import { cn, fuzzyMatch } from '@/lib/utils';
import { useSkillsStore } from '@/stores/useSkillsStore'; import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
@@ -38,13 +39,16 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
const keyboardNavigationRef = React.useRef(false); const keyboardNavigationRef = React.useRef(false);
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]); const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const skills = useSkillsStore((s) => s.skills); // Skills of the directory the composer sends to (session directory, or the
// Chats root for a chat draft), not of the project the app was on last.
const effectiveDirectory = useEffectiveDirectory();
const skills = useSkillsStore((s) => selectSkillsForDirectory(s, effectiveDirectory));
const loadSkills = useSkillsStore((s) => s.loadSkills); const loadSkills = useSkillsStore((s) => s.loadSkills);
React.useEffect(() => { React.useEffect(() => {
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh // Always trigger loadSkills when autocomplete opens to ensure the directory's skills are fresh
void loadSkills(); void loadSkills(effectiveDirectory);
}, [loadSkills]); }, [effectiveDirectory, loadSkills]);
React.useEffect(() => { React.useEffect(() => {
const normalizedQuery = searchQuery.trim(); const normalizedQuery = searchQuery.trim();
@@ -138,14 +138,27 @@ const buildMaterializedSubagentSession = () => {
return { messages, part }; return { messages, part };
}; };
const syncContext = (globalThis as unknown as { // SAFETY: sync-context.tsx publishes exactly these two keys on globalThis
// (SYNC_CONTEXT_GLOBAL_KEY / SYNC_RUNTIME_CONTEXT_GLOBAL_KEY) so every module
// instance shares one context identity; the cast only adds those two optional
// keys to the global object type, and the guards below re-check presence.
const syncGlobals = globalThis as {
__openchamber_sync_context__?: React.Context<unknown>; __openchamber_sync_context__?: React.Context<unknown>;
}).__openchamber_sync_context__; __openchamber_sync_runtime_context__?: React.Context<unknown>;
};
const syncContext = syncGlobals.__openchamber_sync_context__;
if (!syncContext) { if (!syncContext) {
throw new Error('sync context was not published on globalThis by @/sync/sync-context'); throw new Error('sync context was not published on globalThis by @/sync/sync-context');
} }
const syncRuntimeContext = syncGlobals.__openchamber_sync_runtime_context__;
if (!syncRuntimeContext) {
throw new Error('sync runtime context was not published on globalThis by @/sync/sync-context');
}
describe('issue #2903 busy embedded subagent status-line-only', () => { describe('issue #2903 busy embedded subagent status-line-only', () => {
test('cold disabled reads hide a fully materialized 14-message subagent; enabled reads return all 14', async () => { test('cold disabled reads hide a fully materialized 14-message subagent; enabled reads return all 14', async () => {
const dom = installMinimalDom(); const dom = installMinimalDom();
@@ -173,7 +186,16 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
}); });
const system = { childStores, messageLoader: {}, sdk: {}, runtimeKey: 'test', directory: DIRECTORY }; const system = { childStores, messageLoader: {}, sdk: {}, runtimeKey: 'test', directory: DIRECTORY };
const Provider = syncContext.Provider as React.Provider<unknown>; // Mirrors SyncProvider's own nesting: system context outer, runtime inner.
// Directory-scoped hooks read the runtime context, so the harness must
// provide it with a currentDirectory source for the store lookups.
const runtime = {
childStores,
messageLoader: {},
sdk: {},
runtimeKey: 'test',
currentDirectory: { get: () => DIRECTORY, subscribe: () => () => undefined },
};
let inactiveCount = -1; let inactiveCount = -1;
let activeCount = -1; let activeCount = -1;
let enabled = false; let enabled = false;
@@ -188,15 +210,22 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
return null; return null;
}; };
const renderHarness = () =>
React.createElement(
syncContext.Provider,
{ value: system },
React.createElement(syncRuntimeContext.Provider, { value: runtime }, React.createElement(Harness)),
);
try { try {
await act(async () => { await act(async () => {
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness))); root.render(renderHarness());
}); });
expect(inactiveCount).toBe(0); expect(inactiveCount).toBe(0);
enabled = true; enabled = true;
await act(async () => { await act(async () => {
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness))); root.render(renderHarness());
}); });
expect(activeCount).toBe(14); expect(activeCount).toBe(14);
} finally { } finally {
@@ -0,0 +1,18 @@
import React from 'react';
/**
* The session the chat column is showing — the deferred selection the
* timeline renders, not the live store value. The composer and everything
* stacked with the timeline read it so the column changes as one: a session
* click publishes the live selection first, and a composer that followed it
* would change height (changed-files row, todos, queued chips) while the
* outgoing timeline is still on screen, shoving that timeline before the swap.
*/
export type ChatColumnSession = {
sessionId: string | null;
directory: string | null;
};
export const ChatColumnSessionContext = React.createContext<ChatColumnSession | null>(null);
export const useChatColumnSession = (): ChatColumnSession | null => React.useContext(ChatColumnSessionContext);
@@ -33,6 +33,7 @@ export interface MobileComposerHolders {
draftPickerOpen: boolean; draftPickerOpen: boolean;
issuePickerOpen: boolean; issuePickerOpen: boolean;
prPickerOpen: boolean; prPickerOpen: boolean;
linearPickerOpen: boolean;
isDragging: boolean; isDragging: boolean;
} }
@@ -204,7 +205,8 @@ export function useMobileComposerShell(
|| holders.controlsPanelOpen || holders.controlsPanelOpen
|| holders.attachMenuOpen || holders.attachMenuOpen
|| holders.issuePickerOpen || holders.issuePickerOpen
|| holders.prPickerOpen; || holders.prPickerOpen
|| holders.linearPickerOpen;
// Installed PWA (standalone): a focus() from a bare timeout is outside the // Installed PWA (standalone): a focus() from a bare timeout is outside the
// user gesture and iOS refuses to raise the keyboard for it (Safari // user gesture and iOS refuses to raise the keyboard for it (Safari
@@ -212,7 +214,7 @@ export function useMobileComposerShell(
// 'oc:mobile-overlay-closed' synchronously from the same React flush as the // 'oc:mobile-overlay-closed' synchronously from the same React flush as the
// click that closed it — refocus right there, while the gesture is live. // click that closed it — refocus right there, while the gesture is live.
const pickerDialogsOpenRef = React.useRef(false); const pickerDialogsOpenRef = React.useRef(false);
pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen; pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen || holders.linearPickerOpen;
const skipNextCloseRestoreRef = React.useRef(false); const skipNextCloseRestoreRef = React.useRef(false);
const openSheetCountRef = React.useRef(0); const openSheetCountRef = React.useRef(0);
const holdFocusUntilRef = React.useRef(0); const holdFocusUntilRef = React.useRef(0);
@@ -307,6 +309,7 @@ export function useMobileComposerShell(
|| holders.draftPickerOpen || holders.draftPickerOpen
|| holders.issuePickerOpen || holders.issuePickerOpen
|| holders.prPickerOpen || holders.prPickerOpen
|| holders.linearPickerOpen
|| holders.isDragging; || holders.isDragging;
React.useEffect(() => { React.useEffect(() => {
@@ -40,6 +40,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
syntheticTexts: [], syntheticTexts: [],
linkedIssue: null, linkedIssue: null,
linkedPr: null, linkedPr: null,
linkedLinearIssue: null,
...overrides, ...overrides,
}); });
@@ -203,6 +204,17 @@ describe('synthetic context', () => {
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' }); .toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
}); });
test('a linked Linear issue is sent as context', () => {
const result = buildOutgoingMessage(input({
composerText: 'fix it',
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear body' },
}), deps());
expect(result.additionalParts).toHaveLength(1);
expect(result.additionalParts[0].text).toBe('linear body');
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
.toEqual({ kind: 'linear-issue', identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12' });
});
test('synthetic texts precede the linked references', () => { test('synthetic texts precede the linked references', () => {
const result = buildOutgoingMessage(input({ const result = buildOutgoingMessage(input({
composerText: 'x', composerText: 'x',
@@ -255,6 +267,7 @@ describe('full assembly order', () => {
syntheticTexts: ['synthetic'], syntheticTexts: ['synthetic'],
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' }, linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' }, linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear' },
}), deps()); }), deps());
expect(result.primaryText).toBe('q1'); expect(result.primaryText).toBe('q1');
@@ -265,6 +278,7 @@ describe('full assembly order', () => {
'issue', 'issue',
'pr-how', 'pr-how',
'pr-diff', 'pr-diff',
'linear',
'use: deploy', 'use: deploy',
]); ]);
}); });
@@ -53,6 +53,7 @@ export interface OutgoingMessageInput {
syntheticTexts: readonly string[]; syntheticTexts: readonly string[];
linkedIssue: { number: number; title: string; url: string; contextText: string } | null; linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null; linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
linkedLinearIssue: { identifier: string; title: string; url: string; contextText: string } | null;
} }
/** /**
@@ -161,6 +162,11 @@ export function buildOutgoingMessage(
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context)); additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
} }
if (input.linkedLinearIssue) {
const { identifier, title, url, contextText } = input.linkedLinearIssue;
additionalParts.push(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText));
}
const skillInstruction = deps.buildSkillInstruction(skillNames); const skillInstruction = deps.buildSkillInstruction(skillNames);
if (skillInstruction) { if (skillInstruction) {
additionalParts.push({ text: skillInstruction, synthetic: true }); additionalParts.push({ text: skillInstruction, synthetic: true });
@@ -29,6 +29,8 @@ type ComposerAttachmentControlsProps = {
openPrPicker: () => void; openPrPicker: () => void;
/** Shows the GitHub issue/PR or GitLab issue/MR attach actions based on the repo provider. */ /** Shows the GitHub issue/PR or GitLab issue/MR attach actions based on the repo provider. */
gitProvider?: GitProvider | null; gitProvider?: GitProvider | null;
showLinearPicker?: boolean;
openLinearPicker?: () => void;
onOpenSettings?: () => void; onOpenSettings?: () => void;
onMenuOpenChange?: (open: boolean) => void; onMenuOpenChange?: (open: boolean) => void;
/** Mobile: open the attachment bottom sheet instead of the dropdown menu. */ /** Mobile: open the attachment bottom sheet instead of the dropdown menu. */
@@ -45,6 +47,8 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
openIssuePicker, openIssuePicker,
openPrPicker, openPrPicker,
gitProvider, gitProvider,
showLinearPicker,
openLinearPicker,
onOpenSettings, onOpenSettings,
} = props; } = props;
@@ -160,6 +164,16 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
</DropdownMenuItem> </DropdownMenuItem>
</> </>
) : null} ) : null}
{showLinearPicker && openLinearPicker ? (
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(openLinearPicker);
}}
>
<Icon name="linear"/>
{t('chat.chatInput.actions.linkLinearIssue')}
</DropdownMenuItem>
) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -183,6 +197,8 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
&& prev.footerIconButtonClass === next.footerIconButtonClass && prev.footerIconButtonClass === next.footerIconButtonClass
&& prev.iconSizeClass === next.iconSizeClass && prev.iconSizeClass === next.iconSizeClass
&& prev.gitProvider === next.gitProvider && prev.gitProvider === next.gitProvider
&& prev.showLinearPicker === next.showLinearPicker
&& prev.openLinearPicker === next.openLinearPicker
&& prev.onOpenSettings === next.onOpenSettings && prev.onOpenSettings === next.onOpenSettings
&& prev.onMenuOpenChange === next.onMenuOpenChange && prev.onMenuOpenChange === next.onMenuOpenChange
&& prev.onOpenMobileSheet === next.onOpenMobileSheet && prev.onOpenMobileSheet === next.onOpenMobileSheet
@@ -136,7 +136,7 @@ const DraftPreviewEntry: React.FC<{
aria-label={t('chat.chatInput.contextPreview.remove')} aria-label={t('chat.chatInput.contextPreview.remove')}
title={t('chat.chatInput.contextPreview.remove')} title={t('chat.chatInput.contextPreview.remove')}
> >
<Icon name="close" className="h-3 w-3" /> <Icon name="delete-bin" className="h-3 w-3" />
</button> </button>
</div> </div>
<div className="space-y-2 px-3 py-2"> <div className="space-y-2 px-3 py-2">
@@ -56,6 +56,8 @@ export interface ComposerFooterProps {
onPickLocalFiles: () => void; onPickLocalFiles: () => void;
onOpenIssuePicker: () => void; onOpenIssuePicker: () => void;
onOpenPrPicker: () => void; onOpenPrPicker: () => void;
showLinearPicker?: boolean;
onOpenLinearPicker?: () => void;
onOpenAttachSheet: () => void; onOpenAttachSheet: () => void;
onToggleExpandedInput: () => void; onToggleExpandedInput: () => void;
onTogglePermissionAutoAccept: () => void; onTogglePermissionAutoAccept: () => void;
@@ -95,6 +97,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
onPickLocalFiles, onPickLocalFiles,
onOpenIssuePicker, onOpenIssuePicker,
onOpenPrPicker, onOpenPrPicker,
showLinearPicker,
onOpenLinearPicker,
onOpenAttachSheet, onOpenAttachSheet,
onToggleExpandedInput, onToggleExpandedInput,
onTogglePermissionAutoAccept, onTogglePermissionAutoAccept,
@@ -134,6 +138,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
openIssuePicker={onOpenIssuePicker} openIssuePicker={onOpenIssuePicker}
openPrPicker={onOpenPrPicker} openPrPicker={onOpenPrPicker}
gitProvider={gitProvider} gitProvider={gitProvider}
showLinearPicker={showLinearPicker}
openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
onOpenMobileSheet={onOpenAttachSheet} onOpenMobileSheet={onOpenAttachSheet}
/> />
@@ -204,6 +210,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
openIssuePicker={onOpenIssuePicker} openIssuePicker={onOpenIssuePicker}
openPrPicker={onOpenPrPicker} openPrPicker={onOpenPrPicker}
gitProvider={gitProvider} gitProvider={gitProvider}
showLinearPicker={showLinearPicker}
openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
/> />
<FocusModeButton <FocusModeButton
@@ -39,6 +39,8 @@ export interface MobilePillComposerProps {
onPickLocalFiles: () => void; onPickLocalFiles: () => void;
onOpenIssuePicker: () => void; onOpenIssuePicker: () => void;
onOpenPrPicker: () => void; onOpenPrPicker: () => void;
showLinearPicker?: boolean;
onOpenLinearPicker?: () => void;
onOpenAttachSheet: () => void; onOpenAttachSheet: () => void;
onStartDictation: () => void; onStartDictation: () => void;
onAbort: () => void; onAbort: () => void;
@@ -64,6 +66,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
onPickLocalFiles, onPickLocalFiles,
onOpenIssuePicker, onOpenIssuePicker,
onOpenPrPicker, onOpenPrPicker,
showLinearPicker,
onOpenLinearPicker,
onOpenAttachSheet, onOpenAttachSheet,
onStartDictation, onStartDictation,
onAbort, onAbort,
@@ -99,6 +103,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
openIssuePicker={onOpenIssuePicker} openIssuePicker={onOpenIssuePicker}
openPrPicker={onOpenPrPicker} openPrPicker={onOpenPrPicker}
gitProvider={gitProvider} gitProvider={gitProvider}
showLinearPicker={showLinearPicker}
openLinearPicker={onOpenLinearPicker}
onOpenMobileSheet={onOpenAttachSheet} onOpenMobileSheet={onOpenAttachSheet}
/> />
<button <button
@@ -56,4 +56,12 @@ describe('messagePreview', () => {
const parts = [contextPart(chatQuote('quoted bit'), 'raw model text')] const parts = [contextPart(chatQuote('quoted bit'), 'raw model text')]
expect(getPromptPreviewText(parts)).toBe('raw model text') expect(getPromptPreviewText(parts)).toBe('raw model text')
}) })
test('labels a Linear issue attachment from its identifier and title', () => {
const parts = [contextPart(
{ kind: 'linear-issue', identifier: 'ENG-12', title: 'Fix login', url: 'https://linear.app/eng-12' },
'fetched issue body',
)]
expect(getPromptPreviewText(parts, t)).toBe('ENG-12 Fix login')
})
}) })
@@ -57,6 +57,8 @@ const contextSummary = (payload: ContextPartPayload, t: Translate): string => {
return `#${payload.number} ${payload.title}`; return `#${payload.number} ${payload.title}`;
case 'github-pr': case 'github-pr':
return `#${payload.number} ${payload.title}`; return `#${payload.number} ${payload.title}`;
case 'linear-issue':
return `${payload.identifier} ${payload.title}`;
} }
}; };
@@ -78,6 +80,7 @@ const contextBody = (payload: ContextPartPayload): string => {
return payload.quote; return payload.quote;
case 'github-issue': case 'github-issue':
case 'github-pr': case 'github-pr':
case 'linear-issue':
return ''; return '';
} }
}; };
@@ -318,3 +318,41 @@ describe('CJK-aware link parsing', () => {
expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url'); expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url');
}); });
}); });
describe('Escaped brackets versus display math', () => {
// `\[...\]` is display math in LaTeX and an escaped bracket pair in
// CommonMark. Prose escapes brackets far more often than it opens display
// math mid-sentence, so math only wins when it owns its line.
test('keeps escaped brackets inside a link as link text', () => {
const html = renderMarkdownSync(
'[OpenChamber session completed: OPE-316 \\[Bug\\] Opening files](https://example.com/?session=ses_1)',
);
expect(html).toContain('href="https://example.com/?session=ses_1"');
expect(html).toContain('[Bug]');
expect(html).not.toContain('katex');
});
test('leaves escaped brackets in prose as literal brackets', () => {
const html = renderMarkdownSync('Release \\[Bug\\] fixed in v2.');
expect(html).toContain('[Bug]');
expect(html).not.toContain('katex');
});
// Verbatim body of a Linear status comment, which Linear itself renders as
// one link while we used to split it into three blocks.
test('renders a Linear comment with an escaped-bracket title as one link', () => {
const html = renderMarkdownSync(
'[OpenChamber session completed: OPE-316 \\[Bug\\] Opening files with template-literal'
+ ' code triggers catastrophic backtracking → renderer OOM → black/frozen desktop app'
+ ' (v1.17.2)](http://127.0.0.1:63418/?session=ses_fb0bb916effe26bQ1Ofr6Rv4Ei)',
);
expect(html.match(/<a /g)).toHaveLength(1);
expect(html).toContain('[Bug]');
expect(html).not.toContain('katex');
});
test('still renders display math that owns its line', () => {
expect(renderMarkdownSync('\\[x = y\\]')).toContain('katex');
expect(renderMarkdownSync('Before\n\n\\[\nx = y\n\\]\n\nAfter')).toContain('katex');
});
});
@@ -314,15 +314,25 @@ const inlineMathExtension = {
}, },
}; };
// `\[` is display math in LaTeX, but it is also CommonMark's escape for a
// literal `[`, and prose escapes brackets far more often than it opens display
// math. Reading every `\[` as math turned text like
// `[title \[Bug\] more](url)` into a KaTeX block that split the paragraph and
// tore the link apart. Display math therefore has to own its line: it must
// start one and its `\]` must end one. Anything mid-sentence stays an escape.
const BLOCK_MATH_RE = /^[ \t]*\\\[([\s\S]+?)\\\][ \t]*(?:\n|$)/;
const BLOCK_MATH_LINE_START_RE = /(?:^|\n)[ \t]*\\\[/;
const blockMathExtension = { const blockMathExtension = {
name: 'blockMath', name: 'blockMath',
level: 'block' as const, level: 'block' as const,
start(src: string) { start(src: string) {
const index = src.indexOf('\\['); const match = BLOCK_MATH_LINE_START_RE.exec(src);
return index < 0 ? undefined : index; // Point marked at the `\[` itself, never at the newline before it.
return match ? match.index + match[0].length - 2 : undefined;
}, },
tokenizer(src: string): MathToken | undefined { tokenizer(src: string): MathToken | undefined {
const match = /^\\\[([\s\S]+?)\\\]/.exec(src); const match = BLOCK_MATH_RE.exec(src);
if (!match) return undefined; if (!match) return undefined;
return { type: 'blockMath', raw: match[0], text: match[1] ?? '' }; return { type: 'blockMath', raw: match[0], text: match[1] ?? '' };
}, },
@@ -1,13 +1,31 @@
let markdownRendererModulePromise: Promise<typeof import('./MarkdownRendererImpl')> | null = null; type MarkdownRendererModule = typeof import('./MarkdownRendererImpl');
let markdownRendererModulePromise: Promise<MarkdownRendererModule> | null = null;
let markdownRendererModule: MarkdownRendererModule | null = null;
export const loadMarkdownRendererModule = () => { export const loadMarkdownRendererModule = () => {
markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => { markdownRendererModulePromise ??= import('./MarkdownRendererImpl')
.then((module) => {
markdownRendererModule = module;
return module;
})
.catch((error) => {
markdownRendererModulePromise = null; markdownRendererModulePromise = null;
throw error; throw error;
}); });
return markdownRendererModulePromise; return markdownRendererModulePromise;
}; };
/**
* The module once it has loaded, so a renderer can mount synchronously instead
* of suspending. A lazy component that suspends — even on an already-resolved
* promise — shows its fallback for a tick, and React then throttles the reveal
* of every boundary that resolves in the following ~300ms, which is how a
* freshly opened session showed user text first and assistant text a third of
* a second later.
*/
export const getLoadedMarkdownRendererModule = () => markdownRendererModule;
export const preloadMarkdownRenderer = () => { export const preloadMarkdownRenderer = () => {
void loadMarkdownRendererModule().catch(() => undefined); void loadMarkdownRendererModule().catch(() => undefined);
}; };
@@ -10,6 +10,9 @@ import {
startsWithForgeContextPrefix, startsWithForgeContextPrefix,
} from '@/lib/messages/synthetic'; } from '@/lib/messages/synthetic';
const LINEAR_ISSUE_CONTEXT_PREFIX = 'Linear issue context (JSON)';
type IssueContextPayload = { type IssueContextPayload = {
issue?: { issue?: {
number?: unknown; number?: unknown;
@@ -26,6 +29,14 @@ type GitHubPrContextPayload = {
}; };
}; };
type LinearIssueContextPayload = {
issue?: {
identifier?: unknown;
title?: unknown;
url?: unknown;
};
};
type GitLabMrContextPayload = { type GitLabMrContextPayload = {
mr?: { mr?: {
number?: unknown; number?: unknown;
@@ -101,6 +112,25 @@ const buildForgeAttachmentPart = (text: string): Part | null => {
} as Part; } as Part;
} }
// Linear issues
const linearPayload = parseSyntheticJsonPayload<LinearIssueContextPayload>(text, LINEAR_ISSUE_CONTEXT_PREFIX);
if (linearPayload) {
const issue = linearPayload.issue;
const identifier = issue?.identifier;
const title = issue?.title;
const url = issue?.url;
if (typeof identifier !== 'string' || identifier.trim().length === 0 || typeof title !== 'string' || typeof url !== 'string') {
return null;
}
return {
type: 'file',
mime: 'application/vnd.openchamber.linear-issue-link',
filename: `${identifier}: ${title}`,
url,
} as Part;
}
// GitLab issues // GitLab issues
const glIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITLAB_ISSUE_CONTEXT_PREFIX); const glIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITLAB_ISSUE_CONTEXT_PREFIX);
if (glIssuePayload) { if (glIssuePayload) {
@@ -199,7 +229,8 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
const normalizedText = text.trimStart(); const normalizedText = text.trimStart();
return shouldKeepSyntheticUserText(text, planModeEnabled) return shouldKeepSyntheticUserText(text, planModeEnabled)
|| startsWithForgeContextPrefix(normalizedText); || startsWithForgeContextPrefix(normalizedText)
|| normalizedText.startsWith(LINEAR_ISSUE_CONTEXT_PREFIX);
}) })
.map((part) => { .map((part) => {
const rawPart = part as Record<string, unknown>; const rawPart = part as Record<string, unknown>;
@@ -212,10 +243,18 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
if (synthetic) { if (synthetic) {
const contextPayload = readContextPart(part); const contextPayload = readContextPart(part);
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') { if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr' || contextPayload?.kind === 'linear-issue') {
// SAFETY: same display-only file-part shape the legacy // SAFETY: same display-only file-part shape the legacy
// buildGitHubAttachmentPart produces; consumed by // buildGitHubAttachmentPart produces; consumed by
// FileAttachment, which matches on the mime type. // FileAttachment, which matches on the mime type.
if (contextPayload.kind === 'linear-issue') {
return {
type: 'file',
mime: 'application/vnd.openchamber.linear-issue-link',
filename: `${contextPayload.identifier}: ${contextPayload.title}`,
url: contextPayload.url,
} as Part;
}
return { return {
type: 'file', type: 'file',
mime: contextPayload.kind === 'github-issue' mime: contextPayload.kind === 'github-issue'
@@ -127,10 +127,11 @@ Why: only navigation tools use the compact static path; all other tools need obs
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart` annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
routes to it when the part's metadata carries an `openchamberContext` routes to it when the part's metadata carries an `openchamberContext`
payload (see `lib/messages/contextParts.ts`, which owns both the send-time payload (see `lib/messages/contextParts.ts`, which owns both the send-time
builder and the read-back parser). Linked GitHub issues/PRs are instead builder and the read-back parser). Linked GitHub issues/PRs and Linear
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy issues are instead converted to link file-parts in
pre-metadata messages still render via text sniffing (`<terminal_context>` `normalizeUserDisplayParts.ts`. Legacy pre-metadata messages still render
blocks, `GitHub issue context (JSON)` prefixes). via text sniffing (`<terminal_context>` blocks, `GitHub issue context (JSON)`
and `Linear issue context (JSON)` prefixes).
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx` - Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx` - Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx` - Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
@@ -185,6 +185,7 @@ const UserContextPart: React.FC<{
); );
case 'github-issue': case 'github-issue':
case 'github-pr': case 'github-pr':
case 'linear-issue':
// Rendered as link attachments by normalizeUserDisplayParts. // Rendered as link attachments by normalizeUserDisplayParts.
return null; return null;
} }
@@ -0,0 +1,54 @@
import React from 'react';
/**
* Coordinates the first paint of a freshly opened session so the timeline
* appears as one finished picture instead of arriving in pieces.
*
* Renderers that mount with a provisional paint (markdown whose blocks are not
* in the settled cache yet, so code is unhighlighted) take a hold while they
* catch up. The timeline stays invisible while any hold is open, then reveals
* everything at once. The gate accepts holds only during the opening commit:
* rows that mount later, while scrolling, must never hide the timeline.
*
* A hold that never releases must not hide the chat forever, so the owner
* reveals after `TIMELINE_REVEAL_CAP_MS` regardless.
*/
export type TimelineRevealGate = {
/** Take a hold; returns the release. Returns null once the gate is closed. */
hold: () => (() => void) | null;
/** Stops accepting holds. Existing holds still count. */
close: () => void;
readonly holds: number;
/** Called when the last hold releases, if the gate is closed by then. */
onEmpty: (() => void) | null;
};
export const TIMELINE_REVEAL_CAP_MS = 250;
export const createTimelineRevealGate = (): TimelineRevealGate => {
let holds = 0;
let accepting = true;
const gate: TimelineRevealGate = {
hold: () => {
if (!accepting) return null;
holds += 1;
let released = false;
return () => {
if (released) return;
released = true;
holds -= 1;
if (holds === 0 && !accepting) gate.onEmpty?.();
};
},
close: () => {
accepting = false;
},
get holds() {
return holds;
},
onEmpty: null,
};
return gate;
};
export const TimelineRevealGateContext = React.createContext<TimelineRevealGate | null>(null);
@@ -95,11 +95,11 @@ which requests only providers enabled for this panel.
| Block | Source | Notes | | Block | Source | Notes |
|---|---|---| |---|---|---|
| Context + cost | `contextUsage.ts` over `useSessionMessages`; cost via `useSubagentCostRollup` (own cost + every descendant subagent, recursively) | see below — the store getters cannot serve this | | Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this |
| Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints | | Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints |
| Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits | | Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits |
| PR + checks | `useFreshestPrVisualSummaryForBranch` | **read-only**; follows the freshest remote-keyed entry for the branch | | PR + checks | `useFreshestPrVisualSummaryForBranch` | **read-only**; follows the freshest remote-keyed entry for the branch |
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses`; per-row cost from `useSubagentCostRollup`'s `perChildCost` (each child's own subtree total, so nested subagent-of-subagent cost rolls up under its immediate parent row) | | | Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child | | Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not | | Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
| Linked threads | `lib/linkedIssues.ts` over session metadata | written by the flows that attach an issue or PR | | Linked threads | `lib/linkedIssues.ts` over session metadata | written by the flows that attach an issue or PR |
@@ -308,8 +308,8 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
// The heading names what is distinctive about this session when there is // The heading names what is distinctive about this session when there is
// something — an attached thread — and falls back to the ambient counts // something — an attached thread — and falls back to the ambient counts
// when there is not. `1 · 33 · 2` said nothing without opening the section. // when there is not. `1 · 33 · 2` said nothing without opening the section.
const issueCount = linked.filter((entry) => entry.kind === 'issue').length; const issueCount = linked.filter((entry) => entry.kind === 'issue' || entry.kind === 'linear').length;
const prCount = linked.length - issueCount; const prCount = linked.filter((entry) => entry.kind === 'pull').length;
const summaryParts: string[] = []; const summaryParts: string[] = [];
if (issueCount > 0) { if (issueCount > 0) {
summaryParts.push(issueCount === 1 summaryParts.push(issueCount === 1
@@ -13,6 +13,8 @@ type Props = {
directory: string | null; directory: string | null;
}; };
const MCP_STATUS_MAX_AGE_MS = 60_000;
/** /**
* MCP servers with their connection switches, reusing the dropdown's own * MCP servers with their connection switches, reusing the dropdown's own
* connect/disconnect actions. * connect/disconnect actions.
@@ -23,17 +25,19 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
const mcpStatus = useMcpStore( const mcpStatus = useMcpStore(
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]), React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
); );
const refreshMcp = useMcpStore((state) => state.refresh); const ensureMcpFresh = useMcpStore((state) => state.ensureFresh);
const connect = useMcpStore((state) => state.connect); const connect = useMcpStore((state) => state.connect);
const disconnect = useMcpStore((state) => state.disconnect); const disconnect = useMcpStore((state) => state.disconnect);
const [busyServer, setBusyServer] = React.useState<string | null>(null); const [busyServer, setBusyServer] = React.useState<string | null>(null);
// The panel must not depend on the header dropdown having been mounted or // The panel must not depend on the header dropdown having been mounted or
// opened to know its MCP servers. Silent and background-gated, so it cannot // opened to know its MCP servers. Silent and background-gated, so it cannot
// compete with chat bootstrap traffic for sockets. // compete with chat bootstrap traffic for sockets. The section remounts on
// every session switch, so it only asks for a status that is missing or
// older than a minute; connect/disconnect/auth refresh on their own.
React.useEffect(() => { React.useEffect(() => {
void runBackgroundNetworkTask(() => refreshMcp({ directory, silent: true })); void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS }));
}, [directory, refreshMcp]); }, [directory, ensureMcpFresh]);
const mcpServers = React.useMemo( const mcpServers = React.useMemo(
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)), () => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
@@ -3,11 +3,11 @@ import { useI18n } from '@/lib/i18n';
import { useGitStore } from '@/stores/useGitStore'; import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { runBackgroundNetworkTask } from '@/lib/background-network'; import { runBackgroundNetworkTask } from '@/lib/background-network';
import { useGitProvider } from '@/lib/gitProvider'; import { getGitHubPrStatusKey, usePrVisualSummary, useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus'; import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
import { useGiteaPrForBranch } from '@/lib/giteaPrStatus'; import { useGiteaPrForBranch } from '@/lib/giteaPrStatus';
import { useSessionMessages } from '@/sync/sync-context'; import { useGitProvider } from '@/lib/gitProvider';
import { useSession, useSessionMessages } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -17,8 +17,6 @@ import { resolveUsageTone } from '@/lib/quota';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { normalizePath } from '@/lib/pathNormalization'; import { normalizePath } from '@/lib/pathNormalization';
import { computeContextUsage } from './contextUsage'; import { computeContextUsage } from './contextUsage';
import { formatCost } from './subagentCost';
import { useSubagentCostRollup } from './useSubagentCostRollup';
import { import {
WorkStatusCallout, WorkStatusCallout,
WorkStatusMeter, WorkStatusMeter,
@@ -38,6 +36,11 @@ type Props = {
showRepository: boolean; showRepository: boolean;
}; };
// Spend is read against a budget, so it keeps its real precision instead of
// collapsing to two decimals. Trailing zeros are dropped so exact values stay
// short.
const trimZeros = (value: string): string => (value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value);
const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4))}`;
// Matches the header readout exactly: one decimal, capped the same way, so the // Matches the header readout exactly: one decimal, capped the same way, so the
// two places that report context fill never disagree by a rounding step. // two places that report context fill never disagree by a rounding step.
const formatPercent = (percent: number): string => `${Math.min(percent, 999).toFixed(1)}%`; const formatPercent = (percent: number): string => `${Math.min(percent, 999).toFixed(1)}%`;
@@ -49,6 +52,7 @@ const formatPercent = (percent: number): string => `${Math.min(percent, 999).toF
*/ */
export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory, goalRow, showSession, showRepository }) => { export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory, goalRow, showSession, showRepository }) => {
const { t } = useI18n(); const { t } = useI18n();
const session = useSession(sessionId ?? '', directory ?? undefined);
const { git } = useRuntimeAPIs(); const { git } = useRuntimeAPIs();
const ensureStatus = useGitStore((state) => state.ensureStatus); const ensureStatus = useGitStore((state) => state.ensureStatus);
const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchStatus = useGitStore((state) => state.fetchStatus);
@@ -201,16 +205,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
: usageTone === 'warn' ? 'var(--status-warning)' : usageTone === 'warn' ? 'var(--status-warning)'
: 'var(--status-success)'; : 'var(--status-success)';
// Rollup total: own cost plus every descendant subagent's cost, recursively const cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null;
// (see useSubagentCostRollup). Shown here instead of session.cost alone, so
// spend that ran in a spawned subagent doesn't hide from the reader.
const { totalCost, ownCost, subagentCost, subagentCount } = useSubagentCostRollup(sessionId);
const cost = totalCost !== null && totalCost > 0 ? totalCost : null;
// The total answers "what has this cost"; the split answers "why is it more
// than the session I am looking at". Only worth a line once subagents exist —
// without them the total *is* the session's own cost and the row would
// restate the number directly above it.
const showCostBreakdown = cost !== null && subagentCount > 0 && subagentCost > 0;
const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow)); const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow));
const hasGitLabMr = gitProvider === 'gitlab' && gitLabMr !== null; const hasGitLabMr = gitProvider === 'gitlab' && gitLabMr !== null;
const hasGiteaPr = gitProvider === 'gitea' && giteaPr !== null; const hasGiteaPr = gitProvider === 'gitea' && giteaPr !== null;
@@ -259,17 +254,6 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
)} )}
/> />
<WorkStatusMeter percent={usagePercent} color={meterColor} /> <WorkStatusMeter percent={usagePercent} color={meterColor} />
{/* Caption, not a row: it explains the figure above it rather
than reporting a reading of its own, so it carries no icon
and no label column. */}
{showCostBreakdown ? (
<p className="mx-1 mb-1 truncate text-[11px] leading-4 text-muted-foreground tabular-nums">
{t('chat.workStatus.cost.breakdown', {
session: formatCost(ownCost),
subagents: formatCost(subagentCost),
})}
</p>
) : null}
</> </>
) : null} ) : null}
{/* Below the context readout: the goal is a standing instruction, {/* Below the context readout: the goal is a standing instruction,
@@ -142,7 +142,9 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
<div <div
className={cn( className={cn(
'app-region-no-drag group/wctl flex h-8 shrink-0 items-center', 'app-region-no-drag group/wctl flex h-8 shrink-0 items-center',
isLeft ? 'mr-1' : 'ml-1', // macOS-style circles keep an edge inset on the right (the header's
// flush pr-0 is a Windows-caption convention, classic style only).
isLeft ? 'mr-1' : 'ml-1 mr-3',
)} )}
aria-label={t('header.windowControls.groupAria')} aria-label={t('header.windowControls.groupAria')}
> >
@@ -207,7 +209,11 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
type="button" type="button"
className={cn( className={cn(
buttonClassName, buttonClassName,
'hover:bg-[var(--status-error-background)] hover:text-[var(--status-error-foreground)]', // Hover pairs the solid error red with its authored on-red
// foreground (the --destructive pairing). The error-background wash
// is a banner surface tint, not a glyph-button hover: against it the
// on-solid foreground is unreadable in both modes.
'hover:bg-[var(--status-error)] hover:text-[var(--status-error-foreground)]',
)} )}
onClick={() => { void invokeDesktop('desktop_close_current_window'); }} onClick={() => { void invokeDesktop('desktop_close_current_window'); }}
title={t('header.windowControls.close')} title={t('header.windowControls.close')}
@@ -0,0 +1,161 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { GitHubAuthStatus } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { cn } from '@/lib/utils';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
type GitHubAccount = NonNullable<GitHubAuthStatus['accounts']>[number];
const AVATAR_CLASS = 'flex h-6 w-6 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80';
const activateAccount = async (
github: ReturnType<typeof useRuntimeAPIs>['github'],
accountId: string,
): Promise<GitHubAuthStatus> => {
if (github) {
return github.authActivate(accountId);
}
const response = await runtimeFetch('/api/github/auth/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ accountId }),
});
// SAFETY: the route is ours and answers the auth status shape (plus an
// `error` string on failure) on every response; a non-ok status throws below.
const body = (await response.json().catch(() => null)) as (GitHubAuthStatus & { error?: string }) | null;
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText);
}
return body;
};
/**
* The connected GitHub account: an avatar, and a switcher when more than one
* account is signed in (OAuth and `gh` CLI logins). Renders nothing while
* GitHub is disconnected — connecting happens in Settings → Integrations.
*/
export const GitHubAccountControl: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useI18n();
const { github } = useRuntimeAPIs();
const status = useGitHubAuthStore((state) => state.status);
const setStatus = useGitHubAuthStore((state) => state.setStatus);
const [isSwitching, setIsSwitching] = React.useState(false);
const switchAccount = React.useCallback(async (accountId: string) => {
if (!accountId || isSwitching) return;
setIsSwitching(true);
try {
setStatus(await activateAccount(github, accountId));
} catch (error) {
console.error('Failed to switch GitHub account:', error);
} finally {
setIsSwitching(false);
}
}, [github, isSwitching, setStatus]);
if (!status?.connected) {
return null;
}
const login = status.user?.login ?? null;
const avatarUrl = status.user?.avatarUrl ?? null;
const accounts: GitHubAccount[] = status.accounts ?? [];
const title = login ? t('header.github.connectedWithLogin', { login }) : t('header.github.connected');
const avatar = avatarUrl ? (
<img
src={avatarUrl}
alt={login ? t('header.github.avatarWithLogin', { login }) : t('header.github.avatar')}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<Icon name="github-fill" className="h-3.5 w-3.5 text-foreground" />
);
if (accounts.length <= 1) {
return (
<div className={cn(AVATAR_CLASS, className)} title={title}>
{avatar}
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(AVATAR_CLASS, 'p-0 hover:ring-2 hover:ring-primary/40 disabled:opacity-50', className)}
title={title}
disabled={isSwitching}
>
{avatar}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">
{t('header.github.accountsTitle')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{accounts.map((account) => {
const accountUser = account.user;
const isCurrent = Boolean(account.current);
const sourceLabel = account.source === 'gh-cli'
? t('header.github.accountSource.cli')
: t('header.github.accountSource.oauth');
return (
<DropdownMenuItem
key={account.id}
className="gap-2"
disabled={isSwitching}
onSelect={() => {
if (!isCurrent) {
void switchAccount(account.id);
}
}}
>
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.login ? t('header.github.avatarWithLogin', { login: accountUser.login }) : t('header.github.avatar')}
className="h-6 w-6 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-border/60 bg-muted">
<Icon name="github-fill" className="h-3 w-3 text-muted-foreground" />
</div>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate typography-ui-label text-foreground">
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
</span>
{accountUser?.login ? (
<span className="truncate typography-micro text-muted-foreground">
<span className="font-mono">{accountUser.login}</span>
<span className="mx-1 opacity-50">·</span>
<span>{sourceLabel}</span>
</span>
) : null}
</span>
{isCurrent ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -234,6 +234,7 @@ export const iconSpriteData = {
"target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`, "target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`,
"target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`, "target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`,
"task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`, "task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`,
"team": `<path d="M12 11C14.7614 11 17 13.2386 17 16V22H15V16C15 14.4023 13.7511 13.0963 12.1763 13.0051L12 13C10.4023 13 9.09634 14.2489 9.00509 15.8237L9 16V22H7V16C7 13.2386 9.23858 11 12 11ZM5.5 14C5.77885 14 6.05009 14.0326 6.3101 14.0942C6.14202 14.594 6.03873 15.122 6.00896 15.6693L6 16L6.0007 16.0856C5.88757 16.0456 5.76821 16.0187 5.64446 16.0069L5.5 16C4.7203 16 4.07955 16.5949 4.00687 17.3555L4 17.5V22H2V17.5C2 15.567 3.567 14 5.5 14ZM18.5 14C20.433 14 22 15.567 22 17.5V22H20V17.5C20 16.7203 19.4051 16.0796 18.6445 16.0069L18.5 16C18.3248 16 18.1566 16.03 18.0003 16.0852L18 16C18 15.3343 17.8916 14.694 17.6915 14.0956C17.9499 14.0326 18.2211 14 18.5 14ZM5.5 8C6.88071 8 8 9.11929 8 10.5C8 11.8807 6.88071 13 5.5 13C4.11929 13 3 11.8807 3 10.5C3 9.11929 4.11929 8 5.5 8ZM18.5 8C19.8807 8 21 9.11929 21 10.5C21 11.8807 19.8807 13 18.5 13C17.1193 13 16 11.8807 16 10.5C16 9.11929 17.1193 8 18.5 8ZM5.5 10C5.22386 10 5 10.2239 5 10.5C5 10.7761 5.22386 11 5.5 11C5.77614 11 6 10.7761 6 10.5C6 10.2239 5.77614 10 5.5 10ZM18.5 10C18.2239 10 18 10.2239 18 10.5C18 10.7761 18.2239 11 18.5 11C18.7761 11 19 10.7761 19 10.5C19 10.2239 18.7761 10 18.5 10ZM12 2C14.2091 2 16 3.79086 16 6C16 8.20914 14.2091 10 12 10C9.79086 10 8 8.20914 8 6C8 3.79086 9.79086 2 12 2ZM12 4C10.8954 4 10 4.89543 10 6C10 7.10457 10.8954 8 12 8C13.1046 8 14 7.10457 14 6C14 4.89543 13.1046 4 12 4Z" fill="currentColor"/>`,
"terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`, "terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`,
"terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`, "terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`,
"terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`, "terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`,
@@ -18,6 +18,9 @@ const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/w
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView }))); const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView })));
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView }))); const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView })));
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView }))); const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView })));
// The Linear rail icon stays hidden until a workspace is connected, so most
// users never render this panel; keep it out of the main bundle.
const LinearIssuesView = lazyWithChunkRecovery(() => import('@/components/views/LinearIssuesView').then((m) => ({ default: m.LinearIssuesView })));
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView }))); const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView })));
import { ProjectContextPanel } from './RightSidebarTabs'; import { ProjectContextPanel } from './RightSidebarTabs';
import { SidebarFilesTree } from './SidebarFilesTree'; import { SidebarFilesTree } from './SidebarFilesTree';
@@ -123,6 +126,7 @@ const getModeLabel = (
if (mode === 'browser') return t('contextPanel.mode.browser'); if (mode === 'browser') return t('contextPanel.mode.browser');
if (mode === 'git') return t('layout.rightSidebar.git'); if (mode === 'git') return t('layout.rightSidebar.git');
if (mode === 'pr') return gitProvider === 'gitlab' ? t('contextPanel.mode.mr') : t('contextPanel.mode.pr'); if (mode === 'pr') return gitProvider === 'gitlab' ? t('contextPanel.mode.mr') : t('contextPanel.mode.pr');
if (mode === 'linear') return t('contextPanel.mode.linear');
if (mode === 'notes') return t('contextRail.surface.notes'); if (mode === 'notes') return t('contextRail.surface.notes');
if (mode === 'terminal') return t('layout.mainTab.terminal'); if (mode === 'terminal') return t('layout.mainTab.terminal');
return t('contextPanel.mode.context'); return t('contextPanel.mode.context');
@@ -219,6 +223,10 @@ const getTabIcon = (
return <Icon name={gitProvider === 'gitlab' ? 'gitlab' : gitProvider === 'gitea' ? 'gitea' : 'github'} className="h-3.5 w-3.5" />; return <Icon name={gitProvider === 'gitlab' ? 'gitlab' : gitProvider === 'gitea' ? 'gitea' : 'github'} className="h-3.5 w-3.5" />;
} }
if (tab.mode === 'linear') {
return <Icon name="linear" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'notes') { if (tab.mode === 'notes') {
return <Icon name="sticky-note" className="h-3.5 w-3.5" />; return <Icon name="sticky-note" className="h-3.5 w-3.5" />;
} }
@@ -947,6 +955,8 @@ export const ContextPanel: React.FC = () => {
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense> ? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
: activeTab?.mode === 'pr' : activeTab?.mode === 'pr'
? (gitProvider === 'github' ? <PullRequestView /> : gitProvider === 'gitlab' ? <GitLabMrView /> : gitProvider === 'gitea' ? <GiteaPrView /> : null) ? (gitProvider === 'github' ? <PullRequestView /> : gitProvider === 'gitlab' ? <GitLabMrView /> : gitProvider === 'gitea' ? <GiteaPrView /> : null)
: activeTab?.mode === 'linear'
? <React.Suspense fallback={null}><LinearIssuesView /></React.Suspense>
: activeTab?.mode === 'notes' : activeTab?.mode === 'notes'
? <ProjectContextPanel /> ? <ProjectContextPanel />
: activeTab?.mode === 'plan' : activeTab?.mode === 'plan'
@@ -1288,7 +1298,7 @@ export const ContextPanel: React.FC = () => {
{hasWalkthroughTab ? ( {hasWalkthroughTab ? (
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}> <div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<WalkthroughView directory={effectiveDirectory} /> <WalkthroughView directory={effectiveDirectory} visible={activeTab?.mode === 'walkthrough'} />
</React.Suspense> </React.Suspense>
</div> </div>
) : null} ) : null}
@@ -37,6 +37,8 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitStatus } from '@/stores/useGitStore'; import { useGitStatus } from '@/stores/useGitStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog'; import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog';
@@ -167,8 +169,13 @@ export const ContextPanelRail: React.FC = () => {
const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces); const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces);
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder); const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
const openContextSurface = useUIStore((state) => state.openContextSurface); const openContextSurface = useUIStore((state) => state.openContextSurface);
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
const linearConnected = useLinearAuthStore((state) => state.status?.connected === true);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const githubConnected = useGitHubAuthStore((state) => state.status?.connected === true);
const { screenWidth } = useDeviceInfo(); const { screenWidth } = useDeviceInfo();
const gitStatus = useGitStatus(directoryKey || null); const gitStatus = useGitStatus(directoryKey || null);
// Provider-aware 'pr' branding: GitLab repositories get the MR descriptor // Provider-aware 'pr' branding: GitLab repositories get the MR descriptor
@@ -268,9 +275,27 @@ export const ContextPanelRail: React.FC = () => {
isVSCode: isVSCodeRuntime(), isVSCode: isVSCodeRuntime(),
screenWidth, screenWidth,
tabs, tabs,
linearConnected,
gitProvider, gitProvider,
}); });
}, [contextRailHiddenSurfaces, contextRailOrder, gitProvider, planModeEnabled, screenWidth, tabs]); }, [contextRailHiddenSurfaces, contextRailOrder, gitProvider, linearConnected, planModeEnabled, screenWidth, tabs]);
// A surface whose integration disconnected closes rather than lingering as
// an active panel with no rail icon.
React.useEffect(() => {
if (!directoryKey || !linearAuthChecked || linearConnected || activeMode !== 'linear') {
return;
}
closeContextPanel(directoryKey);
}, [activeMode, closeContextPanel, directoryKey, linearAuthChecked, linearConnected]);
React.useEffect(() => {
if (!directoryKey || !githubAuthChecked || githubConnected || activeMode !== 'pr') {
return;
}
closeContextPanel(directoryKey);
}, [activeMode, closeContextPanel, directoryKey, githubAuthChecked, githubConnected]);
>>>>>>> origin/release/v1.22.0
const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false); const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false);
+21 -199
View File
@@ -9,7 +9,6 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
@@ -30,8 +29,6 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { streamPerfCount } from '@/stores/utils/streamDebug'; import { streamPerfCount } from '@/stores/utils/streamDebug';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout'; import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls'; import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls';
@@ -45,10 +42,11 @@ import {
import { import {
} from '@/components/ui/collapsible'; } from '@/components/ui/collapsible';
import type { GitHubAuthStatus } from '@/lib/api/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { useProjectActionsContext } from '@/hooks/useProjectActionsContext';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
import { SessionTabsStrip, type SessionTabMenuArgs } from './SessionTabsStrip'; import { SessionTabsStrip, type SessionTabMenuArgs } from './SessionTabsStrip';
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop';
@@ -123,132 +121,6 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
); );
}); });
type DesktopGitHubControlProps = {
isMobile: boolean;
githubAuthStatus: GitHubAuthStatus | null;
githubAccounts: Array<NonNullable<GitHubAuthStatus['accounts']>[number]>;
githubAvatarUrl: string | null;
githubLogin: string | null;
isSwitchingGitHubAccount: boolean;
handleGitHubAccountSwitch: (accountId: string) => Promise<void>;
};
const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
isMobile,
githubAuthStatus,
githubAccounts,
githubAvatarUrl,
githubLogin,
isSwitchingGitHubAccount,
handleGitHubAccountSwitch,
}: DesktopGitHubControlProps) {
const { t } = useI18n();
if (!githubAuthStatus?.connected || isMobile) {
return null;
}
if (githubAccounts.length > 1) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
DESKTOP_HEADER_ICON_BUTTON_CLASS,
'h-7 w-7 overflow-hidden rounded-full border border-border/60 bg-muted/80 p-0'
)}
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
disabled={isSwitchingGitHubAccount}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<Icon name="github-fill" className="h-3.5 w-3.5 text-foreground" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">
{t('header.github.accountsTitle')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{githubAccounts.map((account) => {
const accountUser = account.user;
const isCurrent = Boolean(account.current);
const sourceLabel = account.source === 'gh-cli'
? t('header.github.accountSource.cli')
: t('header.github.accountSource.oauth');
return (
<DropdownMenuItem
key={account.id}
className="gap-2"
disabled={isSwitchingGitHubAccount}
onSelect={() => {
if (!isCurrent) {
void handleGitHubAccountSwitch(account.id);
}
}}
>
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.login ? t('header.github.avatarWithLogin', { login: accountUser.login }) : t('header.github.avatar')}
className="h-6 w-6 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-border/60 bg-muted">
<Icon name="github-fill" className="h-3 w-3 text-muted-foreground" />
</div>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate typography-ui-label text-foreground">
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
</span>
{accountUser?.login ? (
<span className="truncate typography-micro text-muted-foreground">
<span className="font-mono">{accountUser.login}</span>
<span className="mx-1 opacity-50">·</span>
<span>{sourceLabel}</span>
</span>
) : null}
</span>
{isCurrent ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<div
className="app-region-no-drag flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80"
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<Icon name="github-fill" className="h-3.5 w-3.5 text-foreground" />
)}
</div>
);
});
type DesktopServicesMenuProps = { type DesktopServicesMenuProps = {
isDesktopApp: boolean; isDesktopApp: boolean;
currentInstanceLabel: string; currentInstanceLabel: string;
@@ -439,7 +311,6 @@ export const Header: React.FC = () => {
const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled); const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled);
const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
const runtimeApis = useRuntimeAPIs();
const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
@@ -488,8 +359,6 @@ export const Header: React.FC = () => {
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const setGitHubAuthStatus = useGitHubAuthStore((state) => state.setStatus);
const headerRef = React.useRef<HTMLElement | null>(null); const headerRef = React.useRef<HTMLElement | null>(null);
@@ -571,10 +440,6 @@ export const Header: React.FC = () => {
} }
}, [contextUsage, currentSessionId, isContextUsageResolvedForSession]); }, [contextUsage, currentSessionId, isContextUsageResolvedForSession]);
const githubAvatarUrl = githubAuthStatus?.connected ? (githubAuthStatus.user?.avatarUrl ?? null) : null;
const githubLogin = githubAuthStatus?.connected ? (githubAuthStatus.user?.login ?? null) : null;
const githubAccounts = githubAuthStatus?.accounts ?? [];
const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false);
const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false); const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false);
const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local'); const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local');
const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true); const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true);
@@ -1134,27 +999,9 @@ export const Header: React.FC = () => {
return normalize(openDirectory || activeProject?.path || ''); return normalize(openDirectory || activeProject?.path || '');
}, [activeProject?.path, openDirectory]); }, [activeProject?.path, openDirectory]);
const activeProjectRef = React.useMemo(() => { // Same resolution the titlebar overlay used to own: worktree → session →
if (!activeProject) { // draft → project path, sticky across session switches.
return null; const projectActionsContext = useProjectActionsContext();
}
return { id: activeProject.id, path: activeProject.path };
}, [activeProject]);
const lastProjectActionsContextRef = React.useRef<{
projectRef: { id: string; path: string };
directory: string;
} | null>(null);
React.useEffect(() => {
if (!activeProjectRef || !actionDirectory) {
return;
}
lastProjectActionsContextRef.current = {
projectRef: activeProjectRef,
directory: actionDirectory,
};
}, [actionDirectory, activeProjectRef]);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
@@ -1183,37 +1030,6 @@ export const Header: React.FC = () => {
sessionDirectory, sessionDirectory,
]); ]);
const handleGitHubAccountSwitch = React.useCallback(async (accountId: string) => {
if (!accountId || isSwitchingGitHubAccount) return;
setIsSwitchingGitHubAccount(true);
try {
const payload = runtimeApis.github
? await runtimeApis.github.authActivate(accountId)
: await (async () => {
const response = await runtimeFetch('/api/github/auth/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ accountId }),
});
const body = (await response.json().catch(() => null)) as
| (GitHubAuthStatus & { error?: string })
| null;
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText);
}
return body;
})();
setGitHubAuthStatus(payload);
} catch (error) {
console.error('Failed to switch GitHub account:', error);
} finally {
setIsSwitchingGitHubAccount(false);
}
}, [isSwitchingGitHubAccount, runtimeApis.github, setGitHubAuthStatus]);
@@ -1356,6 +1172,14 @@ export const Header: React.FC = () => {
return undefined; return undefined;
} }
// Custom in-window controls (frameless Electron, right side) own the right
// edge: no inline padding, so the pr-0 class applies and the close button
// sits flush with the window corner per Windows conventions. Only the
// browser's native window-controls overlay reserves padding + right inset.
if (usesFramelessChrome && windowControlsSide === 'right') {
return undefined;
}
return { return {
// Left inset is handled by the no-drag spacer (see renderDesktop); only // Left inset is handled by the no-drag spacer (see renderDesktop); only
// the right inset / titlebar height are owned by the window-controls overlay. // the right inset / titlebar height are owned by the window-controls overlay.
@@ -1363,7 +1187,7 @@ export const Header: React.FC = () => {
minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
}; };
}, [isDesktopApp, isVSCode, usesFramelessChrome]); }, [isDesktopApp, isVSCode, usesFramelessChrome, windowControlsSide]);
const updateHeaderHeight = React.useCallback(() => { const updateHeaderHeight = React.useCallback(() => {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
@@ -1454,6 +1278,13 @@ export const Header: React.FC = () => {
const desktopSidebarActions = ( const desktopSidebarActions = (
<> <>
{projectActionsContext ? (
<ProjectActionsButton
projectRef={projectActionsContext.projectRef}
directory={projectActionsContext.directory}
className="mr-2"
/>
) : null}
<OpenInAppButton directory={actionDirectory} className="mr-1" /> <OpenInAppButton directory={actionDirectory} className="mr-1" />
{/* Instances only exist in the desktop app. On web the menu was left {/* Instances only exist in the desktop app. On web the menu was left
holding a single dev-only shutdown action, which is not a reason to holding a single dev-only shutdown action, which is not a reason to
@@ -1474,15 +1305,6 @@ export const Header: React.FC = () => {
onOpenRemoteUpdate={openRemoteInstanceUpdate} onOpenRemoteUpdate={openRemoteInstanceUpdate}
/> />
) : null} ) : null}
<DesktopGitHubControl
isMobile={isMobile}
githubAuthStatus={githubAuthStatus}
githubAccounts={githubAccounts}
githubAvatarUrl={githubAvatarUrl}
githubLogin={githubLogin}
isSwitchingGitHubAccount={isSwitchingGitHubAccount}
handleGitHubAccountSwitch={handleGitHubAccountSwitch}
/>
</> </>
); );
@@ -159,8 +159,11 @@ const SessionTabItem: React.FC<{
}} }}
data-controls-open={overlayVisible ? 'true' : 'false'} data-controls-open={overlayVisible ? 'true' : 'false'}
className={cn( className={cn(
// No color transition: activation must snap. A crossfade
// here reads as the switch itself being slow, since the
// old and new tab trade colors over several frames right
// after the click.
'session-tab group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2', 'session-tab group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2',
'transition-colors duration-75',
isActive isActive
? 'bg-interactive-selection' ? 'bg-interactive-selection'
: cn( : cn(
@@ -180,8 +183,15 @@ const SessionTabItem: React.FC<{
!suppressControls && 'session-tab-title', !suppressControls && 'session-tab-title',
)} )}
> >
{/* Same box as the active content the header renders
(a centered column with a block title), so the
title sits at the same height before and after
activation and does not jump when the tab swaps
its content. */}
{isActive ? children : ( {isActive ? children : (
<span className="text-[13px] font-medium leading-4">{title}</span> <div className="flex min-w-0 flex-col justify-center">
<span className="block max-w-full overflow-hidden whitespace-nowrap text-[13px] font-medium leading-4">{title}</span>
</div>
)} )}
</div> </div>
{showDot ? ( {showDot ? (
@@ -2,8 +2,8 @@ import React from 'react';
/** /**
* Strip at the top of the desktop left sidebar that reserves room for the * Strip at the top of the desktop left sidebar that reserves room for the
* persistent {@link TitlebarLeftControls} overlay (sidebar toggle + project * persistent {@link TitlebarLeftControls} overlay (sidebar toggle), so the
* actions), so the session list starts below them. Its height tracks the * session list starts below it. Its height tracks the
* header via `--oc-header-height`. * header via `--oc-header-height`.
* *
* Split into two regions so the strip stays a window drag area while the * Split into two regions so the strip stays a window drag area while the
@@ -4,8 +4,6 @@ import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useProjectActionsContext } from '@/hooks/useProjectActionsContext';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls'; import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { invokeDesktop } from '@/lib/desktop'; import { invokeDesktop } from '@/lib/desktop';
@@ -15,7 +13,7 @@ const ICON_BUTTON_CLASS =
'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary hover:bg-interactive-hover transition-colors'; 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary hover:bg-interactive-hover transition-colors';
/** /**
* Persistent top-left titlebar controls (sidebar toggle + project actions). * Persistent top-left titlebar controls (app menu on frameless chrome + sidebar toggle).
* *
* Rendered exactly once as an absolutely-positioned overlay above both the * Rendered exactly once as an absolutely-positioned overlay above both the
* sidebar and the header, so the buttons never migrate / re-mount between the * sidebar and the header, so the buttons never migrate / re-mount between the
@@ -29,7 +27,6 @@ export const TitlebarLeftControls: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const toggleSidebar = useUIStore((state) => state.toggleSidebar); const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const projectActionsContext = useProjectActionsContext();
const clusterRef = React.useRef<HTMLDivElement | null>(null); const clusterRef = React.useRef<HTMLDivElement | null>(null);
const toggleShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_sidebar', shortcutOverrides)); const toggleShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_sidebar', shortcutOverrides));
@@ -123,13 +120,6 @@ export const TitlebarLeftControls: React.FC = () => {
<p>{t('header.actions.openSessionsWithShortcut', { shortcut: toggleShortcut })}</p> <p>{t('header.actions.openSessionsWithShortcut', { shortcut: toggleShortcut })}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{projectActionsContext ? (
<ProjectActionsButton
projectRef={projectActionsContext.projectRef}
directory={projectActionsContext.directory}
/>
) : null}
</div> </div>
</div> </div>
); );
@@ -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');
});
});
@@ -194,6 +194,7 @@ export const GitPage: React.FC<GitPageProps> = (props) => {
<SettingsSection <SettingsSection
title={t('settings.gitIdentities.page.section.title')} title={t('settings.gitIdentities.page.section.title')}
divider={false}
headerAction={( headerAction={(
<Button size="sm" variant="outline" onClick={() => openEditor('new')}> <Button size="sm" variant="outline" onClick={() => openEditor('new')}>
<Icon name="add" className="w-3.5 h-3.5 mr-1" /> {t('settings.common.badge.new')} <Icon name="add" className="w-3.5 h-3.5 mr-1" /> {t('settings.common.badge.new')}
@@ -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 (
<Collapsible open={open} onOpenChange={setOpen}>
<div
data-settings-item="integrations.github"
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
>
<CollapsibleTrigger
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name="github-fill" className="size-5 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">
{t('settings.integrations.github.title')}
</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t('settings.integrations.github.description')}
</p>
</div>
<span
aria-live="polite"
className={cn('max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium', statusClassName)}
>
{statusLabel}
</span>
<Icon
name="arrow-down-s"
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
open && 'rotate-180',
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
<GitHubSettings embedded />
</CollapsibleContent>
</div>
</Collapsible>
);
};
@@ -1,8 +1,11 @@
import React from 'react'; import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; 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 { 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'; import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
interface IntegrationsPageProps { interface IntegrationsPageProps {
@@ -15,25 +18,32 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
onOpenPluginManager, onOpenPluginManager,
}) => { }) => {
const { t } = useI18n(); 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 ( return (
<SettingsPageLayout <SettingsPageLayout
title={t('settings.page.integrations.title')} title={t('settings.page.integrations.title')}
description={( description={t('settings.page.integrations.description')}
<div className="space-y-3"> showSaveStatus
<p className={SETTINGS_DESCRIPTION_CLASS}>{t('settings.page.integrations.description')}</p>
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.integrations.experimentalWarning')}
</p>
</div>
</div>
)}
showSaveStatus={false}
> >
<ThirdPartyIntegrationsSection {hasBuiltIn ? (
<SettingsSection
title={t('settings.integrations.firstParty.title')}
info={t('settings.integrations.firstParty.info')}
divider={false} divider={false}
settingsItem="integrations.first-party"
contentClassName="space-y-3"
>
{hasGitHub ? <GitHubIntegration /> : null}
{hasLinear ? <LinearSettings /> : null}
</SettingsSection>
) : null}
<ThirdPartyIntegrationsSection
divider={hasBuiltIn}
onOpenProviderSetup={onOpenProviderSetup} onOpenProviderSetup={onOpenProviderSetup}
onOpenPluginManager={onOpenPluginManager} onOpenPluginManager={onOpenPluginManager}
/> />
@@ -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<LinearMappingResult | null>(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 (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.linear.mapping.loadFailed')}
</p>
);
}
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 (
<div className={SETTINGS_FIELDS_STACK_CLASS}>
{projects.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.linear.mapping.emptyProjects')}
</p>
) : null}
<SettingsFieldRow
label={t('settings.integrations.linear.mapping.defaultProject')}
info={t('settings.integrations.linear.mapping.defaultProject.info')}
settingsItem="integrations.linear.mapping"
>
<Select
value={mapping.defaultProjectPath || NONE}
disabled={isSaving || projects.length === 0}
onValueChange={(value) => {
void saveMapping({
...mapping,
defaultProjectPath: value === NONE ? null : value,
});
}}
>
<SelectTrigger
size={SETTINGS_SELECT_SIZE}
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
aria-label={t('settings.integrations.linear.mapping.defaultProject.aria')}
>
<SelectValue placeholder={t('settings.integrations.linear.mapping.defaultProject.placeholder')}>
{defaultProjectLabel}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
{t('settings.integrations.linear.mapping.defaultProject.placeholder')}
</SelectItem>
{mapping.defaultProjectPath && !projects.some((entry) => entry.path === mapping.defaultProjectPath) ? (
<SelectItem value={mapping.defaultProjectPath}>{mapping.defaultProjectPath}</SelectItem>
) : null}
{projects.map((project) => (
<SelectItem key={project.id} value={project.path}>
{projectLabel(project.path)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsFieldRow>
<SettingsControlGroup
title={t('settings.integrations.linear.mapping.teams')}
info={t('settings.integrations.linear.mapping.teams.info')}
>
{(mapping.teams ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.linear.mapping.emptyTeams')}
</p>
) : (
<div className={SETTINGS_FIELDS_STACK_CLASS}>
{(mapping.teams ?? []).map((team) => (
<SettingsFieldRow
key={team.id}
label={`${team.key} · ${team.name}`}
>
<Select
value={team.projectPath || INHERIT}
disabled={isSaving || projects.length === 0}
onValueChange={(value) => {
void saveMapping({
...mapping,
teams: (mapping.teams ?? []).map((entry) => (
entry.id === team.id
? { ...entry, projectPath: value === INHERIT ? null : value }
: entry
)),
});
}}
>
<SelectTrigger
size={SETTINGS_SELECT_SIZE}
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
aria-label={t('settings.integrations.linear.mapping.teams.aria', { team: team.key })}
>
<SelectValue placeholder={t('settings.integrations.linear.mapping.teams.useDefault')}>
{teamProjectLabel}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={INHERIT}>
{t('settings.integrations.linear.mapping.teams.useDefault')}
</SelectItem>
{team.projectPath && !projects.some((entry) => entry.path === team.projectPath) ? (
<SelectItem value={team.projectPath}>{team.projectPath}</SelectItem>
) : null}
{projects.map((project) => (
<SelectItem key={project.id} value={project.path}>
{projectLabel(project.path)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsFieldRow>
))}
</div>
)}
</SettingsControlGroup>
</div>
);
}
@@ -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<boolean | null>(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 (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.linear.sessionComments.loadFailed')}
</p>
);
}
return (
<div className={SETTINGS_FIELDS_STACK_CLASS}>
<SettingsFieldRow
label={t('settings.integrations.linear.sessionComments.label')}
info={t('settings.integrations.linear.sessionComments.info')}
settingsItem="integrations.linear.session-comments"
>
<Switch
checked={enabled === true}
disabled={enabled === null || isSaving}
onCheckedChange={(checked) => { void save(checked); }}
aria-label={t('settings.integrations.linear.sessionComments.aria')}
/>
</SettingsFieldRow>
</div>
);
}
@@ -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<number | null>(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 (
<Collapsible
open={expanded}
onOpenChange={(nextOpen) => {
if (isWaiting) {
setOpen(true);
return;
}
setOpen(nextOpen);
}}
>
<div
data-settings-item="integrations.linear"
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
>
<CollapsibleTrigger
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name="linear" className="size-5 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">
{t('settings.integrations.linear.title')}
</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t('settings.integrations.linear.description')}
</p>
</div>
<span
aria-live="polite"
className={cn(
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
statusClassName,
)}
>
{statusLabel}
</span>
<Icon
name="arrow-down-s"
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
expanded && 'rotate-180',
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
<div className="space-y-3">
{connected ? (
<div className="flex min-w-0 items-center gap-3">
{user?.avatarUrl ? (
<img
src={user.avatarUrl}
alt={t('settings.integrations.linear.avatarAlt.withName', { name: displayName })}
className="size-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
<Icon name="linear" className="size-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{displayName}</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{[organization?.name, user?.email].filter(Boolean).join(' · ')}
</p>
</div>
</div>
) : isWaiting ? (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.linear.flow.description')}
</p>
) : null}
{connected ? (
<>
<LinearProjectMapping
linear={runtimeLinear}
connected={connected}
organizationId={organization?.id ?? null}
/>
<LinearSessionComments linear={runtimeLinear} connected={connected} />
{otherWorkspaces.length > 0 ? (
<div className="space-y-2">
<p className="typography-micro text-muted-foreground">
{t('settings.integrations.linear.label.otherWorkspaces')}
</p>
<div className="space-y-1">
{otherWorkspaces.map((workspace) => {
const workspaceUser = workspace.user;
const workspaceName = workspace.name?.trim()
|| t('settings.integrations.linear.status.connected');
return (
<div
key={workspace.id}
className="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-subtle)] bg-[var(--surface-muted)] px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{workspaceName}</div>
{workspaceUser?.email ? (
<p className="truncate text-xs text-muted-foreground">{workspaceUser.email}</p>
) : null}
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void activateWorkspace(workspace.id)}
disabled={isBusy}
>
{t('settings.integrations.linear.actions.switchTo')}
</Button>
</div>
);
})}
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void startConnect()}
disabled={isBusy || isWaiting}
data-settings-item="integrations.linear.add-workspace"
>
{t('settings.integrations.linear.actions.addWorkspace')}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => void disconnect()}
disabled={isBusy}
>
{t('settings.integrations.linear.actions.disconnect')}
</Button>
</div>
</>
) : isWaiting ? (
<div className="flex flex-wrap items-center gap-2">
<span className="typography-micro text-muted-foreground animate-pulse">
{t('settings.integrations.linear.flow.waiting')}
</span>
<Button type="button" size="sm" variant="ghost" disabled={isBusy} onClick={stopWaiting}>
{t('settings.common.actions.cancel')}
</Button>
</div>
) : (
<Button
type="button"
size="sm"
variant="default"
onClick={() => void startConnect()}
disabled={isBusy || (isLoading && !hasChecked)}
>
{isBusy ? <Icon name="loader-4" className="size-3.5 animate-spin" /> : null}
{t('settings.integrations.linear.actions.connect')}
</Button>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
};
@@ -414,6 +414,12 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
settingsItem="integrations.third-party" settingsItem="integrations.third-party"
contentClassName="space-y-3" contentClassName="space-y-3"
> >
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.integrations.experimentalWarning')}
</p>
</div>
{THIRD_PARTY_PLUGINS.map(renderPlugin)} {THIRD_PARTY_PLUGINS.map(renderPlugin)}
</SettingsSection> </SettingsSection>
@@ -61,6 +61,14 @@ const PROMPT_PAGE_MAP: Record<string, PromptPageConfig> = {
{ id: 'github.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, { 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': { 'github.pr.checks.review': {
titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title', titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title',
descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description', descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description',
@@ -35,6 +35,12 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
{ id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' }, { 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', groupKey: 'settings.magicPrompts.sidebar.group.gitlab',
items: [ items: [
@@ -7,7 +7,6 @@ import { AppLinkSecuritySettings } from './AppLinkSecuritySettings';
import { DefaultsSettings } from './DefaultsSettings'; import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings'; import { GitSettings } from './GitSettings';
import { NotificationSettings } from './NotificationSettings'; import { NotificationSettings } from './NotificationSettings';
import { GitHubSettings } from './GitHubSettings';
import { VoiceSettings } from './VoiceSettings'; import { VoiceSettings } from './VoiceSettings';
import { TunnelSettings } from './TunnelSettings'; import { TunnelSettings } from './TunnelSettings';
import { OpenCodeCliSettings } from './OpenCodeCliSettings'; import { OpenCodeCliSettings } from './OpenCodeCliSettings';
@@ -78,8 +77,6 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
return <ShortcutsSectionContent />; return <ShortcutsSectionContent />;
case 'git': case 'git':
return <GitSectionContent />; return <GitSectionContent />;
case 'github':
return <GitHubSectionContent />;
case 'notifications': case 'notifications':
return <NotificationSectionContent />; return <NotificationSectionContent />;
case 'voice': case 'voice':
@@ -233,14 +230,6 @@ const GitSectionContent: React.FC = () => {
return <GitSettings />; return <GitSettings />;
}; };
// GitHub section: Connect account for PR/issue workflows
const GitHubSectionContent: React.FC = () => {
if (isVSCodeRuntime()) {
return null;
}
return <GitHubSettings />;
};
// Notifications section: Native browser notifications // Notifications section: Native browser notifications
const NotificationSectionContent: React.FC = () => { const NotificationSectionContent: React.FC = () => {
return <NotificationSettings />; return <NotificationSettings />;
@@ -71,6 +71,7 @@ const LOCAL_STT_MODELS = [
interface DictationModelState { interface DictationModelState {
id: string; id: string;
description?: string;
installed: boolean; installed: boolean;
downloading: boolean; downloading: boolean;
downloadProgress: number | null; downloadProgress: number | null;
@@ -288,10 +289,32 @@ const KOKORO_VOICE_OPTIONS = [
const LOCAL_TTS_MODEL_ID = 'kokoro-en-v0_19'; const LOCAL_TTS_MODEL_ID = 'kokoro-en-v0_19';
const LocalTtsModelStatus = () => { const KOKORO_MULTI_LANG_MODEL_ID = 'kokoro-multi-lang-v1_1';
const { t } = useI18n(); // A few named speakers out of the 103 in the Chinese/English Kokoro build.
const [model, setModel] = useState<DictationModelState | null>(null); const KOKORO_MULTI_LANG_VOICE_OPTIONS = [
const [requesting, setRequesting] = useState(false); { 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<DictationModelState[]>([]);
const [requestingId, setRequestingId] = useState<string | null>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -300,11 +323,8 @@ const LocalTtsModelStatus = () => {
return; return;
} }
const data = await response.json(); const data = await response.json();
const entry = Array.isArray(data?.ttsModels) if (Array.isArray(data?.ttsModels)) {
? data.ttsModels.find((m: DictationModelState) => m.id === LOCAL_TTS_MODEL_ID) setModels(data.ttsModels);
: null;
if (entry) {
setModel(entry);
} }
} catch { } catch {
// Display-only status; keep the previous state on fetch failure. // Display-only status; keep the previous state on fetch failure.
@@ -315,39 +335,74 @@ const LocalTtsModelStatus = () => {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
const anyDownloading = models.some((model) => model.downloading);
useEffect(() => { useEffect(() => {
if (!model?.downloading) { if (!anyDownloading) {
return; return;
} }
const interval = setInterval(() => { const interval = setInterval(() => {
void refresh(); void refresh();
}, 2000); }, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [model?.downloading, refresh]); }, [anyDownloading, refresh]);
const request = async (method: 'POST' | 'DELETE') => { const request = useCallback(async (modelId: string, method: 'POST' | 'DELETE') => {
setRequesting(true); setRequestingId(modelId);
try { try {
const path = method === 'POST' const path = method === 'POST'
? `/api/dictation/models/${LOCAL_TTS_MODEL_ID}/download` ? `/api/dictation/models/${modelId}/download`
: `/api/dictation/models/${LOCAL_TTS_MODEL_ID}`; : `/api/dictation/models/${modelId}`;
await runtimeFetch(path, { method }); await runtimeFetch(path, { method });
await refresh(); await refresh();
} catch { } catch {
// Status refresh reports errors. // Status refresh reports errors.
} finally { } finally {
setRequesting(false); setRequestingId(null);
} }
}, [refresh]);
return { models, requestingId, request, refresh };
}; };
if (!model) { // 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<typeof useLocalTtsModels>) => {
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 null;
} }
return ( return (
<div className="flex items-center gap-2 py-1.5"> <div className="flex flex-col">
<span className="typography-ui-label text-foreground">Kokoro</span> {visible.map((model) => (
<span className="typography-ui-compact tabular-nums text-muted-foreground">305 MB</span> <div key={model.id} className="flex items-center gap-2 py-1.5">
<span className="typography-ui-label text-foreground">{model.description ?? model.id}</span>
{model.installed ? ( {model.installed ? (
<> <>
<Icon <Icon
@@ -359,8 +414,8 @@ const LocalTtsModelStatus = () => {
variant="ghost" variant="ghost"
size="xs" size="xs"
className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]" className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]"
disabled={requesting} disabled={requestingId !== null}
onClick={() => { void request('DELETE'); }} onClick={() => { void request(model.id, 'DELETE'); }}
title={t('settings.voice.page.stt.modelDelete')} title={t('settings.voice.page.stt.modelDelete')}
aria-label={t('settings.voice.page.stt.modelDelete')} aria-label={t('settings.voice.page.stt.modelDelete')}
> >
@@ -379,8 +434,8 @@ const LocalTtsModelStatus = () => {
variant="ghost" variant="ghost"
size="xs" size="xs"
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
disabled={requesting} disabled={requestingId !== null}
onClick={() => { void request('POST'); }} onClick={() => { void request(model.id, 'POST'); }}
title={t('settings.voice.page.stt.modelDownload')} title={t('settings.voice.page.stt.modelDownload')}
aria-label={t('settings.voice.page.stt.modelDownload')} aria-label={t('settings.voice.page.stt.modelDownload')}
> >
@@ -391,6 +446,8 @@ const LocalTtsModelStatus = () => {
<span className="typography-meta text-[var(--status-error)]">{model.downloadError}</span> <span className="typography-meta text-[var(--status-error)]">{model.downloadError}</span>
) : null} ) : null}
</div> </div>
))}
</div>
); );
}; };
@@ -424,6 +481,12 @@ export const VoiceSettings: React.FC = () => {
const sayVoice = useConfigStore((state) => state.sayVoice); const sayVoice = useConfigStore((state) => state.sayVoice);
const setSayVoice = useConfigStore((state) => state.setSayVoice); const setSayVoice = useConfigStore((state) => state.setSayVoice);
const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId); 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 setLocalTtsVoiceId = useConfigStore((state) => state.setLocalTtsVoiceId);
const { speak: speakLocalTts, stop: stopLocalTts, isPlaying: isLocalTtsPlaying, error: localTtsError } = useLocalTTS(); const { speak: speakLocalTts, stop: stopLocalTts, isPlaying: isLocalTtsPlaying, error: localTtsError } = useLocalTTS();
@@ -432,13 +495,14 @@ export const VoiceSettings: React.FC = () => {
stopLocalTts(); stopLocalTts();
return; 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); ?? String(localTtsVoiceId);
void speakLocalTts(t('settings.voice.page.preview.voiceLine', { voiceName: voiceLabel }), { void speakLocalTts(t('settings.voice.page.preview.voiceLine', { voiceName: voiceLabel }), {
model: localTtsModelId,
speakerId: localTtsVoiceId, speakerId: localTtsVoiceId,
speed: useConfigStore.getState().speechRate, speed: useConfigStore.getState().speechRate,
}); });
}, [isLocalTtsPlaying, localTtsVoiceId, speakLocalTts, stopLocalTts, t]); }, [isLocalTtsPlaying, localTtsModelId, localTtsVoiceId, localTtsVoiceOptions, speakLocalTts, stopLocalTts, t]);
const browserVoice = useConfigStore((state) => state.browserVoice); const browserVoice = useConfigStore((state) => state.browserVoice);
const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice); const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice);
const openaiVoice = useConfigStore((state) => state.openaiVoice); const openaiVoice = useConfigStore((state) => state.openaiVoice);
@@ -959,24 +1023,39 @@ export const VoiceSettings: React.FC = () => {
)} )}
{/* Local (Kokoro) TTS model status */} {/* Local (Kokoro) TTS model status */}
{voiceProvider === 'local' && <LocalTtsModelStatus />} {voiceProvider === 'local' && <LocalTtsModelStatus {...localTtsModels} />}
{(voiceProvider === 'local' || voiceProvider === 'say') && (
<SettingsCheckboxRow
checked={ttsFollowTextLanguage}
onChange={setTtsFollowTextLanguage}
label={t('settings.voice.page.field.followTextLanguage')}
ariaLabel={t('settings.voice.page.field.followTextLanguageAria')}
info={t('settings.voice.page.field.followTextLanguageInfo')}
/>
)}
{/* Voice Selection */} {/* Voice Selection */}
<SettingsFieldRow label={t('settings.voice.page.field.voice')}> <SettingsFieldRow label={t('settings.voice.page.field.voice')}>
{voiceProvider === 'local' && ( {voiceProvider === 'local' && (
<> <>
<Select <Select
value={String(localTtsVoiceId)} value={localTtsVoiceKey(localTtsModelId, localTtsVoiceId)}
onValueChange={(value) => setLocalTtsVoiceId(Number.parseInt(value, 10) || 0)} onValueChange={(value) => {
const option = localTtsVoiceOptions.find((v) => localTtsVoiceKey(v.modelId, v.speakerId) === value);
if (!option) return;
setLocalTtsModelId(option.modelId);
setLocalTtsVoiceId(option.speakerId);
}}
> >
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}> <SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')}> <SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')}>
{(value) => KOKORO_VOICE_OPTIONS.find((v) => String(v.id) === value)?.label ?? value} {(value) => localTtsVoiceOptions.find((v) => localTtsVoiceKey(v.modelId, v.speakerId) === value)?.label ?? value}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{KOKORO_VOICE_OPTIONS.map((v) => ( {localTtsVoiceOptions.map((v) => (
<SelectItem key={v.id} value={String(v.id)}>{v.label}</SelectItem> <SelectItem key={localTtsVoiceKey(v.modelId, v.speakerId)} value={localTtsVoiceKey(v.modelId, v.speakerId)}>{v.label}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -284,7 +284,7 @@ export function GitHubIntegrationDialog({
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
const openGitHubSettings = () => { const openGitHubSettings = () => {
setSettingsPage('github'); setSettingsPage('integrations');
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}; };
@@ -230,7 +230,7 @@ export function GitHubIssuePickerDialog({
const repoUrl = result?.repo?.url ?? null; const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => { const openGitHubSettings = React.useCallback(() => {
setSettingsPage('github'); setSettingsPage('integrations');
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]); }, [setSettingsDialogOpen, setSettingsPage]);
@@ -217,7 +217,7 @@ export function GitHubPrPickerDialog({
const connected = githubAuthChecked ? result?.connected !== false : true; const connected = githubAuthChecked ? result?.connected !== false : true;
const openGitHubSettings = React.useCallback(() => { const openGitHubSettings = React.useCallback(() => {
setSettingsPage('github'); setSettingsPage('integrations');
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]); }, [setSettingsDialogOpen, setSettingsPage]);
@@ -0,0 +1,492 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useUIStore } from '@/stores/useUIStore';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useDeviceInfo } from '@/lib/device';
import { buildIssueContextText, startLinearIssueSession } from '@/lib/linearStartSession';
import type { LinearIssueSummary, LinearMappingResult } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
const parseLinearIssueQuery = (value: string): string | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(/linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i);
if (urlMatch) return urlMatch[1].toUpperCase();
if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmed)) return trimmed.toUpperCase();
return null;
};
export function LinearIssuePickerDialog({
open,
onOpenChange,
mode = 'select',
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
mode?: 'createSession' | 'select';
onSelect?: (issue: {
identifier: string;
title: string;
url: string;
contextText: string;
author?: { login: string; avatarUrl?: string };
}) => void;
}) {
const { t } = useI18n();
const { linear } = useRuntimeAPIs();
const linearAuthStatus = useLinearAuthStore((state) => state.status);
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
const refreshStatus = useLinearAuthStore((state) => state.refreshStatus);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const isMobile = useUIStore((state) => state.isMobile);
const { isTablet } = useDeviceInfo();
const alwaysShowActions = isMobile || isTablet;
const [query, setQuery] = React.useState('');
const [issues, setIssues] = React.useState<LinearIssueSummary[]>([]);
const [cursor, setCursor] = React.useState<string | null>(null);
const [hasMore, setHasMore] = React.useState(false);
const [connected, setConnected] = React.useState(true);
const [startingIssueKey, setStartingIssueKey] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [createInWorktree, setCreateInWorktree] = React.useState(false);
const [mapping, setMapping] = React.useState<LinearMappingResult | null>(null);
const [mappingError, setMappingError] = React.useState<string | null>(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 = (
<>
<div className="relative mt-2">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('session.linearIssuePicker.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<div className={cn(isMobile ? 'min-h-0 mt-2' : 'flex-1 overflow-y-auto mt-2')}>
{runtimeMissing ? (
<div className="text-center text-muted-foreground py-8">{t('session.linearIssuePicker.empty.runtimeUnavailable')}</div>
) : null}
{mode === 'createSession' && mappingError ? (
<div className="text-center text-muted-foreground py-8 break-words">{mappingError}</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.linearIssuePicker.loading.issues')}
</div>
) : null}
{showDisconnected ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>{t('session.linearIssuePicker.empty.notConnected')}</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openLinearSettings}>
{t('session.linearIssuePicker.actions.openSettings')}
</Button>
</div>
</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directIdentifier && linear && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingIssueKey === directIdentifier && 'bg-interactive-selection/30'
)}
onClick={() => handleIssue(directIdentifier)}
>
<span className="typography-meta text-muted-foreground w-16 text-right flex-shrink-0">
{directIdentifier}
</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{t('session.linearIssuePicker.actions.useIssue', { identifier: directIdentifier })}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueKey === directIdentifier ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{issues.length === 0 && !isLoading && connected && linear ? (
<div className="text-center text-muted-foreground py-8">
{debouncedQuery.trim()
? t('session.linearIssuePicker.empty.noIssuesFound')
: t('session.linearIssuePicker.empty.noOpenIssuesFound')}
</div>
) : null}
{issues.map((issue) => (
<div
key={issue.id}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingIssueKey === issue.id && 'bg-interactive-selection/30'
)}
onClick={() => handleIssue(issue.id)}
>
<span className="typography-meta text-muted-foreground w-16 text-right flex-shrink-0">
{issue.identifier}
</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{issue.title}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueKey === issue.id ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors',
alwaysShowActions ? 'flex' : 'hidden group-hover:flex'
)}
onClick={(e) => e.stopPropagation()}
aria-label={t('session.linearIssuePicker.actions.openInLinearAria')}
>
<Icon name="external-link" className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
{hasMore && connected && linear ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(startingIssueKey)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(startingIssueKey)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
)}
>
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.linearIssuePicker.loading.more')}
</span>
) : (
t('session.linearIssuePicker.actions.loadMore')
)}
</button>
</div>
) : null}
</div>
{mode !== 'select' ? (
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('session.linearIssuePicker.actions.sectionTitle')}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
<div
className="flex items-center gap-2 cursor-pointer"
role="button"
tabIndex={0}
aria-pressed={createInWorktree}
onClick={() => setCreateInWorktree((value) => !value)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setCreateInWorktree((value) => !value);
}
}}
>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCreateInWorktree((value) => !value);
}}
aria-label={t('session.linearIssuePicker.actions.toggleWorktreeAria')}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{createInWorktree ? (
<Icon name="checkbox" className="h-4 w-4 text-primary" />
) : (
<Icon name="checkbox-blank" className="h-4 w-4" />
)}
</button>
<span className="typography-meta text-muted-foreground">{t('session.linearIssuePicker.actions.createInWorktree')}</span>
</div>
<div className="hidden sm:block sm:flex-1" />
<Button variant="outline" size="sm" onClick={() => void refresh(debouncedQuery.trim())} disabled={isLoading || Boolean(startingIssueKey)}>
{t('session.linearIssuePicker.actions.refresh')}
</Button>
</div>
</div>
) : null}
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
title={title}
onClose={() => onOpenChange(false)}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
{closeButton}
</div>
<p className="typography-small text-muted-foreground">{description}</p>
</div>
)}
>
{content}
</MobileOverlayPanel>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<Icon name="linear" className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
@@ -29,11 +29,12 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store'; import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions'; import * as sessionActions from '@/sync/session-actions';
import { buildLinkedIssue } from '@/lib/linkedIssues'; import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
import { useGitProvider } from '@/lib/gitProvider'; import { useGitProvider } from '@/lib/gitProvider';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
@@ -43,6 +44,7 @@ import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/ope
import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import { renderMagicPrompt } from '@/lib/magicPrompts'; import { renderMagicPrompt } from '@/lib/magicPrompts';
import { postLinearSessionStarted } from '@/lib/linearSessionStatus';
import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch'; import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
import { import {
@@ -55,6 +57,7 @@ import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/use
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
import { GitLabIntegrationDialog } from './GitLabIntegrationDialog'; import { GitLabIntegrationDialog } from './GitLabIntegrationDialog';
import { GiteaIntegrationDialog } from './GiteaIntegrationDialog'; import { GiteaIntegrationDialog } from './GiteaIntegrationDialog';
import { LinearIssuePickerDialog } from './LinearIssuePickerDialog';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
@@ -72,6 +75,8 @@ import type {
GiteaIssue, GiteaIssue,
GiteaIssuesListResult, GiteaIssuesListResult,
GiteaPullRequestContextResult, GiteaPullRequestContextResult,
LinearIssue,
LinearIssueComment,
} from '@/lib/api/types'; } from '@/lib/api/types';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -85,6 +90,13 @@ interface ValidationState {
touched: boolean; touched: boolean;
} }
type LinkedLinearWorktreeIssue = {
identifier: string;
title: string;
url: string;
author?: { login: string; avatarUrl?: string };
};
// State for New Branch mode // State for New Branch mode
interface NewBranchState { interface NewBranchState {
branchName: string; branchName: string;
@@ -94,6 +106,7 @@ interface NewBranchState {
linkedIssue: GitHubIssue | null; linkedIssue: GitHubIssue | null;
linkedPr: GitHubPullRequestSummary | null; linkedPr: GitHubPullRequestSummary | null;
includePrDiff: boolean; includePrDiff: boolean;
linkedLinearIssue: LinkedLinearWorktreeIssue | null;
linkedGitLabIssue: { number: number; title: string; url: string } | null; linkedGitLabIssue: { number: number; title: string; url: string } | null;
linkedGitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null; linkedGitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null;
includeGitLabMrDiff: boolean; includeGitLabMrDiff: boolean;
@@ -262,13 +275,24 @@ const buildGiteaPrContextText = (payload: GiteaPullRequestContextResult) => {
return `Gitea pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; 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({ export function NewWorktreeDialog({
open, open,
onOpenChange, onOpenChange,
onWorktreeCreated, onWorktreeCreated,
}: NewWorktreeDialogProps) { }: NewWorktreeDialogProps) {
const { t } = useI18n(); const { t } = useI18n();
const { github, git, gitlab, gitea } = useRuntimeAPIs(); const { github, git, gitlab, gitea, linear } = useRuntimeAPIs();
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -278,6 +302,8 @@ export function NewWorktreeDialog({
const giteaAuthStatus = useGiteaAuthStore((state) => state.status); const giteaAuthStatus = useGiteaAuthStore((state) => state.status);
const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked); const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked);
const refreshGiteaAuth = useGiteaAuthStore((state) => state.refreshStatus); 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 activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null; const projectDirectory = activeProject?.path ?? null;
@@ -300,6 +326,7 @@ export function NewWorktreeDialog({
linkedIssue: null, linkedIssue: null,
linkedPr: null, linkedPr: null,
includePrDiff: false, includePrDiff: false,
linkedLinearIssue: null,
linkedGitLabIssue: null, linkedGitLabIssue: null,
linkedGitLabMr: null, linkedGitLabMr: null,
includeGitLabMrDiff: false, includeGitLabMrDiff: false,
@@ -357,6 +384,7 @@ export function NewWorktreeDialog({
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
const [gitlabDialogOpen, setGitlabDialogOpen] = React.useState(false); const [gitlabDialogOpen, setGitlabDialogOpen] = React.useState(false);
const [giteaDialogOpen, setGiteaDialogOpen] = 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" // Populate the GitLab auth status on mount so the "Start from GitLab issue/MR"
// action is available without first visiting Settings. refreshStatus dedupes // action is available without first visiting Settings. refreshStatus dedupes
@@ -564,6 +592,7 @@ export function NewWorktreeDialog({
issue: GitHubIssue | null; issue: GitHubIssue | null;
pr: GitHubPullRequestSummary | null; pr: GitHubPullRequestSummary | null;
includeDiff: boolean; includeDiff: boolean;
linearIssue: LinkedLinearWorktreeIssue | null;
gitLabIssue: { number: number; title: string; url: string } | null; gitLabIssue: { number: number; title: string; url: string } | null;
gitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null; gitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null;
includeGitLabMrDiff: boolean; includeGitLabMrDiff: boolean;
@@ -589,6 +618,65 @@ export function NewWorktreeDialog({
const variant = resolveDefaultVariant(providerID, modelID); 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 (args.issue) {
if (!github || !github.issueGet || !github.issueComments) { if (!github || !github.issueGet || !github.issueComments) {
return; return;
@@ -938,6 +1026,7 @@ export function NewWorktreeDialog({
github, github,
gitlab, gitlab,
gitea, gitea,
linear,
projectDirectory, projectDirectory,
resolveDefaultAgentName, resolveDefaultAgentName,
resolveDefaultModelSelection, resolveDefaultModelSelection,
@@ -1026,6 +1115,7 @@ export function NewWorktreeDialog({
linkedIssue: null, linkedIssue: null,
linkedPr: null, linkedPr: null,
includePrDiff: false, includePrDiff: false,
linkedLinearIssue: null,
linkedGitLabIssue: null, linkedGitLabIssue: null,
linkedGitLabMr: null, linkedGitLabMr: null,
includeGitLabMrDiff: false, includeGitLabMrDiff: false,
@@ -1199,13 +1289,14 @@ export function NewWorktreeDialog({
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; 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 linkedGitLabIssue = mode === 'new-branch' ? newBranchState.linkedGitLabIssue : null;
const linkedGitLabMr = mode === 'new-branch' ? newBranchState.linkedGitLabMr : null; const linkedGitLabMr = mode === 'new-branch' ? newBranchState.linkedGitLabMr : null;
const includeGitLabMrDiff = mode === 'new-branch' ? newBranchState.includeGitLabMrDiff : false; const includeGitLabMrDiff = mode === 'new-branch' ? newBranchState.includeGitLabMrDiff : false;
const linkedGiteaIssue = mode === 'new-branch' ? newBranchState.linkedGiteaIssue : null; const linkedGiteaIssue = mode === 'new-branch' ? newBranchState.linkedGiteaIssue : null;
const linkedGiteaPr = mode === 'new-branch' ? newBranchState.linkedGiteaPr : null; const linkedGiteaPr = mode === 'new-branch' ? newBranchState.linkedGiteaPr : null;
const includeGiteaPrDiff = mode === 'new-branch' ? newBranchState.includeGiteaPrDiff : false; 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 setupCommands = await getWorktreeSetupCommands(projectRef);
const sourceBranch = newBranchState.sourceBranch; const sourceBranch = newBranchState.sourceBranch;
@@ -1289,7 +1380,9 @@ export function NewWorktreeDialog({
await waitForWorktreeBootstrap(metadata.path); await waitForWorktreeBootstrap(metadata.path);
} }
const sessionTitle = linkedIssue const sessionTitle = linkedLinearIssue
? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim()
: linkedIssue
? `#${linkedIssue.number} ${linkedIssue.title}`.trim() ? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
: linkedPrState : linkedPrState
? `#${linkedPrState.number} ${linkedPrState.title}`.trim() ? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
@@ -1350,6 +1443,7 @@ export function NewWorktreeDialog({
issue: linkedIssue, issue: linkedIssue,
pr: linkedPrState, pr: linkedPrState,
includeDiff: includePrDiff, includeDiff: includePrDiff,
linearIssue: linkedLinearIssue,
gitLabIssue: linkedGitLabIssue, gitLabIssue: linkedGitLabIssue,
gitLabMr: linkedGitLabMr, gitLabMr: linkedGitLabMr,
includeGitLabMrDiff: includeGitLabMrDiff, includeGitLabMrDiff: includeGitLabMrDiff,
@@ -1360,7 +1454,10 @@ export function NewWorktreeDialog({
// There is no Gitea-branded send-context error key in the frozen // There is no Gitea-branded send-context error key in the frozen
// catalogs; the gitea path reuses the generic GitHub wording. // catalogs; the gitea path reuses the generic GitHub wording.
const isGitLabLink = Boolean(linkedGitLabIssue || linkedGitLabMr); const isGitLabLink = Boolean(linkedGitLabIssue || linkedGitLabMr);
const errorKey = isGitLabLink const isLinearLink = Boolean(linkedLinearIssue);
const errorKey = isLinearLink
? 'session.newWorktree.error.sendLinearContextFailed'
: isGitLabLink
? 'session.newWorktree.error.sendGitLabContextFailed' ? 'session.newWorktree.error.sendGitLabContextFailed'
: 'session.newWorktree.error.sendGitHubContextFailed'; : 'session.newWorktree.error.sendGitHubContextFailed';
const message = error instanceof Error ? error.message : t(errorKey); const message = error instanceof Error ? error.message : t(errorKey);
@@ -1790,10 +1887,10 @@ export function NewWorktreeDialog({
</div> </div>
)} )}
{!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( {existingBranchRankedGroups.otherLocal.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2"> <div className="typography-small font-semibold text-foreground px-2">
{t('session.newWorktree.localBranches')} {hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{existingBranchRankedGroups.otherLocal.map((branch) => ( {existingBranchRankedGroups.otherLocal.map((branch) => (
@@ -1822,10 +1919,10 @@ export function NewWorktreeDialog({
</div> </div>
)} )}
{!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( {existingBranchRankedGroups.otherRemote.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2"> <div className="typography-small font-semibold text-foreground px-2">
{t('session.newWorktree.remoteBranches')} {hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{existingBranchRankedGroups.otherRemote.map((branch) => ( {existingBranchRankedGroups.otherRemote.map((branch) => (
@@ -2111,10 +2208,10 @@ export function NewWorktreeDialog({
</div> </div>
)} )}
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( {sourceBranchRankedGroups.otherLocal.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2"> <div className="typography-small font-semibold text-foreground px-2">
{t('session.newWorktree.localBranches')} {hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{sourceBranchRankedGroups.otherLocal.map((branch) => ( {sourceBranchRankedGroups.otherLocal.map((branch) => (
@@ -2138,10 +2235,10 @@ export function NewWorktreeDialog({
</div> </div>
)} )}
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( {sourceBranchRankedGroups.otherRemote.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2"> <div className="typography-small font-semibold text-foreground px-2">
{t('session.newWorktree.remoteBranches')} {hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{sourceBranchRankedGroups.otherRemote.map((branch) => ( {sourceBranchRankedGroups.otherRemote.map((branch) => (
@@ -2370,9 +2467,10 @@ export function NewWorktreeDialog({
</div> </div>
)} )}
{!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( {existingBranchRankedGroups.otherLocal.length > 0 && (
<> <>
<CommandGroup heading={t('session.newWorktree.localBranches')}> {hasExistingBranchQuery && <CommandSeparator />}
<CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
{existingBranchRankedGroups.otherLocal.map((branch) => ( {existingBranchRankedGroups.otherLocal.map((branch) => (
<CommandItem <CommandItem
key={`local-${branch}`} key={`local-${branch}`}
@@ -2394,12 +2492,12 @@ export function NewWorktreeDialog({
</> </>
)} )}
{!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( {existingBranchRankedGroups.otherRemote.length > 0 && (
<> <>
{existingBranchRankedGroups.otherLocal.length > 0 && ( {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && (
<CommandSeparator /> <CommandSeparator />
)} )}
<CommandGroup heading={t('session.newWorktree.remoteBranches')}> <CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
{existingBranchRankedGroups.otherRemote.map((branch) => ( {existingBranchRankedGroups.otherRemote.map((branch) => (
<CommandItem <CommandItem
key={`remote-${branch}`} key={`remote-${branch}`}
@@ -2670,9 +2768,10 @@ export function NewWorktreeDialog({
</div> </div>
)} )}
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( {sourceBranchRankedGroups.otherLocal.length > 0 && (
<> <>
<CommandGroup heading={t('session.newWorktree.localBranches')}> {hasSourceBranchQuery && <CommandSeparator />}
<CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
{sourceBranchRankedGroups.otherLocal.map((branch) => ( {sourceBranchRankedGroups.otherLocal.map((branch) => (
<CommandItem <CommandItem
key={`local-${branch}`} key={`local-${branch}`}
@@ -2689,12 +2788,12 @@ export function NewWorktreeDialog({
</> </>
)} )}
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( {sourceBranchRankedGroups.otherRemote.length > 0 && (
<> <>
{sourceBranchRankedGroups.otherLocal.length > 0 && ( {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && (
<CommandSeparator /> <CommandSeparator />
)} )}
<CommandGroup heading={t('session.newWorktree.remoteBranches')}> <CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
{sourceBranchRankedGroups.otherRemote.map((branch) => ( {sourceBranchRankedGroups.otherRemote.map((branch) => (
<CommandItem <CommandItem
key={`remote-${branch}`} key={`remote-${branch}`}
@@ -592,13 +592,12 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
}, [mobileVariant, openNewSessionDraft, setSessionSwitcherOpen]); }, [mobileVariant, openNewSessionDraft, setSessionSwitcherOpen]);
return ( return (
// One shared tooltip provider for the whole sidebar: session tooltips open // One shared tooltip provider for the whole sidebar, matching the opencode
// instantly, and moving between rows hands the tooltip over (grouping) // sidebar feel: 400ms before the first tooltip opens, instant close on
// instead of replaying the exit/enter animation for each row. // leave, and grouping — moving between rows within 600ms hands the tooltip
// closeDelay bridges the small gap between rows: the tooltip survives the // over to the next row without replaying the open delay or exit/enter
// pointer crossing row margins, and the grouping timeout hands it over to // animation.
// the next row without an exit/enter cycle. <TooltipProvider delay={400} closeDelay={0} timeout={300}>
<TooltipProvider delay={0} closeDelay={150} timeout={600}>
<div <div
ref={sessionSearchContainerRef} ref={sessionSearchContainerRef}
className={cn( className={cn(
@@ -232,6 +232,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
availableWorktreesByProject: topology.availableWorktreesByProject, availableWorktreesByProject: topology.availableWorktreesByProject,
projectRepoStatus: topology.projectRepoStatus, projectRepoStatus: topology.projectRepoStatus,
projectRootBranches: topology.projectRootBranches, projectRootBranches: topology.projectRootBranches,
gitBranches: topology.gitBranches,
lastRepoStatus: topology.lastRepoStatus, lastRepoStatus: topology.lastRepoStatus,
buildGroupedSessions, buildGroupedSessions,
hasSessionSearchQuery: view.hasSessionSearchQuery, hasSessionSearchQuery: view.hasSessionSearchQuery,
@@ -5,9 +5,12 @@ import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
const SESSION_PREFETCH_HOVER_DELAY_MS = 180; const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
const SESSION_PREFETCH_SETTLE_MS = 600; const SESSION_PREFETCH_SETTLE_MS = 150;
const SESSION_PREFETCH_CONCURRENCY = 1; const SESSION_PREFETCH_CONCURRENCY = 2;
const SESSION_PREFETCH_PENDING_LIMIT = 6; const SESSION_PREFETCH_PENDING_LIMIT = 8;
// Nearest first: the rows right next to the open session are the likeliest
// next click.
const NEIGHBOR_PREFETCH_OFFSETS = [-1, 1, -2, 2];
type Args = { type Args = {
enabled?: boolean; enabled?: boolean;
@@ -132,8 +135,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId); const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) return; if (currentIndex < 0) return;
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]); for (const offset of NEIGHBOR_PREFETCH_OFFSETS) scheduleSessionPrefetch(sortedSessions[currentIndex + offset]);
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]);
}, SESSION_PREFETCH_SETTLE_MS); }, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]); }, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
@@ -145,8 +147,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId); const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) return; if (currentIndex < 0) return;
scheduleSessionPrefetch(recentSessions[currentIndex - 1]); for (const offset of NEIGHBOR_PREFETCH_OFFSETS) scheduleSessionPrefetch(recentSessions[currentIndex + offset]);
scheduleSessionPrefetch(recentSessions[currentIndex + 1]);
}, SESSION_PREFETCH_SETTLE_MS); }, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]); }, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
@@ -1200,7 +1200,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
</div> </div>
{group.isArchivedBucket && allGroupSessions.length > 0 ? ( {group.isArchivedBucket && allGroupSessions.length > 0 ? (
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}> <div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -1223,7 +1223,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
) : null} ) : null}
{group.directory && !group.isMain && group.worktree ? ( {group.directory && !group.isMain && group.worktree ? (
<div className={cn('absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}> <div className={cn('absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -1247,7 +1247,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
) : null} ) : null}
{group.directory ? ( {group.directory ? (
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}> <div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -342,7 +342,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
showCreateButtons ? 'right-7' : 'right-0.5', showCreateButtons ? 'right-7' : 'right-0.5',
)}> )}>
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? ( {showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -398,7 +398,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
{showCreateButtons && onNewSession ? ( {showCreateButtons && onNewSession ? (
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2"> <div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -28,6 +28,12 @@ const isArchivedSession = (session: Session): boolean => Boolean(session.time?.a
export const useSessionGrouping = (args: Args) => { export const useSessionGrouping = (args: Args) => {
const { t } = useI18n(); const { t } = useI18n();
// Read at call time rather than captured: the branch map is rebuilt whenever
// any directory's git status changes, and a builder that changed identity
// with it would invalidate every project section in the sidebar. The section
// cache compares the branches each project actually uses instead.
const gitBranchesRef = React.useRef(args.gitBranches);
gitBranchesRef.current = args.gitBranches;
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => { const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase(); return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
}, []); }, []);
@@ -233,7 +239,7 @@ export const useSessionGrouping = (args: Args) => {
const worktreeGroups = args.isVSCode ? [] : sortedWorktrees; const worktreeGroups = args.isVSCode ? [] : sortedWorktrees;
worktreeGroups.forEach((meta) => { worktreeGroups.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path; const directory = normalizePath(meta.path) ?? meta.path;
const currentBranch = args.gitBranches.get(directory)?.trim() || null; const currentBranch = gitBranchesRef.current.get(directory)?.trim() || null;
const metadataBranch = meta.branch?.trim() || null; const metadataBranch = meta.branch?.trim() || null;
const shouldSyncLabelWithBranch = Boolean( const shouldSyncLabelWithBranch = Boolean(
currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch), currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
@@ -274,7 +280,7 @@ export const useSessionGrouping = (args: Args) => {
return groups; return groups;
}, },
[args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t], [args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.isVSCode, t],
); );
return { return {
@@ -55,6 +55,7 @@ const renderSections = (group: SessionGroup, query: string): Sections => {
availableWorktreesByProject: new Map(), availableWorktreesByProject: new Map(),
projectRepoStatus: new Map(), projectRepoStatus: new Map(),
projectRootBranches: new Map(), projectRootBranches: new Map(),
gitBranches: new Map(),
lastRepoStatus: false, lastRepoStatus: false,
buildGroupedSessions: grouping.buildGroupedSessions, buildGroupedSessions: grouping.buildGroupedSessions,
hasSessionSearchQuery: query.length > 0, hasSessionSearchQuery: query.length > 0,
@@ -29,11 +29,23 @@ type ProjectSectionCacheEntry = {
archivedSessions: Session[]; archivedSessions: Session[];
availableWorktrees: WorktreeMetadata[]; availableWorktrees: WorktreeMetadata[];
rootBranch: string | null; rootBranch: string | null;
/** Current branch of every worktree directory the section renders. */
worktreeBranchesKey: string;
isRepo: boolean; isRepo: boolean;
buildGroupedSessions: Args['buildGroupedSessions']; buildGroupedSessions: Args['buildGroupedSessions'];
section: ProjectSection; section: ProjectSection;
}; };
const worktreeBranchesKeyFor = (
worktrees: WorktreeMetadata[],
gitBranches: ReadonlyMap<string, string | null>,
): string => worktrees
.map((worktree) => {
const directory = normalizePath(worktree.path) ?? worktree.path;
return `${directory}=${gitBranches.get(directory) ?? ''}`;
})
.join('\n');
const EMPTY_WORKTREES: WorktreeMetadata[] = []; const EMPTY_WORKTREES: WorktreeMetadata[] = [];
type Args = { type Args = {
@@ -43,6 +55,7 @@ type Args = {
availableWorktreesByProject: Map<string, WorktreeMetadata[]>; availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
projectRepoStatus: Map<string, boolean | null>; projectRepoStatus: Map<string, boolean | null>;
projectRootBranches: Map<string, string | null>; projectRootBranches: Map<string, string | null>;
gitBranches: ReadonlyMap<string, string | null>;
lastRepoStatus: boolean; lastRepoStatus: boolean;
buildGroupedSessions: ( buildGroupedSessions: (
sessions: Session[], sessions: Session[],
@@ -73,6 +86,7 @@ export const useSessionSidebarSections = (args: Args) => {
availableWorktreesByProject, availableWorktreesByProject,
projectRepoStatus, projectRepoStatus,
projectRootBranches, projectRootBranches,
gitBranches,
lastRepoStatus, lastRepoStatus,
buildGroupedSessions, buildGroupedSessions,
hasSessionSearchQuery, hasSessionSearchQuery,
@@ -101,6 +115,7 @@ export const useSessionSidebarSections = (args: Args) => {
? Boolean(projectRepoStatus.get(project.id)) ? Boolean(projectRepoStatus.get(project.id))
: lastRepoStatus; : lastRepoStatus;
const rootBranch = projectRootBranches.get(project.id) ?? null; const rootBranch = projectRootBranches.get(project.id) ?? null;
const worktreeBranchesKey = worktreeBranchesKeyFor(worktreesForProject, gitBranches);
const cached = previousCache.get(project.id); const cached = previousCache.get(project.id);
if ( if (
cached cached
@@ -109,6 +124,7 @@ export const useSessionSidebarSections = (args: Args) => {
&& sameSessions(cached.archivedSessions, archivedSessions) && sameSessions(cached.archivedSessions, archivedSessions)
&& cached.availableWorktrees === worktreesForProject && cached.availableWorktrees === worktreesForProject
&& cached.rootBranch === rootBranch && cached.rootBranch === rootBranch
&& cached.worktreeBranchesKey === worktreeBranchesKey
&& cached.isRepo === isRepo && cached.isRepo === isRepo
&& cached.buildGroupedSessions === buildGroupedSessions && cached.buildGroupedSessions === buildGroupedSessions
) { ) {
@@ -118,6 +134,19 @@ export const useSessionSidebarSections = (args: Args) => {
} }
rebuiltSections += 1; rebuiltSections += 1;
if (cached) {
// Diagnostic: name what invalidated the cached section so a sidebar
// that rebuilds on every session switch can be traced to its input.
const reason = cached.project !== project ? 'project'
: !sameSessions(cached.activeSessions, activeSessions) ? 'sessions'
: !sameSessions(cached.archivedSessions, archivedSessions) ? 'archived'
: cached.availableWorktrees !== worktreesForProject ? 'worktrees'
: cached.rootBranch !== rootBranch ? 'branch'
: cached.worktreeBranchesKey !== worktreeBranchesKey ? 'worktreeBranches'
: cached.isRepo !== isRepo ? 'repo'
: 'builder';
streamPerfCount(`ui.sidebar.project_section.rebuilt_reason.${reason}`);
}
const projectSessions = dedupeSessionsById([...activeSessions, ...archivedSessions]); const projectSessions = dedupeSessionsById([...activeSessions, ...archivedSessions]);
const groups = buildGroupedSessions( const groups = buildGroupedSessions(
projectSessions, projectSessions,
@@ -133,6 +162,7 @@ export const useSessionSidebarSections = (args: Args) => {
archivedSessions, archivedSessions,
availableWorktrees: worktreesForProject, availableWorktrees: worktreesForProject,
rootBranch, rootBranch,
worktreeBranchesKey,
isRepo, isRepo,
buildGroupedSessions, buildGroupedSessions,
section, section,
@@ -152,6 +182,7 @@ export const useSessionSidebarSections = (args: Args) => {
lastRepoStatus, lastRepoStatus,
buildGroupedSessions, buildGroupedSessions,
projectRootBranches, projectRootBranches,
gitBranches,
]); ]);
const visibleProjectSections = React.useMemo(() => { const visibleProjectSections = React.useMemo(() => {
@@ -23,7 +23,8 @@ import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession'; import type { ChildSessionExport } from '@/lib/exportSession';
import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context'; import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
import { useSessionMessageRecordsForExport } from '@/sync/use-sync'; import { usePrefetchSessionMessages, useSessionMessageRecordsForExport } from '@/sync/use-sync';
import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from '../folders/sessionFolderDnd'; import { DraggableSessionRow } from '../folders/sessionFolderDnd';
import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils'; import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils';
@@ -232,7 +233,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
}; };
return ( return (
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -406,6 +407,10 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
// selection must survive mixing sessions from different worktrees. // selection must survive mixing sessions from different worktrees.
const selectionScopeKey = projectId ?? sessionDirectory ?? null; const selectionScopeKey = projectId ?? sessionDirectory ?? null;
const loadExportRecords = useSessionMessageRecordsForExport(); const loadExportRecords = useSessionMessageRecordsForExport();
const prefetchSessionMessages = usePrefetchSessionMessages();
// Same gate as the sidebar's neighbor prefetch: the VS Code webview keeps
// its message traffic to what is actually opened.
const prefetchOnPressDisabled = isVSCode;
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled); const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const isRowSelected = useSessionMultiSelectStore( const isRowSelected = useSessionMultiSelectStore(
@@ -908,6 +913,20 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
if (mobileVariant && event.pointerType === 'touch') { if (mobileVariant && event.pointerType === 'touch') {
setIsTouchPressed(true); setIsTouchPressed(true);
} }
// The press is the earliest signal that this row is about to be opened.
// Starting the message load here puts the request on the wire before the
// click handler and the render it triggers, so a cold open overlaps the
// network round trip with that work instead of waiting for it.
if (
event.button === 0
&& !isActive
&& !selectionModeEnabled
&& !prefetchOnPressDisabled
&& sessionDirectory
&& !getSyncSessionMaterializationStatus(session.id, sessionDirectory).renderable
) {
void prefetchSessionMessages({ directory: sessionDirectory, sessionID: session.id }).catch(() => undefined);
}
}; };
const handleRowPointerEnd = (event: React.PointerEvent<HTMLButtonElement>) => { const handleRowPointerEnd = (event: React.PointerEvent<HTMLButtonElement>) => {
if (mobileVariant && event.pointerType === 'touch') { if (mobileVariant && event.pointerType === 'touch') {
@@ -1334,6 +1353,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
data-session-row={session.id} data-session-row={session.id}
data-session-scope={selectionScopeKey ?? ''} data-session-scope={selectionScopeKey ?? ''}
data-session-archived={archivedBucket ? '1' : '0'} data-session-archived={archivedBucket ? '1' : '0'}
aria-current={isActive ? 'page' : undefined}
onClick={handleRowBackgroundClick} onClick={handleRowBackgroundClick}
// Row geometry mirrors the zone-header band: full container // Row geometry mirrors the zone-header band: full container
// width, px-1.5 inner edge, a 14px icon-wide gutter (status // width, px-1.5 inner edge, a 14px icon-wide gutter (status
@@ -1707,24 +1727,27 @@ const areSessionRenderSemanticsEqual = (prev: Session, next: Session): boolean =
&& prev.time?.archived === next.time?.archived && prev.time?.archived === next.time?.archived
); );
const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => { // Returns the name of the first prop whose change requires a render, or null
if (prev.node.session.id !== next.node.session.id) return false; // when the row can skip it. The name feeds the stream perf counters so sidebar
if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return false; // churn is explained, not only counted.
if (!areNodeWorktreeRenderSemanticsEqual(prev.node, next.node)) return false; const sessionNodeItemPropsChange = (prev: SessionNodeItemProps, next: SessionNodeItemProps): string | null => {
if (prev.depth !== next.depth) return false; if (prev.node.session.id !== next.node.session.id) return 'node';
if (prev.groupDirectory !== next.groupDirectory) return false; if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return 'node';
if (prev.projectId !== next.projectId) return false; if (!areNodeWorktreeRenderSemanticsEqual(prev.node, next.node)) return 'node';
if (prev.archivedBucket !== next.archivedBucket) return false; if (prev.depth !== next.depth) return 'depth';
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false; if (prev.groupDirectory !== next.groupDirectory) return 'groupDirectory';
if (prev.mobileVariant !== next.mobileVariant) return false; if (prev.projectId !== next.projectId) return 'projectId';
if (prev.alwaysShowActions !== next.alwaysShowActions) return false; if (prev.archivedBucket !== next.archivedBucket) return 'archivedBucket';
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false; if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return 'renderContext';
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false; if (prev.mobileVariant !== next.mobileVariant) return 'mobileVariant';
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false; if (prev.alwaysShowActions !== next.alwaysShowActions) return 'alwaysShowActions';
if (prev.nodeStructureKey !== next.nodeStructureKey) return false; if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return 'hasSessionSearchQuery';
if (prev.relativeTimeTick !== next.relativeTimeTick) return false; if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return 'normalizedSessionSearchQuery';
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false; if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return 'notifyOnSubtasks';
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false; if (prev.nodeStructureKey !== next.nodeStructureKey) return 'nodeStructureKey';
if (prev.relativeTimeTick !== next.relativeTimeTick) return 'relativeTimeTick';
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return 'nodeDirectory';
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return 'secondaryMeta';
if (prev.pinnedSessionIds !== next.pinnedSessionIds if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& nodeHasPinnedMembershipChange( && nodeHasPinnedMembershipChange(
@@ -1735,11 +1758,11 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
prev.groupDirectory, prev.groupDirectory,
next.groupDirectory, next.groupDirectory,
)) { )) {
return false; return 'pinnedSessionIds';
} }
if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) { if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) {
return false; return 'expandedParents';
} }
if (prev.editingId !== next.editingId if (prev.editingId !== next.editingId
@@ -1747,7 +1770,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing) subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing) || subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) { )) {
return false; return 'editingId';
} }
if (prev.editTitle !== next.editTitle if (prev.editTitle !== next.editTitle
@@ -1755,7 +1778,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing) subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing) || subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) { )) {
return false; return 'editTitle';
} }
if (prev.copiedSessionId !== next.copiedSessionId if (prev.copiedSessionId !== next.copiedSessionId
@@ -1763,18 +1786,18 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
nodeContainsSessionId(prev.node, prev.copiedSessionId) nodeContainsSessionId(prev.node, prev.copiedSessionId)
|| nodeContainsSessionId(next.node, next.copiedSessionId) || nodeContainsSessionId(next.node, next.copiedSessionId)
)) { )) {
return false; return 'copiedSessionId';
} }
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) { if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = getRelevantMenuSessionId(prev); const prevMenuSessionId = getRelevantMenuSessionId(prev);
const nextMenuSessionId = getRelevantMenuSessionId(next); const nextMenuSessionId = getRelevantMenuSessionId(next);
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) { if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
return false; return 'openSidebarMenuKey';
} }
} }
return prev.setEditingId === next.setEditingId const callbacksEqual = prev.setEditingId === next.setEditingId
&& prev.setEditTitle === next.setEditTitle && prev.setEditTitle === next.setEditTitle
&& prev.handleSaveEdit === next.handleSaveEdit && prev.handleSaveEdit === next.handleSaveEdit
&& prev.handleCancelEdit === next.handleCancelEdit && prev.handleCancelEdit === next.handleCancelEdit
@@ -1791,6 +1814,15 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
&& prev.handleRestoreSession === next.handleRestoreSession && prev.handleRestoreSession === next.handleRestoreSession
&& prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad && prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad
&& prev.children === next.children; && prev.children === next.children;
if (!callbacksEqual) return 'callbacks';
return null;
};
const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
const changed = sessionNodeItemPropsChange(prev, next);
if (changed === null) return true;
streamPerfCount(`ui.sidebar_session_node.props_changed.${changed}`);
return false;
}; };
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual); export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);
@@ -97,15 +97,23 @@ export function SessionTreeItem({
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse); const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const descendantIds = React.useMemo(() => { // Keyed by the descendant ids themselves, not by node identity: the sidebar
// rebuilds a project's node tree whenever one of its session records
// changes, and a fresh array here would give every row in that project a
// new delete handler and force it to re-render.
const descendantIdsKey = React.useMemo(() => {
const ids: string[] = []; const ids: string[] = [];
const visit = (current: SessionNode) => current.children.forEach((child) => { const visit = (current: SessionNode) => current.children.forEach((child) => {
ids.push(child.session.id); ids.push(child.session.id);
visit(child); visit(child);
}); });
visit(node); visit(node);
return ids; return ids.join('\n');
}, [node]); }, [node]);
const descendantIds = React.useMemo(
() => (descendantIdsKey ? descendantIdsKey.split('\n') : []),
[descendantIdsKey],
);
const createFolderAndStartRename = React.useCallback((scopeKey: string, parentId?: string | null) => { const createFolderAndStartRename = React.useCallback((scopeKey: string, parentId?: string | null) => {
if (!scopeKey) return null; if (!scopeKey) return null;
if (parentId && useSessionFoldersStore.getState().collapsedFolderIds.has(parentId)) toggleFolderCollapse(parentId); if (parentId && useSessionFoldersStore.getState().collapsedFolderIds.has(parentId)) toggleFolderCollapse(parentId);
@@ -90,7 +90,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
icon inset inside the 24px buttons so the first glyph lines up icon inset inside the 24px buttons so the first glyph lines up
with the New-session icon above (16px from the sidebar edge). */} with the New-session icon above (16px from the sidebar edge). */}
<div className="ml-[3px] flex items-center gap-1.5"> <div className="ml-[3px] flex items-center gap-1.5">
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -104,7 +104,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.addProject')}</p></TooltipContent> <TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.addProject')}</p></TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -118,7 +118,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent> <TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -133,7 +133,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newMultiRun')}</p></TooltipContent> <TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newMultiRun')}</p></TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -149,7 +149,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -164,7 +164,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.searchSessions')}</p></TooltipContent> <TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.searchSessions')}</p></TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
@@ -186,7 +186,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
</Tooltip> </Tooltip>
<DropdownMenu> <DropdownMenu>
<Tooltip delayDuration={500}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
+21 -4
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore'; import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore'; import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope'; import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
@@ -997,7 +999,11 @@ export const DiffView: React.FC<DiffViewProps> = ({
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const { git, files } = useRuntimeAPIs(); const { git, files } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory(); const rootDirectory = useEffectiveDirectory();
// Diffs belong to the repository being diffed: when the root is not
// itself a repository, operate on the resolved nested repository instead.
const { rootIsGitRepo, gitDirectory: nestedGitDirectory, nestedRepos: nestedRepoOptions } = useNestedGitDirectory(rootDirectory ?? null);
const effectiveDirectory = nestedGitDirectory ?? rootDirectory;
const openContextSurface = useUIStore((state) => state.openContextSurface); const openContextSurface = useUIStore((state) => state.openContextSurface);
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource); const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
const { screenWidth, isMobile } = useDeviceInfo(); const { screenWidth, isMobile } = useDeviceInfo();
@@ -1007,6 +1013,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null); const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus); const ensureStatus = useGitStore((state) => state.ensureStatus);
const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches); const fetchBranches = useGitStore((state) => state.fetchBranches);
const clearDiffCache = useGitStore((state) => state.clearDiffCache); const clearDiffCache = useGitStore((state) => state.clearDiffCache);
@@ -1038,7 +1045,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines); const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine); const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory ?? undefined); const sessionMessages = useSessionMessages(currentSessionId ?? '', rootDirectory ?? undefined);
const diffWrapLines = diffWrapLinesStore; const diffWrapLines = diffWrapLinesStore;
const forcedStaged = activeDiffScope === 'staged' ? true : activeDiffScope === 'working' ? false : null; const forcedStaged = activeDiffScope === 'staged' ? true : activeDiffScope === 'working' ? false : null;
const activeDiffStaged = forcedStaged ?? displayFileStaged; const activeDiffStaged = forcedStaged ?? displayFileStaged;
@@ -1645,7 +1652,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => { const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => {
if (!currentSessionId) return; if (!currentSessionId) return;
const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || effectiveDirectory || ''; const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || rootDirectory || '';
if (!directory) { if (!directory) {
toast.error(t('diffView.reviewDialog.toast.noSessionDirectory')); toast.error(t('diffView.reviewDialog.toast.noSessionDirectory'));
return; return;
@@ -1671,7 +1678,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
} finally { } finally {
setReviewFlowSubmitting(false); setReviewFlowSubmitting(false);
} }
}, [currentSessionId, effectiveDirectory, t]); }, [currentSessionId, rootDirectory, t]);
const scrollToFile = React.useCallback((path: string): boolean => { const scrollToFile = React.useCallback((path: string): boolean => {
const node = fileSectionRefs.current.get(path); const node = fileSectionRefs.current.get(path);
@@ -2070,6 +2077,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
return ( return (
<div className="flex h-full flex-col overflow-hidden bg-background"> <div className="flex h-full flex-col overflow-hidden bg-background">
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background"> <div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
{rootIsGitRepo === false && Array.isArray(nestedRepoOptions) && nestedRepoOptions.length > 0 ? (
<NestedRepoPicker
repositories={nestedRepoOptions}
selectedRepository={nestedGitDirectory ?? null}
onSelectRepository={(repository) => {
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
}}
repositoryRoot={rootDirectory ?? undefined}
/>
) : null}
{!isMobile && ( {!isMobile && (
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? ( activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
<ChangeScopeSelector <ChangeScopeSelector
+31 -2
View File
@@ -77,6 +77,7 @@ import { getDefaultTheme } from '@/lib/theme/themes';
import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { useKeybind, useKeybinds } from '@/hooks/useKeybind'; import { useKeybind, useKeybinds } from '@/hooks/useKeybind';
import { isEditableEventTarget } from '@/hooks/keyboard-shortcut-dom';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
@@ -973,6 +974,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [mdPreviewFindOpen, setMdPreviewFindOpen] = React.useState(false); const [mdPreviewFindOpen, setMdPreviewFindOpen] = React.useState(false);
const [mdPreviewFindFocusNonce, setMdPreviewFindFocusNonce] = React.useState(0); const [mdPreviewFindFocusNonce, setMdPreviewFindFocusNonce] = React.useState(0);
const mdPreviewContainerRef = React.useRef<HTMLDivElement | null>(null); const mdPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
// Give the rendered preview keyboard focus (without scrolling it) unless the
// user is typing somewhere else, so Cmd/Ctrl+F opens the preview find bar
// right after a Markdown file opens and after any click inside it.
const focusMdPreviewContainer = React.useCallback((event?: React.MouseEvent<HTMLDivElement>) => {
const container = event?.currentTarget ?? mdPreviewContainerRef.current;
if (!container) return;
const active = document.activeElement;
if (active && active !== document.body && active !== container) {
if (isEditableEventTarget(active)) return;
if (container.contains(active)) return;
}
container.focus({ preventScroll: true });
}, []);
const mdFullscreenPreviewContainerRef = React.useRef<HTMLDivElement | null>(null); const mdFullscreenPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const canCreateFile = Boolean(files.writeFile); const canCreateFile = Boolean(files.writeFile);
@@ -2506,6 +2520,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return mdViewMode; return mdViewMode;
}, [mdViewMode]); }, [mdViewMode]);
const mdPreviewFocusTargetPath = selectedFile && isMarkdown && getMdViewMode() === 'preview' && !fileLoading
? selectedFile.path
: null;
React.useEffect(() => {
if (!mdPreviewFocusTargetPath || isMobile) return;
focusMdPreviewContainer();
}, [focusMdPreviewContainer, isFullscreen, isMobile, mdPreviewFocusTargetPath]);
const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => { const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => {
setJsonViewMode(mode); setJsonViewMode(mode);
try { try {
@@ -3884,7 +3906,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? ( ) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
<div className="relative h-full min-h-0"> <div className="relative h-full min-h-0">
<div <div
className="oc-file-preview h-full overflow-auto p-3" className="oc-file-preview h-full overflow-auto p-3 outline-none"
// Focusable so Cmd/Ctrl+F reaches the find bar: the keybind only
// fires when the event target sits inside this container, and a
// plain div never holds focus. -1 keeps it out of the tab order.
tabIndex={-1}
onMouseDown={focusMdPreviewContainer}
ref={(node) => { ref={(node) => {
markdownPreviewRef.current = node; markdownPreviewRef.current = node;
mdPreviewContainerRef.current = node; mdPreviewContainerRef.current = node;
@@ -4274,7 +4301,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
// highlighted by the search it drives. // highlighted by the search it drives.
<div className="relative h-full min-h-0"> <div className="relative h-full min-h-0">
<div <div
className="oc-file-preview h-full overflow-auto p-4" className="oc-file-preview h-full overflow-auto p-4 outline-none"
tabIndex={-1}
onMouseDown={focusMdPreviewContainer}
ref={(node) => { ref={(node) => {
markdownPreviewRef.current = node; markdownPreviewRef.current = node;
mdFullscreenPreviewContainerRef.current = node; mdFullscreenPreviewContainerRef.current = node;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,11 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot'; import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-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 { useShallow } from 'zustand/react/shallow';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { getRuntimeKey } from '@/lib/runtime-switch'; import { getRuntimeKey } from '@/lib/runtime-switch';
@@ -15,6 +16,8 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { PullRequestSection } from './git/PullRequestSection'; import { PullRequestSection } from './git/PullRequestSection';
import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates';
import { NestedRepoPicker } from './git/NestedRepoPicker';
import { GitHubIssuesSection } from './git/GitHubIssuesSection'; import { GitHubIssuesSection } from './git/GitHubIssuesSection';
import { deriveBaseBranch } from './git/baseBranch'; import { deriveBaseBranch } from './git/baseBranch';
@@ -38,9 +41,17 @@ export const PullRequestView: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const { git } = useRuntimeAPIs(); const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory(); const currentDirectory = useEffectiveDirectory();
const status = useGitStatus(currentDirectory ?? null); // When the root is not itself a repository, the pull-request workflow
const branches = useGitBranches(currentDirectory ?? null); // operates on the resolved nested repository instead.
const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll }))); 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 currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -91,11 +102,11 @@ export const PullRequestView: React.FC = () => {
const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined); const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined);
React.useEffect(() => { React.useEffect(() => {
if (!currentDirectory || !git) { if (!gitDirectory || !git) {
return; return;
} }
void ensureAll(currentDirectory, git); void ensureAll(gitDirectory, git);
}, [currentDirectory, ensureAll, git]); }, [gitDirectory, ensureAll, git]);
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null); const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
@@ -124,52 +135,52 @@ export const PullRequestView: React.FC = () => {
}, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]); }, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]);
const [remotes, setRemotes] = React.useState<GitRemote[]>(() => const [remotes, setRemotes] = React.useState<GitRemote[]>(() =>
(currentDirectory ? remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? [] (gitDirectory ? remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? []
); );
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(() => const [remoteUrl, setRemoteUrl] = React.useState<string | null>(() =>
(currentDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? null (gitDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? null
); );
React.useEffect(() => { React.useEffect(() => {
if (!currentDirectory || !git?.getRemotes) { if (!gitDirectory || !git?.getRemotes) {
setRemotes([]); setRemotes([]);
return; return;
} }
setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []);
let cancelled = false; let cancelled = false;
void git.getRemotes(currentDirectory) void git.getRemotes(gitDirectory)
.then((remoteList) => { .then((remoteList) => {
if (cancelled) return; if (cancelled) return;
remotesCacheByDirectory.set(remoteCacheKey(currentDirectory), remoteList ?? []); remotesCacheByDirectory.set(remoteCacheKey(gitDirectory), remoteList ?? []);
setRemotes(remoteList ?? []); setRemotes(remoteList ?? []);
}) })
.catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); }); .catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentDirectory, git]); }, [gitDirectory, git]);
React.useEffect(() => { React.useEffect(() => {
if (!currentDirectory || !git?.getRemoteUrl) { if (!gitDirectory || !git?.getRemoteUrl) {
setRemoteUrl(null); setRemoteUrl(null);
return; return;
} }
setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null);
let cancelled = false; let cancelled = false;
void git.getRemoteUrl(currentDirectory) void git.getRemoteUrl(gitDirectory)
.then((url) => { .then((url) => {
if (cancelled) return; if (cancelled) return;
remoteUrlCacheByDirectory.set(remoteCacheKey(currentDirectory), url); remoteUrlCacheByDirectory.set(remoteCacheKey(gitDirectory), url);
setRemoteUrl(url); setRemoteUrl(url);
}) })
.catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); }); .catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentDirectory, git]); }, [gitDirectory, git]);
const localBranches = React.useMemo(() => { const localBranches = React.useMemo(() => {
if (!branches?.all) return []; if (!branches?.all) return [];
@@ -261,6 +272,27 @@ export const PullRequestView: React.FC = () => {
return prEmptyState; 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 (
<NestedRepoResolutionStates
rootIsGitRepo={rootIsGitRepo}
resolvedIsGitRepo={isGitRepo}
nestedRepos={nestedRepos}
onRetryDiscovery={() => {
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 ( return (
<ScrollableOverlay <ScrollableOverlay
as={ScrollShadow} as={ScrollShadow}
@@ -270,6 +302,18 @@ export const PullRequestView: React.FC = () => {
preventOverscroll preventOverscroll
> >
<div className="flex h-full min-h-0 flex-col gap-4"> <div className="flex h-full min-h-0 flex-col gap-4">
{showRepositoryPicker ? (
<div className="flex shrink-0 items-center border-b border-border/60 px-4 py-2">
<NestedRepoPicker
repositories={nestedRepos}
selectedRepository={gitDirectory ?? null}
onSelectRepository={(repository) => {
if (currentDirectory) selectNestedRepo(currentDirectory, repository);
}}
repositoryRoot={currentDirectory ?? undefined}
/>
</div>
) : null}
<div className="flex h-8 min-w-0"> <div className="flex h-8 min-w-0">
<SortableTabsStrip <SortableTabsStrip
className="h-full" className="h-full"
@@ -288,7 +332,7 @@ export const PullRequestView: React.FC = () => {
{activeTab === 'pr' ? ( {activeTab === 'pr' ? (
currentBranch ? ( currentBranch ? (
<PullRequestSection <PullRequestSection
directory={currentDirectory} directory={gitDirectory ?? currentDirectory}
branch={currentBranch} branch={currentBranch}
baseBranch={baseBranch} baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined} trackingBranch={status?.tracking ?? undefined}
@@ -1000,7 +1000,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
: <Icon name={iconName!} className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />} : <Icon name={iconName!} className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />}
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100"> <span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span> <span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
{(page.slug === 'tunnel' || page.slug === 'integrations') && ( {page.slug === 'tunnel' && (
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10"> <span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
{t('settings.view.badge.beta')} {t('settings.view.badge.beta')}
</span> </span>
@@ -12,6 +12,7 @@ import type { IconName } from "@/components/icon/icons";
import { BranchSelector } from './BranchSelector'; import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions'; import { SyncActions } from './SyncActions';
import { NestedRepoPicker } from './NestedRepoPicker';
import type { import type {
GitStatus, GitStatus,
GitIdentityProfile, GitIdentityProfile,
@@ -53,6 +54,13 @@ interface GitHeaderProps {
pullRequest?: GitHubPullRequest | null; pullRequest?: GitHubPullRequest | null;
prChecks?: GitHubChecksSummary | null; prChecks?: GitHubChecksSummary | null;
onOpenPullRequest?: () => void; 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; gitLabMr?: GitLabMergeRequestSummary | null;
onOpenGitLabMr?: () => void; onOpenGitLabMr?: () => void;
giteaPr?: GiteaPullRequestSummary | null; giteaPr?: GiteaPullRequestSummary | null;
@@ -264,6 +272,10 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
pullRequest, pullRequest,
prChecks, prChecks,
onOpenPullRequest, onOpenPullRequest,
repositoryOptions,
selectedRepository,
onSelectRepository,
repositoryRoot,
gitLabMr, gitLabMr,
onOpenGitLabMr, onOpenGitLabMr,
giteaPr, giteaPr,
@@ -274,6 +286,8 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
return null; return null;
} }
const repositoryOptionsForPicker = (repositoryOptions ?? []).filter(Boolean);
const managementButtons = ( const managementButtons = (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? ( {onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? (
@@ -488,7 +502,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
return ( return (
<header className="@container/git-header px-3 py-2 bg-transparent"> <header className="@container/git-header px-3 py-2 bg-transparent">
<div className="flex items-center justify-between gap-2 min-w-0"> <div className="flex items-center justify-between gap-2 min-w-0">
<div className="min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-1">
{isWorktreeMode ? ( {isWorktreeMode ? (
<WorktreeBranchDisplay <WorktreeBranchDisplay
currentBranch={status.current} currentBranch={status.current}
@@ -505,6 +519,14 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
remotes={remotes} remotes={remotes}
/> />
)} )}
{repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
<NestedRepoPicker
repositories={repositoryOptionsForPicker}
selectedRepository={selectedRepository ?? null}
onSelectRepository={onSelectRepository}
repositoryRoot={repositoryRoot}
/>
) : null}
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{identityControl} {identityControl}
@@ -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<NestedRepoPickerProps> = ({
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 (
<Select
value={selectedRepository ?? undefined}
onValueChange={(value) => {
if (value) {
onSelectRepository(value);
}
}}
>
<SelectTrigger
size="sm"
className="max-w-[13rem] gap-1.5 px-2 py-1"
aria-label={t('gitView.empty.selectRepositoryPlaceholder')}
>
<Icon name="folder-3" className="size-4 text-muted-foreground" />
<span className="min-w-0 truncate font-medium text-left">
{selectedRepository ? relativePath(selectedRepository) : ''}
</span>
</SelectTrigger>
<SelectContent align="start">
{repositories.map((repository) => (
<SelectItem key={repository} value={repository}>
<span className="truncate">{relativePath(repository)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
@@ -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<typeof NestedRepoResolutionStates>): string =>
renderToStaticMarkup(
<I18nProvider>
<NestedRepoResolutionStates {...props} />
</I18nProvider>,
);
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...');
});
});
@@ -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<NestedRepoResolutionStatesProps> = ({
rootIsGitRepo,
resolvedIsGitRepo,
nestedRepos,
onRetryDiscovery,
emptyStateFooter,
}) => {
const { t } = useI18n();
if (rootIsGitRepo !== false) return null;
if (resolvedIsGitRepo === true) return null;
if (nestedRepos === undefined || nestedRepos === null) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
{nestedRepos === null
? t('gitView.empty.discoverFailed')
: t('gitView.empty.discoveringRepositories')}
</p>
{nestedRepos === null ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3 gap-1.5"
onClick={onRetryDiscovery}
>
<Icon name="refresh" className="size-4" />
{t('gitView.empty.retryDiscovery')}
</Button>
) : null}
</div>
);
}
if (nestedRepos === 'unsupported' || nestedRepos.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<Icon name="git-branch" className="mb-3 size-6 text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
{t('gitView.empty.notGitRepository')}
</p>
<p className="typography-meta mt-1 text-muted-foreground">
{t('gitView.empty.notGitRepositoryDescription')}
</p>
{emptyStateFooter}
</div>
);
}
// Repositories were found and one is about to be auto-selected (or the
// selected repository is still probing) — hold a brief loading state.
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
{t('gitView.loading.checkingRepository')}
</p>
</div>
);
};
@@ -20,6 +20,7 @@ import { useDeviceInfo } from '@/lib/device';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { GitHubAccountControl } from '@/components/github/GitHubAccountControl';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction'; 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; 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 => { const branchToTitle = (branch: string): string => {
return branch return branch
@@ -346,7 +351,7 @@ export const PullRequestSection: React.FC<{
const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime(); const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime();
const openGitHubSettings = React.useCallback(() => { const openGitHubSettings = React.useCallback(() => {
setSettingsPage('github'); setSettingsPage('integrations');
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]); }, [setSettingsDialogOpen, setSettingsPage]);
@@ -1175,6 +1180,31 @@ export const PullRequestSection: React.FC<{
await refreshPrStatus(prStatusKey, options); await refreshPrStatus(prStatusKey, options);
}, [prStatusKey, refreshPrStatus]); }, [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(() => { const scheduleActionRefresh = React.useCallback(() => {
pendingActionRefreshTimersRef.current.forEach((timerId) => { pendingActionRefreshTimersRef.current.forEach((timerId) => {
window.clearTimeout(timerId); window.clearTimeout(timerId);
@@ -1555,7 +1585,10 @@ export const PullRequestSection: React.FC<{
return ( return (
<section className="border-0 bg-transparent rounded-none"> <section className="border-0 bg-transparent rounded-none">
<div className="space-y-1 pt-3"> <div className="space-y-1 pt-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.title')}</div> <div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.title')}</div>
<GitHubAccountControl />
</div>
<div className="typography-micro text-muted-foreground"> <div className="typography-micro text-muted-foreground">
{t('gitView.pullRequest.availableOnFeatureBranches')} {t('gitView.pullRequest.availableOnFeatureBranches')}
</div> </div>
@@ -1599,7 +1632,7 @@ export const PullRequestSection: React.FC<{
return ( return (
<section className={containerClassName}> <section className={containerClassName}>
<div className={headerClassName}> <div className={headerClassName}>
<div className="flex items-start justify-between gap-2"> <div className="@container/pr-actions flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{pr ? ( {pr ? (
<Button <Button
@@ -1621,43 +1654,8 @@ export const PullRequestSection: React.FC<{
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span> <span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 items-center gap-1">
{isLoading ? <Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" /> : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-5 items-center justify-center rounded hover:bg-interactive-hover/60 disabled:opacity-40"
disabled={isLoading}
onClick={() => void refresh({ force: true })}
aria-label={t('gitView.pr.actions.refreshAria')}
>
<Icon name="refresh" className="size-3.5 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent><p>{t('gitView.pr.actions.refresh')}</p></TooltipContent>
</Tooltip>
</div>
</div>
{pr ? (
<div className="@container/pr-actions flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
<span style={{ color: prColorVar }}>{prStatusText}</span>
{checks ? (
<span className="inline-flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} />
{checksText}
</span>
) : null}
{trackingBranch && selectedRemote && trackingBranch.split('/')[0] !== selectedRemote.name ? (
<span className="min-w-0 truncate">
{trackingBranch.split('/')[0]} → {selectedRemote.name}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{showWalkthroughAction ? ( {pr && showWalkthroughAction ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -1674,6 +1672,44 @@ export const PullRequestSection: React.FC<{
</span> </span>
</Button> </Button>
) : null} ) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 px-0"
disabled={isLoading || isManualRefreshing}
onClick={() => void refreshManually()}
aria-label={t('gitView.pr.actions.refreshAria')}
>
{isLoading || isManualRefreshing
? <Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" />
: <Icon name="refresh" className="size-4 text-muted-foreground" />}
</Button>
</TooltipTrigger>
<TooltipContent><p>{t('gitView.pr.actions.refresh')}</p></TooltipContent>
</Tooltip>
<GitHubAccountControl className="h-7 w-7" />
</div>
</div>
{pr ? (
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
<span style={{ color: prColorVar }}>{prStatusText}</span>
{checks ? (
<span className="inline-flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} />
{checksText}
</span>
) : null}
{trackingBranch && selectedRemote && trackingBranch.split('/')[0] !== selectedRemote.name ? (
<span className="min-w-0 truncate">
{trackingBranch.split('/')[0]} → {selectedRemote.name}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{canMerge && pr.draft && pr.state === 'open' ? ( {canMerge && pr.draft && pr.state === 'open' ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -22,7 +22,7 @@ import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch'; import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { useConfigStore } from '@/stores/useConfigStore'; 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 { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { import {
getFreshestPrStatusForBranch, getFreshestPrStatusForBranch,
@@ -30,6 +30,7 @@ import {
useGitHubPrStatusStore, useGitHubPrStatusStore,
} from '@/stores/useGitHubPrStatusStore'; } from '@/stores/useGitHubPrStatusStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -39,9 +40,17 @@ import { WalkthroughStages } from './WalkthroughStages';
import { useWalkthroughStageProgress } from './useWalkthroughStageProgress'; import { useWalkthroughStageProgress } from './useWalkthroughStageProgress';
import { WalkthroughStream } from './WalkthroughStream'; import { WalkthroughStream } from './WalkthroughStream';
import { WalkthroughToc } from './WalkthroughToc'; import { WalkthroughToc } from './WalkthroughToc';
import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
interface WalkthroughViewProps { interface WalkthroughViewProps {
directory: string; 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']; 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. // pickers, 32px action, 36px arrows) read as misalignment, not hierarchy.
const HEADER_COMPACT_WIDTH = 680; 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 { t, locale, locales, label } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null); const rootRef = useRef<HTMLDivElement | null>(null);
const [panelWidth, setPanelWidth] = useState(0); 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 // Panel width, not viewport width: this surface is resizable independently of
// the window. // the window.
useEffect(() => { useEffect(() => {
@@ -502,9 +517,38 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
[activeLanguage, directory, generate, generateDisabled, source] [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 (
<NestedRepoResolutionStates
rootIsGitRepo={rootIsGitRepo}
resolvedIsGitRepo={isGitRepo}
nestedRepos={nestedRepos}
onRetryDiscovery={() => {
if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true });
}}
/>
);
}
return ( return (
<div ref={rootRef} className="flex h-full min-h-0 flex-col"> <div ref={rootRef} className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2"> <header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
{rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? (
<NestedRepoPicker
repositories={nestedRepos}
selectedRepository={gitDirectory ?? null}
onSelectRepository={(repository) => {
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
}}
repositoryRoot={rootDirectory ?? undefined}
/>
) : null}
<DropdownMenu open={sourceMenuOpen} onOpenChange={setSourceMenuOpen}> <DropdownMenu open={sourceMenuOpen} onOpenChange={setSourceMenuOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
+30 -61
View File
@@ -31,6 +31,12 @@ import {
import { isValidTheme } from './theme-validation'; import { isValidTheme } from './theme-validation';
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload'; import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import {
adoptThemePreferencesForRuntime,
resolveThemePreferencesForRuntime,
resolveThemePreferencesFromStorageEvent,
writeThemePreferencesForRuntime,
} from './theme-storage';
type ThemePreferences = { type ThemePreferences = {
themeMode: ThemeMode; themeMode: ThemeMode;
@@ -87,46 +93,27 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
const embeddedMode = embeddedParams?.get('themeMode'); const embeddedMode = embeddedParams?.get('themeMode');
const embeddedLightId = embeddedParams?.get('lightThemeId'); const embeddedLightId = embeddedParams?.get('lightThemeId');
const embeddedDarkId = embeddedParams?.get('darkThemeId'); const embeddedDarkId = embeddedParams?.get('darkThemeId');
const storedMode = localStorage.getItem('themeMode'); // Scoped entry when present; otherwise a one-time seed from the superseded
const storedLightId = localStorage.getItem('lightThemeId'); // global keys (see resolveThemePreferencesForRuntime), so the first scoped
const storedDarkId = localStorage.getItem('darkThemeId'); // write carries the last-known theme instead of defaults.
const legacyUseSystem = localStorage.getItem('useSystemTheme'); const resolvedPreferences = resolveThemePreferencesForRuntime(getRuntimeKey());
const legacyThemeId = localStorage.getItem('selectedThemeId');
const legacyVariant = localStorage.getItem('selectedThemeVariant');
if (embeddedMode === 'light' || embeddedMode === 'dark' || embeddedMode === 'system') { if (embeddedMode === 'light' || embeddedMode === 'dark' || embeddedMode === 'system') {
themeMode = embeddedMode; themeMode = embeddedMode;
} else if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') {
themeMode = storedMode;
} else if (legacyUseSystem !== null) {
const useSystem = legacyUseSystem === 'true';
if (useSystem) {
themeMode = 'system';
} else if (legacyThemeId) {
const legacyTheme = getThemeById(legacyThemeId);
if (legacyTheme) {
themeMode = legacyTheme.metadata.variant === 'dark' ? 'dark' : 'light';
if (legacyTheme.metadata.variant === 'dark') {
darkThemeId = legacyTheme.metadata.id;
} else { } else {
lightThemeId = legacyTheme.metadata.id; themeMode = resolvedPreferences.themeMode;
}
}
}
} else if (legacyVariant === 'light' || legacyVariant === 'dark') {
themeMode = legacyVariant;
} }
if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) { if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) {
lightThemeId = embeddedLightId.trim(); lightThemeId = embeddedLightId.trim();
} else if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) { } else {
lightThemeId = storedLightId.trim(); lightThemeId = resolvedPreferences.lightThemeId;
} }
if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) { if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) {
darkThemeId = embeddedDarkId.trim(); darkThemeId = embeddedDarkId.trim();
} else if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) { } else {
darkThemeId = storedDarkId.trim(); darkThemeId = resolvedPreferences.darkThemeId;
} }
} }
@@ -314,6 +301,9 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
customThemesRequestRef.current += 1; customThemesRequestRef.current += 1;
setCustomThemes([]); setCustomThemes([]);
setCustomThemesLoading(false); setCustomThemesLoading(false);
// Adopt the new instance's last-known theme immediately; the incoming
// settings sync refines it with the server's authoritative value.
setPreferences((prev) => adoptThemePreferencesForRuntime(detail.runtimeKey, prev));
void reloadCustomThemes(); void reloadCustomThemes();
}), [isVSCode, reloadCustomThemes]); }), [isVSCode, reloadCustomThemes]);
@@ -424,6 +414,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return; return;
} }
writeThemePreferencesForRuntime(getRuntimeKey(), {
themeMode: preferences.themeMode,
lightThemeId: preferences.lightThemeId,
darkThemeId: preferences.darkThemeId,
});
// Cosmetic last-writer-wins hints for the pre-React splash shells
// (packages/web/index.html, mobile.html, mini-chat.html) and the Android
// status bar, which run before the scoped key can be read. Not part of the
// app's theme authority — the scoped entry and the per-instance server
// settings own that.
localStorage.setItem('themeMode', preferences.themeMode); localStorage.setItem('themeMode', preferences.themeMode);
localStorage.setItem('lightThemeId', preferences.lightThemeId); localStorage.setItem('lightThemeId', preferences.lightThemeId);
localStorage.setItem('darkThemeId', preferences.darkThemeId); localStorage.setItem('darkThemeId', preferences.darkThemeId);
@@ -434,8 +435,6 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
currentTheme.metadata.variant === 'light' ? 'light' : 'dark', currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
); );
// Splash screen (packages/web/index.html) runs before the theme CSS vars load.
// Persist just enough to theme it on next boot.
const lightTheme = ensureThemeById(preferences.lightThemeId, 'light'); const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark'); const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
@@ -459,37 +458,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return; return;
} }
if (event.key !== 'themeMode' && event.key !== 'lightThemeId' && event.key !== 'darkThemeId') { setPreferences((prev) => resolveThemePreferencesFromStorageEvent(event.key, getRuntimeKey(), prev) ?? prev);
return;
}
setPreferences((prev) => {
const nextModeRaw = localStorage.getItem('themeMode');
const nextMode: ThemeMode =
nextModeRaw === 'light' || nextModeRaw === 'dark' || nextModeRaw === 'system'
? nextModeRaw
: prev.themeMode;
const nextLightRaw = localStorage.getItem('lightThemeId');
const nextLight = typeof nextLightRaw === 'string' && nextLightRaw.trim().length > 0
? nextLightRaw.trim()
: prev.lightThemeId;
const nextDarkRaw = localStorage.getItem('darkThemeId');
const nextDark = typeof nextDarkRaw === 'string' && nextDarkRaw.trim().length > 0
? nextDarkRaw.trim()
: prev.darkThemeId;
if (nextMode === prev.themeMode && nextLight === prev.lightThemeId && nextDark === prev.darkThemeId) {
return prev;
}
return {
themeMode: nextMode,
lightThemeId: nextLight,
darkThemeId: nextDark,
};
});
}; };
window.addEventListener('storage', handleStorage); window.addEventListener('storage', handleStorage);
@@ -0,0 +1,290 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
import {
adoptThemePreferencesForRuntime,
getThemePreferencesStorageKey,
readThemePreferencesForRuntime,
resolveThemePreferencesForRuntime,
resolveThemePreferencesFromStorageEvent,
writeThemePreferencesForRuntime,
} from './theme-storage';
import { isTransientRuntimeKey } from '@/lib/runtime-switch';
let createdWindow = false;
let createdLocalStorage = false;
const ensureLocalStorage = (): void => {
if (typeof localStorage !== 'undefined') {
return;
}
const values = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
clear: () => {
values.clear();
},
},
configurable: true,
writable: true,
});
createdLocalStorage = true;
};
beforeEach(() => {
if (typeof window === 'undefined') {
Object.defineProperty(globalThis, 'window', {
value: {},
configurable: true,
writable: true,
});
createdWindow = true;
}
ensureLocalStorage();
localStorage.clear();
});
afterAll(() => {
if (createdWindow) {
delete (globalThis as { window?: unknown }).window;
}
if (createdLocalStorage) {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
const preferences = {
themeMode: 'dark' as const,
lightThemeId: 'light-theme',
darkThemeId: 'dark-theme',
};
describe('theme preference runtime scoping', () => {
test('keys differ per runtime', () => {
expect(getThemePreferencesStorageKey('runtime-a')).not.toBe(getThemePreferencesStorageKey('runtime-b'));
});
test('round-trips preferences for the same runtime', () => {
writeThemePreferencesForRuntime('runtime-a', preferences);
expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
});
test('a window on one instance never reads another instance theme', () => {
writeThemePreferencesForRuntime('runtime-a', preferences);
expect(readThemePreferencesForRuntime('runtime-b')).toBeNull();
});
test('latest write wins per runtime without cross-instance effects', () => {
writeThemePreferencesForRuntime('runtime-a', preferences);
writeThemePreferencesForRuntime('runtime-b', { themeMode: 'light', lightThemeId: 'other-light', darkThemeId: 'other-dark' });
expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
expect(readThemePreferencesForRuntime('runtime-b')).toEqual({
themeMode: 'light',
lightThemeId: 'other-light',
darkThemeId: 'other-dark',
});
});
test('malformed or invalid payloads are failure, not empty authority', () => {
localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json');
expect(readThemePreferencesForRuntime('runtime-a')).toBeNull();
localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), JSON.stringify({ themeMode: 'neon' }));
expect(readThemePreferencesForRuntime('runtime-a')).toBeNull();
localStorage.setItem(
getThemePreferencesStorageKey('runtime-a'),
JSON.stringify({ themeMode: 'dark', lightThemeId: '', darkThemeId: 'dark-theme' }),
);
expect(readThemePreferencesForRuntime('runtime-a')).toBeNull();
});
test('leaves the splash-hint and migration-seed globals untouched', () => {
localStorage.setItem('themeMode', 'dark');
localStorage.setItem('lightThemeId', 'light-theme');
localStorage.setItem('darkThemeId', 'dark-theme');
localStorage.setItem('useSystemTheme', 'false');
localStorage.setItem('selectedThemeId', 'dark-theme');
localStorage.setItem('selectedThemeVariant', 'dark');
localStorage.setItem('splashBgDark', '#0c0a09');
localStorage.setItem('splashFgDark', '#fafaf9');
writeThemePreferencesForRuntime('runtime-a', preferences);
// The scoped key owns the app theme; the global keys stay as cosmetic
// last-writer-wins hints for the pre-React splash shells and the Android
// status bar, and as the one-time migration seed for new runtimes.
expect(localStorage.getItem('themeMode')).toBe('dark');
expect(localStorage.getItem('lightThemeId')).toBe('light-theme');
expect(localStorage.getItem('darkThemeId')).toBe('dark-theme');
expect(localStorage.getItem('useSystemTheme')).toBe('false');
expect(localStorage.getItem('selectedThemeId')).toBe('dark-theme');
expect(localStorage.getItem('selectedThemeVariant')).toBe('dark');
expect(localStorage.getItem('splashBgDark')).toBe('#0c0a09');
expect(localStorage.getItem('splashFgDark')).toBe('#fafaf9');
expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
});
});
describe('theme preference resolution chain', () => {
test('uses the scoped entry when present', () => {
writeThemePreferencesForRuntime('runtime-a', preferences);
expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
});
test('seeds from the legacy mode and theme ids when no scoped entry exists', () => {
localStorage.setItem('themeMode', 'dark');
localStorage.setItem('lightThemeId', 'legacy-light');
localStorage.setItem('darkThemeId', 'legacy-dark');
expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({
themeMode: 'dark',
lightThemeId: 'legacy-light',
darkThemeId: 'legacy-dark',
});
});
test('seeds from the useSystemTheme/selectedThemeId legacy chain', () => {
localStorage.setItem('useSystemTheme', 'false');
localStorage.setItem('selectedThemeId', DEFAULT_DARK_THEME_ID);
expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({
themeMode: 'dark',
lightThemeId: DEFAULT_LIGHT_THEME_ID,
darkThemeId: DEFAULT_DARK_THEME_ID,
});
});
test('falls back to defaults when nothing is stored', () => {
expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({
themeMode: 'system',
lightThemeId: DEFAULT_LIGHT_THEME_ID,
darkThemeId: DEFAULT_DARK_THEME_ID,
});
});
test('the migrated seed survives into the scoped key while the seed globals stay', () => {
localStorage.setItem('themeMode', 'dark');
localStorage.setItem('lightThemeId', 'legacy-light');
localStorage.setItem('darkThemeId', 'legacy-dark');
writeThemePreferencesForRuntime('runtime-a', resolveThemePreferencesForRuntime('runtime-a'));
expect(readThemePreferencesForRuntime('runtime-a')).toEqual({
themeMode: 'dark',
lightThemeId: 'legacy-light',
darkThemeId: 'legacy-dark',
});
expect(localStorage.getItem('themeMode')).toBe('dark');
expect(localStorage.getItem('lightThemeId')).toBe('legacy-light');
expect(localStorage.getItem('darkThemeId')).toBe('legacy-dark');
});
});
describe('runtime-switch adoption', () => {
const current = { themeMode: 'dark' as const, lightThemeId: 'current-light', darkThemeId: 'current-dark' };
test('adopts the target runtime stored theme when one exists', () => {
writeThemePreferencesForRuntime('runtime-b', preferences);
expect(adoptThemePreferencesForRuntime('runtime-b', current)).toEqual(preferences);
});
test('keeps the current preferences — same reference — when the target runtime has no entry', () => {
expect(adoptThemePreferencesForRuntime('runtime-empty', current)).toBe(current);
});
});
describe('transient runtime keys', () => {
test('uninitialized and disconnected runtime keys are transient', () => {
expect(isTransientRuntimeKey('url:default')).toBe(true);
expect(isTransientRuntimeKey('mobile-disconnected')).toBe(true);
expect(isTransientRuntimeKey('')).toBe(true);
expect(isTransientRuntimeKey('local')).toBe(false);
expect(isTransientRuntimeKey('url:https://host.example')).toBe(false);
});
test('writes are skipped for transient runtimes — no stale cold-boot theme gets pinned', () => {
writeThemePreferencesForRuntime('url:default', preferences);
writeThemePreferencesForRuntime('mobile-disconnected', preferences);
expect(readThemePreferencesForRuntime('url:default')).toBeNull();
expect(readThemePreferencesForRuntime('mobile-disconnected')).toBeNull();
expect(localStorage.getItem(getThemePreferencesStorageKey('url:default'))).toBeNull();
});
test('reads never surface an entry under a transient key', () => {
localStorage.setItem(getThemePreferencesStorageKey('url:default'), JSON.stringify(preferences));
expect(readThemePreferencesForRuntime('url:default')).toBeNull();
});
test('boot resolution falls back to the global splash hints for transient runtimes', () => {
localStorage.setItem('themeMode', 'light');
localStorage.setItem('lightThemeId', 'legacy-light');
localStorage.setItem('darkThemeId', 'legacy-dark');
expect(resolveThemePreferencesForRuntime('url:default')).toEqual({
themeMode: 'light',
lightThemeId: 'legacy-light',
darkThemeId: 'legacy-dark',
});
});
test('endpoint-switch adoption keeps current preferences for transient runtimes', () => {
const current = { themeMode: 'light' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' };
expect(adoptThemePreferencesForRuntime('mobile-disconnected', current)).toBe(current);
});
});
describe('theme storage event resolution', () => {
const current = { themeMode: 'system' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' };
test('adopts a storage event for the current runtime', () => {
writeThemePreferencesForRuntime('runtime-a', preferences);
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toEqual(preferences);
});
test('ignores a storage event from another runtime', () => {
writeThemePreferencesForRuntime('runtime-b', preferences);
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-b'), 'runtime-a', current)).toBeNull();
});
test('ignores legacy global theme keys (revert-to-globals regression guard)', () => {
localStorage.setItem('themeMode', 'dark');
localStorage.setItem('lightThemeId', 'light-theme');
localStorage.setItem('darkThemeId', 'dark-theme');
expect(resolveThemePreferencesFromStorageEvent('themeMode', 'runtime-a', current)).toBeNull();
expect(resolveThemePreferencesFromStorageEvent('lightThemeId', 'runtime-a', current)).toBeNull();
expect(resolveThemePreferencesFromStorageEvent('darkThemeId', 'runtime-a', current)).toBeNull();
});
test('resolves to no change when stored preferences already match', () => {
writeThemePreferencesForRuntime('runtime-a', preferences);
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', preferences)).toBeNull();
});
test('resolves to no change when nothing valid is stored', () => {
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull();
localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json');
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull();
});
});
+181
View File
@@ -0,0 +1,181 @@
import type { ThemeMode } from '@/types/theme';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes';
import { isTransientRuntimeKey } from '@/lib/runtime-switch';
type StoredThemePreferences = {
themeMode: ThemeMode;
lightThemeId: string;
darkThemeId: string;
};
// Theme preferences are scoped per runtime endpoint, like the settings mirror
// (lib/persistence.ts), so windows pointing at different instances never
// overwrite or adopt each other's theme through shared localStorage.
//
// Retention is intentionally unbounded, unlike the mirror's capped 5-runtime
// index: each entry is ~150 bytes, the count is bounded by the distinct
// instances ever visited from this origin, and evicting old entries would only
// discard the last-known theme for rarely visited instances while saving
// trivial space.
const THEME_PREFERENCES_KEY_PREFIX = 'openchamber.theme.v2:';
export const getThemePreferencesStorageKey = (runtimeKey: string): string =>
`${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
const THEME_MODES: readonly ThemeMode[] = ['light', 'dark', 'system'];
const isThemeMode = (value: string): value is ThemeMode =>
THEME_MODES.some((mode) => mode === value);
// Boundary parser for the scoped entry. A malformed or partial payload is a
// failure (`null`), never a valid default: the caller then falls back to the
// legacy seed or keeps its current preferences.
const parseStoredThemePreferences = (raw: string): StoredThemePreferences | null => {
try {
// SAFETY: this key is written only by `writeThemePreferencesForRuntime`
// with exactly this shape. Every field is still re-checked below, and a
// field of the wrong type throws on `.trim()` into the catch.
const candidate = JSON.parse(raw) as Partial<StoredThemePreferences> | null;
if (candidate === null) {
return null;
}
const themeMode = candidate.themeMode ?? '';
if (!isThemeMode(themeMode)) {
return null;
}
const lightThemeId = (candidate.lightThemeId ?? '').trim();
const darkThemeId = (candidate.darkThemeId ?? '').trim();
if (!lightThemeId || !darkThemeId) {
return null;
}
return { themeMode, lightThemeId, darkThemeId };
} catch {
return null;
}
};
const readLocalStorageItem = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => {
if (isTransientRuntimeKey(runtimeKey)) {
return null;
}
const raw = readLocalStorageItem(getThemePreferencesStorageKey(runtimeKey));
return raw ? parseStoredThemePreferences(raw) : null;
};
export const writeThemePreferencesForRuntime = (runtimeKey: string, preferences: StoredThemePreferences): void => {
if (isTransientRuntimeKey(runtimeKey)) {
return;
}
try {
localStorage.setItem(getThemePreferencesStorageKey(runtimeKey), JSON.stringify(preferences));
} catch {
// localStorage unavailable (e.g. read-only contextBridge) — the server
// settings sync remains authoritative and the app still works.
}
};
/**
* Resolve the preferences a cross-window storage event should apply for the
* current runtime. Returns null — meaning "keep current preferences" — when
* the event targets another runtime's key, when no valid stored preferences
* exist, or when the stored preferences already match the current ones (the
* identity check breaks cross-window adoption loops).
*/
export const resolveThemePreferencesFromStorageEvent = (
eventKey: string | null,
runtimeKey: string,
current: StoredThemePreferences,
): StoredThemePreferences | null => {
if (eventKey !== getThemePreferencesStorageKey(runtimeKey)) {
return null;
}
const stored = readThemePreferencesForRuntime(runtimeKey);
if (!stored) {
return null;
}
if (stored.themeMode === current.themeMode && stored.lightThemeId === current.lightThemeId && stored.darkThemeId === current.darkThemeId) {
return null;
}
return stored;
};
// One-time migration seed: pre-scoped builds persisted theme state in these
// global keys. They are resolved only while no scoped entry exists — the
// persist effect then seeds the scoped key from the returned preferences — so
// no client-only theme state is discarded before the authoritative server sync
// lands. The keys themselves stay (see ThemeSystemContext's persist effect):
// the pre-React splash shells and the Android status bar read them as
// cosmetic last-writer-wins hints.
const readLegacyThemePreferences = (): StoredThemePreferences => {
let themeMode: ThemeMode = 'system';
let lightThemeId: string = DEFAULT_LIGHT_THEME_ID;
let darkThemeId: string = DEFAULT_DARK_THEME_ID;
const legacyMode = readLocalStorageItem('themeMode');
const legacyUseSystem = readLocalStorageItem('useSystemTheme');
const legacyThemeId = readLocalStorageItem('selectedThemeId');
const legacyVariant = readLocalStorageItem('selectedThemeVariant');
if (legacyMode !== null && isThemeMode(legacyMode)) {
themeMode = legacyMode;
} else if (legacyUseSystem !== null) {
const useSystem = legacyUseSystem === 'true';
if (useSystem) {
themeMode = 'system';
} else if (legacyThemeId) {
const legacyTheme = getThemeById(legacyThemeId);
if (legacyTheme) {
themeMode = legacyTheme.metadata.variant === 'dark' ? 'dark' : 'light';
if (legacyTheme.metadata.variant === 'dark') {
darkThemeId = legacyTheme.metadata.id;
} else {
lightThemeId = legacyTheme.metadata.id;
}
}
}
} else if (legacyVariant === 'light' || legacyVariant === 'dark') {
themeMode = legacyVariant;
}
const legacyLightId = readLocalStorageItem('lightThemeId')?.trim();
const legacyDarkId = readLocalStorageItem('darkThemeId')?.trim();
if (legacyLightId) {
lightThemeId = legacyLightId;
}
if (legacyDarkId) {
darkThemeId = legacyDarkId;
}
return { themeMode, lightThemeId, darkThemeId };
};
/**
* Resolve the preferences for a runtime at boot: the scoped entry when one
* exists, otherwise a one-time seed from the superseded global keys, otherwise
* defaults. The seed guarantees the first scoped write carries the last-known
* theme instead of defaults.
*/
export const resolveThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences => {
const stored = readThemePreferencesForRuntime(runtimeKey);
return stored ?? readLegacyThemePreferences();
};
/**
* Adopt another runtime's stored preferences when the endpoint switches: the
* new runtime's scoped entry when one exists, otherwise the current
* preferences unchanged (the same reference — no re-render, no write-through)
* until the incoming settings sync refines with the server's authoritative
* value.
*/
export const adoptThemePreferencesForRuntime = (
runtimeKey: string,
current: StoredThemePreferences,
): StoredThemePreferences => readThemePreferencesForRuntime(runtimeKey) ?? current;
+6 -1
View File
@@ -23,6 +23,8 @@ import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
* because this runs above `SyncProvider` — that hook reads the sync context and * because this runs above `SyncProvider` — that hook reads the sync context and
* throws outside it, which took the whole app down with a blank window. * throws outside it, which took the whole app down with a blank window.
*/ */
const AGENT_MEMORY_FRESH_MS = 60_000;
export const useAgentMemorySync = (directory: string | null): void => { export const useAgentMemorySync = (directory: string | null): void => {
const enabled = useUIStore((state) => ( const enabled = useUIStore((state) => (
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
@@ -31,11 +33,14 @@ export const useAgentMemorySync = (directory: string | null): void => {
const owner = useProjectContextOwner(directory); const owner = useProjectContextOwner(directory);
const projectPath = owner?.path ?? null; const projectPath = owner?.path ?? null;
// The owner re-resolves on every directory switch; entries loaded moments
// ago for the same project are still current, and the change event below
// forces a re-read when the agent writes memory.
React.useEffect(() => { React.useEffect(() => {
if (!enabled) { if (!enabled) {
return; return;
} }
void load(projectPath); void load(projectPath, { maxAgeMs: AGENT_MEMORY_FRESH_MS });
}, [enabled, load, projectPath]); }, [enabled, load, projectPath]);
// The agent writes memory mid-turn through its own tool, so the index for the // The agent writes memory mid-turn through its own tool, so the index for the
+9 -2
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useChatColumnSession } from '@/components/chat/chatColumnSession';
import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2'; import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2';
import type { MessageStreamPhase } from '@/stores/types/sessionTypes'; import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
@@ -301,8 +302,14 @@ export const getActiveAssistantContext = (messages: Message[]): ActiveAssistantC
}; };
export function useAssistantStatus(): AssistantStatusSnapshot { export function useAssistantStatus(): AssistantStatusSnapshot {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); // Inside the chat column, follow the session the timeline shows rather
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); // than the live selection, so the status chip changes together with the
// conversation instead of a commit ahead of it.
const chatColumnSession = useChatColumnSession();
const liveSessionId = useSessionUIStore((state) => state.currentSessionId);
const liveSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId;
const currentSessionDirectory = chatColumnSession ? chatColumnSession.directory : liveSessionDirectory;
const rawSessionMessages = useSessionMessages( const rawSessionMessages = useSessionMessages(
currentSessionId ?? '', currentSessionId ?? '',
@@ -4,6 +4,7 @@ import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy'; import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore } from '@/sync/viewport-store'; import { useViewportStore } from '@/sync/viewport-store';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate';
import { import {
CHAT_LIST_ANCHOR_OFFSET, CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics, getAnchoredTurnMetrics,
@@ -76,9 +77,20 @@ interface UseChatTimelineScrollOptions {
// Id of the newest user message in the rendered timeline. When a send has // Id of the newest user message in the rendered timeline. When a send has
// armed the anchor, the next new id here becomes the anchored row. // armed the anchor, the next new id here becomes the anchored row.
lastUserMessageId: string | null; lastUserMessageId: string | null;
// True while the session is producing output. Follow corrections glide
// only then. Outside a live stream — entering a session, a tab becoming
// active, rows re-measuring after a switch — the viewport must land on
// the end instantly: an animated catch-up scrolls visibly through the
// conversation and gets cut short by the next measurement.
sessionIsWorking: boolean;
// Reveal gate of the session being opened. Held until the viewport is
// pinned to the end, so the session is never shown scrolled to the top.
revealGate?: TimelineRevealGate | null;
onActiveTurnChange?: (turnId: string | null) => void; onActiveTurnChange?: (turnId: string | null) => void;
} }
export interface UseChatTimelineScrollResult { export interface UseChatTimelineScrollResult {
scrollRef: React.RefObject<HTMLDivElement | null>; scrollRef: React.RefObject<HTMLDivElement | null>;
// The live scroll element, as state, so effects that must re-bind when the // The live scroll element, as state, so effects that must re-bind when the
@@ -122,8 +134,12 @@ export const useChatTimelineScroll = ({
sessionMessageCount, sessionMessageCount,
composerOverlayHeight, composerOverlayHeight,
lastUserMessageId, lastUserMessageId,
sessionIsWorking,
revealGate = null,
onActiveTurnChange, onActiveTurnChange,
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => { }: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
sessionIsWorkingRef.current = sessionIsWorking;
const scrollRef = React.useRef<HTMLDivElement | null>(null); const scrollRef = React.useRef<HTMLDivElement | null>(null);
const listRef = React.useRef<TimelineListHandle | null>(null); const listRef = React.useRef<TimelineListHandle | null>(null);
@@ -633,6 +649,10 @@ export const useChatTimelineScroll = ({
const end = node.scrollHeight - node.clientHeight; const end = node.scrollHeight - node.clientHeight;
const distance = end - node.scrollTop; const distance = end - node.scrollTop;
if (distance <= 1) return; if (distance <= 1) return;
if (!sessionIsWorkingRef.current) {
node.scrollTop = end;
return;
}
if (distance > node.clientHeight) { if (distance > node.clientHeight) {
node.scrollTop = end - node.clientHeight; node.scrollTop = end - node.clientHeight;
} }
@@ -864,6 +884,61 @@ export const useChatTimelineScroll = ({
}; };
}, [queueSave, realContentOverflowsViewport, scrollNode]); }, [queueSave, realContentOverflowsViewport, scrollNode]);
// ── entry pin ───────────────────────────────────────────────────────────
// An opened session is shown once, already at its end: the reveal gate is
// held until the viewport sits on the end, and the pin is one instant
// write. The list lays its rows out before the first frame, so this
// resolves within a frame; the gate's own cap bounds the wait.
React.useLayoutEffect(() => {
if (!currentSessionKey || !scrollNode) return;
const releaseReveal = revealGate?.hold() ?? null;
let frame: number | null = null;
const settle = () => {
frame = null;
if (!userOwnsScrollRef.current && modeRef.current === 'following-end') {
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
}
releaseReveal?.();
};
frame = requestAnimationFrame(settle);
return () => {
if (frame !== null) cancelAnimationFrame(frame);
releaseReveal?.();
};
}, [currentSessionKey, revealGate, scrollNode]);
// ── pinned end ──────────────────────────────────────────────────────────
// "At the end" is an invariant, not a one-time scroll: while the reader
// sits on the end of a session that is not producing output, any growth
// of the content (a footer that decides to render, a row re-measured)
// keeps the end in view with one instant write. Output growth belongs to
// followEnd, which glides.
React.useEffect(() => {
if (!scrollNode || typeof MutationObserver === 'undefined') return;
const content = scrollNode.firstElementChild;
if (!content) return;
const pin = () => {
if (sessionIsWorkingRef.current) return;
if (userOwnsScrollRef.current || !isAtEndRef.current || modeRef.current !== 'following-end') return;
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
};
// A MutationObserver runs as a microtask right after the list writes
// its layout (row positions, container height), before the frame is
// painted, so the pin lands in the same frame as the growth. A
// ResizeObserver would only see the container a rendering step later
// and let one frame paint with the end out of view.
const mutations = new MutationObserver(pin);
mutations.observe(content, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
const resizes = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(pin);
resizes?.observe(content);
return () => {
mutations.disconnect();
resizes?.disconnect();
};
}, [scrollNode]);
// ── session lifecycle ─────────────────────────────────────────────────── // ── session lifecycle ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef<string | null>(null); const lastSessionKeyRef = React.useRef<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
+11 -1
View File
@@ -3,6 +3,7 @@ import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract'; import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useSessionDirectory } from '@/sync/sync-context'; import { useSessionDirectory } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { getChatsRootForHome } from '@/lib/chatDirectories';
/** /**
* Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal). * Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal).
@@ -11,7 +12,10 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
* 1. Worktree metadata path (for worktree sessions) * 1. Worktree metadata path (for worktree sessions)
* 2. Session directory (for active sessions) * 2. Session directory (for active sessions)
* 3. Draft session directoryOverride (when creating a new session) * 3. Draft session directoryOverride (when creating a new session)
* 4. Fallback directory from DirectoryStore * 4. For a Chat draft, the prepared chat directory or the managed Chats root —
* never the project the app was on before, which would leak that
* project's files, commands, and skills into the chat
* 5. Fallback directory from DirectoryStore
* *
* This ensures that tabs show content from the correct project directory * This ensures that tabs show content from the correct project directory
* even when a draft session is being created. * even when a draft session is being created.
@@ -23,6 +27,7 @@ export const useEffectiveDirectory = (): string | undefined => {
const worktreeAttachment = useSessionWorktreeStore((s) => currentSessionId ? s.getAttachment(currentSessionId) : undefined); const worktreeAttachment = useSessionWorktreeStore((s) => currentSessionId ? s.getAttachment(currentSessionId) : undefined);
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata); const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
// If we have an active session, use its directory // If we have an active session, use its directory
if (currentSessionId) { if (currentSessionId) {
@@ -44,6 +49,11 @@ export const useEffectiveDirectory = (): string | undefined => {
return (newSessionDraft.bootstrapPendingDirectory || newSessionDraft.directoryOverride) ?? undefined; return (newSessionDraft.bootstrapPendingDirectory || newSessionDraft.directoryOverride) ?? undefined;
} }
if (newSessionDraft?.open && newSessionDraft.target === 'chat') {
const chatDirectory = newSessionDraft.preparedChatDirectory ?? getChatsRootForHome(homeDirectory);
if (chatDirectory) return chatDirectory;
}
// Fall back to the global directory // Fall back to the global directory
return fallbackDirectory ?? undefined; return fallbackDirectory ?? undefined;
}; };

Some files were not shown because too many files have changed in this diff Show More