diff --git a/.agents/skills/performance-engineering/SKILL.md b/.agents/skills/performance-engineering/SKILL.md
index 8194aaab..e060e6de 100644
--- a/.agents/skills/performance-engineering/SKILL.md
+++ b/.agents/skills/performance-engineering/SKILL.md
@@ -210,7 +210,7 @@ command, how to stand up a production build to measure against, how to read the
artifacts, and the validity guarantees these scripts enforce. Read it before
measuring.
-Four unattended capture commands exist; prefer them over ad-hoc timing code,
+Five unattended capture commands exist; prefer them over ad-hoc timing code,
and extend them when a scenario is missing rather than measuring by hand.
| Command | Answers |
@@ -218,6 +218,8 @@ and extend them when a scenario is missing rather than measuring by hand.
| `bun run profile:idle` | What the app does while nobody interacts with it. Supports `--session`, `--tab`, `--then-tab`, `--panel`, `--expand-projects` to reach a specific mounted state, plus `--baseline` and `--budget-*` for regression gating. |
| `bun run profile:session` | What a streaming assistant response costs. Creates a session, dispatches a prompt through the `openchamber session` CLI, and records until the session reports idle. Reports the long-task distribution, a timeline-trace breakdown, running animations, and output-normalised metrics. |
| `bun run profile:animation` | What a CSS animation costs, isolated from the app. Animate only `transform` and `opacity`; everything else recalculates style every frame. |
+| `bun run profile:switch` | How long switching sessions from the sidebar takes: `ack` (the clicked row highlights) and `content` (the target session's messages are on screen), cold and warm, plus the requests each switch fires. Use it as the regression gate for any change in the sidebar, header, chat container, or markdown first paint. |
+| `bun run profile:switch` | How long switching sessions from the sidebar takes: `ack` (the clicked row highlights) and `content` (the target session's messages are on screen), cold and warm, plus the requests each switch fires. Use it as the regression gate for any change in the sidebar, header, chat container, or markdown first paint. |
| `bun run profile:browser` | A manually driven capture when the interaction cannot be scripted. |
Both automated commands fail loudly rather than reporting a clean result when
diff --git a/.agents/skills/triage-prs/SKILL.md b/.agents/skills/triage-prs/SKILL.md
index eb95a315..75f885fa 100644
--- a/.agents/skills/triage-prs/SKILL.md
+++ b/.agents/skills/triage-prs/SKILL.md
@@ -42,7 +42,7 @@ Execute the approved closes/comments with retries and ~1–2s spacing between ca
The review bot's `review:*` labels are a pre-sort, not a verdict: `review:ready` PRs go first (the bot found no code defects — likely MERGE/MERGE-THEN-FIX), `review:blocked` ones carry a bot comment whose findings the verdict review verifies rather than rediscovers. Bot labels never replace the pr-review pass — the bot cannot judge product fit or maintainability scope. The reverse holds too: when the bot's BLOCKED findings are the whole story and the author has not answered, the maintainer never re-posts them in their own voice — the PR is *waiting on author* and the report says so in one line.
-Split the clean pool smallest-first (tiny diffs are fast wins and most likely mergeable). Fan out the `pr-reviewer` subagent (`.opencode/agent/pr-reviewer.md`, which loads the `pr-review` skill and carries the hard rules) — one PR per call, or ~10 PRs per general subagent that receives the full `pr-review` skill text when `pr-reviewer` is unavailable. The subagent inherits the chat's model; never hand verdicts to a smaller model to save quota — a verdict from a small model is a pre-sort, not a decision. Each returns per-PR verdict blocks in the skill's output format.
+Split the clean pool smallest-first (tiny diffs are fast wins and most likely mergeable). Fan out the `pr-reviewer` subagent (`.opencode/agent/pr-reviewer.md`, which loads the `pr-review` skill and carries the hard rules). It takes one PR or several per call — group related PRs together when one context can serve them, give a large or contentious PR its own call; fall back to a general subagent that receives the full `pr-review` skill text when `pr-reviewer` is unavailable. The subagent inherits the chat's model; never hand verdicts to a smaller model to save quota — a verdict from a small model is a pre-sort, not a decision. Each returns per-PR verdict blocks in the skill's output format.
**Report format.** The consolidated report is what the maintainer decides from — calibrate each entry so no follow-up question is needed, without ballooning:
diff --git a/.opencode/agent/pr-reviewer.md b/.opencode/agent/pr-reviewer.md
index 91d9a8c9..2fcfee37 100644
--- a/.opencode/agent/pr-reviewer.md
+++ b/.opencode/agent/pr-reviewer.md
@@ -1,10 +1,10 @@
---
mode: subagent
-description: Reviews one pull request as the maintainer's proxy and returns a single verdict (DECLINE / PUSH-BACK / MERGE-THEN-FIX / MERGE) with its ready action. Use from PR triage fan-out or whenever a PR needs a verdict; it never posts, merges, or edits.
+description: Reviews one or several pull requests as the maintainer's proxy and returns one verdict block per PR (DECLINE / PUSH-BACK / MERGE-THEN-FIX / MERGE) with its ready action. Hand it a single PR or a list; it never posts, merges, or edits.
color: "#d08770"
---
-You review exactly one pull request in the OpenChamber repository and return one verdict the maintainer can act on.
+You review the pull requests you were handed — one or several — in the OpenChamber repository, and return one verdict block per PR that the maintainer can act on. Work through them one at a time, fully, before starting the next; count your output blocks against the numbers you received and never drop one.
Load `.agents/skills/pr-review/SKILL.md` first and follow it exactly: it owns the verdict ladder, the "symptom's path" bar for MERGE, the verified-vs-unverifiable distinction, the residue-owner rule between PUSH-BACK and MERGE-THEN-FIX, product-fit escalation, ache salvage, pickup mode, the output format, and the voice. Then follow `AGENTS.md` instruction order for the change's character: load every matching project skill and the owning `DOCUMENTATION.md` / `README.md`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7ac6419d..ccdabf6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [1.22.0] - 2026-08-30
+
+- **Linear integration:** connect a workspace in Settings → Integrations, browse and filter issues, and start a session or worktree from an issue. OpenChamber reports session progress back to Linear and can attach an issue to the next chat message (thanks to @AlexKutas).
+- **Voice:** local text-to-speech and macOS say now choose a voice that matches the reply's language. Additional local models download on first use, and the voice picker lists voices from every installed model.
+- **Git:** projects containing several repositories can now switch between them from the Git tab. Diff, pull request, walkthrough, mobile Changes, and work status follow the selected repository (thanks to @jaygupta17).
+- **Chat:** sessions opened from the sidebar stay at the latest message, and switching sessions no longer causes jumps, partial rendering, crossfades, or tab-title shifts.
+- Chat: command, skill, and file autocomplete in projectless chats no longer uses the previously selected project.
+- Chat: reverting to a message, or forking from one, now brings its attached context back to the composer — review comments, chat and file quotes, terminal selections, and browser annotations are no longer lost.
+- Chat: stopped and unanswered turns now explain what happened. The status report includes recent session, send, and managed OpenCode errors, plus log locations.
+- Files: Ctrl/Cmd+F opens search in the Markdown preview even when the preview is not focused.
+- GitHub: account connection has moved to Settings → Integrations. The pull-request panel includes account controls, and its context-rail icon appears only when connected.
+- Git: the commit graph no longer leaves a lane gap when the same branch is merged twice (thanks to @Naputt1).
+- Settings: themes are now remembered per OpenChamber instance, so windows connected to different instances keep their own theme (thanks to @kydorn).
+- Scheduled tasks: Goal, Auto-accept, and other task settings are preserved when older OpenChamber builds share the same project config.
+- Desktop: on Windows and Linux, the close button reaches the top-right corner and follows the theme on hover (thanks to @kydorn).
+
## [1.21.1] - 2026-08-29
- **Turkish interface:** OpenChamber can now be used in Turkish (thanks to @fitzgpt).
@@ -31,6 +47,8 @@ All notable changes to this project will be documented in this file.
- Small model: requests send the provider's configured headers, such as an API-gateway subscription key (thanks to @dmitrii-galantsev); a configured Anthropic endpoint is used without a doubled `/v1`, and Google models without reasoning no longer receive a thinking option (thanks to @mpeter and @IngTian).
- Projects: the folder picker can select several directories at once and add them together (thanks to @herjarsa).
- Files: files reached through a symlink inside the workspace, or under a project root that is itself a symlink, open again instead of failing with an access error (thanks to @herjarsa).
+- Sidebar: searching sessions now also finds Chats — sessions that belong to no project — which used to vanish from the list as soon as anything was typed (thanks to @yulia-ivashko).
+- Chat: a message made only of quoted context fragments now appears in the prompt navigator; opening or closing the context panel no longer leaves a blank tail under the last message.
- Settings/Providers: after saving an API key or signing in, the provider no longer shows "Credentials missing" with its models hidden until you switch away and back (thanks to @herjarsa).
- Projects: the folder picker can enter a directory that is already a project to browse from there (thanks to @weixiang1862), and sending, forking, and image attachments work in projects whose path has non-ASCII characters, such as `Masaüstü` (thanks to @fitzgpt).
- Git: the status panel refreshes from real repository state after checkout, branch, stash, merge, rebase, or reset, and remote branches that were never fetched appear in branch lists (thanks to @makeittech); the Branch diff scope no longer compares against the wrong base for branches created from the current branch (thanks to @gaojunran); picking `origin/main` in the branch selector checks out the local branch instead of a detached `HEAD` (thanks to @yulia-ivashko); branch search hides non-matching branches (thanks to @bashrusakh).
diff --git a/bun.lock b/bun.lock
index 9353a0f7..15e74422 100644
--- a/bun.lock
+++ b/bun.lock
@@ -97,7 +97,7 @@
},
"packages/electron": {
"name": "@openchamber/electron",
- "version": "1.21.1",
+ "version": "1.22.0",
"dependencies": {
"@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2",
@@ -134,7 +134,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
- "version": "1.21.1",
+ "version": "1.22.0",
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0",
@@ -241,7 +241,7 @@
},
"packages/vscode": {
"name": "openchamber",
- "version": "1.21.1",
+ "version": "1.22.0",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.18.25",
@@ -264,7 +264,7 @@
},
"packages/web": {
"name": "@openchamber/web",
- "version": "1.21.1",
+ "version": "1.22.0",
"bin": {
"openchamber": "./bin/cli.js",
},
diff --git a/package.json b/package.json
index c9ed193c..0c430683 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openchamber-monorepo",
- "version": "1.21.1",
+ "version": "1.22.0",
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
"private": true,
"type": "module",
@@ -88,7 +88,8 @@
"release:test:arm": "./scripts/test-release-build.sh aarch64",
"profile:idle": "node scripts/profile-idle.mjs",
"profile:session": "node scripts/profile-session.mjs",
- "profile:animation": "node scripts/profile-animation.mjs"
+ "profile:animation": "node scripts/profile-animation.mjs",
+ "profile:switch": "node scripts/profile-switch.mjs"
},
"dependencies": {
"@base-ui/react": "^1.4.0",
diff --git a/packages/electron/package.json b/packages/electron/package.json
index 5e169440..2281926a 100644
--- a/packages/electron/package.json
+++ b/packages/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "@openchamber/electron",
- "version": "1.21.1",
+ "version": "1.22.0",
"private": true,
"description": "Electron desktop runtime for OpenChamber",
"author": "OpenChamber",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 4cfe929e..31181a9e 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@openchamber/ui",
- "version": "1.21.1",
+ "version": "1.22.0",
"private": true,
"type": "module",
"main": "src/main.tsx",
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index daad92af..e52bcb1e 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -49,6 +49,7 @@ import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
+import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import type { RuntimeAPIs } from '@/lib/api/types';
import { TooltipProvider } from '@/components/ui/tooltip';
@@ -247,6 +248,7 @@ function App({ apis }: AppProps) {
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
+ const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode);
// Embedded chats start inactive until the parent panel identifies the active
// tab. Otherwise a newly loaded background tab can focus its composer first
@@ -345,7 +347,8 @@ function App({ apis }: AppProps) {
}
void refreshGitHubAuthStatus(apis.github, { force: true });
- }, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
+ void refreshLinearAuthStatus(apis.linear, { force: true });
+ }, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
useAppFontEffects();
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx
index 9178e36b..0d1dbab9 100644
--- a/packages/ui/src/apps/MobileApp.tsx
+++ b/packages/ui/src/apps/MobileApp.tsx
@@ -27,7 +27,7 @@ import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device'
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
-import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
+import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint, MOBILE_DISCONNECTED_RUNTIME_KEY } from '@/lib/runtime-switch';
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache';
import { cn } from '@/lib/utils';
@@ -35,6 +35,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
+import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useGitStore } from '@/stores/useGitStore';
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -630,6 +631,7 @@ export function MobileApp({ apis }: MobileAppProps) {
const clearError = useSessionUIStore((state) => state.clearError);
const setIsMobile = useUIStore((state) => state.setIsMobile);
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
+ const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus);
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
const projects = useProjectsStore((state) => state.projects);
const [connectionEpoch, setConnectionEpoch] = React.useState(0);
@@ -678,12 +680,13 @@ export function MobileApp({ apis }: MobileAppProps) {
const refreshInPlace = () => {
void initializeApp();
void refreshGitHubAuthStatus(apis.github, { force: true });
+ void refreshLinearAuthStatus(apis.linear, { force: true });
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
};
const disconnect = (reason: string) => {
logMobileConnectEvent('resume:disconnect', { reason });
- switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
+ switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1);
};
@@ -746,7 +749,7 @@ export function MobileApp({ apis }: MobileAppProps) {
lastNativeResumeSyncEventAtRef.current = now;
window.dispatchEvent(new Event('openchamber:system-resume'));
}
- }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]);
+ }, [agentsCount, apis.github, apis.linear, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
useNativeMobileChrome();
useNativeMobileLifecycle(handleNativeResume);
@@ -893,7 +896,7 @@ export function MobileApp({ apis }: MobileAppProps) {
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' });
if (notice) setAutoConnectNotice(notice);
- switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
+ switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1);
};
void reprobeActiveConnection().then(async (outcome) => {
@@ -1031,7 +1034,8 @@ export function MobileApp({ apis }: MobileAppProps) {
React.useEffect(() => {
if (!isConnected) return;
void refreshGitHubAuthStatus(apis.github, { force: true });
- }, [apis.github, isConnected, refreshGitHubAuthStatus]);
+ void refreshLinearAuthStatus(apis.linear, { force: true });
+ }, [apis.github, apis.linear, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
// Discover all worktrees for every known project so the draft session's
// worktree/branch dropdown can list every available branch — not only the
@@ -1192,7 +1196,7 @@ export function MobileApp({ apis }: MobileAppProps) {
type="button"
variant="outline"
onClick={() => {
- switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
+ switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1);
}}
>
@@ -1275,7 +1279,7 @@ export function MobileApp({ apis }: MobileAppProps) {
{
- switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
+ switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1);
}} />
diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx
index b8e98b59..463886b3 100644
--- a/packages/ui/src/apps/MobileChangesSurface.tsx
+++ b/packages/ui/src/apps/MobileChangesSurface.tsx
@@ -10,6 +10,7 @@ import { SyncActions } from '@/components/views/git/SyncActions';
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
+import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import type { GitStatus } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi';
@@ -21,6 +22,8 @@ import {
useIsGitRepo,
useGitLoadingStatus,
} from '@/stores/useGitStore';
+import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates';
+import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
import { getRuntimeKey } from '@/lib/runtime-switch';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
@@ -56,12 +59,18 @@ type MobileChangesSurfaceProps = {
export const MobileChangesSurface: React.FC = ({ onClose, initialDiffPath, initialDiffStaged = false }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
- const currentDirectory = normalizePath(useEffectiveDirectory() ?? null);
+ const rootDirectory = normalizePath(useEffectiveDirectory() ?? null);
+ // When the root is not itself a repository, changes come from the resolved
+ // nested repository instead.
+ const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null);
+ const currentDirectory = gitDirectory ?? rootDirectory;
const status = useGitStatus(currentDirectory || null);
const isGitRepo = useIsGitRepo(currentDirectory || null);
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureAll = useGitStore((state) => state.ensureAll);
+ const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos);
+ const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
@@ -465,6 +474,16 @@ export const MobileChangesSurface: React.FC = ({ onCl
{status?.current || currentDirectory || ''}
+ {rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? (
+ {
+ if (rootDirectory) selectNestedRepo(rootDirectory, repository);
+ }}
+ repositoryRoot={rootDirectory ?? undefined}
+ />
+ ) : null}
{state}
@@ -474,12 +493,24 @@ export const MobileChangesSurface: React.FC = ({ onCl
return renderListState( );
}
- if (isLoadingStatus && isGitRepo === null) {
- return renderListState( );
+ // Non-repo root: surface nested-repository resolution while the operating
+ // directory has not proven to be a repository (discovering, failed,
+ // unsupported, none found, or settling on the auto-selected one).
+ if (rootIsGitRepo === false && isGitRepo !== true) {
+ return renderListState(
+ {
+ if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true });
+ }}
+ />
+ );
}
- if (isGitRepo === false) {
- return renderListState( );
+ if (isLoadingStatus && isGitRepo === null) {
+ return renderListState( );
}
if (route.type === 'diff') {
diff --git a/packages/ui/src/apps/mobileNativeChrome.ts b/packages/ui/src/apps/mobileNativeChrome.ts
index a26c6eb4..07d1c7e6 100644
--- a/packages/ui/src/apps/mobileNativeChrome.ts
+++ b/packages/ui/src/apps/mobileNativeChrome.ts
@@ -70,6 +70,27 @@ export const useNativeMobileChrome = (): void => {
const retry = window.setTimeout(() => void applyStatusBar(), 400);
cleanup.push(() => window.clearTimeout(retry));
+ // Theme toggles must reach the status bar without an app restart: re-run
+ // whenever the root dark/light class flips — the one signal every theme
+ // path converges on (settings toggle, synced settings, storage events,
+ // system-preference changes while in system mode). splashBg* colors are
+ // per-variant values, so they are stable across mode toggles.
+ if (platform === 'android') {
+ let wasDark = root.classList.contains('dark');
+ const themeClassObserver = new MutationObserver(() => {
+ const isDark = root.classList.contains('dark');
+ if (isDark === wasDark) return;
+ wasDark = isDark;
+ void applyStatusBar();
+ });
+ themeClassObserver.observe(root, { attributes: true, attributeFilter: ['class'] });
+ if (disposed) {
+ themeClassObserver.disconnect();
+ return;
+ }
+ cleanup.push(() => themeClassObserver.disconnect());
+ }
+
const { App } = await import('@capacitor/app');
const stateHandle = await App.addListener('appStateChange', ({ isActive }) => {
if (isActive) void applyStatusBar();
diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx
index 43aa97dd..ef0ab2b3 100644
--- a/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/packages/ui/src/components/chat/ChatContainer.tsx
@@ -4,6 +4,7 @@ import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import { ChatInput } from './ChatInput';
+import { ChatColumnSessionContext, type ChatColumnSession } from './chatColumnSession';
import { DraftPresetChips } from './DraftPresetChips';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
@@ -11,11 +12,24 @@ import { Skeleton } from '@/components/ui/skeleton';
import ChatEmptyState from './ChatEmptyState';
import { useGlobalSyncStore } from '@/sync/global-sync-store';
import MessageList, { type MessageListHandle } from './MessageList';
+import { createTimelineRevealGate, TIMELINE_REVEAL_CAP_MS, TimelineRevealGateContext, type TimelineRevealGate } from './timelineRevealGate';
+
+// How long the previous timeline stays on screen while a session that is not
+// in memory loads, before the skeleton takes over.
+const SESSION_SWITCH_HOLD_MS = 400;
+// End inset reserved for the status row that floats over the timeline's
+// bottom edge (its tallest resting height plus the mb-2 gap).
+const STATUS_OVERLAY_RESERVED_HEIGHT = 40;
+// A freshly opened timeline is shown once its content height has held still
+// for this many consecutive frames, or after the cap.
+const TIMELINE_SETTLE_STABLE_FRAMES = 2;
+const TIMELINE_SETTLE_CAP_MS = 300;
import { PermissionCard } from './PermissionCard';
import { QuestionCard } from './QuestionCard';
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
+import { SessionErrorNotice } from '@/components/chat/SessionErrorNotice';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
@@ -175,9 +189,9 @@ type ChatViewportProps = {
} | null;
scrollToBottom: () => void;
endPinningReleased: boolean;
- // One-shot fade for content that replaced the hydration skeleton;
- // cached sessions render instantly without it.
- revealContent: boolean;
+ /** The user waited for this session (held or fetched); reveal it with a fade. */
+ revealWaited: boolean;
+ revealGate: TimelineRevealGate;
sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean;
@@ -214,7 +228,8 @@ const ChatViewport = React.memo(({
retryOverlay,
scrollToBottom,
endPinningReleased,
- revealContent,
+ revealWaited,
+ revealGate,
sessionQuestions,
sessionPermissions,
isProgrammaticFollowActive,
@@ -362,12 +377,91 @@ const ChatViewport = React.memo(({
)}
+
>
), [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(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(() => ({
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
style: CHAT_SCROLL_STYLE,
@@ -385,11 +479,12 @@ const ChatViewport = React.memo(({
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1',
- revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
)}
+ ref={timelineRootRef}
aria-hidden={isDesktopExpandedInput}
>
+
+
{showPromptNavigator && promptTurnIds.length >= 2 ? (
= ({
}) => {
const messagesEnabled = messagesEnabledProp ?? active;
const { t } = useI18n();
- // Session UI state
- const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
- const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
+ // Session UI state. The selection is published synchronously by the
+ // sidebar click, but the chat swaps its content on a deferred copy: the
+ // 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 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(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(undefined);
+ const waitedSessionIdRef = React.useRef(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 openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
@@ -604,6 +745,18 @@ export const ChatContainer: React.FC = ({
const currentSessionKey = currentSessionId
? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId])
: 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(
+ () => ({ sessionId: currentSessionId ?? null, directory: currentSessionId ? effectiveSessionDirectory ?? null : null }),
+ [currentSessionId, effectiveSessionDirectory],
+ );
const ensureSessionRenderable = React.useCallback(
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
[effectiveSessionDirectory, sync],
@@ -824,9 +977,6 @@ export const ChatContainer: React.FC = ({
return () => setWorkStatusPanelVisible(false);
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
const messageListRef = React.useRef(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(null);
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
@@ -907,13 +1057,17 @@ export const ChatContainer: React.FC = ({
};
}, []);
+ // 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(() => {
- if (autoOpenDraft && !currentSessionId && !draftOpen) {
+ if (autoOpenDraft && !liveSessionId && !draftOpen) {
// Programmatic fallback, not user navigation — must not clear the
// persisted last-session pointer the cold-launch restore reads.
openNewSessionDraft({ automatic: true });
}
- }, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
+ }, [autoOpenDraft, liveSessionId, draftOpen, openNewSessionDraft]);
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
@@ -924,7 +1078,11 @@ export const ChatContainer: React.FC = ({
// OVER the timeline's bottom edge; its measured height keeps the live
// streaming line above it and reserves matching end inset in the list.
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(null);
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
statusOverlayObserverRef.current?.disconnect();
@@ -981,6 +1139,8 @@ export const ChatContainer: React.FC = ({
sessionMessageCount,
composerOverlayHeight,
lastUserMessageId,
+ sessionIsWorking,
+ revealGate,
onActiveTurnChange: handleActiveTurnChange,
});
@@ -1163,15 +1323,6 @@ export const ChatContainer: React.FC = ({
const isSessionHydrating =
Boolean(currentSessionId)
&& !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(() => {
if (!messagesEnabled || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
@@ -1310,9 +1461,6 @@ export const ChatContainer: React.FC = ({
}
const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
- if (showHydrationSkeleton) {
- hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
- }
if (showHydrationSkeleton) {
if (sessionMessageLoadState.status === 'error') {
return (
@@ -1415,7 +1563,8 @@ export const ChatContainer: React.FC = ({
retryOverlay={retryOverlay}
scrollToBottom={resumeToLatestInstant}
endPinningReleased={userOwnsScroll}
- revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
+ revealWaited={revealWaited}
+ revealGate={revealGate}
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isFollowingProgrammatically}
@@ -1434,6 +1583,7 @@ export const ChatContainer: React.FC = ({
return (
+
{returnToParentButton}
{sessionSurface}
@@ -1518,6 +1668,7 @@ export const ChatContainer: React.FC = ({
onLoadEarlier={handleLoadOlderClick}
/>
+
{/* Kept mounted while it could ever show, so it can animate its own
collapse; `visible` drives that. Unmounting on the spot is what made
the chat jump wide before easing narrow again. */}
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx
index 6df52923..8bb58376 100644
--- a/packages/ui/src/components/chat/ChatInput.tsx
+++ b/packages/ui/src/components/chat/ChatInput.tsx
@@ -17,7 +17,7 @@ import {
} from '@/sync/attachment-files';
import type { AttachedFile } from '@/stores/types/sessionTypes';
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 { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
@@ -51,6 +51,7 @@ import { ModelControls } from './ModelControls';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { ComposerStatusBar } from './ComposerStatusBar';
import { PendingChangesBar } from './PendingChangesBar';
+import { useChatColumnSession } from './chatColumnSession';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
@@ -66,6 +67,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
+import { LinearIssuePickerDialog } from '@/components/session/LinearIssuePickerDialog';
import { GitLabIssuePickerDialog } from '@/components/session/GitLabIssuePickerDialog';
import { GitLabMrPickerDialog } from '@/components/session/GitLabMrPickerDialog';
import { GiteaIssuePickerDialog } from '@/components/session/GiteaIssuePickerDialog';
@@ -77,8 +79,8 @@ import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
import { opencodeClient } from '@/lib/opencode/client';
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
-import { useSkillsStore } from '@/stores/useSkillsStore';
-import { useCommandsStore } from '@/stores/useCommandsStore';
+import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
+import { selectCommandsForDirectory, useCommandsStore } from '@/stores/useCommandsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { usePermissionStore } from '@/stores/permissionStore';
@@ -339,9 +341,16 @@ const ChatInputComponent: React.FC
= ({
const sendMessage = React.useRef((...args: any[]) =>
Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)),
).current;
- const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
+ // Inside the chat column the composer follows the session the timeline is
+ // showing (see chatColumnSession.ts); elsewhere it follows the live one.
+ const liveSessionId = useSessionUIStore((s) => s.currentSessionId);
+ const chatColumnSession = useChatColumnSession();
+ const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId;
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
- const currentDirectory = useEffectiveDirectory() ?? fallbackDirectory;
+ const liveEffectiveDirectory = useEffectiveDirectory();
+ const currentDirectory = (chatColumnSession?.sessionId ? chatColumnSession.directory : null)
+ ?? liveEffectiveDirectory
+ ?? fallbackDirectory;
const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
);
@@ -431,7 +440,7 @@ const ChatInputComponent: React.FC = ({
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
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 cycleAgentShortcut = React.useMemo(() => (
getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined)
@@ -608,8 +617,8 @@ const ChatInputComponent: React.FC = ({
// Known slash-invocations (commands + skills + built-ins) used to highlight
// matching /tokens in the composer, the same way confirmed @files are.
- const availableCommands = useCommandsStore((s) => s.commands);
- const availableSkills = useSkillsStore((s) => s.skills);
+ const availableCommands = useCommandsStore((s) => selectCommandsForDirectory(s, currentDirectory));
+ const availableSkills = useSkillsStore((s) => selectSkillsForDirectory(s, currentDirectory));
const knownSlashNames = React.useMemo(() => {
const names = new Set([
'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 = ({
// Issue linking state
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
+ const [linearPickerOpen, setLinearPickerOpen] = React.useState(false);
const [gitlabIssuePickerOpen, setGitlabIssuePickerOpen] = React.useState(false);
const [gitlabMrPickerOpen, setGitlabMrPickerOpen] = React.useState(false);
const [giteaIssuePickerOpen, setGiteaIssuePickerOpen] = React.useState(false);
@@ -751,6 +761,13 @@ const ChatInputComponent: React.FC = ({
author?: { login: string; avatarUrl?: string };
provider?: 'github' | 'gitlab' | 'gitea';
} | null>(null);
+ const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{
+ identifier: string;
+ title: string;
+ url: string;
+ contextText: string;
+ author?: { login: string; avatarUrl?: string };
+ } | null>(null);
// Message queue
const messageQueueTarget = currentSessionId
@@ -995,6 +1012,10 @@ const ChatInputComponent: React.FC = ({
}
}, [gitProvider]);
+ const openLinearPicker = React.useCallback(() => {
+ setLinearPickerOpen(true);
+ }, []);
+
const getSubmitErrorMessage = (error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : '';
return message.toLowerCase().includes('runtime changed')
@@ -1163,7 +1184,7 @@ const ChatInputComponent: React.FC = ({
: [];
const availableSkillNames = new Set(
- useSkillsStore.getState().skills.map((skill) => skill.name),
+ selectSkillsForDirectory(useSkillsStore.getState(), currentDirectory).map((skill) => skill.name),
);
const outgoing = buildOutgoingMessage({
@@ -1181,6 +1202,9 @@ const ChatInputComponent: React.FC = ({
linkedPr: linkedPr
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
: null,
+ linkedLinearIssue: linkedLinearIssue
+ ? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText }
+ : null,
}, {
parseAgentMention: (text) => {
const { sanitizedText, mention } = parseAgentMentions(text, agents);
@@ -1418,6 +1442,20 @@ const ChatInputComponent: React.FC = ({
true,
).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
if (linkedIssue) {
@@ -1426,6 +1464,9 @@ const ChatInputComponent: React.FC = ({
if (linkedPr) {
setLinkedPr(null);
}
+ if (linkedLinearIssue) {
+ setLinkedLinearIssue(null);
+ }
}).catch((error: unknown) => {
const rawMessage =
error instanceof Error
@@ -2285,10 +2326,14 @@ const ChatInputComponent: React.FC = ({
};
React.useEffect(() => {
-
- if (active && currentSessionId && composerRef.current && !isMobile) {
- composerRef.current.focus();
- }
+ if (!active || !currentSessionId || isMobile) return;
+ // Focusing forces layout. Right after a session switch the layout is
+ // 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]);
React.useEffect(() => {
@@ -2547,6 +2592,7 @@ const ChatInputComponent: React.FC = ({
const footerGapClass = 'gap-x-1.5 gap-y-0';
const isVSCode = isVSCodeRuntime();
+ const showLinearPicker = Boolean(runtimeLinear) && !isVSCode;
// 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
// panel, so these keep their place above the composer there.
@@ -2627,6 +2673,7 @@ const ChatInputComponent: React.FC = ({
draftPickerOpen: mobileDraftPicker !== null,
issuePickerOpen,
prPickerOpen,
+ linearPickerOpen,
isDragging,
},
});
@@ -2801,6 +2848,18 @@ const ChatInputComponent: React.FC = ({
onRemove={() => setLinkedPr(null)}
/>
) : null}
+ {linkedLinearIssue && !isVSCode ? (
+ setLinearPickerOpen(true)}
+ onRemove={() => setLinkedLinearIssue(null)}
+ />
+ ) : null}
= ({
onPickLocalFiles={handlePickLocalFiles}
onOpenIssuePicker={openIssuePicker}
onOpenPrPicker={openPrPicker}
+ showLinearPicker={showLinearPicker}
+ onOpenLinearPicker={openLinearPicker}
onOpenAttachSheet={openMobileAttachSheet}
onStartDictation={toggleDictation}
onAbort={handleAbort}
@@ -3046,6 +3107,8 @@ const ChatInputComponent: React.FC = ({
onPickLocalFiles={handlePickLocalFiles}
onOpenIssuePicker={openIssuePicker}
onOpenPrPicker={openPrPicker}
+ showLinearPicker={showLinearPicker}
+ onOpenLinearPicker={openLinearPicker}
onOpenAttachSheet={openMobileAttachSheet}
onToggleExpandedInput={handleToggleExpandedInput}
onTogglePermissionAutoAccept={handlePermissionAutoAcceptToggle}
@@ -3110,6 +3173,7 @@ const ChatInputComponent: React.FC = ({
onSelect={(issue) => {
setLinkedIssue(issue);
setLinkedPr(null);
+ setLinkedLinearIssue(null);
}}
/>
= ({
onSelect={(pr) => {
setLinkedPr(pr);
setLinkedIssue(null);
+ setLinkedLinearIssue(null);
+ }}
+ />
+ {
+ setLinkedLinearIssue(issue);
+ setLinkedIssue(null);
+ setLinkedPr(null);
}}
/>
= ({
{gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabMr') : gitProvider === 'gitea' ? t('chat.chatInput.actions.linkGiteaPr') : t('chat.chatInput.actions.linkGithubPr')}
+ {showLinearPicker ? (
+ {
+ mobileShell.skipNextOverlayCloseRestore();
+ setMobileAttachMenuOpen(false);
+ requestAnimationFrame(openLinearPicker);
+ }}
+ >
+
+ {t('chat.chatInput.actions.linkLinearIssue')}
+
+ ) : null}
) : null}
diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx
index 6cf85536..355eca35 100644
--- a/packages/ui/src/components/chat/CommandAutocomplete.tsx
+++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx
@@ -1,8 +1,9 @@
import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
-import { useCommandsStore } from '@/stores/useCommandsStore';
-import { useSkillsStore } from '@/stores/useSkillsStore';
+import { selectCommandsForDirectory, useCommandsStore } from '@/stores/useCommandsStore';
+import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
+import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
@@ -73,10 +74,16 @@ export const CommandAutocomplete = React.forwardRef([]);
const [loading, setLoading] = React.useState(false);
- const commandsWithMetadata = useCommandsStore((s) => s.commands);
- const refreshCommands = useCommandsStore((s) => s.loadCommands);
- const skills = useSkillsStore((s) => s.skills);
- const refreshSkills = useSkillsStore((s) => s.loadSkills);
+ // Commands and skills belong to the directory the composer sends to — the
+ // session's own directory, or the Chats root for a chat draft — not to the
+ // project the app was on last.
+ 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 selectedIndexRef = React.useRef(0);
const keyboardNavigationRef = React.useRef(false);
diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx
index f9390fd6..d78d0480 100644
--- a/packages/ui/src/components/chat/FileAttachment.tsx
+++ b/packages/ui/src/components/chat/FileAttachment.tsx
@@ -577,6 +577,8 @@ const PR_LINK_MIMES = new Set([
'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;
const getForgeLinkInfo = (file: FilePart): ForgeLinkInfo => {
@@ -598,9 +600,21 @@ const getForgeLinkInfo = (file: FilePart): ForgeLinkInfo => {
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.kind === 'pr') return 'git-pull-request';
+ if (info.kind === 'linear-issue') return 'linear';
if (info.provider === 'gitlab') return 'gitlab';
if (info.provider === 'gitea') return 'git-branch';
return 'github';
@@ -628,8 +642,8 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
};
const resolveDisplayName = React.useCallback((file: FilePart): string => {
- const isForgeLink = getForgeLinkInfo(file) !== null;
- if (isForgeLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
+ const isLink = getLinkInfo(file) !== null;
+ if (isLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
return file.filename.trim();
}
return extractFilename(file.filename || file.url);
@@ -702,11 +716,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
const fileName = resolveDisplayName(file);
const ext = fileName.split('.').pop() || '';
const sizeText = formatFileSize(file.size);
- const forgeLink = getForgeLinkInfo(file);
+ const linkInfo = getLinkInfo(file);
return (
- {forgeLink && file.url ? (
+ {linkInfo && file.url ? (
{
@@ -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"
>
-
+
{fileName}
@@ -797,7 +811,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
const fileName = resolveDisplayName(file);
const isImage = file.mime?.startsWith('image/');
const sizeText = formatFileSize(file.size);
- const forgeLink = getForgeLinkInfo(file);
+ const linkInfo = getLinkInfo(file);
if (isImage && file.url) {
return (
@@ -820,7 +834,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
);
}
- if (forgeLink && file.url) {
+ if (linkInfo && file.url) {
return (
@@ -835,7 +849,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
)}
>
-
+
{fileName}
diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx
index 5f7f901d..599347f4 100644
--- a/packages/ui/src/components/chat/MarkdownRenderer.tsx
+++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { cn } from '@/lib/utils';
-import { loadMarkdownRendererModule } from './markdownRendererLoader';
+import { getLoadedMarkdownRendererModule, loadMarkdownRendererModule } from './markdownRendererLoader';
// Thin lazy wrapper around the MarkdownRenderer implementation.
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom
@@ -41,21 +41,29 @@ const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown;
);
};
-export const MarkdownRenderer: React.FC
> = (props) => (
- }>
-
-
-);
+export const MarkdownRenderer: React.FC> = (props) => {
+ const loaded = getLoadedMarkdownRendererModule();
+ if (loaded) return ;
+ return (
+ }>
+
+
+ );
+};
type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef & {
fallbackContent?: React.ReactNode;
};
-export const SimpleMarkdownRenderer: React.FC = ({ fallbackContent, ...props }) => (
- }>
-
-
-);
+export const SimpleMarkdownRenderer: React.FC = ({ fallbackContent, ...props }) => {
+ const loaded = getLoadedMarkdownRendererModule();
+ if (loaded) return ;
+ return (
+ }>
+
+
+ );
+};
export const MarkdownImageGallery: React.FC> = (props) => (
diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts
index d4ce252f..a57e6710 100644
--- a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts
+++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts
@@ -193,6 +193,8 @@ const fakeReact = {
return hookStates[index] as { current: T };
},
memo: (component: T): T => component,
+ createContext: (defaultValue: T) => ({ Provider: 'provider', defaultValue }),
+ useContext: (context: { defaultValue: T }): T => context.defaultValue,
};
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
index c02b249b..b6279b24 100644
--- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
+++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
@@ -51,6 +51,7 @@ import {
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
+import { TimelineRevealGateContext } from './timelineRevealGate';
import { getRuntimeKey } from '@/lib/runtime-switch';
const useCurrentMermaidTheme = () => {
@@ -692,6 +693,30 @@ const useMermaidInlineInteractions = ({
const MERMAID_RENDER_CACHE = new Map();
const MERMAID_RENDER_CACHE_MAX = 100;
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();
let nextMarkdownDecorationId = 0;
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
@@ -804,6 +829,16 @@ const useMorphdomMarkdown = ({
const mermaidViewerRef = React.useRef | null>(null);
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
// eligible for capture. A fallback from an earlier content revision is not.
const mountedDomRef = React.useRef<{
@@ -909,6 +944,9 @@ const useMorphdomMarkdown = ({
}
if (hasMermaidBlock) refreshMermaidViewers();
} else {
+ if (!streaming && !releaseRevealHoldRef.current) {
+ releaseRevealHoldRef.current = revealGate?.hold() ?? null;
+ }
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
@@ -924,7 +962,7 @@ const useMorphdomMarkdown = ({
// or re-decorating ordinary blocks.
refreshMermaidViewers();
}
- }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
+ }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers, revealGate]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -939,6 +977,18 @@ const useMorphdomMarkdown = ({
const renderRevision = renderRevisionRef.current;
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) => {
if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
@@ -1028,12 +1078,13 @@ const useMorphdomMarkdown = ({
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
+ releaseRevealHold();
});
return () => {
active = false;
};
- }, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
+ }, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, releaseRevealHold, streaming, text]);
React.useEffect(() => {
const container = containerRef.current;
diff --git a/packages/ui/src/components/chat/SessionErrorNotice.tsx b/packages/ui/src/components/chat/SessionErrorNotice.tsx
new file mode 100644
index 00000000..3fb6c701
--- /dev/null
+++ b/packages/ui/src/components/chat/SessionErrorNotice.tsx
@@ -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(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 = ({ 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 (
+
+
+
+
+
+
{reportedError ? t('chat.sessionError.title') : t('chat.sessionError.noReply')}
+
{name ? `${name}: ${detail}` : detail}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/chat/SessionRecapSpacer.tsx b/packages/ui/src/components/chat/SessionRecapSpacer.tsx
index dc97ac52..c48ad08b 100644
--- a/packages/ui/src/components/chat/SessionRecapSpacer.tsx
+++ b/packages/ui/src/components/chat/SessionRecapSpacer.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { useSessionAssistState } from '@/hooks/useSessionAssist';
import { useI18n } from '@/lib/i18n';
+import { TimelineRevealGateContext } from '@/components/chat/timelineRevealGate';
interface SessionRecapNoteProps {
sessionId: string;
@@ -12,8 +13,17 @@ interface SessionRecapNoteProps {
// 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.
export const SessionRecapNote: React.FC = React.memo(({ sessionId, directory, isMobile }) => {
- const { visibleRecap } = useSessionAssistState(sessionId, directory);
+ const { visibleRecap, sessionKnown } = useSessionAssistState(sessionId, directory);
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) {
return null;
diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx
index a5d05e4a..9a8d6088 100644
--- a/packages/ui/src/components/chat/SkillAutocomplete.tsx
+++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx
@@ -1,6 +1,7 @@
import React from 'react';
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 { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
@@ -38,13 +39,16 @@ export const SkillAutocomplete = React.forwardRef([]);
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);
React.useEffect(() => {
- // Always trigger loadSkills when autocomplete opens to ensure project context is fresh
- void loadSkills();
- }, [loadSkills]);
+ // Always trigger loadSkills when autocomplete opens to ensure the directory's skills are fresh
+ void loadSkills(effectiveDirectory);
+ }, [effectiveDirectory, loadSkills]);
React.useEffect(() => {
const normalizedQuery = searchQuery.trim();
diff --git a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx
index 54ed84d2..3b314a22 100644
--- a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx
+++ b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx
@@ -138,14 +138,27 @@ const buildMaterializedSubagentSession = () => {
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;
-}).__openchamber_sync_context__;
+ __openchamber_sync_runtime_context__?: React.Context;
+};
+
+const syncContext = syncGlobals.__openchamber_sync_context__;
if (!syncContext) {
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', () => {
test('cold disabled reads hide a fully materialized 14-message subagent; enabled reads return all 14', async () => {
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 Provider = syncContext.Provider as React.Provider;
+ // 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 activeCount = -1;
let enabled = false;
@@ -188,15 +210,22 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
return null;
};
+ const renderHarness = () =>
+ React.createElement(
+ syncContext.Provider,
+ { value: system },
+ React.createElement(syncRuntimeContext.Provider, { value: runtime }, React.createElement(Harness)),
+ );
+
try {
await act(async () => {
- root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
+ root.render(renderHarness());
});
expect(inactiveCount).toBe(0);
enabled = true;
await act(async () => {
- root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
+ root.render(renderHarness());
});
expect(activeCount).toBe(14);
} finally {
diff --git a/packages/ui/src/components/chat/chatColumnSession.ts b/packages/ui/src/components/chat/chatColumnSession.ts
new file mode 100644
index 00000000..53519d34
--- /dev/null
+++ b/packages/ui/src/components/chat/chatColumnSession.ts
@@ -0,0 +1,18 @@
+import React from 'react';
+
+/**
+ * The session the chat column is showing — the deferred selection the
+ * timeline renders, not the live store value. The composer and everything
+ * stacked with the timeline read it so the column changes as one: a session
+ * click publishes the live selection first, and a composer that followed it
+ * would change height (changed-files row, todos, queued chips) while the
+ * outgoing timeline is still on screen, shoving that timeline before the swap.
+ */
+export type ChatColumnSession = {
+ sessionId: string | null;
+ directory: string | null;
+};
+
+export const ChatColumnSessionContext = React.createContext(null);
+
+export const useChatColumnSession = (): ChatColumnSession | null => React.useContext(ChatColumnSessionContext);
diff --git a/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts b/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts
index 32b15868..92a21305 100644
--- a/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts
+++ b/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts
@@ -33,6 +33,7 @@ export interface MobileComposerHolders {
draftPickerOpen: boolean;
issuePickerOpen: boolean;
prPickerOpen: boolean;
+ linearPickerOpen: boolean;
isDragging: boolean;
}
@@ -204,7 +205,8 @@ export function useMobileComposerShell(
|| holders.controlsPanelOpen
|| holders.attachMenuOpen
|| holders.issuePickerOpen
- || holders.prPickerOpen;
+ || holders.prPickerOpen
+ || holders.linearPickerOpen;
// Installed PWA (standalone): a focus() from a bare timeout is outside the
// 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
// click that closed it — refocus right there, while the gesture is live.
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 openSheetCountRef = React.useRef(0);
const holdFocusUntilRef = React.useRef(0);
@@ -307,6 +309,7 @@ export function useMobileComposerShell(
|| holders.draftPickerOpen
|| holders.issuePickerOpen
|| holders.prPickerOpen
+ || holders.linearPickerOpen
|| holders.isDragging;
React.useEffect(() => {
diff --git a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts
index 2c219085..972ab91f 100644
--- a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts
+++ b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts
@@ -40,6 +40,7 @@ const input = (overrides: Partial = {}): OutgoingMessageIn
syntheticTexts: [],
linkedIssue: null,
linkedPr: null,
+ linkedLinearIssue: null,
...overrides,
});
@@ -203,6 +204,17 @@ describe('synthetic context', () => {
.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', () => {
const result = buildOutgoingMessage(input({
composerText: 'x',
@@ -255,6 +267,7 @@ describe('full assembly order', () => {
syntheticTexts: ['synthetic'],
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' },
+ linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear' },
}), deps());
expect(result.primaryText).toBe('q1');
@@ -265,6 +278,7 @@ describe('full assembly order', () => {
'issue',
'pr-how',
'pr-diff',
+ 'linear',
'use: deploy',
]);
});
diff --git a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts
index 15c33d6d..b85893f5 100644
--- a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts
+++ b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts
@@ -53,6 +53,7 @@ export interface OutgoingMessageInput {
syntheticTexts: readonly string[];
linkedIssue: { number: number; title: string; url: string; contextText: 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));
}
+ 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);
if (skillInstruction) {
additionalParts.push({ text: skillInstruction, synthetic: true });
diff --git a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx
index 1d6254ea..ed7e7a8e 100644
--- a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx
+++ b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx
@@ -29,6 +29,8 @@ type ComposerAttachmentControlsProps = {
openPrPicker: () => void;
/** Shows the GitHub issue/PR or GitLab issue/MR attach actions based on the repo provider. */
gitProvider?: GitProvider | null;
+ showLinearPicker?: boolean;
+ openLinearPicker?: () => void;
onOpenSettings?: () => void;
onMenuOpenChange?: (open: boolean) => void;
/** Mobile: open the attachment bottom sheet instead of the dropdown menu. */
@@ -45,6 +47,8 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
openIssuePicker,
openPrPicker,
gitProvider,
+ showLinearPicker,
+ openLinearPicker,
onOpenSettings,
} = props;
@@ -160,6 +164,16 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
>
) : null}
+ {showLinearPicker && openLinearPicker ? (
+ {
+ requestAnimationFrame(openLinearPicker);
+ }}
+ >
+
+ {t('chat.chatInput.actions.linkLinearIssue')}
+
+ ) : null}
)}
@@ -183,6 +197,8 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
&& prev.footerIconButtonClass === next.footerIconButtonClass
&& prev.iconSizeClass === next.iconSizeClass
&& prev.gitProvider === next.gitProvider
+ && prev.showLinearPicker === next.showLinearPicker
+ && prev.openLinearPicker === next.openLinearPicker
&& prev.onOpenSettings === next.onOpenSettings
&& prev.onMenuOpenChange === next.onMenuOpenChange
&& prev.onOpenMobileSheet === next.onOpenMobileSheet
diff --git a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx
index f39bb5fd..0641f1cb 100644
--- a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx
+++ b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx
@@ -136,7 +136,7 @@ const DraftPreviewEntry: React.FC<{
aria-label={t('chat.chatInput.contextPreview.remove')}
title={t('chat.chatInput.contextPreview.remove')}
>
-
+
diff --git a/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx b/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx
index e633475d..0eba282e 100644
--- a/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx
+++ b/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx
@@ -56,6 +56,8 @@ export interface ComposerFooterProps {
onPickLocalFiles: () => void;
onOpenIssuePicker: () => void;
onOpenPrPicker: () => void;
+ showLinearPicker?: boolean;
+ onOpenLinearPicker?: () => void;
onOpenAttachSheet: () => void;
onToggleExpandedInput: () => void;
onTogglePermissionAutoAccept: () => void;
@@ -95,6 +97,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
onPickLocalFiles,
onOpenIssuePicker,
onOpenPrPicker,
+ showLinearPicker,
+ onOpenLinearPicker,
onOpenAttachSheet,
onToggleExpandedInput,
onTogglePermissionAutoAccept,
@@ -134,6 +138,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
openIssuePicker={onOpenIssuePicker}
openPrPicker={onOpenPrPicker}
gitProvider={gitProvider}
+ showLinearPicker={showLinearPicker}
+ openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings}
onOpenMobileSheet={onOpenAttachSheet}
/>
@@ -204,6 +210,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
openIssuePicker={onOpenIssuePicker}
openPrPicker={onOpenPrPicker}
gitProvider={gitProvider}
+ showLinearPicker={showLinearPicker}
+ openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings}
/>
void;
onOpenIssuePicker: () => void;
onOpenPrPicker: () => void;
+ showLinearPicker?: boolean;
+ onOpenLinearPicker?: () => void;
onOpenAttachSheet: () => void;
onStartDictation: () => void;
onAbort: () => void;
@@ -64,6 +66,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
onPickLocalFiles,
onOpenIssuePicker,
onOpenPrPicker,
+ showLinearPicker,
+ onOpenLinearPicker,
onOpenAttachSheet,
onStartDictation,
onAbort,
@@ -99,6 +103,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
openIssuePicker={onOpenIssuePicker}
openPrPicker={onOpenPrPicker}
gitProvider={gitProvider}
+ showLinearPicker={showLinearPicker}
+ openLinearPicker={onOpenLinearPicker}
onOpenMobileSheet={onOpenAttachSheet}
/>
{
const parts = [contextPart(chatQuote('quoted bit'), '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')
+ })
})
diff --git a/packages/ui/src/components/chat/lib/messagePreview.ts b/packages/ui/src/components/chat/lib/messagePreview.ts
index 4cd89f7f..4dbfdd81 100644
--- a/packages/ui/src/components/chat/lib/messagePreview.ts
+++ b/packages/ui/src/components/chat/lib/messagePreview.ts
@@ -57,6 +57,8 @@ const contextSummary = (payload: ContextPartPayload, t: Translate): string => {
return `#${payload.number} ${payload.title}`;
case 'github-pr':
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;
case 'github-issue':
case 'github-pr':
+ case 'linear-issue':
return '';
}
};
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts
index b1366366..fa2a2aad 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts
@@ -318,3 +318,41 @@ describe('CJK-aware link parsing', () => {
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(/ {
+ expect(renderMarkdownSync('\\[x = y\\]')).toContain('katex');
+ expect(renderMarkdownSync('Before\n\n\\[\nx = y\n\\]\n\nAfter')).toContain('katex');
+ });
+});
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts
index d9fee589..d5596006 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.ts
@@ -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 = {
name: 'blockMath',
level: 'block' as const,
start(src: string) {
- const index = src.indexOf('\\[');
- return index < 0 ? undefined : index;
+ const match = BLOCK_MATH_LINE_START_RE.exec(src);
+ // 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 {
- const match = /^\\\[([\s\S]+?)\\\]/.exec(src);
+ const match = BLOCK_MATH_RE.exec(src);
if (!match) return undefined;
return { type: 'blockMath', raw: match[0], text: match[1] ?? '' };
},
diff --git a/packages/ui/src/components/chat/markdownRendererLoader.ts b/packages/ui/src/components/chat/markdownRendererLoader.ts
index 986fbedc..033e10fe 100644
--- a/packages/ui/src/components/chat/markdownRendererLoader.ts
+++ b/packages/ui/src/components/chat/markdownRendererLoader.ts
@@ -1,13 +1,31 @@
-let markdownRendererModulePromise: Promise | null = null;
+type MarkdownRendererModule = typeof import('./MarkdownRendererImpl');
+
+let markdownRendererModulePromise: Promise | null = null;
+let markdownRendererModule: MarkdownRendererModule | null = null;
export const loadMarkdownRendererModule = () => {
- markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => {
- markdownRendererModulePromise = null;
- throw error;
- });
+ markdownRendererModulePromise ??= import('./MarkdownRendererImpl')
+ .then((module) => {
+ markdownRendererModule = module;
+ return module;
+ })
+ .catch((error) => {
+ markdownRendererModulePromise = null;
+ throw error;
+ });
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 = () => {
void loadMarkdownRendererModule().catch(() => undefined);
};
diff --git a/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts b/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts
index f4c513ac..fb34591e 100644
--- a/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts
+++ b/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts
@@ -10,6 +10,9 @@ import {
startsWithForgeContextPrefix,
} from '@/lib/messages/synthetic';
+
+const LINEAR_ISSUE_CONTEXT_PREFIX = 'Linear issue context (JSON)';
+
type IssueContextPayload = {
issue?: {
number?: unknown;
@@ -26,6 +29,14 @@ type GitHubPrContextPayload = {
};
};
+type LinearIssueContextPayload = {
+ issue?: {
+ identifier?: unknown;
+ title?: unknown;
+ url?: unknown;
+ };
+};
+
type GitLabMrContextPayload = {
mr?: {
number?: unknown;
@@ -101,6 +112,25 @@ const buildForgeAttachmentPart = (text: string): Part | null => {
} as Part;
}
+ // Linear issues
+ const linearPayload = parseSyntheticJsonPayload(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
const glIssuePayload = parseSyntheticJsonPayload(text, GITLAB_ISSUE_CONTEXT_PREFIX);
if (glIssuePayload) {
@@ -199,7 +229,8 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
const normalizedText = text.trimStart();
return shouldKeepSyntheticUserText(text, planModeEnabled)
- || startsWithForgeContextPrefix(normalizedText);
+ || startsWithForgeContextPrefix(normalizedText)
+ || normalizedText.startsWith(LINEAR_ISSUE_CONTEXT_PREFIX);
})
.map((part) => {
const rawPart = part as Record;
@@ -212,10 +243,18 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
if (synthetic) {
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
// buildGitHubAttachmentPart produces; consumed by
// 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 {
type: 'file',
mime: contextPayload.kind === 'github-issue'
diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md
index d0034968..2f48b596 100644
--- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md
+++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md
@@ -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`
routes to it when the part's metadata carries an `openchamberContext`
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
- builder and the read-back parser). Linked GitHub issues/PRs are instead
- converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
- pre-metadata messages still render via text sniffing (``
- blocks, `GitHub issue context (JSON)` prefixes).
+ builder and the read-back parser). Linked GitHub issues/PRs and Linear
+ issues are instead converted to link file-parts in
+ `normalizeUserDisplayParts.ts`. Legacy pre-metadata messages still render
+ via text sniffing (`` 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`
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
diff --git a/packages/ui/src/components/chat/message/parts/UserContextPart.tsx b/packages/ui/src/components/chat/message/parts/UserContextPart.tsx
index 4402b243..3e2f5415 100644
--- a/packages/ui/src/components/chat/message/parts/UserContextPart.tsx
+++ b/packages/ui/src/components/chat/message/parts/UserContextPart.tsx
@@ -185,6 +185,7 @@ const UserContextPart: React.FC<{
);
case 'github-issue':
case 'github-pr':
+ case 'linear-issue':
// Rendered as link attachments by normalizeUserDisplayParts.
return null;
}
diff --git a/packages/ui/src/components/chat/timelineRevealGate.ts b/packages/ui/src/components/chat/timelineRevealGate.ts
new file mode 100644
index 00000000..7c03ea03
--- /dev/null
+++ b/packages/ui/src/components/chat/timelineRevealGate.ts
@@ -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(null);
diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md
index b99e3dce..13f59014 100644
--- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md
+++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md
@@ -95,11 +95,11 @@ which requests only providers enabled for this panel.
| 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 |
| 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 |
-| 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 |
| 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 |
diff --git a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx
index c2cebd7f..a1759f80 100644
--- a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx
+++ b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx
@@ -308,8 +308,8 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory
// The heading names what is distinctive about this session when there is
// something — an attached thread — and falls back to the ambient counts
// when there is not. `1 · 33 · 2` said nothing without opening the section.
- const issueCount = linked.filter((entry) => entry.kind === 'issue').length;
- const prCount = linked.length - issueCount;
+ const issueCount = linked.filter((entry) => entry.kind === 'issue' || entry.kind === 'linear').length;
+ const prCount = linked.filter((entry) => entry.kind === 'pull').length;
const summaryParts: string[] = [];
if (issueCount > 0) {
summaryParts.push(issueCount === 1
diff --git a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx
index 3c863820..d44a846e 100644
--- a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx
+++ b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx
@@ -13,6 +13,8 @@ type Props = {
directory: string | null;
};
+const MCP_STATUS_MAX_AGE_MS = 60_000;
+
/**
* MCP servers with their connection switches, reusing the dropdown's own
* connect/disconnect actions.
@@ -23,17 +25,19 @@ export const WorkStatusMcpSection: React.FC = ({ directory }) => {
const mcpStatus = useMcpStore(
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 disconnect = useMcpStore((state) => state.disconnect);
const [busyServer, setBusyServer] = React.useState(null);
// 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
- // 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(() => {
- void runBackgroundNetworkTask(() => refreshMcp({ directory, silent: true }));
- }, [directory, refreshMcp]);
+ void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS }));
+ }, [directory, ensureMcpFresh]);
const mcpServers = React.useMemo(
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx
index 68b61f75..b6bd05d7 100644
--- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx
+++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx
@@ -3,11 +3,11 @@ import { useI18n } from '@/lib/i18n';
import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { runBackgroundNetworkTask } from '@/lib/background-network';
-import { useGitProvider } from '@/lib/gitProvider';
-import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
+import { getGitHubPrStatusKey, usePrVisualSummary, useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
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 { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -17,8 +17,6 @@ import { resolveUsageTone } from '@/lib/quota';
import { sessionEvents } from '@/lib/sessionEvents';
import { normalizePath } from '@/lib/pathNormalization';
import { computeContextUsage } from './contextUsage';
-import { formatCost } from './subagentCost';
-import { useSubagentCostRollup } from './useSubagentCostRollup';
import {
WorkStatusCallout,
WorkStatusMeter,
@@ -38,6 +36,11 @@ type Props = {
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
// two places that report context fill never disagree by a rounding step.
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 = ({ sessionId, directory, goalRow, showSession, showRepository }) => {
const { t } = useI18n();
+ const session = useSession(sessionId ?? '', directory ?? undefined);
const { git } = useRuntimeAPIs();
const ensureStatus = useGitStore((state) => state.ensureStatus);
const fetchStatus = useGitStore((state) => state.fetchStatus);
@@ -201,16 +205,7 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory,
: usageTone === 'warn' ? 'var(--status-warning)'
: 'var(--status-success)';
- // Rollup total: own cost plus every descendant subagent's cost, recursively
- // (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 cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null;
const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow));
const hasGitLabMr = gitProvider === 'gitlab' && gitLabMr !== null;
const hasGiteaPr = gitProvider === 'gitea' && giteaPr !== null;
@@ -259,17 +254,6 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory,
)}
/>
- {/* 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 ? (
-
- {t('chat.workStatus.cost.breakdown', {
- session: formatCost(ownCost),
- subagents: formatCost(subagentCost),
- })}
-
- ) : null}
>
) : null}
{/* Below the context readout: the goal is a standing instruction,
diff --git a/packages/ui/src/components/desktop/WindowsWindowControls.tsx b/packages/ui/src/components/desktop/WindowsWindowControls.tsx
index 4d257e30..b74fdcfb 100644
--- a/packages/ui/src/components/desktop/WindowsWindowControls.tsx
+++ b/packages/ui/src/components/desktop/WindowsWindowControls.tsx
@@ -142,7 +142,9 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
@@ -207,7 +209,11 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
type="button"
className={cn(
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'); }}
title={t('header.windowControls.close')}
diff --git a/packages/ui/src/components/github/GitHubAccountControl.tsx b/packages/ui/src/components/github/GitHubAccountControl.tsx
new file mode 100644
index 00000000..2f8a6ca9
--- /dev/null
+++ b/packages/ui/src/components/github/GitHubAccountControl.tsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import type { GitHubAuthStatus } from '@/lib/api/types';
+import { useI18n } from '@/lib/i18n';
+import { runtimeFetch } from '@/lib/runtime-fetch';
+import { cn } from '@/lib/utils';
+import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
+
+type GitHubAccount = NonNullable
[number];
+
+const AVATAR_CLASS = 'flex h-6 w-6 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80';
+
+const activateAccount = async (
+ github: ReturnType['github'],
+ accountId: string,
+): Promise => {
+ if (github) {
+ return github.authActivate(accountId);
+ }
+ const response = await runtimeFetch('/api/github/auth/activate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ accountId }),
+ });
+ // SAFETY: the route is ours and answers the auth status shape (plus an
+ // `error` string on failure) on every response; a non-ok status throws below.
+ const body = (await response.json().catch(() => null)) as (GitHubAuthStatus & { error?: string }) | null;
+ if (!response.ok || !body) {
+ throw new Error(body?.error || response.statusText);
+ }
+ return body;
+};
+
+/**
+ * The connected GitHub account: an avatar, and a switcher when more than one
+ * account is signed in (OAuth and `gh` CLI logins). Renders nothing while
+ * GitHub is disconnected — connecting happens in Settings → Integrations.
+ */
+export const GitHubAccountControl: React.FC<{ className?: string }> = ({ className }) => {
+ const { t } = useI18n();
+ const { github } = useRuntimeAPIs();
+ const status = useGitHubAuthStore((state) => state.status);
+ const setStatus = useGitHubAuthStore((state) => state.setStatus);
+ const [isSwitching, setIsSwitching] = React.useState(false);
+
+ const switchAccount = React.useCallback(async (accountId: string) => {
+ if (!accountId || isSwitching) return;
+ setIsSwitching(true);
+ try {
+ setStatus(await activateAccount(github, accountId));
+ } catch (error) {
+ console.error('Failed to switch GitHub account:', error);
+ } finally {
+ setIsSwitching(false);
+ }
+ }, [github, isSwitching, setStatus]);
+
+ if (!status?.connected) {
+ return null;
+ }
+
+ const login = status.user?.login ?? null;
+ const avatarUrl = status.user?.avatarUrl ?? null;
+ const accounts: GitHubAccount[] = status.accounts ?? [];
+ const title = login ? t('header.github.connectedWithLogin', { login }) : t('header.github.connected');
+ const avatar = avatarUrl ? (
+
+ ) : (
+
+ );
+
+ if (accounts.length <= 1) {
+ return (
+
+ {avatar}
+
+ );
+ }
+
+ return (
+
+
+
+ {avatar}
+
+
+
+
+ {t('header.github.accountsTitle')}
+
+
+ {accounts.map((account) => {
+ const accountUser = account.user;
+ const isCurrent = Boolean(account.current);
+ const sourceLabel = account.source === 'gh-cli'
+ ? t('header.github.accountSource.cli')
+ : t('header.github.accountSource.oauth');
+ return (
+ {
+ if (!isCurrent) {
+ void switchAccount(account.id);
+ }
+ }}
+ >
+ {accountUser?.avatarUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
+
+ {accountUser?.login ? (
+
+ {accountUser.login}
+ ·
+ {sourceLabel}
+
+ ) : null}
+
+ {isCurrent ? : null}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts
index 09237b31..500aea55 100644
--- a/packages/ui/src/components/icon/sprite.ts
+++ b/packages/ui/src/components/icon/sprite.ts
@@ -234,6 +234,7 @@ export const iconSpriteData = {
"target": ` `,
"target-fill": ` `,
"task": ` `,
+ "team": ` `,
"terminal": ` `,
"terminal-box": ` `,
"terminal-window": ` `,
diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx
index ecc2761f..9f458d65 100644
--- a/packages/ui/src/components/layout/ContextPanel.tsx
+++ b/packages/ui/src/components/layout/ContextPanel.tsx
@@ -18,6 +18,9 @@ const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/w
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 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 })));
import { ProjectContextPanel } from './RightSidebarTabs';
import { SidebarFilesTree } from './SidebarFilesTree';
@@ -123,6 +126,7 @@ const getModeLabel = (
if (mode === 'browser') return t('contextPanel.mode.browser');
if (mode === 'git') return t('layout.rightSidebar.git');
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 === 'terminal') return t('layout.mainTab.terminal');
return t('contextPanel.mode.context');
@@ -219,6 +223,10 @@ const getTabIcon = (
return ;
}
+ if (tab.mode === 'linear') {
+ return ;
+ }
+
if (tab.mode === 'notes') {
return ;
}
@@ -947,6 +955,8 @@ export const ContextPanel: React.FC = () => {
?
: activeTab?.mode === 'pr'
? (gitProvider === 'github' ? : gitProvider === 'gitlab' ? : gitProvider === 'gitea' ? : null)
+ : activeTab?.mode === 'linear'
+ ?
: activeTab?.mode === 'notes'
?
: activeTab?.mode === 'plan'
@@ -1288,7 +1298,7 @@ export const ContextPanel: React.FC = () => {
{hasWalkthroughTab ? (
-
+
) : null}
diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx
index 5a2ca0a3..9aa81e8d 100644
--- a/packages/ui/src/components/layout/ContextPanelRail.tsx
+++ b/packages/ui/src/components/layout/ContextPanelRail.tsx
@@ -37,6 +37,8 @@ import {
import { cn } from '@/lib/utils';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitStatus } from '@/stores/useGitStore';
+import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
+import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog';
@@ -167,8 +169,13 @@ export const ContextPanelRail: React.FC = () => {
const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces);
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
const openContextSurface = useUIStore((state) => state.openContextSurface);
+ const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
+ const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
+ const linearConnected = useLinearAuthStore((state) => state.status?.connected === true);
+ const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
+ const githubConnected = useGitHubAuthStore((state) => state.status?.connected === true);
const { screenWidth } = useDeviceInfo();
const gitStatus = useGitStatus(directoryKey || null);
// Provider-aware 'pr' branding: GitLab repositories get the MR descriptor
@@ -268,9 +275,27 @@ export const ContextPanelRail: React.FC = () => {
isVSCode: isVSCodeRuntime(),
screenWidth,
tabs,
+ linearConnected,
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);
diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx
index f8a3f331..41bc1dfa 100644
--- a/packages/ui/src/components/layout/Header.tsx
+++ b/packages/ui/src/components/layout/Header.tsx
@@ -9,7 +9,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
@@ -30,8 +29,6 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
-import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
-import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls';
@@ -45,10 +42,11 @@ import {
import {
} from '@/components/ui/collapsible';
-import type { GitHubAuthStatus } from '@/lib/api/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
+import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
+import { useProjectActionsContext } from '@/hooks/useProjectActionsContext';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
import { SessionTabsStrip, type SessionTabMenuArgs } from './SessionTabsStrip';
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop';
@@ -123,132 +121,6 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
);
});
-type DesktopGitHubControlProps = {
- isMobile: boolean;
- githubAuthStatus: GitHubAuthStatus | null;
- githubAccounts: Array[number]>;
- githubAvatarUrl: string | null;
- githubLogin: string | null;
- isSwitchingGitHubAccount: boolean;
- handleGitHubAccountSwitch: (accountId: string) => Promise;
-};
-
-const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
- isMobile,
- githubAuthStatus,
- githubAccounts,
- githubAvatarUrl,
- githubLogin,
- isSwitchingGitHubAccount,
- handleGitHubAccountSwitch,
-}: DesktopGitHubControlProps) {
- const { t } = useI18n();
- if (!githubAuthStatus?.connected || isMobile) {
- return null;
- }
-
- if (githubAccounts.length > 1) {
- return (
-
-
-
- {githubAvatarUrl ? (
-
- ) : (
-
- )}
-
-
-
-
- {t('header.github.accountsTitle')}
-
-
- {githubAccounts.map((account) => {
- const accountUser = account.user;
- const isCurrent = Boolean(account.current);
- const sourceLabel = account.source === 'gh-cli'
- ? t('header.github.accountSource.cli')
- : t('header.github.accountSource.oauth');
- return (
- {
- if (!isCurrent) {
- void handleGitHubAccountSwitch(account.id);
- }
- }}
- >
- {accountUser?.avatarUrl ? (
-
- ) : (
-
-
-
- )}
-
-
- {accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
-
- {accountUser?.login ? (
-
- {accountUser.login}
- ·
- {sourceLabel}
-
- ) : null}
-
- {isCurrent ? : null}
-
- );
- })}
-
-
- );
- }
-
- return (
-
- {githubAvatarUrl ? (
-
- ) : (
-
- )}
-
- );
-});
-
type DesktopServicesMenuProps = {
isDesktopApp: boolean;
currentInstanceLabel: string;
@@ -439,7 +311,6 @@ export const Header: React.FC = () => {
const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled);
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
- const runtimeApis = useRuntimeAPIs();
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
@@ -488,8 +359,6 @@ export const Header: React.FC = () => {
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
const { isMobile } = useDeviceInfo();
- const githubAuthStatus = useGitHubAuthStore((state) => state.status);
- const setGitHubAuthStatus = useGitHubAuthStore((state) => state.setStatus);
const headerRef = React.useRef(null);
@@ -571,10 +440,6 @@ export const Header: React.FC = () => {
}
}, [contextUsage, currentSessionId, isContextUsageResolvedForSession]);
- const githubAvatarUrl = githubAuthStatus?.connected ? (githubAuthStatus.user?.avatarUrl ?? null) : null;
- const githubLogin = githubAuthStatus?.connected ? (githubAuthStatus.user?.login ?? null) : null;
- const githubAccounts = githubAuthStatus?.accounts ?? [];
- const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false);
const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false);
const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local');
const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true);
@@ -1134,27 +999,9 @@ export const Header: React.FC = () => {
return normalize(openDirectory || activeProject?.path || '');
}, [activeProject?.path, openDirectory]);
- const activeProjectRef = React.useMemo(() => {
- if (!activeProject) {
- return null;
- }
- return { id: activeProject.id, path: activeProject.path };
- }, [activeProject]);
-
- const lastProjectActionsContextRef = React.useRef<{
- projectRef: { id: string; path: string };
- directory: string;
- } | null>(null);
-
- React.useEffect(() => {
- if (!activeProjectRef || !actionDirectory) {
- return;
- }
- lastProjectActionsContextRef.current = {
- projectRef: activeProjectRef,
- directory: actionDirectory,
- };
- }, [actionDirectory, activeProjectRef]);
+ // Same resolution the titlebar overlay used to own: worktree → session →
+ // draft → project path, sticky across session switches.
+ const projectActionsContext = useProjectActionsContext();
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
@@ -1183,37 +1030,6 @@ export const Header: React.FC = () => {
sessionDirectory,
]);
- const handleGitHubAccountSwitch = React.useCallback(async (accountId: string) => {
- if (!accountId || isSwitchingGitHubAccount) return;
- setIsSwitchingGitHubAccount(true);
- try {
- const payload = runtimeApis.github
- ? await runtimeApis.github.authActivate(accountId)
- : await (async () => {
- const response = await runtimeFetch('/api/github/auth/activate', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- },
- body: JSON.stringify({ accountId }),
- });
- const body = (await response.json().catch(() => null)) as
- | (GitHubAuthStatus & { error?: string })
- | null;
- if (!response.ok || !body) {
- throw new Error(body?.error || response.statusText);
- }
- return body;
- })();
-
- setGitHubAuthStatus(payload);
- } catch (error) {
- console.error('Failed to switch GitHub account:', error);
- } finally {
- setIsSwitchingGitHubAccount(false);
- }
- }, [isSwitchingGitHubAccount, runtimeApis.github, setGitHubAuthStatus]);
@@ -1356,6 +1172,14 @@ export const Header: React.FC = () => {
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 {
// Left inset is handled by the no-drag spacer (see renderDesktop); only
// 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))',
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
};
- }, [isDesktopApp, isVSCode, usesFramelessChrome]);
+ }, [isDesktopApp, isVSCode, usesFramelessChrome, windowControlsSide]);
const updateHeaderHeight = React.useCallback(() => {
if (typeof document === 'undefined') {
@@ -1454,6 +1278,13 @@ export const Header: React.FC = () => {
const desktopSidebarActions = (
<>
+ {projectActionsContext ? (
+
+ ) : null}
{/* Instances only exist in the desktop app. On web the menu was left
holding a single dev-only shutdown action, which is not a reason to
@@ -1474,15 +1305,6 @@ export const Header: React.FC = () => {
onOpenRemoteUpdate={openRemoteInstanceUpdate}
/>
) : null}
-
>
);
diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx
index bd646e2f..85413d3f 100644
--- a/packages/ui/src/components/layout/SessionTabsStrip.tsx
+++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx
@@ -159,8 +159,11 @@ const SessionTabItem: React.FC<{
}}
data-controls-open={overlayVisible ? 'true' : 'false'}
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',
- 'transition-colors duration-75',
isActive
? 'bg-interactive-selection'
: cn(
@@ -180,8 +183,15 @@ const SessionTabItem: React.FC<{
!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 : (
- {title}
+
+ {title}
+
)}
{showDot ? (
diff --git a/packages/ui/src/components/layout/SidebarTopBar.tsx b/packages/ui/src/components/layout/SidebarTopBar.tsx
index 564e051a..2e332521 100644
--- a/packages/ui/src/components/layout/SidebarTopBar.tsx
+++ b/packages/ui/src/components/layout/SidebarTopBar.tsx
@@ -2,8 +2,8 @@ import React from 'react';
/**
* Strip at the top of the desktop left sidebar that reserves room for the
- * persistent {@link TitlebarLeftControls} overlay (sidebar toggle + project
- * actions), so the session list starts below them. Its height tracks the
+ * persistent {@link TitlebarLeftControls} overlay (sidebar toggle), so the
+ * session list starts below it. Its height tracks the
* header via `--oc-header-height`.
*
* Split into two regions so the strip stays a window drag area while the
diff --git a/packages/ui/src/components/layout/TitlebarLeftControls.tsx b/packages/ui/src/components/layout/TitlebarLeftControls.tsx
index c8484eb7..41b92b3f 100644
--- a/packages/ui/src/components/layout/TitlebarLeftControls.tsx
+++ b/packages/ui/src/components/layout/TitlebarLeftControls.tsx
@@ -4,8 +4,6 @@ import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
-import { useProjectActionsContext } from '@/hooks/useProjectActionsContext';
-import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { invokeDesktop } from '@/lib/desktop';
@@ -15,7 +13,7 @@ const ICON_BUTTON_CLASS =
'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary hover:bg-interactive-hover transition-colors';
/**
- * Persistent top-left titlebar controls (sidebar toggle + project actions).
+ * Persistent top-left titlebar controls (app menu on frameless chrome + sidebar toggle).
*
* Rendered exactly once as an absolutely-positioned overlay above both the
* sidebar and the header, so the buttons never migrate / re-mount between the
@@ -29,7 +27,6 @@ export const TitlebarLeftControls: React.FC = () => {
const { t } = useI18n();
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
- const projectActionsContext = useProjectActionsContext();
const clusterRef = React.useRef(null);
const toggleShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_sidebar', shortcutOverrides));
@@ -123,13 +120,6 @@ export const TitlebarLeftControls: React.FC = () => {
{t('header.actions.openSessionsWithShortcut', { shortcut: toggleShortcut })}
-
- {projectActionsContext ? (
-
- ) : null}
);
diff --git a/packages/ui/src/components/layout/__tests__/linear-panel-review-guards.test.ts b/packages/ui/src/components/layout/__tests__/linear-panel-review-guards.test.ts
new file mode 100644
index 00000000..df45aec3
--- /dev/null
+++ b/packages/ui/src/components/layout/__tests__/linear-panel-review-guards.test.ts
@@ -0,0 +1,42 @@
+/**
+ * Guards from the OPE-296 review: stale Linear list pages must not land, and a
+ * persisted Linear tab must survive reload until auth has actually resolved.
+ */
+import { describe, expect, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const railSource = readFileSync(join(__dirname, '..', 'ContextPanelRail.tsx'), 'utf-8');
+const issuesViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'LinearIssuesView.tsx'), 'utf-8');
+const pickerSource = readFileSync(join(__dirname, '..', '..', 'session', 'LinearIssuePickerDialog.tsx'), 'utf-8');
+
+const sliceFn = (source: string, marker: string, length: number) => {
+ const start = source.indexOf(marker);
+ expect(start).toBeGreaterThan(-1);
+ return source.slice(start, start + length);
+};
+
+describe('Linear panel review guards', () => {
+ test('disconnect-close waits for Linear auth to resolve', () => {
+ const effect = sliceFn(railSource, 'if (!directoryKey || !linearAuthChecked || linearConnected || activeMode !== \'linear\')', 240);
+ expect(effect).toContain('closeContextPanel(directoryKey)');
+ expect(railSource).toContain('state.hasChecked');
+ });
+
+ test('rail loadMore shares listRequestId with refresh', () => {
+ const loadMore = sliceFn(issuesViewSource, 'const loadMore = React.useCallback(async () => {', 900);
+ expect(loadMore).toContain('const requestId = listRequestId.current + 1');
+ expect(loadMore).toContain('if (requestId !== listRequestId.current) return');
+ });
+
+ test('picker refresh and loadMore reject stale pages', () => {
+ const refresh = sliceFn(pickerSource, 'const refresh = React.useCallback(async (search = \'\') => {', 1400);
+ const loadMore = sliceFn(pickerSource, 'const loadMore = React.useCallback(async () => {', 900);
+ expect(refresh).toContain('const requestId = listRequestId.current + 1');
+ expect(refresh).toContain('if (requestId !== listRequestId.current) return');
+ expect(loadMore).toContain('const requestId = listRequestId.current + 1');
+ expect(loadMore).toContain('if (requestId !== listRequestId.current) return');
+ });
+});
diff --git a/packages/ui/src/components/sections/git-identities/GitPage.tsx b/packages/ui/src/components/sections/git-identities/GitPage.tsx
index 147af05e..ba3bb31e 100644
--- a/packages/ui/src/components/sections/git-identities/GitPage.tsx
+++ b/packages/ui/src/components/sections/git-identities/GitPage.tsx
@@ -194,6 +194,7 @@ export const GitPage: React.FC = (props) => {
openEditor('new')}>
{t('settings.common.badge.new')}
diff --git a/packages/ui/src/components/sections/integrations/GitHubIntegration.tsx b/packages/ui/src/components/sections/integrations/GitHubIntegration.tsx
new file mode 100644
index 00000000..466de0f7
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/GitHubIntegration.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { Icon } from '@/components/icon/Icon';
+import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
+import { useI18n } from '@/lib/i18n';
+import { cn } from '@/lib/utils';
+import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
+
+/**
+ * The GitHub row of Settings → Integrations → Built-in integrations: a
+ * collapsible card whose body is the account/device-flow UI. Sign-in status
+ * shows on the collapsed row so the page answers "am I connected?" at a
+ * glance, like the Linear card beside it.
+ */
+export const GitHubIntegration: React.FC = () => {
+ const { t } = useI18n();
+ const status = useGitHubAuthStore((state) => state.status);
+ const isLoading = useGitHubAuthStore((state) => state.isLoading);
+ const hasChecked = useGitHubAuthStore((state) => state.hasChecked);
+ const [open, setOpen] = React.useState(false);
+
+ const connected = status?.connected === true;
+ const statusLabel = isLoading && !hasChecked
+ ? t('common.loading')
+ : connected
+ ? (status?.user?.login?.trim() || t('settings.github.page.status.active'))
+ : t('settings.integrations.github.status.notConnected');
+ const statusClassName = connected
+ ? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
+ : 'bg-[var(--surface-muted)] text-muted-foreground';
+
+ return (
+
+
+
+
+
+
+
+
+ {t('settings.integrations.github.title')}
+
+
+ {t('settings.integrations.github.description')}
+
+
+
+ {statusLabel}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx
index 666c0801..a3b5956d 100644
--- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx
+++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx
@@ -1,8 +1,11 @@
import React from 'react';
-import { Icon } from '@/components/icon/Icon';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
-import { SETTINGS_DESCRIPTION_CLASS } from '@/components/sections/shared/SettingsSection';
+import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { useI18n } from '@/lib/i18n';
+import { isVSCodeRuntime } from '@/lib/desktop';
+import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
+import { GitHubIntegration } from './GitHubIntegration';
+import { LinearSettings } from './LinearSettings';
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
interface IntegrationsPageProps {
@@ -15,25 +18,32 @@ export const IntegrationsPage: React.FC = ({
onOpenPluginManager,
}) => {
const { t } = useI18n();
+ // GitHub sign-in is an OpenChamber server feature; the VS Code extension
+ // uses the editor's own GitHub session instead.
+ const hasGitHub = !isVSCodeRuntime();
+ const hasLinear = Boolean(getRegisteredRuntimeAPIs()?.linear);
+ const hasBuiltIn = hasGitHub || hasLinear;
return (
- {t('settings.page.integrations.description')}
-
-
-
- {t('settings.integrations.experimentalWarning')}
-
-
-
- )}
- showSaveStatus={false}
+ description={t('settings.page.integrations.description')}
+ showSaveStatus
>
+ {hasBuiltIn ? (
+
+ {hasGitHub ? : null}
+ {hasLinear ? : null}
+
+ ) : null}
diff --git a/packages/ui/src/components/sections/integrations/LinearProjectMapping.tsx b/packages/ui/src/components/sections/integrations/LinearProjectMapping.tsx
new file mode 100644
index 00000000..773b687b
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/LinearProjectMapping.tsx
@@ -0,0 +1,230 @@
+import React from 'react';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import {
+ SettingsControlGroup,
+ SettingsFieldRow,
+ SETTINGS_FIELDS_STACK_CLASS,
+ SETTINGS_SELECT_ROW_TRIGGER_CLASS,
+ SETTINGS_SELECT_SIZE,
+} from '@/components/sections/shared/SettingsSection';
+import { reportSettingsSaveState } from '@/lib/persistence';
+import { useI18n } from '@/lib/i18n';
+import { useProjectsStore } from '@/stores/useProjectsStore';
+import type { LinearAPI, LinearMappingResult } from '@/lib/api/types';
+
+const NONE = '__none__';
+const INHERIT = '__inherit__';
+
+export function LinearProjectMapping({
+ linear,
+ connected,
+ organizationId,
+}: {
+ linear: LinearAPI;
+ connected: boolean;
+ organizationId?: string | null;
+}) {
+ const { t } = useI18n();
+ const projects = useProjectsStore((state) => state.projects);
+ const [mapping, setMapping] = React.useState(null);
+ const [loadFailed, setLoadFailed] = React.useState(false);
+ const [isSaving, setIsSaving] = React.useState(false);
+
+ const loadMapping = React.useCallback(async () => {
+ if (!connected) {
+ setMapping(null);
+ setLoadFailed(false);
+ return;
+ }
+ try {
+ const next = await linear.mappingGet();
+ if (next.connected === false) {
+ setMapping(null);
+ setLoadFailed(false);
+ return;
+ }
+ setMapping(next);
+ setLoadFailed(false);
+ } catch (error) {
+ console.error('Failed to load Linear mapping:', error);
+ setLoadFailed(true);
+ }
+ }, [connected, linear]);
+
+ React.useEffect(() => {
+ void loadMapping();
+ }, [loadMapping, organizationId]);
+
+ const saveMapping = React.useCallback(async (next: LinearMappingResult) => {
+ const teamProjectPaths: { [teamId: string]: string } = {};
+ for (const team of next.teams ?? []) {
+ if (team.projectPath) {
+ teamProjectPaths[team.id] = team.projectPath;
+ }
+ }
+ setIsSaving(true);
+ reportSettingsSaveState('saving');
+ try {
+ const saved = await linear.mappingSet({
+ defaultProjectPath: next.defaultProjectPath ?? null,
+ teamProjectPaths,
+ });
+ if (saved.connected === false) {
+ setMapping(null);
+ reportSettingsSaveState('error');
+ return;
+ }
+ setMapping(saved);
+ setLoadFailed(false);
+ reportSettingsSaveState('saved');
+ } catch (error) {
+ console.error('Failed to save Linear mapping:', error);
+ reportSettingsSaveState('error');
+ } finally {
+ setIsSaving(false);
+ }
+ }, [linear]);
+
+ if (!connected) {
+ return null;
+ }
+
+ if (loadFailed && !mapping) {
+ return (
+
+ {t('settings.integrations.linear.mapping.loadFailed')}
+
+ );
+ }
+
+ if (!mapping) {
+ return null;
+ }
+
+ const projectLabel = (path: string) => {
+ const project = projects.find((entry) => entry.path === path);
+ return project?.label?.trim() || path;
+ };
+
+ const defaultProjectLabel = (value: string | undefined) => {
+ if (!value || value === NONE) {
+ return t('settings.integrations.linear.mapping.defaultProject.placeholder');
+ }
+ return projectLabel(value);
+ };
+
+ const teamProjectLabel = (value: string | undefined) => {
+ if (!value || value === INHERIT) {
+ return t('settings.integrations.linear.mapping.teams.useDefault');
+ }
+ return projectLabel(value);
+ };
+
+ return (
+
+ {projects.length === 0 ? (
+
+ {t('settings.integrations.linear.mapping.emptyProjects')}
+
+ ) : null}
+
+
+ {
+ void saveMapping({
+ ...mapping,
+ defaultProjectPath: value === NONE ? null : value,
+ });
+ }}
+ >
+
+
+ {defaultProjectLabel}
+
+
+
+
+ {t('settings.integrations.linear.mapping.defaultProject.placeholder')}
+
+ {mapping.defaultProjectPath && !projects.some((entry) => entry.path === mapping.defaultProjectPath) ? (
+ {mapping.defaultProjectPath}
+ ) : null}
+ {projects.map((project) => (
+
+ {projectLabel(project.path)}
+
+ ))}
+
+
+
+
+
+ {(mapping.teams ?? []).length === 0 ? (
+
+ {t('settings.integrations.linear.mapping.emptyTeams')}
+
+ ) : (
+
+ {(mapping.teams ?? []).map((team) => (
+
+ {
+ void saveMapping({
+ ...mapping,
+ teams: (mapping.teams ?? []).map((entry) => (
+ entry.id === team.id
+ ? { ...entry, projectPath: value === INHERIT ? null : value }
+ : entry
+ )),
+ });
+ }}
+ >
+
+
+ {teamProjectLabel}
+
+
+
+
+ {t('settings.integrations.linear.mapping.teams.useDefault')}
+
+ {team.projectPath && !projects.some((entry) => entry.path === team.projectPath) ? (
+ {team.projectPath}
+ ) : null}
+ {projects.map((project) => (
+
+ {projectLabel(project.path)}
+
+ ))}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/packages/ui/src/components/sections/integrations/LinearSessionComments.tsx b/packages/ui/src/components/sections/integrations/LinearSessionComments.tsx
new file mode 100644
index 00000000..5593c70a
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/LinearSessionComments.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import { Switch } from '@/components/ui/switch';
+import {
+ SettingsFieldRow,
+ SETTINGS_FIELDS_STACK_CLASS,
+} from '@/components/sections/shared/SettingsSection';
+import { reportSettingsSaveState } from '@/lib/persistence';
+import { useI18n } from '@/lib/i18n';
+import type { LinearAPI } from '@/lib/api/types';
+
+/**
+ * Status comments are written into a Linear workspace other people read, so
+ * they stay off until the user turns them on. The server posts nothing while
+ * this is off, including the completed and failure comments the event hub
+ * sends without going through this interface.
+ */
+export function LinearSessionComments({
+ linear,
+ connected,
+}: {
+ linear: LinearAPI;
+ connected: boolean;
+}) {
+ const { t } = useI18n();
+ const [enabled, setEnabled] = React.useState(null);
+ const [loadFailed, setLoadFailed] = React.useState(false);
+ const [isSaving, setIsSaving] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!connected) {
+ setEnabled(null);
+ setLoadFailed(false);
+ return;
+ }
+ let cancelled = false;
+ void linear.preferencesGet()
+ .then((preferences) => {
+ if (cancelled) return;
+ setEnabled(preferences.sessionComments);
+ setLoadFailed(false);
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setLoadFailed(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [connected, linear]);
+
+ const save = React.useCallback(async (next: boolean) => {
+ const previous = enabled;
+ setEnabled(next);
+ setIsSaving(true);
+ try {
+ const saved = await linear.preferencesSet({ sessionComments: next });
+ setEnabled(saved.sessionComments);
+ reportSettingsSaveState('saved');
+ } catch {
+ setEnabled(previous);
+ reportSettingsSaveState('error');
+ } finally {
+ setIsSaving(false);
+ }
+ }, [enabled, linear]);
+
+ if (!connected) {
+ return null;
+ }
+
+ if (loadFailed) {
+ return (
+
+ {t('settings.integrations.linear.sessionComments.loadFailed')}
+
+ );
+ }
+
+ return (
+
+
+ { void save(checked); }}
+ aria-label={t('settings.integrations.linear.sessionComments.aria')}
+ />
+
+
+ );
+}
diff --git a/packages/ui/src/components/sections/integrations/LinearSettings.tsx b/packages/ui/src/components/sections/integrations/LinearSettings.tsx
new file mode 100644
index 00000000..98bda06c
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/LinearSettings.tsx
@@ -0,0 +1,341 @@
+import React from 'react';
+import { Button } from '@/components/ui/button';
+import { toast } from '@/components/ui';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
+import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
+import { cn } from '@/lib/utils';
+import { openExternalUrl } from '@/lib/url';
+import { useI18n } from '@/lib/i18n';
+import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
+import { Icon } from '@/components/icon/Icon';
+import { LinearProjectMapping } from './LinearProjectMapping';
+import { LinearSessionComments } from './LinearSessionComments';
+
+const AUTHORIZATION_WATCH_MS = 3 * 60_000;
+const AUTHORIZATION_POLL_MS = 1_500;
+
+type WorkspaceSnapshot = {
+ connected: boolean;
+ ids: string;
+ currentId: string;
+ currentAuthorizedAt: number;
+};
+
+function snapshotWorkspaces(status: {
+ connected?: boolean;
+ organization?: { id?: string } | null;
+ workspaces?: Array<{ id: string; current: boolean; authorizedAt?: number | null }>;
+} | null): WorkspaceSnapshot {
+ const workspaces = status?.workspaces ?? [];
+ const current = workspaces.find((entry) => entry.current);
+ return {
+ connected: Boolean(status?.connected),
+ ids: workspaces.map((entry) => entry.id).slice().sort().join(','),
+ currentId: current?.id || status?.organization?.id || '',
+ currentAuthorizedAt: current?.authorizedAt ?? 0,
+ };
+}
+
+function authorizationCompleted(previous: WorkspaceSnapshot, next: WorkspaceSnapshot): boolean {
+ if (!next.connected) return false;
+ if (!previous.connected) return true;
+ return next.ids !== previous.ids
+ || next.currentId !== previous.currentId
+ || next.currentAuthorizedAt !== previous.currentAuthorizedAt;
+}
+
+export const LinearSettings: React.FC = () => {
+ const { t } = useI18n();
+ const runtimeLinear = getRegisteredRuntimeAPIs()?.linear;
+ const status = useLinearAuthStore((state) => state.status);
+ const isLoading = useLinearAuthStore((state) => state.isLoading);
+ const hasChecked = useLinearAuthStore((state) => state.hasChecked);
+ const refreshStatus = useLinearAuthStore((state) => state.refreshStatus);
+ const setStatus = useLinearAuthStore((state) => state.setStatus);
+
+ const [isBusy, setIsBusy] = React.useState(false);
+ const [isWaiting, setIsWaiting] = React.useState(false);
+ const [open, setOpen] = React.useState(false);
+ const pollTimerRef = React.useRef(null);
+
+ const stopWaiting = React.useCallback(() => {
+ if (pollTimerRef.current != null) {
+ window.clearInterval(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ setIsWaiting(false);
+ }, []);
+
+ React.useEffect(() => {
+ if (!runtimeLinear) {
+ return;
+ }
+ if (!hasChecked) {
+ void refreshStatus(runtimeLinear);
+ }
+ return () => {
+ stopWaiting();
+ };
+ }, [hasChecked, refreshStatus, runtimeLinear, stopWaiting]);
+
+ const startConnect = React.useCallback(async () => {
+ if (!runtimeLinear) return;
+ stopWaiting();
+ setIsBusy(true);
+ const previous = snapshotWorkspaces(useLinearAuthStore.getState().status);
+ try {
+ const payload = await runtimeLinear.authStart(isDesktopShell() ? 'desktop' : 'web');
+ setIsWaiting(true);
+ setOpen(true);
+ void openExternalUrl(payload.authorizationUrl);
+
+ const deadline = Date.now() + AUTHORIZATION_WATCH_MS;
+ pollTimerRef.current = window.setInterval(() => {
+ void (async () => {
+ if (Date.now() > deadline) {
+ stopWaiting();
+ toast.error(t('settings.integrations.linear.toast.authorizationFailed'));
+ return;
+ }
+ const next = await refreshStatus(runtimeLinear, { force: true });
+ if (authorizationCompleted(previous, snapshotWorkspaces(next))) {
+ stopWaiting();
+ toast.success(t('settings.integrations.linear.toast.connected'));
+ void focusDesktopWindow();
+ }
+ })();
+ }, AUTHORIZATION_POLL_MS);
+ } catch (error) {
+ console.error('Failed to start Linear connect:', error);
+ toast.error(t('settings.integrations.linear.toast.startConnectFailed'));
+ stopWaiting();
+ } finally {
+ setIsBusy(false);
+ }
+ }, [refreshStatus, runtimeLinear, stopWaiting, t]);
+
+ const activateWorkspace = React.useCallback(async (organizationId: string) => {
+ if (!runtimeLinear || !organizationId) return;
+ setIsBusy(true);
+ try {
+ const payload = await runtimeLinear.authActivate(organizationId);
+ setStatus(payload);
+ toast.success(t('settings.integrations.linear.toast.workspaceSwitched'));
+ } catch (error) {
+ console.error('Failed to switch Linear workspace:', error);
+ toast.error(t('settings.integrations.linear.toast.workspaceSwitchFailed'));
+ } finally {
+ setIsBusy(false);
+ }
+ }, [runtimeLinear, setStatus, t]);
+
+ const disconnect = React.useCallback(async () => {
+ if (!runtimeLinear) return;
+ setIsBusy(true);
+ try {
+ stopWaiting();
+ await runtimeLinear.authDisconnect();
+ toast.success(t('settings.integrations.linear.toast.disconnected'));
+ await refreshStatus(runtimeLinear, { force: true });
+ } catch (error) {
+ console.error('Failed to disconnect Linear:', error);
+ toast.error(t('settings.integrations.linear.toast.disconnectFailed'));
+ } finally {
+ setIsBusy(false);
+ }
+ }, [refreshStatus, runtimeLinear, stopWaiting, t]);
+
+ if (!runtimeLinear) {
+ return null;
+ }
+
+ const connected = Boolean(status?.connected);
+ const user = status?.user;
+ const organization = status?.organization;
+ const workspaces = status?.workspaces ?? [];
+ const otherWorkspaces = workspaces.filter((workspace) => !workspace.current);
+ const displayName = user?.displayName?.trim() || user?.name?.trim() || t('settings.integrations.linear.label.unknownUser');
+ const statusLabel = isWaiting
+ ? t('settings.integrations.linear.status.waiting')
+ : isLoading && !hasChecked
+ ? t('common.loading')
+ : connected
+ ? (organization?.name?.trim() || t('settings.integrations.linear.status.connected'))
+ : t('settings.integrations.linear.status.notConnected');
+ const statusClassName = isWaiting
+ ? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
+ : connected
+ ? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
+ : 'bg-[var(--surface-muted)] text-muted-foreground';
+ const expanded = isWaiting || open;
+
+ return (
+ {
+ if (isWaiting) {
+ setOpen(true);
+ return;
+ }
+ setOpen(nextOpen);
+ }}
+ >
+
+
+
+
+
+
+
+ {t('settings.integrations.linear.title')}
+
+
+ {t('settings.integrations.linear.description')}
+
+
+
+ {statusLabel}
+
+
+
+
+
+ {connected ? (
+
+ {user?.avatarUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
{displayName}
+
+ {[organization?.name, user?.email].filter(Boolean).join(' · ')}
+
+
+
+ ) : isWaiting ? (
+
+ {t('settings.integrations.linear.flow.description')}
+
+ ) : null}
+
+ {connected ? (
+ <>
+
+
+ {otherWorkspaces.length > 0 ? (
+
+
+ {t('settings.integrations.linear.label.otherWorkspaces')}
+
+
+ {otherWorkspaces.map((workspace) => {
+ const workspaceUser = workspace.user;
+ const workspaceName = workspace.name?.trim()
+ || t('settings.integrations.linear.status.connected');
+ return (
+
+
+
{workspaceName}
+ {workspaceUser?.email ? (
+
{workspaceUser.email}
+ ) : null}
+
+
void activateWorkspace(workspace.id)}
+ disabled={isBusy}
+ >
+ {t('settings.integrations.linear.actions.switchTo')}
+
+
+ );
+ })}
+
+
+ ) : null}
+
+ void startConnect()}
+ disabled={isBusy || isWaiting}
+ data-settings-item="integrations.linear.add-workspace"
+ >
+ {t('settings.integrations.linear.actions.addWorkspace')}
+
+ void disconnect()}
+ disabled={isBusy}
+ >
+ {t('settings.integrations.linear.actions.disconnect')}
+
+
+ >
+ ) : isWaiting ? (
+
+
+ {t('settings.integrations.linear.flow.waiting')}
+
+
+ {t('settings.common.actions.cancel')}
+
+
+ ) : (
+
void startConnect()}
+ disabled={isBusy || (isLoading && !hasChecked)}
+ >
+ {isBusy ? : null}
+ {t('settings.integrations.linear.actions.connect')}
+
+ )}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx
index e9cf51bf..542f765b 100644
--- a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx
+++ b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx
@@ -414,6 +414,12 @@ export const ThirdPartyIntegrationsSection: React.FC
+
+
+
+ {t('settings.integrations.experimentalWarning')}
+
+
{THIRD_PARTY_PLUGINS.map(renderPlugin)}
diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx
index d7615788..3cd318a1 100644
--- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx
+++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx
@@ -61,6 +61,14 @@ const PROMPT_PAGE_MAP: Record = {
{ id: 'github.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
],
},
+ 'linear.issue.review': {
+ titleKey: 'settings.magicPrompts.page.group.linearIssueReview.title',
+ descriptionKey: 'settings.magicPrompts.page.group.linearIssueReview.description',
+ blocks: [
+ { id: 'linear.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
+ { id: 'linear.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
+ ],
+ },
'github.pr.checks.review': {
titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title',
descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description',
diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx
index ef8389ff..68893b29 100644
--- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx
+++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx
@@ -35,6 +35,12 @@ export const MagicPromptsSidebar: React.FC = ({ onItem
{ id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' },
],
},
+ {
+ groupKey: 'settings.magicPrompts.sidebar.group.linear',
+ items: [
+ { id: 'linear.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.linearIssueReview' },
+ ],
+ },
{
groupKey: 'settings.magicPrompts.sidebar.group.gitlab',
items: [
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
index d1959d72..d9d7ee93 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
@@ -7,7 +7,6 @@ import { AppLinkSecuritySettings } from './AppLinkSecuritySettings';
import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings';
import { NotificationSettings } from './NotificationSettings';
-import { GitHubSettings } from './GitHubSettings';
import { VoiceSettings } from './VoiceSettings';
import { TunnelSettings } from './TunnelSettings';
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
@@ -78,8 +77,6 @@ export const OpenChamberPage: React.FC = ({ section }) =>
return ;
case 'git':
return ;
- case 'github':
- return ;
case 'notifications':
return ;
case 'voice':
@@ -233,14 +230,6 @@ const GitSectionContent: React.FC = () => {
return ;
};
-// GitHub section: Connect account for PR/issue workflows
-const GitHubSectionContent: React.FC = () => {
- if (isVSCodeRuntime()) {
- return null;
- }
- return ;
-};
-
// Notifications section: Native browser notifications
const NotificationSectionContent: React.FC = () => {
return ;
diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
index 8d3a1ac3..9abfeffe 100644
--- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
@@ -71,6 +71,7 @@ const LOCAL_STT_MODELS = [
interface DictationModelState {
id: string;
+ description?: string;
installed: boolean;
downloading: boolean;
downloadProgress: number | null;
@@ -288,10 +289,32 @@ const KOKORO_VOICE_OPTIONS = [
const LOCAL_TTS_MODEL_ID = 'kokoro-en-v0_19';
-const LocalTtsModelStatus = () => {
- const { t } = useI18n();
- const [model, setModel] = useState(null);
- const [requesting, setRequesting] = useState(false);
+const KOKORO_MULTI_LANG_MODEL_ID = 'kokoro-multi-lang-v1_1';
+// A few named speakers out of the 103 in the Chinese/English Kokoro build.
+const KOKORO_MULTI_LANG_VOICE_OPTIONS = [
+ { id: 0, label: 'Maple (af)' },
+ { id: 1, label: 'Sol (af)' },
+ { id: 2, label: 'Vale (bf)' },
+ { id: 3, label: 'Xiaoxiao (zf)' },
+ { id: 58, label: 'Yunxi (zm)' },
+];
+
+interface LocalTtsVoiceOption {
+ modelId: string;
+ speakerId: number;
+ label: string;
+}
+
+const localTtsVoiceKey = (modelId: string, speakerId: number): string => `${modelId}:${speakerId}`;
+
+/**
+ * Local TTS models as the server reports them, plus the actions Settings
+ * offers on them. Shared by the model list and the voice picker so both see
+ * the same install state.
+ */
+const useLocalTtsModels = () => {
+ const [models, setModels] = useState([]);
+ const [requestingId, setRequestingId] = useState(null);
const refresh = useCallback(async () => {
try {
@@ -300,11 +323,8 @@ const LocalTtsModelStatus = () => {
return;
}
const data = await response.json();
- const entry = Array.isArray(data?.ttsModels)
- ? data.ttsModels.find((m: DictationModelState) => m.id === LOCAL_TTS_MODEL_ID)
- : null;
- if (entry) {
- setModel(entry);
+ if (Array.isArray(data?.ttsModels)) {
+ setModels(data.ttsModels);
}
} catch {
// Display-only status; keep the previous state on fetch failure.
@@ -315,81 +335,118 @@ const LocalTtsModelStatus = () => {
void refresh();
}, [refresh]);
+ const anyDownloading = models.some((model) => model.downloading);
useEffect(() => {
- if (!model?.downloading) {
+ if (!anyDownloading) {
return;
}
const interval = setInterval(() => {
void refresh();
}, 2000);
return () => clearInterval(interval);
- }, [model?.downloading, refresh]);
+ }, [anyDownloading, refresh]);
- const request = async (method: 'POST' | 'DELETE') => {
- setRequesting(true);
+ const request = useCallback(async (modelId: string, method: 'POST' | 'DELETE') => {
+ setRequestingId(modelId);
try {
const path = method === 'POST'
- ? `/api/dictation/models/${LOCAL_TTS_MODEL_ID}/download`
- : `/api/dictation/models/${LOCAL_TTS_MODEL_ID}`;
+ ? `/api/dictation/models/${modelId}/download`
+ : `/api/dictation/models/${modelId}`;
await runtimeFetch(path, { method });
await refresh();
} catch {
// Status refresh reports errors.
} finally {
- setRequesting(false);
+ setRequestingId(null);
}
- };
+ }, [refresh]);
- if (!model) {
+ return { models, requestingId, request, refresh };
+};
+
+// Voices the picker offers: Kokoro speakers for the Kokoro models, one voice
+// per installed Piper model. Only installed models (plus the default) appear,
+// so a language model the server fetched on its own becomes selectable once
+// it is on disk.
+const buildLocalTtsVoiceOptions = (models: DictationModelState[]): LocalTtsVoiceOption[] => {
+ const options: LocalTtsVoiceOption[] = KOKORO_VOICE_OPTIONS.map((voice) => ({
+ modelId: LOCAL_TTS_MODEL_ID,
+ speakerId: voice.id,
+ label: voice.label,
+ }));
+ for (const model of models) {
+ if (model.id === LOCAL_TTS_MODEL_ID || !model.installed) continue;
+ if (model.id === KOKORO_MULTI_LANG_MODEL_ID) {
+ for (const voice of KOKORO_MULTI_LANG_VOICE_OPTIONS) {
+ options.push({ modelId: model.id, speakerId: voice.id, label: `${voice.label} · Kokoro zh/en` });
+ }
+ continue;
+ }
+ options.push({ modelId: model.id, speakerId: 0, label: model.description ?? model.id });
+ }
+ return options;
+};
+
+const LocalTtsModelStatus = ({ models, requestingId, request }: ReturnType) => {
+ const { t } = useI18n();
+
+ // The default English model is always listed; language models the server
+ // fetched on its own appear once they are installed or downloading, so
+ // the list shows what is on disk rather than the whole catalog.
+ const visible = models.filter((model) => model.id === LOCAL_TTS_MODEL_ID || model.installed || model.downloading);
+ if (visible.length === 0) {
return null;
}
return (
-
-
Kokoro
-
305 MB
- {model.installed ? (
- <>
-
-
{ void request('DELETE'); }}
- title={t('settings.voice.page.stt.modelDelete')}
- aria-label={t('settings.voice.page.stt.modelDelete')}
- >
-
-
- >
- ) : model.downloading ? (
-
-
-
- {typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''}
-
-
- ) : (
-
{ void request('POST'); }}
- title={t('settings.voice.page.stt.modelDownload')}
- aria-label={t('settings.voice.page.stt.modelDownload')}
- >
-
-
- )}
- {model.downloadError ? (
-
{model.downloadError}
- ) : null}
+
+ {visible.map((model) => (
+
+ {model.description ?? model.id}
+ {model.installed ? (
+ <>
+
+ { void request(model.id, 'DELETE'); }}
+ title={t('settings.voice.page.stt.modelDelete')}
+ aria-label={t('settings.voice.page.stt.modelDelete')}
+ >
+
+
+ >
+ ) : model.downloading ? (
+
+
+
+ {typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''}
+
+
+ ) : (
+ { void request(model.id, 'POST'); }}
+ title={t('settings.voice.page.stt.modelDownload')}
+ aria-label={t('settings.voice.page.stt.modelDownload')}
+ >
+
+
+ )}
+ {model.downloadError ? (
+ {model.downloadError}
+ ) : null}
+
+ ))}
);
};
@@ -424,6 +481,12 @@ export const VoiceSettings: React.FC = () => {
const sayVoice = useConfigStore((state) => state.sayVoice);
const setSayVoice = useConfigStore((state) => state.setSayVoice);
const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId);
+ const localTtsModelId = useConfigStore((state) => state.localTtsModelId);
+ const setLocalTtsModelId = useConfigStore((state) => state.setLocalTtsModelId);
+ const localTtsModels = useLocalTtsModels();
+ const localTtsVoiceOptions = useMemo(() => buildLocalTtsVoiceOptions(localTtsModels.models), [localTtsModels.models]);
+ const ttsFollowTextLanguage = useConfigStore((state) => state.ttsFollowTextLanguage);
+ const setTtsFollowTextLanguage = useConfigStore((state) => state.setTtsFollowTextLanguage);
const setLocalTtsVoiceId = useConfigStore((state) => state.setLocalTtsVoiceId);
const { speak: speakLocalTts, stop: stopLocalTts, isPlaying: isLocalTtsPlaying, error: localTtsError } = useLocalTTS();
@@ -432,13 +495,14 @@ export const VoiceSettings: React.FC = () => {
stopLocalTts();
return;
}
- const voiceLabel = KOKORO_VOICE_OPTIONS.find((v) => v.id === localTtsVoiceId)?.label
+ const voiceLabel = localTtsVoiceOptions.find((v) => v.modelId === localTtsModelId && v.speakerId === localTtsVoiceId)?.label
?? String(localTtsVoiceId);
void speakLocalTts(t('settings.voice.page.preview.voiceLine', { voiceName: voiceLabel }), {
+ model: localTtsModelId,
speakerId: localTtsVoiceId,
speed: useConfigStore.getState().speechRate,
});
- }, [isLocalTtsPlaying, localTtsVoiceId, speakLocalTts, stopLocalTts, t]);
+ }, [isLocalTtsPlaying, localTtsModelId, localTtsVoiceId, localTtsVoiceOptions, speakLocalTts, stopLocalTts, t]);
const browserVoice = useConfigStore((state) => state.browserVoice);
const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice);
const openaiVoice = useConfigStore((state) => state.openaiVoice);
@@ -959,24 +1023,39 @@ export const VoiceSettings: React.FC = () => {
)}
{/* Local (Kokoro) TTS model status */}
- {voiceProvider === 'local' &&
}
+ {voiceProvider === 'local' &&
}
+
+ {(voiceProvider === 'local' || voiceProvider === 'say') && (
+
+ )}
{/* Voice Selection */}
{voiceProvider === 'local' && (
<>
setLocalTtsVoiceId(Number.parseInt(value, 10) || 0)}
+ value={localTtsVoiceKey(localTtsModelId, localTtsVoiceId)}
+ onValueChange={(value) => {
+ const option = localTtsVoiceOptions.find((v) => localTtsVoiceKey(v.modelId, v.speakerId) === value);
+ if (!option) return;
+ setLocalTtsModelId(option.modelId);
+ setLocalTtsVoiceId(option.speakerId);
+ }}
>
- {(value) => KOKORO_VOICE_OPTIONS.find((v) => String(v.id) === value)?.label ?? value}
+ {(value) => localTtsVoiceOptions.find((v) => localTtsVoiceKey(v.modelId, v.speakerId) === value)?.label ?? value}
- {KOKORO_VOICE_OPTIONS.map((v) => (
- {v.label}
+ {localTtsVoiceOptions.map((v) => (
+ {v.label}
))}
diff --git a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx
index 409348ef..966ff6ea 100644
--- a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx
+++ b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx
@@ -284,7 +284,7 @@ export function GitHubIntegrationDialog({
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
const openGitHubSettings = () => {
- setSettingsPage('github');
+ setSettingsPage('integrations');
setSettingsDialogOpen(true);
};
diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx
index 2db1e456..a861ac7b 100644
--- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx
+++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx
@@ -230,7 +230,7 @@ export function GitHubIssuePickerDialog({
const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => {
- setSettingsPage('github');
+ setSettingsPage('integrations');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
diff --git a/packages/ui/src/components/session/GitHubPrPickerDialog.tsx b/packages/ui/src/components/session/GitHubPrPickerDialog.tsx
index c423503a..fcd0b716 100644
--- a/packages/ui/src/components/session/GitHubPrPickerDialog.tsx
+++ b/packages/ui/src/components/session/GitHubPrPickerDialog.tsx
@@ -217,7 +217,7 @@ export function GitHubPrPickerDialog({
const connected = githubAuthChecked ? result?.connected !== false : true;
const openGitHubSettings = React.useCallback(() => {
- setSettingsPage('github');
+ setSettingsPage('integrations');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
diff --git a/packages/ui/src/components/session/GitLabIntegrationDialog.tsx b/packages/ui/src/components/session/GitLabIntegrationDialog.tsx
index ef729a95..026229a8 100644
--- a/packages/ui/src/components/session/GitLabIntegrationDialog.tsx
+++ b/packages/ui/src/components/session/GitLabIntegrationDialog.tsx
@@ -64,7 +64,7 @@ export function GitLabIntegrationDialog({
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const activeProject = useProjectsStore((state) => state.getActiveProject());
-
+
const projectDirectory = activeProject?.path ?? null;
const projectRef: ProjectRef | null = React.useMemo(() => {
if (projectDirectory && activeProject) {
@@ -93,12 +93,12 @@ export function GitLabIntegrationDialog({
const loadData = React.useCallback(async (query?: string) => {
if (!projectDirectory || !gitlab) return;
if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return;
-
+
setLoading(true);
setError(null);
setPage(1);
setHasMore(false);
-
+
try {
if (activeTab === 'issues' && gitlab.issuesList) {
const result = await gitlab.issuesList(projectDirectory, { page: 1, query });
@@ -192,12 +192,12 @@ export function GitLabIntegrationDialog({
if (!projectDirectory || !gitlab) return;
if (loading || loadingMore) return;
if (!hasMore) return;
-
+
setLoadingMore(true);
-
+
try {
const nextPage = page + 1;
-
+
if (activeTab === 'issues' && gitlab.issuesList) {
const result = debouncedSearchQuery.trim()
? await gitlab.issuesList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() })
@@ -240,26 +240,26 @@ export function GitLabIntegrationDialog({
setHasMore(false);
return;
}
-
+
void loadData();
}, [open, loadData]);
// Validate branches for worktree creation
const validateBranch = React.useCallback(async (branchName: string) => {
if (!projectRef || !branchName) return;
-
+
// Check cache first
if (validations.has(branchName)) return;
-
+
try {
const result = await validateWorktreeCreate(projectRef, {
mode: 'new',
branchName,
worktreeName: branchName,
});
-
+
const blockingError = result.errors.find((entry) => entry.code === 'branch_in_use');
-
+
setValidations(prev => new Map(prev).set(branchName, {
isValid: !blockingError,
error: blockingError
@@ -279,7 +279,7 @@ export function GitLabIntegrationDialog({
// Validate MR branches when loaded
React.useEffect(() => {
if (!open || activeTab !== 'mrs') return;
-
+
mrs.forEach(mr => {
if (mr.sourceBranch) {
void validateBranch(mr.sourceBranch);
@@ -420,7 +420,7 @@ export function GitLabIntegrationDialog({
{t('session.gitlabIntegration.empty.noIssuesFound')}
)}
-
+
{hasMore && !loadingMore && (
{
const blocked = isMrBlocked(mr);
const validation = mr.sourceBranch ? validations.get(mr.sourceBranch) : undefined;
-
+
return (
)}
-
+
{hasMore && !loadingMore && (
)}
-
+
{/* Include Diff Checkbox - only show when MR tab is active and MR is selected */}
{activeTab === 'mrs' && selectedMr && (
@@ -562,7 +562,7 @@ export function GitLabIntegrationDialog({
)}
-
+
{/* Right side: Buttons */}
-
+
{/* Selected Item Inline Display */}
{(selectedIssue || selectedMr) && (
@@ -650,7 +650,7 @@ export function GitLabIntegrationDialog({
{t('session.gitlabIntegration.title')}
-
+
{/* Tabs - using SortableTabsStrip */}
{
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ const urlMatch = trimmed.match(/linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i);
+ if (urlMatch) return urlMatch[1].toUpperCase();
+ if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmed)) return trimmed.toUpperCase();
+ return null;
+};
+
+export function LinearIssuePickerDialog({
+ open,
+ onOpenChange,
+ mode = 'select',
+ onSelect,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ mode?: 'createSession' | 'select';
+ onSelect?: (issue: {
+ identifier: string;
+ title: string;
+ url: string;
+ contextText: string;
+ author?: { login: string; avatarUrl?: string };
+ }) => void;
+}) {
+ const { t } = useI18n();
+ const { linear } = useRuntimeAPIs();
+ const linearAuthStatus = useLinearAuthStore((state) => state.status);
+ const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
+ const refreshStatus = useLinearAuthStore((state) => state.refreshStatus);
+ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
+ const setSettingsPage = useUIStore((state) => state.setSettingsPage);
+ const isMobile = useUIStore((state) => state.isMobile);
+ const { isTablet } = useDeviceInfo();
+ const alwaysShowActions = isMobile || isTablet;
+
+ const [query, setQuery] = React.useState('');
+ const [issues, setIssues] = React.useState([]);
+ const [cursor, setCursor] = React.useState(null);
+ const [hasMore, setHasMore] = React.useState(false);
+ const [connected, setConnected] = React.useState(true);
+ const [startingIssueKey, setStartingIssueKey] = React.useState(null);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [isLoadingMore, setIsLoadingMore] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const [createInWorktree, setCreateInWorktree] = React.useState(false);
+ const [mapping, setMapping] = React.useState(null);
+ const [mappingError, setMappingError] = React.useState(null);
+ const listRequestId = React.useRef(0);
+
+ const directIdentifier = React.useMemo(() => parseLinearIssueQuery(query), [query]);
+ const debouncedQuery = useDebouncedValue(query, 350);
+
+ const refresh = React.useCallback(async (search = '') => {
+ if (linearAuthChecked && linearAuthStatus?.connected === false) {
+ setConnected(false);
+ setIssues([]);
+ setHasMore(false);
+ setCursor(null);
+ setError(null);
+ return;
+ }
+ if (!linear?.issuesList) {
+ setConnected(true);
+ setError(t('session.linearIssuePicker.error.runtimeUnavailable'));
+ return;
+ }
+
+ const requestId = listRequestId.current + 1;
+ listRequestId.current = requestId;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const next = await linear.issuesList(search ? { query: search } : undefined);
+ if (requestId !== listRequestId.current) return;
+ setConnected(next.connected !== false);
+ setIssues(next.issues ?? []);
+ setCursor(next.cursor ?? null);
+ setHasMore(Boolean(next.hasMore));
+ } catch (e) {
+ if (requestId !== listRequestId.current) return;
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ if (requestId === listRequestId.current) {
+ setIsLoading(false);
+ }
+ }
+ }, [linear, linearAuthChecked, linearAuthStatus, t]);
+
+ const refreshMapping = React.useCallback(async () => {
+ if (mode !== 'createSession') {
+ setMapping(null);
+ setMappingError(null);
+ return;
+ }
+ if (!linear?.mappingGet) {
+ setMapping(null);
+ setMappingError(t('session.linearIssuePicker.error.runtimeUnavailable'));
+ return;
+ }
+ try {
+ const next = await linear.mappingGet();
+ setMapping(next);
+ setMappingError(null);
+ } catch (e) {
+ setMapping(null);
+ setMappingError(e instanceof Error ? e.message : String(e));
+ }
+ }, [linear, mode, t]);
+
+ React.useEffect(() => {
+ if (!open) {
+ setQuery('');
+ setStartingIssueKey(null);
+ setError(null);
+ setIssues([]);
+ setCursor(null);
+ setHasMore(false);
+ setIsLoading(false);
+ setConnected(true);
+ setCreateInWorktree(false);
+ setMapping(null);
+ setMappingError(null);
+ return;
+ }
+ if (linear && !linearAuthChecked) {
+ void refreshStatus(linear);
+ }
+ }, [open, linear, linearAuthChecked, refreshStatus]);
+
+ React.useEffect(() => {
+ if (!open) return;
+ void refresh(debouncedQuery.trim());
+ }, [open, debouncedQuery, refresh]);
+
+ React.useEffect(() => {
+ if (!open) return;
+ void refreshMapping();
+ }, [open, refreshMapping]);
+
+ const loadMore = React.useCallback(async () => {
+ if (!linear?.issuesList) return;
+ if (isLoadingMore || isLoading) return;
+ if (!hasMore || !cursor) return;
+
+ const requestId = listRequestId.current + 1;
+ listRequestId.current = requestId;
+ setIsLoadingMore(true);
+ try {
+ const search = debouncedQuery.trim();
+ const next = await linear.issuesList({
+ query: search || undefined,
+ cursor,
+ });
+ if (requestId !== listRequestId.current) return;
+ setConnected(next.connected !== false);
+ setIssues((prev) => [...prev, ...(next.issues ?? [])]);
+ setCursor(next.cursor ?? null);
+ setHasMore(Boolean(next.hasMore));
+ } catch (e) {
+ if (requestId !== listRequestId.current) return;
+ const message = e instanceof Error ? e.message : String(e);
+ toast.error(t('session.linearIssuePicker.toast.loadMoreFailed'), { description: message });
+ } finally {
+ if (requestId === listRequestId.current) {
+ setIsLoadingMore(false);
+ }
+ }
+ }, [cursor, debouncedQuery, hasMore, isLoading, isLoadingMore, linear, t]);
+
+ const openLinearSettings = React.useCallback(() => {
+ setSettingsPage('integrations');
+ setSettingsDialogOpen(true);
+ }, [setSettingsDialogOpen, setSettingsPage]);
+
+ const selectIssue = React.useCallback(async (issueKey: string) => {
+ if (!linear?.issueGet) {
+ toast.error(t('session.linearIssuePicker.error.runtimeUnavailable'));
+ return;
+ }
+ if (startingIssueKey) return;
+ setStartingIssueKey(issueKey);
+ try {
+ const issueRes = await linear.issueGet(issueKey);
+ if (issueRes.connected === false) {
+ toast.error(t('session.linearIssuePicker.error.notConnected'));
+ return;
+ }
+ const issue = issueRes.issue;
+ if (!issue) {
+ toast.error(t('session.linearIssuePicker.error.issueNotFound'));
+ return;
+ }
+ const comments = issue.comments ?? [];
+ const login = issue.assignee?.displayName || issue.assignee?.name;
+ onSelect?.({
+ identifier: issue.identifier,
+ title: issue.title,
+ url: issue.url,
+ contextText: buildIssueContextText({ issue, comments }),
+ author: login
+ ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
+ : undefined,
+ });
+ onOpenChange(false);
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message });
+ } finally {
+ setStartingIssueKey(null);
+ }
+ }, [linear, onOpenChange, onSelect, startingIssueKey, t]);
+
+ const startSession = React.useCallback(async (issueKey: string) => {
+ if (startingIssueKey) return;
+ setStartingIssueKey(issueKey);
+ try {
+ await startLinearIssueSession({
+ linear,
+ issueKey,
+ createInWorktree,
+ mapping,
+ onMappingLoaded: (next) => {
+ setMapping(next);
+ setMappingError(null);
+ },
+ onSessionCreated: () => onOpenChange(false),
+ t,
+ });
+ } finally {
+ setStartingIssueKey(null);
+ }
+ }, [createInWorktree, linear, mapping, onOpenChange, startingIssueKey, t]);
+
+ const handleIssue = React.useCallback((issueKey: string) => {
+ if (mode === 'select') {
+ void selectIssue(issueKey);
+ return;
+ }
+ void startSession(issueKey);
+ }, [mode, selectIssue, startSession]);
+
+ const title = mode === 'select'
+ ? t('session.linearIssuePicker.title')
+ : t('session.linearIssuePicker.title.createSession');
+ const description = mode === 'select'
+ ? t('session.linearIssuePicker.description')
+ : t('session.linearIssuePicker.description.createSession');
+ const showDisconnected = linearAuthChecked && connected === false;
+ const runtimeMissing = !linear;
+
+ const content = (
+ <>
+
+
+ setQuery(e.target.value)}
+ className="pl-9 w-full"
+ />
+
+
+
+ {runtimeMissing ? (
+
{t('session.linearIssuePicker.empty.runtimeUnavailable')}
+ ) : null}
+
+ {mode === 'createSession' && mappingError ? (
+
{mappingError}
+ ) : null}
+
+ {isLoading ? (
+
+
+ {t('session.linearIssuePicker.loading.issues')}
+
+ ) : null}
+
+ {showDisconnected ? (
+
+
{t('session.linearIssuePicker.empty.notConnected')}
+
+
+ {t('session.linearIssuePicker.actions.openSettings')}
+
+
+
+ ) : null}
+
+ {error ? (
+
{error}
+ ) : null}
+
+ {directIdentifier && linear && connected ? (
+
handleIssue(directIdentifier)}
+ >
+
+ {directIdentifier}
+
+
+ {t('session.linearIssuePicker.actions.useIssue', { identifier: directIdentifier })}
+
+
+ {startingIssueKey === directIdentifier ? (
+
+ ) : null}
+
+
+ ) : null}
+
+ {issues.length === 0 && !isLoading && connected && linear ? (
+
+ {debouncedQuery.trim()
+ ? t('session.linearIssuePicker.empty.noIssuesFound')
+ : t('session.linearIssuePicker.empty.noOpenIssuesFound')}
+
+ ) : null}
+
+ {issues.map((issue) => (
+
handleIssue(issue.id)}
+ >
+
+ {issue.identifier}
+
+
+ {issue.title}
+
+
+
+ ))}
+
+ {hasMore && connected && linear ? (
+
+ 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 ? (
+
+
+ {t('session.linearIssuePicker.loading.more')}
+
+ ) : (
+ t('session.linearIssuePicker.actions.loadMore')
+ )}
+
+
+ ) : null}
+
+
+ {mode !== 'select' ? (
+
+
{t('session.linearIssuePicker.actions.sectionTitle')}
+
+
setCreateInWorktree((value) => !value)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setCreateInWorktree((value) => !value);
+ }
+ }}
+ >
+ {
+ 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 ? (
+
+ ) : (
+
+ )}
+
+ {t('session.linearIssuePicker.actions.createInWorktree')}
+
+
+
void refresh(debouncedQuery.trim())} disabled={isLoading || Boolean(startingIssueKey)}>
+ {t('session.linearIssuePicker.actions.refresh')}
+
+
+
+ ) : null}
+ >
+ );
+
+ if (isMobile) {
+ return (
+ onOpenChange(false)}
+ renderHeader={(closeButton) => (
+
+
+
{title}
+ {closeButton}
+
+
{description}
+
+ )}
+ >
+ {content}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+ {content}
+
+
+ );
+}
diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx
index 4b43e716..4601eff1 100644
--- a/packages/ui/src/components/session/NewWorktreeDialog.tsx
+++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx
@@ -29,11 +29,12 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
+import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
-import { buildLinkedIssue } from '@/lib/linkedIssues';
+import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
import { useGitProvider } from '@/lib/gitProvider';
import { useConfigStore } from '@/stores/useConfigStore';
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
@@ -43,6 +44,7 @@ import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/ope
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import { renderMagicPrompt } from '@/lib/magicPrompts';
+import { postLinearSessionStarted } from '@/lib/linearSessionStatus';
import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
import {
@@ -55,6 +57,7 @@ import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/use
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
import { GitLabIntegrationDialog } from './GitLabIntegrationDialog';
import { GiteaIntegrationDialog } from './GiteaIntegrationDialog';
+import { LinearIssuePickerDialog } from './LinearIssuePickerDialog';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon";
@@ -72,6 +75,8 @@ import type {
GiteaIssue,
GiteaIssuesListResult,
GiteaPullRequestContextResult,
+ LinearIssue,
+ LinearIssueComment,
} from '@/lib/api/types';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import { useI18n } from '@/lib/i18n';
@@ -85,6 +90,13 @@ interface ValidationState {
touched: boolean;
}
+type LinkedLinearWorktreeIssue = {
+ identifier: string;
+ title: string;
+ url: string;
+ author?: { login: string; avatarUrl?: string };
+};
+
// State for New Branch mode
interface NewBranchState {
branchName: string;
@@ -94,6 +106,7 @@ interface NewBranchState {
linkedIssue: GitHubIssue | null;
linkedPr: GitHubPullRequestSummary | null;
includePrDiff: boolean;
+ linkedLinearIssue: LinkedLinearWorktreeIssue | null;
linkedGitLabIssue: { number: number; title: string; url: string } | null;
linkedGitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null;
includeGitLabMrDiff: boolean;
@@ -262,13 +275,24 @@ const buildGiteaPrContextText = (payload: GiteaPullRequestContextResult) => {
return `Gitea pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
};
+const buildLinearIssueContextText = (args: {
+ issue: LinearIssue;
+ comments: LinearIssueComment[];
+}) => {
+ const payload = {
+ issue: args.issue,
+ comments: args.comments,
+ };
+ return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
+};
+
export function NewWorktreeDialog({
open,
onOpenChange,
onWorktreeCreated,
}: NewWorktreeDialogProps) {
const { t } = useI18n();
- const { github, git, gitlab, gitea } = useRuntimeAPIs();
+ const { github, git, gitlab, gitea, linear } = useRuntimeAPIs();
const isMobile = useUIStore((state) => state.isMobile);
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -278,8 +302,10 @@ export function NewWorktreeDialog({
const giteaAuthStatus = useGiteaAuthStore((state) => state.status);
const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked);
const refreshGiteaAuth = useGiteaAuthStore((state) => state.refreshStatus);
+ const linearAuthStatus = useLinearAuthStore((state) => state.status);
+ const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
const activeProject = useProjectsStore((state) => state.getActiveProject());
-
+
const projectDirectory = activeProject?.path ?? null;
const projectRef: ProjectRef | null = React.useMemo(() => {
if (projectDirectory && activeProject) {
@@ -290,7 +316,7 @@ export function NewWorktreeDialog({
// Mode state
const [mode, setMode] = React.useState('new-branch');
-
+
// Separate state for each mode (persisted when switching tabs)
const [newBranchState, setNewBranchState] = React.useState({
branchName: '',
@@ -300,6 +326,7 @@ export function NewWorktreeDialog({
linkedIssue: null,
linkedPr: null,
includePrDiff: false,
+ linkedLinearIssue: null,
linkedGitLabIssue: null,
linkedGitLabMr: null,
includeGitLabMrDiff: false,
@@ -307,12 +334,12 @@ export function NewWorktreeDialog({
linkedGiteaPr: null,
includeGiteaPrDiff: false,
});
-
+
const [existingBranchState, setExistingBranchState] = React.useState({
selectedBranch: '',
worktreeName: '',
});
-
+
// Use cached branches from Git store (instant if already fetched)
const branches = useGitBranches(projectDirectory);
const isLoadingBranches = useGitLoadingBranches(projectDirectory);
@@ -325,7 +352,7 @@ export function NewWorktreeDialog({
.filter((branchName: string) => !branchName.startsWith('remotes/'))
.sort();
}, [branches]);
-
+
const remoteBranches = React.useMemo(() => {
if (!branches?.all) return [];
return branches.all
@@ -333,7 +360,7 @@ export function NewWorktreeDialog({
.map((branchName: string) => branchName.replace(/^remotes\//, ''))
.sort();
}, [branches]);
-
+
// Get existing worktrees for the current project to avoid conflicts
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const existingWorktreeNames = React.useMemo(() => {
@@ -341,7 +368,7 @@ export function NewWorktreeDialog({
const worktrees = availableWorktreesByProject.get(projectDirectory) ?? [];
return new Set(worktrees.map(wt => wt.name));
}, [availableWorktreesByProject, projectDirectory]);
-
+
// Generate a unique slug that doesn't conflict with existing worktrees
const generateUniqueSlug = React.useCallback((maxAttempts = 10): string => {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
@@ -353,10 +380,11 @@ export function NewWorktreeDialog({
// Fallback: add timestamp if all attempts failed
return `${generateBranchSlug()}-${Date.now().toString(36).slice(-4)}`;
}, [existingWorktreeNames]);
-
+
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
const [gitlabDialogOpen, setGitlabDialogOpen] = React.useState(false);
const [giteaDialogOpen, setGiteaDialogOpen] = React.useState(false);
+ const [linearDialogOpen, setLinearDialogOpen] = React.useState(false);
// Populate the GitLab auth status on mount so the "Start from GitLab issue/MR"
// action is available without first visiting Settings. refreshStatus dedupes
@@ -373,7 +401,7 @@ export function NewWorktreeDialog({
void refreshGiteaAuth(gitea);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
-
+
// Desktop branch picker states
const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false);
const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false);
@@ -507,7 +535,7 @@ export function NewWorktreeDialog({
worktreeError: null,
touched: false,
});
-
+
// Creation state
const [isCreating, setIsCreating] = React.useState(false);
const [validationAbortController, setValidationAbortController] = React.useState(null);
@@ -564,6 +592,7 @@ export function NewWorktreeDialog({
issue: GitHubIssue | null;
pr: GitHubPullRequestSummary | null;
includeDiff: boolean;
+ linearIssue: LinkedLinearWorktreeIssue | null;
gitLabIssue: { number: number; title: string; url: string } | null;
gitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null;
includeGitLabMrDiff: boolean;
@@ -589,6 +618,65 @@ export function NewWorktreeDialog({
const variant = resolveDefaultVariant(providerID, modelID);
+ if (args.linearIssue) {
+ if (!linear?.issueGet) {
+ return;
+ }
+
+ const issueRes = await linear.issueGet(args.linearIssue.identifier);
+ if (issueRes.connected === false || !issueRes.issue) {
+ throw new Error('Failed to load issue context');
+ }
+
+ const issue = issueRes.issue;
+ const comments = issue.comments ?? [];
+ const login = issue.assignee?.displayName || issue.assignee?.name;
+ const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', {
+ identifier: issue.identifier,
+ });
+ const instructionsText = await renderMagicPrompt('linear.issue.review.instructions');
+ const contextText = buildLinearIssueContextText({ issue, comments });
+
+ postLinearSessionStarted(linear, {
+ sessionId: args.sessionId,
+ issueIdentifier: issue.identifier,
+ });
+
+ await useSessionUIStore.getState().sendMessage(
+ visiblePromptText,
+ providerID,
+ modelID,
+ agentName,
+ undefined,
+ undefined,
+ [
+ { text: instructionsText, synthetic: true },
+ { text: contextText, synthetic: true },
+ ],
+ variant,
+ undefined,
+ { sessionId: args.sessionId, directory: args.directory },
+ );
+
+ void sessionActions.setLinkedIssue(
+ args.sessionId,
+ args.directory,
+ buildLinkedLinearIssue({
+ identifier: issue.identifier,
+ title: issue.title,
+ url: issue.url,
+ author: login
+ ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
+ : args.linearIssue.author,
+ linkedAt: Date.now(),
+ }),
+ true,
+ ).catch(() => undefined);
+
+ toast.success(t('session.newWorktree.toast.sessionFromIssue'));
+ return;
+ }
+
if (args.issue) {
if (!github || !github.issueGet || !github.issueComments) {
return;
@@ -938,6 +1026,7 @@ export function NewWorktreeDialog({
github,
gitlab,
gitea,
+ linear,
projectDirectory,
resolveDefaultAgentName,
resolveDefaultModelSelection,
@@ -1026,6 +1115,7 @@ export function NewWorktreeDialog({
linkedIssue: null,
linkedPr: null,
includePrDiff: false,
+ linkedLinearIssue: null,
linkedGitLabIssue: null,
linkedGitLabMr: null,
includeGitLabMrDiff: false,
@@ -1038,7 +1128,7 @@ export function NewWorktreeDialog({
// Sync worktree name with branch name for new-branch mode
React.useEffect(() => {
if (mode !== 'new-branch' || !newBranchState.isSyncingWorktreeName) return;
-
+
const normalizedBranch = normalizeBranchName(newBranchState.branchName);
const newWorktreeName = slugifyWorktreeName(normalizedBranch);
setNewBranchState(prev => ({ ...prev, worktreeName: newWorktreeName }));
@@ -1047,26 +1137,26 @@ export function NewWorktreeDialog({
// Validation - only runs after fields are touched
const validateInputs = React.useCallback(async () => {
if (!projectRef || !validation.touched || isCreating) return;
-
+
// Cancel previous validation
if (validationAbortController) {
validationAbortController.abort();
}
-
+
const abortController = new AbortController();
setValidationAbortController(abortController);
-
+
setValidation(prev => ({ ...prev, isValidating: true }));
-
+
try {
const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch;
const worktreeName = currentState.worktreeName;
const normalizedBranch = normalizeBranchName(branchName);
const normalizedWorktree = slugifyWorktreeName(worktreeName);
-
+
let branchError: string | null = null;
let worktreeError: string | null = null;
-
+
if (!normalizedBranch) {
branchError = t('session.newWorktree.error.branchNameRequired');
}
@@ -1074,7 +1164,7 @@ export function NewWorktreeDialog({
if (!normalizedWorktree) {
worktreeError = t('session.newWorktree.error.worktreeDirectoryRequired');
}
-
+
// Only run server validation if we have values
if (normalizedBranch && normalizedWorktree) {
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
@@ -1091,9 +1181,9 @@ export function NewWorktreeDialog({
...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
});
-
+
if (abortController.signal.aborted) return;
-
+
if (!result.ok) {
result.errors.forEach((error) => {
if (error.code === 'worktree_exists') {
@@ -1107,7 +1197,7 @@ export function NewWorktreeDialog({
});
}
}
-
+
if (!abortController.signal.aborted) {
setValidation(prev => ({
...prev,
@@ -1147,11 +1237,11 @@ export function NewWorktreeDialog({
// Trigger validation on input changes (only after touched)
React.useEffect(() => {
if (!open || !projectRef || !validation.touched || isCreating) return;
-
+
const timer = setTimeout(() => {
void validateInputs();
}, 300);
-
+
return () => clearTimeout(timer);
}, [currentState.worktreeName, currentBranchName, open, projectRef, validateInputs, validation.touched, isCreating]);
@@ -1161,20 +1251,20 @@ export function NewWorktreeDialog({
toast.error(t('session.newWorktree.error.noActiveProject'));
return;
}
-
+
// Mark as touched and validate immediately
setValidation(prev => ({ ...prev, touched: true }));
-
+
const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch;
const worktreeName = currentState.worktreeName;
const normalizedBranch = normalizeBranchName(branchName);
const normalizedWorktree = slugifyWorktreeName(worktreeName);
-
+
if (!normalizedBranch) {
toast.error(t('session.newWorktree.error.branchNameRequired'));
return;
}
-
+
if (!normalizedWorktree) {
toast.error(t('session.newWorktree.error.worktreeDirectoryRequired'));
return;
@@ -1191,21 +1281,22 @@ export function NewWorktreeDialog({
branchError: null,
worktreeError: null,
}));
-
+
setIsCreating(true);
-
+
try {
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
+ const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null;
const linkedGitLabIssue = mode === 'new-branch' ? newBranchState.linkedGitLabIssue : null;
const linkedGitLabMr = mode === 'new-branch' ? newBranchState.linkedGitLabMr : null;
const includeGitLabMrDiff = mode === 'new-branch' ? newBranchState.includeGitLabMrDiff : false;
const linkedGiteaIssue = mode === 'new-branch' ? newBranchState.linkedGiteaIssue : null;
const linkedGiteaPr = mode === 'new-branch' ? newBranchState.linkedGiteaPr : null;
const includeGiteaPrDiff = mode === 'new-branch' ? newBranchState.includeGiteaPrDiff : false;
- const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedGitLabIssue || linkedGitLabMr || linkedGiteaIssue || linkedGiteaPr);
+ const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue || linkedGitLabIssue || linkedGitLabMr || linkedGiteaIssue || linkedGiteaPr);
const setupCommands = await getWorktreeSetupCommands(projectRef);
const sourceBranch = newBranchState.sourceBranch;
@@ -1289,19 +1380,21 @@ export function NewWorktreeDialog({
await waitForWorktreeBootstrap(metadata.path);
}
- const sessionTitle = linkedIssue
- ? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
- : linkedPrState
- ? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
- : linkedGitLabIssue
- ? `#${linkedGitLabIssue.number} ${linkedGitLabIssue.title}`.trim()
- : linkedGitLabMr
- ? `!${linkedGitLabMr.number} ${linkedGitLabMr.title}`.trim()
- : linkedGiteaIssue
- ? `#${linkedGiteaIssue.number} ${linkedGiteaIssue.title}`.trim()
- : linkedGiteaPr
- ? `#${linkedGiteaPr.number} ${linkedGiteaPr.title}`.trim()
- : t('session.newWorktree.newSessionTitle');
+ const sessionTitle = linkedLinearIssue
+ ? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim()
+ : linkedIssue
+ ? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
+ : linkedPrState
+ ? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
+ : linkedGitLabIssue
+ ? `#${linkedGitLabIssue.number} ${linkedGitLabIssue.title}`.trim()
+ : linkedGitLabMr
+ ? `!${linkedGitLabMr.number} ${linkedGitLabMr.title}`.trim()
+ : linkedGiteaIssue
+ ? `#${linkedGiteaIssue.number} ${linkedGiteaIssue.title}`.trim()
+ : linkedGiteaPr
+ ? `#${linkedGiteaPr.number} ${linkedGiteaPr.title}`.trim()
+ : t('session.newWorktree.newSessionTitle');
const session = await sessionActions.createSession(sessionTitle, metadata.path, null);
if (!session?.id) {
@@ -1324,7 +1417,7 @@ export function NewWorktreeDialog({
onOpenChange(false);
setIsCreating(false);
}
-
+
// Save the last source-branch choice for the next open.
const lastSourceBranch = resolveWorktreeSourceBranchToPersist({
mode,
@@ -1336,7 +1429,7 @@ export function NewWorktreeDialog({
if (lastSourceBranch) {
localStorage.setItem(LAST_WORKTREE_SOURCE_BRANCH_KEY, lastSourceBranch);
}
-
+
toast.success(t('session.newWorktree.toast.worktreeCreated'), {
description: t('session.newWorktree.toast.worktreeCreatedDescription', {
target: `${metadata.branch || metadata.name}${sourceLabel ? ` ${t('session.newWorktree.fromSource', { source: sourceLabel })}` : ''}`,
@@ -1350,6 +1443,7 @@ export function NewWorktreeDialog({
issue: linkedIssue,
pr: linkedPrState,
includeDiff: includePrDiff,
+ linearIssue: linkedLinearIssue,
gitLabIssue: linkedGitLabIssue,
gitLabMr: linkedGitLabMr,
includeGitLabMrDiff: includeGitLabMrDiff,
@@ -1360,9 +1454,12 @@ export function NewWorktreeDialog({
// There is no Gitea-branded send-context error key in the frozen
// catalogs; the gitea path reuses the generic GitHub wording.
const isGitLabLink = Boolean(linkedGitLabIssue || linkedGitLabMr);
- const errorKey = isGitLabLink
- ? 'session.newWorktree.error.sendGitLabContextFailed'
- : 'session.newWorktree.error.sendGitHubContextFailed';
+ const isLinearLink = Boolean(linkedLinearIssue);
+ const errorKey = isLinearLink
+ ? 'session.newWorktree.error.sendLinearContextFailed'
+ : isGitLabLink
+ ? 'session.newWorktree.error.sendGitLabContextFailed'
+ : 'session.newWorktree.error.sendGitHubContextFailed';
const message = error instanceof Error ? error.message : t(errorKey);
toast.error(t(errorKey), { description: message });
});
@@ -1639,7 +1736,7 @@ export function NewWorktreeDialog({
const footerContent = (
{/* Validation error */}
-
+
{validation.touched && (validation.branchError || validation.worktreeError) && (
<>
@@ -1649,7 +1746,7 @@ export function NewWorktreeDialog({
>
)}
-
+
{/* Buttons */}
: }
-
+
{/* Mobile Branch Picker Overlay */}
)}
- {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
+ {existingBranchRankedGroups.otherLocal.length > 0 && (
- {t('session.newWorktree.localBranches')}
+ {hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
{existingBranchRankedGroups.otherLocal.map((branch) => (
@@ -1822,10 +1919,10 @@ export function NewWorktreeDialog({
)}
- {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
+ {existingBranchRankedGroups.otherRemote.length > 0 && (
- {t('session.newWorktree.remoteBranches')}
+ {hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
{existingBranchRankedGroups.otherRemote.map((branch) => (
@@ -2054,7 +2151,7 @@ export function NewWorktreeDialog({
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
)}
-
+
{/* Mobile Source Branch Picker Overlay */}
)}
- {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
+ {sourceBranchRankedGroups.otherLocal.length > 0 && (
- {t('session.newWorktree.localBranches')}
+ {hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
{sourceBranchRankedGroups.otherLocal.map((branch) => (
@@ -2138,10 +2235,10 @@ export function NewWorktreeDialog({
)}
- {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
+ {sourceBranchRankedGroups.otherRemote.length > 0 && (
- {t('session.newWorktree.remoteBranches')}
+ {hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
{sourceBranchRankedGroups.otherRemote.map((branch) => (
@@ -2183,7 +2280,7 @@ export function NewWorktreeDialog({
) : (
)}
-
+
{newBranchState.linkedIssue && (
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
@@ -2214,11 +2311,11 @@ export function NewWorktreeDialog({
{t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })}
)}
-
+
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title}
-
+
-
+
-
+
{/* Row 2: PR/MR branch info + diff indicator */}
{newBranchState.linkedPr && (
@@ -2287,7 +2384,7 @@ export function NewWorktreeDialog({
{t('session.newWorktree.title')}
-
+
{/* Mode Selection - using SortableTabsStrip */}
)}
- {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
+ {existingBranchRankedGroups.otherLocal.length > 0 && (
<>
-
+ {hasExistingBranchQuery && }
+
{existingBranchRankedGroups.otherLocal.map((branch) => (
)}
- {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
+ {existingBranchRankedGroups.otherRemote.length > 0 && (
<>
- {existingBranchRankedGroups.otherLocal.length > 0 && (
+ {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && (
)}
-
+
{existingBranchRankedGroups.otherRemote.map((branch) => (
)}
- {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
+ {sourceBranchRankedGroups.otherLocal.length > 0 && (
<>
-
+ {hasSourceBranchQuery && }
+
{sourceBranchRankedGroups.otherLocal.map((branch) => (
)}
- {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
+ {sourceBranchRankedGroups.otherRemote.length > 0 && (
<>
- {sourceBranchRankedGroups.otherLocal.length > 0 && (
+ {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && (
)}
-
+
{sourceBranchRankedGroups.otherRemote.map((branch) => (
)}
-
+
{newBranchState.linkedIssue && (
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
@@ -2767,11 +2866,11 @@ export function NewWorktreeDialog({
{t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })}
)}
-
+
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title}
-
+
-
+
-
+
{/* Row 2: PR/MR branch info + diff indicator */}
{newBranchState.linkedPr && (
@@ -2844,7 +2943,7 @@ export function NewWorktreeDialog({
>
)}
-
+
= ({
}, [mobileVariant, openNewSessionDraft, setSessionSwitcherOpen]);
return (
- // One shared tooltip provider for the whole sidebar: session tooltips open
- // instantly, and moving between rows hands the tooltip over (grouping)
- // instead of replaying the exit/enter animation for each row.
- // closeDelay bridges the small gap between rows: the tooltip survives the
- // pointer crossing row margins, and the grouping timeout hands it over to
- // the next row without an exit/enter cycle.
-
+ // One shared tooltip provider for the whole sidebar, matching the opencode
+ // sidebar feel: 400ms before the first tooltip opens, instant close on
+ // leave, and grouping — moving between rows within 600ms hands the tooltip
+ // over to the next row without replaying the open delay or exit/enter
+ // animation.
+
= ({ topol
availableWorktreesByProject: topology.availableWorktreesByProject,
projectRepoStatus: topology.projectRepoStatus,
projectRootBranches: topology.projectRootBranches,
+ gitBranches: topology.gitBranches,
lastRepoStatus: topology.lastRepoStatus,
buildGroupedSessions,
hasSessionSearchQuery: view.hasSessionSearchQuery,
diff --git a/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts
index 13a1eeae..5c89d183 100644
--- a/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts
+++ b/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts
@@ -5,9 +5,12 @@ import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
import { isVSCodeRuntime } from '@/lib/desktop';
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
-const SESSION_PREFETCH_SETTLE_MS = 600;
-const SESSION_PREFETCH_CONCURRENCY = 1;
-const SESSION_PREFETCH_PENDING_LIMIT = 6;
+const SESSION_PREFETCH_SETTLE_MS = 150;
+const SESSION_PREFETCH_CONCURRENCY = 2;
+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 = {
enabled?: boolean;
@@ -132,8 +135,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
const timer = window.setTimeout(() => {
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) return;
- scheduleSessionPrefetch(sortedSessions[currentIndex - 1]);
- scheduleSessionPrefetch(sortedSessions[currentIndex + 1]);
+ for (const offset of NEIGHBOR_PREFETCH_OFFSETS) scheduleSessionPrefetch(sortedSessions[currentIndex + offset]);
}, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer);
}, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
@@ -145,8 +147,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
const timer = window.setTimeout(() => {
const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) return;
- scheduleSessionPrefetch(recentSessions[currentIndex - 1]);
- scheduleSessionPrefetch(recentSessions[currentIndex + 1]);
+ for (const offset of NEIGHBOR_PREFETCH_OFFSETS) scheduleSessionPrefetch(recentSessions[currentIndex + offset]);
}, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer);
}, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx
index 42a4b474..abeec157 100644
--- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx
+++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx
@@ -1200,7 +1200,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
-
+
-
+
-
+
= ({
showCreateButtons ? 'right-7' : 'right-0.5',
)}>
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
-
+
= ({
{showCreateButtons && onNewSession ? (
-
+
Boolean(session.time?.a
export const useSessionGrouping = (args: Args) => {
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 => {
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;
worktreeGroups.forEach((meta) => {
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 shouldSyncLabelWithBranch = Boolean(
currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
@@ -274,7 +280,7 @@ export const useSessionGrouping = (args: Args) => {
return groups;
},
- [args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
+ [args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.isVSCode, t],
);
return {
diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx
index 6b4038e8..0d19e9e2 100644
--- a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx
+++ b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx
@@ -55,6 +55,7 @@ const renderSections = (group: SessionGroup, query: string): Sections => {
availableWorktreesByProject: new Map(),
projectRepoStatus: new Map(),
projectRootBranches: new Map(),
+ gitBranches: new Map(),
lastRepoStatus: false,
buildGroupedSessions: grouping.buildGroupedSessions,
hasSessionSearchQuery: query.length > 0,
diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts
index e8976177..35cc465f 100644
--- a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts
+++ b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts
@@ -29,11 +29,23 @@ type ProjectSectionCacheEntry = {
archivedSessions: Session[];
availableWorktrees: WorktreeMetadata[];
rootBranch: string | null;
+ /** Current branch of every worktree directory the section renders. */
+ worktreeBranchesKey: string;
isRepo: boolean;
buildGroupedSessions: Args['buildGroupedSessions'];
section: ProjectSection;
};
+const worktreeBranchesKeyFor = (
+ worktrees: WorktreeMetadata[],
+ gitBranches: ReadonlyMap,
+): string => worktrees
+ .map((worktree) => {
+ const directory = normalizePath(worktree.path) ?? worktree.path;
+ return `${directory}=${gitBranches.get(directory) ?? ''}`;
+ })
+ .join('\n');
+
const EMPTY_WORKTREES: WorktreeMetadata[] = [];
type Args = {
@@ -43,6 +55,7 @@ type Args = {
availableWorktreesByProject: Map;
projectRepoStatus: Map;
projectRootBranches: Map;
+ gitBranches: ReadonlyMap;
lastRepoStatus: boolean;
buildGroupedSessions: (
sessions: Session[],
@@ -73,6 +86,7 @@ export const useSessionSidebarSections = (args: Args) => {
availableWorktreesByProject,
projectRepoStatus,
projectRootBranches,
+ gitBranches,
lastRepoStatus,
buildGroupedSessions,
hasSessionSearchQuery,
@@ -101,6 +115,7 @@ export const useSessionSidebarSections = (args: Args) => {
? Boolean(projectRepoStatus.get(project.id))
: lastRepoStatus;
const rootBranch = projectRootBranches.get(project.id) ?? null;
+ const worktreeBranchesKey = worktreeBranchesKeyFor(worktreesForProject, gitBranches);
const cached = previousCache.get(project.id);
if (
cached
@@ -109,6 +124,7 @@ export const useSessionSidebarSections = (args: Args) => {
&& sameSessions(cached.archivedSessions, archivedSessions)
&& cached.availableWorktrees === worktreesForProject
&& cached.rootBranch === rootBranch
+ && cached.worktreeBranchesKey === worktreeBranchesKey
&& cached.isRepo === isRepo
&& cached.buildGroupedSessions === buildGroupedSessions
) {
@@ -118,6 +134,19 @@ export const useSessionSidebarSections = (args: Args) => {
}
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 groups = buildGroupedSessions(
projectSessions,
@@ -133,6 +162,7 @@ export const useSessionSidebarSections = (args: Args) => {
archivedSessions,
availableWorktrees: worktreesForProject,
rootBranch,
+ worktreeBranchesKey,
isRepo,
buildGroupedSessions,
section,
@@ -152,6 +182,7 @@ export const useSessionSidebarSections = (args: Args) => {
lastRepoStatus,
buildGroupedSessions,
projectRootBranches,
+ gitBranches,
]);
const visibleProjectSections = React.useMemo(() => {
diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx
index df251b0a..1ff494a6 100644
--- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx
+++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx
@@ -23,7 +23,8 @@ import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession';
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 { DraggableSessionRow } from '../folders/sessionFolderDnd';
import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils';
@@ -232,7 +233,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
};
return (
-
+
state.enabled);
const isRowSelected = useSessionMultiSelectStore(
@@ -908,6 +913,20 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
if (mobileVariant && event.pointerType === 'touch') {
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) => {
if (mobileVariant && event.pointerType === 'touch') {
@@ -1334,6 +1353,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
data-session-row={session.id}
data-session-scope={selectionScopeKey ?? ''}
data-session-archived={archivedBucket ? '1' : '0'}
+ aria-current={isActive ? 'page' : undefined}
onClick={handleRowBackgroundClick}
// Row geometry mirrors the zone-header band: full container
// 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
);
-const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
- if (prev.node.session.id !== next.node.session.id) return false;
- if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return false;
- if (!areNodeWorktreeRenderSemanticsEqual(prev.node, next.node)) return false;
- if (prev.depth !== next.depth) return false;
- if (prev.groupDirectory !== next.groupDirectory) return false;
- if (prev.projectId !== next.projectId) return false;
- if (prev.archivedBucket !== next.archivedBucket) return false;
- if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
- if (prev.mobileVariant !== next.mobileVariant) return false;
- if (prev.alwaysShowActions !== next.alwaysShowActions) return false;
- if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
- if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
- if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
- if (prev.nodeStructureKey !== next.nodeStructureKey) return false;
- if (prev.relativeTimeTick !== next.relativeTimeTick) return false;
- if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
- if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
+// Returns the name of the first prop whose change requires a render, or null
+// when the row can skip it. The name feeds the stream perf counters so sidebar
+// churn is explained, not only counted.
+const sessionNodeItemPropsChange = (prev: SessionNodeItemProps, next: SessionNodeItemProps): string | null => {
+ if (prev.node.session.id !== next.node.session.id) return 'node';
+ if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return 'node';
+ if (!areNodeWorktreeRenderSemanticsEqual(prev.node, next.node)) return 'node';
+ if (prev.depth !== next.depth) return 'depth';
+ if (prev.groupDirectory !== next.groupDirectory) return 'groupDirectory';
+ if (prev.projectId !== next.projectId) return 'projectId';
+ if (prev.archivedBucket !== next.archivedBucket) return 'archivedBucket';
+ if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return 'renderContext';
+ if (prev.mobileVariant !== next.mobileVariant) return 'mobileVariant';
+ if (prev.alwaysShowActions !== next.alwaysShowActions) return 'alwaysShowActions';
+ if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return 'hasSessionSearchQuery';
+ if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return 'normalizedSessionSearchQuery';
+ if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return 'notifyOnSubtasks';
+ 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
&& nodeHasPinnedMembershipChange(
@@ -1735,11 +1758,11 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
prev.groupDirectory,
next.groupDirectory,
)) {
- return false;
+ return 'pinnedSessionIds';
}
if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) {
- return false;
+ return 'expandedParents';
}
if (prev.editingId !== next.editingId
@@ -1747,7 +1770,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
- return false;
+ return 'editingId';
}
if (prev.editTitle !== next.editTitle
@@ -1755,7 +1778,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
- return false;
+ return 'editTitle';
}
if (prev.copiedSessionId !== next.copiedSessionId
@@ -1763,18 +1786,18 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
nodeContainsSessionId(prev.node, prev.copiedSessionId)
|| nodeContainsSessionId(next.node, next.copiedSessionId)
)) {
- return false;
+ return 'copiedSessionId';
}
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = getRelevantMenuSessionId(prev);
const nextMenuSessionId = getRelevantMenuSessionId(next);
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.handleSaveEdit === next.handleSaveEdit
&& prev.handleCancelEdit === next.handleCancelEdit
@@ -1791,6 +1814,15 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
&& prev.handleRestoreSession === next.handleRestoreSession
&& prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad
&& 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);
diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx
index 0eee1f45..b926ac76 100644
--- a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx
+++ b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx
@@ -97,15 +97,23 @@ export function SessionTreeItem({
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
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 visit = (current: SessionNode) => current.children.forEach((child) => {
ids.push(child.session.id);
visit(child);
});
visit(node);
- return ids;
+ return ids.join('\n');
}, [node]);
+ const descendantIds = React.useMemo(
+ () => (descendantIdsKey ? descendantIdsKey.split('\n') : []),
+ [descendantIdsKey],
+ );
const createFolderAndStartRename = React.useCallback((scopeKey: string, parentId?: string | null) => {
if (!scopeKey) return null;
if (parentId && useSessionFoldersStore.getState().collapsedFolderIds.has(parentId)) toggleFolderCollapse(parentId);
diff --git a/packages/ui/src/components/session/sidebar/shell/SidebarHeader.tsx b/packages/ui/src/components/session/sidebar/shell/SidebarHeader.tsx
index 90bd777e..05c56cdc 100644
--- a/packages/ui/src/components/session/sidebar/shell/SidebarHeader.tsx
+++ b/packages/ui/src/components/session/sidebar/shell/SidebarHeader.tsx
@@ -90,7 +90,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
icon inset inside the 24px buttons so the first glyph lines up
with the New-session icon above (16px from the sidebar edge). */}
-
+
{t('sessions.sidebar.header.actions.addProject')}
-
+
{t('sessions.sidebar.header.actions.scheduledTasks')}
-
+
{t('sessions.sidebar.header.actions.newMultiRun')}
-
+
-
+
{t('sessions.sidebar.header.actions.searchSessions')}
-
+
-
+
= ({
}) => {
const { t } = useI18n();
const { git, files } = useRuntimeAPIs();
- const effectiveDirectory = useEffectiveDirectory();
+ const rootDirectory = useEffectiveDirectory();
+ // Diffs belong to the repository being diffed: when the root is not
+ // itself a repository, operate on the resolved nested repository instead.
+ const { rootIsGitRepo, gitDirectory: nestedGitDirectory, nestedRepos: nestedRepoOptions } = useNestedGitDirectory(rootDirectory ?? null);
+ const effectiveDirectory = nestedGitDirectory ?? rootDirectory;
const openContextSurface = useUIStore((state) => state.openContextSurface);
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
const { screenWidth, isMobile } = useDeviceInfo();
@@ -1007,6 +1013,7 @@ export const DiffView: React.FC = ({
const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus);
+ const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
@@ -1038,7 +1045,7 @@ export const DiffView: React.FC = ({
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
- const sessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory ?? undefined);
+ const sessionMessages = useSessionMessages(currentSessionId ?? '', rootDirectory ?? undefined);
const diffWrapLines = diffWrapLinesStore;
const forcedStaged = activeDiffScope === 'staged' ? true : activeDiffScope === 'working' ? false : null;
const activeDiffStaged = forcedStaged ?? displayFileStaged;
@@ -1645,7 +1652,7 @@ export const DiffView: React.FC = ({
const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => {
if (!currentSessionId) return;
- const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || effectiveDirectory || '';
+ const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || rootDirectory || '';
if (!directory) {
toast.error(t('diffView.reviewDialog.toast.noSessionDirectory'));
return;
@@ -1671,7 +1678,7 @@ export const DiffView: React.FC = ({
} finally {
setReviewFlowSubmitting(false);
}
- }, [currentSessionId, effectiveDirectory, t]);
+ }, [currentSessionId, rootDirectory, t]);
const scrollToFile = React.useCallback((path: string): boolean => {
const node = fileSectionRefs.current.get(path);
@@ -2070,6 +2077,16 @@ export const DiffView: React.FC = ({
return (
+ {rootIsGitRepo === false && Array.isArray(nestedRepoOptions) && nestedRepoOptions.length > 0 ? (
+
{
+ if (rootDirectory) selectNestedRepo(rootDirectory, repository);
+ }}
+ repositoryRoot={rootDirectory ?? undefined}
+ />
+ ) : null}
{!isMobile && (
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
= ({ mode = 'full' }) => {
const [mdPreviewFindOpen, setMdPreviewFindOpen] = React.useState(false);
const [mdPreviewFindFocusNonce, setMdPreviewFindFocusNonce] = React.useState(0);
const mdPreviewContainerRef = React.useRef(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) => {
+ 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(null);
const canCreateFile = Boolean(files.writeFile);
@@ -2506,6 +2520,14 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
return 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') => {
setJsonViewMode(mode);
try {
@@ -3884,7 +3906,12 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
{
markdownPreviewRef.current = node;
mdPreviewContainerRef.current = node;
@@ -4274,7 +4301,9 @@ export const FilesView: React.FC
= ({ mode = 'full' }) => {
// highlighted by the search it drives.
{
markdownPreviewRef.current = node;
mdFullscreenPreviewContainerRef.current = node;
diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx
index 7d6bcc25..33f8379d 100644
--- a/packages/ui/src/components/views/GitView.tsx
+++ b/packages/ui/src/components/views/GitView.tsx
@@ -19,6 +19,8 @@ import {
useGitLoadingStatus,
useGitLoadingLog,
} from '@/stores/useGitStore';
+import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
+import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { toast } from '@/components/ui';
@@ -252,8 +254,17 @@ export const GitView: React.FC
= ({ isActive }) => {
loadDefaultGitIdentityId: s.loadDefaultGitIdentityId,
})));
- const isGitRepo = useIsGitRepo(currentDirectory ?? null);
- const status = useGitStatus(currentDirectory ?? null);
+ // The root the view is anchored to (session/worktree context stays keyed on
+ // it). When the root is not itself a repository and the user picked a nested
+ // one, `gitDirectory` is the effective repository all git data and actions
+ // operate on. The hook owns probing, discovery, auto-select, and
+ // stale-selection recovery; data fetching below keys off its result.
+ const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(
+ currentDirectory ?? null,
+ { enabled: isActive },
+ );
+ const isGitRepo = useIsGitRepo(gitDirectory ?? null);
+ const status = useGitStatus(gitDirectory ?? null);
// Authoritative session↔worktree attachment for repair action display
const worktreeAttachment = useSessionWorktreeStore((s) =>
@@ -268,11 +279,11 @@ export const GitView: React.FC = ({ isActive }) => {
: undefined;
const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined);
- const branches = useGitBranches(currentDirectory ?? null);
- const log = useGitLog(currentDirectory ?? null);
- const currentIdentity = useGitIdentity(currentDirectory ?? null);
- const isLoading = useGitLoadingStatus(currentDirectory ?? null);
- const isLogLoading = useGitLoadingLog(currentDirectory ?? null);
+ const branches = useGitBranches(gitDirectory ?? null);
+ const log = useGitLog(gitDirectory ?? null);
+ const currentIdentity = useGitIdentity(gitDirectory ?? null);
+ const isLoading = useGitLoadingStatus(gitDirectory ?? null);
+ const isLogLoading = useGitLoadingLog(gitDirectory ?? null);
const {
setActiveDirectory,
fetchAll,
@@ -287,6 +298,8 @@ export const GitView: React.FC = ({ isActive }) => {
moveStatusPathsOptimistically,
restoreStatus,
bumpIndexRevision,
+ ensureNestedRepos,
+ selectNestedRepo,
} = useGitStore(useShallow((state) => ({
setActiveDirectory: state.setActiveDirectory,
fetchAll: state.fetchAll,
@@ -301,6 +314,8 @@ export const GitView: React.FC = ({ isActive }) => {
moveStatusPathsOptimistically: state.moveStatusPathsOptimistically,
restoreStatus: state.restoreStatus,
bumpIndexRevision: state.bumpIndexRevision,
+ ensureNestedRepos: state.ensureNestedRepos,
+ selectNestedRepo: state.selectNestedRepo,
})));
const isMobile = useUIStore((state) => state.isMobile);
const openContextDiff = useUIStore((state) => state.openContextDiff);
@@ -310,10 +325,10 @@ export const GitView: React.FC = ({ isActive }) => {
const { mr: gitLabMr } = useGitLabMrForBranch(currentDirectory, prStatusBranch);
const { pr: giteaPr } = useGiteaPrForBranch(currentDirectory, prStatusBranch);
const prChipStatus = useGitHubPrStatusStore((state) => {
- if (!currentDirectory || !prStatusBranch) {
+ if (!gitDirectory || !prStatusBranch) {
return null;
}
- return getFreshestPrStatusForBranch(state.entries, currentDirectory, prStatusBranch);
+ return getFreshestPrStatusForBranch(state.entries, gitDirectory, prStatusBranch);
});
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
@@ -337,12 +352,12 @@ export const GitView: React.FC = ({ isActive }) => {
clearScheduledGitReconcile();
gitReconcileTimeoutRef.current = window.setTimeout(() => {
gitReconcileTimeoutRef.current = null;
- if (normalizePath(directory) !== normalizePath(currentDirectory)) {
+ if (normalizePath(directory) !== normalizePath(gitDirectory)) {
return;
}
void fetchStatus(directory, git, { silent: true });
}, GIT_RECONCILE_DELAY_MS);
- }, [clearScheduledGitReconcile, currentDirectory, fetchStatus, git]);
+ }, [clearScheduledGitReconcile, gitDirectory, fetchStatus, git]);
React.useEffect(() => clearScheduledGitReconcile, [clearScheduledGitReconcile]);
@@ -496,9 +511,9 @@ export const GitView: React.FC = ({ isActive }) => {
const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap;
const initialSnapshot = React.useMemo(() => {
- if (!currentDirectory) return null;
- return gitViewSnapshots.get(currentDirectory) ?? null;
- }, [currentDirectory]);
+ if (!gitDirectory) return null;
+ return gitViewSnapshots.get(gitDirectory) ?? null;
+ }, [gitDirectory]);
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
const [rootBranchHint, setRootBranchHint] = React.useState(null);
@@ -668,7 +683,7 @@ export const GitView: React.FC = ({ isActive }) => {
// Restore conflict state from localStorage on mount
React.useEffect(() => {
- if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return;
+ if (!conflictStorageKey || typeof window === 'undefined' || !gitDirectory) return;
const raw = window.localStorage.getItem(conflictStorageKey);
if (!raw) return;
@@ -680,8 +695,8 @@ export const GitView: React.FC = ({ isActive }) => {
operation: 'merge' | 'rebase';
};
- // Validate the stored state matches current directory
- if (parsed.directory !== currentDirectory) {
+ // Validate the stored state matches the effective repository
+ if (parsed.directory !== gitDirectory) {
window.localStorage.removeItem(conflictStorageKey);
return;
}
@@ -693,7 +708,7 @@ export const GitView: React.FC = ({ isActive }) => {
} catch {
window.localStorage.removeItem(conflictStorageKey);
}
- }, [conflictStorageKey, currentDirectory]);
+ }, [conflictStorageKey, gitDirectory]);
const [stashDialogOpen, setStashDialogOpen] = React.useState(false);
const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge');
const [stashDialogBranch, setStashDialogBranch] = React.useState('');
@@ -729,7 +744,7 @@ export const GitView: React.FC = ({ isActive }) => {
}, [loadingCommitHashes]);
React.useEffect(() => {
- if (!currentDirectory || !git) return;
+ if (!gitDirectory || !git) return;
// Find hashes that are expanded but not yet loaded or loading
const hashesToLoad = Array.from(expandedCommitHashes).filter(
@@ -752,7 +767,7 @@ export const GitView: React.FC = ({ isActive }) => {
void Promise.all(
hashesToLoad.map((hash) =>
git
- .getCommitFiles(currentDirectory, hash)
+ .getCommitFiles(gitDirectory, hash)
.then((response) => ({ hash, files: response.files }))
.catch((error) => {
console.error('Failed to fetch commit files:', error);
@@ -796,16 +811,26 @@ export const GitView: React.FC = ({ isActive }) => {
return next;
});
};
- }, [expandedCommitHashes, currentDirectory, git]);
+ }, [expandedCommitHashes, gitDirectory, git]);
+
+ // Restore the per-repository draft when the effective repository changes
+ // (e.g. the user picks a different nested repository from the picker),
+ // mirroring the fresh-mount behavior of a directory switch.
+ React.useEffect(() => {
+ if (!gitDirectory) return;
+ const snapshot = gitViewSnapshots.get(gitDirectory) ?? null;
+ setCommitMessage(snapshot?.commitMessage ?? '');
+ setGeneratedHighlights(snapshot?.generatedHighlights ?? []);
+ }, [gitDirectory]);
React.useEffect(() => {
- if (!currentDirectory) return;
- rememberSnapshot(currentDirectory, {
- directory: currentDirectory,
+ if (!gitDirectory) return;
+ rememberSnapshot(gitDirectory, {
+ directory: gitDirectory,
commitMessage,
generatedHighlights,
});
- }, [commitMessage, currentDirectory, generatedHighlights]);
+ }, [commitMessage, gitDirectory, generatedHighlights]);
React.useEffect(() => {
if (!isActive) return;
@@ -816,25 +841,25 @@ export const GitView: React.FC = ({ isActive }) => {
React.useEffect(() => {
if (!isActive) return;
- if (!currentDirectory || !git?.getRemoteUrl) {
+ if (!gitDirectory || !git?.getRemoteUrl || isGitRepo !== true) {
setRemoteUrl(null);
return;
}
let cancelled = false;
git
- .getRemoteUrl(currentDirectory)
+ .getRemoteUrl(gitDirectory)
.then((url) => { if (!cancelled) setRemoteUrl(url); })
.catch(() => { if (!cancelled) setRemoteUrl(null); });
return () => { cancelled = true; };
- }, [isActive, currentDirectory, git]);
+ }, [isActive, gitDirectory, git, isGitRepo]);
const refreshRemotes = React.useCallback(async () => {
- if (!currentDirectory || !git?.getRemotes) {
+ if (!gitDirectory || !git?.getRemotes || isGitRepo !== true) {
setRemotes([]);
return;
}
try {
- const remoteList = await git.getRemotes(currentDirectory);
+ const remoteList = await git.getRemotes(gitDirectory);
if (mountedRef.current) {
setRemotes(remoteList);
}
@@ -843,7 +868,7 @@ export const GitView: React.FC = ({ isActive }) => {
setRemotes([]);
}
}
- }, [currentDirectory, git]);
+ }, [gitDirectory, git, isGitRepo]);
React.useEffect(() => {
if (!isActive) return;
@@ -852,37 +877,37 @@ export const GitView: React.FC = ({ isActive }) => {
React.useEffect(() => {
if (!isActive) return;
- if (currentDirectory) {
+ if (currentDirectory && gitDirectory) {
setActiveDirectory(currentDirectory);
- void ensureAll(currentDirectory, git);
+ void ensureAll(gitDirectory, git);
}
- }, [isActive, currentDirectory, setActiveDirectory, ensureAll, git]);
+ }, [isActive, currentDirectory, gitDirectory, setActiveDirectory, ensureAll, git]);
React.useEffect(() => {
if (!isActive) return;
- if (!currentDirectory) {
+ if (!gitDirectory) {
return;
}
return sessionEvents.onGitRefreshHint((hint) => {
- if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) {
+ if (normalizePath(hint.directory) !== normalizePath(gitDirectory)) {
return;
}
if (hint.paths?.length) {
- clearDiffCache(currentDirectory, hint.paths);
+ clearDiffCache(gitDirectory, hint.paths);
}
- void fetchStatus(currentDirectory, git, { silent: true });
+ void fetchStatus(gitDirectory, git, { silent: true });
});
- }, [isActive, clearDiffCache, currentDirectory, fetchStatus, git]);
+ }, [isActive, clearDiffCache, gitDirectory, fetchStatus, git]);
const refreshStatusAndBranches = React.useCallback(
async (showErrors = true) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
try {
await Promise.all([
- fetchStatus(currentDirectory, git),
- fetchBranches(currentDirectory, git),
+ fetchStatus(gitDirectory, git),
+ fetchBranches(gitDirectory, git),
]);
} catch (err) {
if (showErrors) {
@@ -892,42 +917,42 @@ export const GitView: React.FC = ({ isActive }) => {
}
}
},
- [currentDirectory, git, fetchStatus, fetchBranches, t]
+ [gitDirectory, git, fetchStatus, fetchBranches, t]
);
const refreshLog = React.useCallback(async () => {
- if (!currentDirectory) return;
- await fetchLog(currentDirectory, git, logMaxCountLocal);
- }, [currentDirectory, git, fetchLog, logMaxCountLocal]);
+ if (!gitDirectory) return;
+ await fetchLog(gitDirectory, git, logMaxCountLocal);
+ }, [gitDirectory, git, fetchLog, logMaxCountLocal]);
const refreshIdentity = React.useCallback(async () => {
- if (!currentDirectory) return;
- await fetchIdentity(currentDirectory, git);
- }, [currentDirectory, git, fetchIdentity]);
+ if (!gitDirectory) return;
+ await fetchIdentity(gitDirectory, git);
+ }, [gitDirectory, git, fetchIdentity]);
React.useEffect(() => {
if (!isActive) return;
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
if (!git?.hasLocalIdentity) return;
if (isGitRepo !== true) return;
const defaultId = typeof defaultGitIdentityId === 'string' ? defaultGitIdentityId.trim() : '';
if (!defaultId || defaultId === 'global') return;
- const previousAttempt = autoAppliedDefaultRef.current.get(currentDirectory);
+ const previousAttempt = autoAppliedDefaultRef.current.get(gitDirectory);
if (previousAttempt === defaultId) return;
let cancelled = false;
const run = async () => {
try {
- const hasLocal = await git.hasLocalIdentity?.(currentDirectory);
+ const hasLocal = await git.hasLocalIdentity?.(gitDirectory);
if (cancelled) return;
if (hasLocal === true) return;
beginIdentityApply();
- await git.setGitIdentity(currentDirectory, defaultId);
- autoAppliedDefaultRef.current.set(currentDirectory, defaultId);
+ await git.setGitIdentity(gitDirectory, defaultId);
+ autoAppliedDefaultRef.current.set(gitDirectory, defaultId);
await refreshIdentity();
} catch (error) {
console.warn('Failed to auto-apply default git identity:', error);
@@ -943,7 +968,7 @@ export const GitView: React.FC = ({ isActive }) => {
return () => {
cancelled = true;
};
- }, [isActive, beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]);
+ }, [isActive, beginIdentityApply, gitDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]);
const changeEntries = React.useMemo(() => {
if (!status) return [];
@@ -964,7 +989,7 @@ export const GitView: React.FC = ({ isActive }) => {
);
React.useEffect(() => {
- if (!currentDirectory || changeEntries.length === 0) {
+ if (!gitDirectory || changeEntries.length === 0) {
return;
}
@@ -988,13 +1013,13 @@ export const GitView: React.FC = ({ isActive }) => {
}
const timeoutId = window.setTimeout(() => {
- void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT });
+ void prefetchDiffs(gitDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT });
}, 120);
return () => {
window.clearTimeout(timeoutId);
};
- }, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
+ }, [changeEntries, gitDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
const getPushedRemoteName = (result?: Awaited>) => {
return result?.pushed[0]?.remote
@@ -1005,7 +1030,7 @@ export const GitView: React.FC = ({ isActive }) => {
};
const handleSyncAction = async (action: Exclude, remote?: GitRemote) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
setSyncAction(action);
try {
@@ -1025,20 +1050,20 @@ export const GitView: React.FC = ({ isActive }) => {
if (!remote) {
throw new Error('No remote available for fetch');
}
- await git.gitFetch(currentDirectory, { remote: remote.name });
+ await git.gitFetch(gitDirectory, { remote: remote.name });
toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name }));
} else if (action === 'pull') {
if (!remote) {
throw new Error('No remote available for pull');
}
- const result = await git.gitPull(currentDirectory, getPullOptions(remote));
+ const result = await git.gitPull(gitDirectory, getPullOptions(remote));
toast.success(
result.files.length === 1
? t('gitView.toast.pulledFilesSingle', { count: result.files.length, name: remote.name })
: t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name })
);
} else if (action === 'push') {
- const result = await git.gitPush(currentDirectory);
+ const result = await git.gitPush(gitDirectory);
toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) }));
} else if (action === 'sync') {
if (!remote) {
@@ -1046,21 +1071,21 @@ export const GitView: React.FC = ({ isActive }) => {
}
let pulledFileCount = 0;
let pushedChanges = false;
- await git.gitFetch(currentDirectory, { remote: remote.name });
- const afterFetch = await git.getGitStatus(currentDirectory);
+ await git.gitFetch(gitDirectory, { remote: remote.name });
+ const afterFetch = await git.getGitStatus(gitDirectory);
if ((afterFetch.behind ?? 0) > 0) {
if ((afterFetch.files?.length ?? 0) > 0) {
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
return;
}
- const pullResult = await git.gitPull(currentDirectory, getPullOptions(remote));
+ const pullResult = await git.gitPull(gitDirectory, getPullOptions(remote));
pulledFileCount = pullResult.files.length;
}
- const afterPull = await git.getGitStatus(currentDirectory);
+ const afterPull = await git.getGitStatus(gitDirectory);
if ((afterPull.ahead ?? 0) > 0) {
- await git.gitPush(currentDirectory);
+ await git.gitPush(gitDirectory);
pushedChanges = true;
}
if (pulledFileCount > 0 && pushedChanges) {
@@ -1096,7 +1121,7 @@ export const GitView: React.FC = ({ isActive }) => {
};
const handleRemoveRemote = React.useCallback(async (remote: GitRemote) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
const remoteName = remote.name.trim();
if (!remoteName) {
@@ -1110,7 +1135,7 @@ export const GitView: React.FC = ({ isActive }) => {
setRemovingRemoteName(remoteName);
try {
- await git.removeRemote(currentDirectory, { remote: remoteName });
+ await git.removeRemote(gitDirectory, { remote: remoteName });
toast.success(t('gitView.toast.removedRemote', { name: remoteName }));
await Promise.all([
refreshStatusAndBranches(false),
@@ -1122,10 +1147,10 @@ export const GitView: React.FC = ({ isActive }) => {
} finally {
setRemovingRemoteName(null);
}
- }, [currentDirectory, git, refreshRemotes, refreshStatusAndBranches, t]);
+ }, [gitDirectory, git, refreshRemotes, refreshStatusAndBranches, t]);
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
if (!commitMessage.trim()) {
toast.error(t('gitView.toast.enterCommitMessage'));
return;
@@ -1141,11 +1166,11 @@ export const GitView: React.FC = ({ isActive }) => {
setCommitAction(action);
try {
- await git.createGitCommit(currentDirectory, commitMessage.trim(), {
+ await git.createGitCommit(gitDirectory, commitMessage.trim(), {
files: filesToCommit,
stageFiles: [],
});
- bumpIndexRevision(currentDirectory);
+ bumpIndexRevision(gitDirectory);
toast.success(t('gitView.toast.commitCreated'));
setCommitMessage('');
clearGeneratedHighlights();
@@ -1165,21 +1190,21 @@ export const GitView: React.FC = ({ isActive }) => {
? status.tracking.slice(trackingPrefix.length)
: undefined;
- await git.gitFetch(currentDirectory, { remote: remote.name });
- const afterFetch = await git.getGitStatus(currentDirectory);
+ await git.gitFetch(gitDirectory, { remote: remote.name });
+ const afterFetch = await git.getGitStatus(gitDirectory);
if ((afterFetch.behind ?? 0) > 0) {
if ((afterFetch.files?.length ?? 0) > 0) {
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
await refreshStatusAndBranches(false);
return;
}
- await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true });
+ await git.gitPull(gitDirectory, { remote: remote.name, branch: trackedBranch, rebase: true });
}
- const afterPull = await git.getGitStatus(currentDirectory);
+ const afterPull = await git.getGitStatus(gitDirectory);
let result: Awaited> | undefined;
if ((afterPull.ahead ?? 0) > 0) {
- result = await git.gitPush(currentDirectory);
+ result = await git.gitPush(gitDirectory);
}
toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) }));
triggerFireworks();
@@ -1202,7 +1227,7 @@ export const GitView: React.FC = ({ isActive }) => {
};
const handleGenerateCommitMessage = React.useCallback(async () => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort();
if (selectedFilePaths.length === 0) {
toast.error(t('gitView.toast.stageFileToDescribe'));
@@ -1210,13 +1235,13 @@ export const GitView: React.FC = ({ isActive }) => {
}
console.error('[git-generation][browser] generate button clicked', {
- directory: currentDirectory,
+ directory: gitDirectory,
selectedFiles: selectedFilePaths.length,
});
setIsGeneratingMessage(true);
try {
- const { message } = await generateSessionCommitMessage(currentDirectory, selectedFilePaths);
+ const { message } = await generateSessionCommitMessage(gitDirectory, selectedFilePaths);
const subject = message.subject?.trim() ?? '';
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
@@ -1247,7 +1272,7 @@ export const GitView: React.FC = ({ isActive }) => {
} finally {
setIsGeneratingMessage(false);
}
- }, [currentDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]);
+ }, [gitDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]);
const formatBlockingReason = (reason: ReturnType[number]): string => {
if (reason.reason === 'attention') {
@@ -1260,7 +1285,7 @@ export const GitView: React.FC = ({ isActive }) => {
};
const handleCreateBranch = async (branchName: string, remote?: GitRemote) => {
- if (!currentDirectory || !status) return;
+ if (!gitDirectory || !status) return;
const blockingReasons = getMutationBlockingReasons(worktreeAttachment);
if (blockingReasons.length > 0) {
@@ -1272,15 +1297,15 @@ export const GitView: React.FC = ({ isActive }) => {
const remoteName = remote?.name ?? 'origin';
try {
- await git.createBranch(currentDirectory, branchName, checkoutBase ?? 'HEAD');
+ await git.createBranch(gitDirectory, branchName, checkoutBase ?? 'HEAD');
toast.success(t('gitView.toast.createdBranch', { name: branchName }));
// Checkout the new branch and stay on it
- await git.checkoutBranch(currentDirectory, branchName);
+ await git.checkoutBranch(gitDirectory, branchName);
let pushSucceeded = false;
try {
- await git.gitPush(currentDirectory, {
+ await git.gitPush(gitDirectory, {
remote: remoteName,
branch: branchName,
options: ['--set-upstream'],
@@ -1314,7 +1339,7 @@ export const GitView: React.FC = ({ isActive }) => {
};
const handleRenameBranch = async (oldName: string, newName: string) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
const blockingReasons = getMutationBlockingReasons(worktreeAttachment);
if (blockingReasons.length > 0) {
@@ -1323,7 +1348,7 @@ export const GitView: React.FC = ({ isActive }) => {
}
try {
- await git.renameBranch(currentDirectory, oldName, newName);
+ await git.renameBranch(gitDirectory, oldName, newName);
toast.success(t('gitView.toast.renamedBranch', { oldName, newName }));
await refreshStatusAndBranches();
await refreshLog();
@@ -1335,7 +1360,7 @@ export const GitView: React.FC = ({ isActive }) => {
};
const handleCheckoutBranch = async (branch: string) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
// Block mutation if worktree is in an attention-required state
const blockingReasons = getMutationBlockingReasons(worktreeAttachment);
@@ -1353,7 +1378,7 @@ export const GitView: React.FC = ({ isActive }) => {
try {
// Picking a remote-tracking branch checks out the local branch that
// tracks it, so report the branch the repository actually landed on.
- const result = await git.checkoutBranch(currentDirectory, normalized);
+ const result = await git.checkoutBranch(gitDirectory, normalized);
toast.success(t('gitView.toast.checkedOut', { name: result?.branch || normalized }));
await refreshStatusAndBranches();
await refreshLog();
@@ -1365,11 +1390,11 @@ export const GitView: React.FC = ({ isActive }) => {
};
const handleApplyIdentity = async (profile: GitIdentityProfile) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
beginIdentityApply();
try {
- await git.setGitIdentity(currentDirectory, profile.id);
+ await git.setGitIdentity(gitDirectory, profile.id);
toast.success(t('gitView.toast.appliedIdentity', { name: profile.name }));
await refreshIdentity();
} catch (err) {
@@ -1552,7 +1577,7 @@ export const GitView: React.FC = ({ isActive }) => {
: null;
React.useEffect(() => {
- if (!currentDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) {
+ if (!gitDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) {
setHistoryBranchDivider(null);
return;
}
@@ -1561,7 +1586,7 @@ export const GitView: React.FC = ({ isActive }) => {
const resolveBranchDivider = async () => {
try {
- const branchOnlyLog = await git.getGitLog(currentDirectory, {
+ const branchOnlyLog = await git.getGitLog(gitDirectory, {
from: baseBranch,
to: 'HEAD',
maxCount: logMaxCountLocal,
@@ -1614,21 +1639,21 @@ export const GitView: React.FC = ({ isActive }) => {
return () => {
cancelled = true;
};
- }, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]);
+ }, [baseBranch, currentBranch, gitDirectory, git, log, logMaxCountLocal]);
// Clear graph log when directory changes
React.useEffect(() => {
setGraphLog(null);
- }, [currentDirectory]);
+ }, [gitDirectory]);
React.useEffect(() => {
- if (gitLogDialogMode !== 'graph' || !currentDirectory) {
+ if (gitLogDialogMode !== 'graph' || !gitDirectory) {
if (gitLogDialogMode !== 'graph') setGraphLog(null);
return;
}
let cancelled = false;
setGraphLogLoading(true);
- git.getGitLog(currentDirectory, { maxCount: graphLogMaxCount, all: true })
+ git.getGitLog(gitDirectory, { maxCount: graphLogMaxCount, all: true })
.then((result) => {
if (!cancelled) setGraphLog(result);
})
@@ -1639,33 +1664,33 @@ export const GitView: React.FC = ({ isActive }) => {
if (!cancelled) setGraphLogLoading(false);
});
return () => { cancelled = true; };
- }, [gitLogDialogMode, currentDirectory, graphLogMaxCount, graphLogRefreshToken, git]);
+ }, [gitLogDialogMode, gitDirectory, graphLogMaxCount, graphLogRefreshToken, git]);
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => {
- if (!currentDirectory || paths.length === 0) return;
+ if (!gitDirectory || paths.length === 0) return;
const uniquePaths = Array.from(new Set(paths));
setMovingChangePaths((previous) => {
const next = new Set(previous);
uniquePaths.forEach((path) => next.add(path));
return next;
});
- const previousStatus = moveStatusPathsOptimistically(currentDirectory, uniquePaths, direction);
+ const previousStatus = moveStatusPathsOptimistically(gitDirectory, uniquePaths, direction);
gitIndexMutationQueue.enqueue({
- directory: currentDirectory,
+ directory: gitDirectory,
direction,
paths: new Set(uniquePaths),
- rollback: () => restoreStatus(currentDirectory, previousStatus),
+ rollback: () => restoreStatus(gitDirectory, previousStatus),
});
scheduleGitMutationFlush();
- }, [currentDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]);
+ }, [gitDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]);
const handleRevertFile = React.useCallback(
async (filePath: string) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
setRevertingPaths((previous) => {
const next = new Set(previous);
@@ -1674,7 +1699,7 @@ export const GitView: React.FC = ({ isActive }) => {
});
try {
- await git.revertGitFile(currentDirectory, filePath, { scope: 'working' });
+ await git.revertGitFile(gitDirectory, filePath, { scope: 'working' });
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
await refreshStatusAndBranches(false);
} catch (err) {
@@ -1688,12 +1713,12 @@ export const GitView: React.FC = ({ isActive }) => {
});
}
},
- [currentDirectory, refreshStatusAndBranches, git, t]
+ [gitDirectory, refreshStatusAndBranches, git, t]
);
const handleRevertPaths = React.useCallback(
async (paths: string[], setGlobalReverting: boolean, scope: 'all' | 'working' = 'all') => {
- if (!currentDirectory || paths.length === 0) {
+ if (!gitDirectory || paths.length === 0) {
return;
}
@@ -1719,7 +1744,7 @@ export const GitView: React.FC = ({ isActive }) => {
try {
await Promise.all(uniquePaths.map(async (filePath) => {
try {
- await git.revertGitFile(currentDirectory, filePath, { scope });
+ await git.revertGitFile(gitDirectory, filePath, { scope });
} catch (err) {
failed.push({
path: filePath,
@@ -1729,7 +1754,7 @@ export const GitView: React.FC = ({ isActive }) => {
}));
if (touchesStagedIndex && failed.length < uniquePaths.length) {
- bumpIndexRevision(currentDirectory);
+ bumpIndexRevision(gitDirectory);
}
await refreshStatusAndBranches(false);
@@ -1761,7 +1786,7 @@ export const GitView: React.FC = ({ isActive }) => {
}
}
},
- [bumpIndexRevision, currentDirectory, git, isRevertingAll, refreshStatusAndBranches, revertingPaths, stagedChangeEntries, t]
+ [bumpIndexRevision, gitDirectory, git, isRevertingAll, refreshStatusAndBranches, revertingPaths, stagedChangeEntries, t]
);
const handleRevertAll = React.useCallback(
@@ -1778,6 +1803,10 @@ export const GitView: React.FC = ({ isActive }) => {
[handleRevertPaths]
);
+ // Context-panel tabs are keyed by the project root, not by the repository
+ // being diffed: the diff surface resolves the selected nested repository on
+ // its own, so opening the tab under `gitDirectory` would park it under a key
+ // the panel never displays.
const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => {
if (currentDirectory && !isMobile) {
openContextDiff(currentDirectory, path, staged);
@@ -1932,7 +1961,7 @@ export const GitView: React.FC = ({ isActive }) => {
const handleMerge = React.useCallback(
async (branch: string) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
setBranchOperation('merge');
resetOperationLogs();
@@ -1943,19 +1972,19 @@ export const GitView: React.FC = ({ isActive }) => {
try {
if (target.remote && target.remoteBranch) {
addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running');
- await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch });
+ await git.gitFetch(gitDirectory, { remote: target.remote, branch: target.remoteBranch });
updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`);
}
addOperationLog(`Merging ${target.branch} into ${currentBranch}...`, 'running');
- const result = await git.merge(currentDirectory, { branch: target.branch });
+ const result = await git.merge(gitDirectory, { branch: target.branch });
if (result.conflict) {
updateLastLog('error', `Merge conflicts detected`);
setConflictFiles(result.conflictFiles ?? []);
setConflictOperation('merge');
setConflictDialogOpen(true);
- persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge');
+ persistConflictState(gitDirectory, result.conflictFiles ?? [], 'merge');
} else {
updateLastLog('done', `Merged ${target.branch} into ${currentBranch}`);
clearConflictState();
@@ -1977,12 +2006,12 @@ export const GitView: React.FC = ({ isActive }) => {
}
// Note: branchOperation is cleared when dialog closes via handleOperationComplete
},
- [currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
+ [gitDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
);
const handleRebase = React.useCallback(
async (branch: string) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
setBranchOperation('rebase');
resetOperationLogs();
@@ -1993,19 +2022,19 @@ export const GitView: React.FC = ({ isActive }) => {
try {
if (target.remote && target.remoteBranch) {
addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running');
- await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch });
+ await git.gitFetch(gitDirectory, { remote: target.remote, branch: target.remoteBranch });
updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`);
}
addOperationLog(`Rebasing ${currentBranch} onto ${target.branch}...`, 'running');
- const result = await git.rebase(currentDirectory, { onto: target.branch });
+ const result = await git.rebase(gitDirectory, { onto: target.branch });
if (result.conflict) {
updateLastLog('error', `Rebase conflicts detected`);
setConflictFiles(result.conflictFiles ?? []);
setConflictOperation('rebase');
setConflictDialogOpen(true);
- persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase');
+ persistConflictState(gitDirectory, result.conflictFiles ?? [], 'rebase');
} else {
updateLastLog('done', `Rebased ${currentBranch} onto ${target.branch}`);
clearConflictState();
@@ -2027,18 +2056,18 @@ export const GitView: React.FC = ({ isActive }) => {
}
// Note: branchOperation is cleared when dialog closes via handleOperationComplete
},
- [currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
+ [gitDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
);
const handleAbortConflict = React.useCallback(async () => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
try {
if (conflictOperation === 'merge') {
- await git.abortMerge(currentDirectory);
+ await git.abortMerge(gitDirectory);
toast.success(t('gitView.toast.mergeAborted'));
} else {
- await git.abortRebase(currentDirectory);
+ await git.abortRebase(gitDirectory);
toast.success(t('gitView.toast.rebaseAborted'));
}
clearConflictState();
@@ -2048,7 +2077,7 @@ export const GitView: React.FC = ({ isActive }) => {
const message = err instanceof Error ? err.message : `Failed to abort ${conflictOperation}`;
toast.error(message);
}
- }, [currentDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState, t]);
+ }, [gitDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState, t]);
// Count unresolved conflicts (files with 'U' status)
const conflictCount = React.useMemo(() => {
@@ -2061,19 +2090,19 @@ export const GitView: React.FC = ({ isActive }) => {
}, [status?.files]);
const handleContinueOperation = React.useCallback(async () => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
try {
const isMerge = !!status?.mergeInProgress?.head;
const isRebase = !!(status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto);
if (isMerge) {
- const result = await git.continueMerge(currentDirectory);
+ const result = await git.continueMerge(gitDirectory);
if (result.conflict) {
setConflictFiles(result.conflictFiles ?? []);
setConflictOperation('merge');
setConflictDialogOpen(true);
- persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge');
+ persistConflictState(gitDirectory, result.conflictFiles ?? [], 'merge');
toast.error(t('gitView.toast.mergeConflictsDetected'));
} else {
clearConflictState();
@@ -2082,12 +2111,12 @@ export const GitView: React.FC = ({ isActive }) => {
await refreshLog();
}
} else if (isRebase) {
- const result = await git.continueRebase(currentDirectory);
+ const result = await git.continueRebase(gitDirectory);
if (result.conflict) {
setConflictFiles(result.conflictFiles ?? []);
setConflictOperation('rebase');
setConflictDialogOpen(true);
- persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase');
+ persistConflictState(gitDirectory, result.conflictFiles ?? [], 'rebase');
toast.error(t('gitView.toast.rebaseConflictsDetected'));
} else {
clearConflictState();
@@ -2100,18 +2129,18 @@ export const GitView: React.FC = ({ isActive }) => {
const message = err instanceof Error ? err.message : t('gitView.toast.continueOperationFailed');
toast.error(message);
}
- }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState, t]);
+ }, [gitDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState, t]);
const handleAbortOperation = React.useCallback(async () => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
try {
const isMerge = !!status?.mergeInProgress?.head;
if (isMerge) {
- await git.abortMerge(currentDirectory);
+ await git.abortMerge(gitDirectory);
toast.success(t('gitView.toast.mergeAborted'));
} else {
- await git.abortRebase(currentDirectory);
+ await git.abortRebase(gitDirectory);
toast.success(t('gitView.toast.rebaseAborted'));
}
clearConflictState();
@@ -2121,10 +2150,10 @@ export const GitView: React.FC = ({ isActive }) => {
const message = err instanceof Error ? err.message : t('gitView.toast.abortOperationFailed');
toast.error(message);
}
- }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState, t]);
+ }, [gitDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState, t]);
const handleResolveWithAIFromBanner = React.useCallback(() => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
// Determine operation type from status
const isMerge = !!status?.mergeInProgress?.head;
@@ -2141,11 +2170,11 @@ export const GitView: React.FC = ({ isActive }) => {
}
setConflictOperation(operation);
setConflictDialogOpen(true);
- }, [currentDirectory, status]);
+ }, [gitDirectory, status]);
const handleStashAndRetry = React.useCallback(
async (restoreAfter: boolean) => {
- if (!currentDirectory) return;
+ if (!gitDirectory) return;
const currentBranch = status?.current;
const operation = stashDialogOperation;
@@ -2154,12 +2183,12 @@ export const GitView: React.FC = ({ isActive }) => {
// Stash changes
try {
- await git.stash(currentDirectory, {
+ await git.stash(gitDirectory, {
message: `Auto-stash before ${operation} with ${branch}`,
includeUntracked: true,
});
if (hadStagedChanges) {
- bumpIndexRevision(currentDirectory);
+ bumpIndexRevision(gitDirectory);
}
} catch (stashErr) {
const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes';
@@ -2173,7 +2202,7 @@ export const GitView: React.FC = ({ isActive }) => {
try {
// Perform the operation
if (operation === 'merge') {
- const result = await git.merge(currentDirectory, { branch });
+ const result = await git.merge(gitDirectory, { branch });
if (result.conflict) {
hasConflict = true;
setConflictFiles(result.conflictFiles ?? []);
@@ -2184,7 +2213,7 @@ export const GitView: React.FC = ({ isActive }) => {
toast.success(t('gitView.toast.mergedIntoBranch', { branch, currentBranch: currentBranch || '' }));
}
} else {
- const result = await git.rebase(currentDirectory, { onto: branch });
+ const result = await git.rebase(gitDirectory, { onto: branch });
if (result.conflict) {
hasConflict = true;
setConflictFiles(result.conflictFiles ?? []);
@@ -2199,8 +2228,8 @@ export const GitView: React.FC = ({ isActive }) => {
// Restore stashed changes if requested and operation succeeded
if (restoreAfter && operationSucceeded) {
try {
- await git.stashPop(currentDirectory);
- bumpIndexRevision(currentDirectory);
+ await git.stashPop(gitDirectory);
+ bumpIndexRevision(gitDirectory);
toast.success(t('gitView.toast.stashedRestored'));
} catch (popErr) {
const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed');
@@ -2216,8 +2245,8 @@ export const GitView: React.FC = ({ isActive }) => {
// If the operation failed (not due to conflicts), try to restore stash
if (restoreAfter) {
try {
- await git.stashPop(currentDirectory);
- bumpIndexRevision(currentDirectory);
+ await git.stashPop(gitDirectory);
+ bumpIndexRevision(gitDirectory);
} catch {
// Ignore stash pop errors in this case
}
@@ -2225,18 +2254,18 @@ export const GitView: React.FC = ({ isActive }) => {
throw err;
}
},
- [bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t]
+ [bumpIndexRevision, gitDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t]
);
const handleLogMaxCountChange = React.useCallback(
(count: number) => {
setLogMaxCountLocal(count);
- if (currentDirectory) {
- setLogMaxCount(currentDirectory, count);
- fetchLog(currentDirectory, git, count);
+ if (gitDirectory) {
+ setLogMaxCount(gitDirectory, count);
+ fetchLog(gitDirectory, git, count);
}
},
- [currentDirectory, fetchLog, git, setLogMaxCount]
+ [gitDirectory, fetchLog, git, setLogMaxCount]
);
const handleGraphLogMaxCountChange = React.useCallback((count: number) => {
@@ -2245,12 +2274,12 @@ export const GitView: React.FC = ({ isActive }) => {
const handleGraphActionSuccess = React.useCallback(() => {
setGitLogDialogMode(null);
- if (currentDirectory) {
- fetchStatus(currentDirectory, git);
- fetchBranches(currentDirectory, git);
- fetchLog(currentDirectory, git, logMaxCountLocal);
+ if (gitDirectory) {
+ fetchStatus(gitDirectory, git);
+ fetchBranches(gitDirectory, git);
+ fetchLog(gitDirectory, git, logMaxCountLocal);
}
- }, [currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]);
+ }, [gitDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]);
const handleGraphConflict = React.useCallback((result: {
conflict: boolean;
@@ -2267,10 +2296,10 @@ export const GitView: React.FC = ({ isActive }) => {
files: result.conflictFiles?.join(', ') ?? 'unknown files',
}),
});
- if (currentDirectory) {
- fetchStatus(currentDirectory, git);
- fetchBranches(currentDirectory, git);
- fetchLog(currentDirectory, git, logMaxCountLocal);
+ if (gitDirectory) {
+ fetchStatus(gitDirectory, git);
+ fetchBranches(gitDirectory, git);
+ fetchLog(gitDirectory, git, logMaxCountLocal);
}
return;
}
@@ -2278,10 +2307,10 @@ export const GitView: React.FC = ({ isActive }) => {
setConflictFiles(result.conflictFiles ?? []);
setConflictOperation(result.operation);
setConflictDialogOpen(true);
- if (currentDirectory) {
- persistConflictState(currentDirectory, result.conflictFiles ?? [], result.operation);
+ if (gitDirectory) {
+ persistConflictState(gitDirectory, result.conflictFiles ?? [], result.operation);
}
- }, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]);
+ }, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, gitDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]);
if (!currentDirectory) {
return (
@@ -2319,21 +2348,26 @@ export const GitView: React.FC = ({ isActive }) => {
);
}
+ // Nested repository discovery states (discovering, failed, unsupported,
+ // none found, or settling on the auto-selected repository).
return (
-
-
-
- {t('gitView.empty.notGitRepository')}
-
-
- {t('gitView.empty.notGitRepositoryDescription')}
-
- {repairActions.includes('open-without-worktree-features') ? (
-
- {t('gitView.empty.worktreeFeaturesUnavailable')}
-
- ) : null}
-
+ {
+ if (currentDirectory) {
+ void ensureNestedRepos(currentDirectory, { force: true });
+ }
+ }}
+ emptyStateFooter={
+ repairActions.includes('open-without-worktree-features') ? (
+
+ {t('gitView.empty.worktreeFeaturesUnavailable')}
+
+ ) : undefined
+ }
+ />
);
}
@@ -2366,8 +2400,18 @@ export const GitView: React.FC = ({ isActive }) => {
pullRequest={prChipStatus?.pr ?? null}
prChecks={prChipStatus?.checks ?? null}
onOpenPullRequest={
- currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
+ gitDirectory ? () => openContextSurface(gitDirectory, 'pr') : undefined
}
+ repositoryOptions={
+ gitDirectory !== currentDirectory && Array.isArray(nestedRepos) ? nestedRepos : undefined
+ }
+ selectedRepository={gitDirectory !== currentDirectory ? gitDirectory : null}
+ onSelectRepository={
+ gitDirectory !== currentDirectory && currentDirectory
+ ? (repository) => selectNestedRepo(currentDirectory, repository)
+ : undefined
+ }
+ repositoryRoot={gitDirectory !== currentDirectory ? currentDirectory : undefined}
gitLabMr={gitLabMr}
onOpenGitLabMr={
currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
@@ -2508,10 +2552,10 @@ export const GitView: React.FC = ({ isActive }) => {
refreshKey={integrateRefreshKey}
showHeader={false}
onRefresh={() => {
- if (!currentDirectory) return;
- fetchStatus(currentDirectory, git);
- fetchBranches(currentDirectory, git);
- fetchLog(currentDirectory, git, logMaxCountLocal);
+ if (!gitDirectory) return;
+ fetchStatus(gitDirectory, git);
+ fetchBranches(gitDirectory, git);
+ fetchLog(gitDirectory, git, logMaxCountLocal);
}}
/>
) : null}
@@ -2535,8 +2579,8 @@ export const GitView: React.FC = ({ isActive }) => {
setGraphLogRefreshToken((token) => token + 1);
return;
}
- if (!currentDirectory) return;
- void fetchLog(currentDirectory, git, logMaxCountLocal);
+ if (!gitDirectory) return;
+ void fetchLog(gitDirectory, git, logMaxCountLocal);
}}
disabled={gitLogDialogMode === 'graph' ? graphLogLoading : isLogLoading}
title={t('gitView.history.refresh')}
@@ -2568,7 +2612,7 @@ export const GitView: React.FC = ({ isActive }) => {
commitFilesMap={commitFilesMap}
loadingCommitHashes={loadingCommitHashes}
onCopyHash={handleCopyCommitHash}
- directory={currentDirectory ?? undefined}
+ directory={gitDirectory ?? undefined}
showHeader={false}
contentMaxHeightClassName="h-full max-h-none"
branchDivider={gitLogDialogMode === 'graph' ? null : historyBranchDivider}
@@ -2582,13 +2626,13 @@ export const GitView: React.FC = ({ isActive }) => {
0}
hasStagedChanges={stagedChangeEntries.length > 0}
uncommittedFileCount={status?.files?.length ?? 0}
onChanged={async (change) => {
- if (currentDirectory && change?.affectsIndex) {
- bumpIndexRevision(currentDirectory);
+ if (gitDirectory && change?.affectsIndex) {
+ bumpIndexRevision(gitDirectory);
}
await refreshStatusAndBranches(false);
await refreshLog();
@@ -2626,12 +2670,12 @@ export const GitView: React.FC = ({ isActive }) => {
- {currentDirectory && (
+ {gitDirectory && (
(
+ workspace.name?.trim() || workspace.urlKey?.trim() || workspace.id
+);
+
+const LINEAR_PRIORITY_KEYS = {
+ 0: 'contextPanel.linear.priority.none',
+ 1: 'contextPanel.linear.priority.urgent',
+ 2: 'contextPanel.linear.priority.high',
+ 3: 'contextPanel.linear.priority.medium',
+ 4: 'contextPanel.linear.priority.low',
+} as const;
+
+const LINEAR_WORKFLOW_TYPE_RANK = {
+ triage: 0,
+ backlog: 1,
+ unstarted: 2,
+ started: 3,
+ completed: 4,
+ canceled: 5,
+} as const;
+
+const linearWorkflowTypeRank = (type: string | null): number => {
+ if (
+ type === 'triage'
+ || type === 'backlog'
+ || type === 'unstarted'
+ || type === 'started'
+ || type === 'completed'
+ || type === 'canceled'
+ ) {
+ return LINEAR_WORKFLOW_TYPE_RANK[type];
+ }
+ return 99;
+};
+
+const compareLinearWorkflowStates = (left: LinearWorkflowState, right: LinearWorkflowState): number => {
+ const typeDelta = linearWorkflowTypeRank(left.type) - linearWorkflowTypeRank(right.type);
+ if (typeDelta !== 0) return typeDelta;
+ if (left.position !== right.position) return left.position - right.position;
+ return left.name.localeCompare(right.name);
+};
+
+const linearPriorityMessageKey = (priority: number | null | undefined) => {
+ if (priority !== 0 && priority !== 1 && priority !== 2 && priority !== 3 && priority !== 4) {
+ return null;
+ }
+ return LINEAR_PRIORITY_KEYS[priority];
+};
+
+const STATUS_FILTER_ITEMS = [
+ { value: 'all', labelKey: 'contextPanel.linear.filter.status.all' },
+ { value: 'backlog', labelKey: 'contextPanel.linear.filter.status.backlog' },
+ { value: 'todo', labelKey: 'contextPanel.linear.filter.status.todo' },
+ { value: 'started', labelKey: 'contextPanel.linear.filter.status.started' },
+ { value: 'inReview', labelKey: 'contextPanel.linear.filter.status.inReview' },
+ { value: 'completed', labelKey: 'contextPanel.linear.filter.status.completed' },
+ { value: 'canceled', labelKey: 'contextPanel.linear.filter.status.canceled' },
+ { value: 'duplicate', labelKey: 'contextPanel.linear.filter.status.duplicate' },
+] as const;
+
+const isLinearIssueListStatus = (value: string): value is LinearIssueListStatus => (
+ STATUS_FILTER_ITEMS.some((item) => item.value === value)
+);
+
+const PRIORITY_FILTER_ITEMS = [
+ { value: 'all', labelKey: 'contextPanel.linear.filter.priority.all' },
+ { value: 'urgent', labelKey: 'contextPanel.linear.priority.urgent' },
+ { value: 'high', labelKey: 'contextPanel.linear.priority.high' },
+ { value: 'medium', labelKey: 'contextPanel.linear.priority.medium' },
+ { value: 'low', labelKey: 'contextPanel.linear.priority.low' },
+ { value: 'none', labelKey: 'contextPanel.linear.priority.none' },
+] as const;
+
+const isLinearIssueListPriority = (value: string): value is LinearIssueListPriority => (
+ PRIORITY_FILTER_ITEMS.some((item) => item.value === value)
+);
+
+const labelChipStyle = (color: string | null): React.CSSProperties | undefined => {
+ if (!color) {
+ return { backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 12%, transparent)' };
+ }
+ return {
+ color,
+ backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)`,
+ };
+};
+
+const LinearIssueLabelChips: React.FC<{ labels: LinearIssueLabel[] }> = ({ labels }) => {
+ if (labels.length === 0) return null;
+ return (
+
+ {labels.map((label) => (
+
+ {label.name}
+
+ ))}
+
+ );
+};
+
+const LinearFilterMenu: React.FC<{
+ icon: IconName;
+ label: string;
+ ariaLabel: string;
+ value: string;
+ items: Array<{ value: string; label: string }>;
+ disabled?: boolean;
+ compact?: boolean;
+ active?: boolean;
+ onValueChange: (value: string) => void;
+}> = ({ icon, label, ariaLabel, value, items, disabled, compact, active, onValueChange }) => {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+ {
+ if (!disabled) setOpen(next);
+ }}
+ >
+
+
+
+ {!compact ? (
+ <>
+ {label}
+
+ >
+ ) : null}
+
+
+
+ {
+ onValueChange(next);
+ setOpen(false);
+ }}
+ >
+ {items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+ );
+};
+
+const parseLinearIssueQuery = (value: string): string | null => {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ const urlMatch = trimmed.match(/linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i);
+ if (urlMatch) return urlMatch[1].toUpperCase();
+ if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmed)) return trimmed.toUpperCase();
+ return null;
+};
+
+const toIssueSummary = (issue: LinearIssue): LinearIssueSummary => ({
+ id: issue.id,
+ identifier: issue.identifier,
+ title: issue.title,
+ url: issue.url,
+ state: issue.state,
+ assignee: issue.assignee,
+ team: issue.team,
+ priority: issue.priority,
+ labels: issue.labels,
+});
+
+const patchIssueInList = (issues: LinearIssueSummary[], next: LinearIssue): LinearIssueSummary[] => {
+ const summary = toIssueSummary(next);
+ return issues.map((issue) => (issue.id === next.id ? summary : issue));
+};
+
+export const LinearIssuesView: React.FC = () => {
+ const { t } = useI18n();
+ const { linear } = useRuntimeAPIs();
+ const linearAuthStatus = useLinearAuthStore((state) => state.status);
+ const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
+ const refreshStatus = useLinearAuthStore((state) => state.refreshStatus);
+ const setLinearAuthStatus = useLinearAuthStore((state) => state.setStatus);
+ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
+ const setSettingsPage = useUIStore((state) => state.setSettingsPage);
+ const listStatus = useUIStore((state) => state.linearIssueListStatus);
+ const listAssignee = useUIStore((state) => state.linearIssueListAssignee);
+ const listTeamId = useUIStore((state) => state.linearIssueListTeamId);
+ const listPriority = useUIStore((state) => state.linearIssueListPriority);
+ const linearIssueFocus = useUIStore((state) => state.linearIssueFocus);
+ const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
+ const setListStatus = useUIStore((state) => state.setLinearIssueListStatus);
+ const setListAssignee = useUIStore((state) => state.setLinearIssueListAssignee);
+ const setListTeamId = useUIStore((state) => state.setLinearIssueListTeamId);
+ const setListPriority = useUIStore((state) => state.setLinearIssueListPriority);
+ const resetListFilters = useUIStore((state) => state.resetLinearIssueListFilters);
+ const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus);
+
+ const [query, setQuery] = React.useState('');
+ const [searchOpen, setSearchOpen] = React.useState(false);
+ const [issues, setIssues] = React.useState([]);
+ const [cursor, setCursor] = React.useState(null);
+ const [hasMore, setHasMore] = React.useState(false);
+ const [connected, setConnected] = React.useState(true);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [isLoadingMore, setIsLoadingMore] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const [selectedIssueId, setSelectedIssueId] = React.useState(null);
+ const [selectedIssue, setSelectedIssue] = React.useState(null);
+ const [workflowStates, setWorkflowStates] = React.useState([]);
+ const [isLoadingIssue, setIsLoadingIssue] = React.useState(false);
+ const [isUpdating, setIsUpdating] = React.useState(false);
+ const [isStarting, setIsStarting] = React.useState(false);
+ const [createInWorktree, setCreateInWorktree] = React.useState(false);
+ const [teams, setTeams] = React.useState([]);
+ const [isSwitchingWorkspace, setIsSwitchingWorkspace] = React.useState(false);
+ const listRequestId = React.useRef(0);
+ const listRootRef = React.useRef(null);
+ const searchInputRef = React.useRef(null);
+ const [panelWidth, setPanelWidth] = React.useState(0);
+
+ const directIdentifier = React.useMemo(() => parseLinearIssueQuery(query), [query]);
+ const debouncedQuery = useDebouncedValue(query, 350);
+
+ // Same shape the pull request panel uses, so both context surfaces read alike.
+ const formatCommentTimestamp = React.useCallback((value: string | null) => {
+ if (!value) return '';
+ const timestamp = Date.parse(value);
+ if (!Number.isFinite(timestamp)) return '';
+ return formatDateTimeForPreference(timestamp, timeFormatPreference, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ }, [timeFormatPreference]);
+
+ const openLinearSettings = React.useCallback(() => {
+ setSettingsPage('integrations');
+ setSettingsDialogOpen(true);
+ }, [setSettingsDialogOpen, setSettingsPage]);
+
+ const listQuery = React.useMemo(() => ({
+ query: debouncedQuery.trim() || undefined,
+ status: listStatus,
+ assignee: listAssignee,
+ teamId: listTeamId === LINEAR_ISSUE_LIST_ALL_TEAMS ? undefined : listTeamId,
+ priority: listPriority === 'all' ? undefined : listPriority,
+ }), [debouncedQuery, listAssignee, listPriority, listStatus, listTeamId]);
+
+ const workspaces = linearAuthStatus?.workspaces ?? [];
+ const currentWorkspaceId = workspaces.find((workspace) => workspace.current)?.id
+ || linearAuthStatus?.organization?.id
+ || '';
+
+ const refresh = React.useCallback(async () => {
+ if (linearAuthChecked && linearAuthStatus?.connected === false) {
+ setConnected(false);
+ setIssues([]);
+ setHasMore(false);
+ setCursor(null);
+ setError(null);
+ return;
+ }
+ if (!linear?.issuesList) {
+ setConnected(true);
+ setError(t('session.linearIssuePicker.error.runtimeUnavailable'));
+ return;
+ }
+
+ const requestId = listRequestId.current + 1;
+ listRequestId.current = requestId;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const next = await linear.issuesList(listQuery);
+ if (requestId !== listRequestId.current) return;
+ setConnected(next.connected !== false);
+ if (next.connected === false) {
+ setIssues([]);
+ setHasMore(false);
+ setCursor(null);
+ return;
+ }
+ setIssues(next.issues ?? []);
+ setCursor(next.cursor ?? null);
+ setHasMore(Boolean(next.hasMore));
+ } catch (e) {
+ if (requestId !== listRequestId.current) return;
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ if (requestId === listRequestId.current) {
+ setIsLoading(false);
+ }
+ }
+ }, [linear, linearAuthChecked, linearAuthStatus, listQuery, t]);
+
+ React.useEffect(() => {
+ if (linear && !linearAuthChecked) {
+ void refreshStatus(linear);
+ }
+ }, [linear, linearAuthChecked, refreshStatus]);
+
+ React.useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ React.useEffect(() => {
+ if (!linear?.mappingGet || !connected) {
+ setTeams([]);
+ return;
+ }
+ let cancelled = false;
+ void linear.mappingGet().then((mapping) => {
+ if (cancelled) return;
+ if (mapping.connected === false) {
+ setTeams([]);
+ return;
+ }
+ setTeams(mapping.teams ?? []);
+ }).catch(() => {
+ if (!cancelled) {
+ setTeams([]);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [connected, currentWorkspaceId, linear]);
+
+ React.useEffect(() => {
+ if (listTeamId === LINEAR_ISSUE_LIST_ALL_TEAMS || teams.length === 0) {
+ return;
+ }
+ if (!teams.some((team) => team.id === listTeamId)) {
+ setListTeamId(LINEAR_ISSUE_LIST_ALL_TEAMS);
+ }
+ }, [listTeamId, setListTeamId, teams]);
+
+ const loadMore = React.useCallback(async () => {
+ if (!linear?.issuesList) return;
+ if (isLoadingMore || isLoading) return;
+ if (!hasMore || !cursor) return;
+
+ const requestId = listRequestId.current + 1;
+ listRequestId.current = requestId;
+ setIsLoadingMore(true);
+ try {
+ const next = await linear.issuesList({
+ ...listQuery,
+ cursor,
+ });
+ if (requestId !== listRequestId.current) return;
+ setConnected(next.connected !== false);
+ if (next.connected === false) {
+ return;
+ }
+ setIssues((prev) => [...prev, ...(next.issues ?? [])]);
+ setCursor(next.cursor ?? null);
+ setHasMore(Boolean(next.hasMore));
+ } catch (e) {
+ if (requestId !== listRequestId.current) return;
+ const message = e instanceof Error ? e.message : String(e);
+ toast.error(t('session.linearIssuePicker.toast.loadMoreFailed'), { description: message });
+ } finally {
+ if (requestId === listRequestId.current) {
+ setIsLoadingMore(false);
+ }
+ }
+ }, [cursor, hasMore, isLoading, isLoadingMore, linear, listQuery, t]);
+
+ React.useEffect(() => {
+ if (!selectedIssueId || !linear?.issueGet) {
+ return;
+ }
+ let cancelled = false;
+ setIsLoadingIssue(true);
+ setSelectedIssue(null);
+ setWorkflowStates([]);
+ void (async () => {
+ try {
+ const issueRes = await linear.issueGet(selectedIssueId);
+ if (cancelled) return;
+ if (issueRes.connected === false) {
+ setConnected(false);
+ setSelectedIssueId(null);
+ return;
+ }
+ const issue = issueRes.issue;
+ if (!issue) {
+ toast.error(t('session.linearIssuePicker.error.issueNotFound'));
+ setSelectedIssueId(null);
+ return;
+ }
+ setSelectedIssue(issue);
+ const teamId = issue.team?.id;
+ if (!teamId || !linear.issueStates) {
+ return;
+ }
+ try {
+ const statesRes = await linear.issueStates(teamId);
+ if (cancelled) return;
+ if (statesRes.connected === false) {
+ setConnected(false);
+ return;
+ }
+ setWorkflowStates(statesRes.states ?? []);
+ } catch (e) {
+ if (cancelled) return;
+ const message = e instanceof Error ? e.message : String(e);
+ toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message });
+ }
+ } catch (e) {
+ if (cancelled) return;
+ const message = e instanceof Error ? e.message : String(e);
+ toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message });
+ setSelectedIssueId(null);
+ } finally {
+ if (!cancelled) {
+ setIsLoadingIssue(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [linear, selectedIssueId, t]);
+
+ React.useEffect(() => {
+ if (!linearIssueFocus) return;
+ setSelectedIssueId(linearIssueFocus);
+ setLinearIssueFocus(null);
+ }, [linearIssueFocus, setLinearIssueFocus]);
+
+ const applyUpdatedIssue = React.useCallback((issue: LinearIssue) => {
+ setSelectedIssue(issue);
+ setIssues((prev) => patchIssueInList(prev, issue));
+ }, []);
+
+ const updateIssueState = React.useCallback(async (stateId: string, failedKey: 'contextPanel.linear.toast.statusUpdateFailed' | 'contextPanel.linear.toast.closeFailed') => {
+ if (!linear?.issueUpdate || !selectedIssue || isUpdating) {
+ return;
+ }
+ if (selectedIssue.state?.id === stateId) {
+ return;
+ }
+ setIsUpdating(true);
+ try {
+ const result = await linear.issueUpdate({ id: selectedIssue.id, stateId });
+ if (result.connected === false) {
+ setConnected(false);
+ toast.error(t(failedKey));
+ return;
+ }
+ if (!result.issue) {
+ toast.error(t(failedKey));
+ return;
+ }
+ applyUpdatedIssue(result.issue);
+ toast.success(t('contextPanel.linear.toast.statusUpdated'));
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ toast.error(t(failedKey), { description: message });
+ } finally {
+ setIsUpdating(false);
+ }
+ }, [applyUpdatedIssue, isUpdating, linear, selectedIssue, t]);
+
+ const closeIssue = React.useCallback(() => {
+ const completed = workflowStates.find((state) => state.type === 'completed');
+ if (!completed) {
+ toast.error(t('contextPanel.linear.error.noCompletedState'));
+ return;
+ }
+ void updateIssueState(completed.id, 'contextPanel.linear.toast.closeFailed');
+ }, [t, updateIssueState, workflowStates]);
+
+ const startSession = React.useCallback(async () => {
+ if (!selectedIssue || isStarting) return;
+ setIsStarting(true);
+ try {
+ await startLinearIssueSession({
+ linear,
+ issueKey: selectedIssue.id,
+ createInWorktree,
+ t,
+ });
+ } finally {
+ setIsStarting(false);
+ }
+ }, [createInWorktree, isStarting, linear, selectedIssue, t]);
+
+ const switchWorkspace = React.useCallback(async (organizationId: string) => {
+ if (!linear?.authActivate || !organizationId || organizationId === currentWorkspaceId || isSwitchingWorkspace) {
+ return;
+ }
+ setIsSwitchingWorkspace(true);
+ try {
+ const payload = await linear.authActivate(organizationId);
+ setLinearAuthStatus(payload);
+ setSelectedIssueId(null);
+ setSelectedIssue(null);
+ setWorkflowStates([]);
+ setListTeamId(LINEAR_ISSUE_LIST_ALL_TEAMS);
+ toast.success(t('contextPanel.linear.toast.workspaceSwitched'));
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ toast.error(t('contextPanel.linear.toast.workspaceSwitchFailed'), { description: message });
+ } finally {
+ setIsSwitchingWorkspace(false);
+ }
+ }, [currentWorkspaceId, isSwitchingWorkspace, linear, setLinearAuthStatus, setListTeamId, t]);
+
+ const statusOptions = React.useMemo(() => {
+ const byId = new Map(workflowStates.map((state) => [state.id, state]));
+ const currentId = selectedIssue?.state?.id;
+ const currentName = selectedIssue?.state?.name;
+ const states = currentId && currentName && !byId.has(currentId)
+ ? [
+ {
+ id: currentId,
+ name: currentName,
+ type: selectedIssue.state?.type ?? null,
+ position: 0,
+ },
+ ...workflowStates,
+ ]
+ : workflowStates;
+ return [...states].sort(compareLinearWorkflowStates);
+ }, [selectedIssue, workflowStates]);
+
+ const completedState = workflowStates.find((state) => state.type === 'completed');
+ const alreadyCompleted = selectedIssue?.state?.type === 'completed';
+ const showDisconnected = linearAuthChecked && connected === false;
+ const runtimeMissing = !linear;
+ const showingDetail = Boolean(selectedIssueId);
+ const usingDefaultFilters = listStatus === 'all' && listAssignee === 'any' && listTeamId === LINEAR_ISSUE_LIST_ALL_TEAMS && listPriority === 'all';
+ const canUseListControls = Boolean(linear) && connected && !showDisconnected;
+ const filtersDisabled = !canUseListControls || isSwitchingWorkspace;
+ // Zero means the observer has not reported yet; assume there is room rather
+ // than rendering a compact filter row for one frame on every open.
+ const compactFilters = panelWidth > 0 && panelWidth < FILTER_COMPACT_WIDTH;
+ const searchActive = query.trim().length > 0;
+ const hasActiveFilters = !usingDefaultFilters || searchActive;
+ const showSearchField = !compactFilters || searchOpen || searchActive;
+
+ const closeCompactSearch = React.useCallback(() => {
+ setQuery('');
+ setSearchOpen(false);
+ }, []);
+
+ React.useEffect(() => {
+ const element = listRootRef.current;
+ if (!element || !globalThis.ResizeObserver) return;
+ const observer = new ResizeObserver((entries) => {
+ setPanelWidth(entries[0]?.contentRect.width ?? 0);
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [showingDetail]);
+
+ React.useEffect(() => {
+ if (compactFilters && searchOpen) {
+ searchInputRef.current?.focus();
+ }
+ }, [compactFilters, searchOpen]);
+
+ const worktreeToggle = (
+ setCreateInWorktree((value) => !value)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setCreateInWorktree((value) => !value);
+ }
+ }}
+ >
+ {
+ 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 ? (
+
+ ) : (
+
+ )}
+
+ {t('session.linearIssuePicker.actions.createInWorktree')}
+
+ );
+
+ const renderIssueRow = (issue: LinearIssueSummary) => (
+ setSelectedIssueId(issue.id)}
+ >
+
+ {issue.identifier}
+
+
+ {issue.title}
+
+
+ {
+ event.stopPropagation();
+ void openExternalUrl(issue.url);
+ }}
+ aria-label={t('session.linearIssuePicker.actions.openInLinearAria')}
+ >
+
+
+
+
+ );
+
+ if (showingDetail) {
+ const assigneeName = selectedIssue?.assignee?.displayName || selectedIssue?.assignee?.name;
+ const comments = selectedIssue?.comments ?? [];
+ const description = selectedIssue?.description?.trim() || '';
+ const statusValue = selectedIssue?.state?.id || '';
+ const priorityKey = linearPriorityMessageKey(selectedIssue?.priority);
+ const labels = selectedIssue?.labels ?? [];
+
+ return (
+
+
+ {
+ setSelectedIssueId(null);
+ setSelectedIssue(null);
+ setWorkflowStates([]);
+ }}
+ aria-label={t('contextPanel.linear.actions.backToList')}
+ >
+
+ {t('contextPanel.linear.actions.backToList')}
+
+ {selectedIssue ? (
+ void openExternalUrl(selectedIssue.url)}
+ aria-label={t('session.linearIssuePicker.actions.openInLinearAria')}
+ >
+
+
+ ) : null}
+
+
+ {isLoadingIssue && !selectedIssue ? (
+
+
+ {t('contextPanel.linear.loading.issue')}
+
+ ) : null}
+
+ {selectedIssue ? (
+
+
+ {t('contextPanel.linear.loading.issue')}
+
+ }>
+
+
+
{selectedIssue.identifier}
+
{selectedIssue.title}
+
+
+
+ {statusOptions.length > 0 && statusValue ? (
+ {
+ void updateIssueState(value, 'contextPanel.linear.toast.statusUpdateFailed');
+ }}
+ disabled={isUpdating}
+ >
+
+
+ {(value) => statusOptions.find((state) => state.id === value)?.name ?? value}
+
+
+
+ {statusOptions.map((state) => (
+
+ {state.name}
+
+ ))}
+
+
+ ) : selectedIssue.state?.name ? (
+ {selectedIssue.state.name}
+ ) : null}
+
+ {completedState && !alreadyCompleted ? (
+
+ {isUpdating ? : null}
+ {t('contextPanel.linear.actions.closeIssue')}
+
+ ) : null}
+
+
+
+ {selectedIssue.team?.name ? (
+ <>
+ {t('contextPanel.linear.label.team')}
+ {selectedIssue.team.name}
+ >
+ ) : null}
+ {t('contextPanel.linear.label.assignee')}
+
+ {assigneeName || t('contextPanel.linear.label.unassigned')}
+
+ {priorityKey ? (
+ <>
+ {t('contextPanel.linear.label.priority')}
+
+ {t(priorityKey)}
+
+ >
+ ) : null}
+ {labels.length > 0 ? (
+ <>
+ {t('contextPanel.linear.label.labels')}
+
+
+
+ >
+ ) : null}
+
+
+
+ {description ? (
+
+ ) : (
+
{t('contextPanel.linear.empty.noDescription')}
+ )}
+
+
+
+
{t('contextPanel.linear.label.comments')}
+ {comments.length === 0 ? (
+
{t('contextPanel.linear.empty.noComments')}
+ ) : (
+
+ {comments.map((comment, index) => {
+ const author = comment.user?.displayName
+ || comment.user?.name
+ || t('contextPanel.linear.label.unassigned');
+ const avatarUrl = comment.user?.avatarUrl || null;
+ const initial = author.charAt(0).toUpperCase();
+ const isLast = index === comments.length - 1;
+ const createdLabel = formatCommentTimestamp(comment.createdAt);
+ return (
+
+ {!isLast ? (
+
+ ) : null}
+
+ {avatarUrl ? (
+
+ ) : (
+
{initial}
+ )}
+
+
+
+ {author}
+ {createdLabel ? {createdLabel} : null}
+
+ {comment.body.trim() ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ ) : null}
+
+ {selectedIssue ? (
+
+ {worktreeToggle}
+ void startSession()}
+ disabled={isStarting || isUpdating}
+ className="w-full"
+ >
+ {isStarting ? : null}
+ {t('contextPanel.linear.actions.startSession')}
+
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
+ {showSearchField ? (
+
+
+ setQuery(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Escape' && compactFilters) {
+ event.preventDefault();
+ closeCompactSearch();
+ }
+ }}
+ className={cn('pl-9 w-full', compactFilters && 'pr-9')}
+ />
+ {compactFilters ? (
+
+
+
+ ) : null}
+
+ ) : null}
+
+ {canUseListControls || (compactFilters && !showSearchField) ? (
+
+ {canUseListControls ? (
+ <>
+ item.value === listStatus) ?? STATUS_FILTER_ITEMS[0]).labelKey)}
+ ariaLabel={t('contextPanel.linear.filter.statusAria')}
+ value={listStatus}
+ active={listStatus !== 'all'}
+ disabled={filtersDisabled}
+ items={STATUS_FILTER_ITEMS.map((item) => ({
+ value: item.value,
+ label: t(item.labelKey),
+ }))}
+ onValueChange={(value) => {
+ if (isLinearIssueListStatus(value)) {
+ setListStatus(value);
+ }
+ }}
+ />
+
+ item.value === listPriority) ?? PRIORITY_FILTER_ITEMS[0]).labelKey)}
+ ariaLabel={t('contextPanel.linear.filter.priorityAria')}
+ value={listPriority}
+ active={listPriority !== 'all'}
+ disabled={filtersDisabled}
+ items={PRIORITY_FILTER_ITEMS.map((item) => ({
+ value: item.value,
+ label: t(item.labelKey),
+ }))}
+ onValueChange={(value) => {
+ if (isLinearIssueListPriority(value)) {
+ setListPriority(value);
+ }
+ }}
+ />
+
+ {
+ if (value === 'any' || value === 'me') {
+ setListAssignee(value);
+ }
+ }}
+ />
+
+ {teams.length > 0 ? (
+ team.id === listTeamId)?.name ?? listTeamId)
+ }
+ ariaLabel={t('contextPanel.linear.filter.teamAria')}
+ value={listTeamId}
+ active={listTeamId !== LINEAR_ISSUE_LIST_ALL_TEAMS}
+ disabled={filtersDisabled}
+ items={[
+ { value: LINEAR_ISSUE_LIST_ALL_TEAMS, label: t('contextPanel.linear.filter.team.all') },
+ ...teams.map((team) => ({ value: team.id, label: team.name })),
+ ]}
+ onValueChange={setListTeamId}
+ />
+ ) : null}
+
+ {workspaces.length > 1 && currentWorkspaceId ? (
+ workspace.id === currentWorkspaceId) ?? { id: currentWorkspaceId, name: null, urlKey: null })}
+ ariaLabel={t('contextPanel.linear.label.workspaceAria')}
+ value={currentWorkspaceId}
+ disabled={isSwitchingWorkspace}
+ items={workspaces.map((workspace) => ({
+ value: workspace.id,
+ label: workspaceLabel(workspace),
+ }))}
+ onValueChange={(value) => {
+ void switchWorkspace(value);
+ }}
+ />
+ ) : null}
+
+ {hasActiveFilters ? (
+ {
+ resetListFilters();
+ closeCompactSearch();
+ }}
+ >
+
+ {!compactFilters ? (
+ {t('contextPanel.linear.filter.clear')}
+ ) : null}
+
+ ) : null}
+ >
+ ) : null}
+
+ {compactFilters && !showSearchField ? (
+ setSearchOpen(true)}
+ >
+
+
+ ) : null}
+
+ ) : null}
+
+
+
+ {runtimeMissing ? (
+ {t('session.linearIssuePicker.empty.runtimeUnavailable')}
+ ) : null}
+
+ {isLoading && issues.length === 0 ? (
+
+
+ {t('session.linearIssuePicker.loading.issues')}
+
+ ) : null}
+
+ {showDisconnected ? (
+
+
{t('session.linearIssuePicker.empty.notConnected')}
+
+
+ {t('session.linearIssuePicker.actions.openSettings')}
+
+
+
+ ) : null}
+
+ {error ? (
+ {error}
+ ) : null}
+
+ {directIdentifier && linear && connected ? (
+ setSelectedIssueId(directIdentifier)}
+ >
+
+ {directIdentifier}
+
+
+ {t('session.linearIssuePicker.actions.useIssue', { identifier: directIdentifier })}
+
+
+ ) : null}
+
+ {issues.length === 0 && !isLoading && connected && linear ? (
+
+ {debouncedQuery.trim()
+ ? t('session.linearIssuePicker.empty.noIssuesFound')
+ : usingDefaultFilters
+ ? t('session.linearIssuePicker.empty.noOpenIssuesFound')
+ : t('contextPanel.linear.empty.noMatchingIssues')}
+
+ ) : null}
+
+ {issues.map(renderIssueRow)}
+
+ {hasMore && connected && linear ? (
+
+ void loadMore()}
+ disabled={isLoadingMore}
+ className={cn(
+ 'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
+ isLoadingMore && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
+ )}
+ >
+ {isLoadingMore ? (
+
+
+ {t('session.linearIssuePicker.loading.more')}
+
+ ) : (
+ t('session.linearIssuePicker.actions.loadMore')
+ )}
+
+
+ ) : null}
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/PullRequestView.tsx b/packages/ui/src/components/views/PullRequestView.tsx
index 9fc3facd..eb71c8fb 100644
--- a/packages/ui/src/components/views/PullRequestView.tsx
+++ b/packages/ui/src/components/views/PullRequestView.tsx
@@ -2,10 +2,11 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
+import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
-import { useGitStatus, useGitBranches, useGitStore } from '@/stores/useGitStore';
+import { useGitStatus, useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useShallow } from 'zustand/react/shallow';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { getRuntimeKey } from '@/lib/runtime-switch';
@@ -15,6 +16,8 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { PullRequestSection } from './git/PullRequestSection';
+import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates';
+import { NestedRepoPicker } from './git/NestedRepoPicker';
import { GitHubIssuesSection } from './git/GitHubIssuesSection';
import { deriveBaseBranch } from './git/baseBranch';
@@ -38,9 +41,17 @@ export const PullRequestView: React.FC = () => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
- const status = useGitStatus(currentDirectory ?? null);
- const branches = useGitBranches(currentDirectory ?? null);
- const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll })));
+ // When the root is not itself a repository, the pull-request workflow
+ // operates on the resolved nested repository instead.
+ const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(currentDirectory ?? null);
+ const status = useGitStatus(gitDirectory ?? null);
+ const branches = useGitBranches(gitDirectory ?? null);
+ const isGitRepo = useIsGitRepo(gitDirectory ?? null);
+ const { ensureAll, ensureNestedRepos, selectNestedRepo } = useGitStore(useShallow((state) => ({
+ ensureAll: state.ensureAll,
+ ensureNestedRepos: state.ensureNestedRepos,
+ selectNestedRepo: state.selectNestedRepo,
+ })));
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -91,11 +102,11 @@ export const PullRequestView: React.FC = () => {
const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined);
React.useEffect(() => {
- if (!currentDirectory || !git) {
+ if (!gitDirectory || !git) {
return;
}
- void ensureAll(currentDirectory, git);
- }, [currentDirectory, ensureAll, git]);
+ void ensureAll(gitDirectory, git);
+ }, [gitDirectory, ensureAll, git]);
const [rootBranchHint, setRootBranchHint] = React.useState
(null);
React.useEffect(() => {
@@ -124,52 +135,52 @@ export const PullRequestView: React.FC = () => {
}, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]);
const [remotes, setRemotes] = React.useState(() =>
- (currentDirectory ? remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? []
+ (gitDirectory ? remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? []
);
const [remoteUrl, setRemoteUrl] = React.useState(() =>
- (currentDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? null
+ (gitDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? null
);
React.useEffect(() => {
- if (!currentDirectory || !git?.getRemotes) {
+ if (!gitDirectory || !git?.getRemotes) {
setRemotes([]);
return;
}
- setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []);
+ setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []);
let cancelled = false;
- void git.getRemotes(currentDirectory)
+ void git.getRemotes(gitDirectory)
.then((remoteList) => {
if (cancelled) return;
- remotesCacheByDirectory.set(remoteCacheKey(currentDirectory), remoteList ?? []);
+ remotesCacheByDirectory.set(remoteCacheKey(gitDirectory), remoteList ?? []);
setRemotes(remoteList ?? []);
})
- .catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); });
+ .catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []); });
return () => {
cancelled = true;
};
- }, [currentDirectory, git]);
+ }, [gitDirectory, git]);
React.useEffect(() => {
- if (!currentDirectory || !git?.getRemoteUrl) {
+ if (!gitDirectory || !git?.getRemoteUrl) {
setRemoteUrl(null);
return;
}
- setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null);
+ setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null);
let cancelled = false;
- void git.getRemoteUrl(currentDirectory)
+ void git.getRemoteUrl(gitDirectory)
.then((url) => {
if (cancelled) return;
- remoteUrlCacheByDirectory.set(remoteCacheKey(currentDirectory), url);
+ remoteUrlCacheByDirectory.set(remoteCacheKey(gitDirectory), url);
setRemoteUrl(url);
})
- .catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); });
+ .catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null); });
return () => {
cancelled = true;
};
- }, [currentDirectory, git]);
+ }, [gitDirectory, git]);
const localBranches = React.useMemo(() => {
if (!branches?.all) return [];
@@ -261,6 +272,27 @@ export const PullRequestView: React.FC = () => {
return prEmptyState;
}
+ // Non-repo root: surface nested-repository resolution while the operating
+ // directory has not proven to be a repository (discovering, failed,
+ // unsupported, none found, or settling on the auto-selected one).
+ if (rootIsGitRepo === false && isGitRepo !== true) {
+ return (
+ {
+ void ensureNestedRepos(currentDirectory, { force: true });
+ }}
+ />
+ );
+ }
+
+ // Repository switcher for non-repo roots with discovered nested
+ // repositories; the pick is shared per root across git surfaces.
+ const showRepositoryPicker =
+ rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0;
+
return (
{
preventOverscroll
>
+ {showRepositoryPicker ? (
+
+ {
+ if (currentDirectory) selectNestedRepo(currentDirectory, repository);
+ }}
+ repositoryRoot={currentDirectory ?? undefined}
+ />
+
+ ) : null}
{
{activeTab === 'pr' ? (
currentBranch ? (
= ({ onClose, forceMobile
: }
{getPageTitle(page.slug)}
- {(page.slug === 'tunnel' || page.slug === 'integrations') && (
+ {page.slug === 'tunnel' && (
{t('settings.view.badge.beta')}
diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx
index 3cece850..2f71f554 100644
--- a/packages/ui/src/components/views/git/GitHeader.tsx
+++ b/packages/ui/src/components/views/git/GitHeader.tsx
@@ -12,6 +12,7 @@ import type { IconName } from "@/components/icon/icons";
import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions';
+import { NestedRepoPicker } from './NestedRepoPicker';
import type {
GitStatus,
GitIdentityProfile,
@@ -53,6 +54,13 @@ interface GitHeaderProps {
pullRequest?: GitHubPullRequest | null;
prChecks?: GitHubChecksSummary | null;
onOpenPullRequest?: () => void;
+ // Nested repository picker: shown when the Git tab operates on a repository
+ // nested inside a non-repository root. Options are absolute repository
+ // paths; `repositoryRoot` is the root those paths are relative to.
+ repositoryOptions?: string[];
+ selectedRepository?: string | null;
+ onSelectRepository?: (repository: string) => void;
+ repositoryRoot?: string;
gitLabMr?: GitLabMergeRequestSummary | null;
onOpenGitLabMr?: () => void;
giteaPr?: GiteaPullRequestSummary | null;
@@ -264,6 +272,10 @@ export const GitHeader: React.FC = ({
pullRequest,
prChecks,
onOpenPullRequest,
+ repositoryOptions,
+ selectedRepository,
+ onSelectRepository,
+ repositoryRoot,
gitLabMr,
onOpenGitLabMr,
giteaPr,
@@ -274,6 +286,8 @@ export const GitHeader: React.FC = ({
return null;
}
+ const repositoryOptionsForPicker = (repositoryOptions ?? []).filter(Boolean);
+
const managementButtons = (
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? (
@@ -488,7 +502,7 @@ export const GitHeader: React.FC
= ({
return (
-
+
{isWorktreeMode ? (
= ({
remotes={remotes}
/>
)}
+ {repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
+
+ ) : null}
{identityControl}
diff --git a/packages/ui/src/components/views/git/NestedRepoPicker.tsx b/packages/ui/src/components/views/git/NestedRepoPicker.tsx
new file mode 100644
index 00000000..69b009f4
--- /dev/null
+++ b/packages/ui/src/components/views/git/NestedRepoPicker.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+
+import { Icon } from '@/components/icon/Icon';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+} from '@/components/ui/select';
+import { useI18n } from '@/lib/i18n';
+
+type NestedRepoPickerProps = {
+ /** Discovered repository paths under the project root. */
+ repositories: string[];
+ /** Currently selected repository path (the operating directory). */
+ selectedRepository: string | null;
+ onSelectRepository: (repository: string) => void;
+ /** Root the repository paths are relative to for display labels. */
+ repositoryRoot?: string;
+};
+
+/**
+ * Repository switcher shown on git surfaces when a project root is not itself
+ * a git repository but nested repositories were discovered under it.
+ */
+export const NestedRepoPicker: React.FC
= ({
+ repositories,
+ selectedRepository,
+ onSelectRepository,
+ repositoryRoot,
+}) => {
+ const { t } = useI18n();
+
+ const relativePath = (repository: string): string => {
+ const rootPrefix = `${repositoryRoot ?? ''}/`;
+ return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository;
+ };
+
+ return (
+ {
+ if (value) {
+ onSelectRepository(value);
+ }
+ }}
+ >
+
+
+
+ {selectedRepository ? relativePath(selectedRepository) : ''}
+
+
+
+ {repositories.map((repository) => (
+
+ {relativePath(repository)}
+
+ ))}
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx b/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx
new file mode 100644
index 00000000..8e685852
--- /dev/null
+++ b/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { describe, expect, test } from 'bun:test';
+import { renderToStaticMarkup } from 'react-dom/server';
+
+import { I18nProvider } from '@/lib/i18n';
+
+import { NestedRepoResolutionStates } from './NestedRepoResolutionStates';
+
+const render = (props: React.ComponentProps): string =>
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+const baseProps = {
+ onRetryDiscovery: () => {},
+};
+
+describe('NestedRepoResolutionStates', () => {
+ test('renders nothing while the root has not probed as a non-repository', () => {
+ for (const rootIsGitRepo of [null, true] as const) {
+ const markup = render({ ...baseProps, rootIsGitRepo, resolvedIsGitRepo: null, nestedRepos: undefined });
+ expect(markup).toBe('');
+ }
+ });
+
+ test('renders nothing once the operating directory resolved as a repository', () => {
+ const markup = render({
+ ...baseProps,
+ rootIsGitRepo: false,
+ resolvedIsGitRepo: true,
+ nestedRepos: ['/root/one'],
+ });
+ expect(markup).toBe('');
+ });
+
+ test('shows the discovering state before discovery has run', () => {
+ const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: undefined });
+ expect(markup).toContain('Looking for Git repositories...');
+ });
+
+ test('shows the failure state with a retry when discovery failed', () => {
+ const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: null });
+ expect(markup).toContain('Could not scan for Git repositories');
+ expect(markup).toContain('Retry');
+ });
+
+ test('shows the plain not-a-repository state with no retry when unsupported', () => {
+ const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: 'unsupported' });
+ expect(markup).toContain('This directory is not a Git repository');
+ expect(markup).not.toContain('Retry');
+ });
+
+ test('treats an empty discovery like the not-a-repository state', () => {
+ const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: [] });
+ expect(markup).toContain('This directory is not a Git repository');
+ });
+
+ test('holds a checking state while repositories are found but unresolved', () => {
+ const markup = render({
+ ...baseProps,
+ rootIsGitRepo: false,
+ resolvedIsGitRepo: null,
+ nestedRepos: ['/root/one', '/root/two'],
+ });
+ expect(markup).toContain('Checking repository...');
+ });
+});
diff --git a/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx b/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx
new file mode 100644
index 00000000..3e9e1e6d
--- /dev/null
+++ b/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx
@@ -0,0 +1,96 @@
+import React from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Icon } from '@/components/icon/Icon';
+import { useI18n } from '@/lib/i18n';
+import type { NestedRepoDiscovery } from '@/stores/useGitStore';
+
+type NestedRepoResolutionStatesProps = {
+ /** Probe of the project root: `false` means nested resolution applies. */
+ rootIsGitRepo: boolean | null;
+ /**
+ * Probe of the directory the consumer operates on (root or selected nested
+ * repository). `true` means resolution succeeded and the consumer should
+ * render its own content.
+ */
+ resolvedIsGitRepo: boolean | null;
+ /** Discovery outcome for the root (`undefined` = not run yet). */
+ nestedRepos: NestedRepoDiscovery | undefined;
+ onRetryDiscovery: () => void;
+ /** Optional extra line under the not-a-repository description. */
+ emptyStateFooter?: React.ReactNode;
+};
+
+/**
+ * Shared empty/loading states for git surfaces while nested-repository
+ * resolution is pending, failed, or impossible. Renders null once resolution
+ * has finished — either the root is a repository or the operating directory
+ * probed as one — so the consumer can proceed into its own content.
+ *
+ * A runtime without the discovery route (VS Code) reports "unsupported": the
+ * honest state there is the plain not-a-repository empty state, without a
+ * retry that can never succeed.
+ */
+export const NestedRepoResolutionStates: React.FC = ({
+ rootIsGitRepo,
+ resolvedIsGitRepo,
+ nestedRepos,
+ onRetryDiscovery,
+ emptyStateFooter,
+}) => {
+ const { t } = useI18n();
+
+ if (rootIsGitRepo !== false) return null;
+ if (resolvedIsGitRepo === true) return null;
+
+ if (nestedRepos === undefined || nestedRepos === null) {
+ return (
+
+
+
+ {nestedRepos === null
+ ? t('gitView.empty.discoverFailed')
+ : t('gitView.empty.discoveringRepositories')}
+
+ {nestedRepos === null ? (
+
+
+ {t('gitView.empty.retryDiscovery')}
+
+ ) : null}
+
+ );
+ }
+
+ if (nestedRepos === 'unsupported' || nestedRepos.length === 0) {
+ return (
+
+
+
+ {t('gitView.empty.notGitRepository')}
+
+
+ {t('gitView.empty.notGitRepositoryDescription')}
+
+ {emptyStateFooter}
+
+ );
+ }
+
+ // Repositories were found and one is about to be auto-selected (or the
+ // selected repository is still probing) — hold a brief loading state.
+ return (
+
+
+
+ {t('gitView.loading.checkingRepository')}
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx
index 6b6d6a38..cfe62633 100644
--- a/packages/ui/src/components/views/git/PullRequestSection.tsx
+++ b/packages/ui/src/components/views/git/PullRequestSection.tsx
@@ -20,6 +20,7 @@ import { useDeviceInfo } from '@/lib/device';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { Icon } from "@/components/icon/Icon";
+import { GitHubAccountControl } from '@/components/github/GitHubAccountControl';
import { useUIStore } from '@/stores/useUIStore';
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
@@ -111,6 +112,10 @@ const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'op
};
const PR_ACTION_REFRESH_DELAYS_MS = [2_000, 5_000] as const;
+// A manual refresh keeps its spinner visible at least this long: the request
+// often answers from the server cache within a few milliseconds, and a
+// spinner that never reaches the screen reads as "the button did nothing".
+const PR_MANUAL_REFRESH_MIN_SPIN_MS = 600;
const branchToTitle = (branch: string): string => {
return branch
@@ -346,7 +351,7 @@ export const PullRequestSection: React.FC<{
const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime();
const openGitHubSettings = React.useCallback(() => {
- setSettingsPage('github');
+ setSettingsPage('integrations');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
@@ -1175,6 +1180,31 @@ export const PullRequestSection: React.FC<{
await refreshPrStatus(prStatusKey, options);
}, [prStatusKey, refreshPrStatus]);
+ const [isManualRefreshing, setIsManualRefreshing] = React.useState(false);
+ const manualRefreshMountedRef = React.useRef(true);
+ React.useEffect(() => {
+ manualRefreshMountedRef.current = true;
+ return () => {
+ manualRefreshMountedRef.current = false;
+ };
+ }, []);
+ const refreshManually = React.useCallback(async () => {
+ if (isManualRefreshing) return;
+ setIsManualRefreshing(true);
+ const startedAt = Date.now();
+ try {
+ await refresh({ force: true });
+ } finally {
+ const remaining = PR_MANUAL_REFRESH_MIN_SPIN_MS - (Date.now() - startedAt);
+ if (remaining > 0) {
+ await new Promise((resolve) => window.setTimeout(resolve, remaining));
+ }
+ if (manualRefreshMountedRef.current) {
+ setIsManualRefreshing(false);
+ }
+ }
+ }, [isManualRefreshing, refresh]);
+
const scheduleActionRefresh = React.useCallback(() => {
pendingActionRefreshTimersRef.current.forEach((timerId) => {
window.clearTimeout(timerId);
@@ -1555,7 +1585,10 @@ export const PullRequestSection: React.FC<{
return (
-
{t('gitView.pullRequest.title')}
+
+
{t('gitView.pullRequest.title')}
+
+
{t('gitView.pullRequest.availableOnFeatureBranches')}
@@ -1599,7 +1632,7 @@ export const PullRequestSection: React.FC<{
return (
-
+
{pr ? (
#{pr.number}
) : null}
-
- {isLoading ?
: null}
+
+ {pr && showWalkthroughAction ? (
+
{
+ requestWalkthroughSource(directory, { kind: 'pr', number: pr.number });
+ openContextSurface(directory, 'walkthrough');
+ }}
+ aria-label={t('walkthrough.action.open')}
+ >
+
+
+ {t('walkthrough.action.open')}
+
+
+ ) : null}
- void refresh({ force: true })}
+ void refreshManually()}
aria-label={t('gitView.pr.actions.refreshAria')}
>
-
-
+ {isLoading || isManualRefreshing
+ ?
+ : }
+
{t('gitView.pr.actions.refresh')}
+
{pr ? (
-
+
{prStatusText}
{checks ? (
@@ -1657,23 +1710,6 @@ export const PullRequestSection: React.FC<{
) : null}
- {showWalkthroughAction ? (
-
{
- requestWalkthroughSource(directory, { kind: 'pr', number: pr.number });
- openContextSurface(directory, 'walkthrough');
- }}
- aria-label={t('walkthrough.action.open')}
- >
-
-
- {t('walkthrough.action.open')}
-
-
- ) : null}
{canMerge && pr.draft && pr.state === 'open' ? (
diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx
index 287a1b43..02cad5be 100644
--- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx
+++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx
@@ -22,7 +22,7 @@ import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useConfigStore } from '@/stores/useConfigStore';
-import { useGitBranches, useGitStatus, useGitStore } from '@/stores/useGitStore';
+import { useGitBranches, useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import {
getFreshestPrStatusForBranch,
@@ -30,6 +30,7 @@ import {
useGitHubPrStatusStore,
} from '@/stores/useGitHubPrStatusStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useUIStore } from '@/stores/useUIStore';
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
import { cn } from '@/lib/utils';
@@ -39,9 +40,17 @@ import { WalkthroughStages } from './WalkthroughStages';
import { useWalkthroughStageProgress } from './useWalkthroughStageProgress';
import { WalkthroughStream } from './WalkthroughStream';
import { WalkthroughToc } from './WalkthroughToc';
+import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates';
+import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
interface WalkthroughViewProps {
directory: string;
+ /**
+ * The context panel keeps this view mounted but hidden via CSS, so work
+ * that should only run for a visible consumer has to be told. Defaults to
+ * true for mounts that have no visibility signal.
+ */
+ visible?: boolean;
}
const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working'];
@@ -76,11 +85,17 @@ const TOC_MAX_FRACTION = 0.5;
// pickers, 32px action, 36px arrows) read as misalignment, not hierarchy.
const HEADER_COMPACT_WIDTH = 680;
-export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
+export const WalkthroughView = ({ directory: rootDirectory, visible = true }: WalkthroughViewProps) => {
const { t, locale, locales, label } = useI18n();
const rootRef = useRef(null);
const [panelWidth, setPanelWidth] = useState(0);
+ // The walkthrough documents one repository. When the root is not itself a
+ // repository, that is the resolved nested repository; everything below keys
+ // off `directory`.
+ const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null, { enabled: visible });
+ const directory = gitDirectory ?? rootDirectory;
+
// Panel width, not viewport width: this surface is resizable independently of
// the window.
useEffect(() => {
@@ -502,9 +517,38 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
[activeLanguage, directory, generate, generateDisabled, source]
);
+ const isGitRepo = useIsGitRepo(gitDirectory || null);
+ const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos);
+ const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
+ // Non-repo root: surface nested-repository resolution while the operating
+ // directory has not proven to be a repository (discovering, failed,
+ // unsupported, none found, or settling on the auto-selected one).
+ if (rootIsGitRepo === false && isGitRepo !== true) {
+ return (
+ {
+ if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true });
+ }}
+ />
+ );
+ }
+
return (
+ {rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? (
+ {
+ if (rootDirectory) selectNestedRepo(rootDirectory, repository);
+ }}
+ repositoryRoot={rootDirectory ?? undefined}
+ />
+ ) : null}
{
const embeddedMode = embeddedParams?.get('themeMode');
const embeddedLightId = embeddedParams?.get('lightThemeId');
const embeddedDarkId = embeddedParams?.get('darkThemeId');
- const storedMode = localStorage.getItem('themeMode');
- const storedLightId = localStorage.getItem('lightThemeId');
- const storedDarkId = localStorage.getItem('darkThemeId');
- const legacyUseSystem = localStorage.getItem('useSystemTheme');
- const legacyThemeId = localStorage.getItem('selectedThemeId');
- const legacyVariant = localStorage.getItem('selectedThemeVariant');
+ // Scoped entry when present; otherwise a one-time seed from the superseded
+ // global keys (see resolveThemePreferencesForRuntime), so the first scoped
+ // write carries the last-known theme instead of defaults.
+ const resolvedPreferences = resolveThemePreferencesForRuntime(getRuntimeKey());
if (embeddedMode === 'light' || embeddedMode === 'dark' || embeddedMode === 'system') {
themeMode = embeddedMode;
- } else if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') {
- themeMode = storedMode;
- } else if (legacyUseSystem !== null) {
- const useSystem = legacyUseSystem === 'true';
- if (useSystem) {
- themeMode = 'system';
- } else if (legacyThemeId) {
- const legacyTheme = getThemeById(legacyThemeId);
- if (legacyTheme) {
- themeMode = legacyTheme.metadata.variant === 'dark' ? 'dark' : 'light';
- if (legacyTheme.metadata.variant === 'dark') {
- darkThemeId = legacyTheme.metadata.id;
- } else {
- lightThemeId = legacyTheme.metadata.id;
- }
- }
- }
- } else if (legacyVariant === 'light' || legacyVariant === 'dark') {
- themeMode = legacyVariant;
+ } else {
+ themeMode = resolvedPreferences.themeMode;
}
if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) {
lightThemeId = embeddedLightId.trim();
- } else if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) {
- lightThemeId = storedLightId.trim();
+ } else {
+ lightThemeId = resolvedPreferences.lightThemeId;
}
if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) {
darkThemeId = embeddedDarkId.trim();
- } else if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) {
- darkThemeId = storedDarkId.trim();
+ } else {
+ darkThemeId = resolvedPreferences.darkThemeId;
}
}
@@ -314,6 +301,9 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
customThemesRequestRef.current += 1;
setCustomThemes([]);
setCustomThemesLoading(false);
+ // Adopt the new instance's last-known theme immediately; the incoming
+ // settings sync refines it with the server's authoritative value.
+ setPreferences((prev) => adoptThemePreferencesForRuntime(detail.runtimeKey, prev));
void reloadCustomThemes();
}), [isVSCode, reloadCustomThemes]);
@@ -424,6 +414,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return;
}
+ writeThemePreferencesForRuntime(getRuntimeKey(), {
+ themeMode: preferences.themeMode,
+ lightThemeId: preferences.lightThemeId,
+ darkThemeId: preferences.darkThemeId,
+ });
+
+ // Cosmetic last-writer-wins hints for the pre-React splash shells
+ // (packages/web/index.html, mobile.html, mini-chat.html) and the Android
+ // status bar, which run before the scoped key can be read. Not part of the
+ // app's theme authority — the scoped entry and the per-instance server
+ // settings own that.
localStorage.setItem('themeMode', preferences.themeMode);
localStorage.setItem('lightThemeId', preferences.lightThemeId);
localStorage.setItem('darkThemeId', preferences.darkThemeId);
@@ -434,8 +435,6 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
);
- // Splash screen (packages/web/index.html) runs before the theme CSS vars load.
- // Persist just enough to theme it on next boot.
const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
@@ -459,37 +458,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return;
}
- if (event.key !== 'themeMode' && event.key !== 'lightThemeId' && event.key !== 'darkThemeId') {
- return;
- }
-
- setPreferences((prev) => {
- const nextModeRaw = localStorage.getItem('themeMode');
- const nextMode: ThemeMode =
- nextModeRaw === 'light' || nextModeRaw === 'dark' || nextModeRaw === 'system'
- ? nextModeRaw
- : prev.themeMode;
-
- const nextLightRaw = localStorage.getItem('lightThemeId');
- const nextLight = typeof nextLightRaw === 'string' && nextLightRaw.trim().length > 0
- ? nextLightRaw.trim()
- : prev.lightThemeId;
-
- const nextDarkRaw = localStorage.getItem('darkThemeId');
- const nextDark = typeof nextDarkRaw === 'string' && nextDarkRaw.trim().length > 0
- ? nextDarkRaw.trim()
- : prev.darkThemeId;
-
- if (nextMode === prev.themeMode && nextLight === prev.lightThemeId && nextDark === prev.darkThemeId) {
- return prev;
- }
-
- return {
- themeMode: nextMode,
- lightThemeId: nextLight,
- darkThemeId: nextDark,
- };
- });
+ setPreferences((prev) => resolveThemePreferencesFromStorageEvent(event.key, getRuntimeKey(), prev) ?? prev);
};
window.addEventListener('storage', handleStorage);
diff --git a/packages/ui/src/contexts/theme-storage.test.ts b/packages/ui/src/contexts/theme-storage.test.ts
new file mode 100644
index 00000000..ecba5ad0
--- /dev/null
+++ b/packages/ui/src/contexts/theme-storage.test.ts
@@ -0,0 +1,290 @@
+import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
+
+import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
+
+import {
+ adoptThemePreferencesForRuntime,
+ getThemePreferencesStorageKey,
+ 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();
+ Object.defineProperty(globalThis, 'localStorage', {
+ value: {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ values.set(key, value);
+ },
+ removeItem: (key: string) => {
+ values.delete(key);
+ },
+ clear: () => {
+ values.clear();
+ },
+ },
+ configurable: true,
+ writable: true,
+ });
+ createdLocalStorage = true;
+};
+
+beforeEach(() => {
+ if (typeof window === 'undefined') {
+ Object.defineProperty(globalThis, 'window', {
+ value: {},
+ configurable: true,
+ writable: true,
+ });
+ createdWindow = true;
+ }
+ ensureLocalStorage();
+ localStorage.clear();
+});
+
+afterAll(() => {
+ if (createdWindow) {
+ delete (globalThis as { window?: unknown }).window;
+ }
+ if (createdLocalStorage) {
+ delete (globalThis as { localStorage?: unknown }).localStorage;
+ }
+});
+
+const preferences = {
+ themeMode: 'dark' as const,
+ lightThemeId: 'light-theme',
+ darkThemeId: 'dark-theme',
+};
+
+describe('theme preference runtime scoping', () => {
+ test('keys differ per runtime', () => {
+ expect(getThemePreferencesStorageKey('runtime-a')).not.toBe(getThemePreferencesStorageKey('runtime-b'));
+ });
+
+ test('round-trips preferences for the same runtime', () => {
+ writeThemePreferencesForRuntime('runtime-a', preferences);
+
+ expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
+ });
+
+ test('a window on one instance never reads another instance theme', () => {
+ writeThemePreferencesForRuntime('runtime-a', preferences);
+
+ expect(readThemePreferencesForRuntime('runtime-b')).toBeNull();
+ });
+
+ test('latest write wins per runtime without cross-instance effects', () => {
+ writeThemePreferencesForRuntime('runtime-a', preferences);
+ writeThemePreferencesForRuntime('runtime-b', { themeMode: 'light', lightThemeId: 'other-light', darkThemeId: 'other-dark' });
+
+ expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
+ expect(readThemePreferencesForRuntime('runtime-b')).toEqual({
+ themeMode: 'light',
+ lightThemeId: 'other-light',
+ darkThemeId: 'other-dark',
+ });
+ });
+
+ test('malformed or invalid payloads are failure, not empty authority', () => {
+ localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json');
+ expect(readThemePreferencesForRuntime('runtime-a')).toBeNull();
+
+ localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), JSON.stringify({ themeMode: 'neon' }));
+ expect(readThemePreferencesForRuntime('runtime-a')).toBeNull();
+
+ localStorage.setItem(
+ getThemePreferencesStorageKey('runtime-a'),
+ JSON.stringify({ themeMode: 'dark', lightThemeId: '', darkThemeId: 'dark-theme' }),
+ );
+ expect(readThemePreferencesForRuntime('runtime-a')).toBeNull();
+ });
+
+ test('leaves the splash-hint and migration-seed globals untouched', () => {
+ localStorage.setItem('themeMode', 'dark');
+ localStorage.setItem('lightThemeId', 'light-theme');
+ localStorage.setItem('darkThemeId', 'dark-theme');
+ localStorage.setItem('useSystemTheme', 'false');
+ localStorage.setItem('selectedThemeId', 'dark-theme');
+ localStorage.setItem('selectedThemeVariant', 'dark');
+ localStorage.setItem('splashBgDark', '#0c0a09');
+ localStorage.setItem('splashFgDark', '#fafaf9');
+
+ writeThemePreferencesForRuntime('runtime-a', preferences);
+
+ // The scoped key owns the app theme; the global keys stay as cosmetic
+ // last-writer-wins hints for the pre-React splash shells and the Android
+ // status bar, and as the one-time migration seed for new runtimes.
+ expect(localStorage.getItem('themeMode')).toBe('dark');
+ expect(localStorage.getItem('lightThemeId')).toBe('light-theme');
+ expect(localStorage.getItem('darkThemeId')).toBe('dark-theme');
+ expect(localStorage.getItem('useSystemTheme')).toBe('false');
+ expect(localStorage.getItem('selectedThemeId')).toBe('dark-theme');
+ expect(localStorage.getItem('selectedThemeVariant')).toBe('dark');
+ expect(localStorage.getItem('splashBgDark')).toBe('#0c0a09');
+ expect(localStorage.getItem('splashFgDark')).toBe('#fafaf9');
+ expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
+ });
+});
+
+describe('theme preference resolution chain', () => {
+ test('uses the scoped entry when present', () => {
+ writeThemePreferencesForRuntime('runtime-a', preferences);
+
+ expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
+ });
+
+ test('seeds from the legacy mode and theme ids when no scoped entry exists', () => {
+ localStorage.setItem('themeMode', 'dark');
+ localStorage.setItem('lightThemeId', 'legacy-light');
+ localStorage.setItem('darkThemeId', 'legacy-dark');
+
+ expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({
+ themeMode: 'dark',
+ lightThemeId: 'legacy-light',
+ darkThemeId: 'legacy-dark',
+ });
+ });
+
+ test('seeds from the useSystemTheme/selectedThemeId legacy chain', () => {
+ localStorage.setItem('useSystemTheme', 'false');
+ localStorage.setItem('selectedThemeId', DEFAULT_DARK_THEME_ID);
+
+ expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({
+ themeMode: 'dark',
+ lightThemeId: DEFAULT_LIGHT_THEME_ID,
+ darkThemeId: DEFAULT_DARK_THEME_ID,
+ });
+ });
+
+ test('falls back to defaults when nothing is stored', () => {
+ expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({
+ themeMode: 'system',
+ lightThemeId: DEFAULT_LIGHT_THEME_ID,
+ darkThemeId: DEFAULT_DARK_THEME_ID,
+ });
+ });
+
+ test('the migrated seed survives into the scoped key while the seed globals stay', () => {
+ localStorage.setItem('themeMode', 'dark');
+ localStorage.setItem('lightThemeId', 'legacy-light');
+ localStorage.setItem('darkThemeId', 'legacy-dark');
+
+ writeThemePreferencesForRuntime('runtime-a', resolveThemePreferencesForRuntime('runtime-a'));
+
+ expect(readThemePreferencesForRuntime('runtime-a')).toEqual({
+ themeMode: 'dark',
+ lightThemeId: 'legacy-light',
+ darkThemeId: 'legacy-dark',
+ });
+ expect(localStorage.getItem('themeMode')).toBe('dark');
+ expect(localStorage.getItem('lightThemeId')).toBe('legacy-light');
+ expect(localStorage.getItem('darkThemeId')).toBe('legacy-dark');
+ });
+});
+
+describe('runtime-switch adoption', () => {
+ const current = { themeMode: 'dark' as const, lightThemeId: 'current-light', darkThemeId: 'current-dark' };
+
+ test('adopts the target runtime stored theme when one exists', () => {
+ writeThemePreferencesForRuntime('runtime-b', preferences);
+
+ expect(adoptThemePreferencesForRuntime('runtime-b', current)).toEqual(preferences);
+ });
+
+ test('keeps the current preferences — same reference — when the target runtime has no entry', () => {
+ expect(adoptThemePreferencesForRuntime('runtime-empty', current)).toBe(current);
+ });
+});
+
+describe('transient runtime keys', () => {
+ test('uninitialized and disconnected runtime keys are transient', () => {
+ expect(isTransientRuntimeKey('url:default')).toBe(true);
+ expect(isTransientRuntimeKey('mobile-disconnected')).toBe(true);
+ expect(isTransientRuntimeKey('')).toBe(true);
+ expect(isTransientRuntimeKey('local')).toBe(false);
+ expect(isTransientRuntimeKey('url:https://host.example')).toBe(false);
+ });
+
+ test('writes are skipped for transient runtimes — no stale cold-boot theme gets pinned', () => {
+ writeThemePreferencesForRuntime('url:default', preferences);
+ writeThemePreferencesForRuntime('mobile-disconnected', preferences);
+
+ expect(readThemePreferencesForRuntime('url:default')).toBeNull();
+ expect(readThemePreferencesForRuntime('mobile-disconnected')).toBeNull();
+ expect(localStorage.getItem(getThemePreferencesStorageKey('url:default'))).toBeNull();
+ });
+
+ test('reads never surface an entry under a transient key', () => {
+ localStorage.setItem(getThemePreferencesStorageKey('url:default'), JSON.stringify(preferences));
+
+ expect(readThemePreferencesForRuntime('url:default')).toBeNull();
+ });
+
+ test('boot resolution falls back to the global splash hints for transient runtimes', () => {
+ localStorage.setItem('themeMode', 'light');
+ localStorage.setItem('lightThemeId', 'legacy-light');
+ localStorage.setItem('darkThemeId', 'legacy-dark');
+
+ expect(resolveThemePreferencesForRuntime('url:default')).toEqual({
+ themeMode: 'light',
+ lightThemeId: 'legacy-light',
+ darkThemeId: 'legacy-dark',
+ });
+ });
+
+ test('endpoint-switch adoption keeps current preferences for transient runtimes', () => {
+ const current = { themeMode: 'light' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' };
+
+ expect(adoptThemePreferencesForRuntime('mobile-disconnected', current)).toBe(current);
+ });
+});
+
+describe('theme storage event resolution', () => {
+ const current = { themeMode: 'system' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' };
+
+ test('adopts a storage event for the current runtime', () => {
+ writeThemePreferencesForRuntime('runtime-a', preferences);
+
+ expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toEqual(preferences);
+ });
+
+ test('ignores a storage event from another runtime', () => {
+ writeThemePreferencesForRuntime('runtime-b', preferences);
+
+ expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-b'), 'runtime-a', current)).toBeNull();
+ });
+
+ test('ignores legacy global theme keys (revert-to-globals regression guard)', () => {
+ localStorage.setItem('themeMode', 'dark');
+ localStorage.setItem('lightThemeId', 'light-theme');
+ localStorage.setItem('darkThemeId', 'dark-theme');
+
+ expect(resolveThemePreferencesFromStorageEvent('themeMode', 'runtime-a', current)).toBeNull();
+ expect(resolveThemePreferencesFromStorageEvent('lightThemeId', 'runtime-a', current)).toBeNull();
+ expect(resolveThemePreferencesFromStorageEvent('darkThemeId', 'runtime-a', current)).toBeNull();
+ });
+
+ test('resolves to no change when stored preferences already match', () => {
+ writeThemePreferencesForRuntime('runtime-a', preferences);
+
+ expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', preferences)).toBeNull();
+ });
+
+ test('resolves to no change when nothing valid is stored', () => {
+ expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull();
+
+ localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json');
+ expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull();
+ });
+});
diff --git a/packages/ui/src/contexts/theme-storage.ts b/packages/ui/src/contexts/theme-storage.ts
new file mode 100644
index 00000000..863b9efc
--- /dev/null
+++ b/packages/ui/src/contexts/theme-storage.ts
@@ -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 | 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;
diff --git a/packages/ui/src/hooks/useAgentMemorySync.ts b/packages/ui/src/hooks/useAgentMemorySync.ts
index 24abdcca..c077dc25 100644
--- a/packages/ui/src/hooks/useAgentMemorySync.ts
+++ b/packages/ui/src/hooks/useAgentMemorySync.ts
@@ -23,6 +23,8 @@ import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
* 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.
*/
+const AGENT_MEMORY_FRESH_MS = 60_000;
+
export const useAgentMemorySync = (directory: string | null): void => {
const enabled = useUIStore((state) => (
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
@@ -31,11 +33,14 @@ export const useAgentMemorySync = (directory: string | null): void => {
const owner = useProjectContextOwner(directory);
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(() => {
if (!enabled) {
return;
}
- void load(projectPath);
+ void load(projectPath, { maxAgeMs: AGENT_MEMORY_FRESH_MS });
}, [enabled, load, projectPath]);
// The agent writes memory mid-turn through its own tool, so the index for the
diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts
index 414e1981..3a3c7edc 100644
--- a/packages/ui/src/hooks/useAssistantStatus.ts
+++ b/packages/ui/src/hooks/useAssistantStatus.ts
@@ -1,4 +1,5 @@
import React from 'react';
+import { useChatColumnSession } from '@/components/chat/chatColumnSession';
import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2';
import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
@@ -301,8 +302,14 @@ export const getActiveAssistantContext = (messages: Message[]): ActiveAssistantC
};
export function useAssistantStatus(): AssistantStatusSnapshot {
- const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
- const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
+ // Inside the chat column, follow the session the timeline shows rather
+ // than the live selection, so the status chip changes together with the
+ // conversation instead of a commit ahead of it.
+ const chatColumnSession = useChatColumnSession();
+ const liveSessionId = useSessionUIStore((state) => state.currentSessionId);
+ const liveSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
+ const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId;
+ const currentSessionDirectory = chatColumnSession ? chatColumnSession.directory : liveSessionDirectory;
const rawSessionMessages = useSessionMessages(
currentSessionId ?? '',
diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts
index 5b8d8606..0fb88e51 100644
--- a/packages/ui/src/hooks/useChatTimelineScroll.ts
+++ b/packages/ui/src/hooks/useChatTimelineScroll.ts
@@ -4,6 +4,7 @@ import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore } from '@/sync/viewport-store';
import { useUIStore } from '@/stores/useUIStore';
+import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
@@ -76,9 +77,20 @@ interface UseChatTimelineScrollOptions {
// 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.
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;
}
+
+
export interface UseChatTimelineScrollResult {
scrollRef: React.RefObject;
// The live scroll element, as state, so effects that must re-bind when the
@@ -122,8 +134,12 @@ export const useChatTimelineScroll = ({
sessionMessageCount,
composerOverlayHeight,
lastUserMessageId,
+ sessionIsWorking,
+ revealGate = null,
onActiveTurnChange,
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
+ const sessionIsWorkingRef = React.useRef(sessionIsWorking);
+ sessionIsWorkingRef.current = sessionIsWorking;
const scrollRef = React.useRef(null);
const listRef = React.useRef(null);
@@ -633,6 +649,10 @@ export const useChatTimelineScroll = ({
const end = node.scrollHeight - node.clientHeight;
const distance = end - node.scrollTop;
if (distance <= 1) return;
+ if (!sessionIsWorkingRef.current) {
+ node.scrollTop = end;
+ return;
+ }
if (distance > node.clientHeight) {
node.scrollTop = end - node.clientHeight;
}
@@ -864,6 +884,61 @@ export const useChatTimelineScroll = ({
};
}, [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 ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef(null);
React.useEffect(() => {
diff --git a/packages/ui/src/hooks/useEffectiveDirectory.ts b/packages/ui/src/hooks/useEffectiveDirectory.ts
index 1b783536..d5c6de23 100644
--- a/packages/ui/src/hooks/useEffectiveDirectory.ts
+++ b/packages/ui/src/hooks/useEffectiveDirectory.ts
@@ -3,6 +3,7 @@ import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useSessionDirectory } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
+import { getChatsRootForHome } from '@/lib/chatDirectories';
/**
* 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)
* 2. Session directory (for active sessions)
* 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
* 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 worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
+ const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
// If we have an active session, use its directory
if (currentSessionId) {
@@ -44,6 +49,11 @@ export const useEffectiveDirectory = (): string | 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
return fallbackDirectory ?? undefined;
};
diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts
index e1986f99..1618dfc3 100644
--- a/packages/ui/src/hooks/useKeyboardShortcuts.ts
+++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts
@@ -31,6 +31,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useGitProvider } from '@/lib/gitProvider';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
+import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
@@ -56,10 +57,10 @@ export const useKeyboardShortcuts = () => {
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const effectiveDirectory = useEffectiveDirectory();
+ const activeProject = useProjectsStore((s) => s.getActiveProject());
// Mirrors the rail's provider-aware 'pr' surface: the digit-shortcut list
// must agree with the rail on whether the PR/MR surface is visible.
const gitProvider = useGitProvider(effectiveDirectory);
- const activeProject = useProjectsStore((s) => s.getActiveProject());
const { themeMode, setThemeMode } = useThemeSystem();
const { phase: sessionPhase } = useCurrentSessionActivity();
const abortPrimedUntilRef = React.useRef(null);
@@ -509,6 +510,7 @@ export const useKeyboardShortcuts = () => {
isVSCode: isVSCodeRuntime(),
screenWidth: window.innerWidth,
tabs: panel?.tabs ?? [],
+ linearConnected: useLinearAuthStore.getState().status?.connected === true,
gitProvider,
});
const target = visibleSurfaces[switchSurfaceDigit - 1];
diff --git a/packages/ui/src/hooks/useLocalTTS.ts b/packages/ui/src/hooks/useLocalTTS.ts
index 307356a2..0efc1a46 100644
--- a/packages/ui/src/hooks/useLocalTTS.ts
+++ b/packages/ui/src/hooks/useLocalTTS.ts
@@ -14,10 +14,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
export interface LocalTTSSpeakOptions {
- /** Kokoro speaker id (0-10) */
+ /** Catalog id of the local model to use; defaults to the server's default model. */
+ model?: string;
+ /** Speaker id within the model (Kokoro voices; Piper models have one) */
speakerId?: number;
/** Playback speed multiplier (1.0 = normal) */
speed?: number;
+ /**
+ * `'auto'`: the server picks a model and voice for the text's language.
+ * The language is judged on the whole message, not on each chunk sent for
+ * synthesis, so a short chunk cannot flip the voice mid-reply.
+ */
+ language?: 'auto';
onStart?: () => void;
onEnd?: () => void;
onError?: (error: string) => void;
@@ -35,6 +43,8 @@ export interface UseLocalTTSReturn {
/** Target chunk size: big enough to amortize requests, small enough for low latency. */
const MIN_CHUNK_CHARS = 60;
const MAX_CHUNK_CHARS = 400;
+// Enough of the message for language detection to see whole sentences.
+const LANGUAGE_SAMPLE_CHARS = 2000;
/**
* Split text into sentence-aligned chunks for pipelined synthesis.
@@ -170,6 +180,7 @@ export function useLocalTTS(): UseLocalTTSReturn {
const session: PlaybackSession = { cancelled: false, abort: new AbortController() };
sessionRef.current = session;
+ const languageSample = options?.language === 'auto' ? text.slice(0, LANGUAGE_SAMPLE_CHARS) : undefined;
const fetchChunk = async (chunk: string): Promise => {
const response = await runtimeFetch('/api/dictation/tts/speak', {
@@ -177,8 +188,11 @@ export function useLocalTTS(): UseLocalTTSReturn {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: chunk,
+ model: options?.model,
...(typeof options?.speakerId === 'number' ? { speakerId: options.speakerId } : {}),
...(typeof options?.speed === 'number' ? { speed: options.speed } : {}),
+ language: options?.language,
+ languageSample,
}),
signal: session.abort.signal,
});
diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts
index 88e10615..4950e320 100644
--- a/packages/ui/src/hooks/useMessageTTS.ts
+++ b/packages/ui/src/hooks/useMessageTTS.ts
@@ -61,6 +61,8 @@ export function useMessageTTS(): UseMessageTTSReturn {
const speechVolume = useConfigStore((state) => state.speechVolume);
const sayVoice = useConfigStore((state) => state.sayVoice);
const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId);
+ const localTtsModelId = useConfigStore((state) => state.localTtsModelId);
+ const ttsFollowTextLanguage = useConfigStore((state) => state.ttsFollowTextLanguage);
const browserVoice = useConfigStore((state) => state.browserVoice);
const openaiVoice = useConfigStore((state) => state.openaiVoice);
const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice);
@@ -135,8 +137,10 @@ export function useMessageTTS(): UseMessageTTSReturn {
});
} else if (voiceProvider === 'local') {
await speakLocalTTS(sanitizedText, {
+ model: localTtsModelId,
speakerId: localTtsVoiceId,
speed: speechRate,
+ language: ttsFollowTextLanguage ? 'auto' : undefined,
onEnd: () => setIsPlaying(false),
onError: () => setIsPlaying(false),
});
@@ -145,6 +149,7 @@ export function useMessageTTS(): UseMessageTTSReturn {
await speakSayTTS(sanitizedText, {
voice: sayVoice,
rate: wordsPerMinute,
+ language: ttsFollowTextLanguage ? 'auto' : undefined,
onEnd: () => setIsPlaying(false),
onError: () => setIsPlaying(false),
});
@@ -187,6 +192,8 @@ export function useMessageTTS(): UseMessageTTSReturn {
speakSayTTS,
speakLocalTTS,
localTtsVoiceId,
+ localTtsModelId,
+ ttsFollowTextLanguage,
stop,
]);
diff --git a/packages/ui/src/hooks/useNestedGitDirectory.ts b/packages/ui/src/hooks/useNestedGitDirectory.ts
new file mode 100644
index 00000000..a43bce53
--- /dev/null
+++ b/packages/ui/src/hooks/useNestedGitDirectory.ts
@@ -0,0 +1,119 @@
+import React from 'react';
+import { useShallow } from 'zustand/react/shallow';
+
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import {
+ useEffectiveGitDirectory,
+ useGitStore,
+ useIsGitRepo,
+ useNestedRepoSelection,
+ useNestedRepos,
+ useStaleClearedSelections,
+} from '@/stores/useGitStore';
+
+type UseNestedGitDirectoryOptions = {
+ /** False defers all probing/discovery work while the surface is hidden. */
+ enabled?: boolean;
+};
+
+/**
+ * Resolves the repository a git surface operates on when the project root may
+ * not itself be a git repository. Owns the full resolution flow: probing the
+ * root, discovering nested repositories, auto-selecting the first one, and
+ * dropping a selection whose repository disappeared.
+ *
+ * Consumers still fetch their own git data for the returned `gitDirectory`;
+ * this hook only owns who that directory is.
+ */
+export const useNestedGitDirectory = (
+ root: string | null,
+ options: UseNestedGitDirectoryOptions = {},
+) => {
+ const { enabled = true } = options;
+ const { git } = useRuntimeAPIs();
+
+ const rootIsGitRepo = useIsGitRepo(root);
+ const gitDirectory = useEffectiveGitDirectory(root);
+ const nestedRepos = useNestedRepos(root);
+ const nestedRepoSelection = useNestedRepoSelection(root);
+ const staleClearedSelections = useStaleClearedSelections(root);
+
+ // Probe of the resolved repository, used to detect a stale selection. Null
+ // when there is nothing selected to probe.
+ const selectedIsGitRepo = useIsGitRepo(
+ gitDirectory && gitDirectory !== root ? gitDirectory : null,
+ );
+
+ const { ensureStatus, ensureNestedRepos, selectNestedRepo, clearNestedRepoSelection } = useGitStore(
+ useShallow((state) => ({
+ ensureStatus: state.ensureStatus,
+ ensureNestedRepos: state.ensureNestedRepos,
+ selectNestedRepo: state.selectNestedRepo,
+ clearNestedRepoSelection: state.clearNestedRepoSelection,
+ })),
+ );
+
+ // Probe the root itself so nested-repo resolution never depends on some
+ // other surface (e.g. the sidebar badge) having probed it first.
+ React.useEffect(() => {
+ if (!enabled || !root) return;
+ if (rootIsGitRepo !== null) return;
+ void ensureStatus(root, git);
+ }, [enabled, ensureStatus, git, root, rootIsGitRepo]);
+
+ // Discover nested repositories once the root probe confirms it is not one.
+ React.useEffect(() => {
+ if (!enabled || !root) return;
+ if (rootIsGitRepo !== false) return;
+ void ensureNestedRepos(root);
+ }, [enabled, ensureNestedRepos, root, rootIsGitRepo]);
+
+ // Auto-select the first nested repository so the surface opens straight
+ // into repository data; a picker (where rendered) switches between them.
+ // Repositories whose selection already failed a probe are skipped: without
+ // this, a corrupt repository (discovered via its .git entry but failing
+ // git status) would be re-picked right after every stale-clear and loop
+ // discovery + probe while the surface is visible. When every candidate has
+ // failed, no selection is made — surfaces settle into their unresolved
+ // state instead of churning requests. A manual picker pick is still free
+ // to select anything; it gets probed like any other.
+ React.useEffect(() => {
+ if (!enabled || !root) return;
+ if (rootIsGitRepo !== false) return;
+ if (!Array.isArray(nestedRepos) || nestedRepos.length === 0) return;
+ if (nestedRepoSelection) return;
+ const candidates = staleClearedSelections
+ ? nestedRepos.filter((repository) => !staleClearedSelections.has(repository))
+ : nestedRepos;
+ if (candidates.length === 0) return;
+ selectNestedRepo(root, candidates[0]);
+ }, [
+ enabled,
+ nestedRepos,
+ nestedRepoSelection,
+ root,
+ rootIsGitRepo,
+ selectNestedRepo,
+ staleClearedSelections,
+ ]);
+
+ // A selected repository that is no longer a git repository is stale: drop
+ // the selection and re-scan so resolution reflects the current tree.
+ React.useEffect(() => {
+ if (!enabled || !root || !nestedRepoSelection) return;
+ if (!gitDirectory || gitDirectory === root) return;
+ if (selectedIsGitRepo !== false) return;
+ clearNestedRepoSelection(root);
+ void ensureNestedRepos(root, { force: true });
+ }, [
+ clearNestedRepoSelection,
+ enabled,
+ ensureNestedRepos,
+ gitDirectory,
+ nestedRepoSelection,
+ root,
+ selectedIsGitRepo,
+ ]);
+
+ return { rootIsGitRepo, gitDirectory, nestedRepos, nestedRepoSelection };
+};
diff --git a/packages/ui/src/hooks/usePwaManifestSync.ts b/packages/ui/src/hooks/usePwaManifestSync.ts
index d0c01deb..b834d377 100644
--- a/packages/ui/src/hooks/usePwaManifestSync.ts
+++ b/packages/ui/src/hooks/usePwaManifestSync.ts
@@ -14,6 +14,7 @@ type ManifestSyncWindow = Window & {
};
const MAX_RECENT_SHORTCUTS = 3;
+const MANIFEST_UPDATE_DELAY_MS = 2_000;
const normalizeRecentTitle = (value: string | undefined, fallback: string): string => {
if (typeof value !== 'string') {
@@ -86,7 +87,13 @@ export const usePwaManifestSync = () => {
return;
}
- const win = window as ManifestSyncWindow;
- win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
+ // Rebuilding the manifest fetches it from the server. Shortcuts only
+ // matter to the installed-app menu, so the rebuild waits until the switch
+ // that changed them has settled instead of adding a request to it.
+ const timer = window.setTimeout(() => {
+ const win = window as ManifestSyncWindow;
+ win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
+ }, MANIFEST_UPDATE_DELAY_MS);
+ return () => window.clearTimeout(timer);
}, [hasRecentShortcuts, signature]);
};
diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts
index 2379f9be..6ca3ae8a 100644
--- a/packages/ui/src/hooks/useRouter.ts
+++ b/packages/ui/src/hooks/useRouter.ts
@@ -2,6 +2,7 @@ import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore';
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
+import { openSessionFromRoute } from '@/lib/router/openSessionFromRoute';
import type { RouteState, AppRouteState } from '@/lib/router';
import { resolveSettingsSlug } from '@/lib/settings/metadata';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
@@ -48,7 +49,6 @@ export function useRouter(): void {
const isApplyingRouteRef = React.useRef(false);
// Get store actions (stable references)
- const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
@@ -67,11 +67,7 @@ export function useRouter(): void {
try {
// 1. Apply session first (may trigger async operations)
if (route.sessionId) {
- const currentSessionId = useSessionUIStore.getState().currentSessionId;
- if (route.sessionId !== currentSessionId) {
- const directoryHint = useSessionUIStore.getState().getDirectoryForSession(route.sessionId);
- setCurrentSession(route.sessionId, directoryHint);
- }
+ await openSessionFromRoute(route.sessionId);
}
// 2. Handle settings first because it is a full-screen overlay.
@@ -107,7 +103,7 @@ export function useRouter(): void {
isApplyingRouteRef.current = false;
}
},
- [setCurrentSession, setSettingsDialogOpen, setSettingsPage, navigateToDiff]
+ [setSettingsDialogOpen, setSettingsPage, navigateToDiff]
);
/**
diff --git a/packages/ui/src/hooks/useSayTTS.ts b/packages/ui/src/hooks/useSayTTS.ts
index c00353b1..f9108e6d 100644
--- a/packages/ui/src/hooks/useSayTTS.ts
+++ b/packages/ui/src/hooks/useSayTTS.ts
@@ -105,6 +105,8 @@ interface SpeakOptions {
voice?: string;
/** Speech rate in words per minute (defaults to 200) */
rate?: number;
+ /** `'auto'`: the server switches to a voice that speaks the text's language. */
+ language?: 'auto';
/** Callback when playback starts */
onStart?: () => void;
/** Callback when playback ends */
@@ -229,6 +231,7 @@ export function useSayTTS(options: UseSayTTSOptions = {}): UseSayTTSReturn {
text: text.trim(),
voice: options?.voice || 'Samantha',
rate: options?.rate || 200,
+ language: options?.language,
}),
signal: abortControllerRef.current.signal,
});
diff --git a/packages/ui/src/hooks/useSessionAssist.ts b/packages/ui/src/hooks/useSessionAssist.ts
index 09430efc..78c7f114 100644
--- a/packages/ui/src/hooks/useSessionAssist.ts
+++ b/packages/ui/src/hooks/useSessionAssist.ts
@@ -55,6 +55,8 @@ export interface SessionAssistState {
visibleRecap: string | null;
/** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */
suggestion: string | null;
+ /** False until the session record is in memory; the recap cannot be decided before that. */
+ sessionKnown: boolean;
}
export function useSessionAssistState(sessionId: string, directory?: string): SessionAssistState {
@@ -93,5 +95,6 @@ export function useSessionAssistState(sessionId: string, directory?: string): Se
assist,
visibleRecap: sessionRecapEnabled && assist && assist.recap && quietElapsed ? assist.recap : null,
suggestion: sessionSuggestionEnabled && assist && assist.suggestion ? assist.suggestion : null,
+ sessionKnown: session !== undefined && session !== null,
};
}
diff --git a/packages/ui/src/hooks/useSessionGoal.ts b/packages/ui/src/hooks/useSessionGoal.ts
index 729965c1..db7ebbfa 100644
--- a/packages/ui/src/hooks/useSessionGoal.ts
+++ b/packages/ui/src/hooks/useSessionGoal.ts
@@ -22,6 +22,9 @@ export function useSessionGoal(sessionId: string, directory?: string): SessionGo
};
}
+const OBJECTIVE_CONTENT_CACHE_MAX = 64;
+const objectiveContentByFetchKey = new Map>();
+
// Effective objective text for display. Inline goals return the metadata
// text directly; file-backed goals fetch the server-side file once per
// goal edit (keyed by id + updatedAt). Display-only: a failed fetch yields
@@ -37,7 +40,18 @@ export function useGoalObjectiveContent(sessionId: string, goal: SessionGoalPayl
return undefined;
}
let alive = true;
- void fetchGoalObjectiveContent(sessionId).then((content) => {
+ // The key already names the goal edit, so a remount (every session switch
+ // remounts the strip) reuses the text instead of fetching the file again.
+ let request = objectiveContentByFetchKey.get(fetchKey);
+ if (!request) {
+ request = fetchGoalObjectiveContent(sessionId);
+ objectiveContentByFetchKey.set(fetchKey, request);
+ if (objectiveContentByFetchKey.size > OBJECTIVE_CONTENT_CACHE_MAX) {
+ const oldest = objectiveContentByFetchKey.keys().next().value;
+ if (oldest !== undefined) objectiveContentByFetchKey.delete(oldest);
+ }
+ }
+ void request.then((content) => {
if (alive) setFetched(content);
});
return () => {
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css
index a3c33e3b..30d43cab 100644
--- a/packages/ui/src/index.css
+++ b/packages/ui/src/index.css
@@ -1389,12 +1389,21 @@ html:not(.dark) .chat-scroll {
}
}
-.oc-chat-hydration-reveal {
- animation: oc-chat-hydration-reveal 180ms ease-out both;
+.oc-chat-hydration-reveal,
+[data-timeline-reveal='fading'] {
+ animation: oc-chat-hydration-reveal 100ms ease-out both;
+}
+
+/* Timeline root while a freshly opened session still has provisional first
+ paints (see timelineRevealGate.ts): hidden until every hold releases, then
+ revealed as a whole. */
+[data-timeline-reveal='pending'] {
+ opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
- .oc-chat-hydration-reveal {
+ .oc-chat-hydration-reveal,
+ [data-timeline-reveal='fading'] {
animation: none;
}
}
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts
index ea4cec3a..fa8d1c8c 100644
--- a/packages/ui/src/lib/api/types.ts
+++ b/packages/ui/src/lib/api/types.ts
@@ -1316,6 +1316,199 @@ export type GitHubDeviceFlowComplete =
| { connected: true; user: GitHubUserSummary; scope?: string }
| { connected: false; status?: string; error?: string };
+export type LinearUserSummary = {
+ id: string;
+ name: string | null;
+ displayName: string | null;
+ email: string | null;
+ avatarUrl: string | null;
+};
+
+export type LinearOrganizationSummary = {
+ id: string;
+ name: string;
+ urlKey: string | null;
+};
+
+export type LinearWorkspaceSummary = {
+ id: string;
+ name: string | null;
+ urlKey: string | null;
+ current: boolean;
+ user?: LinearUserSummary | null;
+ authorizedAt?: number | null;
+};
+
+export type LinearAuthStatus = {
+ connected: boolean;
+ user?: LinearUserSummary | null;
+ organization?: LinearOrganizationSummary | null;
+ scope?: string;
+ workspaces?: LinearWorkspaceSummary[];
+};
+
+export type LinearAuthStart = {
+ authorizationUrl: string;
+ expiresIn: number;
+ scope: string;
+};
+
+export type LinearAuthOrigin = 'desktop' | 'web';
+
+export type LinearIssueState = {
+ id: string | null;
+ name: string | null;
+ type: string | null;
+};
+
+export type LinearWorkflowState = {
+ id: string;
+ name: string;
+ type: string | null;
+ position: number;
+};
+
+export type LinearIssueAssignee = {
+ name: string | null;
+ displayName: string | null;
+ avatarUrl: string | null;
+};
+
+export type LinearIssueTeam = {
+ id: string;
+ key: string;
+ name: string;
+};
+
+export type LinearIssuePriority = 0 | 1 | 2 | 3 | 4;
+
+export type LinearIssueLabel = {
+ id: string;
+ name: string;
+ color: string | null;
+};
+
+export type LinearIssueSummary = {
+ id: string;
+ identifier: string;
+ title: string;
+ url: string;
+ state?: LinearIssueState | null;
+ assignee?: LinearIssueAssignee | null;
+ team?: LinearIssueTeam | null;
+ priority?: LinearIssuePriority | null;
+ labels?: LinearIssueLabel[];
+};
+
+export type LinearIssueComment = {
+ id: string;
+ body: string;
+ createdAt: string | null;
+ user?: { name: string | null; displayName: string | null; avatarUrl?: string | null } | null;
+};
+
+export type LinearIssue = LinearIssueSummary & {
+ description?: string | null;
+ comments?: LinearIssueComment[];
+};
+
+export type LinearIssueListStatus = 'all' | 'backlog' | 'todo' | 'started' | 'inReview' | 'completed' | 'canceled' | 'duplicate';
+export type LinearIssueListAssignee = 'any' | 'me';
+export type LinearIssueListPriority = 'all' | 'none' | 'urgent' | 'high' | 'medium' | 'low';
+
+export type LinearIssuesListOptions = {
+ query?: string;
+ cursor?: string;
+ status?: LinearIssueListStatus;
+ assignee?: LinearIssueListAssignee;
+ teamId?: string;
+ priority?: LinearIssueListPriority;
+};
+
+export type LinearIssuesListResult = {
+ connected: boolean;
+ issues?: LinearIssueSummary[];
+ cursor?: string | null;
+ hasMore?: boolean;
+};
+
+export type LinearIssueGetResult = {
+ connected: boolean;
+ issue?: LinearIssue | null;
+};
+
+export type LinearIssueStatesResult = {
+ connected: boolean;
+ states?: LinearWorkflowState[];
+};
+
+export type LinearIssueUpdateInput = {
+ id: string;
+ stateId: string;
+};
+
+export type LinearIssueUpdateResult = {
+ connected: boolean;
+ issue?: LinearIssue | null;
+};
+
+export type LinearTeamMapping = {
+ id: string;
+ key: string;
+ name: string;
+ projectPath: string | null;
+};
+
+export type LinearMappingResult = {
+ connected: boolean;
+ defaultProjectPath?: string | null;
+ teams?: LinearTeamMapping[];
+};
+
+export type LinearMappingWrite = {
+ defaultProjectPath: string | null;
+ teamProjectPaths: { [teamId: string]: string };
+};
+
+export type LinearSessionStatusKind = 'started' | 'completed' | 'failure';
+
+export type LinearSessionStatusPostInput = {
+ kind: LinearSessionStatusKind;
+ sessionId: string;
+ issueIdentifier?: string;
+ sessionOrigin?: string;
+};
+
+export type LinearSessionStatusPostResult =
+ | { connected: false }
+ | { connected: true; posted: true; commentId: string | null }
+ | {
+ connected: true;
+ posted: false;
+ skipped: 'already-posted' | 'issue-not-found' | 'not-started' | 'disabled' | 'origin-not-public';
+ };
+
+export type LinearPreferences = {
+ /** Status comments are off until the user opts in. */
+ sessionComments: boolean;
+};
+
+export interface LinearAPI {
+ authStatus(): Promise;
+ authStart(origin?: LinearAuthOrigin): Promise;
+ authDisconnect(): Promise<{ removed: boolean }>;
+ authActivate(organizationId: string): Promise;
+ issuesList(options?: LinearIssuesListOptions): Promise;
+ issueGet(id: string): Promise;
+ issueStates(teamId: string): Promise;
+ issueUpdate(input: LinearIssueUpdateInput): Promise;
+ mappingGet(): Promise;
+ mappingSet(mapping: LinearMappingWrite): Promise;
+ sessionStatusPost(input: LinearSessionStatusPostInput): Promise;
+ preferencesGet(): Promise;
+ preferencesSet(preferences: LinearPreferences): Promise;
+}
+
export interface GitHubAPI {
authStatus(): Promise;
authStart(): Promise;
@@ -2092,6 +2285,7 @@ export interface RuntimeAPIs {
permissions: PermissionsAPI;
notifications: NotificationsAPI;
github?: GitHubAPI;
+ linear?: LinearAPI;
gitlab?: GitLabAPI;
gitea?: GiteaAPI;
push?: PushAPI;
diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts
index a455d61c..33e79485 100644
--- a/packages/ui/src/lib/debug.ts
+++ b/packages/ui/src/lib/debug.ts
@@ -13,6 +13,8 @@ import {
} from '@/sync/session-directory-resolution';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getRecentSendFailures } from '@/sync/send-failure-log';
+import { getRecentSessionErrors } from '@/sync/session-error-log';
+import { buildOpenCodeStatusReport } from '@/lib/openCodeStatus';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useStreamingStore } from '@/sync/streaming';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -386,6 +388,9 @@ export const debugUtils = {
// this session, so a "my message disappeared" report is not a rejected
// send and needs a different explanation.
recentSendFailures: getRecentSendFailures(),
+ // Same reasoning: empty means OpenCode reported no failed turn in this
+ // app session.
+ recentSessionErrors: getRecentSessionErrors(),
currentSessionDirectoryResolution: sessionState.currentSessionId
? this.diagnoseSessionDirectory(sessionState.currentSessionId)
: null,
@@ -395,6 +400,16 @@ export const debugUtils = {
return report;
},
+ /**
+ * The same text the status report dialog (Ctrl/Cmd+Shift+L) shows, for a
+ * console or remote session that cannot press the shortcut.
+ */
+ async statusReport() {
+ const text = await buildOpenCodeStatusReport();
+ console.log(text);
+ return text;
+ },
+
/**
* Prompt sends that were rejected and rolled back in this app session.
* Newest first; empty means no send was rejected.
diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts
index bf472060..d9908dfc 100644
--- a/packages/ui/src/lib/gitApiHttp.ts
+++ b/packages/ui/src/lib/gitApiHttp.ts
@@ -35,6 +35,7 @@ import type {
RevertCommitResponse,
ResetToCommitResponse,
} from './api/types';
+import { normalizePath } from './pathNormalization';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { getRuntimeKey } from './runtime-switch';
@@ -131,6 +132,35 @@ export async function checkIsGitRepository(directory: string): Promise
}
}
+export class GitDirectoriesUnsupportedError extends Error {
+ constructor() {
+ super('Nested git repository discovery is not supported by this runtime');
+ this.name = 'GitDirectoriesUnsupportedError';
+ }
+}
+
+export async function listGitDirectories(root: string): Promise {
+ const response = await runtimeFetch('/api/fs/git-dirs', { query: { path: root } });
+ if (response.status === 501) {
+ throw new GitDirectoriesUnsupportedError();
+ }
+ if (!response.ok) {
+ throw new Error(`Failed to list git directories: ${response.statusText}`);
+ }
+ // SAFETY: the route is ours (`GET /api/fs/git-dirs`) and answers this exact
+ // shape on every 2xx; a malformed body fails the array check below.
+ const data = await response.json() as { repositories?: Array<{ path?: string | null }> };
+ if (!Array.isArray(data?.repositories)) {
+ throw new Error('Unexpected git directories response');
+ }
+ // The server joins paths with the platform separator; every other git
+ // directory key in the UI is normalized, so match that here or a Windows
+ // repository never equals its own selection or root prefix.
+ return data.repositories
+ .map((entry) => normalizePath(entry?.path ?? null))
+ .filter((path): path is string => path !== null);
+}
+
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise {
const mode = options?.mode;
const runtimeKey = getRuntimeKey();
diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts
index e1daaa28..5e310462 100644
--- a/packages/ui/src/lib/i18n/messages/de.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/de.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung',
@@ -1879,7 +1880,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'Server',
'settings.voice.page.provider.local': 'Lokal',
'settings.voice.page.tooltip.sttLocal': 'On-device Transkription auf dem OpenChamber-Server. Modelle werden automatisch heruntergeladen; kein API-Schlüssel erforderlich.',
- 'settings.voice.page.tooltip.localTts': 'On-device Synthese auf dem OpenChamber-Server (Kokoro, Englisch). Das Modell wird automatisch heruntergeladen; kein API-Schlüssel erforderlich.',
+ 'settings.voice.page.tooltip.localTts': 'On-Device-Synthese auf dem OpenChamber-Server (Kokoro für Englisch; Modelle für andere Sprachen werden beim ersten Einsatz geladen). Kein API-Schlüssel nötig.',
+ 'settings.voice.page.field.followTextLanguage': 'Stimme an die Sprache des Textes anpassen',
+ 'settings.voice.page.field.followTextLanguageAria': 'Stimme an die Sprache des Textes anpassen',
+ 'settings.voice.page.field.followTextLanguageInfo': 'Ist eine Antwort in einer anderen Sprache, wird eine Stimme für diese Sprache verwendet: eine passende macOS-Stimme oder ein lokales Modell, das beim ersten Einsatz geladen wird.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (Englisch)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 europäische Sprachen)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (mehrsprachig)',
@@ -2040,6 +2044,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.activityDefaultModeAria': 'Aktivitäts-Standardmodus: {option}',
'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Erweiterte Bash-Tools anzeigen',
'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Erweiterte Bearbeitungstools anzeigen',
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Editor-Werkzeugleiste immer anzeigen',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Editor-Werkzeugleiste immer anzeigen (unter den Datei-Reitern angeheftet)',
+
'settings.openchamber.visual.field.bash': 'Bash',
'settings.openchamber.visual.field.editTools': 'Bearbeitungstools',
'settings.openchamber.visual.field.userMessageRenderingAria': 'Benutzernachrichten-Rendering: {option}',
@@ -2311,5 +2318,6 @@ export const settingsDict = {
'settings.openchamber.visual.option.themeMode.light.description': 'Immer helles Erscheinungsbild verwenden',
'settings.openchamber.visual.option.themeMode.dark.description': 'Immer dunkles Erscheinungsbild verwenden',
'chat.message.userText.collapseAria': 'Benutzernachricht einklappen',
+ ...linearIntegrationI18n.de,
...thirdPartyIntegrationI18n.de,
};
diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts
index db333f9b..82a492d7 100644
--- a/packages/ui/src/lib/i18n/messages/de.ts
+++ b/packages/ui/src/lib/i18n/messages/de.ts
@@ -1,7 +1,11 @@
import { settingsDict } from './de.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
...settingsDict,
+ ...linearIssuePickerI18n.de,
+ ...linearPanelI18n.de,
'common.language.german': 'Deutsch',
'common.loading': 'Wird geladen...',
'common.unavailable': 'Nicht verfügbar',
@@ -875,6 +879,10 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree-Funktionen sind in diesem Arbeitsbereichsmodus nicht verfügbar.',
'gitView.empty.worktreeSetupDescription': 'Arbeitstruktur-Einrichtung wird abgeschlossen und Repository-Zustand wird vorbereitet.',
'gitView.empty.worktreeSetupInProgress': 'Worktree-Einrichtung läuft',
+ 'gitView.empty.discoveringRepositories': 'Suche nach Git-Repositories...',
+ 'gitView.empty.discoverFailed': 'Git-Repositories konnten nicht durchsucht werden',
+ 'gitView.empty.retryDiscovery': 'Erneut versuchen',
+ 'gitView.empty.selectRepositoryPlaceholder': 'Repository auswählen...',
'worktree.bootstrap.toast.failed': 'Worktree-Einrichtung fehlgeschlagen',
'worktree.bootstrap.toast.failedDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung wurde nicht abgeschlossen.',
'worktree.bootstrap.toast.timeoutDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung hat ein Timeout.',
@@ -1652,6 +1660,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminalpanel ({shortcut})',
'chat.recap.aria': 'Sitzungs-Zusammenfassung',
'chat.recap.label': 'Zusammenfassung:',
+ 'chat.sessionError.title': 'OpenCode hat diese Antwort abgebrochen',
+ 'chat.sessionError.noDetails': 'OpenCode hat keine Details gemeldet. Öffne den Statusbericht (Strg/Cmd+Umschalt+L), um die letzten Fehler zu sehen.',
+ 'chat.sessionError.noReply': 'OpenCode hat keine Antwort auf diese Nachricht begonnen.',
'chat.goal.dialog.titleCreate': 'Sitzungsziel festlegen',
'chat.goal.dialog.titleManage': 'Sitzungsziel',
'chat.goal.dialog.objectiveLabel': 'Ziel',
@@ -2008,6 +2019,9 @@ export const dict = {
'session.newWorktree.noMatchingBranches': 'Keine übereinstimmenden Branches',
'session.newWorktree.localBranches': 'Lokale Branches',
'session.newWorktree.remoteBranches': 'Remote-Branches',
+ 'session.newWorktree.otherLocalBranches': 'Andere lokale Branches',
+ 'session.newWorktree.otherRemoteBranches': 'Andere Remote-Branches',
+
'session.newWorktree.branchName': 'Branch-Name',
'session.newWorktree.branchNamePlaceholder': 'feature/mein-geil-feature',
'session.newWorktree.actions.change': 'Ändern',
diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts
index 91344aa2..f1f85fce 100644
--- a/packages/ui/src/lib/i18n/messages/en.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking',
@@ -1946,7 +1947,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'Server',
'settings.voice.page.provider.local': 'Local',
'settings.voice.page.tooltip.sttLocal': 'On-device transcription on the OpenChamber server. Models download automatically; no API key needed.',
- 'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro, English). The model downloads automatically; no API key needed.',
+ 'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro for English; models for other languages download on first use). No API key needed.',
+ 'settings.voice.page.field.followTextLanguage': 'Match the voice to the language of the text',
+ 'settings.voice.page.field.followTextLanguageAria': 'Match the voice to the language of the text',
+ 'settings.voice.page.field.followTextLanguageInfo': 'When a reply is in another language, a voice for that language is used: a matching macOS voice, or a local model that downloads on first use.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (English)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 European languages)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingual)',
@@ -2118,6 +2122,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.activityDefaultModeAria': 'Activity default mode: {option}',
'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Show expanded bash tools',
'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Show expanded edit tools',
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
+
'settings.openchamber.visual.field.bash': 'Bash',
'settings.openchamber.visual.field.editTools': 'Edit tools',
'settings.openchamber.visual.field.userMessageRenderingAria': 'User message rendering: {option}',
@@ -2310,5 +2317,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...linearIntegrationI18n.en,
...thirdPartyIntegrationI18n.en,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index d37727ba..18b55af9 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -1,7 +1,11 @@
import { settingsDict } from './en.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
...settingsDict,
+ ...linearIssuePickerI18n.en,
+ ...linearPanelI18n.en,
'terminalView.actions.attachSelection': 'Attach selected output',
'terminalView.actions.restart': 'Restart terminal',
'chat.message.terminalContext': '{terminal}, lines {start}-{end}',
@@ -979,6 +983,10 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
+ 'gitView.empty.discoveringRepositories': 'Looking for Git repositories...',
+ 'gitView.empty.discoverFailed': 'Could not scan for Git repositories',
+ 'gitView.empty.retryDiscovery': 'Retry',
+ 'gitView.empty.selectRepositoryPlaceholder': 'Select a repository...',
'worktree.bootstrap.toast.failed': 'Worktree setup failed',
'worktree.bootstrap.toast.failedDescription': 'The worktree was created, but background setup did not finish.',
'worktree.bootstrap.toast.timeoutDescription': 'The worktree was created, but background setup timed out.',
@@ -1948,6 +1956,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})',
'chat.recap.aria': 'Session recap',
'chat.recap.label': 'Recap:',
+ 'chat.sessionError.title': 'OpenCode stopped this reply',
+ 'chat.sessionError.noDetails': 'OpenCode reported no details. Open the status report (Ctrl/Cmd+Shift+L) to see recent errors.',
+ 'chat.sessionError.noReply': 'OpenCode did not start a reply to this message.',
'chat.goal.dialog.titleCreate': 'Set Session Goal',
'chat.goal.dialog.titleManage': 'Session Goal',
'chat.goal.dialog.objectiveLabel': 'Objective',
@@ -2308,6 +2319,9 @@ export const dict = {
'session.newWorktree.noMatchingBranches': 'No matching branches',
'session.newWorktree.localBranches': 'Local branches',
'session.newWorktree.remoteBranches': 'Remote branches',
+ 'session.newWorktree.otherLocalBranches': 'Other local branches',
+ 'session.newWorktree.otherRemoteBranches': 'Other remote branches',
+
'session.newWorktree.branchName': 'Branch Name',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': 'Change',
diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts
index 5c5a3091..e67b96ea 100644
--- a/packages/ui/src/lib/i18n/messages/es.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go',
@@ -1923,7 +1924,10 @@ export const settingsDict = {
"settings.voice.page.provider.server": "Servidor",
"settings.voice.page.provider.local": "Local",
"settings.voice.page.tooltip.sttLocal": "Transcripción local en el servidor de OpenChamber. Los modelos se descargan automáticamente; no se necesita clave de API.",
- "settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro, inglés). El modelo se descarga automáticamente; no se necesita clave de API.",
+ "settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro para inglés; los modelos de otros idiomas se descargan en el primer uso). No requiere clave de API.",
+ "settings.voice.page.field.followTextLanguage": "Ajustar la voz al idioma del texto",
+ "settings.voice.page.field.followTextLanguageAria": "Ajustar la voz al idioma del texto",
+ "settings.voice.page.field.followTextLanguageInfo": "Si una respuesta está en otro idioma, se usa una voz para ese idioma: una voz de macOS adecuada o un modelo local que se descarga en el primer uso.",
"settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglés)",
"settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeos)",
"settings.voice.page.stt.model.whisperBase": "Whisper base (multilingüe)",
@@ -2095,6 +2099,9 @@ export const settingsDict = {
"settings.openchamber.visual.field.activityDefaultModeAria": "Modo predeterminado de actividad: {option}",
"settings.openchamber.visual.field.showExpandedBashToolsAria": "Mostrar herramientas de Bash expandidas",
"settings.openchamber.visual.field.showExpandedEditToolsAria": "Mostrar herramientas de edición expandidas",
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Mostrar siempre la barra de herramientas del editor',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Mostrar siempre la barra de herramientas del editor (anclada bajo las pestañas)',
+
"settings.openchamber.visual.field.bash": "Bash",
"settings.openchamber.visual.field.editTools": "Herramientas de edición",
"settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensajes del usuario: {option}",
@@ -2320,5 +2327,6 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
+ ...linearIntegrationI18n.es,
...thirdPartyIntegrationI18n.es,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 05b4b714..87e73c2b 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './es.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record = {
...settingsDict,
+ ...linearIssuePickerI18n.es,
+ ...linearPanelI18n.es,
'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada',
'terminalView.actions.restart': 'Reiniciar terminal',
'chat.message.terminalContext': '{terminal}, líneas {start}-{end}',
@@ -980,6 +984,10 @@ export const dict: Record = {
"gitView.empty.worktreeFeaturesUnavailable": "Las características de worktree no están disponibles en este modo de espacio de trabajo.",
"gitView.empty.worktreeSetupDescription": "Finalizando la configuración de worktree y preparando el estado del repositorio.",
"gitView.empty.worktreeSetupInProgress": "Configuración de worktree en progreso",
+ "gitView.empty.discoveringRepositories": "Buscando repositorios de Git...",
+ "gitView.empty.discoverFailed": "No se pudo escanear en busca de repositorios de Git",
+ "gitView.empty.retryDiscovery": "Reintentar",
+ "gitView.empty.selectRepositoryPlaceholder": "Selecciona un repositorio...",
"worktree.bootstrap.toast.failed": "Error al configurar el worktree",
"worktree.bootstrap.toast.failedDescription": "El worktree se creó, pero la configuración en segundo plano no terminó.",
"worktree.bootstrap.toast.timeoutDescription": "El worktree se creó, pero la configuración en segundo plano agotó el tiempo de espera.",
@@ -1927,6 +1935,9 @@ export const dict: Record = {
"header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})",
"chat.recap.aria": "Resumen de la sesión",
"chat.recap.label": "Resumen:",
+ "chat.sessionError.title": "OpenCode detuvo esta respuesta",
+ "chat.sessionError.noDetails": "OpenCode no informó detalles. Abre el informe de estado (Ctrl/Cmd+Mayús+L) para ver los errores recientes.",
+ "chat.sessionError.noReply": "OpenCode no comenzó una respuesta a este mensaje.",
"chat.goal.dialog.titleCreate": "Definir objetivo de sesión",
"chat.goal.dialog.titleManage": "Objetivo de sesión",
"chat.goal.dialog.objectiveLabel": "Objetivo",
@@ -2287,6 +2298,9 @@ export const dict: Record = {
"session.newWorktree.noMatchingBranches": "No hay ramas coincidentes",
"session.newWorktree.localBranches": "Ramas locales",
"session.newWorktree.remoteBranches": "Ramas remotas",
+ 'session.newWorktree.otherLocalBranches': 'Otras ramas locales',
+ 'session.newWorktree.otherRemoteBranches': 'Otras ramas remotas',
+
"session.newWorktree.branchName": "Nombre de la rama",
"session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature",
"session.newWorktree.actions.change": "Cambiar",
diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts
index eed4b909..8514029a 100644
--- a/packages/ui/src/lib/i18n/messages/fr.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Suivi de l’utilisation d’OpenCode Go',
@@ -1841,7 +1842,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'Serveur',
'settings.voice.page.provider.local': 'Local',
'settings.voice.page.tooltip.sttLocal': 'Transcription locale sur le serveur OpenChamber. Les modèles se téléchargent automatiquement ; aucune clé d\'API requise.',
- 'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro, anglais). Le modèle se télécharge automatiquement ; aucune clé d’API requise.',
+ 'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro pour l’anglais ; les modèles des autres langues sont téléchargés à la première utilisation). Aucune clé API requise.',
+ 'settings.voice.page.field.followTextLanguage': 'Adapter la voix à la langue du texte',
+ 'settings.voice.page.field.followTextLanguageAria': 'Adapter la voix à la langue du texte',
+ 'settings.voice.page.field.followTextLanguageInfo': 'Si une réponse est dans une autre langue, une voix pour cette langue est utilisée : une voix macOS adaptée ou un modèle local téléchargé à la première utilisation.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (anglais)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 langues européennes)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingue)',
@@ -2002,6 +2006,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.activityDefaultModeAria': 'Mode d\'activité par défaut : {option}',
'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Afficher les outils bash étendus',
'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Afficher les outils d\'édition étendus',
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Toujours afficher la barre d’outils de l’éditeur',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Toujours afficher la barre d’outils de l’éditeur (ancrée sous les onglets de fichiers)',
'settings.openchamber.visual.field.bash': 'Bash',
'settings.openchamber.visual.field.editTools': 'Outils d\'édition',
'settings.openchamber.visual.field.userMessageRenderingAria': 'Rendu du message utilisateur : {option}',
@@ -2320,5 +2326,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...linearIntegrationI18n.fr,
...thirdPartyIntegrationI18n.fr,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts
index 764f0d21..1cf8a81d 100644
--- a/packages/ui/src/lib/i18n/messages/fr.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.ts
@@ -1,7 +1,11 @@
import { settingsDict } from './fr.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
...settingsDict,
+ ...linearIssuePickerI18n.fr,
+ ...linearPanelI18n.fr,
'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée',
'terminalView.actions.restart': 'Redémarrer le terminal',
'chat.message.terminalContext': '{terminal}, lignes {start}-{end}',
@@ -801,6 +805,10 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Les fonctionnalités Worktree ne sont pas disponibles dans ce mode d’espace de travail.',
'gitView.empty.worktreeSetupDescription': 'Termine la configuration du worktree et prépare l\'état du dépôt.',
'gitView.empty.worktreeSetupInProgress': 'Configuration de worktree en cours',
+ 'gitView.empty.discoveringRepositories': 'Recherche des dépôts Git...',
+ 'gitView.empty.discoverFailed': 'Impossible d’analyser les dépôts Git',
+ 'gitView.empty.retryDiscovery': 'Réessayer',
+ 'gitView.empty.selectRepositoryPlaceholder': 'Sélectionnez un dépôt...',
'gitView.gitmoji.empty': 'Aucun gitmoji trouvé',
'gitView.gitmoji.searchPlaceholder': 'Rechercher des gitmoji...',
'gitView.gitmoji.title': 'Insérer un gitmoji',
@@ -1616,6 +1624,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Panneau à bornes ({shortcut})',
'chat.recap.aria': 'Récapitulatif de la session',
'chat.recap.label': 'Récap :',
+ 'chat.sessionError.title': 'OpenCode a interrompu cette réponse',
+ 'chat.sessionError.noDetails': 'OpenCode n\'a fourni aucun détail. Ouvrez le rapport d\'état (Ctrl/Cmd+Maj+L) pour voir les erreurs récentes.',
+ 'chat.sessionError.noReply': 'OpenCode n\'a pas commencé de réponse à ce message.',
'chat.goal.dialog.titleCreate': 'Définir un objectif de session',
'chat.goal.dialog.titleManage': 'Objectif de session',
'chat.goal.dialog.objectiveLabel': 'Objectif',
@@ -1976,6 +1987,9 @@ export const dict = {
'session.newWorktree.noMatchingBranches': 'Aucune branche correspondante',
'session.newWorktree.localBranches': 'Branches locales',
'session.newWorktree.remoteBranches': 'Branches du dépôt distant',
+ 'session.newWorktree.otherLocalBranches': 'Autres branches locales',
+ 'session.newWorktree.otherRemoteBranches': 'Autres branches du remote',
+
'session.newWorktree.branchName': 'Nom de la branche',
'session.newWorktree.branchNamePlaceholder': 'fonctionnalité/ma-fonctionnalité-géniale',
'session.newWorktree.actions.change': 'Changement',
diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts
index 1bb84bd6..12e4eead 100644
--- a/packages/ui/src/lib/i18n/messages/ja.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡',
@@ -1956,7 +1957,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'サーバー',
'settings.voice.page.provider.local': 'ローカル',
'settings.voice.page.tooltip.sttLocal': 'OpenChamber サーバー上でローカルに文字起こしします。モデルは自動でダウンロードされ、API キーは不要です。',
- 'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(Kokoro、英語)。モデルは自動でダウンロードされ、API キーは不要です。',
+ 'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(英語は Kokoro、他の言語のモデルは初回使用時にダウンロード)。API キーは不要です。',
+ 'settings.voice.page.field.followTextLanguage': 'テキストの言語に合わせて音声を選ぶ',
+ 'settings.voice.page.field.followTextLanguageAria': 'テキストの言語に合わせて音声を選ぶ',
+ 'settings.voice.page.field.followTextLanguageInfo': '返答が別の言語の場合、その言語の音声を使います。対応する macOS の音声、または初回使用時にダウンロードされるローカルモデルです。',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英語)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(ヨーロッパ25言語)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base(多言語)',
@@ -2128,6 +2132,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.activityDefaultModeAria': 'アクティビティデフォルトモード: {option}',
'settings.openchamber.visual.field.showExpandedBashToolsAria': '展開された Bash ツールを表示',
'settings.openchamber.visual.field.showExpandedEditToolsAria': '展開された編集ツールを表示',
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'エディターツールバーを常に表示',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'エディターツールバーを常に表示(ファイルタブの下にドッキング)',
+
'settings.openchamber.visual.field.bash': 'Bash',
'settings.openchamber.visual.field.editTools': '編集ツール',
'settings.openchamber.visual.field.userMessageRenderingAria': 'ユーザーメッセージ表示: {option}',
@@ -2320,5 +2327,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー',
+ ...linearIntegrationI18n.ja,
...thirdPartyIntegrationI18n.ja,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts
index f84ce6bc..3be34b66 100644
--- a/packages/ui/src/lib/i18n/messages/ja.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.ts
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './ja.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record = {
...settingsDict,
+ ...linearIssuePickerI18n.ja,
+ ...linearPanelI18n.ja,
'terminalView.actions.attachSelection': '選択した出力を添付',
'terminalView.actions.restart': 'ターミナルを再起動',
'chat.message.terminalContext': '{terminal}、{start}〜{end}行',
@@ -976,6 +980,10 @@ export const dict: Record = {
'gitView.empty.worktreeFeaturesUnavailable': 'このワークスペースモードではワークツリー機能は利用できません。',
'gitView.empty.worktreeSetupDescription': 'ワークツリーのセットアップを完了し、リポジトリ状態を準備中。',
'gitView.empty.worktreeSetupInProgress': 'ワークツリーのセットアップ進行中',
+ 'gitView.empty.discoveringRepositories': 'Git リポジトリを検索しています...',
+ 'gitView.empty.discoverFailed': 'Git リポジトリを検索できませんでした',
+ 'gitView.empty.retryDiscovery': '再試行',
+ 'gitView.empty.selectRepositoryPlaceholder': 'リポジトリを選択...',
'worktree.bootstrap.toast.failed': 'ワークツリーのセットアップに失敗しました',
'worktree.bootstrap.toast.failedDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップが完了しませんでした。',
'worktree.bootstrap.toast.timeoutDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。',
@@ -1945,6 +1953,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': 'ターミナルパネル({shortcut})',
'chat.recap.aria': 'セッションの要約',
'chat.recap.label': '要約:',
+ 'chat.sessionError.title': 'OpenCode がこの返答を停止しました',
+ 'chat.sessionError.noDetails': 'OpenCode から詳細は報告されませんでした。ステータスレポート(Ctrl/Cmd+Shift+L)で最近のエラーを確認してください。',
+ 'chat.sessionError.noReply': 'OpenCode はこのメッセージへの返答を開始しませんでした。',
'chat.goal.dialog.titleCreate': 'セッションゴールを設定',
'chat.goal.dialog.titleManage': 'セッションゴール',
'chat.goal.dialog.objectiveLabel': '目標',
@@ -2305,6 +2316,9 @@ export const dict: Record = {
'session.newWorktree.noMatchingBranches': '一致するブランチがありません',
'session.newWorktree.localBranches': 'ローカルブランチ',
'session.newWorktree.remoteBranches': 'リモートブランチ',
+ 'session.newWorktree.otherLocalBranches': 'その他のローカルブランチ',
+ 'session.newWorktree.otherRemoteBranches': 'その他のリモートブランチ',
+
'session.newWorktree.branchName': 'ブランチ名',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': '変更',
diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts
index 9681e673..8420fb77 100644
--- a/packages/ui/src/lib/i18n/messages/ko.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적',
@@ -1923,7 +1924,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': '서버',
'settings.voice.page.provider.local': '로컬',
'settings.voice.page.tooltip.sttLocal': 'OpenChamber 서버에서 로컬로 변환합니다. 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.',
- 'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(Kokoro, 영어). 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.',
+ 'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(영어는 Kokoro, 다른 언어 모델은 처음 사용할 때 다운로드). API 키가 필요 없습니다.',
+ 'settings.voice.page.field.followTextLanguage': '텍스트 언어에 맞는 음성 사용',
+ 'settings.voice.page.field.followTextLanguageAria': '텍스트 언어에 맞는 음성 사용',
+ 'settings.voice.page.field.followTextLanguageInfo': '응답이 다른 언어이면 해당 언어의 음성을 사용합니다. 일치하는 macOS 음성 또는 처음 사용할 때 다운로드되는 로컬 모델입니다.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (영어)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (유럽 25개 언어)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (다국어)',
@@ -2095,6 +2099,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.activityDefaultModeAria': 'Activity 기본 모드: {option}',
'settings.openchamber.visual.field.showExpandedBashToolsAria': '확장된 Bash 도구 표시',
'settings.openchamber.visual.field.showExpandedEditToolsAria': '확장된 편집 도구 표시',
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
+
'settings.openchamber.visual.field.bash': 'Bash',
'settings.openchamber.visual.field.editTools': '편집 도구',
'settings.openchamber.visual.field.userMessageRenderingAria': '사용자 메시지 렌더링: {option}',
@@ -2320,5 +2327,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...linearIntegrationI18n.ko,
...thirdPartyIntegrationI18n.ko,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index f6ffaf2d..507f9931 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './ko.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record = {
...settingsDict,
+ ...linearIssuePickerI18n.ko,
+ ...linearPanelI18n.ko,
'terminalView.actions.attachSelection': '선택한 출력 첨부',
'terminalView.actions.restart': '터미널 다시 시작',
'chat.message.terminalContext': '{terminal}, {start}-{end}행',
@@ -980,6 +984,10 @@ export const dict: Record = {
'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.',
'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.',
'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중',
+ 'gitView.empty.discoveringRepositories': 'Git 저장소를 찾는 중...',
+ 'gitView.empty.discoverFailed': 'Git 저장소를 검색할 수 없습니다',
+ 'gitView.empty.retryDiscovery': '다시 시도',
+ 'gitView.empty.selectRepositoryPlaceholder': '저장소 선택...',
'worktree.bootstrap.toast.failed': '워크트리 설정 실패',
'worktree.bootstrap.toast.failedDescription': '워크트리는 생성되었지만 백그라운드 설정이 완료되지 않았습니다.',
'worktree.bootstrap.toast.timeoutDescription': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.',
@@ -1951,6 +1959,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': '터미널 패널 ({shortcut})',
'chat.recap.aria': '세션 요약',
'chat.recap.label': '요약:',
+ 'chat.sessionError.title': 'OpenCode가 이 응답을 중단했습니다',
+ 'chat.sessionError.noDetails': 'OpenCode가 세부 정보를 보고하지 않았습니다. 상태 보고서(Ctrl/Cmd+Shift+L)에서 최근 오류를 확인하세요.',
+ 'chat.sessionError.noReply': 'OpenCode가 이 메시지에 대한 응답을 시작하지 않았습니다.',
'chat.goal.dialog.titleCreate': '세션 목표 설정',
'chat.goal.dialog.titleManage': '세션 목표',
'chat.goal.dialog.objectiveLabel': '목표',
@@ -2311,6 +2322,9 @@ export const dict: Record = {
'session.newWorktree.noMatchingBranches': '일치하는 브랜치가 없습니다',
'session.newWorktree.localBranches': '로컬 브랜치',
'session.newWorktree.remoteBranches': '리모트 브랜치',
+ 'session.newWorktree.otherLocalBranches': '기타 로컬 브랜치',
+ 'session.newWorktree.otherRemoteBranches': '기타 리모트 브랜치',
+
'session.newWorktree.branchName': '브랜치 이름',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': '변경',
diff --git a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts
new file mode 100644
index 00000000..0fae64bb
--- /dev/null
+++ b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, test } from 'bun:test';
+import { linearIntegrationI18n } from './linear-integration.i18n';
+
+const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
+
+const requiredKeys = [
+ 'settings.integrations.firstParty.title',
+ 'settings.integrations.firstParty.info',
+ 'settings.integrations.linear.title',
+ 'settings.integrations.linear.description',
+ 'settings.integrations.linear.info',
+ 'settings.integrations.linear.status.notConnected',
+ 'settings.integrations.linear.status.connected',
+ 'settings.integrations.linear.status.waiting',
+ 'settings.integrations.linear.actions.connect',
+ 'settings.integrations.linear.actions.disconnect',
+ 'settings.integrations.linear.actions.addWorkspace',
+ 'settings.integrations.linear.actions.switchTo',
+ 'settings.integrations.linear.label.otherWorkspaces',
+ 'settings.integrations.linear.flow.title',
+ 'settings.integrations.linear.flow.description',
+ 'settings.integrations.linear.flow.waiting',
+ 'settings.integrations.linear.toast.connected',
+ 'settings.integrations.linear.toast.disconnected',
+ 'settings.integrations.linear.toast.workspaceSwitched',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed',
+ 'settings.integrations.linear.toast.startConnectFailed',
+ 'settings.integrations.linear.toast.disconnectFailed',
+ 'settings.integrations.linear.toast.authorizationFailed',
+ 'settings.integrations.linear.avatarAlt.withName',
+ 'settings.integrations.linear.avatarAlt.fallback',
+ 'settings.integrations.linear.label.unknownUser',
+ 'settings.integrations.linear.mapping.defaultProject',
+ 'settings.integrations.linear.mapping.defaultProject.info',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder',
+ 'settings.integrations.linear.mapping.defaultProject.aria',
+ 'settings.integrations.linear.mapping.teams',
+ 'settings.integrations.linear.mapping.teams.info',
+ 'settings.integrations.linear.mapping.teams.useDefault',
+ 'settings.integrations.linear.mapping.teams.aria',
+ 'settings.integrations.linear.mapping.emptyProjects',
+ 'settings.integrations.linear.mapping.emptyTeams',
+ 'settings.integrations.linear.mapping.loadFailed',
+ 'settings.integrations.linear.sessionComments.label',
+ 'settings.integrations.linear.sessionComments.info',
+ 'settings.integrations.linear.sessionComments.aria',
+ 'settings.integrations.linear.sessionComments.loadFailed',
+ 'settings.magicPrompts.sidebar.group.linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview',
+ 'settings.magicPrompts.page.group.linearIssueReview.title',
+ 'settings.magicPrompts.page.group.linearIssueReview.description',
+] as const;
+
+describe('linear integration translations', () => {
+ test('provides every required key in every supported locale', () => {
+ const english = linearIntegrationI18n.en;
+ for (const locale of locales) {
+ for (const key of requiredKeys) {
+ const value = linearIntegrationI18n[locale][key];
+ expect(value).toBeTruthy();
+ if (
+ locale !== 'en'
+ && key !== 'settings.integrations.linear.title'
+ && key !== 'settings.magicPrompts.sidebar.group.linear'
+ ) {
+ expect(value).not.toBe(english[key]);
+ }
+ }
+ }
+ });
+});
diff --git a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts
new file mode 100644
index 00000000..44deca07
--- /dev/null
+++ b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts
@@ -0,0 +1,603 @@
+/** Built-in integration (GitHub, Linear) settings strings — merged into each locale's settings dictionary. */
+export const linearIntegrationI18n = {
+ en: {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': 'Connect a GitHub account for pull requests and issues.',
+ 'settings.integrations.github.status.notConnected': 'Not connected',
+ 'settings.integrations.firstParty.title': 'Built-in integrations',
+ 'settings.integrations.firstParty.info': 'Sign-ins for services that ship with OpenChamber. The login stays on this computer so web, desktop, and a paired phone share it.',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': 'Connect Linear workspaces on this OpenChamber server.',
+ 'settings.integrations.linear.info': 'Connect one or more Linear workspaces. OpenChamber stores the logins on this computer so web, desktop, and a paired phone share them.',
+ 'settings.integrations.linear.status.notConnected': 'Not connected',
+ 'settings.integrations.linear.status.connected': 'Connected',
+ 'settings.integrations.linear.status.waiting': 'Waiting',
+ 'settings.integrations.linear.actions.connect': 'Connect',
+ 'settings.integrations.linear.actions.disconnect': 'Disconnect',
+ 'settings.integrations.linear.actions.addWorkspace': 'Add workspace',
+ 'settings.integrations.linear.actions.switchTo': 'Switch to',
+ 'settings.integrations.linear.label.otherWorkspaces': 'Other workspaces',
+ 'settings.integrations.linear.flow.title': 'Waiting for Linear',
+ 'settings.integrations.linear.flow.description': 'Finish signing in in the browser tab that just opened.',
+ 'settings.integrations.linear.flow.waiting': 'Waiting for authorization…',
+ 'settings.integrations.linear.toast.connected': 'Linear connected',
+ 'settings.integrations.linear.toast.disconnected': 'Linear disconnected',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Switched Linear workspace',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace',
+ 'settings.integrations.linear.toast.startConnectFailed': 'Could not start Linear sign-in',
+ 'settings.integrations.linear.toast.disconnectFailed': 'Could not disconnect Linear',
+ 'settings.integrations.linear.toast.authorizationFailed': 'Linear authorization timed out. Click Connect to try again.',
+ 'settings.integrations.linear.avatarAlt.withName': 'Linear avatar for {name}',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Linear avatar',
+ 'settings.integrations.linear.label.unknownUser': 'Unknown user',
+ 'settings.integrations.linear.mapping.defaultProject': 'Default project',
+ 'settings.integrations.linear.mapping.defaultProject.info': 'New sessions from Linear issues use this project unless the issue\'s team has its own mapping.',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': 'None',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Default project for Linear issues',
+ 'settings.integrations.linear.mapping.teams': 'Team projects',
+ 'settings.integrations.linear.mapping.teams.info': 'Optional. An issue from a mapped team opens in that project instead of the default.',
+ 'settings.integrations.linear.mapping.teams.useDefault': 'Use default',
+ 'settings.integrations.linear.mapping.teams.aria': 'Project for Linear team {team}',
+ 'settings.integrations.linear.mapping.emptyProjects': 'Add a project first, then map Linear teams to it.',
+ 'settings.integrations.linear.mapping.emptyTeams': 'This Linear workspace has no teams.',
+ 'settings.integrations.linear.mapping.loadFailed': 'Could not load Linear project mapping.',
+ 'settings.integrations.linear.sessionComments.label': 'Session comments',
+ 'settings.integrations.linear.sessionComments.info': 'Adds a comment to the issue when a session starts, finishes, or fails. Comments are only posted when this server has a public address, so the link opens the session for everyone on the issue.',
+ 'settings.integrations.linear.sessionComments.aria': 'Post session status comments to Linear',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'Could not load Linear comment settings.',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue Review',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue Review',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts used when starting a session from a Linear issue: visible user message + hidden instructions.',
+ },
+ de: {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': 'GitHub-Konto für Pull Requests und Issues verbinden.',
+ 'settings.integrations.github.status.notConnected': 'Nicht verbunden',
+ 'settings.integrations.firstParty.title': 'Eingebaute Integrationen',
+ 'settings.integrations.firstParty.info': 'Anmeldungen für Dienste, die mit OpenChamber mitgeliefert werden. Die Anmeldung bleibt auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': 'Verbinde Linear-Workspaces mit diesem OpenChamber-Server.',
+ 'settings.integrations.linear.info': 'Verbinde einen oder mehrere Linear-Workspaces. OpenChamber speichert die Anmeldungen auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.',
+ 'settings.integrations.linear.status.notConnected': 'Nicht verbunden',
+ 'settings.integrations.linear.status.connected': 'Verbunden',
+ 'settings.integrations.linear.status.waiting': 'Warten',
+ 'settings.integrations.linear.actions.connect': 'Verbinden',
+ 'settings.integrations.linear.actions.disconnect': 'Trennen',
+ 'settings.integrations.linear.actions.addWorkspace': 'Workspace hinzufügen',
+ 'settings.integrations.linear.actions.switchTo': 'Wechseln zu',
+ 'settings.integrations.linear.label.otherWorkspaces': 'Andere Workspaces',
+ 'settings.integrations.linear.flow.title': 'Warte auf Linear',
+ 'settings.integrations.linear.flow.description': 'Schließe die Anmeldung im gerade geöffneten Browser-Tab ab.',
+ 'settings.integrations.linear.flow.waiting': 'Warte auf die Autorisierung…',
+ 'settings.integrations.linear.toast.connected': 'Linear verbunden',
+ 'settings.integrations.linear.toast.disconnected': 'Linear getrennt',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden',
+ 'settings.integrations.linear.toast.startConnectFailed': 'Linear-Anmeldung konnte nicht gestartet werden',
+ 'settings.integrations.linear.toast.disconnectFailed': 'Linear konnte nicht getrennt werden',
+ 'settings.integrations.linear.toast.authorizationFailed': 'Die Linear-Autorisierung ist abgelaufen. Klicke auf Verbinden, um es erneut zu versuchen.',
+ 'settings.integrations.linear.avatarAlt.withName': 'Linear-Avatar für {name}',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Linear-Avatar',
+ 'settings.integrations.linear.label.unknownUser': 'Unbekannter Benutzer',
+ 'settings.integrations.linear.mapping.defaultProject': 'Standardprojekt',
+ 'settings.integrations.linear.mapping.defaultProject.info': 'Neue Sitzungen aus Linear-Issues nutzen dieses Projekt, sofern das Team des Issues keine eigene Zuordnung hat.',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Keines',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Standardprojekt für Linear-Issues',
+ 'settings.integrations.linear.mapping.teams': 'Team-Projekte',
+ 'settings.integrations.linear.mapping.teams.info': 'Optional. Ein Issue eines zugeordneten Teams öffnet sich in diesem Projekt statt im Standard.',
+ 'settings.integrations.linear.mapping.teams.useDefault': 'Standard verwenden',
+ 'settings.integrations.linear.mapping.teams.aria': 'Projekt für Linear-Team {team}',
+ 'settings.integrations.linear.mapping.emptyProjects': 'Füge zuerst ein Projekt hinzu und ordne dann Linear-Teams zu.',
+ 'settings.integrations.linear.mapping.emptyTeams': 'Dieser Linear-Workspace hat keine Teams.',
+ 'settings.integrations.linear.mapping.loadFailed': 'Linear-Projektzuordnung konnte nicht geladen werden.',
+ 'settings.integrations.linear.sessionComments.label': 'Sitzungskommentare',
+ 'settings.integrations.linear.sessionComments.info': 'Kommentiert das Issue, wenn eine Sitzung startet, endet oder fehlschlägt. Kommentare werden nur gepostet, wenn dieser Server eine öffentliche Adresse hat, damit der Link die Sitzung für alle Beteiligten öffnet.',
+ 'settings.integrations.linear.sessionComments.aria': 'Statuskommentare zu Sitzungen in Linear posten',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'Linear-Kommentareinstellungen konnten nicht geladen werden.',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue-Review',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue-Review',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': 'Eingabeaufforderungen beim Start einer Sitzung aus einem Linear-Issue: sichtbare Benutzernachricht + versteckte Anweisungen.',
+ },
+ fr: {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': 'Connecter un compte GitHub pour les pull requests et les issues.',
+ 'settings.integrations.github.status.notConnected': 'Non connecté',
+ 'settings.integrations.firstParty.title': 'Intégrations natives',
+ 'settings.integrations.firstParty.info': 'Connexions aux services fournis avec OpenChamber. La connexion reste sur cet ordinateur pour que le web, le bureau et un téléphone apparié la partagent.',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': 'Connectez des espaces Linear à ce serveur OpenChamber.',
+ 'settings.integrations.linear.info': 'Connectez un ou plusieurs espaces Linear. OpenChamber enregistre les connexions sur cet ordinateur pour que le web, le bureau et un téléphone apparié les partagent.',
+ 'settings.integrations.linear.status.notConnected': 'Non connecté',
+ 'settings.integrations.linear.status.connected': 'Connecté',
+ 'settings.integrations.linear.status.waiting': 'En attente',
+ 'settings.integrations.linear.actions.connect': 'Connecter',
+ 'settings.integrations.linear.actions.disconnect': 'Déconnecter',
+ 'settings.integrations.linear.actions.addWorkspace': 'Ajouter un workspace',
+ 'settings.integrations.linear.actions.switchTo': 'Basculer vers',
+ 'settings.integrations.linear.label.otherWorkspaces': 'Autres workspaces',
+ 'settings.integrations.linear.flow.title': 'En attente de Linear',
+ 'settings.integrations.linear.flow.description': 'Terminez la connexion dans l’onglet du navigateur qui vient de s’ouvrir.',
+ 'settings.integrations.linear.flow.waiting': 'En attente de l’autorisation…',
+ 'settings.integrations.linear.toast.connected': 'Linear connecté',
+ 'settings.integrations.linear.toast.disconnected': 'Linear déconnecté',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Workspace Linear modifié',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear',
+ 'settings.integrations.linear.toast.startConnectFailed': 'Impossible de démarrer la connexion Linear',
+ 'settings.integrations.linear.toast.disconnectFailed': 'Impossible de déconnecter Linear',
+ 'settings.integrations.linear.toast.authorizationFailed': 'L’autorisation Linear a expiré. Cliquez sur Connecter pour réessayer.',
+ 'settings.integrations.linear.avatarAlt.withName': 'Avatar Linear de {name}',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Avatar Linear',
+ 'settings.integrations.linear.label.unknownUser': 'Utilisateur inconnu',
+ 'settings.integrations.linear.mapping.defaultProject': 'Projet par défaut',
+ 'settings.integrations.linear.mapping.defaultProject.info': 'Les nouvelles sessions depuis des tickets Linear utilisent ce projet, sauf si l’équipe du ticket a sa propre association.',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Aucun',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Projet par défaut pour les tickets Linear',
+ 'settings.integrations.linear.mapping.teams': 'Projets par équipe',
+ 'settings.integrations.linear.mapping.teams.info': 'Facultatif. Un ticket d’une équipe associée s’ouvre dans ce projet plutôt que dans le projet par défaut.',
+ 'settings.integrations.linear.mapping.teams.useDefault': 'Utiliser le défaut',
+ 'settings.integrations.linear.mapping.teams.aria': 'Projet pour l’équipe Linear {team}',
+ 'settings.integrations.linear.mapping.emptyProjects': 'Ajoutez d’abord un projet, puis associez les équipes Linear.',
+ 'settings.integrations.linear.mapping.emptyTeams': 'Cet espace Linear n’a aucune équipe.',
+ 'settings.integrations.linear.mapping.loadFailed': 'Impossible de charger l’association des projets Linear.',
+ 'settings.integrations.linear.sessionComments.label': 'Commentaires de session',
+ 'settings.integrations.linear.sessionComments.info': 'Ajoute un commentaire au ticket quand une session démarre, se termine ou échoue. Les commentaires ne sont publiés que si ce serveur a une adresse publique, afin que le lien ouvre la session pour tout le monde.',
+ 'settings.integrations.linear.sessionComments.aria': 'Publier les commentaires d’état de session dans Linear',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'Impossible de charger les réglages de commentaires Linear.',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revue d’issue',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Revue d’issue',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts utilisés au démarrage d’une session depuis un ticket Linear : message utilisateur visible + instructions masquées.',
+ },
+ es: {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': 'Conecta una cuenta de GitHub para pull requests e issues.',
+ 'settings.integrations.github.status.notConnected': 'No conectado',
+ 'settings.integrations.firstParty.title': 'Integraciones nativas',
+ 'settings.integrations.firstParty.info': 'Inicios de sesión de los servicios incluidos en OpenChamber. El inicio de sesión se guarda en este ordenador para que la web, el escritorio y un teléfono emparejado lo compartan.',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': 'Conecta espacios de Linear a este servidor de OpenChamber.',
+ 'settings.integrations.linear.info': 'Conecta uno o más espacios de Linear. OpenChamber guarda los inicios de sesión en este ordenador para que la web, el escritorio y un teléfono emparejado los compartan.',
+ 'settings.integrations.linear.status.notConnected': 'No conectado',
+ 'settings.integrations.linear.status.connected': 'Conectado',
+ 'settings.integrations.linear.status.waiting': 'Esperando',
+ 'settings.integrations.linear.actions.connect': 'Conectar',
+ 'settings.integrations.linear.actions.disconnect': 'Desconectar',
+ 'settings.integrations.linear.actions.addWorkspace': 'Añadir workspace',
+ 'settings.integrations.linear.actions.switchTo': 'Cambiar a',
+ 'settings.integrations.linear.label.otherWorkspaces': 'Otros workspaces',
+ 'settings.integrations.linear.flow.title': 'Esperando a Linear',
+ 'settings.integrations.linear.flow.description': 'Termina de iniciar sesión en la pestaña del navegador que acaba de abrirse.',
+ 'settings.integrations.linear.flow.waiting': 'Esperando la autorización…',
+ 'settings.integrations.linear.toast.connected': 'Linear conectado',
+ 'settings.integrations.linear.toast.disconnected': 'Linear desconectado',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear',
+ 'settings.integrations.linear.toast.startConnectFailed': 'No se pudo iniciar la conexión con Linear',
+ 'settings.integrations.linear.toast.disconnectFailed': 'No se pudo desconectar Linear',
+ 'settings.integrations.linear.toast.authorizationFailed': 'La autorización de Linear ha caducado. Haz clic en Conectar para intentarlo de nuevo.',
+ 'settings.integrations.linear.avatarAlt.withName': 'Avatar de Linear de {name}',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Avatar de Linear',
+ 'settings.integrations.linear.label.unknownUser': 'Usuario desconocido',
+ 'settings.integrations.linear.mapping.defaultProject': 'Proyecto predeterminado',
+ 'settings.integrations.linear.mapping.defaultProject.info': 'Las sesiones nuevas desde issues de Linear usan este proyecto, salvo que el equipo del issue tenga su propia asignación.',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Ninguno',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Proyecto predeterminado para issues de Linear',
+ 'settings.integrations.linear.mapping.teams': 'Proyectos por equipo',
+ 'settings.integrations.linear.mapping.teams.info': 'Opcional. Un issue de un equipo asignado se abre en ese proyecto en lugar del predeterminado.',
+ 'settings.integrations.linear.mapping.teams.useDefault': 'Usar el predeterminado',
+ 'settings.integrations.linear.mapping.teams.aria': 'Proyecto para el equipo de Linear {team}',
+ 'settings.integrations.linear.mapping.emptyProjects': 'Añade primero un proyecto y luego asigna equipos de Linear.',
+ 'settings.integrations.linear.mapping.emptyTeams': 'Este espacio de Linear no tiene equipos.',
+ 'settings.integrations.linear.mapping.loadFailed': 'No se pudo cargar la asignación de proyectos de Linear.',
+ 'settings.integrations.linear.sessionComments.label': 'Comentarios de sesión',
+ 'settings.integrations.linear.sessionComments.info': 'Añade un comentario a la incidencia cuando una sesión empieza, termina o falla. Los comentarios solo se publican si este servidor tiene una dirección pública, para que el enlace abra la sesión a todos.',
+ 'settings.integrations.linear.sessionComments.aria': 'Publicar comentarios de estado de sesión en Linear',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'No se pudieron cargar los ajustes de comentarios de Linear.',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisión de issue',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisión de issue',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados al iniciar una sesión desde un issue de Linear: mensaje visible del usuario e instrucciones ocultas.',
+ },
+ ja: {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': 'プルリクエストと Issue のために GitHub アカウントを接続します。',
+ 'settings.integrations.github.status.notConnected': '未接続',
+ 'settings.integrations.firstParty.title': '標準連携',
+ 'settings.integrations.firstParty.info': 'OpenChamber に同梱されているサービスのログインです。このコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': 'この OpenChamber サーバーに Linear ワークスペースを接続します。複数接続できます。',
+ 'settings.integrations.linear.info': 'Linear ワークスペースを1つ以上接続します。ログインはこのコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。',
+ 'settings.integrations.linear.status.notConnected': '未接続',
+ 'settings.integrations.linear.status.connected': '接続済み',
+ 'settings.integrations.linear.status.waiting': '待機中',
+ 'settings.integrations.linear.actions.connect': '接続',
+ 'settings.integrations.linear.actions.disconnect': '切断',
+ 'settings.integrations.linear.actions.addWorkspace': 'ワークスペースを追加',
+ 'settings.integrations.linear.actions.switchTo': '切り替える',
+ 'settings.integrations.linear.label.otherWorkspaces': '他のワークスペース',
+ 'settings.integrations.linear.flow.title': 'Linear を待っています',
+ 'settings.integrations.linear.flow.description': '開いたブラウザタブでサインインを完了してください。',
+ 'settings.integrations.linear.flow.waiting': '認可を待っています…',
+ 'settings.integrations.linear.toast.connected': 'Linear に接続しました',
+ 'settings.integrations.linear.toast.disconnected': 'Linear を切断しました',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした',
+ 'settings.integrations.linear.toast.startConnectFailed': 'Linear のサインインを開始できませんでした',
+ 'settings.integrations.linear.toast.disconnectFailed': 'Linear を切断できませんでした',
+ 'settings.integrations.linear.toast.authorizationFailed': 'Linear の認可がタイムアウトしました。接続をもう一度押してください。',
+ 'settings.integrations.linear.avatarAlt.withName': '{name} の Linear アバター',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Linear アバター',
+ 'settings.integrations.linear.label.unknownUser': '不明なユーザー',
+ 'settings.integrations.linear.mapping.defaultProject': 'デフォルトのプロジェクト',
+ 'settings.integrations.linear.mapping.defaultProject.info': 'Linear Issueから作る新しいセッションはこのプロジェクトを使います。チームに個別の割り当てがある場合はそちらを使います。',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': 'なし',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issueのデフォルトプロジェクト',
+ 'settings.integrations.linear.mapping.teams': 'チームのプロジェクト',
+ 'settings.integrations.linear.mapping.teams.info': '任意。割り当てたチームのIssueは、デフォルトではなくそのプロジェクトで開きます。',
+ 'settings.integrations.linear.mapping.teams.useDefault': 'デフォルトを使う',
+ 'settings.integrations.linear.mapping.teams.aria': 'Linearチーム {team} のプロジェクト',
+ 'settings.integrations.linear.mapping.emptyProjects': '先にプロジェクトを追加してから、Linearチームを割り当ててください。',
+ 'settings.integrations.linear.mapping.emptyTeams': 'このLinearワークスペースにはチームがありません。',
+ 'settings.integrations.linear.mapping.loadFailed': 'Linearのプロジェクト割り当てを読み込めませんでした。',
+ 'settings.integrations.linear.sessionComments.label': 'セッションのコメント',
+ 'settings.integrations.linear.sessionComments.info': 'セッションの開始・完了・失敗時にイシューへコメントします。リンクを誰でも開けるよう、このサーバーが公開アドレスを持つ場合のみ投稿します。',
+ 'settings.integrations.linear.sessionComments.aria': 'セッション状態のコメントを Linear に投稿',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'Linear のコメント設定を読み込めませんでした。',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue レビュー',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue レビュー',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear の Issue からセッションを開始するときに使うプロンプト: 表示ユーザーメッセージ + 非表示の指示。',
+ },
+ ko: {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': '풀 리퀘스트와 이슈를 위해 GitHub 계정을 연결합니다.',
+ 'settings.integrations.github.status.notConnected': '연결되지 않음',
+ 'settings.integrations.firstParty.title': '기본 제공 통합',
+ 'settings.integrations.firstParty.info': 'OpenChamber에 포함된 서비스 로그인입니다. 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': '이 OpenChamber 서버에 Linear 워크스페이스를 연결하세요. 여러 개를 연결할 수 있습니다.',
+ 'settings.integrations.linear.info': 'Linear 워크스페이스를 하나 이상 연결하세요. 로그인은 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.',
+ 'settings.integrations.linear.status.notConnected': '연결되지 않음',
+ 'settings.integrations.linear.status.connected': '연결됨',
+ 'settings.integrations.linear.status.waiting': '대기 중',
+ 'settings.integrations.linear.actions.connect': '연결',
+ 'settings.integrations.linear.actions.disconnect': '연결 해제',
+ 'settings.integrations.linear.actions.addWorkspace': '워크스페이스 추가',
+ 'settings.integrations.linear.actions.switchTo': '전환',
+ 'settings.integrations.linear.label.otherWorkspaces': '다른 워크스페이스',
+ 'settings.integrations.linear.flow.title': 'Linear 대기 중',
+ 'settings.integrations.linear.flow.description': '방금 열린 브라우저 탭에서 로그인을 완료하세요.',
+ 'settings.integrations.linear.flow.waiting': '권한 부여를 기다리는 중…',
+ 'settings.integrations.linear.toast.connected': 'Linear가 연결됨',
+ 'settings.integrations.linear.toast.disconnected': 'Linear 연결이 해제됨',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다',
+ 'settings.integrations.linear.toast.startConnectFailed': 'Linear 로그인을 시작하지 못했습니다',
+ 'settings.integrations.linear.toast.disconnectFailed': 'Linear 연결을 해제하지 못했습니다',
+ 'settings.integrations.linear.toast.authorizationFailed': 'Linear 권한 부여가 시간 초과되었습니다. 연결을 다시 누르세요.',
+ 'settings.integrations.linear.avatarAlt.withName': '{name}의 Linear 아바타',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Linear 아바타',
+ 'settings.integrations.linear.label.unknownUser': '알 수 없는 사용자',
+ 'settings.integrations.linear.mapping.defaultProject': '기본 프로젝트',
+ 'settings.integrations.linear.mapping.defaultProject.info': 'Linear 이슈에서 만드는 새 세션은 이 프로젝트를 사용합니다. 해당 팀에 별도 연결이 있으면 그쪽을 씁니다.',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': '없음',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear 이슈의 기본 프로젝트',
+ 'settings.integrations.linear.mapping.teams': '팀 프로젝트',
+ 'settings.integrations.linear.mapping.teams.info': '선택 사항입니다. 연결한 팀의 이슈는 기본값 대신 그 프로젝트에서 열립니다.',
+ 'settings.integrations.linear.mapping.teams.useDefault': '기본값 사용',
+ 'settings.integrations.linear.mapping.teams.aria': 'Linear 팀 {team}의 프로젝트',
+ 'settings.integrations.linear.mapping.emptyProjects': '먼저 프로젝트를 추가한 다음 Linear 팀을 연결하세요.',
+ 'settings.integrations.linear.mapping.emptyTeams': '이 Linear 워크스페이스에는 팀이 없습니다.',
+ 'settings.integrations.linear.mapping.loadFailed': 'Linear 프로젝트 연결을 불러오지 못했습니다.',
+ 'settings.integrations.linear.sessionComments.label': '세션 댓글',
+ 'settings.integrations.linear.sessionComments.info': '세션이 시작, 완료, 실패할 때 이슈에 댓글을 남깁니다. 링크를 모두가 열 수 있도록 이 서버에 공개 주소가 있을 때만 게시합니다.',
+ 'settings.integrations.linear.sessionComments.aria': '세션 상태 댓글을 Linear에 게시',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'Linear 댓글 설정을 불러오지 못했습니다.',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': '이슈 리뷰',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': '이슈 리뷰',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear 이슈로 세션을 시작할 때 쓰는 프롬프트입니다: 표시 사용자 메시지 + 숨겨진 지침.',
+ },
+ pl: {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': 'Połącz konto GitHub dla pull requestów i issues.',
+ 'settings.integrations.github.status.notConnected': 'Nie połączono',
+ 'settings.integrations.firstParty.title': 'Wbudowane integracje',
+ 'settings.integrations.firstParty.info': 'Logowania do usług dostarczanych z OpenChamber. Zapisujemy je na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': 'Połącz przestrzenie Linear z tym serwerem OpenChamber.',
+ 'settings.integrations.linear.info': 'Połącz jedną lub kilka przestrzeni Linear. OpenChamber zapisuje logowania na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.',
+ 'settings.integrations.linear.status.notConnected': 'Nie połączono',
+ 'settings.integrations.linear.status.connected': 'Połączono',
+ 'settings.integrations.linear.status.waiting': 'Oczekiwanie',
+ 'settings.integrations.linear.actions.connect': 'Połącz',
+ 'settings.integrations.linear.actions.disconnect': 'Rozłącz',
+ 'settings.integrations.linear.actions.addWorkspace': 'Dodaj workspace',
+ 'settings.integrations.linear.actions.switchTo': 'Przełącz na',
+ 'settings.integrations.linear.label.otherWorkspaces': 'Inne przestrzenie',
+ 'settings.integrations.linear.flow.title': 'Oczekiwanie na Linear',
+ 'settings.integrations.linear.flow.description': 'Dokończ logowanie w karcie przeglądarki, która właśnie się otworzyła.',
+ 'settings.integrations.linear.flow.waiting': 'Oczekiwanie na autoryzację…',
+ 'settings.integrations.linear.toast.connected': 'Połączono z Linear',
+ 'settings.integrations.linear.toast.disconnected': 'Rozłączono Linear',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Przełączono workspace Linear',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear',
+ 'settings.integrations.linear.toast.startConnectFailed': 'Nie udało się rozpocząć logowania do Linear',
+ 'settings.integrations.linear.toast.disconnectFailed': 'Nie udało się rozłączyć Linear',
+ 'settings.integrations.linear.toast.authorizationFailed': 'Autoryzacja Linear wygasła. Kliknij Połącz, aby spróbować ponownie.',
+ 'settings.integrations.linear.avatarAlt.withName': 'Awatar Linear użytkownika {name}',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Awatar Linear',
+ 'settings.integrations.linear.label.unknownUser': 'Nieznany użytkownik',
+ 'settings.integrations.linear.mapping.defaultProject': 'Domyślny projekt',
+ 'settings.integrations.linear.mapping.defaultProject.info': 'Nowe sesje ze zgłoszeń Linear używają tego projektu, chyba że zespół zgłoszenia ma własne przypisanie.',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Brak',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Domyślny projekt dla zgłoszeń Linear',
+ 'settings.integrations.linear.mapping.teams': 'Projekty zespołów',
+ 'settings.integrations.linear.mapping.teams.info': 'Opcjonalnie. Zgłoszenie z przypisanego zespołu otworzy się w tym projekcie zamiast w domyślnym.',
+ 'settings.integrations.linear.mapping.teams.useDefault': 'Użyj domyślnego',
+ 'settings.integrations.linear.mapping.teams.aria': 'Projekt dla zespołu Linear {team}',
+ 'settings.integrations.linear.mapping.emptyProjects': 'Najpierw dodaj projekt, a potem przypisz zespoły Linear.',
+ 'settings.integrations.linear.mapping.emptyTeams': 'Ten obszar Linear nie ma zespołów.',
+ 'settings.integrations.linear.mapping.loadFailed': 'Nie udało się wczytać przypisania projektów Linear.',
+ 'settings.integrations.linear.sessionComments.label': 'Komentarze o sesji',
+ 'settings.integrations.linear.sessionComments.info': 'Dodaje komentarz do zgłoszenia, gdy sesja się zaczyna, kończy lub kończy błędem. Komentarze pojawiają się tylko wtedy, gdy ten serwer ma publiczny adres, żeby link otwierał sesję każdemu.',
+ 'settings.integrations.linear.sessionComments.aria': 'Publikuj komentarze o stanie sesji w Linear',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'Nie udało się wczytać ustawień komentarzy Linear.',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Przegląd zgłoszenia',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Przegląd zgłoszenia',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompty używane przy starcie sesji ze zgłoszenia Linear: widoczna wiadomość użytkownika i ukryte instrukcje.',
+ },
+ 'pt-BR': {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': 'Conecte uma conta do GitHub para pull requests e issues.',
+ 'settings.integrations.github.status.notConnected': 'Não conectado',
+ 'settings.integrations.firstParty.title': 'Integrações nativas',
+ 'settings.integrations.firstParty.info': 'Logins dos serviços inclusos no OpenChamber. O login fica neste computador para que a web, o app desktop e um celular emparelhado o compartilhem.',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': 'Conecte espaços do Linear a este servidor OpenChamber.',
+ 'settings.integrations.linear.info': 'Conecte um ou mais espaços do Linear. O OpenChamber guarda os logins neste computador para que a web, o app desktop e um celular emparelhado os compartilhem.',
+ 'settings.integrations.linear.status.notConnected': 'Não conectado',
+ 'settings.integrations.linear.status.connected': 'Conectado',
+ 'settings.integrations.linear.status.waiting': 'Aguardando',
+ 'settings.integrations.linear.actions.connect': 'Conectar',
+ 'settings.integrations.linear.actions.disconnect': 'Desconectar',
+ 'settings.integrations.linear.actions.addWorkspace': 'Adicionar workspace',
+ 'settings.integrations.linear.actions.switchTo': 'Alternar para',
+ 'settings.integrations.linear.label.otherWorkspaces': 'Outros workspaces',
+ 'settings.integrations.linear.flow.title': 'Aguardando o Linear',
+ 'settings.integrations.linear.flow.description': 'Conclua o login na aba do navegador que acabou de abrir.',
+ 'settings.integrations.linear.flow.waiting': 'Aguardando autorização…',
+ 'settings.integrations.linear.toast.connected': 'Linear conectado',
+ 'settings.integrations.linear.toast.disconnected': 'Linear desconectado',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Workspace do Linear alterado',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear',
+ 'settings.integrations.linear.toast.startConnectFailed': 'Não foi possível iniciar o login no Linear',
+ 'settings.integrations.linear.toast.disconnectFailed': 'Não foi possível desconectar o Linear',
+ 'settings.integrations.linear.toast.authorizationFailed': 'A autorização do Linear expirou. Clique em Conectar para tentar de novo.',
+ 'settings.integrations.linear.avatarAlt.withName': 'Avatar do Linear de {name}',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Avatar do Linear',
+ 'settings.integrations.linear.label.unknownUser': 'Usuário desconhecido',
+ 'settings.integrations.linear.mapping.defaultProject': 'Projeto padrão',
+ 'settings.integrations.linear.mapping.defaultProject.info': 'Novas sessões a partir de issues do Linear usam este projeto, a menos que a equipe da issue tenha o próprio mapeamento.',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Nenhum',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Projeto padrão para issues do Linear',
+ 'settings.integrations.linear.mapping.teams': 'Projetos por equipe',
+ 'settings.integrations.linear.mapping.teams.info': 'Opcional. Uma issue de uma equipe mapeada abre nesse projeto em vez do padrão.',
+ 'settings.integrations.linear.mapping.teams.useDefault': 'Usar o padrão',
+ 'settings.integrations.linear.mapping.teams.aria': 'Projeto para a equipe do Linear {team}',
+ 'settings.integrations.linear.mapping.emptyProjects': 'Adicione um projeto primeiro e depois mapeie as equipes do Linear.',
+ 'settings.integrations.linear.mapping.emptyTeams': 'Este espaço do Linear não tem equipes.',
+ 'settings.integrations.linear.mapping.loadFailed': 'Não foi possível carregar o mapeamento de projetos do Linear.',
+ 'settings.integrations.linear.sessionComments.label': 'Comentários de sessão',
+ 'settings.integrations.linear.sessionComments.info': 'Comenta na issue quando uma sessão começa, termina ou falha. Os comentários só são publicados se este servidor tiver um endereço público, para que o link abra a sessão para todos.',
+ 'settings.integrations.linear.sessionComments.aria': 'Publicar comentários de status de sessão no Linear',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'Não foi possível carregar as configurações de comentários do Linear.',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisão de issue',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisão de issue',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados ao iniciar uma sessão a partir de uma issue do Linear: mensagem visível do usuário e instruções ocultas.',
+ },
+ uk: {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': 'Підключіть акаунт GitHub для pull request-ів та issues.',
+ 'settings.integrations.github.status.notConnected': 'Не підключено',
+ 'settings.integrations.firstParty.title': 'Вбудовані інтеграції',
+ 'settings.integrations.firstParty.info': 'Входи до сервісів, що входять до OpenChamber. Логін лишається на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються одним обліковим записом.',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': 'Підключіть Linear workspace до цього сервера OpenChamber. Можна кілька.',
+ 'settings.integrations.linear.info': 'Підключіть один або кілька Linear workspace. OpenChamber зберігає входи на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються ними.',
+ 'settings.integrations.linear.status.notConnected': 'Не підключено',
+ 'settings.integrations.linear.status.connected': 'Підключено',
+ 'settings.integrations.linear.status.waiting': 'Очікування',
+ 'settings.integrations.linear.actions.connect': 'Підключити',
+ 'settings.integrations.linear.actions.disconnect': 'Відключити',
+ 'settings.integrations.linear.actions.addWorkspace': 'Додати workspace',
+ 'settings.integrations.linear.actions.switchTo': 'Перемкнути на',
+ 'settings.integrations.linear.label.otherWorkspaces': 'Інші workspace',
+ 'settings.integrations.linear.flow.title': 'Очікування Linear',
+ 'settings.integrations.linear.flow.description': 'Завершіть вхід у вкладці браузера, яка щойно відкрилась.',
+ 'settings.integrations.linear.flow.waiting': 'Очікування авторизації…',
+ 'settings.integrations.linear.toast.connected': 'Linear підключено',
+ 'settings.integrations.linear.toast.disconnected': 'Linear відключено',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace',
+ 'settings.integrations.linear.toast.startConnectFailed': 'Не вдалося почати вхід у Linear',
+ 'settings.integrations.linear.toast.disconnectFailed': 'Не вдалося відключити Linear',
+ 'settings.integrations.linear.toast.authorizationFailed': 'Авторизація Linear завершилась за часом. Натисніть Підключити ще раз.',
+ 'settings.integrations.linear.avatarAlt.withName': 'Аватар Linear для {name}',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Аватар Linear',
+ 'settings.integrations.linear.label.unknownUser': 'Невідомий користувач',
+ 'settings.integrations.linear.mapping.defaultProject': 'Проєкт за замовчуванням',
+ 'settings.integrations.linear.mapping.defaultProject.info': 'Нові сесії з Linear issue використовують цей проєкт, якщо в команди issue немає власної прив’язки.',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Немає',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Проєкт за замовчуванням для Linear issue',
+ 'settings.integrations.linear.mapping.teams': 'Проєкти команд',
+ 'settings.integrations.linear.mapping.teams.info': 'Не обов’язково. Issue з прив’язаної команди відкриється в цьому проєкті, а не в типовому.',
+ 'settings.integrations.linear.mapping.teams.useDefault': 'Використати типовий',
+ 'settings.integrations.linear.mapping.teams.aria': 'Проєкт для команди Linear {team}',
+ 'settings.integrations.linear.mapping.emptyProjects': 'Спочатку додайте проєкт, потім прив’яжіть команди Linear.',
+ 'settings.integrations.linear.mapping.emptyTeams': 'У цьому робочому просторі Linear немає команд.',
+ 'settings.integrations.linear.mapping.loadFailed': 'Не вдалося завантажити прив’язку проєктів Linear.',
+ 'settings.integrations.linear.sessionComments.label': 'Коментарі про сесію',
+ 'settings.integrations.linear.sessionComments.info': 'Додає коментар до тікета, коли сесія починається, завершується або падає. Коментарі публікуються, лише якщо цей сервер має публічну адресу, щоб посилання відкривало сесію для всіх.',
+ 'settings.integrations.linear.sessionComments.aria': 'Публікувати коментарі про стан сесії в Linear',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'Не вдалося завантажити налаштування коментарів Linear.',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Огляд issue',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Огляд issue',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': 'Промпти для старту сесії з Linear issue: видиме повідомлення користувача та приховані інструкції.',
+ },
+ 'zh-CN': {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': '连接 GitHub 账号以处理拉取请求和议题。',
+ 'settings.integrations.github.status.notConnected': '未连接',
+ 'settings.integrations.firstParty.title': '内置集成',
+ 'settings.integrations.firstParty.info': 'OpenChamber 自带服务的登录。登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它。',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': '将 Linear 工作区连接到此 OpenChamber 服务器。可以连接多个。',
+ 'settings.integrations.linear.info': '连接一个或多个 Linear 工作区。OpenChamber 把登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它们。',
+ 'settings.integrations.linear.status.notConnected': '未连接',
+ 'settings.integrations.linear.status.connected': '已连接',
+ 'settings.integrations.linear.status.waiting': '等待中',
+ 'settings.integrations.linear.actions.connect': '连接',
+ 'settings.integrations.linear.actions.disconnect': '断开',
+ 'settings.integrations.linear.actions.addWorkspace': '添加工作区',
+ 'settings.integrations.linear.actions.switchTo': '切换到',
+ 'settings.integrations.linear.label.otherWorkspaces': '其他工作区',
+ 'settings.integrations.linear.flow.title': '正在等待 Linear',
+ 'settings.integrations.linear.flow.description': '请在刚打开的浏览器标签页中完成登录。',
+ 'settings.integrations.linear.flow.waiting': '正在等待授权…',
+ 'settings.integrations.linear.toast.connected': '已连接 Linear',
+ 'settings.integrations.linear.toast.disconnected': '已断开 Linear',
+ 'settings.integrations.linear.toast.workspaceSwitched': '已切换 Linear 工作区',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区',
+ 'settings.integrations.linear.toast.startConnectFailed': '无法开始 Linear 登录',
+ 'settings.integrations.linear.toast.disconnectFailed': '无法断开 Linear',
+ 'settings.integrations.linear.toast.authorizationFailed': 'Linear 授权已超时。请再次点击连接。',
+ 'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 头像',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Linear 头像',
+ 'settings.integrations.linear.label.unknownUser': '未知用户',
+ 'settings.integrations.linear.mapping.defaultProject': '默认项目',
+ 'settings.integrations.linear.mapping.defaultProject.info': '从 Linear Issue 新建的会话会使用此项目,除非该 Issue 所属团队有单独映射。',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': '无',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的默认项目',
+ 'settings.integrations.linear.mapping.teams': '团队项目',
+ 'settings.integrations.linear.mapping.teams.info': '可选。来自已映射团队的 Issue 会在该项目中打开,而不是默认项目。',
+ 'settings.integrations.linear.mapping.teams.useDefault': '使用默认',
+ 'settings.integrations.linear.mapping.teams.aria': 'Linear 团队 {team} 的项目',
+ 'settings.integrations.linear.mapping.emptyProjects': '请先添加一个项目,再映射 Linear 团队。',
+ 'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作区没有团队。',
+ 'settings.integrations.linear.mapping.loadFailed': '无法加载 Linear 项目映射。',
+ 'settings.integrations.linear.sessionComments.label': '会话评论',
+ 'settings.integrations.linear.sessionComments.info': '会话开始、完成或失败时在议题下留言。仅当此服务器拥有公网地址时才发布,这样链接才能让所有人打开该会话。',
+ 'settings.integrations.linear.sessionComments.aria': '将会话状态评论发布到 Linear',
+ 'settings.integrations.linear.sessionComments.loadFailed': '无法加载 Linear 评论设置。',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 审查',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 审查',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': '从 Linear Issue 开始会话时使用的提示词:可见用户消息 + 隐藏指令。',
+ },
+ 'zh-TW': {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': '連接 GitHub 帳號以處理拉取請求與議題。',
+ 'settings.integrations.github.status.notConnected': '未連接',
+ 'settings.integrations.firstParty.title': '內建整合',
+ 'settings.integrations.firstParty.info': 'OpenChamber 內建服務的登入。登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它。',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': '將 Linear 工作區連線到此 OpenChamber 伺服器。可以連線多個。',
+ 'settings.integrations.linear.info': '連接一個或多個 Linear 工作區。OpenChamber 把登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它們。',
+ 'settings.integrations.linear.status.notConnected': '未連線',
+ 'settings.integrations.linear.status.connected': '已連線',
+ 'settings.integrations.linear.status.waiting': '等待中',
+ 'settings.integrations.linear.actions.connect': '連線',
+ 'settings.integrations.linear.actions.disconnect': '中斷連線',
+ 'settings.integrations.linear.actions.addWorkspace': '新增工作區',
+ 'settings.integrations.linear.actions.switchTo': '切換到',
+ 'settings.integrations.linear.label.otherWorkspaces': '其他工作區',
+ 'settings.integrations.linear.flow.title': '正在等待 Linear',
+ 'settings.integrations.linear.flow.description': '請在剛開啟的瀏覽器分頁中完成登入。',
+ 'settings.integrations.linear.flow.waiting': '正在等待授權…',
+ 'settings.integrations.linear.toast.connected': '已連線 Linear',
+ 'settings.integrations.linear.toast.disconnected': '已中斷 Linear',
+ 'settings.integrations.linear.toast.workspaceSwitched': '已切換 Linear 工作區',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區',
+ 'settings.integrations.linear.toast.startConnectFailed': '無法開始 Linear 登入',
+ 'settings.integrations.linear.toast.disconnectFailed': '無法中斷 Linear',
+ 'settings.integrations.linear.toast.authorizationFailed': 'Linear 授權已逾時。請再次按連線。',
+ 'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 頭像',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Linear 頭像',
+ 'settings.integrations.linear.label.unknownUser': '未知使用者',
+ 'settings.integrations.linear.mapping.defaultProject': '預設專案',
+ 'settings.integrations.linear.mapping.defaultProject.info': '從 Linear Issue 新增的會話會使用此專案,除非該 Issue 所屬團隊有單獨對應。',
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': '無',
+ 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的預設專案',
+ 'settings.integrations.linear.mapping.teams': '團隊專案',
+ 'settings.integrations.linear.mapping.teams.info': '選用。來自已對應團隊的 Issue 會在該專案中開啟,而不是預設專案。',
+ 'settings.integrations.linear.mapping.teams.useDefault': '使用預設',
+ 'settings.integrations.linear.mapping.teams.aria': 'Linear 團隊 {team} 的專案',
+ 'settings.integrations.linear.mapping.emptyProjects': '請先新增一個專案,再對應 Linear 團隊。',
+ 'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作區沒有團隊。',
+ 'settings.integrations.linear.mapping.loadFailed': '無法載入 Linear 專案對應。',
+ 'settings.integrations.linear.sessionComments.label': '工作階段留言',
+ 'settings.integrations.linear.sessionComments.info': '工作階段開始、完成或失敗時在議題留言。僅在這台伺服器有公開位址時才發布,這樣連結才能讓所有人開啟該工作階段。',
+ 'settings.integrations.linear.sessionComments.aria': '將工作階段狀態留言發布到 Linear',
+ 'settings.integrations.linear.sessionComments.loadFailed': '無法載入 Linear 留言設定。',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 審查',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 審查',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': '從 Linear Issue 開始會話時使用的提示詞:可見使用者訊息 + 隱藏指令。',
+ },
+ tr: {
+ 'settings.integrations.github.title': 'GitHub',
+ 'settings.integrations.github.description': "Pull request ve issue'lar için bir GitHub hesabı bağlayın.",
+ 'settings.integrations.github.status.notConnected': 'Bağlı değil',
+ 'settings.integrations.firstParty.title': 'Yerleşik entegrasyonlar',
+ 'settings.integrations.firstParty.info': 'OpenChamber ile gelen hizmetlerin oturumları. Giriş bu bilgisayarda kalır; web, masaüstü ve eşlenen telefon paylaşır.',
+ 'settings.integrations.linear.title': 'Linear',
+ 'settings.integrations.linear.description': 'Linear çalışma alanlarını bu OpenChamber sunucusuna bağla.',
+ 'settings.integrations.linear.info': 'Bir veya daha fazla Linear çalışma alanı bağla. OpenChamber girişleri bu bilgisayarda tutar; web, masaüstü ve eşlenen telefon paylaşır.',
+ 'settings.integrations.linear.status.notConnected': 'Bağlı değil',
+ 'settings.integrations.linear.status.connected': 'Bağlı',
+ 'settings.integrations.linear.status.waiting': 'Bekleniyor',
+ 'settings.integrations.linear.actions.connect': 'Bağlan',
+ 'settings.integrations.linear.actions.disconnect': 'Bağlantıyı kes',
+ 'settings.integrations.linear.actions.addWorkspace': 'Çalışma alanı ekle',
+ 'settings.integrations.linear.actions.switchTo': 'Şuna geç',
+ 'settings.integrations.linear.label.otherWorkspaces': 'Diğer çalışma alanları',
+ 'settings.integrations.linear.flow.title': 'Linear bekleniyor',
+ 'settings.integrations.linear.flow.description': 'Az önce açılan tarayıcı sekmesinde girişi bitir.',
+ 'settings.integrations.linear.flow.waiting': 'Yetkilendirme bekleniyor…',
+ 'settings.integrations.linear.toast.connected': 'Linear bağlandı',
+ 'settings.integrations.linear.toast.disconnected': 'Linear bağlantısı kesildi',
+ 'settings.integrations.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi',
+ 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi',
+ 'settings.integrations.linear.toast.startConnectFailed': 'Linear girişi başlatılamadı',
+ 'settings.integrations.linear.toast.disconnectFailed': 'Linear bağlantısı kesilemedi',
+ 'settings.integrations.linear.toast.authorizationFailed': "Linear yetkilendirmesi zaman aşımına uğradı. Yeniden bağlanmak için Bağlan'a bas.",
+ 'settings.integrations.linear.avatarAlt.withName': '{name} için Linear avatarı',
+ 'settings.integrations.linear.avatarAlt.fallback': 'Linear avatarı',
+ 'settings.integrations.linear.label.unknownUser': 'Bilinmeyen kullanıcı',
+ 'settings.integrations.linear.mapping.defaultProject': 'Varsayılan proje',
+ 'settings.integrations.linear.mapping.defaultProject.info': "Linear issue'larından yeni session'lar, ekibin kendi eşlemesi yoksa bu projeyi kullanır.",
+ 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Yok',
+ 'settings.integrations.linear.mapping.defaultProject.aria': "Linear issue'ları için varsayılan proje",
+ 'settings.integrations.linear.mapping.teams': 'Ekip projeleri',
+ 'settings.integrations.linear.mapping.teams.info': 'İsteğe bağlı. Eşlenen bir ekipten gelen issue varsayılan yerine o projede açılır.',
+ 'settings.integrations.linear.mapping.teams.useDefault': 'Varsayılanı kullan',
+ 'settings.integrations.linear.mapping.teams.aria': 'Linear ekibi {team} için proje',
+ 'settings.integrations.linear.mapping.emptyProjects': 'Önce bir proje ekle, sonra Linear ekiplerini ona eşle.',
+ 'settings.integrations.linear.mapping.emptyTeams': 'Bu Linear çalışma alanında ekip yok.',
+ 'settings.integrations.linear.mapping.loadFailed': 'Linear proje eşlemesi yüklenemedi.',
+ 'settings.integrations.linear.sessionComments.label': 'Oturum yorumları',
+ 'settings.integrations.linear.sessionComments.info': 'Bir oturum başladığında, bittiğinde veya başarısız olduğunda göreve yorum ekler. Bağlantının herkeste açılabilmesi için yorumlar yalnızca bu sunucunun genel bir adresi varsa gönderilir.',
+ 'settings.integrations.linear.sessionComments.aria': 'Oturum durumu yorumlarını Linear’a gönder',
+ 'settings.integrations.linear.sessionComments.loadFailed': 'Linear yorum ayarları yüklenemedi.',
+ 'settings.magicPrompts.sidebar.group.linear': 'Linear',
+ 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue incelemesi',
+ 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue incelemesi',
+ 'settings.magicPrompts.page.group.linearIssueReview.description': "Linear issue'dan session başlatırken kullanılan prompt'lar: görünen kullanıcı mesajı + gizli talimatlar.",
+ },
+} as const;
diff --git a/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts
new file mode 100644
index 00000000..7fc0303e
--- /dev/null
+++ b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from 'bun:test';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+
+const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
+
+const requiredKeys = [
+ 'chat.chatInput.actions.linkLinearIssue',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria',
+ 'chat.chatInput.linked.linearIssue.removeAria',
+ 'session.linearIssuePicker.title',
+ 'session.linearIssuePicker.description',
+ 'session.linearIssuePicker.searchPlaceholder',
+ 'session.linearIssuePicker.empty.notConnected',
+ 'session.linearIssuePicker.empty.runtimeUnavailable',
+ 'session.linearIssuePicker.empty.noIssuesFound',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound',
+ 'session.linearIssuePicker.loading.issues',
+ 'session.linearIssuePicker.loading.more',
+ 'session.linearIssuePicker.actions.openSettings',
+ 'session.linearIssuePicker.actions.useIssue',
+ 'session.linearIssuePicker.actions.loadMore',
+ 'session.linearIssuePicker.actions.openInLinearAria',
+ 'session.linearIssuePicker.toast.loadMoreFailed',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed',
+ 'session.linearIssuePicker.error.notConnected',
+ 'session.linearIssuePicker.error.runtimeUnavailable',
+ 'session.linearIssuePicker.error.issueNotFound',
+ 'chat.chatInput.actions.newSessionFromLinearIssue',
+ 'session.linearIssuePicker.title.createSession',
+ 'session.linearIssuePicker.description.createSession',
+ 'session.linearIssuePicker.error.noMappedProject',
+ 'session.linearIssuePicker.error.noModelSelected',
+ 'session.linearIssuePicker.toast.sendContextFailed',
+ 'session.linearIssuePicker.toast.sessionCreated',
+ 'session.linearIssuePicker.toast.startSessionFailed',
+ 'session.linearIssuePicker.actions.sectionTitle',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria',
+ 'session.linearIssuePicker.actions.createInWorktree',
+ 'session.linearIssuePicker.actions.refresh',
+ 'chat.workStatus.linkedIssues.openLinear',
+ 'session.newWorktree.actions.startFromLinearIssue',
+ 'session.newWorktree.fromLinearIssue',
+ 'session.newWorktree.error.sendLinearContextFailed',
+] as const;
+
+describe('linear issue picker translations', () => {
+ test('provides every required key in every supported locale', () => {
+ const english = linearIssuePickerI18n.en;
+ for (const locale of locales) {
+ for (const key of requiredKeys) {
+ const value = linearIssuePickerI18n[locale][key];
+ expect(value).toBeTruthy();
+ if (locale !== 'en') {
+ expect(value).not.toBe(english[key]);
+ }
+ }
+ }
+ });
+});
diff --git a/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts
new file mode 100644
index 00000000..4a7e70bb
--- /dev/null
+++ b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts
@@ -0,0 +1,471 @@
+/** Linear issue picker / composer strings — merged into each locale's main dictionary. */
+export const linearIssuePickerI18n = {
+ en: {
+ 'chat.chatInput.actions.linkLinearIssue': 'Link Linear Issue',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Open issue in Linear',
+ 'chat.chatInput.linked.linearIssue.removeAria': 'Remove linked Linear issue',
+ 'session.linearIssuePicker.title': 'Link Linear Issue',
+ 'session.linearIssuePicker.description': 'Select an issue from your connected Linear workspace.',
+ 'session.linearIssuePicker.searchPlaceholder': 'Search by title, identifier, or Linear URL',
+ 'session.linearIssuePicker.empty.notConnected': 'Linear is not connected. Connect it in Settings → Integrations.',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear is not available in this app.',
+ 'session.linearIssuePicker.empty.noIssuesFound': 'No issues found',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': 'No open issues found',
+ 'session.linearIssuePicker.loading.issues': 'Loading issues...',
+ 'session.linearIssuePicker.loading.more': 'Loading...',
+ 'session.linearIssuePicker.actions.openSettings': 'Open settings',
+ 'session.linearIssuePicker.actions.useIssue': 'Use {identifier}',
+ 'session.linearIssuePicker.actions.loadMore': 'Load more',
+ 'session.linearIssuePicker.actions.openInLinearAria': 'Open in Linear',
+ 'session.linearIssuePicker.toast.loadMoreFailed': 'Failed to load more issues',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Failed to load issue details',
+ 'session.linearIssuePicker.error.notConnected': 'Linear not connected',
+ 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear is not available in this app',
+ 'session.linearIssuePicker.error.issueNotFound': 'Issue not found',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': 'New Session From Linear Issue',
+ 'session.linearIssuePicker.title.createSession': 'New Session From Linear Issue',
+ 'session.linearIssuePicker.description.createSession': 'Creates a session in the project mapped to this Linear team, with the issue as the first prompt.',
+ 'session.linearIssuePicker.error.noMappedProject': 'Map this Linear team to a project in Settings → Integrations',
+ 'session.linearIssuePicker.error.noModelSelected': 'No model selected',
+ 'session.linearIssuePicker.toast.sendContextFailed': 'Failed to send issue context',
+ 'session.linearIssuePicker.toast.sessionCreated': 'Session created from issue',
+ 'session.linearIssuePicker.toast.startSessionFailed': 'Failed to start session',
+ 'session.linearIssuePicker.actions.sectionTitle': 'Actions',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Toggle worktree',
+ 'session.linearIssuePicker.actions.createInWorktree': 'Create in worktree',
+ 'session.linearIssuePicker.actions.refresh': 'Refresh',
+ 'chat.workStatus.linkedIssues.openLinear': 'Open {identifier} in Linear',
+ 'session.newWorktree.actions.startFromLinearIssue': 'Start from Linear Issue',
+ 'session.newWorktree.fromLinearIssue': 'From {identifier}: {title}',
+ 'session.newWorktree.error.sendLinearContextFailed': 'Failed to send Linear context',
+ },
+ de: {
+ 'chat.chatInput.actions.linkLinearIssue': 'Linear-Issue verknüpfen',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Issue in Linear öffnen',
+ 'chat.chatInput.linked.linearIssue.removeAria': 'Verknüpftes Linear-Issue entfernen',
+ 'session.linearIssuePicker.title': 'Linear-Issue verknüpfen',
+ 'session.linearIssuePicker.description': 'Wähle ein Issue aus deinem verbundenen Linear-Workspace.',
+ 'session.linearIssuePicker.searchPlaceholder': 'Nach Titel, Kennung oder Linear-URL suchen',
+ 'session.linearIssuePicker.empty.notConnected': 'Linear ist nicht verbunden. Verbinde es unter Einstellungen → Integrationen.',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar.',
+ 'session.linearIssuePicker.empty.noIssuesFound': 'Keine Issues gefunden',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Keine offenen Issues gefunden',
+ 'session.linearIssuePicker.loading.issues': 'Issues werden geladen...',
+ 'session.linearIssuePicker.loading.more': 'Wird geladen...',
+ 'session.linearIssuePicker.actions.openSettings': 'Einstellungen öffnen',
+ 'session.linearIssuePicker.actions.useIssue': '{identifier} verwenden',
+ 'session.linearIssuePicker.actions.loadMore': 'Mehr laden',
+ 'session.linearIssuePicker.actions.openInLinearAria': 'In Linear öffnen',
+ 'session.linearIssuePicker.toast.loadMoreFailed': 'Weitere Issues konnten nicht geladen werden',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue-Details konnten nicht geladen werden',
+ 'session.linearIssuePicker.error.notConnected': 'Linear nicht verbunden',
+ 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar',
+ 'session.linearIssuePicker.error.issueNotFound': 'Issue nicht gefunden',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': 'Neue Sitzung aus Linear-Issue',
+ 'session.linearIssuePicker.title.createSession': 'Neue Sitzung aus Linear-Issue',
+ 'session.linearIssuePicker.description.createSession': 'Erstellt eine Sitzung im diesem Linear-Team zugeordneten Projekt, mit dem Issue als erstem Prompt.',
+ 'session.linearIssuePicker.error.noMappedProject': 'Ordne dieses Linear-Team in Einstellungen → Integrationen einem Projekt zu',
+ 'session.linearIssuePicker.error.noModelSelected': 'Kein Modell ausgewählt',
+ 'session.linearIssuePicker.toast.sendContextFailed': 'Issue-Kontext konnte nicht gesendet werden',
+ 'session.linearIssuePicker.toast.sessionCreated': 'Sitzung aus Issue erstellt',
+ 'session.linearIssuePicker.toast.startSessionFailed': 'Sitzung konnte nicht gestartet werden',
+ 'session.linearIssuePicker.actions.sectionTitle': 'Aktionen',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Worktree umschalten',
+ 'session.linearIssuePicker.actions.createInWorktree': 'In Worktree erstellen',
+ 'session.linearIssuePicker.actions.refresh': 'Aktualisieren',
+ 'chat.workStatus.linkedIssues.openLinear': '{identifier} in Linear öffnen',
+ 'session.newWorktree.actions.startFromLinearIssue': 'Von Linear-Issue starten',
+ 'session.newWorktree.fromLinearIssue': 'Von {identifier}: {title}',
+ 'session.newWorktree.error.sendLinearContextFailed': 'Linear-Kontext konnte nicht gesendet werden',
+ },
+ fr: {
+ 'chat.chatInput.actions.linkLinearIssue': 'Lier un ticket Linear',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Ouvrir le ticket dans Linear',
+ 'chat.chatInput.linked.linearIssue.removeAria': 'Retirer le ticket Linear lié',
+ 'session.linearIssuePicker.title': 'Lier un ticket Linear',
+ 'session.linearIssuePicker.description': 'Choisissez un ticket dans votre espace Linear connecté.',
+ 'session.linearIssuePicker.searchPlaceholder': 'Rechercher par titre, identifiant ou URL Linear',
+ 'session.linearIssuePicker.empty.notConnected': 'Linear n’est pas connecté. Connectez-le dans Paramètres → Intégrations.',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear n’est pas disponible dans cette application.',
+ 'session.linearIssuePicker.empty.noIssuesFound': 'Aucun ticket trouvé',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Aucun ticket ouvert trouvé',
+ 'session.linearIssuePicker.loading.issues': 'Chargement des tickets...',
+ 'session.linearIssuePicker.loading.more': 'Chargement...',
+ 'session.linearIssuePicker.actions.openSettings': 'Ouvrir les paramètres',
+ 'session.linearIssuePicker.actions.useIssue': 'Utiliser {identifier}',
+ 'session.linearIssuePicker.actions.loadMore': 'Charger plus',
+ 'session.linearIssuePicker.actions.openInLinearAria': 'Ouvrir dans Linear',
+ 'session.linearIssuePicker.toast.loadMoreFailed': 'Impossible de charger d’autres tickets',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Impossible de charger les détails du ticket',
+ 'session.linearIssuePicker.error.notConnected': 'Linear non connecté',
+ 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear n’est pas disponible dans cette application',
+ 'session.linearIssuePicker.error.issueNotFound': 'Ticket introuvable',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nouvelle session depuis un ticket Linear',
+ 'session.linearIssuePicker.title.createSession': 'Nouvelle session depuis un ticket Linear',
+ 'session.linearIssuePicker.description.createSession': 'Crée une session dans le projet associé à cette équipe Linear, avec le ticket comme premier message.',
+ 'session.linearIssuePicker.error.noMappedProject': 'Associez cette équipe Linear à un projet dans Paramètres → Intégrations',
+ 'session.linearIssuePicker.error.noModelSelected': 'Aucun modèle sélectionné',
+ 'session.linearIssuePicker.toast.sendContextFailed': 'Impossible d’envoyer le contexte du ticket',
+ 'session.linearIssuePicker.toast.sessionCreated': 'Session créée depuis le ticket',
+ 'session.linearIssuePicker.toast.startSessionFailed': 'Impossible de démarrer la session',
+ 'session.linearIssuePicker.actions.sectionTitle': 'Actions disponibles',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activer ou désactiver le worktree',
+ 'session.linearIssuePicker.actions.createInWorktree': 'Créer dans un worktree',
+ 'session.linearIssuePicker.actions.refresh': 'Actualiser',
+ 'chat.workStatus.linkedIssues.openLinear': 'Ouvrir {identifier} dans Linear',
+ 'session.newWorktree.actions.startFromLinearIssue': 'Démarrer depuis un ticket Linear',
+ 'session.newWorktree.fromLinearIssue': 'Depuis {identifier} : {title}',
+ 'session.newWorktree.error.sendLinearContextFailed': 'Impossible d’envoyer le contexte Linear',
+ },
+ es: {
+ 'chat.chatInput.actions.linkLinearIssue': 'Vincular issue de Linear',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue en Linear',
+ 'chat.chatInput.linked.linearIssue.removeAria': 'Quitar issue de Linear vinculado',
+ 'session.linearIssuePicker.title': 'Vincular issue de Linear',
+ 'session.linearIssuePicker.description': 'Elige un issue del espacio de Linear conectado.',
+ 'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador o URL de Linear',
+ 'session.linearIssuePicker.empty.notConnected': 'Linear no está conectado. Conéctalo en Ajustes → Integraciones.',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear no está disponible en esta aplicación.',
+ 'session.linearIssuePicker.empty.noIssuesFound': 'No se encontraron issues',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': 'No se encontraron issues abiertos',
+ 'session.linearIssuePicker.loading.issues': 'Cargando issues...',
+ 'session.linearIssuePicker.loading.more': 'Cargando...',
+ 'session.linearIssuePicker.actions.openSettings': 'Abrir ajustes',
+ 'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}',
+ 'session.linearIssuePicker.actions.loadMore': 'Cargar más',
+ 'session.linearIssuePicker.actions.openInLinearAria': 'Abrir en Linear',
+ 'session.linearIssuePicker.toast.loadMoreFailed': 'No se pudieron cargar más issues',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'No se pudieron cargar los detalles del issue',
+ 'session.linearIssuePicker.error.notConnected': 'Linear no conectado',
+ 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear no está disponible en esta aplicación',
+ 'session.linearIssuePicker.error.issueNotFound': 'Issue no encontrado',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nueva sesión desde un issue de Linear',
+ 'session.linearIssuePicker.title.createSession': 'Nueva sesión desde un issue de Linear',
+ 'session.linearIssuePicker.description.createSession': 'Crea una sesión en el proyecto asignado a este equipo de Linear, con el issue como primer mensaje.',
+ 'session.linearIssuePicker.error.noMappedProject': 'Asigna este equipo de Linear a un proyecto en Ajustes → Integraciones',
+ 'session.linearIssuePicker.error.noModelSelected': 'Ningún modelo seleccionado',
+ 'session.linearIssuePicker.toast.sendContextFailed': 'No se pudo enviar el contexto del issue',
+ 'session.linearIssuePicker.toast.sessionCreated': 'Sesión creada desde el issue',
+ 'session.linearIssuePicker.toast.startSessionFailed': 'No se pudo iniciar la sesión',
+ 'session.linearIssuePicker.actions.sectionTitle': 'Acciones',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activar o desactivar worktree',
+ 'session.linearIssuePicker.actions.createInWorktree': 'Crear en worktree',
+ 'session.linearIssuePicker.actions.refresh': 'Actualizar',
+ 'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} en Linear',
+ 'session.newWorktree.actions.startFromLinearIssue': 'Empezar desde un issue de Linear',
+ 'session.newWorktree.fromLinearIssue': 'Desde {identifier}: {title}',
+ 'session.newWorktree.error.sendLinearContextFailed': 'No se pudo enviar el contexto de Linear',
+ },
+ ja: {
+ 'chat.chatInput.actions.linkLinearIssue': 'Linear Issueをリンク',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'LinearでIssueを開く',
+ 'chat.chatInput.linked.linearIssue.removeAria': 'リンクしたLinear Issueを削除',
+ 'session.linearIssuePicker.title': 'Linear Issueをリンク',
+ 'session.linearIssuePicker.description': '接続中のLinearワークスペースからIssueを選びます。',
+ 'session.linearIssuePicker.searchPlaceholder': 'タイトル、識別子、またはLinearのURLで検索',
+ 'session.linearIssuePicker.empty.notConnected': 'Linearは未接続です。設定 → 連携 で接続してください。',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': 'このアプリではLinearを利用できません。',
+ 'session.linearIssuePicker.empty.noIssuesFound': 'Issueが見つかりません',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': '未完了のIssueはありません',
+ 'session.linearIssuePicker.loading.issues': 'Issueを読み込み中...',
+ 'session.linearIssuePicker.loading.more': '読み込み中...',
+ 'session.linearIssuePicker.actions.openSettings': '設定を開く',
+ 'session.linearIssuePicker.actions.useIssue': '{identifier} を使う',
+ 'session.linearIssuePicker.actions.loadMore': 'さらに読み込む',
+ 'session.linearIssuePicker.actions.openInLinearAria': 'Linearで開く',
+ 'session.linearIssuePicker.toast.loadMoreFailed': 'これ以上のIssueを読み込めませんでした',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issueの詳細を読み込めませんでした',
+ 'session.linearIssuePicker.error.notConnected': 'Linear未接続',
+ 'session.linearIssuePicker.error.runtimeUnavailable': 'このアプリではLinearを利用できません',
+ 'session.linearIssuePicker.error.issueNotFound': 'Issueが見つかりません',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear Issueから新しいセッション',
+ 'session.linearIssuePicker.title.createSession': 'Linear Issueから新しいセッション',
+ 'session.linearIssuePicker.description.createSession': 'このLinearチームに割り当てたプロジェクトでセッションを作り、Issueを最初のプロンプトにします。',
+ 'session.linearIssuePicker.error.noMappedProject': '設定 → 連携 でこのLinearチームをプロジェクトに割り当ててください',
+ 'session.linearIssuePicker.error.noModelSelected': 'モデルが選択されていません',
+ 'session.linearIssuePicker.toast.sendContextFailed': 'Issueのコンテキストを送信できませんでした',
+ 'session.linearIssuePicker.toast.sessionCreated': 'Issueからセッションを作成しました',
+ 'session.linearIssuePicker.toast.startSessionFailed': 'セッションを開始できませんでした',
+ 'session.linearIssuePicker.actions.sectionTitle': '操作',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': 'ワークツリーを切り替え',
+ 'session.linearIssuePicker.actions.createInWorktree': 'ワークツリーで作成',
+ 'session.linearIssuePicker.actions.refresh': '更新',
+ 'chat.workStatus.linkedIssues.openLinear': 'Linearで {identifier} を開く',
+ 'session.newWorktree.actions.startFromLinearIssue': 'Linear Issueから開始',
+ 'session.newWorktree.fromLinearIssue': '{identifier}: {title}から',
+ 'session.newWorktree.error.sendLinearContextFailed': 'Linearのコンテキストを送信できませんでした',
+ },
+ 'pt-BR': {
+ 'chat.chatInput.actions.linkLinearIssue': 'Vincular issue do Linear',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue no Linear',
+ 'chat.chatInput.linked.linearIssue.removeAria': 'Remover issue do Linear vinculada',
+ 'session.linearIssuePicker.title': 'Vincular issue do Linear',
+ 'session.linearIssuePicker.description': 'Selecione uma issue do espaço Linear conectado.',
+ 'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador ou URL do Linear',
+ 'session.linearIssuePicker.empty.notConnected': 'O Linear não está conectado. Conecte em Configurações → Integrações.',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': 'O Linear não está disponível neste app.',
+ 'session.linearIssuePicker.empty.noIssuesFound': 'Nenhuma issue encontrada',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nenhuma issue aberta encontrada',
+ 'session.linearIssuePicker.loading.issues': 'Carregando issues...',
+ 'session.linearIssuePicker.loading.more': 'Carregando...',
+ 'session.linearIssuePicker.actions.openSettings': 'Abrir configurações',
+ 'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}',
+ 'session.linearIssuePicker.actions.loadMore': 'Carregar mais',
+ 'session.linearIssuePicker.actions.openInLinearAria': 'Abrir no Linear',
+ 'session.linearIssuePicker.toast.loadMoreFailed': 'Não foi possível carregar mais issues',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Não foi possível carregar os detalhes da issue',
+ 'session.linearIssuePicker.error.notConnected': 'Linear não conectado',
+ 'session.linearIssuePicker.error.runtimeUnavailable': 'O Linear não está disponível neste app',
+ 'session.linearIssuePicker.error.issueNotFound': 'Issue não encontrada',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nova sessão a partir de uma issue do Linear',
+ 'session.linearIssuePicker.title.createSession': 'Nova sessão a partir de uma issue do Linear',
+ 'session.linearIssuePicker.description.createSession': 'Cria uma sessão no projeto associado a esta equipe do Linear, com a issue como o primeiro prompt.',
+ 'session.linearIssuePicker.error.noMappedProject': 'Associe esta equipe do Linear a um projeto em Configurações → Integrações',
+ 'session.linearIssuePicker.error.noModelSelected': 'Nenhum modelo selecionado',
+ 'session.linearIssuePicker.toast.sendContextFailed': 'Não foi possível enviar o contexto da issue',
+ 'session.linearIssuePicker.toast.sessionCreated': 'Sessão criada a partir da issue',
+ 'session.linearIssuePicker.toast.startSessionFailed': 'Não foi possível iniciar a sessão',
+ 'session.linearIssuePicker.actions.sectionTitle': 'Ações',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Ativar ou desativar worktree',
+ 'session.linearIssuePicker.actions.createInWorktree': 'Criar em worktree',
+ 'session.linearIssuePicker.actions.refresh': 'Atualizar',
+ 'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} no Linear',
+ 'session.newWorktree.actions.startFromLinearIssue': 'Começar a partir de uma issue do Linear',
+ 'session.newWorktree.fromLinearIssue': 'De {identifier}: {title}',
+ 'session.newWorktree.error.sendLinearContextFailed': 'Não foi possível enviar o contexto do Linear',
+ },
+ uk: {
+ 'chat.chatInput.actions.linkLinearIssue': 'Прив’язати Linear issue',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Відкрити issue в Linear',
+ 'chat.chatInput.linked.linearIssue.removeAria': 'Прибрати прив’язаний Linear issue',
+ 'session.linearIssuePicker.title': 'Прив’язати Linear issue',
+ 'session.linearIssuePicker.description': 'Оберіть issue з підключеного робочого простору Linear.',
+ 'session.linearIssuePicker.searchPlaceholder': 'Пошук за назвою, ідентифікатором або URL Linear',
+ 'session.linearIssuePicker.empty.notConnected': 'Linear не підключено. Підключіть його в Налаштуваннях → Інтеграції.',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear недоступний у цьому застосунку.',
+ 'session.linearIssuePicker.empty.noIssuesFound': 'Issue не знайдено',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Відкритих issue немає',
+ 'session.linearIssuePicker.loading.issues': 'Завантаження issue...',
+ 'session.linearIssuePicker.loading.more': 'Завантаження...',
+ 'session.linearIssuePicker.actions.openSettings': 'Відкрити налаштування',
+ 'session.linearIssuePicker.actions.useIssue': 'Використати {identifier}',
+ 'session.linearIssuePicker.actions.loadMore': 'Завантажити ще',
+ 'session.linearIssuePicker.actions.openInLinearAria': 'Відкрити в Linear',
+ 'session.linearIssuePicker.toast.loadMoreFailed': 'Не вдалося завантажити більше issue',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Не вдалося завантажити деталі issue',
+ 'session.linearIssuePicker.error.notConnected': 'Linear не підключено',
+ 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear недоступний у цьому застосунку',
+ 'session.linearIssuePicker.error.issueNotFound': 'Issue не знайдено',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': 'Нова сесія з Linear issue',
+ 'session.linearIssuePicker.title.createSession': 'Нова сесія з Linear issue',
+ 'session.linearIssuePicker.description.createSession': 'Створює сесію в проєкті, прив’язаному до цієї команди Linear, з issue як першим запитом.',
+ 'session.linearIssuePicker.error.noMappedProject': 'Прив’яжіть цю команду Linear до проєкту в Налаштуваннях → Інтеграції',
+ 'session.linearIssuePicker.error.noModelSelected': 'Модель не вибрано',
+ 'session.linearIssuePicker.toast.sendContextFailed': 'Не вдалося надіслати контекст issue',
+ 'session.linearIssuePicker.toast.sessionCreated': 'Сесію створено з issue',
+ 'session.linearIssuePicker.toast.startSessionFailed': 'Не вдалося почати сесію',
+ 'session.linearIssuePicker.actions.sectionTitle': 'Дії',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Перемкнути worktree',
+ 'session.linearIssuePicker.actions.createInWorktree': 'Створити у worktree',
+ 'session.linearIssuePicker.actions.refresh': 'Оновити',
+ 'chat.workStatus.linkedIssues.openLinear': 'Відкрити {identifier} у Linear',
+ 'session.newWorktree.actions.startFromLinearIssue': 'Почати з Linear issue',
+ 'session.newWorktree.fromLinearIssue': 'З {identifier}: {title}',
+ 'session.newWorktree.error.sendLinearContextFailed': 'Не вдалося надіслати контекст Linear',
+ },
+ ko: {
+ 'chat.chatInput.actions.linkLinearIssue': 'Linear 이슈 연결',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Linear에서 이슈 열기',
+ 'chat.chatInput.linked.linearIssue.removeAria': '연결된 Linear 이슈 제거',
+ 'session.linearIssuePicker.title': 'Linear 이슈 연결',
+ 'session.linearIssuePicker.description': '연결된 Linear 워크스페이스에서 이슈를 선택하세요.',
+ 'session.linearIssuePicker.searchPlaceholder': '제목, 식별자 또는 Linear URL로 검색',
+ 'session.linearIssuePicker.empty.notConnected': 'Linear가 연결되어 있지 않습니다. 설정 → 연동에서 연결하세요.',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다.',
+ 'session.linearIssuePicker.empty.noIssuesFound': '이슈를 찾을 수 없습니다',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': '열린 이슈가 없습니다',
+ 'session.linearIssuePicker.loading.issues': '이슈를 불러오는 중...',
+ 'session.linearIssuePicker.loading.more': '불러오는 중...',
+ 'session.linearIssuePicker.actions.openSettings': '설정 열기',
+ 'session.linearIssuePicker.actions.useIssue': '{identifier} 사용',
+ 'session.linearIssuePicker.actions.loadMore': '더 보기',
+ 'session.linearIssuePicker.actions.openInLinearAria': 'Linear에서 열기',
+ 'session.linearIssuePicker.toast.loadMoreFailed': '이슈를 더 불러오지 못했습니다',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': '이슈 세부 정보를 불러오지 못했습니다',
+ 'session.linearIssuePicker.error.notConnected': 'Linear가 연결되지 않음',
+ 'session.linearIssuePicker.error.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다',
+ 'session.linearIssuePicker.error.issueNotFound': '이슈를 찾을 수 없습니다',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear 이슈로 새 세션 만들기',
+ 'session.linearIssuePicker.title.createSession': 'Linear 이슈로 새 세션 만들기',
+ 'session.linearIssuePicker.description.createSession': '이 Linear 팀에 연결한 프로젝트에서 세션을 만들고, 이슈를 첫 프롬프트로 넣습니다.',
+ 'session.linearIssuePicker.error.noMappedProject': '설정 → 연동에서 이 Linear 팀을 프로젝트에 연결하세요',
+ 'session.linearIssuePicker.error.noModelSelected': '모델이 선택되지 않았습니다',
+ 'session.linearIssuePicker.toast.sendContextFailed': '이슈 컨텍스트를 보내지 못했습니다',
+ 'session.linearIssuePicker.toast.sessionCreated': '이슈에서 세션을 만들었습니다',
+ 'session.linearIssuePicker.toast.startSessionFailed': '세션을 시작하지 못했습니다',
+ 'session.linearIssuePicker.actions.sectionTitle': '작업',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': '워크트리 전환',
+ 'session.linearIssuePicker.actions.createInWorktree': '워크트리에서 만들기',
+ 'session.linearIssuePicker.actions.refresh': '새로고침',
+ 'chat.workStatus.linkedIssues.openLinear': 'Linear에서 {identifier} 열기',
+ 'session.newWorktree.actions.startFromLinearIssue': 'Linear 이슈에서 시작',
+ 'session.newWorktree.fromLinearIssue': '{identifier}: {title}에서',
+ 'session.newWorktree.error.sendLinearContextFailed': 'Linear 컨텍스트를 보내지 못했습니다',
+ },
+ pl: {
+ 'chat.chatInput.actions.linkLinearIssue': 'Powiąż zgłoszenie Linear',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Otwórz zgłoszenie w Linear',
+ 'chat.chatInput.linked.linearIssue.removeAria': 'Usuń powiązane zgłoszenie Linear',
+ 'session.linearIssuePicker.title': 'Powiąż zgłoszenie Linear',
+ 'session.linearIssuePicker.description': 'Wybierz zgłoszenie z połączonego obszaru Linear.',
+ 'session.linearIssuePicker.searchPlaceholder': 'Szukaj po tytule, identyfikatorze lub adresie URL Linear',
+ 'session.linearIssuePicker.empty.notConnected': 'Linear nie jest połączony. Połącz go w Ustawieniach → Integracje.',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji.',
+ 'session.linearIssuePicker.empty.noIssuesFound': 'Nie znaleziono zgłoszeń',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nie znaleziono otwartych zgłoszeń',
+ 'session.linearIssuePicker.loading.issues': 'Ładowanie zgłoszeń...',
+ 'session.linearIssuePicker.loading.more': 'Ładowanie...',
+ 'session.linearIssuePicker.actions.openSettings': 'Otwórz ustawienia',
+ 'session.linearIssuePicker.actions.useIssue': 'Użyj {identifier}',
+ 'session.linearIssuePicker.actions.loadMore': 'Załaduj więcej',
+ 'session.linearIssuePicker.actions.openInLinearAria': 'Otwórz w Linear',
+ 'session.linearIssuePicker.toast.loadMoreFailed': 'Nie udało się załadować kolejnych zgłoszeń',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Nie udało się załadować szczegółów zgłoszenia',
+ 'session.linearIssuePicker.error.notConnected': 'Linear niepołączony',
+ 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji',
+ 'session.linearIssuePicker.error.issueNotFound': 'Nie znaleziono zgłoszenia',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nowa sesja ze zgłoszenia Linear',
+ 'session.linearIssuePicker.title.createSession': 'Nowa sesja ze zgłoszenia Linear',
+ 'session.linearIssuePicker.description.createSession': 'Tworzy sesję w projekcie przypisanym do tego zespołu Linear, ze zgłoszeniem jako pierwszym poleceniem.',
+ 'session.linearIssuePicker.error.noMappedProject': 'Przypisz ten zespół Linear do projektu w Ustawieniach → Integracje',
+ 'session.linearIssuePicker.error.noModelSelected': 'Nie wybrano modelu',
+ 'session.linearIssuePicker.toast.sendContextFailed': 'Nie udało się wysłać kontekstu zgłoszenia',
+ 'session.linearIssuePicker.toast.sessionCreated': 'Utworzono sesję ze zgłoszenia',
+ 'session.linearIssuePicker.toast.startSessionFailed': 'Nie udało się rozpocząć sesji',
+ 'session.linearIssuePicker.actions.sectionTitle': 'Czynności',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Przełącz worktree',
+ 'session.linearIssuePicker.actions.createInWorktree': 'Utwórz w worktree',
+ 'session.linearIssuePicker.actions.refresh': 'Odśwież',
+ 'chat.workStatus.linkedIssues.openLinear': 'Otwórz {identifier} w Linear',
+ 'session.newWorktree.actions.startFromLinearIssue': 'Zacznij od zgłoszenia Linear',
+ 'session.newWorktree.fromLinearIssue': 'Z {identifier}: {title}',
+ 'session.newWorktree.error.sendLinearContextFailed': 'Nie udało się wysłać kontekstu Linear',
+ },
+ 'zh-CN': {
+ 'chat.chatInput.actions.linkLinearIssue': '关联 Linear Issue',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中打开 Issue',
+ 'chat.chatInput.linked.linearIssue.removeAria': '移除已关联的 Linear Issue',
+ 'session.linearIssuePicker.title': '关联 Linear Issue',
+ 'session.linearIssuePicker.description': '从已连接的 Linear 工作区选择一个 Issue。',
+ 'session.linearIssuePicker.searchPlaceholder': '按标题、标识符或 Linear 链接搜索',
+ 'session.linearIssuePicker.empty.notConnected': '尚未连接 Linear。请到设置 → 集成 中连接。',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': '此应用中无法使用 Linear。',
+ 'session.linearIssuePicker.empty.noIssuesFound': '未找到 Issue',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': '没有未完成的 Issue',
+ 'session.linearIssuePicker.loading.issues': '正在加载 Issue...',
+ 'session.linearIssuePicker.loading.more': '正在加载...',
+ 'session.linearIssuePicker.actions.openSettings': '打开设置',
+ 'session.linearIssuePicker.actions.useIssue': '使用 {identifier}',
+ 'session.linearIssuePicker.actions.loadMore': '加载更多',
+ 'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中打开',
+ 'session.linearIssuePicker.toast.loadMoreFailed': '无法加载更多 Issue',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': '无法加载 Issue 详情',
+ 'session.linearIssuePicker.error.notConnected': '未连接 Linear',
+ 'session.linearIssuePicker.error.runtimeUnavailable': '此应用中无法使用 Linear',
+ 'session.linearIssuePicker.error.issueNotFound': '未找到 Issue',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': '从 Linear Issue 新建会话',
+ 'session.linearIssuePicker.title.createSession': '从 Linear Issue 新建会话',
+ 'session.linearIssuePicker.description.createSession': '在映射到此 Linear 团队的项目中创建会话,并以该 Issue 作为第一条提示。',
+ 'session.linearIssuePicker.error.noMappedProject': '请在设置 → 集成 中将此 Linear 团队映射到一个项目',
+ 'session.linearIssuePicker.error.noModelSelected': '未选择模型',
+ 'session.linearIssuePicker.toast.sendContextFailed': '无法发送 Issue 上下文',
+ 'session.linearIssuePicker.toast.sessionCreated': '已从 Issue 创建会话',
+ 'session.linearIssuePicker.toast.startSessionFailed': '无法开始会话',
+ 'session.linearIssuePicker.actions.sectionTitle': '操作',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': '切换 worktree',
+ 'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中创建',
+ 'session.linearIssuePicker.actions.refresh': '刷新',
+ 'chat.workStatus.linkedIssues.openLinear': '在 Linear 中打开 {identifier}',
+ 'session.newWorktree.actions.startFromLinearIssue': '从 Linear Issue 开始',
+ 'session.newWorktree.fromLinearIssue': '来自 {identifier}:{title}',
+ 'session.newWorktree.error.sendLinearContextFailed': '无法发送 Linear 上下文',
+ },
+ 'zh-TW': {
+ 'chat.chatInput.actions.linkLinearIssue': '關聯 Linear Issue',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中開啟 Issue',
+ 'chat.chatInput.linked.linearIssue.removeAria': '移除已關聯的 Linear Issue',
+ 'session.linearIssuePicker.title': '關聯 Linear Issue',
+ 'session.linearIssuePicker.description': '從已連線的 Linear 工作區選擇一個 Issue。',
+ 'session.linearIssuePicker.searchPlaceholder': '依標題、識別碼或 Linear 網址搜尋',
+ 'session.linearIssuePicker.empty.notConnected': '尚未連線 Linear。請到設定 → 整合 中連線。',
+ 'session.linearIssuePicker.empty.runtimeUnavailable': '此應用程式無法使用 Linear。',
+ 'session.linearIssuePicker.empty.noIssuesFound': '找不到 Issue',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': '沒有未完成的 Issue',
+ 'session.linearIssuePicker.loading.issues': '正在載入 Issue...',
+ 'session.linearIssuePicker.loading.more': '正在載入...',
+ 'session.linearIssuePicker.actions.openSettings': '開啟設定',
+ 'session.linearIssuePicker.actions.useIssue': '使用 {identifier}',
+ 'session.linearIssuePicker.actions.loadMore': '載入更多',
+ 'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中開啟',
+ 'session.linearIssuePicker.toast.loadMoreFailed': '無法載入更多 Issue',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': '無法載入 Issue 詳細資料',
+ 'session.linearIssuePicker.error.notConnected': '未連線 Linear',
+ 'session.linearIssuePicker.error.runtimeUnavailable': '此應用程式無法使用 Linear',
+ 'session.linearIssuePicker.error.issueNotFound': '找不到 Issue',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': '從 Linear Issue 新增會話',
+ 'session.linearIssuePicker.title.createSession': '從 Linear Issue 新增會話',
+ 'session.linearIssuePicker.description.createSession': '在對應到此 Linear 團隊的專案中建立會話,並以該 Issue 作為第一則提示。',
+ 'session.linearIssuePicker.error.noMappedProject': '請在設定 → 整合 中將此 Linear 團隊對應到一個專案',
+ 'session.linearIssuePicker.error.noModelSelected': '尚未選擇模型',
+ 'session.linearIssuePicker.toast.sendContextFailed': '無法傳送 Issue 內容',
+ 'session.linearIssuePicker.toast.sessionCreated': '已從 Issue 建立會話',
+ 'session.linearIssuePicker.toast.startSessionFailed': '無法開始會話',
+ 'session.linearIssuePicker.actions.sectionTitle': '操作',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': '切換 worktree',
+ 'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中建立',
+ 'session.linearIssuePicker.actions.refresh': '重新整理',
+ 'chat.workStatus.linkedIssues.openLinear': '在 Linear 中開啟 {identifier}',
+ 'session.newWorktree.actions.startFromLinearIssue': '從 Linear Issue 開始',
+ 'session.newWorktree.fromLinearIssue': '來自 {identifier}:{title}',
+ 'session.newWorktree.error.sendLinearContextFailed': '無法傳送 Linear 內容',
+ },
+ tr: {
+ 'chat.chatInput.actions.linkLinearIssue': 'Linear Issue bağla',
+ 'chat.chatInput.linked.linearIssue.openInBrowserAria': "Issue'u Linear'da aç",
+ 'chat.chatInput.linked.linearIssue.removeAria': "Bağlı Linear issue'u kaldır",
+ 'session.linearIssuePicker.title': 'Linear Issue bağla',
+ 'session.linearIssuePicker.description': 'Bağlı Linear çalışma alanından bir issue seç.',
+ 'session.linearIssuePicker.searchPlaceholder': "Başlığa, tanımlayıcıya veya Linear URL'sine göre ara",
+ 'session.linearIssuePicker.empty.notConnected': "Linear bağlı değil. Ayarlar → Entegrasyonlar'dan bağla.",
+ 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor.',
+ 'session.linearIssuePicker.empty.noIssuesFound': 'Issue bulunamadı',
+ 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Açık issue bulunamadı',
+ 'session.linearIssuePicker.loading.issues': "Issue'lar yükleniyor...",
+ 'session.linearIssuePicker.loading.more': 'Yükleniyor...',
+ 'session.linearIssuePicker.actions.openSettings': 'Ayarları aç',
+ 'session.linearIssuePicker.actions.useIssue': '{identifier} kullan',
+ 'session.linearIssuePicker.actions.loadMore': 'Daha fazla yükle',
+ 'session.linearIssuePicker.actions.openInLinearAria': "Linear'da aç",
+ 'session.linearIssuePicker.toast.loadMoreFailed': 'Daha fazla issue yüklenemedi',
+ 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue ayrıntıları yüklenemedi',
+ 'session.linearIssuePicker.error.notConnected': 'Linear bağlı değil',
+ 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor',
+ 'session.linearIssuePicker.error.issueNotFound': 'Issue bulunamadı',
+ 'chat.chatInput.actions.newSessionFromLinearIssue': "Linear Issue'dan yeni session",
+ 'session.linearIssuePicker.title.createSession': "Linear Issue'dan yeni session",
+ 'session.linearIssuePicker.description.createSession': 'Bu Linear ekibine eşlenen projede bir session oluşturur; ilk prompt issue olur.',
+ 'session.linearIssuePicker.error.noMappedProject': "Bu Linear ekibini Ayarlar → Entegrasyonlar'da bir projeye eşle",
+ 'session.linearIssuePicker.error.noModelSelected': 'Model seçilmedi',
+ 'session.linearIssuePicker.toast.sendContextFailed': 'Issue bağlamı gönderilemedi',
+ 'session.linearIssuePicker.toast.sessionCreated': "Issue'dan session oluşturuldu",
+ 'session.linearIssuePicker.toast.startSessionFailed': 'Session başlatılamadı',
+ 'session.linearIssuePicker.actions.sectionTitle': 'İşlemler',
+ 'session.linearIssuePicker.actions.toggleWorktreeAria': "Worktree'yi aç veya kapat",
+ 'session.linearIssuePicker.actions.createInWorktree': "Worktree'de oluştur",
+ 'session.linearIssuePicker.actions.refresh': 'Yenile',
+ 'chat.workStatus.linkedIssues.openLinear': "{identifier} issue'unu Linear'da aç",
+ 'session.newWorktree.actions.startFromLinearIssue': "Linear Issue'dan başla",
+ 'session.newWorktree.fromLinearIssue': '{identifier}: {title}',
+ 'session.newWorktree.error.sendLinearContextFailed': 'Linear bağlamı gönderilemedi',
+ },
+} as const;
diff --git a/packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts
new file mode 100644
index 00000000..ed6424f5
--- /dev/null
+++ b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, test } from 'bun:test';
+import { linearPanelI18n } from './linear-panel.i18n';
+
+const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
+
+const requiredKeys = [
+ 'contextPanel.mode.linear',
+ 'contextRail.surface.linear.description',
+ 'contextPanel.linear.actions.backToList',
+ 'contextPanel.linear.actions.startSession',
+ 'contextPanel.linear.actions.closeIssue',
+ 'contextPanel.linear.actions.closeSearch',
+ 'contextPanel.linear.label.status',
+ 'contextPanel.linear.label.team',
+ 'contextPanel.linear.label.assignee',
+ 'contextPanel.linear.label.unassigned',
+ 'contextPanel.linear.label.priority',
+ 'contextPanel.linear.label.labels',
+ 'contextPanel.linear.priority.none',
+ 'contextPanel.linear.priority.urgent',
+ 'contextPanel.linear.priority.high',
+ 'contextPanel.linear.priority.medium',
+ 'contextPanel.linear.priority.low',
+ 'contextPanel.linear.label.comments',
+ 'contextPanel.linear.label.statusAria',
+ 'contextPanel.linear.label.workspace',
+ 'contextPanel.linear.label.workspaceAria',
+ 'contextPanel.linear.filter.statusAria',
+ 'contextPanel.linear.filter.assigneeAria',
+ 'contextPanel.linear.filter.teamAria',
+ 'contextPanel.linear.filter.priorityAria',
+ 'contextPanel.linear.filter.searchAria',
+ 'contextPanel.linear.filter.clear',
+ 'contextPanel.linear.filter.clearAria',
+ 'contextPanel.linear.filter.status.all',
+ 'contextPanel.linear.filter.status.backlog',
+ 'contextPanel.linear.filter.status.todo',
+ 'contextPanel.linear.filter.status.started',
+ 'contextPanel.linear.filter.status.inReview',
+ 'contextPanel.linear.filter.status.completed',
+ 'contextPanel.linear.filter.status.canceled',
+ 'contextPanel.linear.filter.status.duplicate',
+ 'contextPanel.linear.filter.assignee.any',
+ 'contextPanel.linear.filter.assignee.me',
+ 'contextPanel.linear.filter.team.all',
+ 'contextPanel.linear.filter.priority.all',
+ 'contextPanel.linear.empty.noDescription',
+ 'contextPanel.linear.empty.noComments',
+ 'contextPanel.linear.empty.noMatchingIssues',
+ 'contextPanel.linear.loading.issue',
+ 'contextPanel.linear.toast.statusUpdated',
+ 'contextPanel.linear.toast.statusUpdateFailed',
+ 'contextPanel.linear.toast.closeFailed',
+ 'contextPanel.linear.toast.workspaceSwitched',
+ 'contextPanel.linear.toast.workspaceSwitchFailed',
+ 'contextPanel.linear.error.noCompletedState',
+] as const;
+
+const matchingEnglishAllowed = new Set([
+ 'contextPanel.mode.linear',
+ 'contextPanel.linear.label.status',
+ 'contextPanel.linear.label.team',
+]);
+
+describe('linear panel translations', () => {
+ test('provides every required key in every supported locale', () => {
+ const english = linearPanelI18n.en;
+ for (const locale of locales) {
+ for (const key of requiredKeys) {
+ const value = linearPanelI18n[locale][key];
+ expect(value).toBeTruthy();
+ if (locale !== 'en' && !matchingEnglishAllowed.has(key)) {
+ expect(value).not.toBe(english[key]);
+ }
+ }
+ }
+ });
+});
diff --git a/packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts
new file mode 100644
index 00000000..1774acaf
--- /dev/null
+++ b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts
@@ -0,0 +1,627 @@
+/** Linear context-rail panel strings — merged into each locale's main dictionary. */
+export const linearPanelI18n = {
+ en: {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': 'Browse Linear issues, change status, and start a session',
+ 'contextPanel.linear.actions.backToList': 'Back to issues',
+ 'contextPanel.linear.actions.startSession': 'Start session',
+ 'contextPanel.linear.actions.closeIssue': 'Close issue',
+ 'contextPanel.linear.actions.closeSearch': 'Close search',
+ 'contextPanel.linear.label.status': 'Status',
+ 'contextPanel.linear.label.team': 'Team',
+ 'contextPanel.linear.label.assignee': 'Assignee',
+ 'contextPanel.linear.label.unassigned': 'Unassigned',
+ 'contextPanel.linear.label.priority': 'Priority',
+ 'contextPanel.linear.label.labels': 'Labels',
+ 'contextPanel.linear.priority.none': 'No priority',
+ 'contextPanel.linear.priority.urgent': 'Urgent',
+ 'contextPanel.linear.priority.high': 'High',
+ 'contextPanel.linear.priority.medium': 'Medium',
+ 'contextPanel.linear.priority.low': 'Low',
+ 'contextPanel.linear.label.comments': 'Comments',
+ 'contextPanel.linear.label.statusAria': 'Linear issue status',
+ 'contextPanel.linear.label.workspace': 'Workspace',
+ 'contextPanel.linear.label.workspaceAria': 'Linear workspace',
+ 'contextPanel.linear.filter.statusAria': 'Filter issues by status',
+ 'contextPanel.linear.filter.assigneeAria': 'Filter issues by assignee',
+ 'contextPanel.linear.filter.teamAria': 'Filter issues by team',
+ 'contextPanel.linear.filter.priorityAria': 'Filter issues by priority',
+ 'contextPanel.linear.filter.searchAria': 'Search issues',
+ 'contextPanel.linear.filter.clear': 'Clear',
+ 'contextPanel.linear.filter.clearAria': 'Clear issue filters',
+ 'contextPanel.linear.filter.status.all': 'All',
+ 'contextPanel.linear.filter.status.backlog': 'Backlog',
+ 'contextPanel.linear.filter.status.todo': 'To Do',
+ 'contextPanel.linear.filter.status.started': 'In Progress',
+ 'contextPanel.linear.filter.status.inReview': 'In Review',
+ 'contextPanel.linear.filter.status.completed': 'Done',
+ 'contextPanel.linear.filter.status.canceled': 'Canceled',
+ 'contextPanel.linear.filter.status.duplicate': 'Duplicate',
+ 'contextPanel.linear.filter.assignee.any': 'Anyone',
+ 'contextPanel.linear.filter.assignee.me': 'Assigned to me',
+ 'contextPanel.linear.filter.team.all': 'All teams',
+ 'contextPanel.linear.filter.priority.all': 'All priorities',
+ 'contextPanel.linear.empty.noDescription': 'No description',
+ 'contextPanel.linear.empty.noComments': 'No comments',
+ 'contextPanel.linear.empty.noMatchingIssues': 'No issues match these filters',
+ 'contextPanel.linear.loading.issue': 'Loading issue…',
+ 'contextPanel.linear.toast.statusUpdated': 'Issue status updated',
+ 'contextPanel.linear.toast.statusUpdateFailed': 'Could not update issue status',
+ 'contextPanel.linear.toast.closeFailed': 'Could not close issue',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Switched Linear workspace',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace',
+ 'contextPanel.linear.error.noCompletedState': 'This team has no completed status',
+ },
+ de: {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': 'Linear-Issues durchsuchen, Status ändern und eine Sitzung starten',
+ 'contextPanel.linear.actions.backToList': 'Zurück zu den Issues',
+ 'contextPanel.linear.actions.startSession': 'Sitzung starten',
+ 'contextPanel.linear.actions.closeIssue': 'Issue schließen',
+ 'contextPanel.linear.actions.closeSearch': 'Suche schließen',
+ 'contextPanel.linear.label.status': 'Status',
+ 'contextPanel.linear.label.team': 'Team',
+ 'contextPanel.linear.label.assignee': 'Zugewiesen',
+ 'contextPanel.linear.label.unassigned': 'Nicht zugewiesen',
+ 'contextPanel.linear.label.priority': 'Priorität',
+ 'contextPanel.linear.label.labels': 'Kennzeichnungen',
+ 'contextPanel.linear.priority.none': 'Keine Priorität',
+ 'contextPanel.linear.priority.urgent': 'Dringend',
+ 'contextPanel.linear.priority.high': 'Hoch',
+ 'contextPanel.linear.priority.medium': 'Mittel',
+ 'contextPanel.linear.priority.low': 'Niedrig',
+ 'contextPanel.linear.label.comments': 'Kommentare',
+ 'contextPanel.linear.label.statusAria': 'Status des Linear-Issues',
+ 'contextPanel.linear.label.workspace': 'Arbeitsbereich',
+ 'contextPanel.linear.label.workspaceAria': 'Linear-Workspace',
+ 'contextPanel.linear.filter.statusAria': 'Issues nach Status filtern',
+ 'contextPanel.linear.filter.assigneeAria': 'Issues nach Zuweisung filtern',
+ 'contextPanel.linear.filter.teamAria': 'Issues nach Team filtern',
+ 'contextPanel.linear.filter.priorityAria': 'Issues nach Priorität filtern',
+ 'contextPanel.linear.filter.searchAria': 'Issues durchsuchen',
+ 'contextPanel.linear.filter.clear': 'Zurücksetzen',
+ 'contextPanel.linear.filter.clearAria': 'Issue-Filter zurücksetzen',
+ 'contextPanel.linear.filter.status.all': 'Alle',
+ 'contextPanel.linear.filter.status.backlog': 'Warteliste',
+ 'contextPanel.linear.filter.status.todo': 'Zu tun',
+ 'contextPanel.linear.filter.status.started': 'In Bearbeitung',
+ 'contextPanel.linear.filter.status.inReview': 'In Prüfung',
+ 'contextPanel.linear.filter.status.completed': 'Erledigt',
+ 'contextPanel.linear.filter.status.canceled': 'Abgebrochen',
+ 'contextPanel.linear.filter.status.duplicate': 'Duplikat',
+ 'contextPanel.linear.filter.assignee.any': 'Alle Personen',
+ 'contextPanel.linear.filter.assignee.me': 'Mir zugewiesen',
+ 'contextPanel.linear.filter.team.all': 'Alle Teams',
+ 'contextPanel.linear.filter.priority.all': 'Alle Prioritäten',
+ 'contextPanel.linear.empty.noDescription': 'Keine Beschreibung',
+ 'contextPanel.linear.empty.noComments': 'Keine Kommentare',
+ 'contextPanel.linear.empty.noMatchingIssues': 'Keine Issues passen zu diesen Filtern',
+ 'contextPanel.linear.loading.issue': 'Issue wird geladen…',
+ 'contextPanel.linear.toast.statusUpdated': 'Issue-Status aktualisiert',
+ 'contextPanel.linear.toast.statusUpdateFailed': 'Issue-Status konnte nicht aktualisiert werden',
+ 'contextPanel.linear.toast.closeFailed': 'Issue konnte nicht geschlossen werden',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden',
+ 'contextPanel.linear.error.noCompletedState': 'Dieses Team hat keinen erledigten Status',
+ },
+ fr: {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': 'Parcourir les tickets Linear, changer le statut et démarrer une session',
+ 'contextPanel.linear.actions.backToList': 'Retour aux tickets',
+ 'contextPanel.linear.actions.startSession': 'Démarrer une session',
+ 'contextPanel.linear.actions.closeIssue': 'Fermer le ticket',
+ 'contextPanel.linear.actions.closeSearch': 'Fermer la recherche',
+ 'contextPanel.linear.label.status': 'Statut',
+ 'contextPanel.linear.label.team': 'Équipe',
+ 'contextPanel.linear.label.assignee': 'Assigné',
+ 'contextPanel.linear.label.unassigned': 'Non assigné',
+ 'contextPanel.linear.label.priority': 'Priorité',
+ 'contextPanel.linear.label.labels': 'Libellés',
+ 'contextPanel.linear.priority.none': 'Sans priorité',
+ 'contextPanel.linear.priority.urgent': 'Urgente',
+ 'contextPanel.linear.priority.high': 'Haute',
+ 'contextPanel.linear.priority.medium': 'Moyenne',
+ 'contextPanel.linear.priority.low': 'Basse',
+ 'contextPanel.linear.label.comments': 'Commentaires',
+ 'contextPanel.linear.label.statusAria': 'Statut du ticket Linear',
+ 'contextPanel.linear.label.workspace': 'Espace de travail',
+ 'contextPanel.linear.label.workspaceAria': 'Espace de travail Linear',
+ 'contextPanel.linear.filter.statusAria': 'Filtrer les tickets par statut',
+ 'contextPanel.linear.filter.assigneeAria': 'Filtrer les tickets par assigné',
+ 'contextPanel.linear.filter.teamAria': 'Filtrer les tickets par équipe',
+ 'contextPanel.linear.filter.priorityAria': 'Filtrer les tickets par priorité',
+ 'contextPanel.linear.filter.searchAria': 'Rechercher des tickets',
+ 'contextPanel.linear.filter.clear': 'Effacer',
+ 'contextPanel.linear.filter.clearAria': 'Effacer les filtres des tickets',
+ 'contextPanel.linear.filter.status.all': 'Tous',
+ 'contextPanel.linear.filter.status.backlog': 'Liste d’attente',
+ 'contextPanel.linear.filter.status.todo': 'À faire',
+ 'contextPanel.linear.filter.status.started': 'En cours',
+ 'contextPanel.linear.filter.status.inReview': 'En revue',
+ 'contextPanel.linear.filter.status.completed': 'Terminé',
+ 'contextPanel.linear.filter.status.canceled': 'Annulé',
+ 'contextPanel.linear.filter.status.duplicate': 'Doublon',
+ 'contextPanel.linear.filter.assignee.any': 'Tout le monde',
+ 'contextPanel.linear.filter.assignee.me': 'Assignés à moi',
+ 'contextPanel.linear.filter.team.all': 'Toutes les équipes',
+ 'contextPanel.linear.filter.priority.all': 'Toutes les priorités',
+ 'contextPanel.linear.empty.noDescription': 'Aucune description',
+ 'contextPanel.linear.empty.noComments': 'Aucun commentaire',
+ 'contextPanel.linear.empty.noMatchingIssues': 'Aucun ticket ne correspond à ces filtres',
+ 'contextPanel.linear.loading.issue': 'Chargement du ticket…',
+ 'contextPanel.linear.toast.statusUpdated': 'Statut du ticket mis à jour',
+ 'contextPanel.linear.toast.statusUpdateFailed': 'Impossible de mettre à jour le statut du ticket',
+ 'contextPanel.linear.toast.closeFailed': 'Impossible de fermer le ticket',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Workspace Linear modifié',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear',
+ 'contextPanel.linear.error.noCompletedState': 'Cette équipe n’a pas de statut terminé',
+ },
+ es: {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': 'Explora issues de Linear, cambia el estado e inicia una sesión',
+ 'contextPanel.linear.actions.backToList': 'Volver a los issues',
+ 'contextPanel.linear.actions.startSession': 'Iniciar sesión',
+ 'contextPanel.linear.actions.closeIssue': 'Cerrar issue',
+ 'contextPanel.linear.actions.closeSearch': 'Cerrar búsqueda',
+ 'contextPanel.linear.label.status': 'Estado',
+ 'contextPanel.linear.label.team': 'Equipo',
+ 'contextPanel.linear.label.assignee': 'Asignado',
+ 'contextPanel.linear.label.unassigned': 'Sin asignar',
+ 'contextPanel.linear.label.priority': 'Prioridad',
+ 'contextPanel.linear.label.labels': 'Etiquetas',
+ 'contextPanel.linear.priority.none': 'Sin prioridad',
+ 'contextPanel.linear.priority.urgent': 'Urgente',
+ 'contextPanel.linear.priority.high': 'Alta',
+ 'contextPanel.linear.priority.medium': 'Media',
+ 'contextPanel.linear.priority.low': 'Baja',
+ 'contextPanel.linear.label.comments': 'Comentarios',
+ 'contextPanel.linear.label.statusAria': 'Estado del issue de Linear',
+ 'contextPanel.linear.label.workspace': 'Espacio de trabajo',
+ 'contextPanel.linear.label.workspaceAria': 'Espacio de trabajo de Linear',
+ 'contextPanel.linear.filter.statusAria': 'Filtrar issues por estado',
+ 'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por asignado',
+ 'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipo',
+ 'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridad',
+ 'contextPanel.linear.filter.searchAria': 'Buscar issues',
+ 'contextPanel.linear.filter.clear': 'Borrar',
+ 'contextPanel.linear.filter.clearAria': 'Borrar filtros de issues',
+ 'contextPanel.linear.filter.status.all': 'Todos',
+ 'contextPanel.linear.filter.status.backlog': 'Lista de espera',
+ 'contextPanel.linear.filter.status.todo': 'Por hacer',
+ 'contextPanel.linear.filter.status.started': 'En curso',
+ 'contextPanel.linear.filter.status.inReview': 'En revisión',
+ 'contextPanel.linear.filter.status.completed': 'Hecho',
+ 'contextPanel.linear.filter.status.canceled': 'Cancelado',
+ 'contextPanel.linear.filter.status.duplicate': 'Duplicado',
+ 'contextPanel.linear.filter.assignee.any': 'Cualquiera',
+ 'contextPanel.linear.filter.assignee.me': 'Asignados a mí',
+ 'contextPanel.linear.filter.team.all': 'Todos los equipos',
+ 'contextPanel.linear.filter.priority.all': 'Todas las prioridades',
+ 'contextPanel.linear.empty.noDescription': 'Sin descripción',
+ 'contextPanel.linear.empty.noComments': 'Sin comentarios',
+ 'contextPanel.linear.empty.noMatchingIssues': 'Ningún issue coincide con estos filtros',
+ 'contextPanel.linear.loading.issue': 'Cargando issue…',
+ 'contextPanel.linear.toast.statusUpdated': 'Estado del issue actualizado',
+ 'contextPanel.linear.toast.statusUpdateFailed': 'No se pudo actualizar el estado del issue',
+ 'contextPanel.linear.toast.closeFailed': 'No se pudo cerrar el issue',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear',
+ 'contextPanel.linear.error.noCompletedState': 'Este equipo no tiene un estado completado',
+ },
+ ja: {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': 'Linear の Issue を一覧し、状態を変えてセッションを開始します',
+ 'contextPanel.linear.actions.backToList': 'Issue 一覧に戻る',
+ 'contextPanel.linear.actions.startSession': 'セッションを開始',
+ 'contextPanel.linear.actions.closeIssue': 'Issue をクローズ',
+ 'contextPanel.linear.actions.closeSearch': '検索を閉じる',
+ 'contextPanel.linear.label.status': '状態',
+ 'contextPanel.linear.label.team': 'チーム',
+ 'contextPanel.linear.label.assignee': '担当者',
+ 'contextPanel.linear.label.unassigned': '未割り当て',
+ 'contextPanel.linear.label.priority': '優先度',
+ 'contextPanel.linear.label.labels': 'ラベル',
+ 'contextPanel.linear.priority.none': '優先度なし',
+ 'contextPanel.linear.priority.urgent': '緊急',
+ 'contextPanel.linear.priority.high': '高',
+ 'contextPanel.linear.priority.medium': '中',
+ 'contextPanel.linear.priority.low': '低',
+ 'contextPanel.linear.label.comments': 'コメント',
+ 'contextPanel.linear.label.statusAria': 'Linear Issue の状態',
+ 'contextPanel.linear.label.workspace': 'ワークスペース',
+ 'contextPanel.linear.label.workspaceAria': 'Linear ワークスペース',
+ 'contextPanel.linear.filter.statusAria': '状態で Issue を絞り込む',
+ 'contextPanel.linear.filter.assigneeAria': '担当者で Issue を絞り込む',
+ 'contextPanel.linear.filter.teamAria': 'チームで Issue を絞り込む',
+ 'contextPanel.linear.filter.priorityAria': '優先度で Issue を絞り込む',
+ 'contextPanel.linear.filter.searchAria': 'Issue を検索',
+ 'contextPanel.linear.filter.clear': 'クリア',
+ 'contextPanel.linear.filter.clearAria': 'Issue フィルターをクリア',
+ 'contextPanel.linear.filter.status.all': 'すべて',
+ 'contextPanel.linear.filter.status.backlog': 'バックログ',
+ 'contextPanel.linear.filter.status.todo': '未着手',
+ 'contextPanel.linear.filter.status.started': '進行中',
+ 'contextPanel.linear.filter.status.inReview': 'レビュー中',
+ 'contextPanel.linear.filter.status.completed': '完了',
+ 'contextPanel.linear.filter.status.canceled': 'キャンセル',
+ 'contextPanel.linear.filter.status.duplicate': '重複',
+ 'contextPanel.linear.filter.assignee.any': '全員',
+ 'contextPanel.linear.filter.assignee.me': '自分に割り当て',
+ 'contextPanel.linear.filter.team.all': 'すべてのチーム',
+ 'contextPanel.linear.filter.priority.all': 'すべての優先度',
+ 'contextPanel.linear.empty.noDescription': '説明はありません',
+ 'contextPanel.linear.empty.noComments': 'コメントはありません',
+ 'contextPanel.linear.empty.noMatchingIssues': 'この条件に合う Issue はありません',
+ 'contextPanel.linear.loading.issue': 'Issue を読み込み中…',
+ 'contextPanel.linear.toast.statusUpdated': 'Issue の状態を更新しました',
+ 'contextPanel.linear.toast.statusUpdateFailed': 'Issue の状態を更新できませんでした',
+ 'contextPanel.linear.toast.closeFailed': 'Issue をクローズできませんでした',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした',
+ 'contextPanel.linear.error.noCompletedState': 'このチームには完了ステータスがありません',
+ },
+ ko: {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': 'Linear 이슈를 보고 상태를 바꾼 뒤 세션을 시작합니다',
+ 'contextPanel.linear.actions.backToList': '이슈 목록으로',
+ 'contextPanel.linear.actions.startSession': '세션 시작',
+ 'contextPanel.linear.actions.closeIssue': '이슈 닫기',
+ 'contextPanel.linear.actions.closeSearch': '검색 닫기',
+ 'contextPanel.linear.label.status': '상태',
+ 'contextPanel.linear.label.team': '팀',
+ 'contextPanel.linear.label.assignee': '담당자',
+ 'contextPanel.linear.label.unassigned': '담당자 없음',
+ 'contextPanel.linear.label.priority': '우선순위',
+ 'contextPanel.linear.label.labels': '레이블',
+ 'contextPanel.linear.priority.none': '우선순위 없음',
+ 'contextPanel.linear.priority.urgent': '긴급',
+ 'contextPanel.linear.priority.high': '높음',
+ 'contextPanel.linear.priority.medium': '보통',
+ 'contextPanel.linear.priority.low': '낮음',
+ 'contextPanel.linear.label.comments': '댓글',
+ 'contextPanel.linear.label.statusAria': 'Linear 이슈 상태',
+ 'contextPanel.linear.label.workspace': '워크스페이스',
+ 'contextPanel.linear.label.workspaceAria': 'Linear 워크스페이스',
+ 'contextPanel.linear.filter.statusAria': '상태로 이슈 필터',
+ 'contextPanel.linear.filter.assigneeAria': '담당자로 이슈 필터',
+ 'contextPanel.linear.filter.teamAria': '팀으로 이슈 필터',
+ 'contextPanel.linear.filter.priorityAria': '우선순위로 이슈 필터',
+ 'contextPanel.linear.filter.searchAria': '이슈 검색',
+ 'contextPanel.linear.filter.clear': '지우기',
+ 'contextPanel.linear.filter.clearAria': '이슈 필터 지우기',
+ 'contextPanel.linear.filter.status.all': '전체',
+ 'contextPanel.linear.filter.status.backlog': '백로그',
+ 'contextPanel.linear.filter.status.todo': '할 일',
+ 'contextPanel.linear.filter.status.started': '작업 중',
+ 'contextPanel.linear.filter.status.inReview': '검토 중',
+ 'contextPanel.linear.filter.status.completed': '완료',
+ 'contextPanel.linear.filter.status.canceled': '취소됨',
+ 'contextPanel.linear.filter.status.duplicate': '중복',
+ 'contextPanel.linear.filter.assignee.any': '누구나',
+ 'contextPanel.linear.filter.assignee.me': '내게 할당됨',
+ 'contextPanel.linear.filter.team.all': '모든 팀',
+ 'contextPanel.linear.filter.priority.all': '모든 우선순위',
+ 'contextPanel.linear.empty.noDescription': '설명이 없습니다',
+ 'contextPanel.linear.empty.noComments': '댓글이 없습니다',
+ 'contextPanel.linear.empty.noMatchingIssues': '이 필터에 맞는 이슈가 없습니다',
+ 'contextPanel.linear.loading.issue': '이슈를 불러오는 중…',
+ 'contextPanel.linear.toast.statusUpdated': '이슈 상태를 업데이트했습니다',
+ 'contextPanel.linear.toast.statusUpdateFailed': '이슈 상태를 업데이트하지 못했습니다',
+ 'contextPanel.linear.toast.closeFailed': '이슈를 닫지 못했습니다',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다',
+ 'contextPanel.linear.error.noCompletedState': '이 팀에는 완료 상태가 없습니다',
+ },
+ pl: {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': 'Przeglądaj zgłoszenia Linear, zmieniaj status i uruchamiaj sesję',
+ 'contextPanel.linear.actions.backToList': 'Wróć do zgłoszeń',
+ 'contextPanel.linear.actions.startSession': 'Uruchom sesję',
+ 'contextPanel.linear.actions.closeIssue': 'Zamknij zgłoszenie',
+ 'contextPanel.linear.actions.closeSearch': 'Zamknij wyszukiwanie',
+ 'contextPanel.linear.label.status': 'Status',
+ 'contextPanel.linear.label.team': 'Zespół',
+ 'contextPanel.linear.label.assignee': 'Przypisane',
+ 'contextPanel.linear.label.unassigned': 'Nieprzypisane',
+ 'contextPanel.linear.label.priority': 'Priorytet',
+ 'contextPanel.linear.label.labels': 'Etykiety',
+ 'contextPanel.linear.priority.none': 'Brak priorytetu',
+ 'contextPanel.linear.priority.urgent': 'Pilne',
+ 'contextPanel.linear.priority.high': 'Wysoki',
+ 'contextPanel.linear.priority.medium': 'Średni',
+ 'contextPanel.linear.priority.low': 'Niski',
+ 'contextPanel.linear.label.comments': 'Komentarze',
+ 'contextPanel.linear.label.statusAria': 'Status zgłoszenia Linear',
+ 'contextPanel.linear.label.workspace': 'Obszar roboczy',
+ 'contextPanel.linear.label.workspaceAria': 'Workspace Linear',
+ 'contextPanel.linear.filter.statusAria': 'Filtruj zgłoszenia według statusu',
+ 'contextPanel.linear.filter.assigneeAria': 'Filtruj zgłoszenia według osoby',
+ 'contextPanel.linear.filter.teamAria': 'Filtruj zgłoszenia według zespołu',
+ 'contextPanel.linear.filter.priorityAria': 'Filtruj zgłoszenia według priorytetu',
+ 'contextPanel.linear.filter.searchAria': 'Szukaj zgłoszeń',
+ 'contextPanel.linear.filter.clear': 'Wyczyść',
+ 'contextPanel.linear.filter.clearAria': 'Wyczyść filtry zgłoszeń',
+ 'contextPanel.linear.filter.status.all': 'Wszystkie',
+ 'contextPanel.linear.filter.status.backlog': 'Lista oczekujących',
+ 'contextPanel.linear.filter.status.todo': 'Do zrobienia',
+ 'contextPanel.linear.filter.status.started': 'W toku',
+ 'contextPanel.linear.filter.status.inReview': 'W recenzji',
+ 'contextPanel.linear.filter.status.completed': 'Ukończone',
+ 'contextPanel.linear.filter.status.canceled': 'Anulowane',
+ 'contextPanel.linear.filter.status.duplicate': 'Duplikat',
+ 'contextPanel.linear.filter.assignee.any': 'Ktokolwiek',
+ 'contextPanel.linear.filter.assignee.me': 'Przypisane do mnie',
+ 'contextPanel.linear.filter.team.all': 'Wszystkie zespoły',
+ 'contextPanel.linear.filter.priority.all': 'Wszystkie priorytety',
+ 'contextPanel.linear.empty.noDescription': 'Brak opisu',
+ 'contextPanel.linear.empty.noComments': 'Brak komentarzy',
+ 'contextPanel.linear.empty.noMatchingIssues': 'Żadne zgłoszenie nie pasuje do tych filtrów',
+ 'contextPanel.linear.loading.issue': 'Wczytywanie zgłoszenia…',
+ 'contextPanel.linear.toast.statusUpdated': 'Zaktualizowano status zgłoszenia',
+ 'contextPanel.linear.toast.statusUpdateFailed': 'Nie udało się zaktualizować statusu zgłoszenia',
+ 'contextPanel.linear.toast.closeFailed': 'Nie udało się zamknąć zgłoszenia',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Przełączono workspace Linear',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear',
+ 'contextPanel.linear.error.noCompletedState': 'Ten zespół nie ma statusu ukończenia',
+ },
+ 'pt-BR': {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': 'Navegue pelas issues do Linear, altere o status e inicie uma sessão',
+ 'contextPanel.linear.actions.backToList': 'Voltar às issues',
+ 'contextPanel.linear.actions.startSession': 'Iniciar sessão',
+ 'contextPanel.linear.actions.closeIssue': 'Fechar issue',
+ 'contextPanel.linear.actions.closeSearch': 'Fechar pesquisa',
+ 'contextPanel.linear.label.status': 'Status',
+ 'contextPanel.linear.label.team': 'Equipe',
+ 'contextPanel.linear.label.assignee': 'Responsável',
+ 'contextPanel.linear.label.unassigned': 'Sem responsável',
+ 'contextPanel.linear.label.priority': 'Prioridade',
+ 'contextPanel.linear.label.labels': 'Etiquetas',
+ 'contextPanel.linear.priority.none': 'Sem prioridade',
+ 'contextPanel.linear.priority.urgent': 'Urgente',
+ 'contextPanel.linear.priority.high': 'Alta',
+ 'contextPanel.linear.priority.medium': 'Média',
+ 'contextPanel.linear.priority.low': 'Baixa',
+ 'contextPanel.linear.label.comments': 'Comentários',
+ 'contextPanel.linear.label.statusAria': 'Status da issue do Linear',
+ 'contextPanel.linear.label.workspace': 'Espaço de trabalho',
+ 'contextPanel.linear.label.workspaceAria': 'Workspace do Linear',
+ 'contextPanel.linear.filter.statusAria': 'Filtrar issues por status',
+ 'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por responsável',
+ 'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipe',
+ 'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridade',
+ 'contextPanel.linear.filter.searchAria': 'Pesquisar issues',
+ 'contextPanel.linear.filter.clear': 'Limpar',
+ 'contextPanel.linear.filter.clearAria': 'Limpar filtros de issues',
+ 'contextPanel.linear.filter.status.all': 'Todas',
+ 'contextPanel.linear.filter.status.backlog': 'Lista de espera',
+ 'contextPanel.linear.filter.status.todo': 'A fazer',
+ 'contextPanel.linear.filter.status.started': 'Em andamento',
+ 'contextPanel.linear.filter.status.inReview': 'Em revisão',
+ 'contextPanel.linear.filter.status.completed': 'Concluído',
+ 'contextPanel.linear.filter.status.canceled': 'Cancelado',
+ 'contextPanel.linear.filter.status.duplicate': 'Duplicado',
+ 'contextPanel.linear.filter.assignee.any': 'Qualquer pessoa',
+ 'contextPanel.linear.filter.assignee.me': 'Atribuídas a mim',
+ 'contextPanel.linear.filter.team.all': 'Todas as equipes',
+ 'contextPanel.linear.filter.priority.all': 'Todas as prioridades',
+ 'contextPanel.linear.empty.noDescription': 'Sem descrição',
+ 'contextPanel.linear.empty.noComments': 'Sem comentários',
+ 'contextPanel.linear.empty.noMatchingIssues': 'Nenhuma issue corresponde a estes filtros',
+ 'contextPanel.linear.loading.issue': 'Carregando issue…',
+ 'contextPanel.linear.toast.statusUpdated': 'Status da issue atualizado',
+ 'contextPanel.linear.toast.statusUpdateFailed': 'Não foi possível atualizar o status da issue',
+ 'contextPanel.linear.toast.closeFailed': 'Não foi possível fechar a issue',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Workspace do Linear alterado',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear',
+ 'contextPanel.linear.error.noCompletedState': 'Esta equipe não tem um status de concluído',
+ },
+ uk: {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': 'Переглядайте Linear issue, змінюйте статус і запускайте сесію',
+ 'contextPanel.linear.actions.backToList': 'Назад до issues',
+ 'contextPanel.linear.actions.startSession': 'Почати сесію',
+ 'contextPanel.linear.actions.closeIssue': 'Закрити issue',
+ 'contextPanel.linear.actions.closeSearch': 'Закрити пошук',
+ 'contextPanel.linear.label.status': 'Статус',
+ 'contextPanel.linear.label.team': 'Команда',
+ 'contextPanel.linear.label.assignee': 'Виконавець',
+ 'contextPanel.linear.label.unassigned': 'Не призначено',
+ 'contextPanel.linear.label.priority': 'Пріоритет',
+ 'contextPanel.linear.label.labels': 'Мітки',
+ 'contextPanel.linear.priority.none': 'Без пріоритету',
+ 'contextPanel.linear.priority.urgent': 'Терміновий',
+ 'contextPanel.linear.priority.high': 'Високий',
+ 'contextPanel.linear.priority.medium': 'Середній',
+ 'contextPanel.linear.priority.low': 'Низький',
+ 'contextPanel.linear.label.comments': 'Коментарі',
+ 'contextPanel.linear.label.statusAria': 'Статус Linear issue',
+ 'contextPanel.linear.label.workspace': 'Робочий простір',
+ 'contextPanel.linear.label.workspaceAria': 'Робочий простір Linear',
+ 'contextPanel.linear.filter.statusAria': 'Фільтрувати issues за статусом',
+ 'contextPanel.linear.filter.assigneeAria': 'Фільтрувати issues за виконавцем',
+ 'contextPanel.linear.filter.teamAria': 'Фільтрувати issues за командою',
+ 'contextPanel.linear.filter.priorityAria': 'Фільтрувати issues за пріоритетом',
+ 'contextPanel.linear.filter.searchAria': 'Шукати issues',
+ 'contextPanel.linear.filter.clear': 'Скинути',
+ 'contextPanel.linear.filter.clearAria': 'Скинути фільтри issues',
+ 'contextPanel.linear.filter.status.all': 'Усі',
+ 'contextPanel.linear.filter.status.backlog': 'Беклог',
+ 'contextPanel.linear.filter.status.todo': 'До виконання',
+ 'contextPanel.linear.filter.status.started': 'У роботі',
+ 'contextPanel.linear.filter.status.inReview': 'На перегляді',
+ 'contextPanel.linear.filter.status.completed': 'Готово',
+ 'contextPanel.linear.filter.status.canceled': 'Скасовано',
+ 'contextPanel.linear.filter.status.duplicate': 'Дублікат',
+ 'contextPanel.linear.filter.assignee.any': 'Будь-хто',
+ 'contextPanel.linear.filter.assignee.me': 'Призначені мені',
+ 'contextPanel.linear.filter.team.all': 'Усі команди',
+ 'contextPanel.linear.filter.priority.all': 'Усі пріоритети',
+ 'contextPanel.linear.empty.noDescription': 'Немає опису',
+ 'contextPanel.linear.empty.noComments': 'Немає коментарів',
+ 'contextPanel.linear.empty.noMatchingIssues': 'Немає issues за цими фільтрами',
+ 'contextPanel.linear.loading.issue': 'Завантаження issue…',
+ 'contextPanel.linear.toast.statusUpdated': 'Статус issue оновлено',
+ 'contextPanel.linear.toast.statusUpdateFailed': 'Не вдалося оновити статус issue',
+ 'contextPanel.linear.toast.closeFailed': 'Не вдалося закрити issue',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace',
+ 'contextPanel.linear.error.noCompletedState': 'У цієї команди немає статусу completed',
+ },
+ 'zh-CN': {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': '浏览 Linear Issue、更改状态并开始会话',
+ 'contextPanel.linear.actions.backToList': '返回 Issue 列表',
+ 'contextPanel.linear.actions.startSession': '开始会话',
+ 'contextPanel.linear.actions.closeIssue': '关闭 Issue',
+ 'contextPanel.linear.actions.closeSearch': '关闭搜索',
+ 'contextPanel.linear.label.status': '状态',
+ 'contextPanel.linear.label.team': '团队',
+ 'contextPanel.linear.label.assignee': '负责人',
+ 'contextPanel.linear.label.unassigned': '未指派',
+ 'contextPanel.linear.label.priority': '优先级',
+ 'contextPanel.linear.label.labels': '标签',
+ 'contextPanel.linear.priority.none': '无优先级',
+ 'contextPanel.linear.priority.urgent': '紧急',
+ 'contextPanel.linear.priority.high': '高',
+ 'contextPanel.linear.priority.medium': '中',
+ 'contextPanel.linear.priority.low': '低',
+ 'contextPanel.linear.label.comments': '评论',
+ 'contextPanel.linear.label.statusAria': 'Linear Issue 状态',
+ 'contextPanel.linear.label.workspace': '工作区',
+ 'contextPanel.linear.label.workspaceAria': 'Linear 工作区',
+ 'contextPanel.linear.filter.statusAria': '按状态筛选 Issue',
+ 'contextPanel.linear.filter.assigneeAria': '按负责人筛选 Issue',
+ 'contextPanel.linear.filter.teamAria': '按团队筛选 Issue',
+ 'contextPanel.linear.filter.priorityAria': '按优先级筛选 Issue',
+ 'contextPanel.linear.filter.searchAria': '搜索 Issue',
+ 'contextPanel.linear.filter.clear': '清除',
+ 'contextPanel.linear.filter.clearAria': '清除 Issue 筛选',
+ 'contextPanel.linear.filter.status.all': '全部',
+ 'contextPanel.linear.filter.status.backlog': '待办池',
+ 'contextPanel.linear.filter.status.todo': '待办',
+ 'contextPanel.linear.filter.status.started': '进行中',
+ 'contextPanel.linear.filter.status.inReview': '审核中',
+ 'contextPanel.linear.filter.status.completed': '已完成',
+ 'contextPanel.linear.filter.status.canceled': '已取消',
+ 'contextPanel.linear.filter.status.duplicate': '重复',
+ 'contextPanel.linear.filter.assignee.any': '任何人',
+ 'contextPanel.linear.filter.assignee.me': '指派给我',
+ 'contextPanel.linear.filter.team.all': '所有团队',
+ 'contextPanel.linear.filter.priority.all': '所有优先级',
+ 'contextPanel.linear.empty.noDescription': '没有描述',
+ 'contextPanel.linear.empty.noComments': '没有评论',
+ 'contextPanel.linear.empty.noMatchingIssues': '没有符合这些筛选条件的 Issue',
+ 'contextPanel.linear.loading.issue': '正在加载 Issue…',
+ 'contextPanel.linear.toast.statusUpdated': '已更新 Issue 状态',
+ 'contextPanel.linear.toast.statusUpdateFailed': '无法更新 Issue 状态',
+ 'contextPanel.linear.toast.closeFailed': '无法关闭 Issue',
+ 'contextPanel.linear.toast.workspaceSwitched': '已切换 Linear 工作区',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区',
+ 'contextPanel.linear.error.noCompletedState': '此团队没有已完成状态',
+ },
+ 'zh-TW': {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': '瀏覽 Linear Issue、變更狀態並開始會話',
+ 'contextPanel.linear.actions.backToList': '返回 Issue 列表',
+ 'contextPanel.linear.actions.startSession': '開始會話',
+ 'contextPanel.linear.actions.closeIssue': '關閉 Issue',
+ 'contextPanel.linear.actions.closeSearch': '關閉搜尋',
+ 'contextPanel.linear.label.status': '狀態',
+ 'contextPanel.linear.label.team': '團隊',
+ 'contextPanel.linear.label.assignee': '負責人',
+ 'contextPanel.linear.label.unassigned': '未指派',
+ 'contextPanel.linear.label.priority': '優先級',
+ 'contextPanel.linear.label.labels': '標籤',
+ 'contextPanel.linear.priority.none': '無優先級',
+ 'contextPanel.linear.priority.urgent': '緊急',
+ 'contextPanel.linear.priority.high': '高',
+ 'contextPanel.linear.priority.medium': '中',
+ 'contextPanel.linear.priority.low': '低',
+ 'contextPanel.linear.label.comments': '留言',
+ 'contextPanel.linear.label.statusAria': 'Linear Issue 狀態',
+ 'contextPanel.linear.label.workspace': '工作區',
+ 'contextPanel.linear.label.workspaceAria': 'Linear 工作區',
+ 'contextPanel.linear.filter.statusAria': '依狀態篩選 Issue',
+ 'contextPanel.linear.filter.assigneeAria': '依負責人篩選 Issue',
+ 'contextPanel.linear.filter.teamAria': '依團隊篩選 Issue',
+ 'contextPanel.linear.filter.priorityAria': '依優先級篩選 Issue',
+ 'contextPanel.linear.filter.searchAria': '搜尋 Issue',
+ 'contextPanel.linear.filter.clear': '清除',
+ 'contextPanel.linear.filter.clearAria': '清除 Issue 篩選',
+ 'contextPanel.linear.filter.status.all': '全部',
+ 'contextPanel.linear.filter.status.backlog': '待辦池',
+ 'contextPanel.linear.filter.status.todo': '待辦',
+ 'contextPanel.linear.filter.status.started': '進行中',
+ 'contextPanel.linear.filter.status.inReview': '審核中',
+ 'contextPanel.linear.filter.status.completed': '已完成',
+ 'contextPanel.linear.filter.status.canceled': '已取消',
+ 'contextPanel.linear.filter.status.duplicate': '重複',
+ 'contextPanel.linear.filter.assignee.any': '任何人',
+ 'contextPanel.linear.filter.assignee.me': '指派給我',
+ 'contextPanel.linear.filter.team.all': '所有團隊',
+ 'contextPanel.linear.filter.priority.all': '所有優先級',
+ 'contextPanel.linear.empty.noDescription': '沒有描述',
+ 'contextPanel.linear.empty.noComments': '沒有留言',
+ 'contextPanel.linear.empty.noMatchingIssues': '沒有符合這些篩選條件的 Issue',
+ 'contextPanel.linear.loading.issue': '正在載入 Issue…',
+ 'contextPanel.linear.toast.statusUpdated': '已更新 Issue 狀態',
+ 'contextPanel.linear.toast.statusUpdateFailed': '無法更新 Issue 狀態',
+ 'contextPanel.linear.toast.closeFailed': '無法關閉 Issue',
+ 'contextPanel.linear.toast.workspaceSwitched': '已切換 Linear 工作區',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區',
+ 'contextPanel.linear.error.noCompletedState': '此團隊沒有已完成狀態',
+ },
+ tr: {
+ 'contextPanel.mode.linear': 'Linear',
+ 'contextRail.surface.linear.description': "Linear issue'larını incele, durumu değiştir ve session başlat",
+ 'contextPanel.linear.actions.backToList': 'Issue listesine dön',
+ 'contextPanel.linear.actions.startSession': 'Session başlat',
+ 'contextPanel.linear.actions.closeIssue': "Issue'u kapat",
+ 'contextPanel.linear.actions.closeSearch': 'Aramayı kapat',
+ 'contextPanel.linear.label.status': 'Durum',
+ 'contextPanel.linear.label.team': 'Ekip',
+ 'contextPanel.linear.label.assignee': 'Atanan',
+ 'contextPanel.linear.label.unassigned': 'Atanmamış',
+ 'contextPanel.linear.label.priority': 'Öncelik',
+ 'contextPanel.linear.label.labels': 'Etiketler',
+ 'contextPanel.linear.priority.none': 'Öncelik yok',
+ 'contextPanel.linear.priority.urgent': 'Acil',
+ 'contextPanel.linear.priority.high': 'Yüksek',
+ 'contextPanel.linear.priority.medium': 'Orta',
+ 'contextPanel.linear.priority.low': 'Düşük',
+ 'contextPanel.linear.label.comments': 'Yorumlar',
+ 'contextPanel.linear.label.statusAria': 'Linear issue durumu',
+ 'contextPanel.linear.label.workspace': 'Çalışma alanı',
+ 'contextPanel.linear.label.workspaceAria': 'Linear çalışma alanı',
+ 'contextPanel.linear.filter.statusAria': "Issue'ları duruma göre süz",
+ 'contextPanel.linear.filter.assigneeAria': "Issue'ları atanan kişiye göre süz",
+ 'contextPanel.linear.filter.teamAria': "Issue'ları ekibe göre süz",
+ 'contextPanel.linear.filter.priorityAria': "Issue'ları önceliğe göre süz",
+ 'contextPanel.linear.filter.searchAria': "Issue'larda ara",
+ 'contextPanel.linear.filter.clear': 'Temizle',
+ 'contextPanel.linear.filter.clearAria': "Issue filtrelerini temizle",
+ 'contextPanel.linear.filter.status.all': 'Tümü',
+ 'contextPanel.linear.filter.status.backlog': 'Bekleme listesi',
+ 'contextPanel.linear.filter.status.todo': 'Yapılacak',
+ 'contextPanel.linear.filter.status.started': 'Devam ediyor',
+ 'contextPanel.linear.filter.status.inReview': 'İncelemede',
+ 'contextPanel.linear.filter.status.completed': 'Bitti',
+ 'contextPanel.linear.filter.status.canceled': 'İptal',
+ 'contextPanel.linear.filter.status.duplicate': 'Yinelenen',
+ 'contextPanel.linear.filter.assignee.any': 'Herkes',
+ 'contextPanel.linear.filter.assignee.me': 'Bana atananlar',
+ 'contextPanel.linear.filter.team.all': 'Tüm ekipler',
+ 'contextPanel.linear.filter.priority.all': 'Tüm öncelikler',
+ 'contextPanel.linear.empty.noDescription': 'Açıklama yok',
+ 'contextPanel.linear.empty.noComments': 'Yorum yok',
+ 'contextPanel.linear.empty.noMatchingIssues': 'Bu süzgeçlere uyan issue yok',
+ 'contextPanel.linear.loading.issue': 'Issue yükleniyor…',
+ 'contextPanel.linear.toast.statusUpdated': 'Issue durumu güncellendi',
+ 'contextPanel.linear.toast.statusUpdateFailed': 'Issue durumu güncellenemedi',
+ 'contextPanel.linear.toast.closeFailed': 'Issue kapatılamadı',
+ 'contextPanel.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi',
+ 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi',
+ 'contextPanel.linear.error.noCompletedState': 'Bu ekibin tamamlandı durumu yok',
+ },
+} as const;
diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts
index 9c770d90..6681114c 100644
--- a/packages/ui/src/lib/i18n/messages/pl.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go',
@@ -1193,6 +1194,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.showDotfilesAria': 'Pokaż pliki ukryte',
'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Pokaż rozwinięte narzędzia bash',
'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Pokaż rozwinięte narzędzia edycji',
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
+
'settings.openchamber.visual.field.sessionRecap': 'Generuj podsumowanie sesji',
'settings.openchamber.visual.field.sessionRecapAria': 'Generuj podsumowanie po zakończeniu pracy agenta',
'settings.openchamber.visual.field.sessionSuggestion': 'Generuj sugestię następnej wiadomości użytkownika',
@@ -2268,7 +2272,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'Serwer',
'settings.voice.page.provider.local': 'Lokalny',
'settings.voice.page.tooltip.sttLocal': 'Transkrypcja lokalna na serwerze OpenChamber. Modele pobierają się automatycznie; klucz API nie jest potrzebny.',
- 'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro, angielski). Model pobiera się automatycznie; klucz API nie jest potrzebny.',
+ 'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro dla angielskiego; modele innych języków pobierane przy pierwszym użyciu). Klucz API nie jest potrzebny.',
+ 'settings.voice.page.field.followTextLanguage': 'Dopasuj głos do języka tekstu',
+ 'settings.voice.page.field.followTextLanguageAria': 'Dopasuj głos do języka tekstu',
+ 'settings.voice.page.field.followTextLanguageInfo': 'Gdy odpowiedź jest w innym języku, używany jest głos dla tego języka: pasujący głos macOS albo lokalny model pobierany przy pierwszym użyciu.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (angielski)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 języków europejskich)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (wielojęzyczny)',
@@ -2313,5 +2320,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...linearIntegrationI18n.pl,
...thirdPartyIntegrationI18n.pl,
};
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index 38a3f144..62d658c0 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './pl.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record = {
...settingsDict,
+ ...linearIssuePickerI18n.pl,
+ ...linearPanelI18n.pl,
'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe',
'terminalView.actions.restart': 'Uruchom terminal ponownie',
'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}',
@@ -2370,6 +2374,10 @@ export const dict: Record = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
+ 'gitView.empty.discoveringRepositories': 'Szukanie repozytoriów Git...',
+ 'gitView.empty.discoverFailed': 'Nie udało się przeskanować repozytoriów Git',
+ 'gitView.empty.retryDiscovery': 'Ponów',
+ 'gitView.empty.selectRepositoryPlaceholder': 'Wybierz repozytorium...',
'worktree.bootstrap.toast.failed': 'Konfiguracja drzewa pracy nie powiodła się',
'worktree.bootstrap.toast.failedDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle nie została ukończona.',
'worktree.bootstrap.toast.timeoutDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle przekroczyła limit czasu.',
@@ -2685,6 +2693,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': 'Panel terminala ({shortcut})',
'chat.recap.aria': 'Podsumowanie sesji',
'chat.recap.label': 'Podsumowanie:',
+ 'chat.sessionError.title': 'OpenCode przerwał tę odpowiedź',
+ 'chat.sessionError.noDetails': 'OpenCode nie podał szczegółów. Otwórz raport stanu (Ctrl/Cmd+Shift+L), aby zobaczyć ostatnie błędy.',
+ 'chat.sessionError.noReply': 'OpenCode nie rozpoczął odpowiedzi na tę wiadomość.',
'chat.goal.dialog.titleCreate': 'Ustaw cel sesji',
'chat.goal.dialog.titleManage': 'Cel sesji',
'chat.goal.dialog.objectiveLabel': 'Cel',
@@ -3245,6 +3256,9 @@ export const dict: Record = {
'session.newWorktree.prNumber': 'PR #{number}',
'session.newWorktree.mrNumber': 'MR #{number}',
'session.newWorktree.remoteBranches': 'Zdalne gałęzie',
+ 'session.newWorktree.otherLocalBranches': 'Pozostałe lokalne gałęzie',
+ 'session.newWorktree.otherRemoteBranches': 'Pozostałe zdalne gałęzie',
+
'session.newWorktree.resetToMatchBranchName': 'Zresetuj do nazwy zgodnej z gałęzią',
'session.newWorktree.searchBranches': 'Szukaj gałęzi...',
'session.newWorktree.selectBranch': 'Wybierz gałąź',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
index 76b4b9f5..6f7a1477 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go',
@@ -1923,7 +1924,10 @@ export const settingsDict = {
"settings.voice.page.provider.server": "Servidor",
"settings.voice.page.provider.local": "Local",
"settings.voice.page.tooltip.sttLocal": "Transcrição local no servidor do OpenChamber. Os modelos são baixados automaticamente; não é necessária chave de API.",
- "settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro, inglês). O modelo é baixado automaticamente; não é necessária chave de API.",
+ "settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro para inglês; modelos de outros idiomas são baixados no primeiro uso). Não requer chave de API.",
+ "settings.voice.page.field.followTextLanguage": "Ajustar a voz ao idioma do texto",
+ "settings.voice.page.field.followTextLanguageAria": "Ajustar a voz ao idioma do texto",
+ "settings.voice.page.field.followTextLanguageInfo": "Se uma resposta estiver em outro idioma, uma voz desse idioma é usada: uma voz do macOS correspondente ou um modelo local baixado no primeiro uso.",
"settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglês)",
"settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeus)",
"settings.voice.page.stt.model.whisperBase": "Whisper base (multilíngue)",
@@ -2095,6 +2099,9 @@ export const settingsDict = {
"settings.openchamber.visual.field.activityDefaultModeAria": "Modo padrão de atividade: {option}",
"settings.openchamber.visual.field.showExpandedBashToolsAria": "Mostrar ferramentas de Bash expandidas",
"settings.openchamber.visual.field.showExpandedEditToolsAria": "Mostrar ferramentas de edição expandidas",
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Mostrar sempre a barra de ferramentas do editor',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Mostrar sempre a barra de ferramentas do editor (ancorada sob as abas)',
+
"settings.openchamber.visual.field.bash": "Bash",
"settings.openchamber.visual.field.editTools": "Ferramentas de edição",
"settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensagens do usuário: {option}",
@@ -2320,5 +2327,6 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
+ ...linearIntegrationI18n['pt-BR'],
...thirdPartyIntegrationI18n['pt-BR'],
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index d40ce92e..1a387da7 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './pt-BR.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record = {
...settingsDict,
+ ...linearIssuePickerI18n['pt-BR'],
+ ...linearPanelI18n['pt-BR'],
'terminalView.actions.attachSelection': 'Anexar saída selecionada',
'terminalView.actions.restart': 'Reiniciar terminal',
'chat.message.terminalContext': '{terminal}, linhas {start}-{end}',
@@ -980,6 +984,10 @@ export const dict: Record = {
"gitView.empty.worktreeFeaturesUnavailable": "Os recursos de worktree não estão disponíveis neste modo de workspace.",
"gitView.empty.worktreeSetupDescription": "Finalizando a configuração de worktree e preparando o status do repositório.",
"gitView.empty.worktreeSetupInProgress": "Configuração de worktree em andamento",
+ "gitView.empty.discoveringRepositories": "Procurando repositórios Git...",
+ "gitView.empty.discoverFailed": "Não foi possível verificar os repositórios Git",
+ "gitView.empty.retryDiscovery": "Tentar novamente",
+ "gitView.empty.selectRepositoryPlaceholder": "Selecione um repositório...",
"worktree.bootstrap.toast.failed": "Falha na configuração do worktree",
"worktree.bootstrap.toast.failedDescription": "O worktree foi criado, mas a configuração em segundo plano não terminou.",
"worktree.bootstrap.toast.timeoutDescription": "O worktree foi criado, mas a configuração em segundo plano atingiu o tempo limite.",
@@ -1927,6 +1935,9 @@ export const dict: Record = {
"header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})",
"chat.recap.aria": "Resumo da sessão",
"chat.recap.label": "Resumo:",
+ "chat.sessionError.title": "O OpenCode interrompeu esta resposta",
+ "chat.sessionError.noDetails": "O OpenCode não informou detalhes. Abra o relatório de status (Ctrl/Cmd+Shift+L) para ver os erros recentes.",
+ "chat.sessionError.noReply": "O OpenCode não iniciou uma resposta a esta mensagem.",
"chat.goal.dialog.titleCreate": "Definir objetivo da sessão",
"chat.goal.dialog.titleManage": "Objetivo da sessão",
"chat.goal.dialog.objectiveLabel": "Objetivo",
@@ -2287,6 +2298,9 @@ export const dict: Record = {
"session.newWorktree.noMatchingBranches": "Não há branches coincidentes",
"session.newWorktree.localBranches": "Branches locais",
"session.newWorktree.remoteBranches": "Branches remotas",
+ 'session.newWorktree.otherLocalBranches': 'Outras branches locais',
+ 'session.newWorktree.otherRemoteBranches': 'Outras branches remotas',
+
"session.newWorktree.branchName": "Nome da branch",
"session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature",
"session.newWorktree.actions.change": "Alterar",
diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts
index 34125588..f385efa4 100644
--- a/packages/ui/src/lib/i18n/messages/tr.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go kullanım takibi',
@@ -1786,7 +1787,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'Sunucu',
'settings.voice.page.provider.local': 'Yerel',
'settings.voice.page.tooltip.sttLocal': 'OpenChamber sunucusunda cihaz üstü transkripsiyon. Modeller otomatik indirilir; API anahtarı gerekmez.',
- 'settings.voice.page.tooltip.localTts': 'OpenChamber sunucusunda cihaz üstü sentez (Kokoro, İngilizce). Model otomatik indirilir; API anahtarı gerekmez.',
+ 'settings.voice.page.tooltip.localTts': 'OpenChamber sunucusunda yerel sentez (İngilizce için Kokoro; diğer dillerin modelleri ilk kullanımda indirilir). API anahtarı gerekmez.',
+ 'settings.voice.page.field.followTextLanguage': 'Sesi metnin diline göre seç',
+ 'settings.voice.page.field.followTextLanguageAria': 'Sesi metnin diline göre seç',
+ 'settings.voice.page.field.followTextLanguageInfo': 'Yanıt başka bir dildeyse o dil için bir ses kullanılır: uygun bir macOS sesi veya ilk kullanımda indirilen yerel bir model.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (İngilizce)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 Avrupa dili)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (çok dilli)',
@@ -1950,6 +1954,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.activityDefaultModeAria': 'Etkinlik varsayılan modu: {option}',
'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Genişletilmiş bash araçlarını göster',
'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Genişletilmiş düzenleme araçlarını göster',
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Düzenleyici araç çubuğunu her zaman göster',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Düzenleyici araç çubuğunu her zaman göster (dosya sekmelerinin altına sabitlenmiş)',
'settings.openchamber.visual.field.bash': 'Bash',
'settings.openchamber.visual.field.editTools': 'Düzenleme araçları',
'settings.openchamber.visual.field.userMessageRenderingAria': 'Kullanıcı mesajı görüntüleme: {option}',
@@ -2218,4 +2224,98 @@ export const settingsDict = {
'settings.openchamber.visual.field.sessionTabsAria': 'Başlıktaki session sekmelerini aç/kapat',
'settings.openchamber.visual.field.sessionTabsInfo': 'Açtığınız session\'lar başlıkta sekmeler olarak dizilir. Kapatırsanız düz session başlığına döner.',
...thirdPartyIntegrationI18n.tr,
+ ...linearIntegrationI18n.tr,
+ 'settings.git.tabs.gitea': 'Gitea',
+ 'settings.git.tabs.github': 'GitHub',
+ 'settings.git.tabs.gitlab': 'GitLab',
+ 'settings.gitProviders.detectUrls.add': 'Add a detection URL',
+ 'settings.gitProviders.detectUrls.invalid': 'Enter a valid SSH or HTTPS URL or hostname.',
+ 'settings.gitProviders.detectUrls.remove': 'Remove {host}',
+ 'settings.gitProviders.overridesLocked.description': 'Connect a {provider} account to configure the API base URL and detection URLs.',
+ 'settings.gitea.page.accessToken.label': 'Personal Access Token',
+ 'settings.gitea.page.accessToken.placeholder': 'Paste your Gitea personal access token',
+ 'settings.gitea.page.actions.connect': 'Connect Gitea',
+ 'settings.gitea.page.actions.disconnect': 'Disconnect',
+ 'settings.gitea.page.actions.switch': 'Switch to',
+ 'settings.gitea.page.apiBaseUrl.description': 'Default base URL for API calls. For self-hosted instances, use your server address, e.g. https://gitea.example.com.',
+ 'settings.gitea.page.apiBaseUrl.label': 'API base URL',
+ 'settings.gitea.page.apiBaseUrl.placeholder': 'https://codeberg.org',
+ 'settings.gitea.page.avatarAlt.fallback': 'Gitea avatar',
+ 'settings.gitea.page.avatarAlt.withLogin': '{login} avatar',
+ 'settings.gitea.page.baseUrl.label': 'Base URL (optional)',
+ 'settings.gitea.page.baseUrl.placeholder': 'https://gitea.example.com',
+ 'settings.gitea.page.connectedAs': 'Connected as',
+ 'settings.gitea.page.description': 'Paste a Gitea or Forgejo personal access token to connect. Set the base URL when using a self-hosted Gitea or Forgejo instance.',
+ 'settings.gitea.page.detectUrls.description': 'Repos cloned over SSH or HTTPS from these hosts are recognized as Gitea or Forgejo.',
+ 'settings.gitea.page.detectUrls.label': 'Detection URLs',
+ 'settings.gitea.page.detectUrls.placeholder': 'codeberg.org',
+ 'settings.gitea.page.errors.failed': 'Failed to connect Gitea',
+ 'settings.gitea.page.errors.invalidToken': 'Enter a valid Gitea personal access token',
+ 'settings.gitea.page.label.otherAccounts': 'Other Accounts',
+ 'settings.gitea.page.label.unknownUser': 'unknown',
+ 'settings.gitea.page.status.notConnected': 'Not Connected',
+ 'settings.gitea.page.title': 'Gitea / Forgejo Personal Access Token',
+ 'settings.gitea.page.toast.accountSwitchFailed': 'Failed to switch Gitea account',
+ 'settings.gitea.page.toast.accountSwitched': 'Gitea account switched',
+ 'settings.gitea.page.toast.connected': 'Gitea connected',
+ 'settings.gitea.page.toast.disconnectFailed': 'Failed to disconnect Gitea',
+ 'settings.gitea.page.toast.disconnected': 'Gitea disconnected',
+ 'settings.gitea.page.tooltip.connectAccount': 'Connect a Gitea or Forgejo account for in-app issue and pull request workflows.',
+ 'settings.github.page.apiBaseUrl.description': 'Default base URL for API calls. For GitHub Enterprise, use your server address, e.g. https://github.example.com/api/v3.',
+ 'settings.github.page.apiBaseUrl.label': 'API base URL',
+ 'settings.github.page.apiBaseUrl.placeholder': 'https://api.github.com',
+ 'settings.github.page.detectUrls.description': 'Repos cloned over SSH or HTTPS from these hosts are recognized as GitHub.',
+ 'settings.github.page.detectUrls.label': 'Detection URLs',
+ 'settings.github.page.detectUrls.placeholder': 'github.com',
+ 'settings.gitlab.page.accessToken.label': 'Personal Access Token',
+ 'settings.gitlab.page.accessToken.placeholder': 'Paste your GitLab personal access token',
+ 'settings.gitlab.page.actions.connect': 'Connect GitLab',
+ 'settings.gitlab.page.actions.disconnect': 'Disconnect',
+ 'settings.gitlab.page.actions.switch': 'Switch to',
+ 'settings.gitlab.page.apiBaseUrl.description': 'Default base URL for API calls. For self-hosted instances, use your server address, e.g. https://gitlab.example.com.',
+ 'settings.gitlab.page.apiBaseUrl.label': 'API base URL',
+ 'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.com',
+ 'settings.gitlab.page.avatarAlt.fallback': 'GitLab avatar',
+ 'settings.gitlab.page.avatarAlt.withLogin': '{login} avatar',
+ 'settings.gitlab.page.baseUrl.label': 'Base URL (optional)',
+ 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
+ 'settings.gitlab.page.connectedAs': 'Connected as',
+ 'settings.gitlab.page.description': 'Paste a GitLab personal access token to connect. Set the base URL when using a self-hosted GitLab instance.',
+ 'settings.gitlab.page.detectUrls.description': 'Repos cloned over SSH or HTTPS from these hosts are recognized as GitLab.',
+ 'settings.gitlab.page.detectUrls.label': 'Detection URLs',
+ 'settings.gitlab.page.detectUrls.placeholder': 'gitlab.com',
+ 'settings.gitlab.page.errors.failed': 'Failed to connect GitLab',
+ 'settings.gitlab.page.errors.invalidToken': 'Enter a valid GitLab personal access token',
+ 'settings.gitlab.page.label.otherAccounts': 'Other Accounts',
+ 'settings.gitlab.page.label.unknownUser': 'unknown',
+ 'settings.gitlab.page.status.notConnected': 'Not Connected',
+ 'settings.gitlab.page.title': 'GitLab Personal Access Token',
+ 'settings.gitlab.page.toast.accountSwitchFailed': 'Failed to switch GitLab account',
+ 'settings.gitlab.page.toast.accountSwitched': 'GitLab account switched',
+ 'settings.gitlab.page.toast.connected': 'GitLab connected',
+ 'settings.gitlab.page.toast.disconnectFailed': 'Failed to disconnect GitLab',
+ 'settings.gitlab.page.toast.disconnected': 'GitLab disconnected',
+ 'settings.gitlab.page.tooltip.connectAccount': 'Connect a GitLab account for in-app issue and merge request workflows.',
+ 'settings.magicPrompts.page.group.giteaIssueReview.description': 'Prompts used for Gitea issue review flow: visible user message + hidden instruction payload.',
+ 'settings.magicPrompts.page.group.giteaIssueReview.title': 'Issue Review',
+ 'settings.magicPrompts.page.group.giteaPrReview.description': 'Prompts used for Gitea pull request review flow: visible user message + hidden instruction payload.',
+ 'settings.magicPrompts.page.group.giteaPrReview.title': 'PR Review',
+ 'settings.magicPrompts.page.group.gitlabIssueReview.description': 'Prompts used for GitLab issue review flow: visible user message + hidden instruction payload.',
+ 'settings.magicPrompts.page.group.gitlabIssueReview.title': 'Issue Review',
+ 'settings.magicPrompts.page.group.gitlabPrReview.description': 'Prompts used for GitLab merge request review flow: visible user message + hidden instruction payload.',
+ 'settings.magicPrompts.page.group.gitlabPrReview.title': 'MR Review',
+ 'settings.magicPrompts.sidebar.group.gitea': 'Gitea',
+ 'settings.magicPrompts.sidebar.group.gitlab': 'GitLab',
+ 'settings.magicPrompts.sidebar.item.giteaIssueReview': 'Issue Review',
+ 'settings.magicPrompts.sidebar.item.giteaPrReview': 'PR Review',
+ 'settings.magicPrompts.sidebar.item.gitlabIssueReview': 'Issue Review',
+ 'settings.magicPrompts.sidebar.item.gitlabPrReview': 'MR Review',
+ 'settings.projects.page.gitProviders.description': 'Override the global API base URL for this project. When unset, the global setting is used.',
+ 'settings.projects.page.gitProviders.inheritsGlobal': 'Inherits: {url}',
+ 'settings.projects.page.gitProviders.provider.auto': 'Auto-detect',
+ 'settings.projects.page.gitProviders.provider.autoUnknown': 'Couldn\\\'t identify the provider from this project\\\'s remote. Choose one above to set an API base URL.',
+ 'settings.projects.page.gitProviders.provider.description': 'Force this project\\\'s repository to use the selected forge. Auto-detects from the remote when unset.',
+ 'settings.projects.page.gitProviders.provider.detectedAs': 'Auto-detected as {provider}. Inherits: {url}',
+ 'settings.projects.page.gitProviders.provider.label': 'Git provider',
+ 'settings.projects.page.gitProviders.title': 'Git Provider API Base URLs',
};
diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts
index cee61449..c6aeba1e 100644
--- a/packages/ui/src/lib/i18n/messages/tr.ts
+++ b/packages/ui/src/lib/i18n/messages/tr.ts
@@ -1,7 +1,11 @@
import { settingsDict } from './tr.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
...settingsDict,
+ ...linearIssuePickerI18n.tr,
+ ...linearPanelI18n.tr,
'terminalView.actions.attachSelection': 'Seçili çıktıyı ekle',
'terminalView.actions.restart': 'Terminali yeniden başlat',
'chat.message.terminalContext': '{terminal}, {start}-{end}. satırlar',
@@ -1712,6 +1716,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminal paneli ({shortcut})',
'chat.recap.aria': 'Session özeti',
'chat.recap.label': 'Özet:',
+ 'chat.sessionError.title': 'OpenCode bu yanıtı durdurdu',
+ 'chat.sessionError.noDetails': 'OpenCode ayrıntı bildirmedi. Son hataları görmek için durum raporunu açın (Ctrl/Cmd+Shift+L).',
+ 'chat.sessionError.noReply': 'OpenCode bu mesaja yanıt vermeye başlamadı.',
'chat.goal.dialog.titleCreate': 'Session hedefi belirle',
'chat.goal.dialog.titleManage': 'Session hedefi',
'chat.goal.dialog.objectiveLabel': 'Amaç',
@@ -1956,6 +1963,8 @@ export const dict = {
'session.newWorktree.noMatchingBranches': 'Eşleşen branch yok',
'session.newWorktree.localBranches': 'Yerel branch\'ler',
'session.newWorktree.remoteBranches': 'Uzak branch\'ler',
+ 'session.newWorktree.otherLocalBranches': 'Diğer yerel branch\'ler',
+ 'session.newWorktree.otherRemoteBranches': 'Diğer uzak branch\'ler',
'session.newWorktree.branchName': 'Branch Adı',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': 'Değiştir',
@@ -3737,4 +3746,386 @@ export const dict = {
'settings.projects.page.gitProviders.title': 'Git Provider API Base URLs',
'walkthrough.scope.mergeRequest': 'MR !{number}',
'chat.workStatus.cost.breakdown': 'Session {session} · Subagent\'ler {subagents}',
+ 'chat.chatInput.actions.linkGiteaIssue': 'Link Gitea Issue',
+ 'chat.chatInput.actions.linkGiteaPr': 'Link Gitea PR',
+ 'chat.chatInput.actions.linkGitlabIssue': 'Link GitLab Issue',
+ 'chat.chatInput.actions.linkGitlabMr': 'Link GitLab MR',
+ 'chat.chatInput.linked.mr.number': 'MR !{number}',
+ 'chat.workStatus.action.openMr': 'Open merge request',
+ 'chat.workStatus.linkedIssues.empty': 'No linked issues or pull requests',
+ 'chat.workStatus.linkedIssues.linkDialogTitle': 'Link issue or pull request',
+ 'chat.workStatus.linkedIssues.linkInvalid': 'That doesn\\\'t look like a supported issue/PR URL',
+ 'chat.workStatus.linkedIssues.linkPlaceholder': 'Paste an issue or PR URL…',
+ 'chat.workStatus.linkedIssues.linked': 'Linked',
+ 'chat.workStatus.linkedIssues.liveRefresh': 'Refresh status',
+ 'chat.workStatus.linkedIssues.liveUnavailable': 'Live status unavailable',
+ 'chat.workStatus.linkedIssues.unlinkConfirm': 'Unlink this issue/PR from the session?',
+ 'chat.workStatus.mr.untitled': 'Untitled merge request',
+ 'contextPanel.giteaPr.actions.openSettings': 'Open settings',
+ 'contextPanel.giteaPr.branchSectionTitle': 'Current branch',
+ 'contextPanel.giteaPr.createPr.branchesLoading': 'Loading branches...',
+ 'contextPanel.giteaPr.createPr.descriptionLabel': 'Description',
+ 'contextPanel.giteaPr.createPr.removeSourceBranch': 'Remove source branch on merge',
+ 'contextPanel.giteaPr.createPr.sourceBranch': 'Source branch',
+ 'contextPanel.giteaPr.createPr.submit': 'Create pull request',
+ 'contextPanel.giteaPr.createPr.submitting': 'Creating...',
+ 'contextPanel.giteaPr.createPr.targetBranch': 'Target branch',
+ 'contextPanel.giteaPr.createPr.title': 'New pull request',
+ 'contextPanel.giteaPr.createPr.titleLabel': 'Title',
+ 'contextPanel.giteaPr.createPr.titlePlaceholder': 'Pull request title',
+ 'contextPanel.giteaPr.createPr.toast.createFailed': 'Failed to create pull request',
+ 'contextPanel.giteaPr.createPr.toast.created': 'Pull request created',
+ 'contextPanel.giteaPr.draft': 'Draft',
+ 'contextPanel.giteaPr.empty.noActiveProject': 'No active project',
+ 'contextPanel.giteaPr.error.loadFailed': 'Failed to load pull requests',
+ 'contextPanel.giteaPr.error.notConnected': 'Gitea is not connected',
+ 'contextPanel.giteaPr.hideContext': 'Hide context',
+ 'contextPanel.giteaPr.issues.detail.back': 'Back to issues',
+ 'contextPanel.giteaPr.issues.detail.commentsEmpty': 'No comments',
+ 'contextPanel.giteaPr.issues.empty': 'No open issues',
+ 'contextPanel.giteaPr.issues.error.loadFailed': 'Failed to load issues',
+ 'contextPanel.giteaPr.issues.listSectionTitle': 'Open issues',
+ 'contextPanel.giteaPr.listSectionTitle': 'In this repository',
+ 'contextPanel.giteaPr.loadContext': 'Load context',
+ 'contextPanel.giteaPr.loadMore': 'Load more',
+ 'contextPanel.giteaPr.loading': 'Loading...',
+ 'contextPanel.giteaPr.mergePr.action': 'Merge',
+ 'contextPanel.giteaPr.mergePr.merging': 'Merging...',
+ 'contextPanel.giteaPr.mergePr.squash': 'Squash commits',
+ 'contextPanel.giteaPr.mergePr.toast.mergeFailed': 'Failed to merge pull request',
+ 'contextPanel.giteaPr.mergePr.toast.merged': 'Pull request merged',
+ 'contextPanel.giteaPr.noPrForBranch': 'No pull request for this branch',
+ 'contextPanel.giteaPr.openInGitea': 'Open in Gitea',
+ 'contextPanel.giteaPr.openPrEmpty': 'No open pull requests',
+ 'contextPanel.giteaPr.openPrTitle': 'Open pull requests',
+ 'contextPanel.giteaPr.state.closed': 'Closed',
+ 'contextPanel.giteaPr.state.merged': 'Merged',
+ 'contextPanel.giteaPr.state.opened': 'Open',
+ 'contextPanel.giteaPr.tabs.issues': 'Issues',
+ 'contextPanel.giteaPr.tabs.pullRequests': 'Pull requests',
+ 'contextPanel.giteaPr.title': 'Pull requests',
+ 'contextPanel.giteaPr.updatePr.save': 'Save',
+ 'contextPanel.giteaPr.updatePr.saving': 'Saving...',
+ 'contextPanel.giteaPr.updatePr.toast.updateFailed': 'Failed to update pull request',
+ 'contextPanel.giteaPr.updatePr.toast.updated': 'Pull request updated',
+ 'contextPanel.giteaPr.updatePr.toggle': 'Edit title & description',
+ 'contextPanel.gitlabMr.actions.openSettings': 'Open settings',
+ 'contextPanel.gitlabMr.branchSectionTitle': 'Current branch',
+ 'contextPanel.gitlabMr.createMr.branchesLoading': 'Loading branches...',
+ 'contextPanel.gitlabMr.createMr.descriptionLabel': 'Description',
+ 'contextPanel.gitlabMr.createMr.removeSourceBranch': 'Remove source branch on merge',
+ 'contextPanel.gitlabMr.createMr.sourceBranch': 'Source branch',
+ 'contextPanel.gitlabMr.createMr.submit': 'Create merge request',
+ 'contextPanel.gitlabMr.createMr.submitting': 'Creating...',
+ 'contextPanel.gitlabMr.createMr.targetBranch': 'Target branch',
+ 'contextPanel.gitlabMr.createMr.title': 'New merge request',
+ 'contextPanel.gitlabMr.createMr.titleLabel': 'Title',
+ 'contextPanel.gitlabMr.createMr.titlePlaceholder': 'Merge request title',
+ 'contextPanel.gitlabMr.createMr.toast.createFailed': 'Failed to create merge request',
+ 'contextPanel.gitlabMr.createMr.toast.created': 'Merge request created',
+ 'contextPanel.gitlabMr.draft': 'Draft',
+ 'contextPanel.gitlabMr.empty.noActiveProject': 'No active project',
+ 'contextPanel.gitlabMr.error.loadFailed': 'Failed to load merge requests',
+ 'contextPanel.gitlabMr.error.notConnected': 'GitLab is not connected',
+ 'contextPanel.gitlabMr.hideContext': 'Hide context',
+ 'contextPanel.gitlabMr.issues.detail.back': 'Back to issues',
+ 'contextPanel.gitlabMr.issues.detail.commentsEmpty': 'No comments',
+ 'contextPanel.gitlabMr.issues.empty': 'No open issues',
+ 'contextPanel.gitlabMr.issues.error.loadFailed': 'Failed to load issues',
+ 'contextPanel.gitlabMr.issues.listSectionTitle': 'Open issues',
+ 'contextPanel.gitlabMr.listSectionTitle': 'In this repository',
+ 'contextPanel.gitlabMr.loadContext': 'Load context',
+ 'contextPanel.gitlabMr.loadMore': 'Load more',
+ 'contextPanel.gitlabMr.loading': 'Loading...',
+ 'contextPanel.gitlabMr.mergeMr.action': 'Merge',
+ 'contextPanel.gitlabMr.mergeMr.merging': 'Merging...',
+ 'contextPanel.gitlabMr.mergeMr.squash': 'Squash commits',
+ 'contextPanel.gitlabMr.mergeMr.toast.mergeFailed': 'Failed to merge merge request',
+ 'contextPanel.gitlabMr.mergeMr.toast.merged': 'Merge request merged',
+ 'contextPanel.gitlabMr.noMrForBranch': 'No merge request for this branch',
+ 'contextPanel.gitlabMr.openInGitLab': 'Open in GitLab',
+ 'contextPanel.gitlabMr.openMrEmpty': 'No open merge requests',
+ 'contextPanel.gitlabMr.openMrTitle': 'Open merge requests',
+ 'contextPanel.gitlabMr.state.closed': 'Closed',
+ 'contextPanel.gitlabMr.state.merged': 'Merged',
+ 'contextPanel.gitlabMr.state.opened': 'Open',
+ 'contextPanel.gitlabMr.tabs.issues': 'Issues',
+ 'contextPanel.gitlabMr.tabs.mergeRequests': 'Merge requests',
+ 'contextPanel.gitlabMr.title': 'Merge requests',
+ 'contextPanel.gitlabMr.updateMr.save': 'Save',
+ 'contextPanel.gitlabMr.updateMr.saving': 'Saving...',
+ 'contextPanel.gitlabMr.updateMr.toast.updateFailed': 'Failed to update merge request',
+ 'contextPanel.gitlabMr.updateMr.toast.updated': 'Merge request updated',
+ 'contextPanel.gitlabMr.updateMr.toggle': 'Edit title & description',
+ 'contextPanel.mode.mr': 'Merge request',
+ 'contextRail.surface.mr.description': 'Create, review, and merge the merge request for the current branch',
+ 'forge.actions.addAssignee': 'Add assignee',
+ 'forge.actions.addLabel': 'Add label',
+ 'forge.actions.added': 'Added',
+ 'forge.actions.approve': 'Approve',
+ 'forge.actions.cancel': 'Cancel',
+ 'forge.actions.close': 'Close',
+ 'forge.actions.closeConfirm': 'Close this issue/PR?',
+ 'forge.actions.comment': 'Comment',
+ 'forge.actions.commentPlaceholder': 'Add a comment…',
+ 'forge.actions.createIssue': 'Create issue',
+ 'forge.actions.draftChanged': 'Draft status updated',
+ 'forge.actions.edit': 'Edit',
+ 'forge.actions.error': 'Action failed',
+ 'forge.actions.issueBodyPlaceholder': 'Describe the issue…',
+ 'forge.actions.issueCreated': 'Issue created',
+ 'forge.actions.issueDialogTitle': 'New issue',
+ 'forge.actions.issueLabelsPlaceholder': 'Labels (comma-separated)',
+ 'forge.actions.issueTitlePlaceholder': 'Title',
+ 'forge.actions.markDraft': 'Mark as draft',
+ 'forge.actions.markReady': 'Mark ready',
+ 'forge.actions.metadataChanged': 'Metadata updated',
+ 'forge.actions.newIssue': 'New issue',
+ 'forge.actions.posting': 'Posting…',
+ 'forge.actions.remove': 'Remove',
+ 'forge.actions.removed': 'Removed',
+ 'forge.actions.reopen': 'Reopen',
+ 'forge.actions.reply': 'Reply',
+ 'forge.actions.requestChanges': 'Request changes',
+ 'forge.actions.reviewBodyPlaceholder': 'Leave a comment (optional)',
+ 'forge.actions.reviewComment': 'Comment',
+ 'forge.actions.reviewDialogTitle': 'Submit review',
+ 'forge.actions.reviewed': 'Review submitted',
+ 'forge.actions.save': 'Save',
+ 'forge.actions.setMilestone': 'Set milestone',
+ 'forge.actions.stateChanged': 'State updated',
+ 'forge.actions.updated': 'Updated',
+ 'forge.author': 'Author',
+ 'forge.baseToHead': '{head} into {base}',
+ 'forge.checks.empty': 'No checks',
+ 'forge.checks.state.cancelled': 'Cancelled',
+ 'forge.checks.state.failure': 'Failed',
+ 'forge.checks.state.pending': 'Pending',
+ 'forge.checks.state.skipped': 'Skipped',
+ 'forge.checks.state.success': 'Success',
+ 'forge.checks.state.unknown': 'Unknown',
+ 'forge.checks.statusStrip': 'Commit statuses',
+ 'forge.comment.inlineAt': 'At {path}:{line}',
+ 'forge.commits.empty': 'No commits found',
+ 'forge.commits.parents': 'Parents',
+ 'forge.copied': 'Commit hash copied',
+ 'forge.created': 'Created',
+ 'forge.draft': 'Draft',
+ 'forge.error': 'Failed to load',
+ 'forge.files.empty': 'No files changed',
+ 'forge.files.noDiff': 'No diff available',
+ 'forge.linkedSessions.count': 'Sessions linked to this entity: {count}',
+ 'forge.linkedSessions.open': 'Open session "{title}"',
+ 'forge.linkedSessions.title': 'Chats working on this',
+ 'forge.loading': 'Loading...',
+ 'forge.lookup.empty': 'No matches',
+ 'forge.lookup.loading': 'Searching…',
+ 'forge.notConnected': 'Your git forge is not connected',
+ 'forge.section.checks': 'Checks',
+ 'forge.section.commits': 'Commits',
+ 'forge.section.files': 'Changed files',
+ 'forge.section.metadata': 'Details',
+ 'forge.section.timeline': 'Activity',
+ 'forge.state.closed': 'Closed',
+ 'forge.state.merged': 'Merged',
+ 'forge.state.open': 'Open',
+ 'forge.timeline.empty': 'No activity yet',
+ 'forge.timeline.event.approved': 'Approved the pull request',
+ 'forge.timeline.event.assigned': 'Assigned a user',
+ 'forge.timeline.event.closed': 'Closed the pull request',
+ 'forge.timeline.event.commented': 'Commented',
+ 'forge.timeline.event.committed': 'Added a commit',
+ 'forge.timeline.event.demilestoned': 'Removed the milestone',
+ 'forge.timeline.event.labeled': 'Added a label',
+ 'forge.timeline.event.merged': 'Merged the pull request',
+ 'forge.timeline.event.milestoned': 'Added a milestone',
+ 'forge.timeline.event.opened': 'Opened the pull request',
+ 'forge.timeline.event.other': 'Other activity',
+ 'forge.timeline.event.referenced': 'Referenced the pull request',
+ 'forge.timeline.event.reopened': 'Reopened the pull request',
+ 'forge.timeline.event.requested-changes': 'Requested changes',
+ 'forge.timeline.event.reviewed': 'Reviewed the pull request',
+ 'forge.timeline.event.unassigned': 'Unassigned a user',
+ 'forge.timeline.event.unlabeled': 'Removed a label',
+ 'forge.updated': 'Updated',
+ 'gitView.empty.discoverFailed': 'Could not scan for Git repositories',
+ 'gitView.empty.discoveringRepositories': 'Looking for Git repositories...',
+ 'gitView.empty.retryDiscovery': 'Retry',
+ 'gitView.empty.selectRepositoryPlaceholder': 'Select a repository...',
+ 'gitView.header.openMergeRequest': 'Open merge request',
+ 'gitView.pr.field.headBranch': 'Head branch',
+ 'gitView.pullRequest.issues.detail.back': 'Back to issues',
+ 'gitView.pullRequest.issues.detail.commentsEmpty': 'No comments',
+ 'gitView.pullRequest.issues.detail.openInGitHub': 'Open in GitHub',
+ 'gitView.pullRequest.issues.empty': 'No open issues',
+ 'gitView.pullRequest.issues.error.loadFailed': 'Failed to load issues',
+ 'gitView.pullRequest.issues.listSectionTitle': 'Open issues',
+ 'gitView.pullRequest.tabs.issues': 'Issues',
+ 'gitView.pullRequest.tabs.pullRequests': 'Pull requests',
+ 'session.giteaIntegration.actions.cancel': 'Cancel',
+ 'session.giteaIntegration.actions.loadMore': 'Load more',
+ 'session.giteaIntegration.actions.select': 'Select',
+ 'session.giteaIntegration.connect.action': 'Connect Gitea',
+ 'session.giteaIntegration.connect.description': 'Link issues or pull requests to auto-fill worktree details',
+ 'session.giteaIntegration.connect.title': 'Connect to Gitea',
+ 'session.giteaIntegration.draftBadge': 'Draft',
+ 'session.giteaIntegration.empty.noIssuesFound': 'No issues found',
+ 'session.giteaIntegration.empty.noPullRequestsFound': 'No pull requests found',
+ 'session.giteaIntegration.error.loadDataFailed': 'Failed to load data',
+ 'session.giteaIntegration.error.notConnected': 'Gitea not connected',
+ 'session.giteaIntegration.includeDiff': 'Include PR diff',
+ 'session.giteaIntegration.includeDiffAria': 'Include PR diff in session context',
+ 'session.giteaIntegration.search.issuesPlaceholder': 'Search Gitea issues',
+ 'session.giteaIntegration.search.prsPlaceholder': 'Search Gitea pull requests',
+ 'session.giteaIntegration.selected.issueNumber': 'Issue #{number}',
+ 'session.giteaIntegration.selected.prNumber': 'PR #{number}',
+ 'session.giteaIntegration.tabs.issues': 'Issues',
+ 'session.giteaIntegration.tabs.pullRequests': 'Pull Requests',
+ 'session.giteaIntegration.title': 'Select from Gitea',
+ 'session.giteaIntegration.validation.branchAlreadyCheckedOut': 'Branch is already checked out in a worktree',
+ 'session.giteaIntegration.validation.branchAlreadyExists': 'Branch already exists locally',
+ 'session.giteaIntegration.validation.failed': 'Validation failed',
+ 'session.giteaIssuePicker.actions.createInWorktree': 'Create in worktree',
+ 'session.giteaIssuePicker.actions.loadMore': 'Load more',
+ 'session.giteaIssuePicker.actions.openInGiteaAria': 'Open in Gitea',
+ 'session.giteaIssuePicker.actions.openRepo': 'Open Repo',
+ 'session.giteaIssuePicker.actions.openSettings': 'Open settings',
+ 'session.giteaIssuePicker.actions.refresh': 'Refresh',
+ 'session.giteaIssuePicker.actions.sectionTitle': 'Actions',
+ 'session.giteaIssuePicker.actions.toggleWorktreeAria': 'Toggle worktree',
+ 'session.giteaIssuePicker.actions.useIssue': 'Use issue #{number}',
+ 'session.giteaIssuePicker.description.createSession': 'Seeds a new session with hidden issue context (title/body/labels/comments).',
+ 'session.giteaIssuePicker.description.select': 'Select an issue to link to this session.',
+ 'session.giteaIssuePicker.empty.noActiveProject': 'No active project selected.',
+ 'session.giteaIssuePicker.empty.noIssuesFound': 'No issues found',
+ 'session.giteaIssuePicker.empty.noOpenIssuesFound': 'No open issues found',
+ 'session.giteaIssuePicker.empty.notConnected': 'Gitea not connected. Connect your Gitea account in settings.',
+ 'session.giteaIssuePicker.empty.runtimeUnavailable': 'Gitea runtime API unavailable.',
+ 'session.giteaIssuePicker.error.issueNotFound': 'Issue not found',
+ 'session.giteaIssuePicker.error.noActiveProject': 'No active project',
+ 'session.giteaIssuePicker.error.noModelSelected': 'No model selected',
+ 'session.giteaIssuePicker.error.notConnected': 'Gitea not connected',
+ 'session.giteaIssuePicker.error.repoMustBeGitea': 'origin remote must be a Gitea URL',
+ 'session.giteaIssuePicker.error.repoNotResolvable': 'Repo not resolvable',
+ 'session.giteaIssuePicker.error.runtimeUnavailable': 'Gitea runtime API unavailable',
+ 'session.giteaIssuePicker.loading.issues': 'Loading issues...',
+ 'session.giteaIssuePicker.loading.more': 'Loading...',
+ 'session.giteaIssuePicker.searchPlaceholder': 'Search by title or paste an issue URL',
+ 'session.giteaIssuePicker.title.createSession': 'New Session From Gitea Issue',
+ 'session.giteaIssuePicker.title.select': 'Link Gitea Issue',
+ 'session.giteaIssuePicker.toast.loadIssueDetailsFailed': 'Failed to load issue details',
+ 'session.giteaIssuePicker.toast.loadMoreFailed': 'Failed to load more issues',
+ 'session.giteaIssuePicker.toast.sendContextFailed': 'Failed to send issue context',
+ 'session.giteaIssuePicker.toast.sessionCreated': 'Session created from issue',
+ 'session.giteaIssuePicker.toast.startSessionFailed': 'Failed to start session',
+ 'session.giteaPrPicker.actions.loadMore': 'Load more',
+ 'session.giteaPrPicker.actions.openInGiteaAria': 'Open in Gitea',
+ 'session.giteaPrPicker.actions.openSettings': 'Open settings',
+ 'session.giteaPrPicker.actions.usePullRequest': 'Use pull request #{number}',
+ 'session.giteaPrPicker.description': 'Select a pull request to attach review context to this message.',
+ 'session.giteaPrPicker.empty.noActiveProject': 'No active project selected.',
+ 'session.giteaPrPicker.empty.noOpenPullRequestsFound': 'No open pull requests found',
+ 'session.giteaPrPicker.empty.noPullRequestsFound': 'No pull requests found',
+ 'session.giteaPrPicker.empty.notConnected': 'Gitea not connected. Connect your Gitea account in settings.',
+ 'session.giteaPrPicker.empty.runtimeUnavailable': 'Gitea runtime API unavailable.',
+ 'session.giteaPrPicker.error.noActiveProject': 'No active project',
+ 'session.giteaPrPicker.error.notConnected': 'Gitea not connected',
+ 'session.giteaPrPicker.error.prNotFound': 'Pull request not found',
+ 'session.giteaPrPicker.error.repoMustBeGitea': 'origin remote must be a Gitea URL',
+ 'session.giteaPrPicker.error.repoNotResolvable': 'Repo not resolvable',
+ 'session.giteaPrPicker.error.runtimeUnavailable': 'Gitea runtime API unavailable',
+ 'session.giteaPrPicker.includeDiff': 'Include PR diff',
+ 'session.giteaPrPicker.includeDiffAria': 'Include PR diff in attached context',
+ 'session.giteaPrPicker.loading.more': 'Loading...',
+ 'session.giteaPrPicker.loading.pullRequests': 'Loading pull requests...',
+ 'session.giteaPrPicker.searchPlaceholder': 'Search by title or paste a pull request URL',
+ 'session.giteaPrPicker.title': 'Link Gitea Pull Request',
+ 'session.giteaPrPicker.toast.loadDetailsFailed': 'Failed to load pull request details',
+ 'session.giteaPrPicker.toast.loadMoreFailed': 'Failed to load more pull requests',
+ 'session.gitlabIntegration.actions.cancel': 'Cancel',
+ 'session.gitlabIntegration.actions.loadMore': 'Load more',
+ 'session.gitlabIntegration.actions.select': 'Select',
+ 'session.gitlabIntegration.connect.action': 'Connect GitLab',
+ 'session.gitlabIntegration.connect.description': 'Link issues or merge requests to auto-fill worktree details',
+ 'session.gitlabIntegration.connect.title': 'Connect to GitLab',
+ 'session.gitlabIntegration.draftBadge': 'Draft',
+ 'session.gitlabIntegration.empty.noIssuesFound': 'No issues found',
+ 'session.gitlabIntegration.empty.noMergeRequestsFound': 'No merge requests found',
+ 'session.gitlabIntegration.error.loadDataFailed': 'Failed to load data',
+ 'session.gitlabIntegration.error.notConnected': 'GitLab not connected',
+ 'session.gitlabIntegration.includeDiff': 'Include MR diff',
+ 'session.gitlabIntegration.includeDiffAria': 'Include MR diff in session context',
+ 'session.gitlabIntegration.search.issuesPlaceholder': 'Search GitLab issues',
+ 'session.gitlabIntegration.search.mrsPlaceholder': 'Search GitLab merge requests',
+ 'session.gitlabIntegration.selected.issueNumber': 'Issue #{number}',
+ 'session.gitlabIntegration.selected.mrNumber': 'MR #{number}',
+ 'session.gitlabIntegration.tabs.issues': 'Issues',
+ 'session.gitlabIntegration.tabs.mergeRequests': 'Merge Requests',
+ 'session.gitlabIntegration.title': 'Select from GitLab',
+ 'session.gitlabIntegration.validation.branchAlreadyCheckedOut': 'Branch is already checked out in a worktree',
+ 'session.gitlabIntegration.validation.branchAlreadyExists': 'Branch already exists locally',
+ 'session.gitlabIntegration.validation.failed': 'Validation failed',
+ 'session.gitlabIssuePicker.actions.createInWorktree': 'Create in worktree',
+ 'session.gitlabIssuePicker.actions.loadMore': 'Load more',
+ 'session.gitlabIssuePicker.actions.openInGitLabAria': 'Open in GitLab',
+ 'session.gitlabIssuePicker.actions.openRepo': 'Open Repo',
+ 'session.gitlabIssuePicker.actions.openSettings': 'Open settings',
+ 'session.gitlabIssuePicker.actions.refresh': 'Refresh',
+ 'session.gitlabIssuePicker.actions.sectionTitle': 'Actions',
+ 'session.gitlabIssuePicker.actions.toggleWorktreeAria': 'Toggle worktree',
+ 'session.gitlabIssuePicker.actions.useIssue': 'Use issue #{number}',
+ 'session.gitlabIssuePicker.description.createSession': 'Seeds a new session with hidden issue context (title/body/labels/comments).',
+ 'session.gitlabIssuePicker.description.select': 'Select an issue to link to this session.',
+ 'session.gitlabIssuePicker.empty.noActiveProject': 'No active project selected.',
+ 'session.gitlabIssuePicker.empty.noIssuesFound': 'No issues found',
+ 'session.gitlabIssuePicker.empty.noOpenIssuesFound': 'No open issues found',
+ 'session.gitlabIssuePicker.empty.notConnected': 'GitLab not connected. Connect your GitLab account in settings.',
+ 'session.gitlabIssuePicker.empty.runtimeUnavailable': 'GitLab runtime API unavailable.',
+ 'session.gitlabIssuePicker.error.issueNotFound': 'Issue not found',
+ 'session.gitlabIssuePicker.error.noActiveProject': 'No active project',
+ 'session.gitlabIssuePicker.error.noModelSelected': 'No model selected',
+ 'session.gitlabIssuePicker.error.notConnected': 'GitLab not connected',
+ 'session.gitlabIssuePicker.error.repoMustBeGitlab': 'origin remote must be a GitLab URL',
+ 'session.gitlabIssuePicker.error.repoNotResolvable': 'Repo not resolvable',
+ 'session.gitlabIssuePicker.error.runtimeUnavailable': 'GitLab runtime API unavailable',
+ 'session.gitlabIssuePicker.loading.issues': 'Loading issues...',
+ 'session.gitlabIssuePicker.loading.more': 'Loading...',
+ 'session.gitlabIssuePicker.searchPlaceholder': 'Search by title or paste an issue URL',
+ 'session.gitlabIssuePicker.title.createSession': 'New Session From GitLab Issue',
+ 'session.gitlabIssuePicker.title.select': 'Link GitLab Issue',
+ 'session.gitlabIssuePicker.toast.loadIssueDetailsFailed': 'Failed to load issue details',
+ 'session.gitlabIssuePicker.toast.loadMoreFailed': 'Failed to load more issues',
+ 'session.gitlabIssuePicker.toast.sendContextFailed': 'Failed to send issue context',
+ 'session.gitlabIssuePicker.toast.sessionCreated': 'Session created from issue',
+ 'session.gitlabIssuePicker.toast.startSessionFailed': 'Failed to start session',
+ 'session.gitlabMrPicker.actions.loadMore': 'Load more',
+ 'session.gitlabMrPicker.actions.openInGitLabAria': 'Open in GitLab',
+ 'session.gitlabMrPicker.actions.openSettings': 'Open settings',
+ 'session.gitlabMrPicker.actions.useMergeRequest': 'Use merge request !{number}',
+ 'session.gitlabMrPicker.description': 'Select a merge request to attach review context to this message.',
+ 'session.gitlabMrPicker.empty.noActiveProject': 'No active project selected.',
+ 'session.gitlabMrPicker.empty.noMergeRequestsFound': 'No merge requests found',
+ 'session.gitlabMrPicker.empty.noOpenMergeRequestsFound': 'No open merge requests found',
+ 'session.gitlabMrPicker.empty.notConnected': 'GitLab not connected. Connect your GitLab account in settings.',
+ 'session.gitlabMrPicker.empty.runtimeUnavailable': 'GitLab runtime API unavailable.',
+ 'session.gitlabMrPicker.error.mrNotFound': 'Merge request not found',
+ 'session.gitlabMrPicker.error.noActiveProject': 'No active project',
+ 'session.gitlabMrPicker.error.notConnected': 'GitLab not connected',
+ 'session.gitlabMrPicker.error.repoMustBeGitlab': 'origin remote must be a GitLab URL',
+ 'session.gitlabMrPicker.error.repoNotResolvable': 'Repo not resolvable',
+ 'session.gitlabMrPicker.error.runtimeUnavailable': 'GitLab runtime API unavailable',
+ 'session.gitlabMrPicker.includeDiff': 'Include MR diff',
+ 'session.gitlabMrPicker.includeDiffAria': 'Include MR diff in attached context',
+ 'session.gitlabMrPicker.loading.mergeRequests': 'Loading merge requests...',
+ 'session.gitlabMrPicker.loading.more': 'Loading...',
+ 'session.gitlabMrPicker.searchPlaceholder': 'Search by title or paste a merge request URL',
+ 'session.gitlabMrPicker.title': 'Link GitLab Merge Request',
+ 'session.gitlabMrPicker.toast.loadDetailsFailed': 'Failed to load merge request details',
+ 'session.gitlabMrPicker.toast.loadMoreFailed': 'Failed to load more merge requests',
+ 'session.newWorktree.actions.startFromGitLabIssueMr': 'Start from GitLab Issue/MR',
+ 'session.newWorktree.error.sendGitLabContextFailed': 'Failed to send GitLab context',
+ 'session.newWorktree.mrNumber': 'MR #{number}',
+ 'session.newWorktree.toast.sessionFromMr': 'Session created from merge request',
+ 'session.newWorktree.usingMrBranch': 'Using MR branch: {branch}',
+ 'walkthrough.scope.mergeRequest': 'MR !{number}',
};
diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts
index 90617ce6..efe7d326 100644
--- a/packages/ui/src/lib/i18n/messages/uk.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go',
@@ -1923,7 +1924,10 @@ export const settingsDict = {
"settings.voice.page.provider.server": "Сервер",
"settings.voice.page.provider.local": "Локальний",
"settings.voice.page.tooltip.sttLocal": "Локальна розшифровка на сервері OpenChamber. Моделі завантажуються автоматично; ключ API не потрібен.",
- "settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro, англійська). Модель завантажується автоматично; ключ API не потрібен.",
+ "settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro для англійської; моделі для інших мов завантажуються при першому використанні). Ключ API не потрібен.",
+ "settings.voice.page.field.followTextLanguage": "Підбирати голос під мову тексту",
+ "settings.voice.page.field.followTextLanguageAria": "Підбирати голос під мову тексту",
+ "settings.voice.page.field.followTextLanguageInfo": "Якщо відповідь іншою мовою, використовується голос цієї мови: відповідний голос macOS або локальна модель, яка завантажується при першому використанні.",
"settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (англійська)",
"settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 європейських мов)",
"settings.voice.page.stt.model.whisperBase": "Whisper base (мультимовна)",
@@ -2095,6 +2099,9 @@ export const settingsDict = {
"settings.openchamber.visual.field.activityDefaultModeAria": "Типовий режим активності: {option}",
"settings.openchamber.visual.field.showExpandedBashToolsAria": "Показати розширені інструменти bash",
"settings.openchamber.visual.field.showExpandedEditToolsAria": "Показати розширені інструменти редагування",
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Завжди показувати панель інструментів редактора',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Завжди показувати панель інструментів редактора (закріплена під вкладками)',
+
"settings.openchamber.visual.field.bash": "Bash",
"settings.openchamber.visual.field.editTools": "Інструменти редагування",
"settings.openchamber.visual.field.userMessageRenderingAria": "Відображення повідомлень користувача: {option}",
@@ -2320,5 +2327,6 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
+ ...linearIntegrationI18n.uk,
...thirdPartyIntegrationI18n.uk,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index c962070b..d285dd96 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './uk.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record = {
...settingsDict,
+ ...linearIssuePickerI18n.uk,
+ ...linearPanelI18n.uk,
'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід',
'terminalView.actions.restart': 'Перезапустити термінал',
'chat.message.terminalContext': '{terminal}, рядки {start}-{end}',
@@ -980,6 +984,10 @@ export const dict: Record = {
"gitView.empty.worktreeFeaturesUnavailable": "У цьому режимі робочої області функції worktree недоступні.",
"gitView.empty.worktreeSetupDescription": "Завершення налаштування worktree та підготовка стану сховища.",
"gitView.empty.worktreeSetupInProgress": "Виконується налаштування worktree",
+ "gitView.empty.discoveringRepositories": "Пошук репозиторіїв Git...",
+ "gitView.empty.discoverFailed": "Не вдалося просканувати репозиторії Git",
+ "gitView.empty.retryDiscovery": "Повторити",
+ "gitView.empty.selectRepositoryPlaceholder": "Виберіть репозиторій...",
"worktree.bootstrap.toast.failed": "Не вдалося налаштувати worktree",
"worktree.bootstrap.toast.failedDescription": "Worktree створено, але фонове налаштування не завершилося.",
"worktree.bootstrap.toast.timeoutDescription": "Worktree створено, але час очікування фонового налаштування минув.",
@@ -1927,6 +1935,9 @@ export const dict: Record = {
"header.actions.terminalPanelWithShortcut": "Термінальна панель ({shortcut})",
"chat.recap.aria": "Підсумок сесії",
"chat.recap.label": "Підсумок:",
+ "chat.sessionError.title": "OpenCode зупинив цю відповідь",
+ "chat.sessionError.noDetails": "OpenCode не повідомив деталей. Відкрий звіт про стан (Ctrl/Cmd+Shift+L), щоб побачити останні помилки.",
+ "chat.sessionError.noReply": "OpenCode не почав відповідь на це повідомлення.",
"chat.goal.dialog.titleCreate": "Встановити ціль сесії",
"chat.goal.dialog.titleManage": "Ціль сесії",
"chat.goal.dialog.objectiveLabel": "Ціль",
@@ -2287,6 +2298,9 @@ export const dict: Record = {
"session.newWorktree.noMatchingBranches": "Немає відповідних гілок",
"session.newWorktree.localBranches": "Локальні гілки",
"session.newWorktree.remoteBranches": "Віддалені гілки",
+ 'session.newWorktree.otherLocalBranches': 'Інші локальні гілки',
+ 'session.newWorktree.otherRemoteBranches': 'Інші віддалені гілки',
+
"session.newWorktree.branchName": "Назва гілки",
"session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature",
"session.newWorktree.actions.change": "Змінити",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
index 3b67756d..aae853ed 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪',
@@ -1923,7 +1924,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': '服务器',
'settings.voice.page.provider.local': '本地',
'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 服务器上本地转写。模型自动下载,无需 API 密钥。',
- 'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(Kokoro,英语)。模型自动下载,无需 API 密钥。',
+ 'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(英语使用 Kokoro;其他语言的模型在首次使用时下载)。无需 API 密钥。',
+ 'settings.voice.page.field.followTextLanguage': '根据文本语言匹配语音',
+ 'settings.voice.page.field.followTextLanguageAria': '根据文本语言匹配语音',
+ 'settings.voice.page.field.followTextLanguageInfo': '当回复使用其他语言时,将使用该语言的语音:匹配的 macOS 语音,或首次使用时下载的本地模型。',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英语)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(25 种欧洲语言)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base(多语言)',
@@ -2095,6 +2099,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.activityDefaultModeAria': '活动默认模式:{option}',
'settings.openchamber.visual.field.showExpandedBashToolsAria': '默认展开 Bash 工具',
'settings.openchamber.visual.field.showExpandedEditToolsAria': '默认展开编辑工具',
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
+
'settings.openchamber.visual.field.bash': 'Bash',
'settings.openchamber.visual.field.editTools': '编辑工具',
'settings.openchamber.visual.field.userMessageRenderingAria': '用户消息渲染:{option}',
@@ -2320,5 +2327,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...linearIntegrationI18n['zh-CN'],
...thirdPartyIntegrationI18n['zh-CN'],
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index da7c6799..f257fc84 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './zh-CN.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record = {
...settingsDict,
+ ...linearIssuePickerI18n['zh-CN'],
+ ...linearPanelI18n['zh-CN'],
'terminalView.actions.attachSelection': '附加所选输出',
'terminalView.actions.restart': '重启终端',
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
@@ -980,6 +984,10 @@ export const dict: Record = {
'gitView.empty.worktreeFeaturesUnavailable': '当前工作区模式下,工作树功能不可用。',
'gitView.empty.worktreeSetupDescription': '正在完成工作树设置并准备仓库状态。',
'gitView.empty.worktreeSetupInProgress': '工作树设置进行中',
+ 'gitView.empty.discoveringRepositories': '正在查找 Git 仓库...',
+ 'gitView.empty.discoverFailed': '无法扫描 Git 仓库',
+ 'gitView.empty.retryDiscovery': '重试',
+ 'gitView.empty.selectRepositoryPlaceholder': '选择仓库...',
'worktree.bootstrap.toast.failed': '工作树设置失败',
'worktree.bootstrap.toast.failedDescription': '工作树已创建,但后台设置未完成。',
'worktree.bootstrap.toast.timeoutDescription': '工作树已创建,但后台设置超时。',
@@ -1915,6 +1923,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': '终端面板({shortcut})',
'chat.recap.aria': '会话回顾',
'chat.recap.label': '回顾:',
+ 'chat.sessionError.title': 'OpenCode 停止了本次回复',
+ 'chat.sessionError.noDetails': 'OpenCode 未报告任何详情。打开状态报告(Ctrl/Cmd+Shift+L)查看最近的错误。',
+ 'chat.sessionError.noReply': 'OpenCode 没有开始回复这条消息。',
'chat.goal.dialog.titleCreate': '设置会话目标',
'chat.goal.dialog.titleManage': '会话目标',
'chat.goal.dialog.objectiveLabel': '目标',
@@ -2275,6 +2286,9 @@ export const dict: Record = {
'session.newWorktree.noMatchingBranches': '没有匹配分支',
'session.newWorktree.localBranches': '本地分支',
'session.newWorktree.remoteBranches': '远程分支',
+ 'session.newWorktree.otherLocalBranches': '其他本地分支',
+ 'session.newWorktree.otherRemoteBranches': '其他远程分支',
+
'session.newWorktree.branchName': '分支名',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': '更改',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
index 20b6fc7d..a1a110eb 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
@@ -1,3 +1,4 @@
+import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤',
@@ -1830,7 +1831,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': '伺服器',
'settings.voice.page.provider.local': '本機',
'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 伺服器上本機轉寫。模型會自動下載,無需 API 金鑰。',
- 'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(Kokoro,英文)。模型會自動下載,無需 API 金鑰。',
+ 'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(英文使用 Kokoro;其他語言的模型在首次使用時下載)。不需要 API 金鑰。',
+ 'settings.voice.page.field.followTextLanguage': '依文字語言選擇語音',
+ 'settings.voice.page.field.followTextLanguageAria': '依文字語言選擇語音',
+ 'settings.voice.page.field.followTextLanguageInfo': '當回覆使用其他語言時,會使用該語言的語音:相符的 macOS 語音,或首次使用時下載的本機模型。',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英文)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(25 種歐洲語言)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base(多語言)',
@@ -2002,6 +2006,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.activityDefaultModeAria': '活動預設模式:{option}',
'settings.openchamber.visual.field.showExpandedBashToolsAria': '預設展開 Bash 工具',
'settings.openchamber.visual.field.showExpandedEditToolsAria': '預設展開編輯工具',
+ 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
+ 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
+
'settings.openchamber.visual.field.bash': 'Bash',
'settings.openchamber.visual.field.editTools': '編輯工具',
'settings.openchamber.visual.field.userMessageRenderingAria': '使用者訊息渲染:{option}',
@@ -2320,5 +2327,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...linearIntegrationI18n['zh-TW'],
...thirdPartyIntegrationI18n['zh-TW'],
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts
index 300aac8d..051b472b 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './zh-TW.settings';
+import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
+import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record = {
...settingsDict,
+ ...linearIssuePickerI18n['zh-TW'],
+ ...linearPanelI18n['zh-TW'],
'terminalView.actions.attachSelection': '附加所選輸出',
'terminalView.actions.restart': '重新啟動終端',
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
@@ -992,6 +996,10 @@ export const dict: Record = {
'gitView.empty.worktreeFeaturesUnavailable': '目前工作區模式下,worktree 功能無法使用。',
'gitView.empty.worktreeSetupDescription': '正在完成 worktree 設定並準備儲存庫狀態。',
'gitView.empty.worktreeSetupInProgress': 'worktree 設定進行中',
+ 'gitView.empty.discoveringRepositories': '正在尋找 Git 儲存庫...',
+ 'gitView.empty.discoverFailed': '無法掃描 Git 儲存庫',
+ 'gitView.empty.retryDiscovery': '重試',
+ 'gitView.empty.selectRepositoryPlaceholder': '選擇儲存庫...',
'worktree.bootstrap.toast.failed': 'worktree 設定失敗',
'worktree.bootstrap.toast.failedDescription': 'worktree 已建立,但背景設定未完成。',
'worktree.bootstrap.toast.timeoutDescription': 'worktree 已建立,但背景設定逾時。',
@@ -1919,6 +1927,9 @@ export const dict: Record = {
'header.actions.terminalPanelWithShortcut': '終端機面板({shortcut})',
'chat.recap.aria': '工作階段回顧',
'chat.recap.label': '回顧:',
+ 'chat.sessionError.title': 'OpenCode 停止了本次回覆',
+ 'chat.sessionError.noDetails': 'OpenCode 未回報任何詳情。開啟狀態報告(Ctrl/Cmd+Shift+L)查看最近的錯誤。',
+ 'chat.sessionError.noReply': 'OpenCode 沒有開始回覆這則訊息。',
'chat.goal.dialog.titleCreate': '設定工作階段目標',
'chat.goal.dialog.titleManage': '工作階段目標',
'chat.goal.dialog.objectiveLabel': '目標',
@@ -2279,6 +2290,9 @@ export const dict: Record = {
'session.newWorktree.noMatchingBranches': '沒有符合分支',
'session.newWorktree.localBranches': '本地分支',
'session.newWorktree.remoteBranches': '遠端分支',
+ 'session.newWorktree.otherLocalBranches': '其他本地分支',
+ 'session.newWorktree.otherRemoteBranches': '其他遠端分支',
+
'session.newWorktree.branchName': '分支名稱',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': '變更',
diff --git a/packages/ui/src/lib/linearProjectMapping.test.ts b/packages/ui/src/lib/linearProjectMapping.test.ts
new file mode 100644
index 00000000..8f5b64d9
--- /dev/null
+++ b/packages/ui/src/lib/linearProjectMapping.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from 'bun:test';
+import { resolveLinearMappedProjectPath } from './linearProjectMapping';
+import type { LinearMappingResult } from './api/types';
+
+const mapping = (): LinearMappingResult => ({
+ connected: true,
+ defaultProjectPath: '/default',
+ teams: [
+ { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/eng' },
+ { id: 'team-des', key: 'DES', name: 'Design', projectPath: null },
+ ],
+});
+
+describe('resolveLinearMappedProjectPath', () => {
+ test('prefers the team path over the default', () => {
+ expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-eng', key: 'ENG', name: 'Engineering' }))
+ .toBe('/eng');
+ });
+
+ test('falls back to the default when the team has no path', () => {
+ expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-des', key: 'DES', name: 'Design' }))
+ .toBe('/default');
+ });
+
+ test('matches a team by key when the id is missing', () => {
+ expect(resolveLinearMappedProjectPath(mapping(), { id: '', key: 'ENG', name: 'Engineering' }))
+ .toBe('/eng');
+ });
+
+ test('returns null when Linear is disconnected or unmapped', () => {
+ expect(resolveLinearMappedProjectPath({ connected: false }, { id: 'team-eng', key: 'ENG', name: 'Engineering' }))
+ .toBeNull();
+ expect(resolveLinearMappedProjectPath({
+ connected: true,
+ defaultProjectPath: null,
+ teams: [{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null }],
+ }, { id: 'team-des', key: 'DES', name: 'Design' })).toBeNull();
+ });
+});
diff --git a/packages/ui/src/lib/linearProjectMapping.ts b/packages/ui/src/lib/linearProjectMapping.ts
new file mode 100644
index 00000000..c8fb2ae5
--- /dev/null
+++ b/packages/ui/src/lib/linearProjectMapping.ts
@@ -0,0 +1,24 @@
+import type { LinearIssueTeam, LinearMappingResult } from '@/lib/api/types';
+
+export function resolveLinearMappedProjectPath(
+ mapping: LinearMappingResult | null | undefined,
+ team: LinearIssueTeam | null | undefined,
+): string | null {
+ if (!mapping || mapping.connected === false) {
+ return null;
+ }
+ const teams = mapping.teams ?? [];
+ if (team?.id) {
+ const byId = teams.find((entry) => entry.id === team.id);
+ if (byId?.projectPath) {
+ return byId.projectPath;
+ }
+ }
+ if (team?.key) {
+ const byKey = teams.find((entry) => entry.key === team.key);
+ if (byKey?.projectPath) {
+ return byKey.projectPath;
+ }
+ }
+ return mapping.defaultProjectPath?.trim() || null;
+}
diff --git a/packages/ui/src/lib/linearSessionStatus.test.ts b/packages/ui/src/lib/linearSessionStatus.test.ts
new file mode 100644
index 00000000..672fb3f5
--- /dev/null
+++ b/packages/ui/src/lib/linearSessionStatus.test.ts
@@ -0,0 +1,50 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+
+import { resolveLinearSessionOrigin } from './linearSessionStatus';
+
+describe('resolveLinearSessionOrigin', () => {
+ const originalWindow = globalThis.window;
+
+ afterEach(() => {
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: originalWindow,
+ });
+ });
+
+ test('uses the page origin on web', () => {
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ location: { origin: 'https://app.example.com' },
+ },
+ });
+ expect(resolveLinearSessionOrigin()).toBe('https://app.example.com');
+ });
+
+ test('uses the desktop loopback origin instead of the packaged UI scheme', () => {
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ location: { origin: 'openchamber-ui://app' },
+ __OPENCHAMBER_ELECTRON__: { runtime: 'electron' },
+ __OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:3001',
+ },
+ });
+ expect(resolveLinearSessionOrigin()).toBe('http://127.0.0.1:3001');
+ });
+
+ test('reports no origin when the desktop shell has no http loopback', () => {
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ location: { origin: 'openchamber-ui://app' },
+ __OPENCHAMBER_ELECTRON__: { runtime: 'electron' },
+ __OPENCHAMBER_LOCAL_ORIGIN__: 'openchamber-ui://app',
+ },
+ });
+ // A deep link is unopenable for everyone but this machine, so the server
+ // gets no origin and posts no comment.
+ expect(resolveLinearSessionOrigin()).toBe(undefined);
+ });
+});
diff --git a/packages/ui/src/lib/linearSessionStatus.ts b/packages/ui/src/lib/linearSessionStatus.ts
new file mode 100644
index 00000000..c7e0cee1
--- /dev/null
+++ b/packages/ui/src/lib/linearSessionStatus.ts
@@ -0,0 +1,45 @@
+import type { LinearAPI } from '@/lib/api/types';
+import { isElectronShell } from '@/lib/desktop';
+import { getLocalDesktopOrigin } from '@/lib/desktopCurrentHost';
+
+function isHttpOrigin(value: string): boolean {
+ try {
+ const url = new URL(value);
+ return url.protocol === 'http:' || url.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Origin Linear comments should open. Packaged desktop UI lives on
+ * `openchamber-ui://`, which is not a URL a browser can load from Linear, so
+ * report the http origin the local server actually listens on instead. The
+ * server decides whether that origin is reachable by anyone else; a comment is
+ * only posted when it is.
+ */
+export function resolveLinearSessionOrigin(): string | undefined {
+ if (typeof window === 'undefined') return undefined;
+ if (isElectronShell()) {
+ const localOrigin = getLocalDesktopOrigin().trim();
+ if (localOrigin && isHttpOrigin(localOrigin)) {
+ return new URL(localOrigin).origin;
+ }
+ return undefined;
+ }
+ const origin = window.location.origin.trim();
+ return origin || undefined;
+}
+
+export function postLinearSessionStarted(
+ linear: LinearAPI | undefined,
+ args: { sessionId: string; issueIdentifier: string },
+): void {
+ if (!linear?.sessionStatusPost) return;
+ void linear.sessionStatusPost({
+ kind: 'started',
+ sessionId: args.sessionId,
+ issueIdentifier: args.issueIdentifier,
+ sessionOrigin: resolveLinearSessionOrigin(),
+ }).catch(() => undefined);
+}
diff --git a/packages/ui/src/lib/linearStartSession.test.ts b/packages/ui/src/lib/linearStartSession.test.ts
new file mode 100644
index 00000000..d19ebf39
--- /dev/null
+++ b/packages/ui/src/lib/linearStartSession.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from 'bun:test';
+import { buildIssueContextText } from './linearStartSession';
+import type { LinearIssue } from '@/lib/api/types';
+
+const issue: LinearIssue = {
+ id: 'issue-1',
+ identifier: 'ENG-12',
+ title: 'Broken login',
+ url: 'https://linear.app/openchamber/issue/ENG-12',
+ description: 'Users cannot sign in.',
+ comments: [],
+};
+
+describe('buildIssueContextText', () => {
+ test('serializes the issue and comments as JSON context', () => {
+ const text = buildIssueContextText({
+ issue,
+ comments: [{
+ id: 'comment-1',
+ body: 'Still broken',
+ createdAt: '2026-08-24T10:00:00.000Z',
+ user: { name: 'Ada', displayName: 'Ada Lovelace' },
+ }],
+ });
+ expect(text.startsWith('Linear issue context (JSON)\n')).toBe(true);
+ expect(text).toContain('"identifier": "ENG-12"');
+ expect(text).toContain('Still broken');
+ });
+});
diff --git a/packages/ui/src/lib/linearStartSession.ts b/packages/ui/src/lib/linearStartSession.ts
new file mode 100644
index 00000000..532db084
--- /dev/null
+++ b/packages/ui/src/lib/linearStartSession.ts
@@ -0,0 +1,235 @@
+import { toast } from '@/components/ui';
+import type { LinearAPI, LinearIssue, LinearIssueComment, LinearMappingResult } from '@/lib/api/types';
+import type { I18nKey, I18nParams } from '@/lib/i18n';
+import { parseModelIdentifier } from '@/lib/modelIdentifier';
+import { modelVariantNames } from '@/lib/modelVariants';
+import { renderMagicPrompt } from '@/lib/magicPrompts';
+import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
+import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
+import { buildLinkedLinearIssue } from '@/lib/linkedIssues';
+import { resolveLinearMappedProjectPath } from '@/lib/linearProjectMapping';
+import { postLinearSessionStarted } from '@/lib/linearSessionStatus';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { useSessionUIStore } from '@/sync/session-ui-store';
+import { useSelectionStore } from '@/sync/selection-store';
+import * as sessionActions from '@/sync/session-actions';
+
+type TranslateFn = (key: I18nKey, params?: I18nParams) => string;
+
+export function buildIssueContextText(args: {
+ issue: LinearIssue;
+ comments: LinearIssueComment[];
+}): string {
+ const payload = {
+ issue: args.issue,
+ comments: args.comments,
+ };
+ return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
+}
+
+function resolveDefaultAgentName(): string | undefined {
+ const configState = useConfigStore.getState();
+ const settingsDefaultAgent = configState.settingsDefaultAgent;
+ if (settingsDefaultAgent) {
+ return settingsDefaultAgent;
+ }
+ const visibleAgents = configState.agents.filter((agent) => !agent.hidden);
+ return (
+ configState.currentAgentName
+ || visibleAgents.find((agent) => agent.mode === 'primary' || !agent.mode)?.name
+ || visibleAgents[0]?.name
+ );
+}
+
+function resolveDefaultModelSelection(): { providerID: string; modelID: string } | null {
+ const configState = useConfigStore.getState();
+ const settingsDefaultModel = configState.settingsDefaultModel;
+ if (!settingsDefaultModel) {
+ return null;
+ }
+
+ const parsed = parseModelIdentifier(settingsDefaultModel);
+ if (!parsed) {
+ return null;
+ }
+ const { providerId: providerID, modelId: modelID } = parsed;
+
+ const modelMetadata = configState.getModelMetadata(providerID, modelID);
+ if (!modelMetadata) {
+ return null;
+ }
+
+ return { providerID, modelID };
+}
+
+function resolveDefaultVariant(providerID: string, modelID: string): string | undefined {
+ const configState = useConfigStore.getState();
+ const settingsDefaultVariant = configState.settingsDefaultVariant;
+ const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID
+ ? configState.currentVariant
+ : undefined;
+
+ const provider = configState.providers.find((entry) => entry.id === providerID);
+ const model = provider?.models.find((entry) => entry.id === modelID);
+ const variantNames = modelVariantNames(model);
+ if (variantNames.length === 0) {
+ return settingsDefaultVariant || currentVariant || undefined;
+ }
+ if (settingsDefaultVariant && variantNames.includes(settingsDefaultVariant)) {
+ return settingsDefaultVariant;
+ }
+ if (currentVariant && variantNames.includes(currentVariant)) {
+ return currentVariant;
+ }
+ return undefined;
+}
+
+export async function startLinearIssueSession(args: {
+ linear: LinearAPI | undefined;
+ issueKey: string;
+ createInWorktree: boolean;
+ mapping?: LinearMappingResult | null;
+ onMappingLoaded?: (mapping: LinearMappingResult) => void;
+ onSessionCreated?: () => void;
+ t: TranslateFn;
+}): Promise {
+ const { linear, issueKey, createInWorktree, t } = args;
+ if (!linear?.issueGet || !linear.mappingGet) {
+ toast.error(t('session.linearIssuePicker.error.runtimeUnavailable'));
+ return false;
+ }
+
+ try {
+ let mappingView = args.mapping;
+ if (!mappingView) {
+ mappingView = await linear.mappingGet();
+ args.onMappingLoaded?.(mappingView);
+ }
+ if (mappingView.connected === false) {
+ toast.error(t('session.linearIssuePicker.error.notConnected'));
+ return false;
+ }
+
+ const issueRes = await linear.issueGet(issueKey);
+ if (issueRes.connected === false) {
+ toast.error(t('session.linearIssuePicker.error.notConnected'));
+ return false;
+ }
+ const issue = issueRes.issue;
+ if (!issue) {
+ toast.error(t('session.linearIssuePicker.error.issueNotFound'));
+ return false;
+ }
+
+ const projectDirectory = resolveLinearMappedProjectPath(mappingView, issue.team);
+ if (!projectDirectory) {
+ toast.error(t('session.linearIssuePicker.error.noMappedProject'));
+ return false;
+ }
+
+ const comments = issue.comments ?? [];
+ const sessionTitle = `${issue.identifier} ${issue.title}`.trim();
+ const login = issue.assignee?.displayName || issue.assignee?.name;
+
+ const { sessionId, sessionDirectory } = await (async () => {
+ if (createInWorktree) {
+ const preferred = `issue-${issue.identifier}-${generateBranchSlug()}`;
+ const created = await createWorktreeSessionForNewBranch(
+ projectDirectory,
+ preferred,
+ undefined,
+ { returnAfterDirectoryCreated: true },
+ );
+ if (!created?.id) {
+ throw new Error('Failed to create worktree session');
+ }
+ return { sessionId: created.id, sessionDirectory: created.path };
+ }
+
+ const session = await sessionActions.createSession(sessionTitle, projectDirectory, null);
+ if (!session?.id) {
+ throw new Error('Failed to create session');
+ }
+ return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory };
+ })();
+
+ void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
+
+ try {
+ useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
+ } catch {
+ // ignore
+ }
+
+ args.onSessionCreated?.();
+ useUIStore.getState().closeMainSurfaces();
+ useUIStore.getState().setSessionSwitcherOpen(false);
+
+ postLinearSessionStarted(linear, {
+ sessionId,
+ issueIdentifier: issue.identifier,
+ });
+
+ const configState = useConfigStore.getState();
+ const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
+ const defaultModel = resolveDefaultModelSelection();
+ const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
+ const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
+ const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
+ if (!providerID || !modelID) {
+ toast.error(t('session.linearIssuePicker.error.noModelSelected'));
+ return true;
+ }
+
+ const variant = resolveDefaultVariant(providerID, modelID);
+ const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', {
+ identifier: issue.identifier,
+ });
+ const instructionsText = await renderMagicPrompt('linear.issue.review.instructions');
+ const contextText = buildIssueContextText({ issue, comments });
+
+ void sessionActions.setLinkedIssue(
+ sessionId,
+ sessionDirectory,
+ buildLinkedLinearIssue({
+ identifier: issue.identifier,
+ title: issue.title,
+ url: issue.url,
+ author: login
+ ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
+ : undefined,
+ linkedAt: Date.now(),
+ }),
+ true,
+ ).catch(() => undefined);
+
+ void useSessionUIStore.getState().sendMessage(
+ visiblePromptText,
+ providerID,
+ modelID,
+ agentName,
+ undefined,
+ undefined,
+ [
+ { text: instructionsText, synthetic: true },
+ { text: contextText, synthetic: true },
+ ],
+ variant,
+ undefined,
+ { sessionId, directory: sessionDirectory },
+ ).catch((error) => {
+ const message = error instanceof Error ? error.message : String(error);
+ toast.error(t('session.linearIssuePicker.toast.sendContextFailed'), {
+ description: message,
+ });
+ });
+
+ toast.success(t('session.linearIssuePicker.toast.sessionCreated'));
+ return true;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ toast.error(t('session.linearIssuePicker.toast.startSessionFailed'), { description: message });
+ return false;
+ }
+}
diff --git a/packages/ui/src/lib/linkedIssues.test.ts b/packages/ui/src/lib/linkedIssues.test.ts
index 740088c5..953b505f 100644
--- a/packages/ui/src/lib/linkedIssues.test.ts
+++ b/packages/ui/src/lib/linkedIssues.test.ts
@@ -6,6 +6,8 @@ import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore'
import {
buildLinkedIssue,
buildLinkedIssueId,
+ buildLinkedLinearIssue,
+ canOpenLinearIssueInContextPanel,
deriveLinkedIssueProvider,
deriveLinkedIssueRepo,
getLinkedIssues,
@@ -15,7 +17,9 @@ import {
type LinkedIssue,
} from './linkedIssues';
-const issue = (overrides: Partial = {}): LinkedIssue => ({
+type LinkedGitHubIssue = Exclude;
+
+const issue = (overrides: Partial = {}): LinkedGitHubIssue => ({
id: 'owner/repo#12',
number: 12,
title: 'Rail badge count',
@@ -632,6 +636,28 @@ describe('parseForgeEntityUrl', () => {
});
});
+describe('buildLinkedLinearIssue', () => {
+ test('stores the Linear identifier without inventing a GitHub number', () => {
+ const built = buildLinkedLinearIssue({
+ identifier: 'ENG-12',
+ title: 'Broken login',
+ url: 'https://linear.app/openchamber/issue/ENG-12',
+ author: { login: 'Ada', avatarUrl: 'https://avatars/1' },
+ linkedAt: 5,
+ });
+ expect(built).toEqual({
+ id: 'linear:ENG-12',
+ identifier: 'ENG-12',
+ title: 'Broken login',
+ url: 'https://linear.app/openchamber/issue/ENG-12',
+ kind: 'linear',
+ author: 'Ada',
+ authorAvatarUrl: 'https://avatars/1',
+ linkedAt: 5,
+ });
+ });
+});
+
describe('getLinkedIssues', () => {
test('returns an empty list for a session with no metadata', () => {
expect(getLinkedIssues(undefined)).toEqual([]);
@@ -651,6 +677,17 @@ describe('getLinkedIssues', () => {
expect(getLinkedIssues(session)).toEqual([good]);
});
+ test('keeps Linear entries next to GitHub ones', () => {
+ const github = issue();
+ const linear = buildLinkedLinearIssue({
+ identifier: 'ENG-12',
+ title: 'Broken login',
+ url: 'https://linear.app/openchamber/issue/ENG-12',
+ linkedAt: 2,
+ });
+ expect(getLinkedIssues(sessionWith([github, linear]))).toEqual([github, linear]);
+ });
+
test('survives a non-array payload', () => {
expect(getLinkedIssues(sessionWith({ nope: true }))).toEqual([]);
});
@@ -712,3 +749,41 @@ describe('withLinkedIssue', () => {
expect((next.openchamber as { linked_issues: LinkedIssue[] }).linked_issues).toEqual([issue()]);
});
});
+
+describe('canOpenLinearIssueInContextPanel', () => {
+ test('opens the rail when Linear is connected, the shell has a context panel, and a directory is known', () => {
+ expect(canOpenLinearIssueInContextPanel({
+ linearAvailable: true,
+ linearConnected: true,
+ inDedicatedMobileShell: false,
+ directory: '/repo',
+ })).toBe(true);
+ });
+
+ test('falls back when Linear is missing, disconnected, the mobile shell is open, or the directory is blank', () => {
+ expect(canOpenLinearIssueInContextPanel({
+ linearAvailable: false,
+ linearConnected: true,
+ inDedicatedMobileShell: false,
+ directory: '/repo',
+ })).toBe(false);
+ expect(canOpenLinearIssueInContextPanel({
+ linearAvailable: true,
+ linearConnected: false,
+ inDedicatedMobileShell: false,
+ directory: '/repo',
+ })).toBe(false);
+ expect(canOpenLinearIssueInContextPanel({
+ linearAvailable: true,
+ linearConnected: true,
+ inDedicatedMobileShell: true,
+ directory: '/repo',
+ })).toBe(false);
+ expect(canOpenLinearIssueInContextPanel({
+ linearAvailable: true,
+ linearConnected: true,
+ inDedicatedMobileShell: false,
+ directory: ' ',
+ })).toBe(false);
+ });
+});
diff --git a/packages/ui/src/lib/linkedIssues.ts b/packages/ui/src/lib/linkedIssues.ts
index e35ec879..92dedfad 100644
--- a/packages/ui/src/lib/linkedIssues.ts
+++ b/packages/ui/src/lib/linkedIssues.ts
@@ -27,7 +27,7 @@ import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewM
* free.
*/
-export type LinkedIssue = {
+export type LinkedGitHubIssue = {
/** `owner/repo#number`, unique per session and stable across renames. */
id: string;
number: number;
@@ -45,10 +45,29 @@ export type LinkedIssue = {
linkedAt: number;
};
+export type LinkedLinearIssue = {
+ /** `linear:{identifier}`, unique per session. */
+ id: string;
+ identifier: string;
+ title: string;
+ url: string;
+ kind: 'linear';
+ author?: string;
+ authorAvatarUrl?: string;
+ /** Present only when a linear issue is also linked to a forge repo. */
+ number?: number;
+ provider?: ForgeProviderKind;
+ repo?: string;
+ host?: string;
+ linkedAt: number;
+};
+
+export type LinkedIssue = LinkedGitHubIssue | LinkedLinearIssue;
+
const isRecord = (value: unknown): value is Record =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
-const isLinkedIssue = (value: unknown): value is LinkedIssue => (
+const isLinkedGitHubIssue = (value: unknown): value is LinkedGitHubIssue => (
isRecord(value)
&& typeof value.id === 'string'
&& value.id.length > 0
@@ -61,9 +80,29 @@ const isLinkedIssue = (value: unknown): value is LinkedIssue => (
&& Number.isFinite(value.linkedAt)
);
+const isLinkedLinearIssue = (value: unknown): value is LinkedLinearIssue => (
+ isRecord(value)
+ && typeof value.id === 'string'
+ && value.id.length > 0
+ && typeof value.identifier === 'string'
+ && value.identifier.length > 0
+ && typeof value.title === 'string'
+ && typeof value.url === 'string'
+ && value.kind === 'linear'
+ && typeof value.linkedAt === 'number'
+ && Number.isFinite(value.linkedAt)
+);
+
+const isLinkedIssue = (value: unknown): value is LinkedIssue => (
+ isLinkedGitHubIssue(value) || isLinkedLinearIssue(value)
+);
+
export const buildLinkedIssueId = (owner: string, repo: string, number: number): string =>
`${owner}/${repo}#${number}`;
+const buildLinkedLinearIssueId = (identifier: string): string =>
+ `linear:${identifier}`;
+
/**
* Builds the stored snapshot from what an attach flow already has.
*
@@ -300,6 +339,35 @@ export const buildLinkedIssue = (input: {
};
};
+export const buildLinkedLinearIssue = (input: {
+ identifier: string;
+ title: string;
+ url: string;
+ author?: { login?: string; avatarUrl?: string } | null;
+ linkedAt: number;
+}): LinkedLinearIssue => ({
+ id: buildLinkedLinearIssueId(input.identifier),
+ identifier: input.identifier,
+ title: input.title,
+ url: input.url,
+ kind: 'linear',
+ author: input.author?.login ?? undefined,
+ authorAvatarUrl: input.author?.avatarUrl ?? undefined,
+ linkedAt: input.linkedAt,
+});
+
+export const canOpenLinearIssueInContextPanel = (options: {
+ linearAvailable: boolean;
+ linearConnected: boolean;
+ inDedicatedMobileShell: boolean;
+ directory: string | null | undefined;
+}): boolean => (
+ options.linearAvailable
+ && options.linearConnected
+ && !options.inDedicatedMobileShell
+ && Boolean(options.directory?.trim())
+);
+
export const getLinkedIssues = (session: Session | null | undefined): LinkedIssue[] => {
const openchamber = getSessionMetadata(session).openchamber;
if (!isRecord(openchamber) || !Array.isArray(openchamber.linked_issues)) return [];
diff --git a/packages/ui/src/lib/linkedSessionMatches.test.ts b/packages/ui/src/lib/linkedSessionMatches.test.ts
index d13129aa..31bec982 100644
--- a/packages/ui/src/lib/linkedSessionMatches.test.ts
+++ b/packages/ui/src/lib/linkedSessionMatches.test.ts
@@ -3,7 +3,7 @@ import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
import type { ForgeRepoRef } from '@/lib/forge/types';
-import type { LinkedIssue } from '@/lib/linkedIssues';
+import type { LinkedGitHubIssue } from '@/lib/linkedIssues';
import {
findLinkedSessionsForEntity,
linkedEntityCandidateIds,
@@ -26,7 +26,7 @@ const repoRef = (overrides: Partial = {}): ForgeRepoRef => ({
...overrides,
});
-const linkedIssue = (overrides: Partial = {}): LinkedIssue => ({
+const linkedIssue = (overrides: Partial = {}): LinkedGitHubIssue => ({
id: 'owner/widget#42',
number: 42,
title: 'Rail badge count',
diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts
index 1f57ea14..a5463fe8 100644
--- a/packages/ui/src/lib/magicPrompts.ts
+++ b/packages/ui/src/lib/magicPrompts.ts
@@ -13,6 +13,8 @@ export type MagicPromptId =
| 'github.pr.review.instructions'
| 'github.issue.review.visible'
| 'github.issue.review.instructions'
+ | 'linear.issue.review.visible'
+ | 'linear.issue.review.instructions'
| 'github.pr.checks.review.visible'
| 'github.pr.checks.review.instructions'
| 'github.pr.comments.review.visible'
@@ -64,7 +66,7 @@ export interface MagicPromptDefinition {
id: MagicPromptId;
title: string;
description: string;
- group: 'Git' | 'GitHub' | 'GitLab' | 'Gitea' | 'Planning' | 'Session';
+ group: 'Git' | 'GitHub' | 'GitLab' | 'Gitea' | 'Linear' | 'Planning' | 'Session';
template: string;
placeholders?: Array<{ key: string; description: string }>;
}
@@ -269,6 +271,61 @@ Question/Support:
- Answer/guidance (max 6 lines)
- Missing info (max 4)
+Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`,
+ },
+ {
+ id: 'linear.issue.review.visible',
+ title: 'Linear Issue Review Visible Prompt',
+ group: 'Linear',
+ description: 'Visible user message when creating a session from a Linear issue.',
+ placeholders: [
+ { key: 'identifier', description: 'Linear issue identifier, such as ENG-12.' },
+ ],
+ template: 'Review this Linear issue {{identifier}} using the provided issue context',
+ },
+ {
+ id: 'linear.issue.review.instructions',
+ title: 'Linear Issue Review Instructions',
+ group: 'Linear',
+ description: 'Hidden instructions attached when generating a Linear issue review response.',
+ template: `Review this Linear issue using the provided issue context.
+
+Process:
+- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: .
+- Gather any needed repository context (code, config, docs) to validate assumptions.
+- After gathering, if anything is still unclear or cannot be verified, do not speculate — state what's missing and ask targeted questions.
+
+Mode selection by type:
+- Bug / Question/Support / Ops: deliver the response directly using the matching template below. Do not bombard me with questions for straightforward diagnosis; use "Missing info" / "Repro/diagnostics needed" fields instead.
+- Feature request / Refactor with substantive unknowns: this is effectively a planning session. Do not emit the Feature template on the first turn. Instead, ask me focused clarifying questions in batches of at most 3, one topic at a time (scope, constraints, tradeoffs, UX, etc.), wait for answers, drop questions that became irrelevant, and repeat until you have no more substantive questions. Only then emit the Feature template.
+
+Output rules:
+- Compact output; pick ONE template below and omit the others.
+- No emojis. No code snippets. No fenced blocks.
+- Short inline code identifiers allowed.
+- Reference evidence with file paths and line ranges when applicable; if exact lines are not available, cite the file and say "approx" + why.
+- Keep the entire response under ~300 words (applies to the final template output, not to clarifying-question turns).
+
+Templates (choose one):
+Bug:
+- Summary (1-2 sentences)
+- Likely cause (max 2)
+- Repro/diagnostics needed (max 3)
+- Fix approach (max 4 steps)
+- Verification (max 3)
+
+Feature:
+- Summary (1-2 sentences)
+- Requirements (max 4)
+- Unknowns/questions (max 4)
+- Proposed plan (max 5 steps)
+- Verification (max 3)
+
+Question/Support:
+- Summary (1-2 sentences)
+- Answer/guidance (max 6 lines)
+- Missing info (max 4)
+
Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`,
},
{
diff --git a/packages/ui/src/lib/messages/contextParts.test.ts b/packages/ui/src/lib/messages/contextParts.test.ts
index 52caca5f..a04b8658 100644
--- a/packages/ui/src/lib/messages/contextParts.test.ts
+++ b/packages/ui/src/lib/messages/contextParts.test.ts
@@ -114,6 +114,13 @@ describe('round-trip through part metadata', () => {
expect(readContextPart(part)).toEqual(payload);
});
+ test('linear references carry picker-built text and the identifier', () => {
+ const payload: ContextPartPayload = { kind: 'linear-issue', identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12' };
+ const part = asPart(payload, 'Linear issue context (JSON)\n{}');
+ expect(part.text).toBe('Linear issue context (JSON)\n{}');
+ expect(readContextPart(part)).toEqual(payload);
+ });
+
test('non-text parts, missing metadata, and malformed payloads read as null', () => {
expect(readContextPart({ type: 'file', metadata: {} })).toBeNull();
expect(readContextPart({ type: 'text' })).toBeNull();
diff --git a/packages/ui/src/lib/messages/contextParts.ts b/packages/ui/src/lib/messages/contextParts.ts
index f875f8a7..4b6b78a9 100644
--- a/packages/ui/src/lib/messages/contextParts.ts
+++ b/packages/ui/src/lib/messages/contextParts.ts
@@ -96,6 +96,13 @@ type GitHubPrContext = {
url: string;
};
+type LinearIssueContext = {
+ kind: 'linear-issue';
+ identifier: string;
+ title: string;
+ url: string;
+};
+
export type ContextPartPayload =
| CodeCommentContext
| TerminalContextPayload
@@ -105,7 +112,8 @@ export type ContextPartPayload =
| FileQuoteContext
| ChatQuoteContext
| GitHubIssueContext
- | GitHubPrContext;
+ | GitHubPrContext
+ | LinearIssueContext;
export type ContextPartMetadata = { [K in typeof CONTEXT_METADATA_KEY]: ContextPartPayload };
@@ -154,6 +162,7 @@ export function formatContextText(payload: ContextPartPayload): string {
return `Attached failed GitHub PR check (${payload.label}):\n\`\`\`\n${payload.output}\n\`\`\`${payload.text ? `\n\n${payload.text}` : ''}`;
case 'github-issue':
case 'github-pr':
+ case 'linear-issue':
// Linked issues/PRs carry server-fetched context text built by
// their pickers; there is no default text to derive here.
return '';
@@ -162,8 +171,9 @@ export function formatContextText(payload: ContextPartPayload): string {
/**
* Build the synthetic part for one context payload. `text` overrides the
- * derived text; github-issue/github-pr payloads require it because their
- * model-facing context is fetched by the picker, not derived from metadata.
+ * derived text; github-issue/github-pr/linear-issue payloads require it
+ * because their model-facing context is fetched by the picker, not derived
+ * from metadata.
*/
export function createContextPart(payload: ContextPartPayload, text?: string): ContextPart {
const resolvedText = text ?? formatContextText(payload);
@@ -297,6 +307,12 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [
title: z.string(),
url: z.string(),
}),
+ z.object({
+ kind: z.literal('linear-issue'),
+ identifier: z.string().min(1),
+ title: z.string(),
+ url: z.string(),
+ }),
]);
/** The subset of a message part that context read-back inspects. */
@@ -317,3 +333,78 @@ export function readContextPart(part: ContextCarrierPart): ContextPartPayload |
export function hasContextParts(parts: ContextCarrierPart[]): boolean {
return parts.some((part) => readContextPart(part) !== null);
}
+
+/**
+ * The composer draft a context payload came from, so reverting or forking a
+ * message can put its attached context back on the chips instead of dropping
+ * it. Linked issues/PRs have no draft form — they are owned by their own
+ * pickers — so they map to null.
+ */
+export function draftFromContextPayload(
+ payload: ContextPartPayload,
+): Omit | null {
+ switch (payload.kind) {
+ case 'code-comment': {
+ const draft: Omit = {
+ source: payload.source,
+ fileLabel: payload.fileLabel,
+ startLine: payload.startLine,
+ endLine: payload.endLine,
+ code: payload.code,
+ language: payload.language,
+ text: payload.text,
+ };
+ if (payload.side) draft.side = payload.side;
+ return draft;
+ }
+ case 'terminal':
+ return {
+ source: 'terminal',
+ fileLabel: payload.terminalLabel,
+ startLine: payload.startLine,
+ endLine: payload.endLine,
+ code: payload.output,
+ language: '',
+ text: '',
+ terminalId: payload.terminalId,
+ };
+ case 'browser-annotation':
+ return {
+ source: 'preview-annotation',
+ fileLabel: payload.pageUrl,
+ startLine: 0,
+ endLine: 0,
+ code: payload.prompt,
+ language: '',
+ text: payload.text,
+ };
+ case 'pr-comment':
+ return { source: 'pr-comment', fileLabel: payload.label, startLine: 0, endLine: 0, code: payload.body, language: '', text: payload.text };
+ case 'pr-check':
+ return { source: 'pr-check', fileLabel: payload.label, startLine: 0, endLine: 0, code: payload.output, language: '', text: payload.text };
+ case 'file-quote':
+ return {
+ source: 'file-quote',
+ fileLabel: payload.fileLabel,
+ startLine: payload.startLine ?? 0,
+ endLine: payload.endLine ?? 0,
+ code: payload.quote,
+ language: '',
+ text: payload.text,
+ };
+ case 'chat-quote':
+ return {
+ source: 'chat-quote',
+ fileLabel: payload.messageId ?? '',
+ startLine: 0,
+ endLine: 0,
+ code: payload.quote,
+ language: '',
+ text: payload.text,
+ };
+ case 'github-issue':
+ case 'github-pr':
+ case 'linear-issue':
+ return null;
+ }
+}
diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts
index 31c7578b..b3d393de 100644
--- a/packages/ui/src/lib/openCodeStatus.ts
+++ b/packages/ui/src/lib/openCodeStatus.ts
@@ -4,6 +4,8 @@ import { useUIStore } from '@/stores/useUIStore';
import { getRuntimeUrlResolver } from './runtime-url';
import { opencodeClient } from './opencode/client';
import { runtimeFetch } from './runtime-fetch';
+import { getRecentSendFailures } from '@/sync/send-failure-log';
+import { getRecentSessionErrors } from '@/sync/session-error-log';
declare const __APP_VERSION__: string | undefined;
@@ -21,6 +23,8 @@ type OpenChamberHealthSnapshot = {
openCodeAuthSource?: unknown;
isOpenCodeReady?: unknown;
lastOpenCodeError?: unknown;
+ lastOpenCodeHealthFailure?: unknown;
+ lastManagedOpenCodeProcess?: unknown;
lastOpenCodeLaunchDiagnostics?: unknown;
opencodeBinaryResolved?: unknown;
opencodeBinarySource?: unknown;
@@ -128,6 +132,15 @@ const normalizePort = (value: unknown): number | null => {
const isRecord = (value: unknown): value is Record =>
!!value && typeof value === 'object' && !Array.isArray(value);
+const STDERR_TAIL_LINES = 12;
+const RECENT_RECORD_LINES = 8;
+
+const joinPath = (base: string, relative: string, windows: boolean): string => {
+ const separator = windows ? '\\' : '/';
+ const trimmed = base.replace(/[\\/]+$/, '');
+ return `${trimmed}${separator}${windows ? relative.replace(/\//g, '\\') : relative}`;
+};
+
const formatUnknown = (value: unknown, fallback = '(n/a)'): string => {
if (typeof value === 'string') return value.trim() || fallback;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
@@ -148,7 +161,7 @@ const formatLaunchRuntime = (wrapperType: string, node: string, bun: string): st
return 'direct executable';
};
-const buildOpenCodeStatusReport = async (): Promise => {
+export const buildOpenCodeStatusReport = async (): Promise => {
const now = new Date();
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
const platform = typeof navigator !== 'undefined' ? navigator.userAgent : '(no navigator)';
@@ -159,6 +172,7 @@ const buildOpenCodeStatusReport = async (): Promise => {
const healthUrl = urls.health();
const apiBase = urls.api('/api/');
+
const openChamberHealth: OpenChamberHealthSnapshot | null = await (async () => {
if (!healthUrl) return null;
const controller = new AbortController();
@@ -227,15 +241,36 @@ const buildOpenCodeStatusReport = async (): Promise => {
const buildProbeUrl = (pathname: string, includeDirectory = true): string | null => {
if (!apiBase) return null;
- const url = new URL(pathname.replace(/^\/+/, ''), apiBase);
+ // A web runtime resolves its API base relative to the page; a relative
+ // base is not a valid URL base on its own.
+ const absoluteBase = /^[a-z][a-z0-9+.-]*:/i.test(apiBase) || !origin ? apiBase : new URL(apiBase, origin).toString();
+ const url = new URL(pathname.replace(/^\/+/, ''), absoluteBase);
if (includeDirectory && directory) {
url.searchParams.set('directory', directory);
}
return url.toString();
};
+ // OpenCode's own view of its directories; `home` anchors the log path below.
+ const pathInfo: { home?: unknown } | null = await (async () => {
+ const url = buildProbeUrl('/path', true);
+ if (!url) return null;
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 5000);
+ try {
+ const resp = await runtimeFetch(url, { signal: controller.signal, cache: 'no-store' });
+ if (!resp.ok) return null;
+ const json = (await resp.json().catch(() => null)) as unknown;
+ return isRecord(json) ? json : null;
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(timeout);
+ }
+ })();
+
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
- { label: 'health', path: '/health', includeDirectory: false },
+ { label: 'health', path: '/global/health', includeDirectory: false },
{ label: 'config', path: '/config', includeDirectory: true },
{ label: 'providers', path: '/config/providers', includeDirectory: true },
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 },
@@ -278,6 +313,64 @@ const buildOpenCodeStatusReport = async (): Promise => {
lines.push(`OpenCode auth source: ${openChamberHealth.openCodeAuthSource}`);
}
+ // What the managed OpenCode process last said for itself. A turn that stops
+ // with nothing on screen usually left its reason here or in the session
+ // errors below, not in the UI.
+ const lastOpenCodeError = formatUnknown(openChamberHealth?.lastOpenCodeError, '');
+ const managedProcess = isRecord(openChamberHealth?.lastManagedOpenCodeProcess)
+ ? openChamberHealth.lastManagedOpenCodeProcess
+ : null;
+ const stderrTail = managedProcess && typeof managedProcess.stderrTail === 'string'
+ ? managedProcess.stderrTail.trim()
+ : '';
+ if (lastOpenCodeError || managedProcess) {
+ lines.push('');
+ lines.push('OpenCode process:');
+ if (lastOpenCodeError) lines.push(`- last error: ${lastOpenCodeError}`);
+ if (managedProcess) {
+ lines.push(`- pid: ${formatUnknown(managedProcess.pid, '(none)')} exit=${formatUnknown(managedProcess.exitCode, '(running)')} signal=${formatUnknown(managedProcess.signalCode, '(none)')}`);
+ }
+ if (stderrTail) {
+ const tailLines = stderrTail.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-STDERR_TAIL_LINES);
+ lines.push(`- stderr (last ${tailLines.length} lines):`);
+ for (const line of tailLines) lines.push(` ${line.slice(0, 300)}`);
+ }
+ }
+
+ const sessionErrors = getRecentSessionErrors();
+ lines.push('');
+ lines.push(`Recent OpenCode session errors: ${sessionErrors.length === 0 ? '(none this app session)' : ''}`.trimEnd());
+ for (const record of sessionErrors.slice(0, RECENT_RECORD_LINES)) {
+ const detail = record.message ?? '(no message)';
+ lines.push(`- ${formatIso(record.at)} session=${record.sessionId.slice(0, 16)} ${record.name ? `${record.name}: ` : ''}${detail}`);
+ }
+
+ const sendFailures = getRecentSendFailures();
+ lines.push('');
+ lines.push(`Recent rejected sends: ${sendFailures.length === 0 ? '(none this app session)' : ''}`.trimEnd());
+ for (const record of sendFailures.slice(0, RECENT_RECORD_LINES)) {
+ lines.push(`- ${formatIso(record.at)} session=${record.sessionId.slice(0, 16)} status=${record.status ?? 'transport'}${record.ambiguous ? ' ambiguous' : ''} ${record.reason}`);
+ }
+
+ // Where to look next. OpenCode keeps its own log under the XDG data
+ // directory (the same default on every platform, which is why Windows users
+ // do not find it under AppData); the desktop app writes the server console,
+ // including OpenCode lifecycle lines, through electron-log.
+ const opencodeHome = typeof pathInfo?.home === 'string' ? pathInfo.home : '';
+ const isWindows = /Windows NT/.test(platform);
+ const isDesktop = origin.startsWith('openchamber-ui://');
+ lines.push('');
+ lines.push('Log files:');
+ lines.push(`- OpenCode: ${opencodeHome ? joinPath(opencodeHome, '.local/share/opencode/log', isWindows) : '/.local/share/opencode/log'} (or $XDG_DATA_HOME/opencode/log when set)`);
+ if (isDesktop) {
+ const isMacDesktop = /Mac OS X|Macintosh/.test(platform);
+ lines.push(`- OpenChamber desktop: ${isWindows
+ ? '%APPDATA%\\OpenChamber\\logs\\main.log'
+ : isMacDesktop
+ ? '~/Library/Logs/OpenChamber/main.log'
+ : '~/.config/OpenChamber/logs/main.log'}`);
+ }
+
if (typeof window !== 'undefined') {
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts
index bf75addf..fbde16bc 100644
--- a/packages/ui/src/lib/persistence.ts
+++ b/packages/ui/src/lib/persistence.ts
@@ -115,11 +115,6 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
}
persistRuntimeSettingsMirror(settings, getRuntimeKey());
- setOrRemoveLocalStorage('selectedThemeId', settings.themeId || null);
- setOrRemoveLocalStorage('selectedThemeVariant', settings.themeVariant || null);
- setOrRemoveLocalStorage('lightThemeId', settings.lightThemeId || null);
- setOrRemoveLocalStorage('darkThemeId', settings.darkThemeId || null);
- setOrRemoveLocalStorage('useSystemTheme', typeof settings.useSystemTheme === 'boolean' ? String(settings.useSystemTheme) : null);
setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null);
if (settings.homeDirectory) {
localStorage.setItem('homeDirectory', settings.homeDirectory);
diff --git a/packages/ui/src/lib/router/openSessionFromRoute.test.ts b/packages/ui/src/lib/router/openSessionFromRoute.test.ts
new file mode 100644
index 00000000..d6db8198
--- /dev/null
+++ b/packages/ui/src/lib/router/openSessionFromRoute.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, test } from 'bun:test';
+import type { Session } from '@opencode-ai/sdk/v2';
+
+import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
+import { useSessionUIStore } from '@/sync/session-ui-store';
+
+import { openSessionFromRoute } from './openSessionFromRoute';
+
+const SESSION_ID = 'ses_linear_open';
+const PROJECT_DIR = '/projects/linear-from-url';
+const OTHER_DIR = '/projects/linear-from-url-other';
+
+const buildSession = (id: string, directory: string): Session => ({
+ id,
+ title: id,
+ directory,
+ time: { created: 1, updated: 2 },
+} as Session);
+
+describe('openSessionFromRoute', () => {
+ beforeEach(() => {
+ useSessionUIStore.getState().setCurrentSession(null);
+ useGlobalSessionsStore.setState({
+ activeSessions: [],
+ archivedSessions: [],
+ sessionsByDirectory: new Map(),
+ hasLoaded: true,
+ status: 'ready',
+ });
+ });
+
+ test('selects the routed session once the global list knows its directory', async () => {
+ useGlobalSessionsStore.setState({
+ activeSessions: [buildSession(SESSION_ID, PROJECT_DIR)],
+ archivedSessions: [],
+ hasLoaded: true,
+ status: 'ready',
+ });
+
+ await openSessionFromRoute(SESSION_ID);
+
+ expect(useSessionUIStore.getState().currentSessionId).toBe(SESSION_ID);
+ expect(useSessionUIStore.getState().currentSessionDirectory).toBe(PROJECT_DIR);
+ });
+
+ test('replaces a guessed directory once the global list knows the owner', async () => {
+ const id = 'ses_linear_guessed';
+ useSessionUIStore.getState().setCurrentSession(id);
+ const guessed = useSessionUIStore.getState().currentSessionDirectory;
+
+ useGlobalSessionsStore.setState({
+ activeSessions: [buildSession(id, OTHER_DIR)],
+ archivedSessions: [],
+ hasLoaded: true,
+ status: 'ready',
+ });
+
+ await openSessionFromRoute(id);
+
+ expect(useSessionUIStore.getState().currentSessionId).toBe(id);
+ expect(useSessionUIStore.getState().currentSessionDirectory).toBe(OTHER_DIR);
+ expect(guessed).not.toBe(OTHER_DIR);
+ });
+});
diff --git a/packages/ui/src/lib/router/openSessionFromRoute.ts b/packages/ui/src/lib/router/openSessionFromRoute.ts
new file mode 100644
index 00000000..c59aa9b4
--- /dev/null
+++ b/packages/ui/src/lib/router/openSessionFromRoute.ts
@@ -0,0 +1,33 @@
+import { ensureGlobalSessionsLoaded, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
+import { useSessionUIStore } from '@/sync/session-ui-store';
+
+/**
+ * Select a session named by `/?session=`. Cold loads often do not know the
+ * owning directory yet, so a first selection may guess the active project.
+ * After the global session list is available, re-select with that directory
+ * unless the user already moved to a different session.
+ */
+export async function openSessionFromRoute(sessionId: string): Promise {
+ const id = sessionId.trim();
+ if (!id) return;
+
+ const initial = useSessionUIStore.getState();
+ if (initial.currentSessionId !== id) {
+ initial.setCurrentSession(id, initial.getDirectoryForSession(id));
+ }
+
+ const snapshot = await ensureGlobalSessionsLoaded().catch(() => null);
+ if (!snapshot) return;
+
+ const latest = useSessionUIStore.getState();
+ if (latest.currentSessionId !== id) return;
+
+ const session = [...snapshot.activeSessions, ...snapshot.archivedSessions]
+ .find((entry) => entry.id === id);
+ if (!session) return;
+
+ const directory = resolveGlobalSessionDirectory(session);
+ if (!directory || directory === latest.currentSessionDirectory) return;
+
+ latest.setCurrentSession(id, directory);
+}
diff --git a/packages/ui/src/lib/router/parseRoute.test.ts b/packages/ui/src/lib/router/parseRoute.test.ts
new file mode 100644
index 00000000..610fb68e
--- /dev/null
+++ b/packages/ui/src/lib/router/parseRoute.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from 'bun:test';
+
+import { parseRoute } from './parseRoute';
+
+describe('parseRoute session', () => {
+ test('reads a session id including OpenCode underscores', () => {
+ const route = parseRoute(new URLSearchParams('session=ses_abc123'));
+ expect(route.sessionId).toBe('ses_abc123');
+ });
+
+ test('decodes a percent-encoded session id', () => {
+ const route = parseRoute(new URLSearchParams('session=ses%5Fabc123'));
+ expect(route.sessionId).toBe('ses_abc123');
+ });
+
+ test('ignores a blank session param', () => {
+ const route = parseRoute(new URLSearchParams('session='));
+ expect(route.sessionId).toBeNull();
+ });
+});
diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts
index 8b11ef90..a6a6eecc 100644
--- a/packages/ui/src/lib/runtime-switch.ts
+++ b/packages/ui/src/lib/runtime-switch.ts
@@ -51,6 +51,17 @@ const normalizeRuntimeUrlKey = (value: string): string => {
}
};
+// Runtime keys that mean "no instance connected": the uninitialized default
+// (`normalizeRuntimeUrlKey` of an empty/unparseable base URL) and the mobile
+// disconnect state (`MobileApp` switches to it when the connection drops).
+// Per-instance client state (e.g. the scoped theme entry) must not be read
+// from or written under them.
+export const MOBILE_DISCONNECTED_RUNTIME_KEY = 'mobile-disconnected';
+const UNINITIALIZED_RUNTIME_KEY = 'url:default';
+
+export const isTransientRuntimeKey = (runtimeKey: string): boolean =>
+ runtimeKey === '' || runtimeKey === UNINITIALIZED_RUNTIME_KEY || runtimeKey === MOBILE_DISCONNECTED_RUNTIME_KEY;
+
const readInjectedApiBaseUrl = (): string => {
if (typeof window === 'undefined') return '';
const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__;
diff --git a/packages/ui/src/lib/sessionKnowledgeApi.ts b/packages/ui/src/lib/sessionKnowledgeApi.ts
index 91803009..cb3f9042 100644
Binary files a/packages/ui/src/lib/sessionKnowledgeApi.ts and b/packages/ui/src/lib/sessionKnowledgeApi.ts differ
diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts
index eea1ba80..8603434d 100644
--- a/packages/ui/src/lib/settings/metadata.ts
+++ b/packages/ui/src/lib/settings/metadata.ts
@@ -150,7 +150,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
title: 'Git',
group: 'projects',
kind: 'single',
- keywords: ['git', 'github', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji', 'oauth', 'prs', 'issues'],
+ keywords: ['git', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
@@ -202,7 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{ slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode },
- { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger'] },
+ { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'github', 'linear'] },
] as const;
const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = {
diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts
index 8212e2f6..6a1d4bc7 100644
--- a/packages/ui/src/lib/settings/search.ts
+++ b/packages/ui/src/lib/settings/search.ts
@@ -150,20 +150,19 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
descriptionKey: 'settings.openchamber.visual.field.autoSaveEnabledInfo',
keywords: ['editor', 'autosave', 'auto-save', 'files', 'save'],
},
+ {
+ id: 'appearance.expanded-editor-toolbar',
+ page: 'general',
+ titleKey: 'settings.openchamber.visual.field.expandedEditorToolbar',
+ keywords: ['editor', 'toolbar', 'tabs', 'docked', 'files'],
+ isAvailable: (ctx) => !ctx.isVSCode,
+ },
{
id: 'appearance.file-editor-keymap',
page: 'general',
titleKey: 'settings.openchamber.visual.field.fileEditorKeymap',
keywords: ['editor', 'vim', 'keymap'],
},
- {
- id: 'appearance.session-tabs',
- page: 'general',
- titleKey: 'settings.openchamber.visual.field.sessionTabsGroup',
- descriptionKey: 'settings.openchamber.visual.field.sessionTabsInfo',
- keywords: ['session', 'tabs', 'header', 'working set'],
- isAvailable: (ctx) => !ctx.isMobile && !ctx.isVSCode,
- },
{
id: 'appearance.terminal-quick-keys',
page: 'general',
@@ -242,19 +241,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.openchamber.visual.section.reasoning',
keywords: ['thinking', 'traces'],
},
- {
- id: 'chat.streaming',
- page: 'chat',
- titleKey: 'settings.openchamber.visual.section.streaming',
- keywords: ['stream', 'scroll'],
- },
- {
- id: 'chat.streaming-auto-follow',
- page: 'chat',
- titleKey: 'settings.openchamber.visual.field.streamingAutoFollow',
- descriptionKey: 'settings.openchamber.visual.field.streamingAutoFollowInfo',
- keywords: ['autoscroll', 'auto-scroll', 'follow', 'stick to bottom', 'streaming'],
- },
{
id: 'chat.sticky-user-header',
page: 'chat',
@@ -353,7 +339,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
id: 'chat.composer',
page: 'chat',
titleKey: 'settings.openchamber.visual.section.composer',
- keywords: ['input', 'draft', 'spellcheck', 'paste'],
+ keywords: ['input', 'draft', 'spellcheck'],
},
{
id: 'chat.spellcheck',
@@ -362,13 +348,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['spelling', 'input'],
isAvailable: (ctx) => !ctx.isMobile,
},
- {
- id: 'chat.large-text-paste',
- page: 'chat',
- titleKey: 'settings.openchamber.visual.field.largeTextPaste',
- descriptionKey: 'settings.openchamber.visual.field.largeTextPasteHint',
- keywords: ['paste', 'clipboard', 'attachment', 'large', 'text', 'file'],
- },
{
id: 'sessions.default-model',
page: 'sessions',
diff --git a/packages/ui/src/lib/surfaces/DOCUMENTATION.md b/packages/ui/src/lib/surfaces/DOCUMENTATION.md
index 32feda29..d363530d 100644
--- a/packages/ui/src/lib/surfaces/DOCUMENTATION.md
+++ b/packages/ui/src/lib/surfaces/DOCUMENTATION.md
@@ -27,11 +27,11 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
configure button — `ContextRailSurfacesDialog`), drops the plan surface
unless plan mode is enabled,
drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, hides
- `has-content` surfaces until a tab of their mode exists, and drops the `pr`
- surface when the repository is on a git provider other than GitHub or GitLab
- (`gitProvider: 'other'`). Both consumers use
- it so the digit shown on a rail badge always maps to the same surface the
- shortcut opens.
+ Linear unless a workspace is connected, hides `has-content` surfaces
+ until a tab of their mode exists, and drops the `pr` surface when the
+ repository is on a git provider other than GitHub, GitLab, or Gitea
+ (`gitProvider: 'other'`). Both consumers use it so the digit shown on a rail
+ badge always maps to the same surface the shortcut opens.
## Adding a surface
@@ -55,7 +55,22 @@ the `openContext*` actions in `useUIStore`.
positions). Chat tab records stay open, but only the active chat iframe is
mounted while the panel is open. A selected chat restores its state from
the session stores. A closed panel mounts no chat iframe.
- Singleton surfaces (git, pr, notes, plan, context) remount on switch. These
+ Singleton surfaces (git, pr, linear, notes, plan, context) remount on switch. These
surfaces must restore their state from stores or snapshots.
- Runtime scope: desktop/web `MainLayout` only. VS Code and the dedicated
mobile shell have their own layouts and do not consume this registry.
+ Linear is a desktop/web singleton on this rail. VS Code and mobile omit it
+ (no this registry, and VS Code has no `RuntimeAPIs.linear`). The Linear
+ rail icon is hidden until a Linear workspace is connected. A persisted Linear
+ tab stays open across reload until auth has resolved; only a confirmed
+ disconnect closes the panel. The surface lists
+ issues with status (All, Backlog, To Do, In Progress, In Review, Done, Canceled, Duplicate), assignee, team, and priority filters, can switch
+ the current workspace, and keeps Start session in a footer on the issue card.
+ Those filters restore from `useUIStore` when the surface remounts. Non-default
+ status, assignee, team, priority, and search tint the filter icon `text-primary`,
+ same as the context rail; one control clears them. Workspace switch is not a
+ filter. Work-status Context sources
+ can open a specific issue here through `linearIssueFocus`. Below 520px
+ search and the filters other than status drop to icons; status keeps its label. The card
+ shows priority and labels. Changing filters keeps the previous list
+ until the next page arrives.
diff --git a/packages/ui/src/lib/surfaces/registry.test.ts b/packages/ui/src/lib/surfaces/registry.test.ts
index 36d57595..4f0e7445 100644
--- a/packages/ui/src/lib/surfaces/registry.test.ts
+++ b/packages/ui/src/lib/surfaces/registry.test.ts
@@ -12,6 +12,7 @@ const baseOptions = {
isVSCode: false,
screenWidth: 1200,
tabs: [],
+ linearConnected: true,
} as const;
describe('getVisibleContextRailSurfaces', () => {
@@ -75,4 +76,16 @@ describe('getVisibleContextRailSurfaces', () => {
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, railOrder: ['git', 'context'] });
expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']);
});
+
+ test('places Linear right after the walkthrough in the default order', () => {
+ const ids = getVisibleContextRailSurfaces(baseOptions).map((surface) => surface.id);
+ const walkthrough = ids.indexOf('walkthrough');
+ expect(walkthrough).toBeGreaterThanOrEqual(0);
+ expect(ids.indexOf('linear')).toBe(walkthrough + 1);
+ });
+
+ test('hides Linear until a workspace is connected', () => {
+ expect(getVisibleContextRailSurfaces({ ...baseOptions, linearConnected: false }).some((s) => s.id === 'linear')).toBe(false);
+ expect(getVisibleContextRailSurfaces({ ...baseOptions, linearConnected: true }).some((s) => s.id === 'linear')).toBe(true);
+ });
});
diff --git a/packages/ui/src/lib/surfaces/registry.ts b/packages/ui/src/lib/surfaces/registry.ts
index 437cd535..cd6b954b 100644
--- a/packages/ui/src/lib/surfaces/registry.ts
+++ b/packages/ui/src/lib/surfaces/registry.ts
@@ -6,6 +6,7 @@ export type ContextSurfaceId =
| 'editor'
| 'git'
| 'pr'
+ | 'linear'
| 'diff'
| 'walkthrough'
| 'terminal'
@@ -83,6 +84,15 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
labelKey: 'contextPanel.mode.walkthrough',
availability: 'always',
},
+ {
+ id: 'linear',
+ descriptionKey: 'contextRail.surface.linear.description',
+ defaultWidthFraction: 0.45,
+ mode: 'linear',
+ icon: 'linear',
+ labelKey: 'contextPanel.mode.linear',
+ availability: 'always',
+ },
{
id: 'editor',
descriptionKey: 'contextRail.surface.editor.description',
@@ -194,10 +204,13 @@ type VisibleRailSurfacesOptions = {
isVSCode: boolean;
screenWidth: number;
tabs: readonly { mode: ContextPanelMode }[];
+ /** Linear's rail icon stays off until a workspace is connected. */
+ linearConnected: boolean;
/**
* The repository's git provider. The 'pr' surface renders the GitHub pull
- * request / GitLab merge request view; it is hidden for repositories on
- * any other provider. null (unknown, still resolving) keeps it visible.
+ * request / GitLab merge request / Gitea pull request view; it is hidden for
+ * repositories on any other provider. null (unknown, still resolving) keeps
+ * it visible.
*/
gitProvider?: 'github' | 'gitlab' | 'gitea' | 'other' | null;
};
@@ -238,6 +251,9 @@ export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOption
if (surface.id === 'browser' && options.isVSCode) {
return false;
}
+ if (surface.id === 'linear' && !options.linearConnected) {
+ return false;
+ }
if (surface.availability === 'has-content') {
return options.tabs.some((tab) => tab.mode === surface.mode);
}
diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx
index 1e35a5f9..13323f7a 100644
--- a/packages/ui/src/main.tsx
+++ b/packages/ui/src/main.tsx
@@ -10,6 +10,7 @@ import './lib/debug'
import { syncDesktopSettings, initializeAppearancePreferences } from './lib/persistence'
import { startAppearanceAutoSave } from './lib/appearanceAutoSave'
import { applyPersistedDirectoryPreferences } from './lib/directoryPersistence'
+import { preloadMarkdownRenderer } from './components/chat/markdownRendererLoader'
import { startTypographyWatcher } from './lib/typographyWatcher'
import { startModelPrefsAutoSave } from './lib/modelPrefsAutoSave'
import { initializeLocale, I18nProvider } from './lib/i18n'
@@ -53,6 +54,11 @@ if (!rootElement) {
throw new Error('Root element not found');
}
+// The first session opened after load renders its messages through the lazy
+// markdown chunk; fetching it now, while the app boots, means that open shows
+// text instead of empty message boxes until the chunk arrives.
+preloadMarkdownRenderer();
+
createRoot(rootElement).render(
diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md
index 1817654a..ac82998a 100644
--- a/packages/ui/src/stores/DOCUMENTATION.md
+++ b/packages/ui/src/stores/DOCUMENTATION.md
@@ -38,7 +38,7 @@ Examples:
- `useFeatureFlagsStore.ts`
- `useUpdateStore.ts`
-These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection.
+These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted.
Context-panel session chats mount only the active chat iframe. After installing
its message listener, the iframe requests its authoritative visibility from the
@@ -148,6 +148,8 @@ Important properties:
- loading state is per-directory, not global
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
- in-flight dedupe exists for status and `ensureAll()`; status dedupe is scoped to the per-directory status mutation revision, so a refresh requested after a mutation never joins a pre-mutation in-flight request
+- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). A selection whose repository fails its probe is dropped and remembered session-only (`staleClearedSelections`) so auto-select does not re-pick it and loop walk+probe; manual picker picks bypass the memory. `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface (Git tab, diff view, pull-request view, walkthrough view, mobile changes, work-status project readout), and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states
+- worktree bootstrap polling and session/worktree machinery stay keyed on the project root even while a nested repository is selected; only git data and actions follow the selection
- runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions
- status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations
- status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes
diff --git a/packages/ui/src/stores/useAgentMemoryStore.ts b/packages/ui/src/stores/useAgentMemoryStore.ts
index 55bcbf73..f435a4f2 100644
--- a/packages/ui/src/stores/useAgentMemoryStore.ts
+++ b/packages/ui/src/stores/useAgentMemoryStore.ts
@@ -27,13 +27,19 @@ interface AgentMemoryState {
projectPath: string | null;
loading: boolean;
loaded: boolean;
+ /** When the held entries were last read successfully. */
+ loadedAt: number | null;
/** True once the server has reported the feature switched off. */
disabled: boolean;
globalFailed: boolean;
projectFailed: boolean;
error: string | null;
- load: (projectPath: string | null) => Promise;
+ /**
+ * `maxAgeMs` skips the read when the same project's entries were loaded
+ * more recently than that; omit it for an unconditional re-read.
+ */
+ load: (projectPath: string | null, options?: { maxAgeMs?: number }) => Promise;
/** Re-read the store the last load used. */
refresh: () => Promise;
saveEntry: (
@@ -55,6 +61,7 @@ const EMPTY_STATE = {
globalFailed: false,
projectFailed: false,
error: null as string | null,
+ loadedAt: null as number | null,
};
const EMPTY_MEMORY: AgentMemoryEntry[] = [];
@@ -99,10 +106,19 @@ const errorMessage = (error: unknown, fallback: string): string => (
export const useAgentMemoryStore = create((set, get) => ({
...EMPTY_STATE,
- load: async (projectPath) => {
- const requestId = ++loadSequence;
+ load: async (projectPath, options) => {
const previous = get();
const ownerChanged = previous.projectPath !== projectPath;
+ if (
+ options?.maxAgeMs !== undefined
+ && !ownerChanged
+ && previous.loaded
+ && previous.loadedAt !== null
+ && Date.now() - previous.loadedAt < options.maxAgeMs
+ ) {
+ return;
+ }
+ const requestId = ++loadSequence;
if (ownerChanged) {
set({ loading: true, projectPath, project: [], projectFailed: false });
} else {
@@ -120,6 +136,7 @@ export const useAgentMemoryStore = create((set, get) => ({
projectFailed: snapshot.projectFailed,
loading: false,
loaded: true,
+ loadedAt: Date.now(),
disabled: false,
error: null,
});
diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts
index 7fbe8328..76562284 100644
--- a/packages/ui/src/stores/useConfigStore.ts
+++ b/packages/ui/src/stores/useConfigStore.ts
@@ -70,7 +70,26 @@ interface OpenChamberDefaults {
gitProviders?: unknown;
}
-const fetchOpenChamberDefaults = async (): Promise => {
+// Directory activation re-reads the OpenChamber defaults, which are global,
+// not per directory: one request serves the switches that land inside this
+// window, and concurrent activations share the in-flight one.
+const OPENCHAMBER_DEFAULTS_FRESH_MS = 15_000;
+let openChamberDefaultsCache: { at: number; request: Promise } | null = null;
+
+const fetchOpenChamberDefaults = (): Promise => {
+ const now = Date.now();
+ if (openChamberDefaultsCache && now - openChamberDefaultsCache.at < OPENCHAMBER_DEFAULTS_FRESH_MS) {
+ return openChamberDefaultsCache.request;
+ }
+ const request = requestOpenChamberDefaults();
+ openChamberDefaultsCache = { at: now, request };
+ request.catch(() => {
+ if (openChamberDefaultsCache?.request === request) openChamberDefaultsCache = null;
+ });
+ return request;
+};
+
+const requestOpenChamberDefaults = async (): Promise => {
markStartupTrace('config.defaults:start');
const started = typeof performance !== 'undefined' ? performance.now() : Date.now();
const finish = (source: string, result: OpenChamberDefaults) => {
@@ -1071,6 +1090,10 @@ interface ConfigStore {
sayVoice: string;
browserVoice: string;
localTtsVoiceId: number;
+ /** Local TTS model the chosen voice belongs to (catalog id). */
+ localTtsModelId: string;
+ /** Local and macOS voices follow the language of the text being read. */
+ ttsFollowTextLanguage: boolean;
openaiVoice: string;
openaiApiKey: string;
openaiCompatibleUrl: string;
@@ -1098,6 +1121,8 @@ interface ConfigStore {
setSayVoice: (voice: string) => void;
setBrowserVoice: (voice: string) => void;
setLocalTtsVoiceId: (voiceId: number) => void;
+ setLocalTtsModelId: (modelId: string) => void;
+ setTtsFollowTextLanguage: (enabled: boolean) => void;
setOpenaiVoice: (voice: string) => void;
setOpenaiApiKey: (apiKey: string) => void;
setOpenaiCompatibleUrl: (url: string) => void;
@@ -1281,6 +1306,21 @@ export const useConfigStore = create()(
}
return 0;
})(),
+ localTtsModelId: (() => {
+ if (typeof window !== 'undefined') {
+ const saved = localStorage.getItem('localTtsModelId');
+ if (saved) return saved;
+ }
+ return 'kokoro-en-v0_19';
+ })(),
+
+ ttsFollowTextLanguage: (() => {
+ if (typeof window !== 'undefined') {
+ const saved = localStorage.getItem('ttsFollowTextLanguage');
+ if (saved !== null) return saved === 'true';
+ }
+ return true;
+ })(),
// Browser voice - load from localStorage or default to empty (auto-select)
browserVoice: (() => {
if (typeof window !== 'undefined') {
@@ -2978,6 +3018,20 @@ export const useConfigStore = create()(
}
},
+ setLocalTtsModelId: (modelId: string) => {
+ set({ localTtsModelId: modelId });
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('localTtsModelId', modelId);
+ }
+ },
+
+ setTtsFollowTextLanguage: (enabled: boolean) => {
+ set({ ttsFollowTextLanguage: enabled });
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('ttsFollowTextLanguage', String(enabled));
+ }
+ },
+
setBrowserVoice: (voice: string) => {
set({ browserVoice: voice });
if (typeof window !== 'undefined') {
diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts
index 15e22afd..e96445b9 100644
--- a/packages/ui/src/stores/useGitStore.test.ts
+++ b/packages/ui/src/stores/useGitStore.test.ts
@@ -1,9 +1,23 @@
-import { beforeEach, describe, expect, test } from 'bun:test';
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { GitStatus } from '@/lib/api/types';
import { useGitStore } from './useGitStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
+// The real transport has no server in tests and fails as a generic error.
+// Tests that exercise other failure modes swap this implementation; the
+// default keeps every pre-existing expectation (generic failure → null).
+const listGitDirectoriesControl: { impl: (root: string) => Promise } = {
+ impl: async () => {
+ throw new Error('network unavailable');
+ },
+};
+class TestGitDirectoriesUnsupportedError extends Error {}
+mock.module('@/lib/gitApiHttp', () => ({
+ GitDirectoriesUnsupportedError: TestGitDirectoriesUnsupportedError,
+ listGitDirectories: (root: string) => listGitDirectoriesControl.impl(root),
+}));
+
type Deferred = {
promise: Promise;
resolve: (value: T) => void;
@@ -415,3 +429,123 @@ describe('useGitStore', () => {
expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus);
});
});
+
+describe('useGitStore nested repository discovery', () => {
+ beforeEach(() => {
+ listGitDirectoriesControl.impl = async () => {
+ throw new Error('network unavailable');
+ };
+ useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
+ });
+
+ test('selects a nested repo per root and persists the selection', () => {
+ useGitStore.getState().selectNestedRepo('/root-a', '/root-a/repo-one');
+
+ expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one');
+
+ // Re-seeding from storage (as a page refresh would) restores the pick.
+ useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
+ expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one');
+ });
+
+ test('keeps selections isolated per root', () => {
+ useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
+ useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two');
+
+ expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/one');
+ expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two');
+ });
+
+ test('clears only the given root selection', () => {
+ useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
+ useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two');
+
+ useGitStore.getState().clearNestedRepoSelection('/root-a');
+
+ expect(useGitStore.getState().nestedRepoSelection.has('/root-a')).toBe(false);
+ expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two');
+ });
+
+ test('remembers a stale-cleared repository so auto-select can skip it', () => {
+ useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
+ useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two');
+
+ useGitStore.getState().clearNestedRepoSelection('/root-a');
+ useGitStore.getState().clearNestedRepoSelection('/root-b');
+ useGitStore.getState().clearNestedRepoSelection('/root-b');
+
+ const clearedA = useGitStore.getState().staleClearedSelections.get('/root-a');
+ const clearedB = useGitStore.getState().staleClearedSelections.get('/root-b');
+ expect(clearedA).toEqual(new Set(['/root-a/one']));
+ // Repeated clears of the same path stay a set, not an ever-growing list.
+ expect(clearedB).toEqual(new Set(['/root-b/two']));
+ });
+
+ test('runtime switch clears stale-cleared memory with the rest', () => {
+ useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
+ useGitStore.getState().clearNestedRepoSelection('/root-a');
+
+ useGitStore.getState().resetForRuntimeSwitch('runtime-b');
+
+ expect(useGitStore.getState().staleClearedSelections.size).toBe(0);
+ });
+
+ test('runtime switch does not leak selections or discovery across runtimes', () => {
+ useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
+ useGitStore.setState({ nestedReposByRoot: new Map([['/root-a', ['/root-a/one']]]) });
+
+ useGitStore.getState().resetForRuntimeSwitch('runtime-b');
+
+ expect(useGitStore.getState().nestedRepoSelection.size).toBe(0);
+ expect(useGitStore.getState().nestedReposByRoot.size).toBe(0);
+ });
+
+ test('discards an in-flight discovery result when the runtime switches', async () => {
+ const stale = useGitStore.getState().ensureNestedRepos('/root-a');
+ useGitStore.getState().resetForRuntimeSwitch('runtime-b');
+ await stale;
+
+ // The old runtime's late completion must not repopulate the cleared map.
+ expect(useGitStore.getState().nestedReposByRoot.has('/root-a')).toBe(false);
+
+ // Discovery started under the new runtime still commits normally.
+ await useGitStore.getState().ensureNestedRepos('/root-a');
+ expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull();
+ });
+
+ test('marks discovery failure as a failed marker, not an empty success', async () => {
+ await useGitStore.getState().ensureNestedRepos('/root-a');
+
+ expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull();
+ });
+
+ test('marks a 501 runtime as unsupported instead of failed', async () => {
+ listGitDirectoriesControl.impl = async () => {
+ throw new TestGitDirectoriesUnsupportedError();
+ };
+
+ await useGitStore.getState().ensureNestedRepos('/root-a');
+
+ expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBe('unsupported');
+ });
+
+ test('unsupported does not clobber a previous successful discovery', async () => {
+ listGitDirectoriesControl.impl = async () => ['/root-a/one'];
+ await useGitStore.getState().ensureNestedRepos('/root-a');
+
+ listGitDirectoriesControl.impl = async () => {
+ throw new TestGitDirectoriesUnsupportedError();
+ };
+ await useGitStore.getState().ensureNestedRepos('/root-a', { force: true });
+
+ expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toEqual(['/root-a/one']);
+ });
+
+ test('dedupes concurrent discovery runs for the same root', async () => {
+ const first = useGitStore.getState().ensureNestedRepos('/root-a');
+ const second = useGitStore.getState().ensureNestedRepos('/root-a');
+ await Promise.all([first, second]);
+
+ expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull();
+ });
+});
diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts
index 04e9c7c9..341a77a6 100644
--- a/packages/ui/src/stores/useGitStore.ts
+++ b/packages/ui/src/stores/useGitStore.ts
@@ -9,6 +9,7 @@ import type {
} from '@/lib/api/types';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { getRuntimeKey } from '@/lib/runtime-switch';
+import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp';
import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation';
const LOG_STALE_THRESHOLD = 10000;
@@ -28,6 +29,11 @@ const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200;
type GitStatusFetchMode = 'full' | 'light';
+// Discovery outcome for a root that is not itself a git repository. The three
+// states are mutually exclusive: a repository list (possibly empty), a failed
+// scan (`null`), or a runtime without the discovery route (`'unsupported'`).
+export type NestedRepoDiscovery = string[] | null | 'unsupported';
+
interface DirectoryGitState {
isGitRepo: boolean | null;
status: GitStatus | null;
@@ -78,6 +84,24 @@ interface GitStore {
setLogMaxCount: (directory: string, maxCount: number) => void;
+ // Nested repository discovery: when the root directory is not itself a git
+ // repository, these hold the discovered repositories and the user's pick.
+ // `nestedReposByRoot` values are `null` when discovery failed — never a
+ // valid empty result — `'unsupported'` when the runtime has no discovery
+ // route, and absent when discovery has not run yet.
+ nestedReposByRoot: Map;
+ nestedRepoSelection: Map;
+ /**
+ * Repositories whose selection was dropped because their probe reported
+ * them as no longer a repository (corrupt or missing gitdir). Session-only
+ * memory so auto-select does not immediately re-pick the same broken path
+ * and loop walk+probe. Not persisted: the next launch re-probes honestly.
+ */
+ staleClearedSelections: Map>;
+ ensureNestedRepos: (root: string, options?: { force?: boolean }) => Promise;
+ selectNestedRepo: (root: string, repository: string) => void;
+ clearNestedRepoSelection: (root: string) => void;
+
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise;
resetForRuntimeSwitch: (runtimeKey: string) => void;
}
@@ -102,6 +126,7 @@ const inFlightDiffFetchesByDirectory = new Map>();
const diffFetchGenerationByDirectory = new Map();
const inFlightStatusFetches = new Map; statusMutationRevision: number }>();
const inFlightEnsureAllByDirectory = new Map>();
+const inFlightNestedRepoDiscovery = new Map>();
const requestGenerationByChannel = new Map